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

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,