From dd2d051b3d193df18afd46d068cd504e8760e6a1 Mon Sep 17 00:00:00 2001 From: bugwz Date: Sat, 20 Dec 2025 20:57:32 +0800 Subject: [PATCH 1/4] Add pan and zoom support for mermaid diagrams --- layout/includes/third-party/math/mermaid.pug | 195 +++++++++++++++++++ source/css/_layout/third-party.styl | 7 + 2 files changed, 202 insertions(+) diff --git a/layout/includes/third-party/math/mermaid.pug b/layout/includes/third-party/math/mermaid.pug index 0be9dec..3befc21 100644 --- a/layout/includes/third-party/math/mermaid.pug +++ b/layout/includes/third-party/math/mermaid.pug @@ -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 diff --git a/source/css/_layout/third-party.styl b/source/css/_layout/third-party.styl index 8d7ac55..7881bbb 100644 --- a/source/css/_layout/third-party.styl +++ b/source/css/_layout/third-party.styl @@ -70,6 +70,13 @@ if hexo-config('mermaid.enable') & > svg height: 100% + max-width: 100% + cursor: grab + touch-action: none + user-select: none + + &:active + cursor: grabbing if hexo-config('mermaid.code_write') pre > code.mermaid From 0cc950a111915aebe69bdfdb7b3753a4e10bd8d1 Mon Sep 17 00:00:00 2001 From: bugwz Date: Sat, 20 Dec 2025 22:24:10 +0800 Subject: [PATCH 2/4] Add view button --- layout/includes/third-party/math/mermaid.pug | 64 +++++++++++++++++++- source/css/_layout/third-party.styl | 34 ++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/layout/includes/third-party/math/mermaid.pug b/layout/includes/third-party/math/mermaid.pug index 3befc21..936c7b1 100644 --- a/layout/includes/third-party/math/mermaid.pug +++ b/layout/includes/third-party/math/mermaid.pug @@ -31,6 +31,66 @@ script. const clamp = (v, min, max) => Math.max(min, Math.min(max, v)) + const openSvgInNewTab = ({ source, initViewBox }) => { + const getClonedSvg = () => { + if (typeof source === 'string') { + const template = document.createElement('template') + template.innerHTML = source.trim() + const svg = template.content.querySelector('svg') + return svg ? svg.cloneNode(true) : null + } + if (source && typeof source.cloneNode === 'function') { + return source.cloneNode(true) + } + return null + } + + const clone = getClonedSvg() + if (!clone) return + if (initViewBox && initViewBox.length === 4) { + clone.setAttribute('viewBox', initViewBox.join(' ')) + } + if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + if (!clone.getAttribute('xmlns:xlink') && clone.outerHTML.includes('xlink:')) { + clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') + } + const serializer = new XMLSerializer() + const svgSource = serializer.serializeToString(clone) + const blob = new Blob([svgSource], { type: 'image/svg+xml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + window.open(url, '_blank', 'noopener') + setTimeout(() => URL.revokeObjectURL(url), 30000) + } + + const attachMermaidViewerButton = wrap => { + const originalSvg = wrap.__mermaidOriginalSvg + let btn = wrap.querySelector('.mermaid-open-btn') + if (!btn) { + btn = document.createElement('button') + btn.type = 'button' + btn.className = 'mermaid-open-btn' + wrap.appendChild(btn) + } + + btn.innerHTML = '' + + if (!btn.__mermaidViewerBound) { + btn.addEventListener('click', e => { + e.preventDefault() + e.stopPropagation() + const svg = originalSvg || wrap.querySelector('svg') + if (!svg) return + const initViewBox = wrap.__mermaidInitViewBox + if (typeof svg === 'string') { + openSvgInNewTab({ source: svg, initViewBox }) + return + } + openSvgInNewTab({ source: svg, initViewBox }) + }) + btn.__mermaidViewerBound = true + } + } + // Zoom around a point (px, py) in the SVG viewport (in viewBox coordinates) const zoomAtPoint = (vb, factor, px, py) => { const w = vb[2] * factor @@ -212,6 +272,8 @@ script. const renderMermaid = svg => { mermaidSrc.insertAdjacentHTML('afterend', svg) initMermaidGestures(item) + item.__mermaidOriginalSvg = svg + attachMermaidViewerButton(item) } // mermaid v9 and v10 compatibility @@ -247,4 +309,4 @@ script. btf.addGlobalFn('encrypt', loadMermaid, 'mermaid') window.pjax ? loadMermaid() : document.addEventListener('DOMContentLoaded', loadMermaid) - })() \ No newline at end of file + })() diff --git a/source/css/_layout/third-party.styl b/source/css/_layout/third-party.styl index 7881bbb..2fd833a 100644 --- a/source/css/_layout/third-party.styl +++ b/source/css/_layout/third-party.styl @@ -65,9 +65,41 @@ if hexo-config('waline.bg') if hexo-config('mermaid.enable') .mermaid-wrap + position: relative margin: 0 0 20px text-align: center + .mermaid-open-btn + position: absolute + top: 8px + right: 8px + z-index: 2 + width: 34px + height: 25px + padding: 0 + border: none + border-radius: 20% + display: flex + align-items: center + justify-content: center + font-size: 0 + line-height: 1 + background: #D3D3D3 + color: #fff + cursor: pointer + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15) + transition: background 0.2s ease, transform 0.2s ease + + i + font-size: 16px + line-height: 1 + + &:hover, + &:focus-visible + transform: translateY(-1px) + background: #C0C0C0 + outline: none + & > svg height: 100% max-width: 100% @@ -191,4 +223,4 @@ if hexo-config('math.use') +maxWidth768() .fancybox__toolbar__column.is-middle - display: none \ No newline at end of file + display: none From 2c5e8c7cbbb9f4471a7a915c8b1d91d780093b02 Mon Sep 17 00:00:00 2001 From: bugwz Date: Sun, 21 Dec 2025 19:19:42 +0800 Subject: [PATCH 3/4] Optimize mermaid view --- layout/includes/third-party/math/mermaid.pug | 6 ++++++ source/css/_layout/third-party.styl | 1 + 2 files changed, 7 insertions(+) diff --git a/layout/includes/third-party/math/mermaid.pug b/layout/includes/third-party/math/mermaid.pug index 936c7b1..8b62cd7 100644 --- a/layout/includes/third-party/math/mermaid.pug +++ b/layout/includes/third-party/math/mermaid.pug @@ -54,6 +54,11 @@ script. if (!clone.getAttribute('xmlns:xlink') && clone.outerHTML.includes('xlink:')) { clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') } + // inject background to match current theme + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + const bg = getComputedStyle(document.body).backgroundColor || (isDark ? '#1e1e1e' : '#ffffff') + if (!clone.style.background) clone.style.background = bg + const serializer = new XMLSerializer() const svgSource = serializer.serializeToString(clone) const blob = new Blob([svgSource], { type: 'image/svg+xml;charset=utf-8' }) @@ -276,6 +281,7 @@ script. attachMermaidViewerButton(item) } + // mermaid v9 and v10 compatibility typeof renderFn === 'string' ? renderMermaid(renderFn) : renderFn.then(({ svg }) => renderMermaid(svg)) }) diff --git a/source/css/_layout/third-party.styl b/source/css/_layout/third-party.styl index 2fd833a..676743f 100644 --- a/source/css/_layout/third-party.styl +++ b/source/css/_layout/third-party.styl @@ -68,6 +68,7 @@ if hexo-config('mermaid.enable') position: relative margin: 0 0 20px text-align: center + background: var(--card-bg) .mermaid-open-btn position: absolute From e4597dededf0852cf331c05250e8d1859ad34f36 Mon Sep 17 00:00:00 2001 From: Jerry Date: Thu, 15 Jan 2026 23:28:14 +0800 Subject: [PATCH 4/4] update --- layout/includes/third-party/math/mermaid.pug | 11 ++++++--- source/css/_layout/third-party.styl | 26 ++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/layout/includes/third-party/math/mermaid.pug b/layout/includes/third-party/math/mermaid.pug index 8b62cd7..148d18f 100644 --- a/layout/includes/third-party/math/mermaid.pug +++ b/layout/includes/third-party/math/mermaid.pug @@ -61,14 +61,19 @@ script. const serializer = new XMLSerializer() const svgSource = serializer.serializeToString(clone) - const blob = new Blob([svgSource], { type: 'image/svg+xml;charset=utf-8' }) + const htmlSource = ` + + ${svgSource}` + const blob = new Blob([htmlSource], { type: 'text/html;charset=utf-8' }) const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener') setTimeout(() => URL.revokeObjectURL(url), 30000) } const attachMermaidViewerButton = wrap => { - const originalSvg = wrap.__mermaidOriginalSvg let btn = wrap.querySelector('.mermaid-open-btn') if (!btn) { btn = document.createElement('button') @@ -83,7 +88,7 @@ script. btn.addEventListener('click', e => { e.preventDefault() e.stopPropagation() - const svg = originalSvg || wrap.querySelector('svg') + const svg = wrap.__mermaidOriginalSvg || wrap.querySelector('svg') if (!svg) return const initViewBox = wrap.__mermaidInitViewBox if (typeof svg === 'string') { diff --git a/source/css/_layout/third-party.styl b/source/css/_layout/third-party.styl index 676743f..cc7ff8d 100644 --- a/source/css/_layout/third-party.styl +++ b/source/css/_layout/third-party.styl @@ -67,29 +67,29 @@ if hexo-config('mermaid.enable') .mermaid-wrap position: relative margin: 0 0 20px - text-align: center background: var(--card-bg) + text-align: center .mermaid-open-btn position: absolute top: 8px right: 8px z-index: 2 + display: flex + justify-content: center + align-items: center + padding: 0 width: 34px height: 25px - padding: 0 border: none border-radius: 20% - display: flex - align-items: center - justify-content: center + background: #D3D3D3 + box-shadow: 0 4px 10px rgba(0, 0, 0, .15) + color: #fff font-size: 0 line-height: 1 - background: #D3D3D3 - color: #fff cursor: pointer - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15) - transition: background 0.2s ease, transform 0.2s ease + transition: background .2s ease, transform .2s ease i font-size: 16px @@ -97,16 +97,16 @@ if hexo-config('mermaid.enable') &:hover, &:focus-visible - transform: translateY(-1px) - background: #C0C0C0 outline: none + background: #C0C0C0 + transform: translateY(-1px) & > svg - height: 100% max-width: 100% + height: 100% cursor: grab - touch-action: none user-select: none + touch-action: none &:active cursor: grabbing