diff --git a/package-lock.json b/package-lock.json index 8cdbc7180..ea9edcd22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2ee69f0f9..d8bea4ddc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx index c6363ccfc..d5341d2af 100644 --- a/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx @@ -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() }, ]; diff --git a/packages/ui/src/RichEditor/MermaidExtension.ts b/packages/ui/src/RichEditor/MermaidExtension.ts new file mode 100644 index 000000000..435208753 --- /dev/null +++ b/packages/ui/src/RichEditor/MermaidExtension.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Probo Inc . +// 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); + }, +}); diff --git a/packages/ui/src/RichEditor/MermaidNodeView.tsx b/packages/ui/src/RichEditor/MermaidNodeView.tsx new file mode 100644 index 000000000..d9e2df89f --- /dev/null +++ b/packages/ui/src/RichEditor/MermaidNodeView.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Probo Inc . +// 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(null); + const [error, setError] = useState(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 ( +
+ No diagram to display +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!svg) { + return ( +
+ Rendering... +
+ ); + } + + return ( +
+ ); +} + +export function MermaidNodeView({ node }: ReactNodeViewProps) { + const isMermaid = node.attrs.language === "mermaid"; + + if (!isMermaid) { + return ( + + as="code" /> + + ); + } + + return ; +} + +function MermaidBlock({ node }: { node: ReactNodeViewProps["node"] }) { + const hasContent = node.textContent.trim().length > 0; + const [mode, setMode] = useState(hasContent ? "preview" : "code"); + + return ( + +
+
+ + +
+ +
+           as="code" />
+        
+ + {mode === "preview" && ( + + )} +
+
+ ); +} diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 4984d117e..d88fd45ce 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -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, diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index acffec08d..2e8d66241 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -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; + } } diff --git a/pkg/prosemirror/html.go b/pkg/prosemirror/html.go index b42ec85a8..f4383fc18 100644 --- a/pkg/prosemirror/html.go +++ b/pkg/prosemirror/html.go @@ -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("
`)
+			if err := renderChildren(buf, n.Content); err != nil {
+				return err
+			}
+			buf.WriteString("
") + } else { + buf.WriteString("
')
+			if err := renderChildren(buf, n.Content); err != nil {
+				return err
+			}
+			buf.WriteString("
") } - buf.WriteByte('>') - if err := renderChildren(buf, n.Content); err != nil { - return err - } - buf.WriteString("") case NodeHorizontalRule: buf.WriteString("
") case NodeHardBreak: diff --git a/pkg/prosemirror/html_test.go b/pkg/prosemirror/html_test.go index 9b7a5e9c1..8333383af 100644 --- a/pkg/prosemirror/html_test.go +++ b/pkg/prosemirror/html_test.go @@ -100,6 +100,18 @@ func TestRenderHTML_CodeBlockWithLanguage(t *testing.T) { assert.Equal(t, `
fmt.Println()
`, 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, "
graph TD\n  A-->B
", got) +} + func TestRenderHTML_CodeBlockWithoutLanguage(t *testing.T) { t.Parallel()