Files
Decodal/site/decodal-site/src/scripts/playground.js
T

356 lines
11 KiB
JavaScript

import initRuntime, { evaluateProject } from 'decodal-wasm';
import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url';
import { EditorView, basicSetup } from 'codemirror';
import { keymap } from '@codemirror/view';
import { decodal, decodalLanguage } from 'decodal-codemirror';
import { formatDecodal, initDecodalFormatter } from 'decodal-codemirror/format';
import toolsWasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
import { playgroundExamples } from './playground-examples.js';
import { createProjectCompletionSource } from './playground-completion.js';
const STORAGE_KEY = 'decodal-playground-project-v2';
const starterProject = playgroundExamples[0];
const editorHost = document.getElementById('editor');
const output = document.getElementById('output');
const run = document.getElementById('run');
const formatButton = document.getElementById('format');
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 entrySelect = document.getElementById('entry-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)',
},
'.cm-tooltip-autocomplete': {
backgroundColor: '#111827',
border: '1px solid #334155',
borderRadius: '8px',
color: '#e5e7eb',
overflow: 'hidden',
},
'.cm-tooltip-autocomplete > ul > li': {
padding: '3px 8px',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: '#1d4ed8',
color: '#fff',
},
'.cm-completionDetail': {
color: '#94a3b8',
fontStyle: 'normal',
marginLeft: '1.5em',
},
}, { dark: true });
const completionSource = createProjectCompletionSource({
getFiles: () => project.files,
getActivePath: () => project.activePath,
});
const editor = new EditorView({
doc: project.files[project.activePath] ?? '',
parent: editorHost,
extensions: [
basicSetup,
decodal(),
decodalLanguage.data.of({ autocomplete: completionSource }),
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);
updateRunLabel();
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) {
const storedEntryPath = typeof stored.entryPath === 'string' ? normalizePath(stored.entryPath) : '';
const entryPath = files[storedEntryPath] === undefined
? files['main.dcdl'] === undefined ? activePath : 'main.dcdl'
: storedEntryPath;
return { files, activePath, entryPath };
}
}
} catch (_error) {
// Fall back to the starter project.
}
return {
files: cloneFiles(starterProject.files),
activePath: starterProject.activePath,
entryPath: starterProject.entryPath ?? 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;
const files = cloneFiles(example.files);
project.files = {};
project.activePath = '';
project.entryPath = example.entryPath ?? example.activePath;
project.files = files;
exampleSelect.value = example.id;
setActiveFile(example.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();
updateRunLabel();
saveProject();
}
function updateEntrySelect() {
const paths = Object.keys(project.files).sort();
const entryPath = project.files[project.entryPath] === undefined ? project.activePath : project.entryPath;
project.entryPath = entryPath;
entrySelect.replaceChildren(
...paths.map((path) => {
const option = document.createElement('option');
option.value = path;
option.textContent = path;
return option;
}),
);
entrySelect.value = entryPath;
}
function updateRunLabel() {
updateEntrySelect();
run.textContent = 'Run';
run.title = `Materialize ${project.entryPath}`;
}
function execute() {
project.files[project.activePath] = getEditorText();
const entryPath = project.files[project.entryPath] === undefined ? project.activePath : project.entryPath;
project.entryPath = entryPath;
updateRunLabel();
saveProject();
const result = JSON.parse(evaluateProject(entryPath, JSON.stringify(project.files)));
output.textContent = result.ok ? result.output : result.error;
output.classList.toggle('error', !result.ok);
}
function formatActiveFile() {
const result = formatDecodal(getEditorText());
if (!result.ok) {
output.textContent = result.error;
output.classList.add('error');
return;
}
setEditorText(result.source);
project.files[project.activePath] = result.source;
saveProject();
output.textContent = 'Formatted.';
output.classList.remove('error');
}
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 Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]);
run.disabled = false;
formatButton.disabled = false;
status.textContent = 'Ctrl+Space: complete';
execute();
} catch (error) {
status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
}
entrySelect.addEventListener('change', () => {
const path = normalizePath(entrySelect.value);
if (project.files[path] === undefined) return;
project.entryPath = path;
updateRunLabel();
saveProject();
});
run.addEventListener('click', execute);
formatButton.addEventListener('click', formatActiveFile);
loadExample.addEventListener('click', () => {
const example = playgroundExamples.find((item) => item.id === exampleSelect.value);
if (!example) 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];
if (project.files[project.entryPath] === undefined) {
project.entryPath = Object.keys(project.files).sort()[0];
}
setActiveFile(Object.keys(project.files).sort()[0]);
});