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

201 lines
7.0 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,
distanceBetween,
midpointOf,
panBy,
wheelZoomFactor,
zoomByAtCenter,
zoomToAt,
type DiagramTransform,
} from "./diagram-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("clamps a diagram too large for even the minimum zoom", () => {
expect(computeFitScale({ width: 100000, height: 100000 }, VIEWPORT)).toBe(MIN_SCALE);
});
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: DiagramTransform = { 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: DiagramTransform = { 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: DiagramTransform = { 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: DiagramTransform = { 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 });
});
});