Mermaid diagrams (flowcharts) in notes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 10:51:27 +02:00
1 parent b8609363b7
commit 292614c696
5 files changed
+244 -2

No files matched your search

+3 -1
View File
@@ -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",
+20 -1
View File
@@ -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 <div className="PreviewEmpty">Select a file to preview.</div>;
}
@@ -334,6 +352,7 @@ export default function PreviewPane({
</div>
<div
className="MarkdownBody"
ref={MarkdownBodyRef}
onClick={HandleMarkdownClick}
dangerouslySetInnerHTML={{
__html: MarkdownRenderer.render(ResolvedContent, {
+78
View File
@@ -3,10 +3,14 @@
// > 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) {
+114
View File
@@ -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 `<div class="MermaidDiagram" data-source="${EscapeHtml(Source)}"><pre><code>${EscapeHtml(Source)}</code></pre></div>\n`;
}
// Redraw every diagram when the theme changes (App.jsx's ApplyTheme sets the
// variables on <html>).
new MutationObserver(() => RenderDiagramsIn(document)).observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
+29
View File
@@ -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;