diff --git a/Frontend/package.json b/Frontend/package.json
index ebd47f4..b059392 100644
--- a/Frontend/package.json
+++ b/Frontend/package.json
@@ -17,10 +17,12 @@
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
+ "@codemirror/state": "^6.4.1",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"markdown-it-mark": "^4.0.0",
- "markdown-it-footnote": "^4.0.0"
+ "markdown-it-footnote": "^4.0.0",
+ "mermaid": "^11.4.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx
index 725f986..6c6bb01 100644
--- a/Frontend/src/Components/PreviewPane.jsx
+++ b/Frontend/src/Components/PreviewPane.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { gutter, GutterMarker } from "@codemirror/view";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
@@ -10,6 +10,7 @@ import MarkdownItMark from "markdown-it-mark";
import MarkdownItFootnote from "markdown-it-footnote";
import { LivePreview } from "../LivePreview.js";
+import { DiagramHtml, RenderDiagramsIn } from "../Mermaid.js";
import {
FetchBlame,
@@ -42,6 +43,17 @@ MarkdownRenderer.renderer.rules.image = (Tokens, Index, Options, Env, Self) => {
return DefaultImageRenderer(Tokens, Index, Options, Env, Self);
};
+// ```mermaid blocks become diagrams (Mermaid.js); other code blocks as usual.
+const DefaultFenceRenderer = MarkdownRenderer.renderer.rules.fence;
+
+MarkdownRenderer.renderer.rules.fence = (Tokens, Index, Options, Env, Self) => {
+ const Token = Tokens[Index];
+ if (Token.info.trim().toLowerCase() === "mermaid") {
+ return DiagramHtml(Token.content, MarkdownRenderer.utils.escapeHtml);
+ }
+ return DefaultFenceRenderer(Tokens, Index, Options, Env, Self);
+};
+
const TextTypes = ["markdown", "text", "config", "html"];
const LinkedFileTypeMap = {
@@ -183,6 +195,7 @@ export default function PreviewPane({
const [IsSaving, SetIsSaving] = useState(false);
const [SaveError, SetSaveError] = useState(null);
const [BlameMap, SetBlameMap] = useState({});
+ const MarkdownBodyRef = useRef(null);
function RefreshResolvedContent() {
FetchResolvedFile(RootName, FilePath)
@@ -208,6 +221,11 @@ export default function PreviewPane({
}
}, [RootName, FilePath, FileType, ShowGitBlame]);
+ // Draw the rendered view's diagrams once its HTML is on the page.
+ useEffect(() => {
+ if (MarkdownBodyRef.current) RenderDiagramsIn(MarkdownBodyRef.current);
+ }, [ResolvedContent, FileType, IsEditing, MarkdownEditing, IsGuest]);
+
if (!FilePath) {
return
Select a file to preview.
;
}
@@ -334,6 +352,7 @@ export default function PreviewPane({
and list markers) hidden. Wherever the cursor is, the syntax comes back so
// it can be edited: a heading's #s on its line, a bold's ** inside it.
// The colours are the same roles as .MarkdownBody (Styles/Theme.css).
+// ```mermaid blocks show as their diagram until the cursor goes into them.
import { syntaxTree } from "@codemirror/language";
+import { StateField } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+import { RenderDiagram } from "./Mermaid.js";
+
class BulletWidget extends WidgetType {
eq() {
return true;
@@ -63,6 +67,42 @@ class CheckboxWidget extends WidgetType {
}
}
+// A ```mermaid block drawn as its diagram; clicking it puts the cursor in
+// the block, which brings the code back.
+class DiagramWidget extends WidgetType {
+ constructor(Source) {
+ super();
+ this.Source = Source;
+ }
+
+ eq(Other) {
+ return this.Source === Other.Source;
+ }
+
+ toDOM(View) {
+ const Element = document.createElement("div");
+ Element.className = "MermaidDiagram cm-LiveDiagram";
+ Element.dataset.source = this.Source;
+ Element.addEventListener("mousedown", (Event) => {
+ Event.preventDefault();
+ const Line = View.state.doc.lineAt(View.posAtDOM(Element));
+ const Inside = Math.min(Line.to + 1, View.state.doc.length);
+ View.dispatch({ selection: { anchor: Inside } });
+ View.focus();
+ });
+ RenderDiagram(Element);
+ return Element;
+ }
+
+ get estimatedHeight() {
+ return 200;
+ }
+
+ ignoreEvent() {
+ return true;
+ }
+}
+
const Hidden = Decoration.replace({});
const Bullet = Decoration.replace({ widget: new BulletWidget() });
const Rule = Decoration.replace({ widget: new RuleWidget() });
@@ -216,7 +256,45 @@ function BuildDecorations(View) {
return Decoration.set(Ranges, true);
}
+// Diagrams replace whole lines, which CodeMirror only allows from a state
+// field, not from the plugin above. Without focus info here, a block shows
+// as code while the cursor (or selection) is in it, even when unfocused.
+function BuildDiagrams(State) {
+ const Doc = State.doc;
+ const Ranges = [];
+
+ syntaxTree(State).iterate({
+ enter(Node) {
+ if (Node.name !== "FencedCode") return;
+ const Info = Node.node.getChild("CodeInfo");
+ if (!Info || Doc.sliceString(Info.from, Info.to).trim().toLowerCase() !== "mermaid") return false;
+
+ const From = Doc.lineAt(Node.from).from;
+ const To = Doc.lineAt(Node.to).to;
+ if (State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From)) return false;
+
+ const Code = Node.node.getChild("CodeText");
+ const Source = Code ? Doc.sliceString(Code.from, Code.to) : "";
+ Ranges.push(Decoration.replace({ widget: new DiagramWidget(Source), block: true }).range(From, To));
+ return false;
+ },
+ });
+
+ return Decoration.set(Ranges, true);
+}
+
+const Diagrams = StateField.define({
+ create: BuildDiagrams,
+ update(Current, Transaction) {
+ const TreeChanged = syntaxTree(Transaction.state) !== syntaxTree(Transaction.startState);
+ if (Transaction.docChanged || Transaction.selection || TreeChanged) return BuildDiagrams(Transaction.state);
+ return Current;
+ },
+ provide: (Field) => EditorView.decorations.from(Field),
+});
+
export const LivePreview = [
+ Diagrams,
ViewPlugin.fromClass(
class {
constructor(View) {
diff --git a/Frontend/src/Mermaid.js b/Frontend/src/Mermaid.js
new file mode 100644
index 0000000..8b78d13
--- /dev/null
+++ b/Frontend/src/Mermaid.js
@@ -0,0 +1,114 @@
+// Mermaid diagrams, like Obsidian's: a ```mermaid code block (flowchart,
+// sequence diagram, pie chart, ...) is drawn instead of shown as code, in both
+// the rendered view and the live editor. The colours and font come from the
+// theme's variables (ThemeRoles.js), so diagrams match whichever theme is on.
+// Mermaid itself is big, so it's only loaded once a note has a diagram.
+
+let MermaidModule = null;
+let DiagramCount = 0;
+
+function LoadMermaid() {
+ if (!MermaidModule) MermaidModule = import("mermaid").then((Module) => Module.default);
+ return MermaidModule;
+}
+
+function Variable(Name) {
+ return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim();
+}
+
+// Mermaid works out its own shades from these, so it needs the actual colours,
+// not var(--Role).
+function ThemeVariables() {
+ const Role = Variable;
+ const Background = Role("Background");
+ const Channels = (Background.replace("#", "").match(/../g) || ["00", "00", "00"]).map((Hex) => parseInt(Hex, 16));
+ const Luminance = (0.299 * Channels[0] + 0.587 * Channels[1] + 0.114 * Channels[2]) / 255;
+
+ return {
+ darkMode: Luminance < 0.5,
+ background: Background,
+ fontFamily: Role("FontFamily"),
+ fontSize: Role("FontSize"),
+ textColor: Role("Text"),
+ lineColor: Role("TextMuted"),
+ primaryColor: Role("SurfaceAlt"),
+ primaryTextColor: Role("Text"),
+ primaryBorderColor: Role("Purple"),
+ secondaryColor: Role("Surface"),
+ secondaryTextColor: Role("Text"),
+ secondaryBorderColor: Role("Blue"),
+ tertiaryColor: Role("Background"),
+ tertiaryTextColor: Role("Text"),
+ tertiaryBorderColor: Role("Border"),
+ mainBkg: Role("SurfaceAlt"),
+ nodeBorder: Role("Purple"),
+ clusterBkg: Role("Surface"),
+ clusterBorder: Role("Border"),
+ edgeLabelBackground: Role("Surface"),
+ titleColor: Role("Text"),
+ noteBkgColor: Role("Surface"),
+ noteTextColor: Role("Text"),
+ noteBorderColor: Role("Yellow"),
+ actorBkg: Role("SurfaceAlt"),
+ actorBorder: Role("Purple"),
+ actorTextColor: Role("Text"),
+ actorLineColor: Role("TextMuted"),
+ signalColor: Role("Text"),
+ signalTextColor: Role("Text"),
+ labelBoxBkgColor: Role("SurfaceAlt"),
+ labelBoxBorderColor: Role("Purple"),
+ labelTextColor: Role("Text"),
+ loopTextColor: Role("Text"),
+ activationBkgColor: Role("Surface"),
+ activationBorderColor: Role("Blue"),
+ pie1: Role("Blue"),
+ pie2: Role("Purple"),
+ pie3: Role("Green"),
+ pie4: Role("Yellow"),
+ pie5: Role("Orange"),
+ pie6: Role("Red"),
+ pieTitleTextColor: Role("Text"),
+ pieSectionTextColor: Role("Black"),
+ pieLegendTextColor: Role("Text"),
+ pieStrokeColor: Background,
+ pieOuterStrokeColor: Role("Border"),
+ };
+}
+
+// Draws one .MermaidDiagram element from its data-source.
+export async function RenderDiagram(Element) {
+ const Source = Element.dataset.source || "";
+ const Mermaid = await LoadMermaid();
+ // "strict" keeps diagrams from running scripts or adding click handlers:
+ // notes can come from other accounts.
+ Mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", themeVariables: ThemeVariables() });
+
+ try {
+ // parse() first: a broken diagram then throws without leaving Mermaid's
+ // error drawing in the page.
+ await Mermaid.parse(Source);
+ const { svg } = await Mermaid.render(`MermaidDiagram${++DiagramCount}`, Source);
+ Element.classList.remove("MermaidError");
+ Element.innerHTML = svg;
+ } catch (Error) {
+ Element.classList.add("MermaidError");
+ Element.textContent = `Diagram error: ${Error.message || Error}`;
+ }
+}
+
+export function RenderDiagramsIn(Container) {
+ Container.querySelectorAll(".MermaidDiagram").forEach(RenderDiagram);
+}
+
+// The HTML for a ```mermaid block in the rendered view; RenderDiagramsIn
+// draws it once it's on the page. The code shows until then.
+export function DiagramHtml(Source, EscapeHtml) {
+ return `
\n`;
+}
+
+// Redraw every diagram when the theme changes (App.jsx's ApplyTheme sets the
+// variables on ).
+new MutationObserver(() => RenderDiagramsIn(document)).observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["style"],
+});
diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css
index 5be3044..bba6ebf 100644
--- a/Frontend/src/Styles/Theme.css
+++ b/Frontend/src/Styles/Theme.css
@@ -481,6 +481,35 @@ img,
padding: 0;
}
+.MermaidDiagram {
+ display: flex;
+ justify-content: center;
+ margin: 12px 0;
+ overflow-x: auto;
+}
+
+.MermaidDiagram svg {
+ max-width: 100%;
+ height: auto;
+}
+
+.MermaidDiagram pre {
+ flex: 1;
+}
+
+.MermaidError {
+ justify-content: flex-start;
+ padding: 8px 12px;
+ border-radius: 4px;
+ background: var(--Surface);
+ color: var(--Red);
+ white-space: pre-wrap;
+}
+
+.LiveEditor .cm-LiveDiagram {
+ cursor: pointer;
+}
+
.MarkdownBody blockquote {
margin: 12px 0;
padding: 6px 12px;