Add pan and zoom support for mermaid diagrams

This commit is contained in:
bugwz
2025-12-20 20:57:32 +08:00
parent c771efa9bc
commit dd2d051b3d
2 changed files with 202 additions and 0 deletions

View File

@@ -1,11 +1,205 @@
script.
(() => {
const parseViewBox = viewBox => {
if (!viewBox) return null
const parts = viewBox.trim().split(/[\s,]+/).map(n => Number(n))
if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return null
return parts
}
const getSvgViewBox = svg => {
const attr = parseViewBox(svg.getAttribute('viewBox'))
if (attr) return attr
// Fallback: use bbox to build a viewBox
try {
const bbox = svg.getBBox()
if (bbox && bbox.width && bbox.height) return [bbox.x, bbox.y, bbox.width, bbox.height]
} catch (e) {
// getBBox may fail on some edge cases; ignore
}
const w = Number(svg.getAttribute('width')) || 0
const h = Number(svg.getAttribute('height')) || 0
if (w > 0 && h > 0) return [0, 0, w, h]
return [0, 0, 100, 100]
}
const setSvgViewBox = (svg, vb) => {
svg.setAttribute('viewBox', `${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}`)
}
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
// Zoom around a point (px, py) in the SVG viewport (in viewBox coordinates)
const zoomAtPoint = (vb, factor, px, py) => {
const w = vb[2] * factor
const h = vb[3] * factor
const nx = px - (px - vb[0]) * factor
const ny = py - (py - vb[1]) * factor
return [nx, ny, w, h]
}
const initMermaidGestures = wrap => {
const svg = wrap.querySelector('svg')
if (!svg) return
// Ensure viewBox exists so gestures always work
const initVb = getSvgViewBox(svg)
wrap.__mermaidInitViewBox = initVb
wrap.__mermaidCurViewBox = initVb.slice()
setSvgViewBox(svg, initVb)
// Avoid binding multiple times on themeChange/pjax
if (wrap.__mermaidGestureBound) return
wrap.__mermaidGestureBound = true
// Helper: map client (viewport) coordinate -> viewBox coordinate
const clientToViewBox = (clientX, clientY) => {
const rect = svg.getBoundingClientRect()
const vb = wrap.__mermaidCurViewBox || getSvgViewBox(svg)
const x = vb[0] + (clientX - rect.left) * (vb[2] / rect.width)
const y = vb[1] + (clientY - rect.top) * (vb[3] / rect.height)
return { x, y, rect, vb }
}
const state = {
pointers: new Map(),
startVb: null,
startDist: 0,
startCenter: null
}
const clampVb = vb => {
const init = wrap.__mermaidInitViewBox || vb
const minW = init[2] * 0.1
const maxW = init[2] * 10
const minH = init[3] * 0.1
const maxH = init[3] * 10
vb[2] = clamp(vb[2], minW, maxW)
vb[3] = clamp(vb[3], minH, maxH)
return vb
}
const setCurVb = vb => {
vb = clampVb(vb)
wrap.__mermaidCurViewBox = vb
setSvgViewBox(svg, vb)
}
const onPointerDown = e => {
// Allow only primary button for mouse
if (e.pointerType === 'mouse' && e.button !== 0) return
svg.setPointerCapture(e.pointerId)
state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (state.pointers.size === 1) {
state.startVb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice()
} else if (state.pointers.size === 2) {
const pts = [...state.pointers.values()]
const dx = pts[0].x - pts[1].x
const dy = pts[0].y - pts[1].y
state.startDist = Math.hypot(dx, dy)
state.startVb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice()
state.startCenter = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }
}
}
const onPointerMove = e => {
if (!state.pointers.has(e.pointerId)) return
state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
// Pan with 1 pointer
if (state.pointers.size === 1 && state.startVb) {
const p = [...state.pointers.values()][0]
const prev = { x: e.clientX - e.movementX, y: e.clientY - e.movementY }
// movementX/Y unreliable on touch, compute from stored last position
const last = wrap.__mermaidLastSinglePointer || p
const dxClient = p.x - last.x
const dyClient = p.y - last.y
wrap.__mermaidLastSinglePointer = p
const { rect } = clientToViewBox(p.x, p.y)
const vb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice()
const dx = dxClient * (vb[2] / rect.width)
const dy = dyClient * (vb[3] / rect.height)
setCurVb([vb[0] - dx, vb[1] - dy, vb[2], vb[3]])
return
}
// Pinch zoom with 2 pointers
if (state.pointers.size === 2 && state.startVb && state.startDist > 0) {
const pts = [...state.pointers.values()]
const dx = pts[0].x - pts[1].x
const dy = pts[0].y - pts[1].y
const dist = Math.hypot(dx, dy)
if (!dist) return
const factor = state.startDist / dist // dist bigger => zoom in (viewBox smaller)
const cx = (pts[0].x + pts[1].x) / 2
const cy = (pts[0].y + pts[1].y) / 2
const centerClient = { x: cx, y: cy }
const pxy = clientToViewBox(centerClient.x, centerClient.y)
const cpx = pxy.x
const cpy = pxy.y
const vb = zoomAtPoint(state.startVb, factor, cpx, cpy)
setCurVb(vb)
}
}
const onPointerUpOrCancel = e => {
state.pointers.delete(e.pointerId)
if (state.pointers.size === 0) {
state.startVb = null
state.startDist = 0
state.startCenter = null
wrap.__mermaidLastSinglePointer = null
} else if (state.pointers.size === 1) {
// reset single pointer baseline to avoid jump
wrap.__mermaidLastSinglePointer = [...state.pointers.values()][0]
}
}
// Wheel zoom (mouse/trackpad)
const onWheel = e => {
// ctrlKey on mac trackpad pinch; we treat both as zoom
e.preventDefault()
const delta = e.deltaY
const zoomFactor = delta > 0 ? 1.1 : 0.9
const { x, y } = clientToViewBox(e.clientX, e.clientY)
const vb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice()
setCurVb(zoomAtPoint(vb, zoomFactor, x, y))
}
const onDblClick = () => {
const init = wrap.__mermaidInitViewBox
if (!init) return
wrap.__mermaidCurViewBox = init.slice()
setSvgViewBox(svg, init)
}
svg.addEventListener('pointerdown', onPointerDown)
svg.addEventListener('pointermove', onPointerMove)
svg.addEventListener('pointerup', onPointerUpOrCancel)
svg.addEventListener('pointercancel', onPointerUpOrCancel)
svg.addEventListener('wheel', onWheel, { passive: false })
svg.addEventListener('dblclick', onDblClick)
}
const runMermaid = ele => {
window.loadMermaid = true
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? '!{theme.mermaid.theme.dark}' : '!{theme.mermaid.theme.light}'
ele.forEach((item, index) => {
const mermaidSrc = item.firstElementChild
// Clear old render (themeChange/pjax will rerun)
const oldSvg = item.querySelector('svg')
if (oldSvg) oldSvg.remove()
item.__mermaidGestureBound = false
const config = mermaidSrc.dataset.config ? JSON.parse(mermaidSrc.dataset.config) : {}
if (!config.theme) {
config.theme = theme
@@ -17,6 +211,7 @@ script.
const renderFn = mermaid.render(mermaidID, mermaidDefinition)
const renderMermaid = svg => {
mermaidSrc.insertAdjacentHTML('afterend', svg)
initMermaidGestures(item)
}
// mermaid v9 and v10 compatibility