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

235 lines
7.1 KiB
TypeScript

/**
* Pan/zoom math for the shared zoom canvas — Mermaid diagrams and image
* previews both drive their viewer through this module.
*
* Content is never asked to zoom itself: a Mermaid diagram lives inside an
* empty-sandbox iframe that cannot run scripts, and an `<img>` has no zoom of
* its own either. All interaction is driven from the host document by
* transforming the wrapper around the content, so this module stays pure
* DOM-free math: it is the single place where "where is the content and how
* big is it" is decided.
*
* Coordinate model: the content wrapper has `transform-origin: 0 0` and is
* positioned at the viewport's top-left, so a transform is applied as
* `translate(x, y) scale(scale)`. `x`/`y` are viewport-space pixels of the
* content's top-left corner; `scale` is the natural-size multiplier.
*/
export interface Size {
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
export interface ZoomTransform {
scale: number;
x: number;
y: number;
}
// Default lower zoom bound. Only a default: `computeMinScale` lowers it for
// content that cannot fit at 25% — see the comment there.
export const MIN_SCALE = 0.25;
export const MAX_SCALE = 4;
// How much of the content must stay inside the viewport. Panning is clamped so
// the user can never fling the canvas out of sight and be left staring at an
// empty viewport with no way back other than Reset.
const MIN_VISIBLE_PX = 48;
// Keyboard/button zoom step. 1.2 gives ~4 presses per doubling, which feels
// responsive without skipping past the scale the user wanted.
export const ZOOM_STEP = 1.2;
// Keyboard pan step, in viewport pixels per arrow-key press.
export const PAN_STEP_PX = 48;
export function clampScale(scale: number, minScale: number = MIN_SCALE): number {
if (!Number.isFinite(scale)) return 1;
return Math.min(MAX_SCALE, Math.max(minScale, scale));
}
function hasArea(size: Size): boolean {
return (
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0
);
}
/**
* Scale that makes the content fit the viewport, never magnifying past natural
* size. A small diagram or thumbnail opened in a large viewer should read at
* 100%, not be blown up until it looks broken — same convention as macOS
* Preview.
*
* Deliberately NOT floored at MIN_SCALE: a full-page screenshot ten times
* taller than the canvas fits at ~0.1, and returning 0.25 instead would open
* it already cropped.
*/
export function computeFitScale(content: Size, viewport: Size): number {
if (!hasArea(content) || !hasArea(viewport)) return 1;
return Math.min(
MAX_SCALE,
Math.min(1, viewport.width / content.width, viewport.height / content.height),
);
}
/**
* Lower zoom bound for a given content/viewport pair: MIN_SCALE normally, but
* never above the fit scale.
*
* Without this, "fit to view" is unreachable for content more than 4x the
* canvas in either axis — exactly the long-screenshot case — because every
* clamp would snap the scale back up to 25% and leave the content cropped
* with no way to see all of it.
*/
export function computeMinScale(content: Size, viewport: Size): number {
return Math.min(MIN_SCALE, computeFitScale(content, viewport));
}
/** Centers `content` at `scale` inside `viewport`. */
export function centerTransform(
content: Size,
viewport: Size,
scale: number,
): ZoomTransform {
return {
scale,
x: (viewport.width - content.width * scale) / 2,
y: (viewport.height - content.height * scale) / 2,
};
}
/** Default view: fit to viewport, centered. */
export function computeFitTransform(content: Size, viewport: Size): ZoomTransform {
return centerTransform(content, viewport, computeFitScale(content, viewport));
}
/**
* Keeps at least `MIN_VISIBLE_PX` of the content inside the viewport (or all of
* it, when the content is smaller than that margin), so the canvas can never be
* panned into nothing.
*/
export function clampTransform(
transform: ZoomTransform,
content: Size,
viewport: Size,
): ZoomTransform {
if (!hasArea(content) || !hasArea(viewport)) return transform;
const scale = clampScale(transform.scale, computeMinScale(content, viewport));
const scaledWidth = content.width * scale;
const scaledHeight = content.height * scale;
const marginX = Math.min(MIN_VISIBLE_PX, scaledWidth);
const marginY = Math.min(MIN_VISIBLE_PX, scaledHeight);
return {
scale,
x: Math.min(
Math.max(transform.x, marginX - scaledWidth),
viewport.width - marginX,
),
y: Math.min(
Math.max(transform.y, marginY - scaledHeight),
viewport.height - marginY,
),
};
}
/**
* Zooms to `nextScale` while pinning the content point under `anchor` (in
* viewport coordinates) to that same spot. This is what makes wheel and pinch
* zoom track the cursor/fingers instead of drifting toward a corner.
*/
export function zoomToAt(
transform: ZoomTransform,
nextScale: number,
anchor: Point,
content: Size,
viewport: Size,
): ZoomTransform {
const scale = clampScale(nextScale, computeMinScale(content, viewport));
if (scale === transform.scale) return transform;
const ratio = scale / transform.scale;
return clampTransform(
{
scale,
x: anchor.x - (anchor.x - transform.x) * ratio,
y: anchor.y - (anchor.y - transform.y) * ratio,
},
content,
viewport,
);
}
/** Multiplicative zoom (wheel notch, +/- key, toolbar button) about `anchor`. */
export function zoomByAt(
transform: ZoomTransform,
factor: number,
anchor: Point,
content: Size,
viewport: Size,
): ZoomTransform {
return zoomToAt(transform, transform.scale * factor, anchor, content, viewport);
}
/** Zoom about the viewport center — the anchor to use for keyboard/buttons. */
export function zoomByAtCenter(
transform: ZoomTransform,
factor: number,
content: Size,
viewport: Size,
): ZoomTransform {
return zoomByAt(
transform,
factor,
{ x: viewport.width / 2, y: viewport.height / 2 },
content,
viewport,
);
}
export function panBy(
transform: ZoomTransform,
deltaX: number,
deltaY: number,
content: Size,
viewport: Size,
): ZoomTransform {
return clampTransform(
{ scale: transform.scale, x: transform.x + deltaX, y: transform.y + deltaY },
content,
viewport,
);
}
/** Distance between two active pointers — the pinch gesture's scale signal. */
export function distanceBetween(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
export function midpointOf(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
/**
* Converts a wheel notch into a zoom factor. `deltaMode` matters: mice report
* lines (1) or pages (2) while trackpads report pixels (0), so a raw `deltaY`
* comparison would make one of the two unusable.
*/
export function wheelZoomFactor(deltaY: number, deltaMode: number): number {
const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 100 : deltaY;
// Exponential mapping keeps zooming symmetric: equal and opposite scrolls
// return to the original scale. The 400 divisor tunes trackpad sensitivity.
return Math.exp(-pixels / 400);
}