From 3d32ac79896b3ee596bb8917d1f8c7520bd7a342 Mon Sep 17 00:00:00 2001 From: Carson <88074703+c-w-xiaohei@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:43:23 +0800 Subject: [PATCH] feat(chat): add Mermaid diagram zoom controls (#2100) * feat(chat): add mermaid diagram zoom controls * fix(chat): handle malformed mermaid data urls * fix(chat): preserve mermaid load error stack --------- Co-authored-by: Bohdan Triapitsyn --- .../components/chat/MarkdownRendererImpl.tsx | 142 ++++-- .../src/components/chat/markdown/decorate.ts | 91 +++- .../chat/markdown/mermaidViewer.test.ts | 198 ++++++++ .../components/chat/markdown/mermaidViewer.ts | 467 ++++++++++++++++++ .../chat/message/ToolOutputDialog.test.ts | 31 ++ .../chat/message/ToolOutputDialog.tsx | 60 ++- .../chat/message/toolOutputDialogMermaid.ts | 37 ++ packages/ui/src/index.css | 58 ++- packages/ui/src/lib/i18n/messages/en.ts | 10 + packages/ui/src/lib/i18n/messages/es.ts | 10 + packages/ui/src/lib/i18n/messages/fr.ts | 10 + packages/ui/src/lib/i18n/messages/ja.ts | 10 + packages/ui/src/lib/i18n/messages/ko.ts | 10 + packages/ui/src/lib/i18n/messages/pl.ts | 10 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 10 + packages/ui/src/lib/i18n/messages/uk.ts | 10 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 10 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 10 + 18 files changed, 1069 insertions(+), 115 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/mermaidViewer.test.ts create mode 100644 packages/ui/src/components/chat/markdown/mermaidViewer.ts create mode 100644 packages/ui/src/components/chat/message/ToolOutputDialog.test.ts create mode 100644 packages/ui/src/components/chat/message/toolOutputDialogMermaid.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 77b7ecbe..f88450cb 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -29,8 +29,10 @@ import { syncMarkdownCodeLineNumbers, type DecorateContext, type DecorateLabels, + type MermaidControlOptions, type MermaidRender, } from './markdown/decorate'; +import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer'; import { BLOCK_PATH_TOKEN_RE, isAbsoluteReferencePath, @@ -103,27 +105,12 @@ const useExternalLinkInteractions = ({ }, [containerRef, enabled]); }; -type MermaidControlOptions = { - download: boolean; - copy: boolean; - fullscreen: boolean; - panZoom: boolean; -}; - -const extractMermaidBlocks = (markdown: string): string[] => { - if (!markdown.includes('mermaid')) return []; - const blocks: string[] = []; - const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi; - let match: RegExpExecArray | null = regex.exec(markdown); - - while (match) { - const block = (match[2] ?? '').replace(/\s+$/, ''); - blocks.push(block); - match = regex.exec(markdown); - } - - return blocks; +const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = { + download: true, + copy: true, + showPanZoomControls: true, }; +const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true; const stripLeadingFrontmatter = (markdown: string): string => { const frontmatterMatch = markdown.match( @@ -153,7 +140,6 @@ interface MarkdownRendererProps { enableFileReferences?: boolean; } -const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; @@ -666,14 +652,16 @@ const useFileReferenceInteractions = ({ const useMermaidInlineInteractions = ({ containerRef, - mermaidBlocks, onShowPopup, - allowWheelZoom, + enableFullscreen, + enablePanZoom, + allowMermaidWheelEvents, }: { containerRef: React.RefObject; - mermaidBlocks: string[]; onShowPopup?: (content: ToolPopupContent) => void; - allowWheelZoom?: boolean; + enableFullscreen?: boolean; + enablePanZoom?: boolean; + allowMermaidWheelEvents?: boolean; }) => { React.useEffect(() => { const container = containerRef.current; @@ -682,7 +670,7 @@ const useMermaidInlineInteractions = ({ } const handleMermaidClick = (event: MouseEvent) => { - if (!onShowPopup) { + if (!enableFullscreen || !onShowPopup) { return; } @@ -700,13 +688,18 @@ const useMermaidInlineInteractions = ({ return; } - const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR)); - const blockIndex = renderedBlocks.indexOf(block); + if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) { + block.removeAttribute('data-mermaid-suppress-click'); + return; + } + + const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR)); + const blockIndex = renderedBlocks.indexOf(block as HTMLElement); if (blockIndex < 0) { return; } - const source = mermaidBlocks[blockIndex]; + const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null; if (!source || source.trim().length === 0) { return; } @@ -729,7 +722,7 @@ const useMermaidInlineInteractions = ({ }; const handleInlineWheel = (event: WheelEvent) => { - if (allowWheelZoom) { + if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) { return; } @@ -754,7 +747,7 @@ const useMermaidInlineInteractions = ({ container.removeEventListener('click', handleMermaidClick); container.removeEventListener('wheel', handleInlineWheel, true); }; - }, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]); + }, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]); }; // --------------------------------------------------------------------------- @@ -862,17 +855,21 @@ const useDecorateContext = ( currentTheme: Theme, deferCodeLineNumberSync: boolean, onPreviewLoopback?: (url: string) => void, + mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS, ): DecorateContext => { const { t } = useI18n(); const labels: DecorateLabels = React.useMemo(() => ({ - copy: 'Copy code', - copied: 'Copied', + copy: t('markdownRenderer.code.actions.copyTitle'), + copied: t('markdownRenderer.code.actions.copiedTitle'), enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'), disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'), copyTable: t('markdownRenderer.table.actions.copyTitle'), downloadTable: t('markdownRenderer.table.actions.downloadTitle'), copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'), downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'), + zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'), + zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'), + resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'), previewLabel: t('terminalView.preview.open'), previewTitle: t('terminalView.preview.openTitle'), }), [t]); @@ -896,8 +893,8 @@ const useDecorateContext = ( return {}; } }); - return { labels, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback }; - }, [currentTheme, labels, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]); + return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback }; + }, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]); }; // Runs the async render pipeline into the container and keeps a stable @@ -921,6 +918,22 @@ const useMorphdomMarkdown = ({ ensureMarkdownShikiTheme(); }, []); + const mermaidViewerRef = React.useRef | null>(null); + const refreshMermaidViewers = React.useCallback(() => { + const container = containerRef.current; + if (!container) { + return; + } + if (!mermaidViewerRef.current) { + if (!shouldRefreshMermaidViewers(container)) { + return; + } + mermaidViewerRef.current = createMermaidViewerRegistry(container); + return; + } + mermaidViewerRef.current.refresh(); + }, [containerRef]); + // Synchronous first paint: while the async parse is in-flight, show escaped // plain text immediately so there is no blank frame on initial mount. Only // runs when the target is empty — subsequent updates keep the prior rich DOM @@ -944,8 +957,16 @@ const useMorphdomMarkdown = ({ // the structure here keeps the async morph to syntax colors only. decorateMarkdown(block, ctx); target.appendChild(block); + if (shouldRefreshMermaidViewers(block)) { + refreshMermaidViewers(); + } } - }, [containerRef, text, ctx]); + }, [containerRef, text, ctx, refreshMermaidViewers]); + + React.useEffect(() => () => { + mermaidViewerRef.current?.cleanup(); + mermaidViewerRef.current = null; + }, []); React.useEffect(() => { const container = containerRef.current; @@ -973,16 +994,30 @@ const useMorphdomMarkdown = ({ const temp = document.createElement('div'); temp.innerHTML = block.html; decorateMarkdown(temp, ctx); + const hadMermaidBlock = shouldRefreshMermaidViewers(el); + const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp); morphdom(el, temp, { childrenOnly: true, onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl), }); el.setAttribute('data-md-id', block.id); + if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) { + refreshMermaidViewers(); + } }); // Remove any trailing block elements no longer present. + const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target); + let removedMermaidBlock = false; for (let i = existing.length - 1; i >= blocks.length; i -= 1) { - existing[i]?.remove(); + const removed = existing[i]; + if (removed && shouldRefreshMermaidViewers(removed)) { + removedMermaidBlock = true; + } + removed?.remove(); + } + if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) { + refreshMermaidViewers(); } if (!ctx.deferCodeLineNumberSync) { @@ -993,7 +1028,7 @@ const useMorphdomMarkdown = ({ return () => { active = false; }; - }, [containerRef, text, streaming, cacheKey, ctx]); + }, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]); React.useEffect(() => { const container = containerRef.current; @@ -1073,8 +1108,12 @@ const MarkdownRendererImpl: React.FC = ({ const live = isStreaming && !disableStreamAnimation; const pacedText = usePacedText(content, live); - const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); - useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup }); + useMermaidInlineInteractions({ + containerRef, + onShowPopup, + enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED, + enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls, + }); useFileReferenceInteractions({ containerRef, effectiveDirectory, @@ -1085,7 +1124,7 @@ const MarkdownRendererImpl: React.FC = ({ useExternalLinkInteractions({ containerRef }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); - const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined); + const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx }); @@ -1129,7 +1168,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ stripFrontmatter?: boolean; onShowPopup?: (content: ToolPopupContent) => void; mermaidControls?: MermaidControlOptions; - allowMermaidWheelZoom?: boolean; + allowMermaidWheelEvents?: boolean; enableFileReferences?: boolean; }> = ({ content, @@ -1138,7 +1177,8 @@ const SimpleMarkdownRendererImpl: React.FC<{ disableLinkSafety, stripFrontmatter = false, onShowPopup, - allowMermaidWheelZoom = false, + mermaidControls = DEFAULT_MERMAID_CONTROLS, + allowMermaidWheelEvents = false, enableFileReferences = true, }) => { const { editor, runtime } = useRuntimeAPIs(); @@ -1151,12 +1191,12 @@ const SimpleMarkdownRendererImpl: React.FC<{ [content, stripFrontmatter], ); - const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]); useMermaidInlineInteractions({ containerRef, - mermaidBlocks, onShowPopup, - allowWheelZoom: allowMermaidWheelZoom, + enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED, + enablePanZoom: mermaidControls.showPanZoomControls, + allowMermaidWheelEvents, }); useFileReferenceInteractions({ containerRef, @@ -1168,7 +1208,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); - const ctx = useDecorateContext(currentTheme, false); + const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); useMorphdomMarkdown({ containerRef, @@ -1187,12 +1227,18 @@ const SimpleMarkdownRendererImpl: React.FC<{ }; export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => { + const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS; + const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS; + return prev.content === next.content && prev.variant === next.variant && prev.className === next.className && prev.disableLinkSafety === next.disableLinkSafety && prev.stripFrontmatter === next.stripFrontmatter && prev.onShowPopup === next.onShowPopup - && prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom + && prevMermaidControls.download === nextMermaidControls.download + && prevMermaidControls.copy === nextMermaidControls.copy + && prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls + && prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents && prev.enableFileReferences === next.enableFileReferences; }); diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index a4d6495c..6af9efe7 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -1,6 +1,8 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl } from '@/lib/url'; import { dropdownMenuItemClass, dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles'; +import type { IconName } from '@/components/icon/icons'; +import { getMermaidViewerController } from './mermaidViewer'; // --------------------------------------------------------------------------- // Shared decoration context @@ -17,12 +19,22 @@ export type DecorateLabels = { downloadTable: string; copyDiagram: string; downloadDiagram: string; + zoomInDiagram: string; + zoomOutDiagram: string; + resetDiagramView: string; previewLabel: string; previewTitle: string; }; +export type MermaidControlOptions = { + download: boolean; + copy: boolean; + showPanZoomControls: boolean; +}; + export type DecorateContext = { labels: DecorateLabels; + mermaidControls: MermaidControlOptions; codeBlockLineWrap: boolean; deferCodeLineNumberSync?: boolean; onToggleCodeBlockLineWrap?: () => void; @@ -34,20 +46,23 @@ export type DecorateContext = { // Reference the app's icon sprite (injected into by the shared Icon // component) so DOM-built controls use the same themed icons as the rest of // the app. Sprite symbols are registered under `#oc-`. -const spriteIcon = (name: string): string => +const spriteIcon = (name: IconName): string => ``; const ICONS = { copy: spriteIcon('file-copy'), check: spriteIcon('check'), download: spriteIcon('download'), + zoomIn: spriteIcon('add'), + zoomOut: spriteIcon('subtract'), + fit: spriteIcon('refresh'), textWrap: spriteIcon('text-wrap'), } as const; const ICON_BTN_CLASS = - 'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors'; + 'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]'; -const setHtml = (el: Element, html: string): void => { +const setIconHtml = (el: Element, html: string): void => { el.innerHTML = html; }; @@ -58,7 +73,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): button.setAttribute('data-md-action', slot); button.setAttribute('title', title); button.setAttribute('aria-label', title); - setHtml(button, ICONS[icon]); + setIconHtml(button, ICONS[icon]); return button; }; @@ -207,11 +222,13 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool }; const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => { - setHtml(button, ICONS.check); + setIconHtml(button, ICONS.check); button.setAttribute('title', copiedTitle); + button.setAttribute('aria-label', copiedTitle); window.setTimeout(() => { - setHtml(button, ICONS[restore]); + setIconHtml(button, ICONS[restore]); button.setAttribute('title', restoreTitle); + button.setAttribute('aria-label', restoreTitle); }, 2000); }; @@ -410,33 +427,52 @@ const decorateMermaid = (root: HTMLElement, ctx: DecorateContext): void => { const block = document.createElement('div'); block.setAttribute('data-markdown', 'mermaid-block'); + block.setAttribute('data-md-source', source); block.className = 'group relative'; const scroll = document.createElement('div'); scroll.setAttribute('data-markdown', 'mermaid-scroll'); const toolbar = document.createElement('div'); - toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity'; + toolbar.setAttribute('data-markdown', 'mermaid-toolbar'); + toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity'; if (rendered.svg) { + block.setAttribute('data-mermaid-render', 'svg'); + const viewport = document.createElement('div'); + viewport.setAttribute('data-markdown', 'mermaid-viewport'); const svgHost = document.createElement('div'); svgHost.setAttribute('data-markdown', 'mermaid'); - setHtml(svgHost, rendered.svg); - scroll.appendChild(svgHost); - const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy'); - copy.setAttribute('data-md-source', source); - const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download'); - download.setAttribute('data-md-svg', '1'); - toolbar.appendChild(copy); - toolbar.appendChild(download); + svgHost.setAttribute('data-md-original-svg', rendered.svg); + svgHost.innerHTML = rendered.svg; + viewport.appendChild(svgHost); + scroll.appendChild(viewport); + if (ctx.mermaidControls.showPanZoomControls) { + toolbar.appendChild(makeIconButton('zoomIn', ctx.labels.zoomInDiagram, 'mermaid-zoom-in')); + toolbar.appendChild(makeIconButton('zoomOut', ctx.labels.zoomOutDiagram, 'mermaid-zoom-out')); + toolbar.appendChild(makeIconButton('fit', ctx.labels.resetDiagramView, 'mermaid-fit')); + } + if (ctx.mermaidControls.copy) { + const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy'); + copy.setAttribute('data-md-source', source); + toolbar.appendChild(copy); + } + if (ctx.mermaidControls.download) { + const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download'); + download.setAttribute('data-md-svg', '1'); + toolbar.appendChild(download); + } } else { + block.setAttribute('data-mermaid-render', 'ascii'); const asciiPre = document.createElement('pre'); asciiPre.setAttribute('data-markdown', 'mermaid-ascii'); asciiPre.textContent = rendered.ascii || source; scroll.appendChild(asciiPre); - const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy'); - copy.setAttribute('data-md-source', rendered.ascii || source); - toolbar.appendChild(copy); + if (ctx.mermaidControls.copy) { + const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy'); + copy.setAttribute('data-md-source', rendered.ascii || source); + toolbar.appendChild(copy); + } } block.appendChild(scroll); @@ -486,7 +522,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => { preview.setAttribute('data-md-url', href); preview.setAttribute('title', ctx.labels.previewTitle); preview.setAttribute('aria-label', ctx.labels.previewLabel); - setHtml(preview, ICONS.download); + setIconHtml(preview, ICONS.download); anchor.parentNode?.insertBefore(preview, anchor.nextSibling); } } @@ -600,10 +636,25 @@ export const attachMarkdownInteractions = ( return; } + // Mermaid local pan/zoom controls + if (action === 'mermaid-zoom-in' || action === 'mermaid-zoom-out' || action === 'mermaid-fit') { + event.preventDefault(); + const block = actionEl.closest('[data-markdown="mermaid-block"]'); + const controller = getMermaidViewerController(block); + if (action === 'mermaid-zoom-in') { + controller?.zoomIn(); + } else if (action === 'mermaid-zoom-out') { + controller?.zoomOut(); + } else { + controller?.fit(); + } + return; + } + // Mermaid download svg if (action === 'mermaid-download') { const svgHost = actionEl.closest('[data-markdown="mermaid-block"]')?.querySelector('[data-markdown="mermaid"]'); - const svg = svgHost?.innerHTML ?? ''; + const svg = svgHost?.getAttribute('data-md-original-svg') ?? svgHost?.innerHTML ?? ''; if (svg) downloadBlob('diagram.svg', svg, 'image/svg+xml;charset=utf-8'); return; } diff --git a/packages/ui/src/components/chat/markdown/mermaidViewer.test.ts b/packages/ui/src/components/chat/markdown/mermaidViewer.test.ts new file mode 100644 index 00000000..b462f4c7 --- /dev/null +++ b/packages/ui/src/components/chat/markdown/mermaidViewer.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test'; + +import { + fitMermaidViewBox, + formatMermaidViewBox, + getMermaidSvgContentBox, + getMermaidViewerSignature, + hasMermaidPointerDragMoved, + MERMAID_BLOCK_SELECTOR, + panMermaidViewBox, + shouldRefreshMermaidViewers, + zoomMermaidViewBoxAtPoint, +} from './mermaidViewer'; + +describe('mermaidViewer', () => { + test('extracts content bounds from the root SVG viewBox', () => { + expect(getMermaidSvgContentBox({ viewBox: '10 20 400 200', width: '999', height: '999' })).toEqual({ + x: 10, + y: 20, + width: 400, + height: 200, + }); + }); + + test('falls back to numeric SVG width and height when viewBox is missing', () => { + expect(getMermaidSvgContentBox({ width: '640', height: '320' })).toEqual({ + x: 0, + y: 0, + width: 640, + height: 320, + }); + }); + + test('accepts bare and px SVG width and height values without parsing unresolved units', () => { + expect(getMermaidSvgContentBox({ width: '1e3', height: '2.5e2px' })).toEqual({ + x: 0, + y: 0, + width: 1000, + height: 250, + }); + + expect(getMermaidSvgContentBox({ width: '100%', height: '200' })).toBeNull(); + expect(getMermaidSvgContentBox({ width: '100em', height: '200' })).toBeNull(); + }); + + test('fits content into a viewport while preserving aspect ratio', () => { + expect(fitMermaidViewBox({ x: 0, y: 0, width: 400, height: 200 }, { width: 300, height: 300 })).toEqual({ + x: 0, + y: -100, + width: 400, + height: 400, + }); + }); + + test('zooms around a pointer so the SVG point under the pointer stays stable', () => { + const current = { x: 0, y: 0, width: 400, height: 400 }; + const next = zoomMermaidViewBoxAtPoint({ + currentBox: current, + contentBox: { x: 0, y: 0, width: 400, height: 200 }, + viewport: { width: 300, height: 300 }, + pointer: { x: 75, y: 150 }, + zoomFactor: 2, + minScale: 0.5, + maxScale: 4, + }); + + const before = { + x: current.x + (75 / 300) * current.width, + y: current.y + (150 / 300) * current.height, + }; + const after = { + x: next.x + (75 / 300) * next.width, + y: next.y + (150 / 300) * next.height, + }; + + expect(Math.abs(after.x - before.x) < 1e-6).toBe(true); + expect(Math.abs(after.y - before.y) < 1e-6).toBe(true); + expect(next).toEqual({ x: 50, y: 100, width: 200, height: 200 }); + }); + + test('clamps zoom to the configured viewBox scale bounds', () => { + const next = zoomMermaidViewBoxAtPoint({ + currentBox: { x: 0, y: 0, width: 400, height: 400 }, + contentBox: { x: 0, y: 0, width: 400, height: 200 }, + viewport: { width: 300, height: 300 }, + pointer: { x: 150, y: 150 }, + zoomFactor: 100, + minScale: 0.5, + maxScale: 4, + }); + + expect(next.width).toBe(100); + expect(next.height).toBe(100); + expect(next.x).toBe(150); + expect(next.y).toBe(150); + }); + + test('clamps zoom scale relative to the fitted viewport box', () => { + const next = zoomMermaidViewBoxAtPoint({ + currentBox: { x: -100, y: 0, width: 400, height: 400 }, + contentBox: { x: 0, y: 0, width: 200, height: 400 }, + viewport: { width: 300, height: 300 }, + pointer: { x: 150, y: 150 }, + zoomFactor: 100, + minScale: 0.5, + maxScale: 4, + }); + + expect(next).toEqual({ x: 50, y: 150, width: 100, height: 100 }); + }); + + test('returns the current viewBox for invalid zoom scale bounds', () => { + const current = { x: 0, y: 0, width: 400, height: 400 }; + + expect(zoomMermaidViewBoxAtPoint({ + currentBox: current, + contentBox: { x: 0, y: 0, width: 400, height: 200 }, + viewport: { width: 300, height: 300 }, + pointer: { x: 150, y: 150 }, + zoomFactor: 2, + minScale: 0, + maxScale: 4, + })).toBe(current); + + expect(zoomMermaidViewBoxAtPoint({ + currentBox: current, + contentBox: { x: 0, y: 0, width: 400, height: 200 }, + viewport: { width: 300, height: 300 }, + pointer: { x: 150, y: 150 }, + zoomFactor: 2, + minScale: 0.5, + maxScale: Number.POSITIVE_INFINITY, + })).toBe(current); + }); + + test('formats viewBox numbers without noisy floating point tails', () => { + expect(formatMermaidViewBox({ + x: 1 / 3, + y: -2.5, + width: 100.0000001, + height: 40, + })).toBe('0.333333 -2.5 100 40'); + }); + + test('pans viewBox by viewport pixel deltas in SVG coordinates', () => { + expect(panMermaidViewBox({ + currentBox: { x: 10, y: 20, width: 400, height: 200 }, + viewport: { width: 200, height: 100 }, + delta: { x: 25, y: -10 }, + })).toEqual({ + x: -40, + y: 40, + width: 400, + height: 200, + }); + }); + + test('distinguishes real pointer drag from click jitter', () => { + expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 12, y: 11 })).toBe(false); + expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(true); + }); + + test('viewer signature changes when SVG identity changes within an existing block', () => { + const first = getMermaidViewerSignature({ + renderMode: 'svg', + svgMarkup: '', + viewBox: '0 0 100 50', + width: null, + height: null, + }); + const second = getMermaidViewerSignature({ + renderMode: 'svg', + svgMarkup: '', + viewBox: '0 0 240 120', + width: null, + height: null, + }); + + expect(second).not.toBe(first); + }); + + test('viewer signature distinguishes render mode flips and missing SVGs', () => { + expect(getMermaidViewerSignature({ renderMode: 'ascii' })).toBe('ascii:no-svg'); + expect(getMermaidViewerSignature({ renderMode: 'svg' })).toBe('svg:no-svg'); + }); + + test('only requests renderer refresh work for existing mermaid DOM blocks', () => { + const withoutMermaidBlock = { querySelector: () => null }; + const withMermaidBlock = { querySelector: () => ({}) as Element }; + + expect(shouldRefreshMermaidViewers(withoutMermaidBlock)).toBe(false); + expect(shouldRefreshMermaidViewers(withMermaidBlock)).toBe(true); + }); + + test('exports the shared Mermaid block selector', () => { + expect(MERMAID_BLOCK_SELECTOR).toBe('[data-markdown="mermaid-block"]'); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/mermaidViewer.ts b/packages/ui/src/components/chat/markdown/mermaidViewer.ts new file mode 100644 index 00000000..d4532546 --- /dev/null +++ b/packages/ui/src/components/chat/markdown/mermaidViewer.ts @@ -0,0 +1,467 @@ +type MermaidViewBox = { + x: number; + y: number; + width: number; + height: number; +}; + +type MermaidViewport = { + width: number; + height: number; +}; + +type MermaidPoint = { + x: number; + y: number; +}; + +type MermaidViewerController = { + zoomIn: () => void; + zoomOut: () => void; + fit: () => void; + cleanup: () => void; +}; + +type MermaidSvgBoundsSource = { + viewBox?: string | null; + width?: string | number | null; + height?: string | number | null; +}; + +type MermaidViewerSignatureSource = MermaidSvgBoundsSource & { + renderMode?: string | null; + svgMarkup?: string | null; +}; + +const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0; + +const parseSvgNumber = (value: string | number | null | undefined): number | null => { + if (typeof value === 'number') { + return isPositiveFinite(value) ? value : null; + } + if (typeof value !== 'string') { + return null; + } + const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/); + if (!match) { + return null; + } + const parsed = Number(match[1]); + return isPositiveFinite(parsed) ? parsed : null; +}; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +const VIEW_BOX_PRECISION = 6; +const ZOOM_STEP = 1.25; +const WHEEL_ZOOM_BASE = 1.0015; +const MIN_SCALE = 0.5; +const MAX_SCALE = 12; +const DRAG_CLICK_SUPPRESSION_THRESHOLD_PX = 3; +const DRAG_CLICK_SUPPRESSION_CLEAR_MS = 400; +export const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; + +export const shouldRefreshMermaidViewers = (container: Pick): boolean => ( + container.querySelector(MERMAID_BLOCK_SELECTOR) !== null +); + +export const formatMermaidViewBox = (box: MermaidViewBox): string => ( + [box.x, box.y, box.width, box.height] + .map((value) => { + const rounded = Number(value.toFixed(VIEW_BOX_PRECISION)); + return Object.is(rounded, -0) ? '0' : String(rounded); + }) + .join(' ') +); + +export const getMermaidSvgContentBox = (source: MermaidSvgBoundsSource): MermaidViewBox | null => { + const viewBoxParts = source.viewBox + ?.trim() + .split(/[\s,]+/) + .map((part) => Number.parseFloat(part)); + if (viewBoxParts?.length === 4) { + const [x, y, width, height] = viewBoxParts; + if ( + Number.isFinite(x) + && Number.isFinite(y) + && isPositiveFinite(width) + && isPositiveFinite(height) + ) { + return { x, y, width, height }; + } + } + + const width = parseSvgNumber(source.width); + const height = parseSvgNumber(source.height); + if (width === null || height === null) { + return null; + } + + return { x: 0, y: 0, width, height }; +}; + +const hashMermaidSignaturePart = (value: string): string => { + let hash = 2166136261; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +}; + +export const getMermaidViewerSignature = (source: MermaidViewerSignatureSource): string => { + const renderMode = source.renderMode || 'unknown'; + const svgMarkup = source.svgMarkup ?? ''; + if (!svgMarkup) { + const bounds = [source.viewBox ?? '', source.width ?? '', source.height ?? '']; + return bounds.some((part) => part !== '') ? `${renderMode}:bounds:${bounds.join(':')}` : `${renderMode}:no-svg`; + } + + return [ + renderMode, + hashMermaidSignaturePart(svgMarkup), + ].join(':'); +}; + +export const fitMermaidViewBox = (contentBox: MermaidViewBox, viewport: MermaidViewport): MermaidViewBox => { + if (!isPositiveFinite(viewport.width) || !isPositiveFinite(viewport.height)) { + return contentBox; + } + + const contentAspect = contentBox.width / contentBox.height; + const viewportAspect = viewport.width / viewport.height; + if (contentAspect > viewportAspect) { + const height = contentBox.width / viewportAspect; + return { + x: contentBox.x, + y: contentBox.y - (height - contentBox.height) / 2, + width: contentBox.width, + height, + }; + } + + const width = contentBox.height * viewportAspect; + return { + x: contentBox.x - (width - contentBox.width) / 2, + y: contentBox.y, + width, + height: contentBox.height, + }; +}; + +export const panMermaidViewBox = ({ + currentBox, + viewport, + delta, +}: { + currentBox: MermaidViewBox; + viewport: MermaidViewport; + delta: MermaidPoint; +}): MermaidViewBox => { + if ( + !isPositiveFinite(viewport.width) + || !isPositiveFinite(viewport.height) + || !isPositiveFinite(currentBox.width) + || !isPositiveFinite(currentBox.height) + ) { + return currentBox; + } + + return { + x: currentBox.x - (delta.x / viewport.width) * currentBox.width, + y: currentBox.y - (delta.y / viewport.height) * currentBox.height, + width: currentBox.width, + height: currentBox.height, + }; +}; + +export const hasMermaidPointerDragMoved = (start: MermaidPoint, current: MermaidPoint): boolean => { + const deltaX = current.x - start.x; + const deltaY = current.y - start.y; + return (deltaX * deltaX) + (deltaY * deltaY) > (DRAG_CLICK_SUPPRESSION_THRESHOLD_PX * DRAG_CLICK_SUPPRESSION_THRESHOLD_PX); +}; + +export const zoomMermaidViewBoxAtPoint = ({ + currentBox, + contentBox, + viewport, + pointer, + zoomFactor, + minScale, + maxScale, +}: { + currentBox: MermaidViewBox; + contentBox: MermaidViewBox; + viewport: MermaidViewport; + pointer: MermaidPoint; + zoomFactor: number; + minScale: number; + maxScale: number; +}): MermaidViewBox => { + if ( + !isPositiveFinite(viewport.width) + || !isPositiveFinite(viewport.height) + || !isPositiveFinite(zoomFactor) + || !isPositiveFinite(currentBox.width) + || !isPositiveFinite(currentBox.height) + || !isPositiveFinite(contentBox.width) + || !isPositiveFinite(contentBox.height) + || !isPositiveFinite(minScale) + || !isPositiveFinite(maxScale) + ) { + return currentBox; + } + + const min = Math.min(minScale, maxScale); + const max = Math.max(minScale, maxScale); + const fittedBox = fitMermaidViewBox(contentBox, viewport); + const currentScale = fittedBox.width / currentBox.width; + const nextScale = clamp(currentScale * zoomFactor, min, max); + const nextWidth = fittedBox.width / nextScale; + const nextHeight = nextWidth / (currentBox.width / currentBox.height); + const pointerRatioX = clamp(pointer.x / viewport.width, 0, 1); + const pointerRatioY = clamp(pointer.y / viewport.height, 0, 1); + const svgPointX = currentBox.x + pointerRatioX * currentBox.width; + const svgPointY = currentBox.y + pointerRatioY * currentBox.height; + + return { + x: svgPointX - pointerRatioX * nextWidth, + y: svgPointY - pointerRatioY * nextHeight, + width: nextWidth, + height: nextHeight, + }; +}; + +const controllerByBlock = new WeakMap(); + +export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => ( + block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null +); + +const getSvgViewport = (block: HTMLElement): HTMLElement | null => ( + block.querySelector('[data-markdown="mermaid-viewport"]') + ?? block.querySelector('[data-markdown="mermaid"]') +); + +const getBlockViewerSignature = (block: HTMLElement): string => { + const svg = block.querySelector('[data-markdown="mermaid"] svg'); + const svgHost = block.querySelector('[data-markdown="mermaid"]'); + return getMermaidViewerSignature({ + renderMode: block.getAttribute('data-mermaid-render'), + svgMarkup: svgHost?.getAttribute('data-md-original-svg') ?? svg?.outerHTML ?? null, + viewBox: svg?.getAttribute('viewBox'), + width: svg?.getAttribute('width'), + height: svg?.getAttribute('height'), + }); +}; + +const getViewportSize = (viewport: HTMLElement): MermaidViewport => { + const rect = viewport.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; +}; + +const getPointerInViewport = (event: Pick, viewport: HTMLElement): MermaidPoint => { + const rect = viewport.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +}; + +const isPanExcludedTarget = (target: EventTarget | null): boolean => ( + target instanceof Element && Boolean(target.closest('button, a, [role="button"]')) +); + +const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => { + const viewport = getSvgViewport(block); + const svg = block.querySelector('[data-markdown="mermaid"] svg'); + if (!viewport || !svg) { + return null; + } + + const contentBox = getMermaidSvgContentBox({ + viewBox: svg.getAttribute('viewBox'), + width: svg.getAttribute('width'), + height: svg.getAttribute('height'), + }); + if (!contentBox) { + return null; + } + + let currentBox = contentBox; + let activePointerId: number | null = null; + let dragStartPointer: MermaidPoint | null = null; + let lastPointer: MermaidPoint | null = null; + let clearClickSuppressionTimer: number | null = null; + + const applyViewBox = (box: MermaidViewBox): void => { + currentBox = box; + svg.setAttribute('viewBox', formatMermaidViewBox(box)); + svg.removeAttribute('width'); + svg.removeAttribute('height'); + }; + + const fit = (): void => { + applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport))); + }; + + const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => { + applyViewBox(zoomMermaidViewBoxAtPoint({ + currentBox, + contentBox, + viewport: getViewportSize(viewport), + pointer, + zoomFactor, + minScale: MIN_SCALE, + maxScale: MAX_SCALE, + })); + }; + + const zoomIn = (): void => { + const size = getViewportSize(viewport); + zoomAt({ x: size.width / 2, y: size.height / 2 }, ZOOM_STEP); + }; + + const zoomOut = (): void => { + const size = getViewportSize(viewport); + zoomAt({ x: size.width / 2, y: size.height / 2 }, 1 / ZOOM_STEP); + }; + + const onWheel = (event: WheelEvent): void => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + event.preventDefault(); + event.stopPropagation(); + zoomAt(getPointerInViewport(event, viewport), Math.pow(WHEEL_ZOOM_BASE, -event.deltaY)); + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isPanExcludedTarget(event.target)) { + return; + } + activePointerId = event.pointerId; + dragStartPointer = { x: event.clientX, y: event.clientY }; + lastPointer = dragStartPointer; + if (clearClickSuppressionTimer !== null) { + window.clearTimeout(clearClickSuppressionTimer); + clearClickSuppressionTimer = null; + } + block.removeAttribute('data-mermaid-suppress-click'); + viewport.setPointerCapture?.(event.pointerId); + block.setAttribute('data-mermaid-panning', 'true'); + event.preventDefault(); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (activePointerId !== event.pointerId || !lastPointer) { + return; + } + const nextPointer = { x: event.clientX, y: event.clientY }; + applyViewBox(panMermaidViewBox({ + currentBox, + viewport: getViewportSize(viewport), + delta: { + x: nextPointer.x - lastPointer.x, + y: nextPointer.y - lastPointer.y, + }, + })); + lastPointer = nextPointer; + if (dragStartPointer && hasMermaidPointerDragMoved(dragStartPointer, nextPointer)) { + block.setAttribute('data-mermaid-suppress-click', 'true'); + } + event.preventDefault(); + }; + + const stopPan = (event: PointerEvent): void => { + if (activePointerId !== event.pointerId) { + return; + } + viewport.releasePointerCapture?.(event.pointerId); + activePointerId = null; + dragStartPointer = null; + lastPointer = null; + block.removeAttribute('data-mermaid-panning'); + if (block.hasAttribute('data-mermaid-suppress-click')) { + clearClickSuppressionTimer = window.setTimeout(() => { + block.removeAttribute('data-mermaid-suppress-click'); + clearClickSuppressionTimer = null; + }, DRAG_CLICK_SUPPRESSION_CLEAR_MS); + } + }; + + const onResize = (): void => { + fit(); + }; + + viewport.addEventListener('wheel', onWheel, { passive: false }); + viewport.addEventListener('pointerdown', onPointerDown); + viewport.addEventListener('pointermove', onPointerMove); + viewport.addEventListener('pointerup', stopPan); + viewport.addEventListener('pointercancel', stopPan); + window.addEventListener('resize', onResize); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize); + observer?.observe(viewport); + fit(); + + return { + zoomIn, + zoomOut, + fit, + cleanup: () => { + viewport.removeEventListener('wheel', onWheel); + viewport.removeEventListener('pointerdown', onPointerDown); + viewport.removeEventListener('pointermove', onPointerMove); + viewport.removeEventListener('pointerup', stopPan); + viewport.removeEventListener('pointercancel', stopPan); + window.removeEventListener('resize', onResize); + observer?.disconnect(); + if (clearClickSuppressionTimer !== null) { + window.clearTimeout(clearClickSuppressionTimer); + } + block.removeAttribute('data-mermaid-panning'); + block.removeAttribute('data-mermaid-suppress-click'); + controllerByBlock.delete(block); + }, + }; +}; + +export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => { + const controllers = new Map(); + const signatures = new Map(); + + const refresh = (): void => { + for (const [block, controller] of Array.from(controllers.entries())) { + const signature = getBlockViewerSignature(block); + if (!container.contains(block) || signature !== signatures.get(block)) { + controller.cleanup(); + controllers.delete(block); + signatures.delete(block); + } + } + + for (const block of Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR))) { + if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) { + continue; + } + const controller = createMermaidViewerController(block); + if (!controller) { + continue; + } + controllers.set(block, controller); + signatures.set(block, getBlockViewerSignature(block)); + controllerByBlock.set(block, controller); + } + }; + + const cleanup = (): void => { + for (const controller of controllers.values()) { + controller.cleanup(); + } + controllers.clear(); + signatures.clear(); + }; + + refresh(); + return { refresh, cleanup }; +}; diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts new file mode 100644 index 00000000..08584eb8 --- /dev/null +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; + +import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; + +describe('getMermaidDataUrlSourcePromise', () => { + test('turns malformed data URLs into rejected promises', async () => { + const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64'); + + await sourcePromise.then( + () => { + throw new Error('expected malformed data URL to reject'); + }, + (error) => { + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(MermaidLoadFailure); + expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed'); + expect(error.params).toBe(undefined); + }, + ); + }); +}); + +describe('Mermaid load request ids', () => { + test('invalidates stale async loads when a newer load starts', () => { + const firstRequest = nextMermaidLoadRequestId(0); + const secondRequest = nextMermaidLoadRequestId(firstRequest); + + expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false); + expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true); + }); +}); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index 798ecbb2..15298662 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle'; import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock'; import { JsonTreeView } from '@/components/ui/JsonTreeView'; import { Icon } from "@/components/icon/Icon"; -import { useI18n } from '@/lib/i18n'; +import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; interface ToolOutputDialogProps { popup: ToolPopupContent; @@ -35,6 +36,8 @@ interface ToolOutputDialogProps { isMobile: boolean; } +const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params); + const getToolIcon = (toolName: string) => { const iconClass = 'h-3.5 w-3.5 flex-shrink-0'; const tool = toolName.toLowerCase(); @@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3; const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }; -const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true }; +const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true }; type PierreThemeConfig = { theme: { light: string; dark: string }; @@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{ return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null); }, []); - const decodeDataUrl = React.useCallback((value: string): string => { - const commaIndex = value.indexOf(','); - if (commaIndex < 0) { - throw new Error('Malformed data URL'); - } - - const metadata = value.slice(0, commaIndex).toLowerCase(); - const payload = value.slice(commaIndex + 1); - if (metadata.includes(';base64')) { - return atob(payload); - } - return decodeURIComponent(payload); - }, []); - const loadMermaidSource = React.useCallback(async () => { const target = popup.mermaid; + const requestId = nextMermaidLoadRequestId(requestIdRef.current); + requestIdRef.current = requestId; + if (!target?.url) { setStatus('error'); setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource')); @@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{ return; } - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - setStatus('loading'); setErrorMessage(''); let sourcePromise: Promise; if (target.url.startsWith('data:')) { - sourcePromise = Promise.resolve(decodeDataUrl(target.url)); + sourcePromise = getMermaidDataUrlSourcePromise(target.url); } else if (target.url.toLowerCase().startsWith('file://')) { const normalizedPath = normalizeFilePath(target.url); if (!normalizedPath) { - sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.')); + sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath')); } else { sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } }) .then((response) => { if (!response.ok) { - return Promise.reject(new Error(`Failed to read diagram file (${response.status})`)); + return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status })); } return response.text(); }); @@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{ const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null; if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) { - sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.')); + sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol')); } else { sourcePromise = fetch(resolvedUrl.toString()) .then((response) => { if (!response.ok) { - return Promise.reject(new Error(`Failed to load diagram (${response.status})`)); + return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status })); } return response.text(); }); @@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{ await sourcePromise .then((resolvedSource) => { - if (requestIdRef.current !== requestId) { + if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) { return; } @@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{ setStatus('ready'); }) .catch((error) => { - if (requestIdRef.current !== requestId) { + if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) { return; } setStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed')); + setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed')); }); - }, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]); + }, [normalizeFilePath, popup.mermaid, t]); React.useEffect(() => { if (!popup.open || !popup.mermaid) { @@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{