Skip to content

Commit 28c52d3

Browse files
committed
GRAPHS!!!
1 parent ca1fbff commit 28c52d3

File tree

12 files changed

+258
-23
lines changed

12 files changed

+258
-23
lines changed

web/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
"@codemirror/state": "^6.5.1",
1717
"@codemirror/theme-one-dark": "github:codemirror/theme-one-dark",
1818
"@codemirror/view": "^6.36.2",
19+
"@mermaid-js/layout-elk": "^0.1.7",
1920
"codemirror": "^6.0.1",
21+
"mermaid": "^11.4.1",
22+
"panzoom": "^9.4.3",
2023
"solid-js": "^1.9.4",
2124
"solid-markdown": "2.0.1",
2225
"yaml": "^2.7.0"

web/src/App.tsx

+13-7
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ import { lazy, Match, Suspense, Switch } from "solid-js";
55
const Editor = lazy(() =>
66
import("./modes/editor/Editor").then((m) => ({ default: m.Editor }))
77
);
8+
const Graph = lazy(() =>
9+
import("./modes/graph/Graph").then((m) => ({ default: m.Graph }))
10+
);
811

912
export function App() {
10-
const editorMode = window.location.search.slice(1) == "editor";
13+
const mode = window.location.search.slice(1);
1114
const initialStep = window.location.hash.slice(1);
1215

1316
return (
14-
<Switch fallback={<Guide content={CONTENT} initialStep={initialStep} />}>
15-
<Match when={editorMode}>
16-
<Suspense fallback={<p>Loading...</p>}>
17+
<Suspense fallback={<p>Loading...</p>}>
18+
<Switch fallback={<Guide content={CONTENT} initialStep={initialStep} />}>
19+
<Match when={mode == "editor"}>
1720
<Editor />
18-
</Suspense>
19-
</Match>
20-
</Switch>
21+
</Match>
22+
<Match when={mode == "graph"}>
23+
<Graph />
24+
</Match>
25+
</Switch>
26+
</Suspense>
2127
);
2228
}

web/src/index.css

+6
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@
6969
height: 100%;
7070
}
7171

