Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"jszip": "3.10.1",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"mermaid": "^11.16.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
36 changes: 25 additions & 11 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import {
BrowserPreviewUnavailableError,
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { MermaidDiagram } from "./MermaidDiagram";

interface ChatMarkdownProps {
text: string;
Expand Down Expand Up @@ -897,7 +898,7 @@ function MarkdownCodeBlock({
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
className="chat-markdown-chrome-action chat-markdown-wrap-action"
aria-pressed={wrapped}
onClick={() => setWrapped((value) => !value)}
aria-label={wrapLabel}
Expand Down Expand Up @@ -2713,23 +2714,36 @@ function ChatMarkdown({

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
const codeFallback = (
<RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
);
const renderMermaid = !isStreaming && language.toLowerCase() === "mermaid";
return (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
>
<RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
{renderMermaid ? (
<MermaidDiagram
key={codeBlock.code}
code={codeBlock.code}
theme={resolvedTheme}
fallback={codeFallback}
/>
) : (
codeFallback
)}
</MarkdownCodeBlock>
);
},
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/MermaidDiagram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mermaid = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));

vi.mock("mermaid", () => ({ default: mermaid }));

import { renderMermaidDiagram } from "./MermaidDiagram";
import { serializeMarkdownCodeFence } from "../markdown-clipboard";

describe("renderMermaidDiagram", () => {
beforeEach(() => {
mermaid.initialize.mockReset();
mermaid.render.mockReset();
});

it("renders with strict security and the selected theme", async () => {
mermaid.render.mockResolvedValue({ svg: "<svg />" });

await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark");

expect(mermaid.initialize).toHaveBeenCalledWith({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"themeCSS",
"fontFamily",
"altFontFamily",
],
theme: "dark",
});
expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B");
});

it("continues rendering after an invalid diagram", async () => {
mermaid.render
.mockRejectedValueOnce(new Error("Invalid diagram"))
.mockResolvedValueOnce({ svg: "<svg />" });

await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow();
await expect(
renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"),
).resolves.toEqual({ svg: "<svg />" });
});

it("skips queued work after its diagram unmounts", async () => {
let finishFirstRender!: (result: { svg: string }) => void;
mermaid.render.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirstRender = resolve;
}),
);

const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light");
await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1));
const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false);
finishFirstRender({ svg: "<svg />" });

await first;
await expect(second).resolves.toBeNull();
expect(mermaid.render).toHaveBeenCalledTimes(1);
});

it("chooses a fence longer than backtick runs in copied source", () => {
expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe(
"````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n",
);
});
});
105 changes: 105 additions & 0 deletions apps/web/src/components/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { RenderResult } from "mermaid";

import { serializeMarkdownCodeFence } from "../markdown-clipboard";

type MermaidTheme = "light" | "dark";

// Mermaid configuration is global, so initialization and rendering must stay paired.
let mermaidRenderQueue = Promise.resolve();

export function renderMermaidDiagram(
id: string,
code: string,
theme: MermaidTheme,
isActive: () => boolean = () => true,
) {
const render = async () => {
if (!isActive()) return null;
const { default: mermaid } = await import("mermaid");
if (!isActive()) return null;
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"themeCSS",
"fontFamily",
"altFontFamily",
],
theme: theme === "dark" ? "dark" : "default",
});
return mermaid.render(id, code);
};

const result = mermaidRenderQueue.then(render, render);
mermaidRenderQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}

export function MermaidDiagram({
code,
theme,
fallback,
}: {
code: string;
theme: MermaidTheme;
fallback: ReactNode;
}) {
const reactId = useId();
const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const renderSequenceRef = useRef(0);
const diagramRef = useRef<HTMLDivElement>(null);
const [renderedDiagram, setRenderedDiagram] = useState<{
code: string;
theme: MermaidTheme;
result: RenderResult;
} | null>(null);

useEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
let active = true;
const renderId = `${diagramId}-${renderSequenceRef.current++}`;
void renderMermaidDiagram(renderId, code, theme, () => active).then(
(nextResult) => {
if (active && nextResult) setRenderedDiagram({ code, theme, result: nextResult });
},
() => undefined,
);
return () => {
active = false;
};
}, [code, diagramId, theme]);

