261 lines
7.7 KiB
JavaScript
261 lines
7.7 KiB
JavaScript
import init, { evaluateProject } from 'decodal-wasm';
|
|
import { EditorView, basicSetup } from 'codemirror';
|
|
import { keymap } from '@codemirror/view';
|
|
import { decodal } from 'decodal-codemirror';
|
|
import { playgroundExamples } from './playground-examples.js';
|
|
|
|
const STORAGE_KEY = 'decodal-playground-project-v1';
|
|
const starterProject = playgroundExamples[0];
|
|
|
|
const editorHost = document.getElementById('editor');
|
|
const output = document.getElementById('output');
|
|
const run = document.getElementById('run');
|
|
const status = document.getElementById('status');
|
|
const fileTree = document.getElementById('file-tree');
|
|
const activeFile = document.getElementById('active-file');
|
|
const newFile = document.getElementById('new-file');
|
|
const deleteFile = document.getElementById('delete-file');
|
|
const exampleSelect = document.getElementById('example-select');
|
|
const loadExample = document.getElementById('load-example');
|
|
|
|
const project = loadProject();
|
|
|
|
const editorTheme = EditorView.theme({
|
|
'&': {
|
|
backgroundColor: '#0f172a',
|
|
color: '#e5e7eb',
|
|
height: '100%',
|
|
},
|
|
'.cm-scroller': {
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
|
|
fontSize: '14px',
|
|
lineHeight: '1.5',
|
|
overflow: 'auto',
|
|
},
|
|
'.cm-content': {
|
|
caretColor: '#e5e7eb',
|
|
minHeight: '100%',
|
|
padding: '12px',
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: '#111827',
|
|
borderRight: '1px solid #334155',
|
|
color: '#94a3b8',
|
|
},
|
|
'&.cm-focused': {
|
|
outline: 'none',
|
|
},
|
|
'&.cm-focused .cm-cursor': {
|
|
borderLeftColor: '#e5e7eb',
|
|
},
|
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
|
backgroundColor: 'rgb(59 130 246 / 0.35)',
|
|
},
|
|
}, { dark: true });
|
|
|
|
const editor = new EditorView({
|
|
doc: project.files[project.activePath] ?? '',
|
|
parent: editorHost,
|
|
extensions: [
|
|
basicSetup,
|
|
decodal(),
|
|
editorTheme,
|
|
keymap.of([
|
|
{
|
|
key: 'Mod-Enter',
|
|
run() {
|
|
execute();
|
|
return true;
|
|
},
|
|
},
|
|
]),
|
|
EditorView.updateListener.of((update) => {
|
|
if (!update.docChanged) return;
|
|
project.files[project.activePath] = update.state.doc.toString();
|
|
saveProject();
|
|
}),
|
|
],
|
|
});
|
|
|
|
for (const example of playgroundExamples) {
|
|
const option = document.createElement('option');
|
|
option.value = example.id;
|
|
option.textContent = example.title;
|
|
exampleSelect.append(option);
|
|
}
|
|
exampleSelect.value = starterProject.id;
|
|
|
|
setActiveFile(project.activePath);
|
|
renderFileTree();
|
|
|
|
function loadProject() {
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
|
|
if (stored && stored.files && typeof stored.activePath === 'string') {
|
|
const files = normalizeFiles(stored.files);
|
|
const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath;
|
|
if (activePath) return { files, activePath };
|
|
}
|
|
} catch (_error) {
|
|
// Fall back to the starter project.
|
|
}
|
|
return { files: cloneFiles(starterProject.files), activePath: starterProject.activePath };
|
|
}
|
|
|
|
function cloneFiles(files) {
|
|
return Object.fromEntries(Object.entries(files).map(([path, source]) => [path, source]));
|
|
}
|
|
|
|
function normalizeFiles(files) {
|
|
return Object.fromEntries(
|
|
Object.entries(files)
|
|
.filter(([_path, value]) => typeof value === 'string')
|
|
.map(([path, value]) => [normalizePath(path), value])
|
|
.filter(([path]) => path),
|
|
);
|
|
}
|
|
|
|
function saveProject() {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
|
}
|
|
|
|
function loadExampleProject(exampleId) {
|
|
const example = playgroundExamples.find((item) => item.id === exampleId) ?? starterProject;
|
|
project.files = cloneFiles(example.files);
|
|
project.activePath = example.activePath;
|
|
exampleSelect.value = example.id;
|
|
setActiveFile(project.activePath);
|
|
output.textContent = '';
|
|
output.classList.remove('error');
|
|
execute();
|
|
}
|
|
|
|
function normalizePath(path) {
|
|
const parts = [];
|
|
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
|
if (!part || part === '.') continue;
|
|
if (part === '..') parts.pop();
|
|
else parts.push(part);
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
function getEditorText() {
|
|
return editor.state.doc.toString();
|
|
}
|
|
|
|
function setEditorText(value) {
|
|
editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: editor.state.doc.length,
|
|
insert: value,
|
|
},
|
|
});
|
|
}
|
|
|
|
function setActiveFile(path) {
|
|
const normalized = normalizePath(path);
|
|
if (project.files[normalized] === undefined) return;
|
|
if (project.activePath && project.files[project.activePath] !== undefined) {
|
|
project.files[project.activePath] = getEditorText();
|
|
}
|
|
project.activePath = normalized;
|
|
setEditorText(project.files[normalized]);
|
|
activeFile.textContent = normalized;
|
|
deleteFile.disabled = Object.keys(project.files).length <= 1;
|
|
renderFileTree();
|
|
saveProject();
|
|
}
|
|
|
|
function execute() {
|
|
project.files[project.activePath] = getEditorText();
|
|
saveProject();
|
|
const result = JSON.parse(evaluateProject(project.activePath, JSON.stringify(project.files)));
|
|
output.textContent = result.ok ? result.output : result.error;
|
|
output.classList.toggle('error', !result.ok);
|
|
}
|
|
|
|
function renderFileTree() {
|
|
const tree = buildTree(Object.keys(project.files).sort());
|
|
fileTree.replaceChildren(renderTreeList(tree.children));
|
|
}
|
|
|
|
function buildTree(paths) {
|
|
const root = { name: '', children: new Map(), path: '' };
|
|
for (const path of paths) {
|
|
const parts = path.split('/');
|
|
let node = root;
|
|
let currentPath = '';
|
|
parts.forEach((part, index) => {
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
if (!node.children.has(part)) {
|
|
node.children.set(part, { name: part, children: new Map(), path: currentPath, file: index + 1 === parts.length });
|
|
}
|
|
node = node.children.get(part);
|
|
});
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function renderTreeList(children) {
|
|
const list = document.createElement('ul');
|
|
for (const child of [...children.values()].sort(compareNodes)) {
|
|
const item = document.createElement('li');
|
|
if (child.file) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = child.path === project.activePath ? 'file active' : 'file';
|
|
button.textContent = child.name;
|
|
button.title = child.path;
|
|
button.addEventListener('click', () => setActiveFile(child.path));
|
|
item.append(button);
|
|
} else {
|
|
const label = document.createElement('span');
|
|
label.className = 'folder';
|
|
label.textContent = `${child.name}/`;
|
|
item.append(label, renderTreeList(child.children));
|
|
}
|
|
list.append(item);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function compareNodes(a, b) {
|
|
if (a.file !== b.file) return a.file ? 1 : -1;
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
|
|
try {
|
|
await init();
|
|
run.disabled = false;
|
|
status.textContent = '';
|
|
execute();
|
|
} catch (error) {
|
|
status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
|
|
}
|
|
|
|
run.addEventListener('click', execute);
|
|
loadExample.addEventListener('click', () => {
|
|
const example = playgroundExamples.find((item) => item.id === exampleSelect.value);
|
|
if (!example) return;
|
|
if (!confirm(`Load example "${example.title}"? This replaces the current virtual files.`)) return;
|
|
loadExampleProject(example.id);
|
|
});
|
|
newFile.addEventListener('click', () => {
|
|
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
|
|
if (!path) return;
|
|
if (project.files[path] !== undefined) {
|
|
setActiveFile(path);
|
|
return;
|
|
}
|
|
project.files[path] = 'value = "new";\n';
|
|
setActiveFile(path);
|
|
});
|
|
deleteFile.addEventListener('click', () => {
|
|
if (Object.keys(project.files).length <= 1) return;
|
|
if (!confirm(`Delete ${project.activePath}?`)) return;
|
|
delete project.files[project.activePath];
|
|
setActiveFile(Object.keys(project.files).sort()[0]);
|
|
});
|