VitePress: Add mermaid zoom

This commit is contained in:
Meow
2026-01-28 08:48:25 +08:00
committed by Fangliding
parent a2f0f7a5e1
commit ee521108f3
6 changed files with 532 additions and 1 deletions
+497
View File
@@ -0,0 +1,497 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue"
// --- Types ---
interface PanZoomInstance {
destroy: () => void
resize?: () => void
fit?: () => void
center?: () => void
resetZoom?: () => void
zoomIn?: () => void
zoomOut?: () => void
zoomBy?: (scale: number) => void
panBy?: (p: { x: number; y: number }) => void
getPan?: () => { x: number; y: number }
pan?: (p: { x: number; y: number }) => void
}
// --- State ---
const open = ref(false)
const svgHtml = ref("")
const contentRef = ref<HTMLElement | null>(null)
let panZoom: PanZoomInstance | null = null
// Factory cache (Singleton pattern)
type SvgPanZoomFactory = (
svg: SVGSVGElement,
opts: Record<string, any>
) => PanZoomInstance
let svgPanZoomFactory: SvgPanZoomFactory | null = null
const PAN_STEP_PX = 60
// --- Backdrop Logic ---
let backdropPress = false
let backdropPointerId: number | null = null
// --- Actions ---
const destroyPanzoom = () => {
if (!panZoom) return
try {
panZoom.destroy()
} catch {}
panZoom = null
}
const initPanzoom = async () => {
await nextTick()
destroyPanzoom()
const svg = contentRef.value?.querySelector(
"svg"
) as SVGSVGElement | null
if (!svg) return
// Cache the factory to avoid repeated dynamic imports
if (!svgPanZoomFactory) {
const mod: any = await import("svg-pan-zoom")
svgPanZoomFactory = (mod.default ?? mod) as SvgPanZoomFactory
}
panZoom = svgPanZoomFactory(svg, {
zoomEnabled: true,
panEnabled: true,
mouseWheelZoomEnabled: true,
dblClickZoomEnabled: true,
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 20,
controlIconsEnabled: false,
preventMouseEventsDefault: true
})
// Initial alignment:
// 1. Sync call ensures correct position on first paint (no flash).
// 2. RAF handles any layout shifts or font loading settlements.
const align = () => {
try {
panZoom?.resize?.()
panZoom?.fit?.()
panZoom?.center?.()
} catch {}
}
align()
requestAnimationFrame(align)
}
const close = () => {
open.value = false
svgHtml.value = ""
destroyPanzoom()
backdropPress = false
backdropPointerId = null
}
// --- Pan/Zoom Helpers ---
// Helper to safely execute PanZoom methods if the instance exists
const withPanZoom = (fn: (pz: PanZoomInstance) => void) => {
const pz = panZoom
if (!pz) return
try {
fn(pz)
} catch {}
}
const zoomIn = () => withPanZoom((pz) => pz.zoomIn?.())
const zoomOut = () => withPanZoom((pz) => pz.zoomOut?.())
const resetView = () =>
withPanZoom((pz) => {
pz.resetZoom?.()
pz.fit?.()
pz.center?.()
})
const panBy = (dx: number, dy: number) =>
withPanZoom((pz) => {
// Prefer native panBy if available (better stability with transforms)
if (pz.panBy) {
pz.panBy({ x: dx, y: dy })
} else if (pz.getPan && pz.pan) {
const { x, y } = pz.getPan()
pz.pan({ x: x + dx, y: y + dy })
}
})
const panUp = () => panBy(0, PAN_STEP_PX)
const panDown = () => panBy(0, -PAN_STEP_PX)
const panLeft = () => panBy(PAN_STEP_PX, 0)
const panRight = () => panBy(-PAN_STEP_PX, 0)
// --- Event Handlers ---
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close()
}
async function onDocClick(e: MouseEvent) {
if (open.value) return
const target = e.target as HTMLElement | null
const svg = target?.closest?.("svg") as SVGElement | null
// Strict check: only Mermaid diagrams
if (!svg || !svg.closest(".mermaid")) return
e.preventDefault()
svgHtml.value = svg.outerHTML
open.value = true
await initPanzoom()
}
// Backdrop specific: only close if user starts AND ends click on backdrop
function onBackdropPointerDown(e: PointerEvent) {
if (e.target === e.currentTarget) {
backdropPress = true
backdropPointerId = e.pointerId
} else {
backdropPress = false
backdropPointerId = null
}
}
function onBackdropPointerUp(e: PointerEvent) {
const isSamePointer = backdropPointerId === e.pointerId
const releasedOnBackdrop = e.target === e.currentTarget
if (backdropPress && isSamePointer && releasedOnBackdrop) {
close()
}
backdropPress = false
backdropPointerId = null
}
function onBackdropPointerCancel() {
backdropPress = false
backdropPointerId = null
}
onMounted(() => {
document.addEventListener("keydown", onKeydown)
document.addEventListener("click", onDocClick, true)
})
onBeforeUnmount(() => {
document.removeEventListener("keydown", onKeydown)
document.removeEventListener("click", onDocClick, true)
destroyPanzoom()
})
</script>
<template>
<Teleport to="body">
<div
v-show="open"
class="mz-overlay"
aria-modal="true"
role="dialog"
@pointerdown="onBackdropPointerDown"
@pointerup="onBackdropPointerUp"
@pointercancel="onBackdropPointerCancel"
>
<div class="mz-box" @click.stop @pointerdown.stop @pointerup.stop>
<div class="mz-toolbar" @click.stop>
<button
class="mz-btn"
type="button"
title="Zoom out"
aria-label="Zoom out"
@click="zoomOut"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19.96 11.48C19.96 13.8 19.02 15.9 17.5 17.45C16.93 18.02 16.28 18.5 15.57 18.9C14.36 19.58 12.96 19.96 11.48 19.96C6.8 19.96 3 16.16 3 11.48C3 6.8 6.8 3 11.48 3C16.16 3 19.96 6.8 19.96 11.48Z"
/>
<path d="M18.15 18.15L21.88 21.88" />
<path d="M8 11.55H15.1" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Zoom in"
aria-label="Zoom in"
@click="zoomIn"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19.96 11.48C19.96 13.8 19.02 15.9 17.5 17.45C16.93 18.02 16.28 18.5 15.57 18.9C14.36 19.58 12.96 19.96 11.48 19.96C6.8 19.96 3 16.16 3 11.48C3 6.8 6.8 3 11.48 3C16.16 3 19.96 6.8 19.96 11.48Z"
/>
<path d="M18.15 18.15L21.88 21.88" />
<path d="M8 11.55H15.1" />
<path d="M11.55 15.1L11.55 8" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Reset view"
aria-label="Reset view"
@click="resetView"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.95 9.75L22.25 6.13" />
<path d="M20.95 9.75L17.16 9.12" />
<path
d="M20.16 8.28C19.4 6.57 18.09 5.1 16.32 4.14C12.06 1.85 6.75 3.45 4.46 7.7C3.92 8.7 3.6 9.78 3.48 10.85"
/>
<path d="M3.54 14.25L2.24 17.87" />
<path d="M3.54 14.25L7.33 14.87" />
<path
d="M4.33 15.71C5.1 17.42 6.4 18.9 8.18 19.85C12.44 22.14 17.74 20.55 20.03 16.3C20.57 15.28 20.9 14.22 21.02 13.15"
/>
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Pan up"
aria-label="Pan up"
@click="panUp"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.4 7L17.8 11.5" />
<path d="M12.4 7L7 11.5" />
<path d="M12.4 12L17.8 16.5" />
<path d="M12.4 12L7 16.5" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Pan down"
aria-label="Pan down"
@click="panDown"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17.65 12L12.25 16.5L6.85 12" />
<path d="M17.65 7L12.25 11.5L6.85 7" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Pan left"
aria-label="Pan left"
@click="panLeft"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 11.75L11.5 17.15" />
<path d="M7 11.75L11.5 6.35" />
<path d="M12 11.75L16.5 17.15" />
<path d="M12 11.75L16.5 6.35" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Pan right"
aria-label="Pan right"
@click="panRight"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 11.75L11.5 17.15" />
<path d="M16 11.75L11.5 6.35" />
<path d="M11 11.75L6.5 17.15" />
<path d="M11 11.75L6.5 6.35" />
</svg>
</button>
<button
class="mz-btn"
type="button"
title="Close"
aria-label="Close"
@click="close"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 5L5 19" />
<path d="M5 5L19 19" />
</svg>
</button>
</div>
<div ref="contentRef" class="mz-canvas" v-html="svgHtml" />
</div>
</div>
</Teleport>
</template>
<style scoped>
.mz-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(2px);
}
.mz-box {
position: relative;
width: min(1200px, 96vw);
height: min(900px, 92vh);
background: var(--vp-c-bg, #fff);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
border: 1px solid var(--vp-c-divider, #eee);
}
.mz-toolbar {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
padding: 6px;
border: 1px solid var(--vp-c-divider, #ddd);
border-radius: 10px;
background: color-mix(in srgb, var(--vp-c-bg, #fff) 85%, transparent);
backdrop-filter: blur(8px);
}
.mz-btn {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
background: transparent;
color: var(--vp-c-text-1, #333);
border-radius: 8px;
cursor: pointer;
display: grid;
place-items: center;
transition:
background-color 0.2s,
color 0.2s;
}
.mz-btn:hover {
background-color: var(--vp-c-bg-soft, #f4f4f4);
color: var(--vp-c-brand, #3451b2);
}
.mz-btn:active {
background-color: var(--vp-c-bg-mute, #e2e2e2);
}
.mz-canvas {
position: absolute;
inset: 0;
overflow: hidden;
background-image: radial-gradient(
var(--vp-c-divider, #e5e5e5) 1px,
transparent 1px
);
background-size: 20px 20px;
}
.mz-canvas :deep(svg) {
position: absolute;
inset: 0;
display: block;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
cursor: grab;
}
.mz-canvas :deep(svg:active) {
cursor: grabbing;
}
</style>
+2 -1
View File
@@ -12,6 +12,7 @@ import { h } from "vue"
import TranslationNotice from "./components/TranslationNotice.vue"
import PageContributors from "./components/PageContributors.vue"
import BackToTop from "./components/BackToTop.vue"
import MermaidZoom from "./components/MermaidZoom.vue"
export default {
extends: DefaultTheme,
@@ -35,7 +36,7 @@ export default {
return h(DefaultTheme.Layout, null, {
"doc-before": () => h(TranslationNotice),
"doc-after": () => h(PageContributors),
"layout-bottom": () => h(BackToTop)
"layout-bottom": () => [h(BackToTop), h(MermaidZoom)]
})
}
}
+1
View File
@@ -1,3 +1,4 @@
@import "./var.css";
@import "./custom-block.css";
@import "./blockquote.css";
@import "./vitepress-plugin-mermaid.css";
@@ -0,0 +1,23 @@
/* .vitepress/theme/style/vitepress-plugin-mermaid.css */
/* Fix: VitePress typography styles affect Mermaid htmlLabels and clip last line */
.vp-doc .mermaid svg foreignObject p {
margin: 0 !important;
padding: 0 !important;
line-height: 1.2 !important;
}
/* In some cases, the default overflow setting for foreignObjects may cause clipping, so I'll go ahead and disable it */
.vp-doc .mermaid svg foreignObject > div {
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
text-align: center !important;
box-sizing: border-box;
}
.vp-doc .mermaid svg {
cursor: zoom-in;
}
+1
View File
@@ -2,6 +2,7 @@
"devDependencies": {
"medium-zoom": "^1.1.0",
"mermaid": "^11.12.2",
"svg-pan-zoom": "^3.6.2",
"vitepress": "2.0.0-alpha.15",
"vitepress-plugin-llms": "^1.10.0",
"vitepress-plugin-mermaid": "^2.0.17"
+8
View File
@@ -14,6 +14,9 @@ importers:
mermaid:
specifier: ^11.12.2
version: 11.12.2
svg-pan-zoom:
specifier: ^3.6.2
version: 3.6.2
vitepress:
specifier: 2.0.0-alpha.15
version: 2.0.0-alpha.15(postcss@8.5.6)
@@ -1320,6 +1323,9 @@ packages:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
svg-pan-zoom@3.6.2:
resolution: {integrity: sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==}
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
@@ -2898,6 +2904,8 @@ snapshots:
dependencies:
copy-anything: 4.0.5
svg-pan-zoom@3.6.2: {}
tabbable@6.3.0: {}
tinyexec@1.0.2: {}