返回 DeepSeek-Reasonix
mermaidPanZoom.ts
根目录 / desktop / frontend / src / components / mermaidPanZoom.ts
1 // Inline pan/zoom for the Mermaid preview, replacing svg-pan-zoom (#8068):
2 // that library inverts the live SVG matrix on wheel/drag and crashes with a
3 // singular matrix on hidden or zero-sized layouts. Everything here is a plain
4 // translate/scale string applied to one viewport <g> — no matrix inversion.
5 //
6 // Scale is relative to the fit scale (1 = diagram fits the container); pan is
7 // in screen pixels. The SVG keeps width/height 100% with the browser's own
8 // viewBox "meet" mapping, which already fits and centers the drawing, so
9 // fit/center simply reset the user transform.
10
11 export interface MermaidPanZoomOptions {
12 minZoom?: number;
13 maxZoom?: number;
14 zoomScaleSensitivity?: number;
15 }
16
17 export interface MermaidPanZoomInstance {
18 destroy(): void;
19 resize(): void;
20 fit(): void;
21 center(): void;
22 zoomIn(): void;
23 zoomOut(): void;
24 reset(): void;
25 }
26
27 const VIEWPORT_ATTR = "data-mermaid-pan-zoom-viewport";
28 const SVG_NS = "http://www.w3.org/2000/svg";
29
30 type ViewBox = { x: number; y: number; w: number; h: number };
31
32 function readViewBox(svg: SVGSVGElement): ViewBox | null {
33 const raw = svg.getAttribute("viewBox");
34 if (!raw) return null;
35 const parts = raw.trim().split(/[\s,]+/).map(Number);
36 if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)) || parts[2] <= 0 || parts[3] <= 0) return null;
37 return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
38 }
39
40 function ensureViewport(svg: SVGSVGElement): SVGGElement {
41 const existing = svg.querySelector<SVGGElement>(`g[${VIEWPORT_ATTR}]`);
42 if (existing) return existing;
43 const viewport = document.createElementNS(SVG_NS, "g");
44 viewport.setAttribute(VIEWPORT_ATTR, "");
45 // Keep definitions in the SVG coordinate system. Mermaid commonly puts
46 // markers, clip paths, and gradients in <defs>; moving them under the
47 // transformed viewport changes their user coordinate system and can distort
48 // arrowheads or clipped/filled shapes.
49 for (const child of Array.from(svg.childNodes)) {
50 if (child.nodeType === 3 || (child.nodeType === 1 && (child as Element).tagName.toLowerCase() === "defs")) continue;
51 viewport.appendChild(child);
52 }
53 svg.appendChild(viewport);
54 return viewport;
55 }
56
57 // Geometry of the browser's meet mapping: drawing units → client pixels,
58 // including the centering offset inside the SVG's layout box.
59 function measure(svg: SVGSVGElement): { fitScale: number; originX: number; originY: number } | null {
60 const box = readViewBox(svg);
61 if (!box) return null;
62 const rect = svg.getBoundingClientRect();
63 if (rect.width <= 0 || rect.height <= 0) return null;
64 const fitScale = Math.min(rect.width / box.w, rect.height / box.h);
65 return {
66 fitScale,
67 originX: rect.left + (rect.width - box.w * fitScale) / 2 - box.x * fitScale,
68 originY: rect.top + (rect.height - box.h * fitScale) / 2 - box.y * fitScale,
69 };
70 }
71
72 export function createMermaidPanZoom(svg: SVGSVGElement, options: MermaidPanZoomOptions = {}): MermaidPanZoomInstance {
73 const minZoom = options.minZoom ?? 0.3;
74 const maxZoom = options.maxZoom ?? 8;
75 const sensitivity = options.zoomScaleSensitivity ?? 0.3;
76 const clampZoom = (zoom: number) => Math.min(maxZoom, Math.max(minZoom, zoom));
77
78 const viewport = ensureViewport(svg);
79 let scale = 1;
80 let panX = 0;
81 let panY = 0;
82
83 // Rounded to 0.1px/0.01% precision so long zoom chains keep clean transforms.
84 const num = (n: number) => Math.round(n * 1e4) / 1e4;
85
86 const apply = () => {
87 const m = measure(svg);
88 if (!m) return;
89 viewport.setAttribute("transform", `translate(${num(panX / m.fitScale)} ${num(panY / m.fitScale)}) scale(${num(scale)})`);
90 };
91
92 // Keep the content point under (clientX, clientY) fixed while zooming.
93 const zoomAt = (clientX: number, clientY: number, nextZoom: number) => {
94 const next = clampZoom(nextZoom);
95 if (next === scale) return;
96 const m = measure(svg);
97 if (m) {
98 const ratio = next / scale;
99 panX = (clientX - m.originX) * (1 - ratio) + panX * ratio;
100 panY = (clientY - m.originY) * (1 - ratio) + panY * ratio;
101 }
102 scale = next;
103 apply();
104 };
105
106 const center = () => {
107 const rect = svg.getBoundingClientRect();
108 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
109 };
110
111 const onWheel = (event: WheelEvent) => {
112 event.preventDefault();
113 zoomAt(event.clientX, event.clientY, scale * (event.deltaY < 0 ? 1 + sensitivity : 1 / (1 + sensitivity)));
114 };
115
116 let drag: { pointerId: number; x: number; y: number; panX: number; panY: number } | null = null;
117 const onPointerDown = (event: PointerEvent) => {
118 if (event.button !== 0) return;
119 drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX, panY };
120 try {
121 svg.setPointerCapture(event.pointerId);
122 } catch {
123 /* pointer capture is unavailable in some test DOMs */
124 }
125 };
126 const onPointerMove = (event: PointerEvent) => {
127 if (!drag || event.pointerId !== drag.pointerId) return;
128 panX = drag.panX + event.clientX - drag.x;
129 panY = drag.panY + event.clientY - drag.y;
130 apply();
131 };
132 const onPointerEnd = (event: PointerEvent) => {
133 if (drag?.pointerId === event.pointerId) drag = null;
134 };
135 const onDblClick = (event: MouseEvent) => {
136 event.preventDefault();
137 zoomAt(event.clientX, event.clientY, scale * (1 + sensitivity));
138 };
139
140 svg.addEventListener("wheel", onWheel, { passive: false });
141 svg.addEventListener("pointerdown", onPointerDown);
142 svg.addEventListener("pointermove", onPointerMove);
143 svg.addEventListener("pointerup", onPointerEnd);
144 svg.addEventListener("pointercancel", onPointerEnd);
145 svg.addEventListener("dblclick", onDblClick);
146
147 return {
148 destroy() {
149 svg.removeEventListener("wheel", onWheel);
150 svg.removeEventListener("pointerdown", onPointerDown);
151 svg.removeEventListener("pointermove", onPointerMove);
152 svg.removeEventListener("pointerup", onPointerEnd);
153 svg.removeEventListener("pointercancel", onPointerEnd);
154 svg.removeEventListener("dblclick", onDblClick);
155 drag = null;
156 },
157 resize() {
158 apply();
159 },
160 fit() {
161 scale = clampZoom(1);
162 panX = 0;
163 panY = 0;
164 apply();
165 },
166 center() {
167 panX = 0;
168 panY = 0;
169 apply();
170 },
171 zoomIn() {
172 const c = center();
173 zoomAt(c.x, c.y, scale * (1 + sensitivity));
174 },
175 zoomOut() {
176 const c = center();
177 zoomAt(c.x, c.y, scale / (1 + sensitivity));
178 },
179 reset() {
180 scale = 1;
181 panX = 0;
182 panY = 0;
183 apply();
184 },
185 };
186 }
187
187 lines TYPESCRIPT