diff --git a/web/package.json b/web/package.json index 72cdea9..ff4f9ab 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,10 @@ "@codemirror/state": "^6.5.1", "@codemirror/theme-one-dark": "github:codemirror/theme-one-dark", "@codemirror/view": "^6.36.2", + "@mermaid-js/layout-elk": "^0.1.7", "codemirror": "^6.0.1", + "mermaid": "^11.4.1", + "panzoom": "^9.4.3", "solid-js": "^1.9.4", "solid-markdown": "2.0.1", "yaml": "^2.7.0" diff --git a/web/src/App.tsx b/web/src/App.tsx index bfdc94e..b874a34 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,18 +5,24 @@ import { lazy, Match, Suspense, Switch } from "solid-js"; const Editor = lazy(() => import("./modes/editor/Editor").then((m) => ({ default: m.Editor })) ); +const Graph = lazy(() => + import("./modes/graph/Graph").then((m) => ({ default: m.Graph })) +); export function App() { - const editorMode = window.location.search.slice(1) == "editor"; + const mode = window.location.search.slice(1); const initialStep = window.location.hash.slice(1); return ( - }> - - Loading...

}> + Loading...

}> + }> + -
-
-
+ + + + + + ); } diff --git a/web/src/index.css b/web/src/index.css index 4cc7e43..960dabe 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -69,6 +69,12 @@ height: 100%; } +.full-window { + overflow: hidden; + width: 100%; + height: 100%; +} + @layer utils { .button-list { display: inline-grid; diff --git a/web/src/modes/editor/CodeMirror.tsx b/web/src/modes/editor/CodeMirror.tsx index 58c08b2..f551bdb 100644 --- a/web/src/modes/editor/CodeMirror.tsx +++ b/web/src/modes/editor/CodeMirror.tsx @@ -1,19 +1,21 @@ import { EditorView, basicSetup } from "codemirror"; -import { onCleanup } from "solid-js"; +import { createEffect, onCleanup } from "solid-js"; import { yaml } from "@codemirror/lang-yaml"; import { indentWithTab } from "@codemirror/commands"; import { oneDark } from "@codemirror/theme-one-dark"; -import { keymap } from "@codemirror/view"; +import { keymap, ViewPlugin } from "@codemirror/view"; import { customLinter } from "./linter"; import { lintGutter } from "@codemirror/lint"; export interface CodeMirrorProps { - content?: string; + initialContent?: string; + showStep?: string; + onChange?: (content: string) => void; } export function CodeMirror(props: CodeMirrorProps) { let view = new EditorView({ - doc: props.content, + doc: props.initialContent, extensions: [ basicSetup, oneDark, @@ -21,9 +23,38 @@ export function CodeMirror(props: CodeMirrorProps) { customLinter, lintGutter(), keymap.of([indentWithTab]), + ViewPlugin.define((view) => ({ + update(update) { + if (update.docChanged) { + props.onChange?.(view.state.doc.toString()); + } + }, + })), ], }); + function findStep(step: string) { + let i = 1; + for (const line of view.state.doc.iterLines()) { + if (line.startsWith(step)) return i; + i++; + } + } + + async function scrollToStep(step: string) { + if (!step) return; + const lineNumber = await findStep(step); + if (lineNumber == undefined) return; + const line = view.state.doc.line(lineNumber); + if (!line) return; + const block = view.lineBlockAt(line.from); + view.scrollDOM.scrollTo({ ...block, behavior: "smooth" }); + } + + createEffect(async () => { + if (props.showStep) await scrollToStep(props.showStep); + }); + onCleanup(() => view.destroy()); return <>{view.dom}; diff --git a/web/src/modes/editor/Editor.tsx b/web/src/modes/editor/Editor.tsx index f9fe373..02a6498 100644 --- a/web/src/modes/editor/Editor.tsx +++ b/web/src/modes/editor/Editor.tsx @@ -1,13 +1,66 @@ import contentRaw from "$content/guide.yaml?raw"; import { CodeMirror } from "./CodeMirror"; +import { + createSignal, + ErrorBoundary, + lazy, + Suspense, + createMemo, +} from "solid-js"; +import YAML from "yaml"; +import { contentToMermaid } from "../graph/mermaid"; +import { Button } from "$components/Button"; import "./editor.css"; +import { download } from "./util"; + +const Mermaid = lazy(() => + import("../graph/Mermaid").then((m) => ({ default: m.Mermaid })) +); export function Editor() { + const [showStep, setShowStep] = createSignal(); + const [content, setContent] = createSignal(contentRaw); + const mermaid = createMemo(() => contentToMermaid(YAML.parse(content()))); + return ( -
- -
E
+
+
+ +
+ +
+
+ + +
+ + Loading...

}> + ( +
+ Error rendering graph +
+
{err.toString()}
+
+ )} + > + +
+
+
); } diff --git a/web/src/modes/editor/editor.css b/web/src/modes/editor/editor.css index cc0ddb5..cda1187 100644 --- a/web/src/modes/editor/editor.css +++ b/web/src/modes/editor/editor.css @@ -1,7 +1,3 @@ -body { - overflow: hidden; -} - #editor { flex-grow: 1; @@ -9,7 +5,17 @@ body { height: 100vh; display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +#editor .toolbar { + background: #222; + padding: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + position: relative; + z-index: 10; } .cm-editor { diff --git a/web/src/modes/editor/linter.ts b/web/src/modes/editor/linter.ts index a9474cd..a4b6d5d 100644 --- a/web/src/modes/editor/linter.ts +++ b/web/src/modes/editor/linter.ts @@ -1,5 +1,5 @@ import { Step } from "$lib/content"; -import { ensureSyntaxTree, Language, syntaxTree } from "@codemirror/language"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; import { linter, Diagnostic } from "@codemirror/lint"; type SyntaxNodeRef = Parameters< @@ -146,11 +146,18 @@ export const customLinter = linter( tree.cursor().iterate(enter, leave); // Analyze steps - const stepKeys = new Set([...steps.keys()]); - console.log(stepKeys); for (const [key, step] of steps) { + if (!step.title) { + diagnostics.push({ + from: step.keyNode.from, + to: step.keyNode.to, + severity: "error", + message: `Step '${key}' does not have a 'title'!`, + }); + } + for (const [target, optionNodes] of step.optionTargets) { - if (!stepKeys.has(target)) { + if (!steps.has(target)) { for (const optionNode of optionNodes) { diagnostics.push({ from: optionNode.from, diff --git a/web/src/modes/editor/util.ts b/web/src/modes/editor/util.ts new file mode 100644 index 0000000..bb1dc11 --- /dev/null +++ b/web/src/modes/editor/util.ts @@ -0,0 +1,14 @@ +export function download( + filename: string, + content: string, + type = "text/plain" +) { + const blob = new Blob([content], { type }); + const href = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.setAttribute("download", filename); + a.setAttribute("href", href); + a.click(); + URL.revokeObjectURL(href); +} diff --git a/web/src/modes/graph/Graph.tsx b/web/src/modes/graph/Graph.tsx new file mode 100644 index 0000000..7263518 --- /dev/null +++ b/web/src/modes/graph/Graph.tsx @@ -0,0 +1,11 @@ +import { CONTENT } from "$lib/content"; +import { contentToMermaid } from "./mermaid"; +import { Mermaid } from "./Mermaid"; + +export function Graph() { + return ( +
+ +
+ ); +} diff --git a/web/src/modes/graph/Mermaid.tsx b/web/src/modes/graph/Mermaid.tsx new file mode 100644 index 0000000..59e4296 --- /dev/null +++ b/web/src/modes/graph/Mermaid.tsx @@ -0,0 +1,53 @@ +import { createResource, onMount, onCleanup } from "solid-js"; +import mermaid from "mermaid"; +import elkLayouts from "@mermaid-js/layout-elk"; +import panzoom, { PanZoom } from "panzoom"; + +import "./mermaid.css"; + +mermaid.registerLayoutLoaders(elkLayouts); +mermaid.initialize({ + startOnLoad: false, + theme: "dark", + layout: "elk", + elk: { + nodePlacementStrategy: "LINEAR_SEGMENTS", + cycleBreakingStrategy: "MODEL_ORDER", + }, +}); + +export interface MermaidProps { + content: string; + onClick?: (step: string) => void; +} + +export function Mermaid(props: MermaidProps) { + const [svg] = createResource(async () => { + const { svg } = await mermaid.render("mermaid", props.content); + return svg; + }); + + let element: HTMLDivElement | undefined = undefined; + let controls: PanZoom; + + onMount(() => { + controls = panzoom(element!, { + bounds: true, + boundsPadding: 0.0, + }); + }); + + onCleanup(() => { + controls.dispose(); + }); + + function onClick(ev: MouseEvent) { + const element = ev.target as Element; + const parent = element.closest("g.node"); + if (!parent) return; + const step = parent.getAttribute("id")?.split("-").slice(1, -1).join("-"); + if (step) props.onClick?.(step); + } + + return
; +} diff --git a/web/src/modes/graph/mermaid.css b/web/src/modes/graph/mermaid.css new file mode 100644 index 0000000..66566b5 --- /dev/null +++ b/web/src/modes/graph/mermaid.css @@ -0,0 +1,11 @@ +#mermaid .node.default { + cursor: pointer; +} + +#mermaid .node.default .label-container { + transition: fill 0.1s; +} + +#mermaid .node.default:hover .label-container { + fill: #333; +} diff --git a/web/src/modes/graph/mermaid.ts b/web/src/modes/graph/mermaid.ts new file mode 100644 index 0000000..50357d7 --- /dev/null +++ b/web/src/modes/graph/mermaid.ts @@ -0,0 +1,34 @@ +import { Content } from "$lib/content"; + +export function contentToMermaid(content: Content) { + let mermaid = `graph TD\n`; + + for (const [stepName, step] of Object.entries(content)) { + let title = `"${step.title.replace('"', "#quot;")}"`; + if (stepName == "start" || !step.options?.length) { + title = "([" + title + "])"; + } else if ((step.options?.length ?? 0) > 1) { + title = "{{" + title + "}}"; + } else { + title = "[" + title + "]"; + } + + if (step.options?.length) { + let first = true; + for (const option of step.options) { + mermaid += `\t${stepName}`; + + if (first) { + mermaid += `${title}`; + first = false; + } + + mermaid += ` --"${option.label}"--> ${option.target}\n`; + } + } else { + mermaid += `\t${stepName}${title}\n`; + } + } + + return mermaid; +}