From 2e26f406e14d0a4b56be9950cd7ec89ad71953f8 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 11 Aug 2026 15:20:02 +0200 Subject: [PATCH 1/4] Add controls on mermaid diagrams Signed-off-by: Jonathan --- package-lock.json | 7 + packages/ui/package.json | 1 + .../src/Atoms/Markdown/Markdown.stories.tsx | 28 ++++ .../ui/src/Atoms/Markdown/MermaidDiagram.tsx | 155 +++++++++++++----- .../src/Atoms/Markdown/MermaidInteractions.ts | 142 ++++++++++++++++ 5 files changed, 290 insertions(+), 43 deletions(-) create mode 100644 packages/ui/src/Atoms/Markdown/MermaidInteractions.ts diff --git a/package-lock.json b/package-lock.json index 37cee1dd1c..0ff774d8fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20141,6 +20141,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pan-zoom": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/svg-pan-zoom/-/svg-pan-zoom-3.6.2.tgz", + "integrity": "sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==", + "license": "BSD-2-Clause" + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -22981,6 +22987,7 @@ "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "svg-pan-zoom": "^3.6.2", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.3.0", "tailwindcss": "^4.3.1", diff --git a/packages/ui/package.json b/packages/ui/package.json index f53f763d3d..1ce4944c86 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "svg-pan-zoom": "^3.6.2", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.3.0", "tailwindcss": "^4.3.1", diff --git a/packages/ui/src/Atoms/Markdown/Markdown.stories.tsx b/packages/ui/src/Atoms/Markdown/Markdown.stories.tsx index a94d2d8622..fbb9f5482f 100644 --- a/packages/ui/src/Atoms/Markdown/Markdown.stories.tsx +++ b/packages/ui/src/Atoms/Markdown/Markdown.stories.tsx @@ -94,3 +94,31 @@ certum: tempora, telisque. In quaesitique habitavit nostris Scylaceaque potest omnia pastoribus meminisse ignara. Sed pando functaque perenni gemitus tibi.`, }, }; + +export const Mermaid: Story = { + args: { + content: `You can render UML diagrams using [Mermaid](https://mermaidjs.github.io/). For example, this will produce a sequence diagram: + +\`\`\`mermaid +sequenceDiagram +Alice ->> Bob: Hello Bob, how are you? +Bob-->>John: How about you John? +Bob--x Alice: I am good thanks! +Bob-x John: I am good thanks! +Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. + +Bob-->Alice: Checking with John... +Alice->John: Yes... John, how are you? +\`\`\` + +And this will produce a flow chart: + +\`\`\`mermaid +graph LR +A[Square Rect] -- Link text --> B((Circle)) +A --> C(Round Rect) +B --> D{Rhombus} +C --> D +\`\`\``, + }, +}; diff --git a/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx b/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx index c229de85d3..cd1698613f 100644 --- a/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx +++ b/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx @@ -18,79 +18,148 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useEffect, useId, useState } from "react"; +import { CornersInIcon, CornersOutIcon } from "@phosphor-icons/react"; +import { clsx } from "clsx"; +import { memo, useCallback, useEffect, useId, useRef, useState } from "react"; +import svgPanZoom from "svg-pan-zoom"; import { mermaidRenderErrorToast, renderMermaidDiagram } from "../../lib/mermaid"; +import { Button } from "../Button/Button"; +import { IconMinusLarge, IconPlusLarge, IconRotateCw } from "../Icons"; import { useToast } from "../Toasts/Toasts"; +import { addMermaidInteractions } from "./MermaidInteractions"; + type Props = { chart: string; }; -type MermaidRenderState = { - source: string; - svg: string | null; - hasError: boolean; -}; - export function MermaidDiagram({ chart }: Props) { + const svgRef = useRef(null); + const zoomRef = useRef(null); + const wrapper = useRef(null); + const style = useRef(""); + const [fullscreen, setFullScreen] = useState(false); + + // Handle fullscreen state change + useEffect(() => { + function onFullscreenChange() { + const isFullScreen = document.fullscreenElement === wrapper.current; + svgRef.current?.setAttribute("style", isFullScreen ? "width: 100%; height: 100%;" : style.current); + zoomRef.current?.resize(); + zoomRef.current?.reset(); + setFullScreen(document.fullscreenElement === wrapper.current); + } + + document.addEventListener("fullscreenchange", onFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", onFullscreenChange); + }; + }, []); + + const zoomIn = () => { + zoomRef.current?.zoomIn(); + }; + const zoomOut = () => { + zoomRef.current?.zoomOut(); + }; + const zoomReset = () => { + zoomRef.current?.resetZoom(); + zoomRef.current?.resetPan(); + }; + const toggleFullscreen = () => { + if (document.fullscreenElement === wrapper.current) { + document.exitFullscreen().catch(console.error); + return + } + wrapper.current?.requestFullscreen().catch(console.error); + }; + const onSVGLoaded = useCallback((svg: SVGElement, zoom: SvgPanZoom.Instance) => { + svgRef.current = svg; + zoomRef.current = zoom; + style.current = svg.getAttribute("style") ?? ""; + }, []); + + if (!chart) { + return null; + } + + return ( +
+ +
+
+
+ ); +} + +const MermaidSVG = memo(({ source, onSVG }: { source: string; onSVG: (el: SVGElement, zoom: SvgPanZoom.Instance) => void }) => { const id = useId().replace(/:/g, ""); - const [renderState, setRenderState] = useState({ - source: "", - svg: null, - hasError: false, - }); const { toast } = useToast(); + const [error, setError] = useState(false); + const div = useRef(null); - const source = (chart ?? "").trim(); - const svg = renderState.source === source ? renderState.svg : null; - const hasError = renderState.source === source && renderState.hasError; - + // Load and render mermaid SVG useEffect(() => { if (!source) { return; } - - let cancelled = false; - + let destroy = () => {}; renderMermaidDiagram(`mermaid-${id}`, source) - .then((result) => { - if (!cancelled) { - setRenderState({ - source, - svg: result.svg, - hasError: false, - }); + .then((r) => { + const element = div.current; + if (!element || element.innerHTML) { + return; + } + element.innerHTML = r.svg; + r.bindFunctions?.(element); + const svg = element.firstElementChild; + if (!(svg instanceof SVGElement)) { + return; } + const { width, height } = svg.getBoundingClientRect(); + svg.style.aspectRatio = `${width}/${height}`; + svg.style.minHeight = "200px"; + const zoom = svgPanZoom(svg); + const removeInteractions = addMermaidInteractions(svg); + destroy = () => { + zoom.destroy(); + removeInteractions(); + }; + onSVG(svg, zoom); }) .catch(() => { - if (!cancelled) { - setRenderState({ - source, - svg: null, - hasError: true, - }); - toast(mermaidRenderErrorToast); - } + setError(true); + toast(mermaidRenderErrorToast); }); return () => { - cancelled = true; + destroy(); }; - }, [source, id, toast]); + }, [source, id, toast, onSVG]); - if (hasError) { + if (error) { return (
-        {chart}
+        {source}
       
); } return ( -
+
); -} +}); + +MermaidSVG.displayName = "MermaidSVG"; diff --git a/packages/ui/src/Atoms/Markdown/MermaidInteractions.ts b/packages/ui/src/Atoms/Markdown/MermaidInteractions.ts new file mode 100644 index 0000000000..d70153c589 --- /dev/null +++ b/packages/ui/src/Atoms/Markdown/MermaidInteractions.ts @@ -0,0 +1,142 @@ +const DIMMED_OPACITY = "0.3"; +const EMPHASIZED_OPACITY = "1"; + +type Edge = { + id: string; + elements: SVGElement[]; + nodeIDs: [string, string] | null; +}; + +// Extract Mermaid's logical node ID from either its data attribute or flowchart DOM ID. +function getNodeID(node: SVGElement) { + // Some Mermaid diagram types expose the logical ID directly. + const nodeID = node.getAttribute("data-id"); + if (nodeID) { + return nodeID; + } + + // Flowcharts encode it as "...-flowchart--". + const id = node.getAttribute("id"); + return id?.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1] ?? null; +} + +// Resolve an edge's endpoints by matching Mermaid's L___ ID. +function getEdgeNodeIDs(edgeID: string, nodeIDs: string[]) { + // Testing known IDs keeps node names containing underscores supported. + for (const sourceID of nodeIDs) { + for (const targetID of nodeIDs) { + if (edgeID.startsWith(`L_${sourceID}_${targetID}_`)) { + return [sourceID, targetID] as [string, string]; + } + } + } + + return null; +} + +export function addMermaidInteractions(svg: SVGElement) { + // Collect nodes and their logical IDs once + const nodes = [...svg.querySelectorAll(".node")]; + const nodeIDs = nodes.map(getNodeID).filter((id): id is string => id !== null); + const edgeElements = [...svg.querySelectorAll("[data-edge='true']")]; + // Index edges by ID so clicks on an edge label resolve to the rendered path as well. + const edgesByID = new Map(); + for (const element of edgeElements) { + const id = element.getAttribute("data-id"); + if (!id) { + continue; + } + + edgesByID.set(id, { + id, + elements: [ + element, + // Edge labels are separate SVG groups and must fade with their path. + ...svg.querySelectorAll(`.edgeLabels [data-id='${id}']`), + ], + nodeIDs: getEdgeNodeIDs(id, nodeIDs), + }); + } + + const visualElements = [ + ...nodes, + ...[...edgesByID.values()].flatMap(edge => edge.elements), + ]; + // Preserve Mermaid's inline styles so reset restores the exact initial rendering. + const initialStyles = new Map( + visualElements.map(element => [element, element.getAttribute("style")]), + ); + + // Restore all interactive elements to their original Mermaid styles. + function reset() { + for (const element of visualElements) { + const initialStyle = initialStyles.get(element); + if (initialStyle === null) { + element.removeAttribute("style"); + } else { + element.setAttribute("style", initialStyle ?? ""); + } + } + } + + // Dim the full graph, except for the supplied nodes and edges. + function emphasize(nodeIDsToEmphasize: Set, edgeIDsToEmphasize: Set) { + for (const node of nodes) { + const isEmphasized = nodeIDsToEmphasize.has(getNodeID(node) ?? ""); + node.style.opacity = isEmphasized ? EMPHASIZED_OPACITY : DIMMED_OPACITY; + } + + for (const edge of edgesByID.values()) { + const isEmphasized = edgeIDsToEmphasize.has(edge.id); + for (const element of edge.elements) { + element.style.opacity = isEmphasized ? EMPHASIZED_OPACITY : DIMMED_OPACITY; + } + } + } + + // Use one SVG-level listener so clicks on nested labels and shapes behave consistently. + function onClick(event: MouseEvent) { + if (!(event.target instanceof Element)) { + reset(); + return; + } + + const node = event.target.closest(".node"); + if (node instanceof SVGElement && svg.contains(node)) { + const nodeID = getNodeID(node); + if (!nodeID) { + reset(); + return; + } + + // A node selection includes every directly connected node and its incident edges. + const associatedEdges = [...edgesByID.values()].filter(edge => edge.nodeIDs?.includes(nodeID)); + emphasize( + new Set([nodeID, ...associatedEdges.flatMap(edge => edge.nodeIDs ?? [])]), + new Set(associatedEdges.map(edge => edge.id)), + ); + return; + } + + const edgeElement = event.target.closest("[data-edge='true'], .edgeLabels [data-id]"); + const edgeID = edgeElement?.getAttribute("data-id"); + const edge = edgeID ? edgesByID.get(edgeID) : undefined; + if (edge?.nodeIDs) { + // An edge selection only keeps that edge and its two endpoint nodes visible. + emphasize(new Set(edge.nodeIDs), new Set([edge.id])); + return; + } + + // Clicking the SVG background clears the active selection. + reset(); + } + + // Attach the delegated click handler after Mermaid has populated the SVG. + svg.addEventListener("click", onClick); + + // Let the caller remove the listener and restore the diagram when it unmounts. + return () => { + svg.removeEventListener("click", onClick); + reset(); + }; +} From 316407347231af459d2a41f6a96db7cce50301a9 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 12 Aug 2026 10:42:27 +0200 Subject: [PATCH 2/4] Add controls on mermaid diagrams Signed-off-by: Jonathan --- packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx b/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx index cd1698613f..2a00d6f2ed 100644 --- a/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx +++ b/packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx @@ -70,7 +70,7 @@ export function MermaidDiagram({ chart }: Props) { const toggleFullscreen = () => { if (document.fullscreenElement === wrapper.current) { document.exitFullscreen().catch(console.error); - return + return; } wrapper.current?.requestFullscreen().catch(console.error); }; From e2c0c982eb57e95a036eebe1d3aa3fd985d42ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 13 Aug 2026 11:33:16 +0200 Subject: [PATCH 3/4] Isolate v1 Storybook from the v2 kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook treats each stories[] entry as its own specifier, so "!../src/v2/**" never ignored the v2 tree. Importing theme.css from preview.tsx also compiled it without Tailwind, which made @apply my-2 fail. Signed-off-by: Émile Ré --- packages/ui/.storybook/main.ts | 9 ++++++--- packages/ui/.storybook/preview.css | 16 ++++++++++++++++ packages/ui/.storybook/preview.tsx | 1 - 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index e0ff054b9e..0b76fc4bb6 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -15,10 +15,13 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - // v2 components have their own theme-isolated Storybook (.storybook-v2). + // v2 has its own Storybook (.storybook-v2). A "!../src/v2/**" stories + // entry does not ignore; Storybook treats each item as its own specifier. stories: [ - "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)", - "!../src/v2/**", + "../src/Atoms/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../src/Molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../src/Layouts/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../src/*.stories.@(js|jsx|mjs|ts|tsx)", ], addons: [ import.meta.resolve("@chromatic-com/storybook"), diff --git a/packages/ui/.storybook/preview.css b/packages/ui/.storybook/preview.css index a82a2d77f8..93f7306d7d 100644 --- a/packages/ui/.storybook/preview.css +++ b/packages/ui/.storybook/preview.css @@ -1,5 +1,21 @@ +/* Copyright (c) 2025-2026 Probo Inc . + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + */ + @import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap"); @import "tailwindcss"; @import "tw-animate-css"; @import "../src/theme.css"; @source "../../helpers/src"; +@source not "../src/v2"; diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 17ffc7fcdf..74e0d701ef 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -16,7 +16,6 @@ import type { Preview } from "@storybook/react"; import { useEffect } from "react"; import { BrowserRouter } from "react-router"; -import "../src/theme.css"; import "./preview.css"; const preview: Preview = { From c0c79e476245ad388de25cedd174a07a1ce44f84 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 24 Aug 2026 10:15:56 +0200 Subject: [PATCH 4/4] Add mermaid interactive diagram on TipTap editor Signed-off-by: Jonathan --- .../ui/src/RichEditor/MermaidNodeView.tsx | 91 +------------------ packages/ui/src/rich-editor.css | 20 +--- 2 files changed, 8 insertions(+), 103 deletions(-) diff --git a/packages/ui/src/RichEditor/MermaidNodeView.tsx b/packages/ui/src/RichEditor/MermaidNodeView.tsx index 3c74debc08..44a457031a 100644 --- a/packages/ui/src/RichEditor/MermaidNodeView.tsx +++ b/packages/ui/src/RichEditor/MermaidNodeView.tsx @@ -5,97 +5,12 @@ import { CodeIcon, EyeIcon } from "@phosphor-icons/react"; import type { ReactNodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; -import { useEffect, useId, useState } from "react"; +import { useState } from "react"; -import { useToast } from "../Atoms/Toasts/Toasts"; -import { mermaidRenderErrorToast, renderMermaidDiagram } from "../lib/mermaid"; +import { MermaidDiagram } from "../Atoms/Markdown/MermaidDiagram"; type MermaidMode = "code" | "preview"; -type MermaidRenderState = { - source: string; - svg: string | null; - hasError: boolean; -}; - -function MermaidPreview({ chart }: { chart: string }) { - const id = useId().replace(/:/g, ""); - const [renderState, setRenderState] = useState({ - source: "", - svg: null, - hasError: false, - }); - const { toast } = useToast(); - - const source = chart.trim(); - const svg = renderState.source === source ? renderState.svg : null; - const hasError = renderState.source === source && renderState.hasError; - - useEffect(() => { - if (source.length === 0) { - return; - } - - let cancelled = false; - - renderMermaidDiagram(`mermaid-editor-${id}`, source) - .then((result) => { - if (!cancelled) { - setRenderState({ - source, - svg: result.svg, - hasError: false, - }); - } - }) - .catch(() => { - if (!cancelled) { - setRenderState({ - source, - svg: null, - hasError: true, - }); - toast(mermaidRenderErrorToast); - } - }); - - return () => { - cancelled = true; - }; - }, [source, id, toast]); - - if (source.length === 0) { - return ( -
- No diagram to display -
- ); - } - - if (hasError) { - return ( -
- Unable to render diagram. Check the syntax and try again. -
- ); - } - - if (!svg) { - return ( -
- Rendering... -
- ); - } - - return ( -
- ); -} - export function MermaidNodeView({ node }: ReactNodeViewProps) { const isMermaid = node.attrs.language === "mermaid"; @@ -143,7 +58,7 @@ function MermaidBlock({ node }: { node: ReactNodeViewProps["node"] }) { {mode === "preview" && ( - + )}
diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index 2631eafb57..752395f7f1 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -103,6 +103,11 @@ pre { @apply my-0 rounded-lg border-none; } + + svg { + max-width: none!important; + aspect-ratio: none!important; + } } .mermaid-toolbar { @@ -123,19 +128,4 @@ } } - .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; - } }