Add canonical grammar and CodeMirror playground

This commit is contained in:
2026-07-08 23:41:26 +09:00
parent a07f4c48aa
commit ee031bd2e8
15 changed files with 709 additions and 81 deletions
+81 -42
View File
@@ -1,12 +1,13 @@
import init, { evaluateProject, tokenizeSource } from '../wasm/decodal_wasm.js';
import { highlightDecodal, highlightDecodalTokens } from '../lib/highlight.js';
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
import { EditorView, basicSetup } from 'codemirror';
import { keymap } from '@codemirror/view';
import { decodal } from '../lib/codemirror/decodal.js';
import { playgroundExamples } from './playground-examples.js';
const STORAGE_KEY = 'decodal-playground-project-v1';
const starterProject = playgroundExamples[0];
const source = document.getElementById('source');
const sourceHighlight = document.getElementById('source-highlight');
const editorHost = document.getElementById('editor');
const output = document.getElementById('output');
const run = document.getElementById('run');
const status = document.getElementById('status');
@@ -18,7 +19,63 @@ const exampleSelect = document.getElementById('example-select');
const loadExample = document.getElementById('load-example');
const project = loadProject();
let wasmReady = false;
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');
@@ -30,7 +87,6 @@ exampleSelect.value = starterProject.id;
setActiveFile(project.activePath);
renderFileTree();
updateHighlight();
function loadProject() {
try {
@@ -84,41 +140,36 @@ function normalizePath(path) {
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;
source.value = project.files[normalized];
setEditorText(project.files[normalized]);
activeFile.textContent = normalized;
deleteFile.disabled = Object.keys(project.files).length <= 1;
updateHighlight();
syncHighlightScroll();
renderFileTree();
saveProject();
}
function updateHighlight() {
sourceHighlight.innerHTML = `${highlightSource(source.value)}\n`;
}
function highlightSource(value) {
if (!wasmReady) return highlightDecodal(value);
try {
const result = JSON.parse(tokenizeSource(value));
if (result.ok) return highlightDecodalTokens(value, result.tokens);
} catch (_error) {
// Fall back to the lightweight JavaScript highlighter.
}
return highlightDecodal(value);
}
function syncHighlightScroll() {
sourceHighlight.scrollTop = source.scrollTop;
sourceHighlight.scrollLeft = source.scrollLeft;
}
function execute() {
project.files[project.activePath] = source.value;
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;
@@ -177,8 +228,6 @@ function compareNodes(a, b) {
try {
await init();
wasmReady = true;
updateHighlight();
run.disabled = false;
status.textContent = '';
execute();
@@ -209,13 +258,3 @@ deleteFile.addEventListener('click', () => {
delete project.files[project.activePath];
setActiveFile(Object.keys(project.files).sort()[0]);
});
source.addEventListener('input', () => {
project.files[project.activePath] = source.value;
updateHighlight();
syncHighlightScroll();
saveProject();
});
source.addEventListener('scroll', syncHighlightScroll);
source.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') execute();
});