import { signal } from "@preact/signals"; import { Diagnostic } from "@codemirror/lint"; import { useEffect, useRef, useState } from "preact/hooks"; import { h, render, VNode } from "preact"; import { ChangeEvent } from "preact/compat"; import { archive, preload } from "./preload.ts"; import { b64decode, b64encode } from "./base64"; import { AbstractEditor, EditorDelegate, TopData, Marker, } from "./types.ts"; import { CMEditor } from "./cmeditor.ts"; import { deflate } from "./deflate.ts"; import { inflate } from "./inflate.ts"; import { IPC } from "./ipc.ts"; import helpText from "./help.md?raw"; let topData: undefined | TopData; const ipc = new IPC(); function mdline2nodes(s: string) { let cs: (VNode|string)[] = [] let toks = s.matchAll(/(\*\*.*?\*\*)|(\*.*?\*)|(_.*?_)|[^*]+|\*/g) for (let tok of toks) { if (tok[1]) cs.push(h('b',{},tok[0].slice(2,-2))) else if (tok[2]) cs.push(h('em',{},tok[0].slice(1,-1))) else if (tok[3]) cs.push(h('em',{},tok[0].slice(1,-1))) else cs.push(tok[0]) } return cs } function md2nodes(md: string) { let rval: VNode[] = [] let list: VNode[] | undefined for (let line of md.split("\n")) { if (line.startsWith('- ')) { if (!list) { list = [] rval.push(h('ul', {}, list)) } list.push(h('li', {}, mdline2nodes(line.slice(2)))) continue } list = undefined if (line.startsWith('# ')) { rval.push(h('h2', {}, mdline2nodes(line.slice(2)))) } else if (line.startsWith('## ')) { rval.push(h('h3', {}, mdline2nodes(line.slice(3)))) } else { rval.push(h('div', {}, mdline2nodes(line))) } } return rval } // iframe for running newt output const iframe = document.createElement("iframe"); iframe.src = "frame.html"; iframe.style.display = "none"; document.body.appendChild(iframe); async function refreshJS() { if (!state.javascript.value) { let src = state.editor.value!.getValue(); console.log("SEND TO", iframe.contentWindow); const fileName = state.currentFile.value; // maybe send fileName, src? await ipc.sendMessage("save", [fileName, src]); let js = await ipc.sendMessage("compile", [fileName]); state.javascript.value = js; } } async function runOutput() { await refreshJS() const src = state.javascript.value; console.log("RUN", iframe.contentWindow); try { iframe.contentWindow?.postMessage({ type: "exec", src }, "*"); } catch (e) { console.error(e); } } function setOutput(output: string) { let lines = output.split("\n"); output = lines.filter((l) => !l.startsWith("TOP:")).join("\n"); let data = lines.find((l) => l.startsWith("TOP:")); if (data) { try { topData = JSON.parse(data.slice(4)); console.log({ topData }); } catch (e) { console.error(e); } } else { topData = undefined; } state.output.value = output; } window.addEventListener("message", (ev) => { if (ev.data.id) return; console.log("window got", ev.data); if ("messages" in ev.data) state.messages.value = ev.data.messages; if ("message" in ev.data) { state.messages.value = [...state.messages.value, ev.data.message]; } }); async function copyToClipboard(ev: Event) { ev.preventDefault(); let src = state.editor.value!.getValue(); let hash = `#code/${b64encode(deflate(new TextEncoder().encode(src)))}`; window.location.hash = hash; await navigator.clipboard.writeText(window.location.href); state.toast.value = "URL copied to clipboard"; setTimeout(() => (state.toast.value = ""), 2_000); } // We could push this into the editor document.addEventListener("keydown", (ev) => { if ((ev.metaKey || ev.ctrlKey) && ev.code == "KeyS") copyToClipboard(ev); }); function getSavedCode() { let value: string = localStorage.code || LOADING; let hash = window.location.hash; if (hash.startsWith("#code/")) { try { value = new TextDecoder().decode(inflate(b64decode(hash.slice(6)))); } catch (e) { console.error(e); } } return value; } const state = { output: signal(""), toast: signal(""), javascript: signal(""), messages: signal([]), editor: signal(null), dark: signal(false), files: signal(["Tour.newt"]), currentFile: signal(localStorage.currentFile ?? "Tour.newt"), }; // Monitor dark mode state (TODO - let user override system setting) if (window.matchMedia) { function checkDark(ev: { matches: boolean }) { console.log("CHANGE", ev); if (ev.matches) { document.body.className = "dark"; state.dark.value = true; state.editor.value?.setDark(true); } else { document.body.className = "light"; state.dark.value = false; state.editor.value?.setDark(false); } } let query = window.matchMedia("(prefers-color-scheme: dark)"); query.addEventListener("change", checkDark); checkDark(query); } async function loadFile(fn: string) { await preload; let data = archive?.getData(fn); if (data) { let text = new TextDecoder().decode(data); state.editor.value!.setValue(text); } else { state.editor.value!.setValue("module Main\n\n-- File not found\n"); } state.currentFile.value = fn; localStorage.currentFile = fn; } // I keep pressing this. document.addEventListener("keydown", (ev) => { if (ev.metaKey && ev.code == "KeyS") ev.preventDefault(); }); const LOADING = "module Loading\n"; let value = getSavedCode() || LOADING; let initialVertical = localStorage.vertical == "true"; interface EditorProps { initialValue: string; } const language: EditorDelegate = { getEntry(word, _row, _col) { return topData?.context.find((entry) => entry.name === word); }, onChange(_value) { // we're using lint() now }, getFileName() { if (!topData) return ""; let last = topData.context[topData.context.length - 1]; return last.fc.file; }, async lint(view) { console.log("LINT"); let src = view.state.doc.toString(); localStorage.code = src; let module = src.match(/module\s+([^\s]+)/)?.[1]; if (module) { // This causes problems with stuff like aoc/... state.currentFile.value = module.replace(".", "/") + ".newt"; } state.javascript.value = '' let fileName = state.currentFile.value; console.log("FN", fileName); try { await ipc.sendMessage("save", [fileName, src]); let out = await ipc.sendMessage("typeCheck", [fileName]); setOutput(out); let markers = processOutput(out); let diags: Diagnostic[] = []; for (let marker of markers) { let col = marker.startColumn; let line = view.state.doc.line(marker.startLineNumber); const pos = line.from + col - 1; let word = view.state.wordAt(pos); diags.push({ from: word?.from ?? pos, to: word?.to ?? pos + 1, severity: marker.severity, message: marker.message, }); } return diags; } catch (e) { console.log("ERR", e); } return []; }, }; function Editor({ initialValue }: EditorProps) { const ref = useRef(null); useEffect(() => { const container = ref.current!; const editor = new CMEditor(container, value, language); // const editor = new MonacoEditor(container, value, language) state.editor.value = editor; editor.setDark(state.dark.value); if (initialValue === LOADING) loadFile("Tour.newt"); }, []); return h("div", { id: "editor", ref }); } // for extra credit, we could have a read-only monaco function JavaScript() { const text = state.javascript.value; return h("div", { id: "javascript" }, text); } function Result() { const text = state.output.value; return h("div", { id: "result" }, text); } function Help() { return h("div", { id: "help" }, md2nodes(helpText)) } function Console() { const messages = state.messages.value ?? []; return h( "div", { id: "console" }, messages.map((msg) => h("div", { className: "message" }, msg)) ); } const RESULTS = "Output"; const JAVASCRIPT = "JS"; const CONSOLE = "Console"; const HELP = "Help"; function Tabs() { const [selected, setSelected] = useState(localStorage.tab ?? RESULTS); const Tab = (label: string) => { let onClick = () => { setSelected(label); localStorage.tab = label; }; let className = "tab"; if (label == selected) className += " selected"; return h("div", { className, onClick }, label); }; useEffect(() => { if (state.messages.value.length) setSelected(CONSOLE); }, [state.messages.value]); useEffect(() => { if (selected === JAVASCRIPT && !state.javascript.value) refreshJS(); }, [selected, state.javascript.value]); let body; switch (selected) { case RESULTS: body = h(Result, {}); break; case JAVASCRIPT: body = h(JavaScript, {}); break; case CONSOLE: body = h(Console, {}); break; case HELP: body = h(Help, {}); break; default: body = h("div", {}); } return h( "div", { className: "tabPanel right" }, h( "div", { className: "tabBar" }, Tab(RESULTS), Tab(JAVASCRIPT), Tab(CONSOLE), Tab(HELP), ), h("div", { className: "tabBody" }, body) ); } preload.then(() => { if (archive) { let files = []; for (let name in archive.entries) { if (name.endsWith(".newt")) files.push(name); } files.sort(); state.files.value = files; } }); function EditWrap({ vertical, toggle, }: { vertical: boolean; toggle: () => void; }) { const options = state.files.value.map((value) => h("option", { value }, value) ); const selectFile = async (ev: ChangeEvent) => { if (ev.target instanceof HTMLSelectElement) { let fn = ev.target.value; ev.target.value = ""; loadFile(fn); } }; const char = vertical ? "↕" : "↔" // let d = vertical // ? "M0 0 h20 v20 h-20 z M0 10 h20" // : "M0 0 h20 v20 h-20 z M10 0 v20"; // let play = "M0 0 L20 10 L0 20 z"; // let svg = (d: string) => // h("svg", { width: 20, height: 20, className: "icon" }, h("path", { d })); return h( "div", { className: "tabPanel left" }, h( "div", { className: "tabBar" }, h( "select", { onChange: selectFile, value: "" }, h("option", { value: "" }, "-- load sample --"), options ), h("div", { style: { flex: "1 1" } }), h("button", { onClick: copyToClipboard, title: "copy url" }, "📋"), h("button", { onClick: runOutput, title: "run program" }, "▶"), h("button", { onClick: toggle, title: "change layout" }, char), // h("button", { onClick: toggle }, svg(d)) ), h("div", { className: "tabBody" }, h(Editor, { initialValue: value })) ); } function App() { let [vertical, setVertical] = useState(initialVertical); let toggle = () => { setVertical(!vertical); localStorage.vertical = !vertical; }; let toast; if (state.toast.value) { toast = h("p", { className: "toast" }, h("div", {}, state.toast.value)); } let className = `wrapper ${vertical ? "vertical" : "horizontal"}`; return h( "div", { className }, toast, h(EditWrap, { vertical, toggle }), h(Tabs, {}) ); } render(h(App, {}), document.getElementById("app")!); const processOutput = ( output: string ) => { console.log("process output", output); let markers: Marker[] = []; let lines = output.split("\n"); let m = lines[0].match(/.*Process (.*)/); let fn = ""; if (m) fn = m[1]; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match( /(INFO|ERROR) at ([^:]+):\((\d+):(\d+)-(\d+):(\d+)\):\s*(.*)/ ); if (match) { let [_full, kind, file, line, col, eline, ecol, message] = match; let startLineNumber = +line; let startColumn = +col; let endLineNumber = +eline; let endColumn = +ecol // FIXME - pass the real path in if (fn && fn !== file) { startLineNumber = startColumn = 0; } // we don't have the full range, so grab the surrounding word // let endColumn = startColumn + 1; // heuristics to grab the entire message: // anything indented // Context:, or Goal: are part of PRINTME // unexpected / expecting appear in parse errors while (lines[i + 1]?.match(/^( )/)) { message += "\n" + lines[++i]; } if (kind === "ERROR" || startLineNumber) markers.push({ severity: kind === "ERROR" ? "error" : "info", message, startLineNumber, endLineNumber, startColumn, endColumn, }); } } console.log("markers", markers); return markers; };