Files
multica/packages/views/editor/utils/diagram-transform.ts
Naiyuan Qing 17ee59ccc5 feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908) (#5564)
* feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908)

Mermaid's fullscreen view was a bespoke lightbox: no visible close button, no
canvas interaction, and a toolbar that scrolled away with wide diagrams. Escape
also stopped working once the iframe took focus, which is why it read as
"impossible to close".

Replace it with a viewer built on the shared Dialog, so it inherits the
backdrop, focus trap, scroll lock, focus restore and Escape handling that HTML
preview already had.

The diagram stays in an `sandbox=""` iframe — isolation is not relaxed to buy
interactivity. Instead the iframe is `pointer-events: none` and pan/zoom is
applied from the host document as a transform on its wrapper. That inversion is
also what keeps Escape working: the iframe can never take focus.

- Viewer: fit-on-open, drag pan, wheel/pinch/keyboard zoom (25%-400%) anchored
  at the pointer, Fit / 100% / Reset, and a header toolbar that cannot scroll
  away. Panning is clamped so the canvas can never be lost.
- Export: `.mmd`, `.svg` and `.png`. Requires `htmlLabels: false` — browsers do
  not rasterize Mermaid's default HTML-in-foreignObject labels through an
  `<img>`; verified in Chromium that they paint zero pixels AND taint the
  canvas, so PNG export silently produced nothing.
- Inline: toolbar lifted out of the scroll container, natural-size rendering
  (small diagrams centered, wide ones scroll at a readable size with edge
  fades), copy source, and a parser message on the error fallback.
- Theme switches no longer close the viewer or discard zoom/position; the
  previous diagram stays on screen until its replacement is ready.

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

* fix(editor): stop Mermaid drags selecting text and misfiring the viewer (MUL-4908)

Two gesture defects on the inline diagram, reported by Naiyuan.

1. Dragging a diagram started a native text selection. The iframe is a replaced
   element, so the whole diagram box got painted with the selection highlight
   and the drag ran on into the surrounding comment text. Fixed with
   `user-select: none` on the inline scroller and the viewer canvas.

   Deliberately NOT by preventDefault-ing pointerdown: verified in Chromium that
   it also drops the default focus, which silently kills the viewer's keyboard
   controls (+/-, 0, arrows).

2. Any drag ended in a `click`, so trying to look along a wide diagram opened
   the viewer on top of the user. The inline diagram had no drag handling at
   all — only `onClick`. Suppressing the selection alone would have made this
   fire more reliably, not less, so both had to land together.

   Past a 5px threshold the gesture is a drag for good: releasing no longer
   taps, and a horizontal drag pans the scroller instead. This holds even when
   the diagram has no room to scroll, so an unscrollable diagram cannot open on
   release either. A still click, a sub-threshold jitter, and the expand button
   all still open the viewer.

Touch is left alone — it already pans natively and its vertical drags belong to
the page; the browser announces its takeover with pointercancel. Mouse and pen
have no native drag-to-scroll and are panned here.

Verified in Chromium: still click opens; a 150px drag pans and does not open; a
drag on an unscrollable diagram does not open; 3px jitter still opens; and no
gesture selects text.

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

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 16:07:37 +08:00

212 lines
6.1 KiB
TypeScript

/**
* Pan/zoom math for the Mermaid diagram viewer.
*
* The diagram itself lives inside an empty-sandbox iframe, which cannot run
* scripts and therefore cannot implement its own pan/zoom. All interaction is
* driven from the host document by transforming the wrapper around that
* iframe, so this module stays pure DOM-free math: it is the single place
* where "where is the diagram 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 DiagramTransform {
scale: number;
x: number;
y: number;
}
export const MIN_SCALE = 0.25;
export const MAX_SCALE = 4;
// How much of the diagram 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): number {
if (!Number.isFinite(scale)) return 1;
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, 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 opened in a large viewer should read at 100%, not be
* blown up until its strokes look broken — same convention as macOS Preview.
*/
export function computeFitScale(content: Size, viewport: Size): number {
if (!hasArea(content) || !hasArea(viewport)) return 1;
return clampScale(
Math.min(1, viewport.width / content.width, viewport.height / content.height),
);
}
/** Centers `content` at `scale` inside `viewport`. */
export function centerTransform(
content: Size,
viewport: Size,
scale: number,
): DiagramTransform {
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): DiagramTransform {
return centerTransform(content, viewport, computeFitScale(content, viewport));
}
/**
* Keeps at least `MIN_VISIBLE_PX` of the diagram inside the viewport (or all of
* it, when the diagram is smaller than that margin), so the canvas can never be
* panned into nothing.
*/
export function clampTransform(
transform: DiagramTransform,
content: Size,
viewport: Size,
): DiagramTransform {
if (!hasArea(content) || !hasArea(viewport)) return transform;
const scale = clampScale(transform.scale);
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: DiagramTransform,
nextScale: number,
anchor: Point,
content: Size,
viewport: Size,
): DiagramTransform {
const scale = clampScale(nextScale);
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: DiagramTransform,
factor: number,
anchor: Point,
content: Size,
viewport: Size,
): DiagramTransform {
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: DiagramTransform,
factor: number,
content: Size,
viewport: Size,
): DiagramTransform {
return zoomByAt(
transform,
factor,
{ x: viewport.width / 2, y: viewport.height / 2 },
content,
viewport,
);
}
export function panBy(
transform: DiagramTransform,
deltaX: number,
deltaY: number,
content: Size,
viewport: Size,
): DiagramTransform {
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);
}