Files
multica/packages/views/editor/utils/zoom-transform.test.ts
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

239 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
MAX_SCALE,
MIN_SCALE,
centerTransform,
clampScale,
clampTransform,
computeFitScale,
computeFitTransform,
computeMinScale,
distanceBetween,
midpointOf,
panBy,
wheelZoomFactor,
zoomByAtCenter,
zoomToAt,
type ZoomTransform,
} from "./zoom-transform";
const VIEWPORT = { width: 1000, height: 600 };
describe("clampScale", () => {
it("holds zoom inside the 25%400% product range", () => {
expect(clampScale(0.01)).toBe(MIN_SCALE);
expect(clampScale(99)).toBe(MAX_SCALE);
expect(clampScale(1.5)).toBe(1.5);
});
it("falls back to 1 for non-finite input rather than propagating NaN into the transform", () => {
// NaN/Infinity can only reach here from a degenerate measurement; a safe
// default beats poisoning every later transform with NaN.
expect(clampScale(Number.NaN)).toBe(1);
expect(clampScale(Number.POSITIVE_INFINITY)).toBe(1);
});
});
describe("computeFitScale", () => {
it("shrinks an oversized diagram to the tighter axis", () => {
// Width needs 0.5, height needs 0.6 — the diagram only fits at 0.5.
expect(computeFitScale({ width: 2000, height: 1000 }, VIEWPORT)).toBe(0.5);
});
it("leaves a small diagram at natural size instead of magnifying it", () => {
expect(computeFitScale({ width: 100, height: 80 }, VIEWPORT)).toBe(1);
});
it("goes below the default minimum for content that cannot fit at 25%", () => {
// A full-page screenshot is routinely 10x taller than the canvas. Flooring
// the fit at MIN_SCALE would open it already cropped, with no zoom level
// that shows all of it.
const fit = computeFitScale({ width: 1600, height: 8000 }, VIEWPORT);
expect(fit).toBeLessThan(MIN_SCALE);
expect(fit).toBeCloseTo(VIEWPORT.height / 8000, 10);
});
it("returns 1 when either size is unknown, so a 0x0 measure never divides by zero", () => {
expect(computeFitScale({ width: 0, height: 0 }, VIEWPORT)).toBe(1);
expect(computeFitScale({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(1);
});
});
describe("computeFitTransform", () => {
it("centers the fitted diagram", () => {
const transform = computeFitTransform({ width: 2000, height: 1000 }, VIEWPORT);
expect(transform.scale).toBe(0.5);
// 2000*0.5 = 1000 wide → exactly fills, no horizontal offset.
expect(transform.x).toBe(0);
// 1000*0.5 = 500 tall in a 600 viewport → 50px of slack each side.
expect(transform.y).toBe(50);
});
});
describe("clampTransform", () => {
const content = { width: 400, height: 300 };
it("keeps the diagram reachable when panned far off the left edge", () => {
const clamped = clampTransform({ scale: 1, x: -5000, y: 0 }, content, VIEWPORT);
// At least 48px of the 400px-wide diagram stays on screen.
expect(clamped.x).toBe(48 - 400);
expect(clamped.x + content.width).toBe(48);
});
it("keeps the diagram reachable when panned far off the right edge", () => {
const clamped = clampTransform({ scale: 1, x: 9999, y: 0 }, content, VIEWPORT);
expect(clamped.x).toBe(VIEWPORT.width - 48);
});
it("requires a diagram smaller than the margin to stay fully visible", () => {
// A 20px-wide diagram must not be allowed to hide 48px off-screen —
// that would leave nothing at all on the canvas.
const clamped = clampTransform({ scale: 1, x: -9999, y: 0 }, { width: 20, height: 20 }, VIEWPORT);
expect(clamped.x).toBe(0);
});
it("leaves an in-bounds transform untouched", () => {
const transform: ZoomTransform = { scale: 1, x: 100, y: 50 };
expect(clampTransform(transform, content, VIEWPORT)).toEqual(transform);
});
});
describe("zoomToAt", () => {
const content = { width: 1000, height: 1000 };
it("pins the content point under the anchor so zoom tracks the cursor", () => {
const start: ZoomTransform = { scale: 1, x: 0, y: 0 };
const anchor = { x: 200, y: 100 };
const zoomed = zoomToAt(start, 2, anchor, content, VIEWPORT);
// The content coordinate under the anchor before and after must match.
const contentXBefore = (anchor.x - start.x) / start.scale;
const contentXAfter = (anchor.x - zoomed.x) / zoomed.scale;
expect(contentXAfter).toBeCloseTo(contentXBefore, 5);
const contentYBefore = (anchor.y - start.y) / start.scale;
const contentYAfter = (anchor.y - zoomed.y) / zoomed.scale;
expect(contentYAfter).toBeCloseTo(contentYBefore, 5);
});
it("clamps to the max zoom rather than overshooting", () => {
const zoomed = zoomToAt({ scale: 1, x: 0, y: 0 }, 50, { x: 0, y: 0 }, content, VIEWPORT);
expect(zoomed.scale).toBe(MAX_SCALE);
});
it("clamps to the min zoom rather than undershooting", () => {
const zoomed = zoomToAt({ scale: 1, x: 0, y: 0 }, 0.001, { x: 0, y: 0 }, content, VIEWPORT);
expect(zoomed.scale).toBe(MIN_SCALE);
});
it("is a no-op once already at the limit, so held keys/wheel do not drift the diagram", () => {
const atMax: ZoomTransform = { scale: MAX_SCALE, x: 10, y: 20 };
expect(zoomToAt(atMax, MAX_SCALE * 2, { x: 100, y: 100 }, content, VIEWPORT)).toBe(atMax);
});
});
describe("zoomByAtCenter", () => {
it("zooms about the viewport center for button/keyboard input", () => {
const start: ZoomTransform = { scale: 1, x: 0, y: 0 };
const center = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };
const zoomed = zoomByAtCenter(start, 2, { width: 1000, height: 1000 }, VIEWPORT);
expect(zoomed.scale).toBe(2);
expect((center.x - zoomed.x) / zoomed.scale).toBeCloseTo(center.x - start.x, 5);
});
});
describe("panBy", () => {
it("moves by the delta and re-clamps", () => {
const panned = panBy({ scale: 1, x: 0, y: 0 }, 50, -20, { width: 400, height: 300 }, VIEWPORT);
expect(panned).toEqual({ scale: 1, x: 50, y: -20 });
});
it("cannot pan the diagram out of sight", () => {
const panned = panBy({ scale: 1, x: 0, y: 0 }, -100000, 0, { width: 400, height: 300 }, VIEWPORT);
expect(panned.x + 400).toBe(48);
});
});
describe("centerTransform", () => {
it("centers content at an explicit scale", () => {
expect(centerTransform({ width: 400, height: 200 }, VIEWPORT, 1)).toEqual({
scale: 1,
x: 300,
y: 200,
});
});
});
describe("wheelZoomFactor", () => {
it("zooms in when scrolling up and out when scrolling down", () => {
expect(wheelZoomFactor(-100, 0)).toBeGreaterThan(1);
expect(wheelZoomFactor(100, 0)).toBeLessThan(1);
});
it("is symmetric, so equal opposite scrolls return to the original scale", () => {
expect(wheelZoomFactor(-100, 0) * wheelZoomFactor(100, 0)).toBeCloseTo(1, 10);
});
it("normalizes line and page delta modes so a mouse wheel is not 16x weaker than a trackpad", () => {
// deltaMode 1 (lines) — one line is treated as 16 pixels.
expect(wheelZoomFactor(1, 1)).toBeCloseTo(wheelZoomFactor(16, 0), 10);
// deltaMode 2 (pages) — one page is treated as 100 pixels.
expect(wheelZoomFactor(1, 2)).toBeCloseTo(wheelZoomFactor(100, 0), 10);
});
});
describe("pinch helpers", () => {
it("measures distance between two pointers", () => {
expect(distanceBetween({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
});
it("finds the midpoint used as the pinch anchor", () => {
expect(midpointOf({ x: 0, y: 0 }, { x: 10, y: 20 })).toEqual({ x: 5, y: 10 });
});
});
describe("computeMinScale", () => {
it("is the default floor for content that fits comfortably", () => {
expect(computeMinScale({ width: 800, height: 400 }, VIEWPORT)).toBe(MIN_SCALE);
});
it("drops to the fit scale for content too large to fit at the default floor", () => {
const content = { width: 1600, height: 8000 };
expect(computeMinScale(content, VIEWPORT)).toBe(computeFitScale(content, VIEWPORT));
});
it("keeps the fitted transform of a long screenshot intact through a clamp", () => {
// Regression guard: with a hard 0.25 floor, clampTransform snapped the fit
// scale back up and the image opened cropped.
const content = { width: 1600, height: 8000 };
const fitted = computeFitTransform(content, VIEWPORT);
expect(clampTransform(fitted, content, VIEWPORT)).toEqual(fitted);
expect(content.height * fitted.scale).toBeLessThanOrEqual(VIEWPORT.height);
});
it("lets zoomToAt reach the fit scale of a long screenshot", () => {
const content = { width: 1600, height: 8000 };
const fitScale = computeFitScale(content, VIEWPORT);
const zoomed = zoomToAt({ scale: 1, x: 0, y: 0 }, 0.0001, { x: 0, y: 0 }, content, VIEWPORT);
expect(zoomed.scale).toBe(fitScale);
});
});