-
-
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;
+}