Files
multica/packages/views/editor/mermaid-viewer.test.tsx
Naiyuan Qing a964c5229d feat(editor): pan/zoom the image attachment preview (MUL-5316) (#5971)
* feat(editor): pan/zoom the image attachment preview (MUL-5316)

Image preview could only fit-to-window, so a screenshot of text opened
unreadably small with no way in. It now runs on the same pan/zoom canvas
the Mermaid viewer uses: fit on open, then wheel (cursor-anchored), drag,
pinch, double-click fit<->100%, +/-/0 and arrow keys, plus a toolbar with
zoom out / % / zoom in / fit / actual size / reset.

The canvas is generalised out of Mermaid rather than duplicated:
diagram-transform -> zoom-transform, use-diagram-canvas -> use-zoom-canvas,
the canvas CSS out of mermaid.css into zoom-canvas.css, and a shared
ZoomCanvas + ZoomControls that MermaidViewer now composes too (dropping its
hand-rolled toolbar and canvas markup). The five zoom labels move from
editor.mermaid.* to a shared editor.canvas.* in all four locales.

Two fixes the generalisation forced, both of which also improve Mermaid:

- MIN_SCALE floored computeFitScale at 25%, so content more than 4x the
  canvas could not be fitted at all. A 1600x8000 full-page screenshot needs
  ~10% and would have opened cropped — worse than the fit-only behaviour it
  replaces. The lower bound is now min(0.25, fitScale), threaded through
  clampTransform / zoomToAt / canZoomOut.
- The canvas measured its viewport with getBoundingClientRect while its
  container was mid scale-in animation, fitting against a viewport a few
  percent too small; ResizeObserver reports the untransformed layout box so
  it never fired to correct it. Measures offsetWidth/offsetHeight now.

Image-specific handling: natural size is read from the ref as well as
onLoad (a cached image is already complete before onLoad attaches), and an
image with no intrinsic size — an SVG with only a viewBox — keeps the old
letterboxed render with the zoom controls hidden instead of a blank canvas.
The canvas is focused on open so the keyboard controls work without a click
first, native image drag is disabled so it can't hijack the pan, and the
backdrop only closes on a click that actually lands on it.

Verified: pnpm typecheck, pnpm lint, pnpm test (3003 views tests, 50 in
attachment-preview-modal). The flex chain and the long-screenshot fit were
also checked in headless Chromium, which jsdom cannot measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): keep kbd keycaps readable inside tooltips

Upstream shadcn inverts its tooltip surface, so Kbd forced near-white text
inside tooltip-content. Our TooltipContent keeps the popover surface, which
made keycaps render white-on-white. Drop the inverted-surface overrides so
keycaps keep their regular muted colors, which read correctly on popover in
both themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(editor): drop the redundant reset zoom control, add shortcut tooltips

The reset toolbar button was a literal alias of fit (reset: fit) — it could
never do anything fit doesn't, so remove it along with the reset API, the
isFitted state that only served its disabled look, and the reset_view copy
in all four locales.

