From b43a0e59042812332072d113df4dbfd41d1db7f6 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Sun, 31 May 2026 23:29:57 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20make=20mermaid=20diagram?= =?UTF-8?q?=20controls=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Replace Mermaid diagram +/- max-height controls with persisted SVG zoom controls. - Apply the zoom CSS in both desktop and VS Code webview styles. - Add a regression test covering zoom state and persistence. Validation: - bun test src/browser/features/Messages/Mermaid.test.tsx - make typecheck - make lint - nix shell nixpkgs#shfmt nixpkgs#shellcheck nixpkgs#hadolint -c make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `17793{MUX_COSTS_USD:-0.00}`_ --- .../features/Messages/Mermaid.test.tsx | 34 ++++++++++- src/browser/features/Messages/Mermaid.tsx | 59 ++++++++++--------- src/browser/styles/globals.css | 4 +- vscode/src/webview/webview.css | 4 +- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/browser/features/Messages/Mermaid.test.tsx b/src/browser/features/Messages/Mermaid.test.tsx index 120dbd3a02..f32a5404e0 100644 --- a/src/browser/features/Messages/Mermaid.test.tsx +++ b/src/browser/features/Messages/Mermaid.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { StreamingContext } from "./StreamingContext"; @@ -179,6 +179,38 @@ describe("Mermaid layout stability", () => { }); }); + test("zoom controls scale rendered diagrams", async () => { + const view = renderMermaid(); + + await waitFor(() => { + expect(view.container.querySelector(".mermaid-container svg")).not.toBeNull(); + }); + + const container = view.container.querySelector(".mermaid-container"); + expect(container).not.toBeNull(); + expect(container?.style.getPropertyValue("--diagram-zoom")).toBe("1"); + + const buttons = Array.from(view.container.querySelectorAll("button")); + const zoomOutButton = buttons.find((button) => button.textContent === "−"); + const zoomInButton = buttons.find((button) => button.textContent === "+"); + if (!zoomOutButton || !zoomInButton) { + throw new Error("Expected Mermaid zoom controls to render"); + } + + fireEvent.click(zoomInButton); + + await waitFor(() => { + expect(container?.style.getPropertyValue("--diagram-zoom")).toBe("1.1"); + }); + expect(window.localStorage.getItem("mermaid-diagram-zoom")).toBe("1.1"); + + fireEvent.click(zoomOutButton); + + await waitFor(() => { + expect(container?.style.getPropertyValue("--diagram-zoom")).toBe("1"); + }); + }); + test("renders sanitized SVG inside the stable container", async () => { mermaidRender.mockImplementation(() => Promise.resolve({ diff --git a/src/browser/features/Messages/Mermaid.tsx b/src/browser/features/Messages/Mermaid.tsx index 5409f639b1..b16f3a90bd 100644 --- a/src/browser/features/Messages/Mermaid.tsx +++ b/src/browser/features/Messages/Mermaid.tsx @@ -6,7 +6,10 @@ import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; const MIN_HEIGHT = 300; -const MAX_HEIGHT = 1200; +const DEFAULT_ZOOM = 1; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 3; +const ZOOM_STEP = 0.1; // Initialize mermaid mermaid.initialize({ @@ -188,6 +191,14 @@ export function sanitizeMermaidSvg(svg: string): string | null { return svgRoot.outerHTML; } +function normalizeDiagramZoom(value: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_ZOOM; + } + + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2)))); +} + // Common button styles const getButtonStyle = (disabled = false): CSSProperties => ({ background: disabled ? "rgba(255, 255, 255, 0.05)" : "rgba(255, 255, 255, 0.1)", @@ -286,24 +297,26 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { return () => clearTimeout(timer); }, [chart]); - const [diagramMaxHeight, setDiagramMaxHeight] = usePersistedState( - "mermaid-diagram-max-height", - MIN_HEIGHT, + const [storedDiagramZoom, setStoredDiagramZoom] = usePersistedState( + "mermaid-diagram-zoom", + DEFAULT_ZOOM, { listener: true } ); - - const atMinHeight = diagramMaxHeight <= MIN_HEIGHT; - const atMaxHeight = diagramMaxHeight >= MAX_HEIGHT; - - const handleIncreaseHeight = () => { - if (!atMaxHeight) { - setDiagramMaxHeight((prev) => Math.min(MAX_HEIGHT, Math.round(prev * 1.1))); + // The +/- controls are shown as zoom affordances, so scale the rendered SVG + // itself; changing only max-height is a no-op for diagrams already below the cap. + const diagramZoom = normalizeDiagramZoom(storedDiagramZoom); + const atMinZoom = diagramZoom <= MIN_ZOOM; + const atMaxZoom = diagramZoom >= MAX_ZOOM; + + const handleZoomIn = () => { + if (!atMaxZoom) { + setStoredDiagramZoom((prev) => normalizeDiagramZoom(normalizeDiagramZoom(prev) + ZOOM_STEP)); } }; - const handleDecreaseHeight = () => { - if (!atMinHeight) { - setDiagramMaxHeight((prev) => Math.max(MIN_HEIGHT, Math.round(prev * 0.9))); + const handleZoomOut = () => { + if (!atMinZoom) { + setStoredDiagramZoom((prev) => normalizeDiagramZoom(normalizeDiagramZoom(prev) - ZOOM_STEP)); } }; @@ -395,21 +408,13 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { gap: "4px", }} > - - - - @@ -424,7 +429,7 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { style={{ maxWidth: "70%", margin: "0 auto", - ["--diagram-max-height" as string]: `${diagramMaxHeight}px`, + ["--diagram-zoom" as string]: `${diagramZoom}`, minHeight: `${MIN_HEIGHT}px`, ...(showPendingPlaceholder ? { diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index a4d8d13dbb..62617244bd 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1993,7 +1993,7 @@ code { display: flex; justify-content: center; align-items: center; - overflow-x: auto; + overflow: auto; max-width: 100%; } @@ -2002,6 +2002,8 @@ code { max-width: none; max-height: var(--diagram-max-height, 300px); height: auto; + /* Electron is Chromium-only; zoom affects layout/scrollbars, unlike transform. */ + zoom: var(--diagram-zoom, 1); } /* Mermaid in modal - allow larger sizing */ diff --git a/vscode/src/webview/webview.css b/vscode/src/webview/webview.css index 92ad01bbd4..834ead49d0 100644 --- a/vscode/src/webview/webview.css +++ b/vscode/src/webview/webview.css @@ -715,7 +715,7 @@ body { display: flex; justify-content: center; align-items: center; - overflow-x: auto; + overflow: auto; max-width: 100%; } @@ -724,6 +724,8 @@ body { max-width: none; max-height: var(--diagram-max-height, 300px); height: auto; + /* VS Code webviews are Chromium-backed; zoom affects layout/scrollbars, unlike transform. */ + zoom: var(--diagram-zoom, 1); } /* Mermaid in modal - allow larger sizing */