Files
multica/packages/views/editor/styles/zoom-canvas.css
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

74 lines
2.4 KiB
CSS

/*
* Shared pan/zoom canvas — the Mermaid viewer and the image attachment
* preview both render through `ZoomCanvas` (editor/zoom-canvas.tsx).
*
* Not scoped to `.rich-text-editor`: both viewers are portaled to
* document.body and never render inside the editor subtree. The background is
* deliberately left to the caller so each surface can pick its own backdrop.
*/
.zoom-canvas {
position: relative;
width: 100%;
/* Flex owns the height: the header is shrink-0 and the canvas takes the rest.
`min-height: 0` is what allows it to shrink below content size instead of
pushing the toolbar out of the dialog. */
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
cursor: grab;
/* Claim every touch gesture: browser pan/pinch would otherwise scroll or
zoom the page underneath instead of moving the content. */
touch-action: none;
/* A pan that leaves the canvas would otherwise start a native text selection
and highlight the toolbar text plus the whole content box. Done in CSS
rather than by preventDefault-ing pointerdown, which would also suppress
the default focus and silently kill the keyboard controls. */
user-select: none;
outline: none;
}
.zoom-canvas:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.zoom-canvas.is-panning {
cursor: grabbing;
}
.zoom-canvas-content {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
}
/* Only discrete controls (toolbar buttons, +/-/0, arrow keys) ease between
states. Drag and wheel/pinch set `is-animated` off so the content tracks the
input exactly — easing direct manipulation reads as lag, not polish. */
.zoom-canvas-content.is-animated {
transition: transform 150ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.zoom-canvas-content.is-animated {
transition: none;
}
}
/* Unmeasured fallback: content whose natural size can't be read (an SVG with
only a viewBox has no intrinsic size in Chromium) has nothing to build a
transform against. Keep the same element chain as the measured branch so
React swaps classes instead of remounting — a remount would drop a decoded
image and flash — and just letterbox the content in place. */
.zoom-canvas-fit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}