Replace the native title attribute on the remaining zoom controls with the
shared Base UI Tooltip + ShortcutKeycaps pattern (same as the editor bubble
menu), showing the matching keyboard hints: zoom out (-), zoom in (+), fit
to view (0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:27:58 +08:00

486 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
// Resolve against the real EN bundle rather than a hand-written stub: the
// accessible names below are then the strings that actually ship, and a
// missing/renamed key fails here instead of silently rendering "".
vi.mock("../i18n", async () => {
const editor = (await import("../locales/en/editor.json")).default;
return {
useT: () => ({ t: (select: (bundle: typeof editor) => string) => select(editor) }),
};
});
// CodeBlockStatic pulls in lowlight, which has a heavy import surface and a
// jsdom-incompatible path. The source view's wiring is what matters here.
vi.mock("./code-block-static", () => ({
CodeBlockStatic: ({ body }: { body: string }) => (
<pre data-testid="code-block-static">{body}</pre>
),
}));
const copyTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(true));
vi.mock("@multica/ui/lib/clipboard", () => ({ copyText: copyTextMock }));
import { MermaidViewer } from "./mermaid-viewer";
const CHART = "graph LR\n A[Start] --> B[Done]";
const SVG = '<svg viewBox="0 0 1000 500"><text>mock diagram</text></svg>';
const VIEWER_DOC = "<!doctype html><html><body>mock</body></html>";
const LAYOUT = { width: 1000, height: 500 };
// The canvas measures itself from its layout box and converts pointer
// coordinates through its client rect; jsdom reports both as 0. Without a real
// viewport the transform math has nothing to fit against and every gesture is
// a no-op, so pin a deterministic size for both.
const VIEWPORT = { width: 800, height: 400 };
function stubViewportSize() {
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(VIEWPORT.width);
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(VIEWPORT.height);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
bottom: VIEWPORT.height,
height: VIEWPORT.height,
left: 0,
right: VIEWPORT.width,
top: 0,
width: VIEWPORT.width,
x: 0,
y: 0,
toJSON: () => ({}),
});
}
function Harness({ initialOpen = true }: { initialOpen?: boolean }) {
const [open, setOpen] = useState(initialOpen);
return (
<>
<button type="button" data-testid="opener" onClick={() => setOpen(true)}>
open
</button>
<MermaidViewer
open={open}
onOpenChange={setOpen}
chart={CHART}
svg={SVG}
viewerDocument={VIEWER_DOC}
layout={LAYOUT}
exportBackground="rgb(240, 240, 240)"
exportFontFamily="Inter, sans-serif"
/>
</>
);
}
function canvas(): HTMLElement {
return screen.getByRole("application");
}
function content(): HTMLElement {
return document.querySelector<HTMLElement>(".zoom-canvas-content")!;
}
/** Reads the applied scale back out of the inline transform. */
function currentScale(): number {
const match = /scale\(([\d.]+)\)/.exec(content().style.transform);
return Number.parseFloat(match![1]!);
}
function currentTranslate(): { x: number; y: number } {
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(content().style.transform);
return { x: Number.parseFloat(match![1]!), y: Number.parseFloat(match![2]!) };
}
beforeEach(() => {
stubViewportSize();
copyTextMock.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("MermaidViewer closing", () => {
it("closes via the explicit X button", async () => {
render(<Harness />);
expect(canvas()).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => {
expect(screen.queryByRole("application")).toBeNull();
});
});
it("closes on Escape after the canvas has been clicked and focused", async () => {
// The regression this pins: the old lightbox put the diagram in an iframe
// that took focus on click, stranding keydown inside a document the host
// could not hear — so Escape silently stopped working and the overlay had
// no visible close button. Interaction now lives in the host document.
render(<Harness />);
fireEvent.pointerDown(canvas(), { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerUp(canvas(), { pointerId: 1, clientX: 100, clientY: 100 });
canvas().focus();
expect(document.activeElement).toBe(canvas());
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("application")).toBeNull();
});
});
it("keeps the diagram inside an empty sandbox, so it can never steal focus or run scripts", () => {
render(<Harness />);
const frame = document.querySelector<HTMLIFrameElement>(".mermaid-viewer-frame");
expect(frame).not.toBeNull();
// An empty sandbox is what makes host-side pan/zoom necessary; if this ever
// loosens, the isolation the whole design protects is gone.
expect(frame?.getAttribute("sandbox")).toBe("");
expect(frame?.srcdoc).toBe(VIEWER_DOC);
});
it("returns focus to the element that opened it", async () => {
function FocusHarness() {
const [open, setOpen] = useState(false);
const ref = { current: null as HTMLButtonElement | null };
return (
<>
<button
type="button"
data-testid="opener"
ref={(node) => {
ref.current = node;
}}
onClick={() => setOpen(true)}
>
open
</button>
<MermaidViewer
open={open}
onOpenChange={setOpen}
chart={CHART}
svg={SVG}
viewerDocument={VIEWER_DOC}
layout={LAYOUT}
exportBackground="rgb(240, 240, 240)"
exportFontFamily="Inter, sans-serif"
finalFocusRef={ref}
/>
</>
);
}
render(<FocusHarness />);
const opener = screen.getByTestId("opener");
opener.focus();
fireEvent.click(opener);
await screen.findByRole("application");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => {
expect(document.activeElement).toBe(opener);
});
});
it("locks background scroll while open and restores it on close", async () => {
render(<Harness />);
// Base UI marks the scroll-locked root; without the lock the page behind
// drifts and reopening lands the reader somewhere else.
await waitFor(() => {
expect(document.documentElement).toHaveAttribute("data-base-ui-scroll-locked");
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => {
expect(document.documentElement).not.toHaveAttribute("data-base-ui-scroll-locked");
});
});
});
describe("MermaidViewer zoom controls", () => {
it("opens fitted to the viewport rather than squashed to a fixed size", () => {
render(<Harness />);
// 1000x500 content in an 800x400 viewport → limited by width: 0.8.
expect(currentScale()).toBeCloseTo(0.8, 5);
expect(screen.getByText("80%")).toBeInTheDocument();
});
it("zooms in and out from the toolbar", () => {
render(<Harness />);
const initial = currentScale();
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
expect(currentScale()).toBeGreaterThan(initial);
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
expect(currentScale()).toBeCloseTo(initial, 5);
});
it("jumps to natural size with Actual size", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Actual size" }));
expect(currentScale()).toBe(1);
expect(screen.getByText("100%")).toBeInTheDocument();
});
it("restores the fitted view with Fit to view after panning away", () => {
render(<Harness />);
const fitted = currentTranslate();
fireEvent.pointerDown(canvas(), { pointerId: 1, clientX: 400, clientY: 200 });
fireEvent.pointerMove(canvas(), { pointerId: 1, clientX: 200, clientY: 120 });
fireEvent.pointerUp(canvas(), { pointerId: 1, clientX: 200, clientY: 120 });
expect(currentTranslate()).not.toEqual(fitted);
fireEvent.click(screen.getByRole("button", { name: "Fit to view" }));
expect(currentTranslate().x).toBeCloseTo(fitted.x, 5);
expect(currentTranslate().y).toBeCloseTo(fitted.y, 5);
});
it("stops at 400% and disables Zoom in at the ceiling", () => {
render(<Harness />);
for (let i = 0; i < 20; i++) {
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
}
expect(currentScale()).toBe(4);
expect(screen.getByText("400%")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zoom in" })).toBeDisabled();
});
it("re-fits on reopen instead of inheriting the previous session's zoom", async () => {
render(<Harness initialOpen={false} />);
fireEvent.click(screen.getByTestId("opener"));
await screen.findByRole("application");
const fitted = currentScale();
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
expect(currentScale()).toBeGreaterThan(fitted);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("application")).toBeNull());
fireEvent.click(screen.getByTestId("opener"));
await screen.findByRole("application");
// Reopening is a fresh read; carrying a stale zoom over would drop the
// reader somewhere in the middle of a diagram they just reopened.
expect(currentScale()).toBeCloseTo(fitted, 5);
});
it("reopens showing the diagram even if it was left on the source view", async () => {
render(<Harness initialOpen={false} />);
fireEvent.click(screen.getByTestId("opener"));
await screen.findByRole("application");
fireEvent.click(screen.getByRole("button", { name: "Show source" }));
expect(screen.queryByRole("application")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByTestId("code-block-static")).toBeNull());
fireEvent.click(screen.getByTestId("opener"));
expect(await screen.findByRole("application")).toBeInTheDocument();
});
it("stops at 25% and disables Zoom out at the floor", () => {
render(<Harness />);
for (let i = 0; i < 20; i++) {
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
}
expect(currentScale()).toBe(0.25);
expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zoom out" })).toBeDisabled();
});
});
describe("MermaidViewer canvas interaction", () => {
it("pans with a mouse drag", () => {
render(<Harness />);
const before = currentTranslate();
fireEvent.pointerDown(canvas(), { pointerId: 1, clientX: 300, clientY: 200 });
fireEvent.pointerMove(canvas(), { pointerId: 1, clientX: 350, clientY: 230 });
const after = currentTranslate();
expect(after.x).toBeCloseTo(before.x + 50, 5);
expect(after.y).toBeCloseTo(before.y + 30, 5);
});
it("ignores right-button drags so context-menu clicks do not shift the diagram", () => {
render(<Harness />);
const before = currentTranslate();
fireEvent.pointerDown(canvas(), {
pointerId: 1,
button: 2,
pointerType: "mouse",
clientX: 300,
clientY: 200,
});
fireEvent.pointerMove(canvas(), { pointerId: 1, clientX: 350, clientY: 230 });
expect(currentTranslate()).toEqual(before);
});
it("zooms on wheel, anchored so the point under the cursor stays put", () => {
render(<Harness />);
const before = { scale: currentScale(), ...currentTranslate() };
const anchor = { x: 200, y: 150 };
fireEvent.wheel(canvas(), { deltaY: -100, clientX: anchor.x, clientY: anchor.y });
const after = { scale: currentScale(), ...currentTranslate() };
expect(after.scale).toBeGreaterThan(before.scale);
// Same content coordinate under the cursor before and after.
expect((anchor.x - after.x) / after.scale).toBeCloseTo(
(anchor.x - before.x) / before.scale,
3,
);
});
it("zooms out on the opposite wheel direction", () => {
render(<Harness />);
const before = currentScale();
fireEvent.wheel(canvas(), { deltaY: 100, clientX: 200, clientY: 150 });
expect(currentScale()).toBeLessThan(before);
});
it("pinch-zooms with two touch pointers", () => {
render(<Harness />);
const before = currentScale();
fireEvent.pointerDown(canvas(), {
pointerId: 1,
pointerType: "touch",
clientX: 300,
clientY: 200,
});
fireEvent.pointerDown(canvas(), {
pointerId: 2,
pointerType: "touch",
clientX: 400,
clientY: 200,
});
// Fingers spread from 100px apart to 200px apart → ~2x.
fireEvent.pointerMove(canvas(), {
pointerId: 2,
pointerType: "touch",
clientX: 500,
clientY: 200,
});
expect(currentScale()).toBeCloseTo(before * 2, 2);
});
it("zooms with the +/- keys and refits with 0", () => {
render(<Harness />);
const fitted = currentScale();
fireEvent.keyDown(canvas(), { key: "+" });
expect(currentScale()).toBeGreaterThan(fitted);
fireEvent.keyDown(canvas(), { key: "-" });
expect(currentScale()).toBeCloseTo(fitted, 5);
fireEvent.keyDown(canvas(), { key: "+" });
fireEvent.keyDown(canvas(), { key: "0" });
expect(currentScale()).toBeCloseTo(fitted, 5);
});
it("pans with the arrow keys", () => {
render(<Harness />);
const before = currentTranslate();
fireEvent.keyDown(canvas(), { key: "ArrowRight" });
// ArrowRight moves the viewport right, i.e. the content left.
expect(currentTranslate().x).toBeLessThan(before.x);
});
it("never lets the diagram be panned entirely out of view", () => {
render(<Harness />);
fireEvent.pointerDown(canvas(), { pointerId: 1, clientX: 400, clientY: 200 });
fireEvent.pointerMove(canvas(), { pointerId: 1, clientX: -100000, clientY: -100000 });
const { x, y } = currentTranslate();
const scale = currentScale();
// At least a sliver of content still overlaps the viewport.
expect(x + LAYOUT.width * scale).toBeGreaterThan(0);
expect(y + LAYOUT.height * scale).toBeGreaterThan(0);
});
});
describe("MermaidViewer source and export", () => {
it("copies the Mermaid source", async () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Copy diagram source" }));
await waitFor(() => {
expect(copyTextMock).toHaveBeenCalledWith(CHART);
});
});
it("toggles to the source view and back, because Issue/Comment is where agent output gets audited", async () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Show source" }));
expect(screen.queryByRole("application")).toBeNull();
expect(screen.getByText(/graph LR/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show diagram" }));
expect(screen.getByRole("application")).toBeInTheDocument();
});
it("offers PNG, SVG and .mmd exports", async () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Export" }));
expect(await screen.findByText("Download PNG")).toBeInTheDocument();
expect(screen.getByText("Download SVG")).toBeInTheDocument();
expect(screen.getByText("Download .mmd")).toBeInTheDocument();
});
it("downloads an .mmd file containing the original source", async () => {
const createObjectURL = vi.fn((_blob: Blob) => "blob:mock");
const revokeObjectURL = vi.fn((_url: string) => {});
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL });
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Export" }));
fireEvent.click(await screen.findByText("Download .mmd"));
await waitFor(() => {
expect(clickSpy).toHaveBeenCalled();
});
const blob = createObjectURL.mock.calls[0]![0];
await expect(blob.text()).resolves.toBe(CHART);
vi.unstubAllGlobals();
});
});