useLayoutEffect(() => {
const svg = diagramRef.current?.querySelector("svg");
const width = svg?.viewBox.baseVal.width ?? 0;
if (svg && Number.isFinite(width) && width > 0) {
svg.style.width = `${Math.ceil(width)}px`;
svg.style.maxWidth = "none";
}
});

const result =
renderedDiagram?.code === code && renderedDiagram.theme === theme
? renderedDiagram.result
: null;
if (!result) return fallback;

return (
<div
ref={diagramRef}
className="chat-markdown-mermaid"
data-markdown-copy={serializeMarkdownCodeFence(code, "mermaid")}
Comment thread
adamblumoff marked this conversation as resolved.
dangerouslySetInnerHTML={{ __html: result.svg }}
/>
Comment thread
adamblumoff marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
);
}
17 changes: 17 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,23 @@ code {
background: transparent !important;
}

.chat-markdown .chat-markdown-mermaid {
max-width: 100%;
overflow-x: auto;
padding: 1rem;
}

.chat-markdown .chat-markdown-codeblock:has(.chat-markdown-mermaid) .chat-markdown-wrap-action {
display: none;
}

.chat-markdown .chat-markdown-mermaid svg {
display: block;
max-width: none;
height: auto;
margin: 0 auto;
}

/* Diagnostics-style tables: row separators only, uppercase headers, and a
scroll-fade container for horizontal overflow. The root chat-markdown
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
Expand Down
51 changes: 48 additions & 3 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
import {
chatMarkdownClipboardPayload,
serializeRenderedMarkdownFragment,
} from "./markdown-clipboard";
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import {
collectAssistantCitations,
Expand All @@ -13,13 +16,15 @@ const ELEMENT_NODE = 1;
class FakeText {
readonly nodeType = TEXT_NODE;
readonly childNodes: ReadonlyArray<never> = [];
parentElement: FakeElement | null = null;

constructor(readonly textContent: string) {}
}

class FakeElement {
readonly nodeType = ELEMENT_NODE;
readonly childNodes: Array<FakeElement | FakeText> = [];
parentElement: FakeElement | null = null;
readonly classList = {
contains: (name: string) => this.classNames.includes(name),
};
Expand All @@ -43,10 +48,16 @@ class FakeElement {
}

append(...children: Array<FakeElement | FakeText>): this {
for (const child of children) child.parentElement = this;
this.childNodes.push(...children);
return this;
}

appendChild<T extends FakeElement | FakeText>(child: T): T {
this.append(child);
return child;
}

getAttribute(name: string): string | null {
return this.attributes[name] ?? null;
}
Expand All @@ -55,8 +66,20 @@ class FakeElement {
return Object.hasOwn(this.attributes, name);
}

closest(): FakeElement | null {
return null;
closest(selector: string): FakeElement | null {
if (selector === "[data-markdown-copy]" && this.hasAttribute("data-markdown-copy")) {
return this;
}
if (this.tagName === selector.toUpperCase()) return this;
return this.parentElement?.closest(selector) ?? null;
}

querySelectorAll(): ReadonlyArray<FakeElement> {
return [];
}

get innerHTML(): string {
return this.textContent;
}

/** Supports only the selectors markdown-clipboard actually asks for. */
Expand Down Expand Up @@ -262,4 +285,26 @@ describe("serializeRenderedMarkdownFragment", () => {
"Hello World (Document template)",
);
});

it("uses an ancestor's explicit Markdown when a selection only clones its children", () => {
const label = new FakeElement("TEXT").append(new FakeText("A"));
new FakeElement("DIV", [], {
"data-markdown-copy": "```mermaid\nflowchart LR\nA-->B\n```\n\n",
}).append(new FakeElement("SVG").append(label));
vi.stubGlobal("document", {
createElement: () => new FakeElement("DIV"),
});

const payload = chatMarkdownClipboardPayload({
rangeCount: 1,
getRangeAt: () => ({
collapsed: false,
cloneContents: () => new FakeElement("SVG").append(new FakeText("A")),
commonAncestorContainer: label,
toString: () => "A",
}),
} as unknown as Selection);

expect(payload?.text).toBe("```mermaid\nflowchart LR\nA-->B\n```\n\n");
});
});
Loading
Loading