automata/web/root/src/editor.ts

205 lines
No EOL
5.3 KiB
TypeScript

// deno-lint-ignore-file
import {
EditorView,
keymap,
hoverTooltip,
Decoration,
lineNumbers,
highlightActiveLineGutter,
highlightActiveLine
} from "npm:@codemirror/view";
import { EditorState, StateField, Text } from "npm:@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "npm:@codemirror/commands";
import { bracketMatching, indentOnInput } from "npm:@codemirror/language";
import { closeBrackets } from "npm:@codemirror/autocomplete";
import wasm from "./wasm.ts"
import { sharedText } from "./share.ts";
import { examples } from "./examples.ts";
import { bus } from "./bus.ts";
function tokenize(text: string) {
try {
return wasm.lex(text);
} catch (e) {
console.log(e)
return []
}
}
function compile(text: string): wasm.CompileResult {
try {
return wasm.compile(text);
} catch (e) {
console.log(e);
// @ts-expect-error wasm defines extra cleanup
return {log: [], log_formatted: "", graph: ""};
}
}
const eventBusConnection = StateField.define({
create(state) {
const text = state.doc.toString();
bus.emit("editor/change", {text, doc: state.doc});
return buildAnalysis(text, state.doc);
},
update(value, tr) {
if (!tr.docChanged) return value;
const text = tr.state.doc.toString();
bus.emit("editor/change", {text, doc: state.doc});
return buildAnalysis(text, tr.state.doc);
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
function buildAnalysis(text: string, doc: Text) {
const tokens = tokenize(text);
const { log, ansi_log, machine } = compile(text);
bus.emit("compiled", {log, ansi_log, machine})
const marks = [];
const docLen = doc.length;
for (const tok of tokens) {
const start = Math.max(0, Math.min(docLen, tok.start));
const end = Math.max(start, Math.min(docLen, tok.end));
let tc = tokenClass(tok.kind);
if (tc === "rb-") {
tc += tok.scope_level.toString();
}
if (end > start) {
marks.push(Decoration.mark({ class: tc }).range(start, end));
}
}
for (const d of log) {
if (d.start === undefined || d.end === undefined) continue;
const start = Math.max(0, Math.min(docLen, d.start));
const endRaw = d.end == null ? d.start : d.end;
const end = Math.max(start, Math.min(docLen, endRaw));
const cls = severityClass(d.level);
if (end > start) {
marks.push(Decoration.mark({ class: cls }).range(start, end));
} else {
const end = Math.min(docLen, start + 1);
if (end > start) marks.push(Decoration.mark({ class: cls }).range(start, end));
}
}
const deco = Decoration.set(marks, true);
return { tokens, log, ansi_log, deco };
}
const tokenClass = (t: string) =>
({
comment: "tok-comment",
keyword: "tok-keyword",
error: "tok-error",
ident: "tok-ident",
punc: "tok-punc",
string: "tok-string",
lpar: "rb-",
lbrace: "rb-",
lbracket: "rb-",
rpar: "rb-",
rbrace: "rb-",
rbracket: "rb-",
}[t] || "tok-ident");
function severityClass(sev: string) {
const s = (sev || "error").toLowerCase();
if (s === "warning") return "cm-diag-warning";
if (s === "info") return "cm-diag-info";
return "cm-diag-error";
}
function sevRank(sev: string) {
if (sev === "error") return 3;
if (sev === "warning") return 2;
return 1;
}
// ===================== Hover tooltip (uses cached diags) =====================
const diagHover = hoverTooltip((view, pos) => {
const { log } = view.state.field(eventBusConnection);
const hits = log.filter((d) => d.start !== undefined && d.end !== undefined && pos >= d.start && pos <= d.end);
if (hits.length === 0) return null;
const top = hits.reduce((a, b) => (sevRank(b.level) > sevRank(a.level) ? b : a), hits[0]);
return {
pos,
end: pos,
above: true,
create() {
const dom = document.createElement("div");
dom.className = "cm-tooltip cm-tooltip-hover";
const title = document.createElement("div");
title.className = `tipTitle ${top.level}`;
title.textContent =
hits.length === 1 ? top.level.toUpperCase() : `${top.level.toUpperCase()} (${hits.length})`;
const body = document.createElement("div");
body.className = "tipBody";
body.textContent = hits
.slice()
.sort((a, b) => sevRank(b.level) - sevRank(a.level))
.map((h) => `[${h.level.toUpperCase()}] ${h.message}`)
.join("\n");
dom.appendChild(title);
dom.appendChild(body);
return { dom };
},
};
});
function save(text: string){
globalThis.localStorage.save = text;
}
function getSaved(): string | undefined{
return globalThis.localStorage.save;
}
export function setText(text: string){
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: text } });
}
export function getText(): string{
return editor.state.doc.toString()
}
const state = EditorState.create({
doc: "",
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
history(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
closeBrackets(),
keymap.of([...defaultKeymap, ...historyKeymap]),
eventBusConnection,
diagHover,
EditorView.lineWrapping,
],
});
const editor = new EditorView({
state,
parent: document.getElementById("editor")!,
});
bus.on("begin", _ => setText(sharedText() ?? getSaved() ?? examples[0].machine))