From 1ef024ebf1f7564c88923d98e3ad2fec52809d3d Mon Sep 17 00:00:00 2001
From: ytahdn <1294726970@qq.com>
Date: Fri, 18 Sep 2026 09:56:24 +0000
Subject: [PATCH] fix(web-shell): keep the mermaid canvas fixed while zooming
out (#12177)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Zoom was a transform on the box that also carried the canvas background and
the diagram's own horizontal scrollbar. A transform moves paint, not layout,
so zooming out scaled the scroll viewport along with the diagram and left the
pan range computed from the unscaled width: at half size a 3223px diagram
painted 1612px inside a 489px sliding strip in the middle of a 978px row,
while the scrollable range stayed 2277px and matched nothing on screen.
Panning was a second translate on the same box on top of that — unbounded
horizontally, so dragging past the content origin pushed the leading edge of
the diagram out of the scrollable region, where no scroll brought it back.
Give the three roles their own boxes: the wrapper is the canvas and the only
scroller, the inner box carries nothing but the diagram, scaling uses `zoom`
so layout moves with the paint and the reachable range tracks the diagram
rather than its full-size box, and dragging drives the scroll position instead
of a transform. The canvas takes the diagram's own size, read from its svg:
mermaid marks everything outside flowchart `width="100%"`, so those diagrams
keep shrinking to the canvas width while the rest sit at their design size — a
`fit-content` box cannot resolve that percentage and would fall back to the
300x150 default object size. The canvas holds the diagram's unscaled height so
zooming never reflows the transcript, and it offers the grab cursor only while
part of the diagram is out of view: a diagram that already fits cannot be
moved by dragging, and the cursor should not promise otherwise.
---
.../components/messages/Markdown.css.test.ts | 31 +++
.../messages/Markdown.mermaid.test.ts | 130 +++++++++++-
.../components/messages/Markdown.module.css | 37 +++-
.../client/components/messages/Markdown.tsx | 187 ++++++++++++++----
4 files changed, 341 insertions(+), 44 deletions(-)
create mode 100644 packages/web-shell/client/components/messages/Markdown.css.test.ts
diff --git a/packages/web-shell/client/components/messages/Markdown.css.test.ts b/packages/web-shell/client/components/messages/Markdown.css.test.ts
new file mode 100644
index 00000000000..1458f46a731
--- /dev/null
+++ b/packages/web-shell/client/components/messages/Markdown.css.test.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// jsdom neither computes the cascade nor lays anything out, so the pairing that
+// holds the canvas together is pinned at the source. The canvas height is the
+// diagram's own height plus `MERMAID_CANVAS_PADDING_PX` on both sides, and that
+// constant is a second copy of the stylesheet's padding: change the stylesheet
+// without it and a diagram that exactly fills the canvas is clipped — nothing in
+// the jsdom suite can see that, because every test mocks the geometry. Strip
+// comments so the guard matches declarations, not prose about them.
+const markdownCss = readFileSync(
+ fileURLToPath(new URL('./Markdown.module.css', import.meta.url)),
+ 'utf8',
+).replace(/\/\*[\s\S]*?\*\//g, '');
+const markdownSource = readFileSync(
+ fileURLToPath(new URL('./Markdown.tsx', import.meta.url)),
+ 'utf8',
+);
+
+describe('Mermaid canvas stylesheet', () => {
+ it('keeps the canvas padding constant equal to the stylesheet padding', () => {
+ const constant = markdownSource.match(
+ /MERMAID_CANVAS_PADDING_PX\s*=\s*(\d+)/,
+ )?.[1];
+ const wrapper = markdownCss.match(/\.mermaidZoomWrapper\s*\{[^}]*\}/)?.[0];
+ expect(constant).toBeTruthy();
+ expect(wrapper).toBeTruthy();
+ expect(wrapper).toMatch(new RegExp(`padding:\\s*${constant}px;`));
+ });
+});
diff --git a/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts
index 63cb792fa3f..326cc53df0a 100644
--- a/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts
+++ b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts
@@ -8,7 +8,9 @@ import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
- render: vi.fn(() => Promise.resolve({ svg: '' })),
+ render: vi.fn(() =>
+ Promise.resolve({ svg: '' }),
+ ),
}));
vi.mock('mermaid', () => ({ default: mermaidMock }));
@@ -70,7 +72,9 @@ beforeEach(() => {
vi.useFakeTimers();
mermaidMock.initialize.mockClear();
mermaidMock.render.mockReset();
- mermaidMock.render.mockResolvedValue({ svg: '' });
+ mermaidMock.render.mockResolvedValue({
+ svg: '',
+ });
});
afterEach(() => {
@@ -198,3 +202,125 @@ describe('Markdown Mermaid render modes', () => {
expect(container.querySelector('pre code')).toBeNull();
});
});
+
+describe('Markdown mermaid zoom', () => {
+ async function mountZoomableDiagram() {
+ const view = mountMermaid('interactive');
+ await startMermaidRender();
+ const stage = view.container.querySelector('svg')?.parentElement;
+ if (!(stage instanceof HTMLElement)) throw new Error('no diagram mounted');
+ const viewport = stage.parentElement;
+ if (!(viewport instanceof HTMLElement)) throw new Error('no viewport');
+ // No i18n provider here, so `t()` returns the key and that is the title.
+ const button = (title: string) => {
+ const found = view.container.querySelector(
+ `button[title="${title}"]`,
+ );
+ if (!found) throw new Error(`no ${title} button`);
+ return found;
+ };
+ return {
+ stage,
+ viewport,
+ zoomIn: button('mermaid.zoomIn'),
+ zoomOut: button('mermaid.zoomOut'),
+ zoomReset: button('mermaid.zoomReset'),
+ };
+ }
+
+ // The scaling itself is a layout effect of the `zoom` property, which jsdom
+ // does not model; it is covered by a browser-side probe instead.
+ it('offers the reset only while the view is zoomed or panned', async () => {
+ const { zoomOut, zoomReset } = await mountZoomableDiagram();
+
+ expect(zoomReset.disabled).toBe(true);
+
+ act(() => zoomOut.click());
+ expect(zoomReset.disabled).toBe(false);
+
+ act(() => zoomReset.click());
+ expect(zoomReset.disabled).toBe(true);
+ });
+
+ it('holds the canvas at the diagram height while zooming', async () => {
+ const { stage, viewport, zoomOut, zoomReset } =
+ await mountZoomableDiagram();
+
+ // 150 (the rendered svg) + the canvas padding on both sides, and the box the
+ // zoom scales is the diagram's own 300.
+ expect(viewport.style.height).toBe('182px');
+ expect(stage.style.width).toBe('300px');
+
+ act(() => zoomOut.click());
+ expect(viewport.style.height).toBe('182px');
+
+ act(() => zoomReset.click());
+ expect(viewport.style.height).toBe('182px');
+ });
+
+ it('offers the grab cursor only while the diagram reaches past the canvas', async () => {
+ const { viewport, zoomIn } = await mountZoomableDiagram();
+
+ // jsdom lays nothing out, so the canvas reports no overflow to begin with.
+ expect(viewport.className).not.toContain('mermaidPannable');
+
+ // Give it geometry, then let the zoom re-measure (that is the same path a
+ // real zoom takes).
+ Object.defineProperty(viewport, 'scrollWidth', {
+ value: 1000,
+ configurable: true,
+ });
+ Object.defineProperty(viewport, 'clientWidth', {
+ value: 100,
+ configurable: true,
+ });
+ act(() => zoomIn.click());
+ expect(viewport.className).toContain('mermaidPannable');
+ });
+
+ it('pans the viewport instead of moving the diagram box', async () => {
+ const { stage, viewport, zoomReset } = await mountZoomableDiagram();
+
+ act(() => {
+ viewport.dispatchEvent(
+ new MouseEvent('mousedown', { bubbles: true, clientX: 500 }),
+ );
+ });
+ act(() => {
+ window.dispatchEvent(
+ new MouseEvent('mousemove', { bubbles: true, clientX: 200 }),
+ );
+ });
+ // Dragging left reveals what is to the right of the fold. jsdom has no
+ // layout, so this pins the delta wiring; the browser-side clamp that makes
+ // the same drag a no-op on a diagram that already fits is not visible here.
+ expect(viewport.scrollLeft).toBe(300);
+ expect(zoomReset.disabled).toBe(false);
+ // Panning is the viewport's scroll position, not a shifted box.
+ expect(stage.style.transform).toBe('');
+
+ act(() => {
+ window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ });
+ act(() => zoomReset.click());
+ expect(viewport.scrollLeft).toBe(0);
+ });
+
+ it('takes a percentage-width diagram size from its viewBox', async () => {
+ // Outside flowchart mermaid emits `width="100%"` and no height at all, and a
+ // `fit-content` box cannot resolve that percentage: it fell back to the
+ // 300x150 default object size. The viewBox is what has to size it.
+ mermaidMock.render.mockResolvedValueOnce({
+ svg: '',
+ });
+ const view = mountMermaid('interactive');
+ await startMermaidRender();
+ const stage = view.container.querySelector('svg')?.parentElement;
+ if (!(stage instanceof HTMLElement)) throw new Error('no diagram mounted');
+ const viewport = stage.parentElement;
+ if (!(viewport instanceof HTMLElement)) throw new Error('no viewport');
+
+ expect(stage.style.width).toBe('400px');
+ expect(viewport.style.height).toBe('232px');
+ });
+});
diff --git a/packages/web-shell/client/components/messages/Markdown.module.css b/packages/web-shell/client/components/messages/Markdown.module.css
index 5f9b0c6b2b9..550ea0f474b 100644
--- a/packages/web-shell/client/components/messages/Markdown.module.css
+++ b/packages/web-shell/client/components/messages/Markdown.module.css
@@ -297,6 +297,24 @@
border-radius: 0;
}
+/* The box that carries the diagram. The zoom wrapper is the canvas and the only
+ scroller, so this one holds no chrome and no scroll region of its own: it is
+ only scaled, and its overflow therefore reaches the wrapper.
+ Its width comes from the rendered svg (see the measurement in Markdown.tsx);
+ `fit-content` only covers the moments before that lands. Hugging the diagram is
+ what keeps the scroll range honest: a box as wide as the row would scale its
+ own empty width into the range, letting a diagram that still fits be scrolled
+ away. */
+.mermaidBlock.mermaidStage {
+ width: fit-content;
+ margin: 0 auto;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background: none;
+ overflow: visible;
+}
+
.mermaidBlock :global(svg) {
max-width: none;
height: auto;
@@ -323,13 +341,24 @@
}
.mermaidZoomWrapper {
- overflow-x: auto;
- overflow-y: hidden;
- padding: 8px 0;
- cursor: grab;
+ /* The canvas: the row's full width, the diagram's unscaled height, and the
+ only scroller. The diagram keeps its own layout size and is scaled inside,
+ so the scroll range follows what scaling paints — zooming out shrinks the
+ diagram, not the area the viewer can pan in. */
+ overflow: auto;
+ padding: 16px;
+ background: var(--popover);
+ border-radius: var(--radius);
user-select: none;
}
+/* Panning moves the scroll position, so the grab cursor is only honest while
+ part of the diagram is out of view: a diagram that already fits cannot be
+ moved, and offering to drag it reads as broken. */
+.mermaidPannable {
+ cursor: grab;
+}
+
.mermaidDragging {
cursor: grabbing;
}
diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx
index 4f8540c937a..bee08850472 100644
--- a/packages/web-shell/client/components/messages/Markdown.tsx
+++ b/packages/web-shell/client/components/messages/Markdown.tsx
@@ -6,6 +6,7 @@ import {
useContext,
useEffect,
useId,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -201,6 +202,9 @@ let mermaidRenderId = 0;
const MAX_MERMAID_TEXT_CHARS = 50_000;
const MAX_MERMAID_EDGES = 500;
const MERMAID_RENDER_TIMEOUT_MS = 10_000;
+// Must match `.mermaidZoomWrapper`'s padding in Markdown.module.css: the canvas
+// height is set from the diagram's own height plus this both sides.
+const MERMAID_CANVAS_PADDING_PX = 16;
function MermaidBlock({ code }: { code: string }) {
const { t } = useI18n();
@@ -216,13 +220,27 @@ function MermaidBlock({ code }: { code: string }) {
const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram');
const [copied, flashCopied] = useCopiedFlash();
const [zoom, setZoom] = useState(1);
- const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
+ // The canvas is the viewport: panning moves its scroll position, so a drag
+ // and the scrollbar are one mechanism, and the reachable range is the one the
+ // browser already reports for the scaled diagram.
+ const viewportRef = useRef(null);
+ const [isPanned, setIsPanned] = useState(false);
+ // Whether the diagram reaches past the canvas, i.e. whether dragging it has
+ // anywhere to go.
+ const [canPan, setCanPan] = useState(false);
+ // The box the zoom scales, and the height the canvas is held at so that
+ // zooming never reflows the transcript. Both are read off the rendered svg.
+ const [diagramBox, setDiagramBox] = useState<{
+ stageWidth: number;
+ canvasHeight: number;
+ } | null>(null);
+ const stageRef = useRef(null);
const dragRef = useRef<{
startX: number;
startY: number;
- origX: number;
- origY: number;
+ startLeft: number;
+ startTop: number;
} | null>(null);
const mermaidTheme = appTheme === 'light' ? 'default' : 'dark';
@@ -240,40 +258,103 @@ function MermaidBlock({ code }: { code: string }) {
dragRef.current = null;
setIsDragging(false);
setZoom(1);
- setOffset({ x: 0, y: 0 });
+ const viewport = viewportRef.current;
+ if (viewport) {
+ viewport.scrollLeft = 0;
+ viewport.scrollTop = 0;
+ }
+ setIsPanned(false);
}, []);
- const handleMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- setIsDragging(true);
- dragRef.current = {
- startX: e.clientX,
- startY: e.clientY,
- origX: offset.x,
- origY: offset.y,
- };
- },
- [offset],
- );
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+ e.preventDefault();
+ setIsDragging(true);
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startLeft: viewport.scrollLeft,
+ startTop: viewport.scrollTop,
+ };
+ }, []);
+
+ const handleScroll = useCallback((e: React.UIEvent) => {
+ const { scrollLeft, scrollTop } = e.currentTarget;
+ setIsPanned(scrollLeft !== 0 || scrollTop !== 0);
+ }, []);
+
+ const syncViewport = useCallback(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+ setCanPan(
+ viewport.scrollWidth > viewport.clientWidth ||
+ viewport.scrollHeight > viewport.clientHeight,
+ );
+ setIsPanned(viewport.scrollLeft !== 0 || viewport.scrollTop !== 0);
+ }, []);
+
+ // Mermaid sizes every diagram it renders in a `viewBox`, and that is the size
+ // it designed: the box the zoom scales, and the height the canvas keeps.
+ // Outside `flowchart` — where this component asks for `useMaxWidth: false` —
+ // mermaid also marks the svg `width="100%"`, so those diagrams shrink to the
+ // canvas instead of sitting at their design size. Measuring them here is what
+ // keeps both cases working: a `fit-content` box cannot resolve that percentage
+ // and falls back to the 300x150 default object size.
+ const measureDiagram = useCallback(() => {
+ const viewport = viewportRef.current;
+ const svgEl = stageRef.current?.querySelector('svg');
+ if (!viewport || !svgEl) return;
+ const [, , boxWidth, boxHeight] = (svgEl.getAttribute('viewBox') ?? '')
+ .split(/[\s,]+/)
+ .map(Number);
+ const declaredWidth = (svgEl.getAttribute('width') ?? '').trim();
+ const declaredHeight = Number.parseFloat(
+ svgEl.getAttribute('height') ?? '',
+ );
+ const hasViewBox = boxWidth > 0 && boxHeight > 0;
+ const width = hasViewBox ? boxWidth : Number.parseFloat(declaredWidth);
+ const height = hasViewBox ? boxHeight : declaredHeight;
+ if (!(width > 0) || !(height > 0)) {
+ setDiagramBox(null);
+ return;
+ }
+ const available = viewport.clientWidth - MERMAID_CANVAS_PADDING_PX * 2;
+ const stageWidth =
+ declaredWidth.endsWith('%') && available > 0
+ ? Math.min(width, available)
+ : width;
+ // A classic (space-taking) scrollbar would otherwise be taken out of the
+ // height the canvas is held at, and hide the bottom of a diagram that
+ // exactly fills it. Overlay scrollbars measure zero.
+ const scrollbar = Math.max(
+ 0,
+ viewport.offsetHeight - viewport.clientHeight,
+ );
+ setDiagramBox({
+ stageWidth,
+ canvasHeight:
+ (stageWidth * height) / width +
+ MERMAID_CANVAS_PADDING_PX * 2 +
+ scrollbar,
+ });
+ }, []);
useEffect(() => {
if (!isDragging) return;
const onMouseMove = (e: MouseEvent) => {
- if (!dragRef.current) return;
- const dx = e.clientX - dragRef.current.startX;
- const dy = e.clientY - dragRef.current.startY;
- // Clamp Y to prevent dragging into overflow-y: hidden clipped area.
- // X is unclamped — overflow-x: auto provides native horizontal scroll.
- const PAN_LIMIT = 1500;
- setOffset({
- x: dragRef.current.origX + dx,
- y: Math.max(
- -PAN_LIMIT,
- Math.min(PAN_LIMIT, dragRef.current.origY + dy),
- ),
- });
+ const viewport = viewportRef.current;
+ const drag = dragRef.current;
+ if (!viewport || !drag) return;
+ // Grab semantics: the diagram follows the pointer, so the scroll position
+ // moves against it. The browser clamps at both ends, which is what keeps
+ // the leading edge of an over-wide diagram reachable.
+ viewport.scrollLeft = drag.startLeft - (e.clientX - drag.startX);
+ viewport.scrollTop = drag.startTop - (e.clientY - drag.startY);
+ // Scroll events arrive asynchronously, so report the position the browser
+ // just settled on (assignments are clamped) instead of waiting for one.
+ setIsPanned(viewport.scrollLeft !== 0 || viewport.scrollTop !== 0);
};
const onMouseUp = () => {
@@ -292,9 +373,29 @@ function MermaidBlock({ code }: { code: string }) {
}, [isDragging]);
useEffect(() => {
- setZoom(1);
- setOffset({ x: 0, y: 0 });
- }, [code]);
+ resetZoomAndPan();
+ }, [code, resetZoomAndPan]);
+
+ // Both the box and the drag affordance follow the rendered diagram, so they are
+ // re-read when it arrives, when the zoom changes (a diagram can reach past the
+ // canvas at one zoom and not another), and when the view is remounted.
+ useLayoutEffect(() => {
+ measureDiagram();
+ syncViewport();
+ }, [svg, zoom, viewMode, measureDiagram, syncViewport]);
+
+ // A narrower column changes both a `width="100%"` diagram's size and whether
+ // there is anything to pan, without either of those React values changing.
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+ const observer = new ResizeObserver(() => {
+ measureDiagram();
+ syncViewport();
+ });
+ observer.observe(viewport);
+ return () => observer.disconnect();
+ }, [svg, viewMode, measureDiagram, syncViewport]);
useEffect(() => {
let cancelled = false;
@@ -409,7 +510,7 @@ function MermaidBlock({ code }: { code: string }) {
className={styles.codeBlockCopy}
onClick={resetZoomAndPan}
title={t('mermaid.zoomReset')}
- disabled={zoom === 1 && offset.x === 0 && offset.y === 0}
+ disabled={zoom === 1 && !isPanned}
>
{t('mermaid.zoomReset')}
@@ -450,15 +551,25 @@ function MermaidBlock({ code }: { code: string }) {
) : (