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]); });