mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Mermaid diagrams (flowcharts) in notes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b8609363b7
commit
292614c696
5 files changed
+244
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user