mirror of
https://github.com/XTLS/Xray-docs-next.git
synced 2026-09-22 22:38:05 +03:00
VitePress: Add mermaid zoom
This commit is contained in:
@@ -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>
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Generated
+8
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user