Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions packages/ui/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/.storybook/preview.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
/* Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
*
* 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";
1 change: 0 additions & 1 deletion packages/ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/src/Atoms/Markdown/Markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>long time, so long<br/>that the text does<br/>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
\`\`\``,
},
};
155 changes: 112 additions & 43 deletions packages/ui/src/Atoms/Markdown/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGElement>(null);
const zoomRef = useRef<SvgPanZoom.Instance>(null);
const wrapper = useRef<HTMLDivElement>(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);
Comment thread
Grafikart marked this conversation as resolved.
};
const onSVGLoaded = useCallback((svg: SVGElement, zoom: SvgPanZoom.Instance) => {
svgRef.current = svg;
zoomRef.current = zoom;
style.current = svg.getAttribute("style") ?? "";
}, []);

if (!chart) {
return null;
}

return (
<div className={clsx("relative w-full")} ref={wrapper}>
<MermaidSVG
source={chart}
onSVG={onSVGLoaded}
/>
<div className={clsx("flex flex-col gap-2 absolute", fullscreen ? "bottom-4 right-4" : "bottom-0 right-0")}>
<Button
icon={fullscreen ? CornersInIcon : CornersOutIcon}
onClick={toggleFullscreen}
variant="secondary"
/>
<Button variant="secondary" icon={IconRotateCw} onClick={zoomReset} />
<Button variant="secondary" icon={IconPlusLarge} onClick={zoomIn} />
<Button variant="secondary" icon={IconMinusLarge} onClick={zoomOut} />
</div>
</div>
);
}

const MermaidSVG = memo(({ source, onSVG }: { source: string; onSVG: (el: SVGElement, zoom: SvgPanZoom.Instance) => void }) => {
const id = useId().replace(/:/g, "");
const [renderState, setRenderState] = useState<MermaidRenderState>({
source: "",
svg: null,
hasError: false,
});
const { toast } = useToast();
const [error, setError] = useState(false);
const div = useRef<HTMLDivElement>(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) {
Comment thread
Grafikart marked this conversation as resolved.
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);
Comment thread
Grafikart marked this conversation as resolved.
toast(mermaidRenderErrorToast);
});

return () => {
cancelled = true;
destroy();
};
}, [source, id, toast]);
}, [source, id, toast, onSVG]);

if (hasError) {
if (error) {
return (
<pre className="border border-border-solid rounded p-4 bg-transparent font-mono text-sm overflow-x-auto text-inherit">
<code>{chart}</code>
<code>{source}</code>
</pre>
);
}

return (
<div
className="flex justify-center my-4"
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
/>
<div id={id} className="contents" ref={div} />
);
}
});

MermaidSVG.displayName = "MermaidSVG";
Loading