mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
123 lines
4.6 KiB
TypeScript
123 lines
4.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { buildExportSvg, diagramFilenameStem } from "./mermaid-export";
|
|
|
|
const BASE_OPTIONS = {
|
|
background: "rgb(240, 240, 240)",
|
|
fontFamily: "Inter, sans-serif",
|
|
width: 800,
|
|
height: 600,
|
|
};
|
|
|
|
function parse(markup: string): SVGElement {
|
|
const doc = new DOMParser().parseFromString(markup, "image/svg+xml");
|
|
return doc.documentElement as unknown as SVGElement;
|
|
}
|
|
|
|
describe("buildExportSvg", () => {
|
|
it("replaces Mermaid's percentage width with concrete pixels an <img> can size against", () => {
|
|
// Mermaid renders for embedding: width="100%" has no basis in a file, and
|
|
// the PNG path's <img> would have no intrinsic size to draw.
|
|
const result = buildExportSvg(
|
|
'<svg width="100%" viewBox="0 0 800 600"><g/></svg>',
|
|
BASE_OPTIONS,
|
|
);
|
|
|
|
const root = parse(result!);
|
|
expect(root.getAttribute("width")).toBe("800");
|
|
expect(root.getAttribute("height")).toBe("600");
|
|
});
|
|
|
|
it("drops the host-tuned max-width so the export is not clipped to the old container", () => {
|
|
const result = buildExportSvg(
|
|
'<svg style="max-width: 320px;" viewBox="0 0 800 600"><g/></svg>',
|
|
BASE_OPTIONS,
|
|
);
|
|
|
|
expect(result).not.toContain("max-width");
|
|
});
|
|
|
|
it("paints an opaque background so the export is not transparent-on-transparent", () => {
|
|
const result = buildExportSvg('<svg viewBox="0 0 800 600"><g/></svg>', BASE_OPTIONS);
|
|
|
|
const root = parse(result!);
|
|
const rect = root.firstElementChild;
|
|
expect(rect?.tagName.toLowerCase()).toBe("rect");
|
|
expect(rect?.getAttribute("fill")).toBe("rgb(240, 240, 240)");
|
|
});
|
|
|
|
it("covers a negative-origin viewBox, so padded diagrams do not export with transparent margins", () => {
|
|
// Mermaid emits negative viewBox origins for diagrams with padding; a
|
|
// 0,0-anchored background rect would leave those margins unpainted.
|
|
const result = buildExportSvg('<svg viewBox="-8 -12 800 600"><g/></svg>', BASE_OPTIONS);
|
|
|
|
const rect = parse(result!).firstElementChild;
|
|
expect(rect?.getAttribute("x")).toBe("-8");
|
|
expect(rect?.getAttribute("y")).toBe("-12");
|
|
expect(rect?.getAttribute("width")).toBe("800");
|
|
expect(rect?.getAttribute("height")).toBe("600");
|
|
});
|
|
|
|
it("falls back to the given size when the viewBox is missing or malformed", () => {
|
|
const rect = parse(buildExportSvg("<svg><g/></svg>", BASE_OPTIONS)!).firstElementChild;
|
|
expect(rect?.getAttribute("width")).toBe("800");
|
|
expect(rect?.getAttribute("height")).toBe("600");
|
|
|
|
const bad = parse(buildExportSvg('<svg viewBox="nonsense"><g/></svg>', BASE_OPTIONS)!);
|
|
expect(bad.firstElementChild?.getAttribute("width")).toBe("800");
|
|
});
|
|
|
|
it("resolves font-family, which has nothing to inherit from in a standalone file", () => {
|
|
const result = buildExportSvg('<svg viewBox="0 0 800 600"><g/></svg>', BASE_OPTIONS);
|
|
|
|
expect(result).toContain("Inter, sans-serif");
|
|
});
|
|
|
|
it("declares the SVG namespace so the file opens outside a browser document", () => {
|
|
const result = buildExportSvg('<svg viewBox="0 0 800 600"><g/></svg>', BASE_OPTIONS);
|
|
|
|
expect(result).toContain("http://www.w3.org/2000/svg");
|
|
});
|
|
|
|
it("preserves the diagram content", () => {
|
|
const result = buildExportSvg(
|
|
'<svg viewBox="0 0 800 600"><text>Start</text></svg>',
|
|
BASE_OPTIONS,
|
|
);
|
|
|
|
expect(result).toContain("Start");
|
|
});
|
|
|
|
it("returns null on unparseable markup instead of emitting a corrupt file", () => {
|
|
expect(buildExportSvg("<svg><unclosed>", BASE_OPTIONS)).toBeNull();
|
|
});
|
|
|
|
it("returns null when the root is not an <svg>", () => {
|
|
expect(buildExportSvg("<div>not a diagram</div>", BASE_OPTIONS)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("diagramFilenameStem", () => {
|
|
it("derives the stem from the first meaningful line", () => {
|
|
expect(diagramFilenameStem("graph LR\n A --> B")).toBe("graph-lr");
|
|
});
|
|
|
|
it("skips %% comments and blank lines to reach the real declaration", () => {
|
|
expect(diagramFilenameStem("\n%% title: ignored\n\nsequenceDiagram\n A->>B: hi")).toBe(
|
|
"sequencediagram",
|
|
);
|
|
});
|
|
|
|
it("falls back to 'diagram' when nothing usable survives slugification", () => {
|
|
expect(diagramFilenameStem("")).toBe("diagram");
|
|
expect(diagramFilenameStem("%%%%")).toBe("diagram");
|
|
expect(diagramFilenameStem("中文标题")).toBe("diagram");
|
|
});
|
|
|
|
it("caps length and leaves no trailing separator for the extension to butt against", () => {
|
|
const stem = diagramFilenameStem("graph LR with an extremely long trailing description here");
|
|
|
|
expect(stem.length).toBeLessThanOrEqual(40);
|
|
expect(stem.endsWith("-")).toBe(false);
|
|
});
|
|
});
|