From 292614c696a192868ce7d4b2a632a08d9d4b6144 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 10:51:27 +0200 Subject: [PATCH] Mermaid diagrams (flowcharts) in notes Co-Authored-By: Claude Opus 5.5 --- Frontend/package.json | 4 +- Frontend/src/Components/PreviewPane.jsx | 21 ++++- Frontend/src/LivePreview.js | 78 ++++++++++++++++ Frontend/src/Mermaid.js | 114 ++++++++++++++++++++++++ Frontend/src/Styles/Theme.css | 29 ++++++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 Frontend/src/Mermaid.js 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 `
${EscapeHtml(Source)}
\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;