| 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 |