Add mermaid tiptap extension

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-01 16:48:41 +04:00
parent 679c18c548
commit 9bbcba68c0
9 changed files with 236 additions and 20 deletions

16
package-lock.json generated
View File

@@ -2600,9 +2600,9 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz",
"integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"license": "MIT",
"dependencies": {
"langium": "^4.0.0"
@@ -14203,14 +14203,14 @@
}
},
"node_modules/mermaid": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz",
"integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==",
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.0.1",
"@mermaid-js/parser": "^1.1.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
@@ -19746,7 +19746,7 @@
"@tiptap/starter-kit": "^3.20.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"mermaid": "^11.13.0",
"mermaid": "^11.14.0",
"react-dropzone": "^14.3.8",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",

View File

@@ -31,7 +31,7 @@
"@tiptap/starter-kit": "^3.20.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"mermaid": "^11.13.0",
"mermaid": "^11.14.0",
"react-dropzone": "^14.3.8",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",

View File

@@ -10,7 +10,20 @@ import {
useFloating,
} from "@floating-ui/react";
import type { Icon } from "@phosphor-icons/react";
import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, QuotesIcon, TextHFourIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
import {
CodeBlockIcon,
GridFourIcon,
ListBulletsIcon,
ListNumbersIcon,
MinusIcon,
QuotesIcon,
TextHFourIcon,
TextHOneIcon,
TextHThreeIcon,
TextHTwoIcon,
TextTIcon,
TreeStructureIcon,
} from "@phosphor-icons/react";
import { type Editor } from "@tiptap/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
@@ -40,6 +53,7 @@ const BLOCK_ITEMS: BlockItem[] = [
{ label: "Ordered List", icon: ListNumbersIcon, action: chain => chain.toggleOrderedList() },
{ label: "Code Block", icon: CodeBlockIcon, action: chain => chain.toggleCodeBlock() },
{ label: "Blockquote", icon: QuotesIcon, action: chain => chain.toggleBlockquote() },
{ label: "Mermaid Diagram", icon: TreeStructureIcon, action: chain => chain.setCodeBlock({ language: "mermaid" }) },
{ label: "Divider", icon: MinusIcon, action: chain => chain.setHorizontalRule() },
{ label: "Table", icon: GridFourIcon, action: chain => chain.insertTable() },
];

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { CodeBlock } from "@tiptap/extension-code-block";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MermaidNodeView } from "./MermaidNodeView";
export const MermaidCodeBlock = CodeBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(MermaidNodeView);
},
});

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { CodeIcon, EyeIcon } from "@phosphor-icons/react";
import type { ReactNodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import mermaid from "mermaid";
import { useEffect, useId, useState } from "react";
type MermaidMode = "code" | "preview";
function MermaidPreview({ chart }: { chart: string }) {
const id = useId().replace(/:/g, "");
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const source = chart.trim();
useEffect(() => {
if (source.length === 0) return;
let cancelled = false;
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
mermaid
.render(`mermaid-editor-${id}`, source)
.then((result) => {
if (!cancelled) {
setSvg(result.svg);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [source, id]);
if (source.length === 0) {
return (
<div className="mermaid-empty">
No diagram to display
</div>
);
}
if (error) {
return (
<div className="mermaid-error">
{error}
</div>
);
}
if (!svg) {
return (
<div className="mermaid-empty">
Rendering...
</div>
);
}
return (
<div
className="mermaid-preview"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
export function MermaidNodeView({ node }: ReactNodeViewProps) {
const isMermaid = node.attrs.language === "mermaid";
if (!isMermaid) {
return (
<NodeViewWrapper as="pre">
<NodeViewContent<"code"> as="code" />
</NodeViewWrapper>
);
}
return <MermaidBlock node={node} />;
}
function MermaidBlock({ node }: { node: ReactNodeViewProps["node"] }) {
const hasContent = node.textContent.trim().length > 0;
const [mode, setMode] = useState<MermaidMode>(hasContent ? "preview" : "code");
return (
<NodeViewWrapper>
<div className="mermaid-block">
<div className="mermaid-toolbar">
<button
type="button"
className={`mermaid-toolbar-btn ${mode === "code" ? "active" : ""}`}
onClick={() => setMode("code")}
onMouseDown={e => e.preventDefault()}
>
<CodeIcon size={14} weight="bold" />
Code
</button>
<button
type="button"
className={`mermaid-toolbar-btn ${mode === "preview" ? "active" : ""}`}
onClick={() => setMode("preview")}
onMouseDown={e => e.preventDefault()}
>
<EyeIcon size={14} weight="bold" />
Preview
</button>
</div>
<pre className={mode === "preview" ? "hidden" : ""}>
<NodeViewContent<"code"> as="code" />
</pre>
{mode === "preview" && (
<MermaidPreview chart={node.textContent} />
)}
</div>
</NodeViewWrapper>
);
}

View File

@@ -5,7 +5,6 @@
import { Blockquote } from "@tiptap/extension-blockquote";
import { Bold } from "@tiptap/extension-bold";
import { Code } from "@tiptap/extension-code";
import { CodeBlock } from "@tiptap/extension-code-block";
import { Document } from "@tiptap/extension-document";
import { HardBreak } from "@tiptap/extension-hard-break";
import { Heading } from "@tiptap/extension-heading";
@@ -25,6 +24,7 @@ import { tv } from "tailwind-variants";
import { BlockMenu } from "./BlockMenu/BlockMenu";
import { BubbleMenu } from "./BubbleMenu";
import { LinkExtension } from "./LinkExtension";
import { MermaidCodeBlock } from "./MermaidExtension";
import { OptionsMenu } from "./OptionsMenu/OptionsMenu";
import { PlaceholderExtension } from "./PlaceholderExtension";
import { SlashCommandExtension } from "./SlashCommandExtension";
@@ -43,7 +43,7 @@ const extensions = [
Strike,
Underline,
Code,
CodeBlock,
MermaidCodeBlock,
LinkExtension,
SlashCommandExtension,
PlaceholderExtension,

View File

@@ -95,4 +95,42 @@
.tableWrapper {
@apply relative overflow-x-auto my-4;
}
.mermaid-block {
@apply my-4 border border-border-mid rounded-lg overflow-hidden;
pre {
@apply my-0 rounded-none border-none;
}
}
.mermaid-toolbar {
@apply flex gap-1 px-2 py-1.5 bg-subtle border-b border-border-mid;
}
.mermaid-toolbar-btn {
@apply flex items-center gap-1 px-2 py-1 text-xs font-medium rounded
text-txt-secondary cursor-pointer
hover:bg-border-solid transition-colors;
&.active {
@apply bg-border-solid text-txt-primary;
}
}
.mermaid-preview {
@apply flex justify-center p-6 min-h-24;
svg {
@apply max-w-full h-auto;
}
}
.mermaid-error {
@apply p-4 text-sm font-mono text-txt-danger;
}
.mermaid-empty {
@apply p-4 text-sm text-txt-tertiary text-center;
}
}

View File

@@ -71,15 +71,23 @@ func renderNode(buf *bytes.Buffer, n Node) error {
if err != nil {
return fmt.Errorf("cannot render code block node: %w", err)
}
buf.WriteString("<pre><code")
if attrs.Language != nil {
writeAttr(buf, "class", "language-"+*attrs.Language)
if attrs.Language != nil && *attrs.Language == "mermaid" {
buf.WriteString(`<pre class="mermaid">`)
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</pre>")
} else {
buf.WriteString("<pre><code")
if attrs.Language != nil {
writeAttr(buf, "class", "language-"+*attrs.Language)
}
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</code></pre>")
}
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</code></pre>")
case NodeHorizontalRule:
buf.WriteString("<hr>")
case NodeHardBreak:

View File

@@ -100,6 +100,18 @@ func TestRenderHTML_CodeBlockWithLanguage(t *testing.T) {
assert.Equal(t, `<pre><code class="language-go">fmt.Println()</code></pre>`, got)
}
func TestRenderHTML_CodeBlockMermaid(t *testing.T) {
t.Parallel()
raw := `{"type":"codeBlock","attrs":{"language":"mermaid"},"content":[{"type":"text","text":"graph TD\n A-->B"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, "<pre class=\"mermaid\">graph TD\n A--&gt;B</pre>", got)
}
func TestRenderHTML_CodeBlockWithoutLanguage(t *testing.T) {
t.Parallel()