Add mermaid tiptap extension
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() },
|
||||
];
|
||||
|
||||
14
packages/ui/src/RichEditor/MermaidExtension.ts
Normal file
14
packages/ui/src/RichEditor/MermaidExtension.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
130
packages/ui/src/RichEditor/MermaidNodeView.tsx
Normal file
130
packages/ui/src/RichEditor/MermaidNodeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-->B</pre>", got)
|
||||
}
|
||||
|
||||
func TestRenderHTML_CodeBlockWithoutLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user