72+
.full-window {
73+
overflow: hidden;
74+
width: 100%;
75+
height: 100%;
76+
}
77+
7278
@layer utils {
7379
.button-list {
7480
display: inline-grid;

web/src/modes/editor/CodeMirror.tsx

+35-4
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,60 @@
11
import { EditorView, basicSetup } from "codemirror";
2-
import { onCleanup } from "solid-js";
2+
import { createEffect, onCleanup } from "solid-js";
33
import { yaml } from "@codemirror/lang-yaml";
44
import { indentWithTab } from "@codemirror/commands";
55
import { oneDark } from "@codemirror/theme-one-dark";
6-
import { keymap } from "@codemirror/view";
6+
import { keymap, ViewPlugin } from "@codemirror/view";
77
import { customLinter } from "./linter";
88
import { lintGutter } from "@codemirror/lint";
99

1010
export interface CodeMirrorProps {
11-
content?: string;
11+
initialContent?: string;
12+
showStep?: string;
13+
onChange?: (content: string) => void;
1214
}
1315

1416
export function CodeMirror(props: CodeMirrorProps) {
1517
let view = new EditorView({
16-
doc: props.content,
18+
doc: props.initialContent,
1719
extensions: [
1820
basicSetup,
1921
oneDark,
2022
yaml(),
2123
customLinter,
2224
lintGutter(),
2325
keymap.of([indentWithTab]),
26+
ViewPlugin.define((view) => ({
27+
update(update) {
28+
if (update.docChanged) {
29+
props.onChange?.(view.state.doc.toString());
30+
}
31+
},
32+
})),
2433
],
2534
});
2635

36+
function findStep(step: string) {
37+
let i = 1;
38+
for (const line of view.state.doc.iterLines()) {
39+
if (line.startsWith(step)) return i;
40+
i++;
41+
}
42+
}
43+
44+
async function scrollToStep(step: string) {
45+
if (!step) return;
46+
const lineNumber = await findStep(step);
47+
if (lineNumber == undefined) return;
48+
const line = view.state.doc.line(lineNumber);
49+
if (!line) return;
50+
const block = view.lineBlockAt(line.from);
51+
view.scrollDOM.scrollTo({ ...block, behavior: "smooth" });
52+
}
53+
54+
createEffect(async () => {
55+
if (props.showStep) await scrollToStep(props.showStep);
56+
});
57+
2758
onCleanup(() => view.destroy());
2859

2960
return <>{view.dom}</>;

web/src/modes/editor/Editor.tsx

+56-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,66 @@
11
import contentRaw from "$content/guide.yaml?raw";
22
import { CodeMirror } from "./CodeMirror";
3+
import {
4+
createSignal,
5+
ErrorBoundary,
6+
lazy,
7+
Suspense,
8+
createMemo,
9+
} from "solid-js";
10+
import YAML from "yaml";
11+
import { contentToMermaid } from "../graph/mermaid";
12+
import { Button } from "$components/Button";
313

414
import "./editor.css";
15+
import { download } from "./util";
16+
17+
const Mermaid = lazy(() =>
18+
import("../graph/Mermaid").then((m) => ({ default: m.Mermaid }))
19+
);
520

621
export function Editor() {
22+
const [showStep, setShowStep] = createSignal<string>();
23+
const [content, setContent] = createSignal(contentRaw);
24+
const mermaid = createMemo(() => contentToMermaid(YAML.parse(content())));
25+
726
return (
8-
<div id="editor">
9-
<CodeMirror content={contentRaw} />
10-
<div>E</div>
27+
<div id="editor" class="full-window">
28+
<div>
29+
<CodeMirror
30+
initialContent={content()}
31+
onChange={setContent}
32+
showStep={showStep()}
33+
/>
34+
</div>
35+
36+
<div class="full-window">
37+
<div class="toolbar">
38+
<Button onClick={() => download("guide.yaml", content())}>
39+
Download YAML
40+
</Button>
41+
<Button
42+
onClick={() =>
43+
download("guide.md", "```mermaid\n" + mermaid() + "\n```")
44+
}
45+
>
46+
Download Mermaid
47+
</Button>
48+
</div>
49+
50+
<Suspense fallback={<p>Loading...</p>}>
51+
<ErrorBoundary
52+
fallback={(err) => (
53+
<div>
54+
Error rendering graph
55+
<br />
56+
<pre>{err.toString()}</pre>
57+
</div>
58+
)}
59+
>
60+
<Mermaid content={mermaid()} onClick={setShowStep} />
61+
</ErrorBoundary>
62+
</Suspense>
63+
</div>
1164
</div>
1265
);
1366
}

web/src/modes/editor/editor.css

+11-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
body {
2-
overflow: hidden;
3-
}
4-
51
#editor {
62
flex-grow: 1;
73

84
width: 100vw;
95
height: 100vh;
106

117
display: grid;
12-
grid-template-columns: repeat(2, 1fr);
8+
grid-template-columns: repeat(2, minmax(0, 1fr));
9+
}
10+
11+
#editor .toolbar {
12+
background: #222;
13+
padding: 0.25rem;
14+
display: flex;
15+
flex-wrap: wrap;
16+
gap: 0.25rem;
17+
position: relative;
18+
z-index: 10;
1319
}
1420

1521
.cm-editor {

web/src/modes/editor/linter.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Step } from "$lib/content";
2-
import { ensureSyntaxTree, Language, syntaxTree } from "@codemirror/language";
2+
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
33
import { linter, Diagnostic } from "@codemirror/lint";
44

55
type SyntaxNodeRef = Parameters<
@@ -146,11 +146,18 @@ export const customLinter = linter(
146146
tree.cursor().iterate(enter, leave);
147147

148148
// Analyze steps
149-
const stepKeys = new Set([...steps.keys()]);
150-
console.log(stepKeys);
151149
for (const [key, step] of steps) {
150+
if (!step.title) {
151+
diagnostics.push({
152+
from: step.keyNode.from,
153+
to: step.keyNode.to,
154+
severity: "error",
155+
message: `Step '${key}' does not have a 'title'!`,
156+
});
157+
}
158+
152159
for (const [target, optionNodes] of step.optionTargets) {
153-
if (!stepKeys.has(target)) {
160+
if (!steps.has(target)) {
154161
for (const optionNode of optionNodes) {
155162
diagnostics.push({
156163
from: optionNode.from,

web/src/modes/editor/util.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function download(
2+
filename: string,
3+
content: string,
4+
type = "text/plain"
5+
) {
6+
const blob = new Blob([content], { type });
7+
const href = URL.createObjectURL(blob);
8+
9+
const a = document.createElement("a");
10+
a.setAttribute("download", filename);
11+
a.setAttribute("href", href);
12+
a.click();
13+
URL.revokeObjectURL(href);
14+
}

web/src/modes/graph/Graph.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { CONTENT } from "$lib/content";
2+
import { contentToMermaid } from "./mermaid";
3+
import { Mermaid } from "./Mermaid";
4+
5+
export function Graph() {
6+
return (
7+
<div class="full-window">
8+
<Mermaid content={contentToMermaid(CONTENT)} />
9+
</div>
10+
);
11+
}

web/src/modes/graph/Mermaid.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createResource, onMount, onCleanup } from "solid-js";
2+
import mermaid from "mermaid";
3+
import elkLayouts from "@mermaid-js/layout-elk";
4+
import panzoom, { PanZoom } from "panzoom";
5+
6+
import "./mermaid.css";
7+
8+
mermaid.registerLayoutLoaders(elkLayouts);
9+
mermaid.initialize({
10+
startOnLoad: false,
11+
theme: "dark",
12+
layout: "elk",
13+
elk: {
14+
nodePlacementStrategy: "LINEAR_SEGMENTS",
15+
cycleBreakingStrategy: "MODEL_ORDER",
16+
},
17+
});
18+
19+
export interface MermaidProps {
20+
content: string;
21+
onClick?: (step: string) => void;
22+
}
23+
24+
export function Mermaid(props: MermaidProps) {
25+
const [svg] = createResource(async () => {
26+
const { svg } = await mermaid.render("mermaid", props.content);
27+
return svg;
28+
});
29+
30+
let element: HTMLDivElement | undefined = undefined;
31+
let controls: PanZoom;
32+
33+
onMount(() => {
34+
controls = panzoom(element!, {
35+
bounds: true,
36+
boundsPadding: 0.0,
37+
});
38+
});
39+
40+
onCleanup(() => {
41+
controls.dispose();
42+
});
43+
44+
function onClick(ev: MouseEvent) {
45+
const element = ev.target as Element;
46+
const parent = element.closest("g.node");
47+
if (!parent) return;
48+
const step = parent.getAttribute("id")?.split("-").slice(1, -1).join("-");
49+
if (step) props.onClick?.(step);
50+
}
51+
52+
return <div id="mermaid" ref={element} onClick={onClick} innerHTML={svg()} />;
53+
}

web/src/modes/graph/mermaid.css

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#mermaid .node.default {
2+
cursor: pointer;
3+
}
4+
5+
#mermaid .node.default .label-container {
6+
transition: fill 0.1s;
7+
}
8+
9+
#mermaid .node.default:hover .label-container {
10+
fill: #333;
11+
}

web/src/modes/graph/mermaid.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Content } from "$lib/content";
2+
3+
export function contentToMermaid(content: Content) {
4+
let mermaid = `graph TD\n`;
5+
6+
for (const [stepName, step] of Object.entries(content)) {
7+
let title = `"${step.title.replace('"', "#quot;")}"`;
8+
if (stepName == "start" || !step.options?.length) {
9+
title = "([" + title + "])";
10+
} else if ((step.options?.length ?? 0) > 1) {
11+
title = "{{" + title + "}}";
12+
} else {
13+
title = "[" + title + "]";
14+
}
15+
16+
if (step.options?.length) {
17+
let first = true;
18+
for (const option of step.options) {
19+
mermaid += `\t${stepName}`;
20+
21+
if (first) {
22+
mermaid += `${title}`;
23+
first = false;
24+
}
25+
26+
mermaid += ` --"${option.label}"--> ${option.target}\n`;
27+
}
28+
} else {
29+
mermaid += `\t${stepName}${title}\n`;
30+
}
31+
}
32+
33+
return mermaid;
34+
}

0 commit comments

Comments
 (0)