Skip to content

Commit cfa045d

Browse files
feat: better mermaid display (#119)
1 parent 356a449 commit cfa045d

8 files changed

Lines changed: 402 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"devDependencies": {
1717
"@dhlx/vitepress-plugin-drawio": "^0.0.10",
18+
"@panzoom/panzoom": "^4.6.2",
1819
"@types/node": "^25.6.2",
1920
"markdown-it-mathjax3": "^4.3.2",
2021
"mermaid": "^10.9.6",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/.vitepress/config/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export default withDrawio(defineConfig({
3636
vite: {
3737
plugins: [serveCodeExamplesInDev()],
3838
ssr: {
39-
external: ['mermaid'],
39+
// mermaid / @panzoom/panzoom 都只在客户端 onMounted 后动态 import 求值,
40+
// SSR 阶段不能也不应打包求值(mermaid 访问 document、panzoom 访问 DOM)。
41+
external: ['mermaid', '@panzoom/panzoom'],
4042
},
4143
},
4244

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<template>
2+
<!-- Teleport 到 body:盖在整页最上层,不被正文 stacking context 裁剪。
3+
抄 OnlineCompilerDemo.vue 的模态范式(body.overflow 锁 + ESC + onBeforeUnmount 清理)。 -->
4+
<Teleport to="body">
5+
<div
6+
v-if="open"
7+
class="mermaid-lightbox"
8+
role="dialog"
9+
aria-modal="true"
10+
aria-label="放大查看图表"
11+
>
12+
<div class="mermaid-lightbox__toolbar" role="toolbar" aria-label="图表缩放控制">
13+
<button
14+
ref="firstBtnRef"
15+
type="button"
16+
class="mermaid-lightbox__btn"
17+
title="放大"
18+
@click="zoomIn"
19+
>
20+
放大
21+
</button>
22+
<button
23+
type="button"
24+
class="mermaid-lightbox__btn"
25+
title="缩小"
26+
@click="zoomOut"
27+
>
28+
缩小
29+
</button>
30+
<button
31+
type="button"
32+
class="mermaid-lightbox__btn"
33+
title="复位 / 适应窗口"
34+
@click="reset"
35+
>
36+
复位
37+
</button>
38+
<span class="mermaid-lightbox__hint">滚轮缩放 · 拖拽平移 · 双指缩放</span>
39+
<button
40+
type="button"
41+
class="mermaid-lightbox__btn mermaid-lightbox__close"
42+
title="关闭 (Esc)"
43+
aria-label="关闭"
44+
@click="close"
45+
>
46+
47+
</button>
48+
</div>
49+
<!-- @click.self:点舞台空白区关闭;点 SVG 本身(panzoom 接管拖拽)不关闭。 -->
50+
<div
51+
ref="stageRef"
52+
class="mermaid-lightbox__stage"
53+
@click.self="close"
54+
>
55+
<div ref="targetRef" class="mermaid-lightbox__target" />
56+
</div>
57+
</div>
58+
</Teleport>
59+
</template>
60+
61+
<script setup lang="ts">
62+
import { nextTick, onBeforeUnmount, ref } from 'vue'
63+
import { registerMermaidLightboxOpener, type MermaidLightboxPayload } from '../mermaid-lightbox'
64+
65+
// panzoom 只在 mountDiagram 里动态 import,不进 SSR bundle,也不进首屏 chunk。
66+
// 这里用本地最小类型,避免静态 import 类型把库拽进来。
67+
interface PanzoomInstance {
68+
zoomIn: (opts?: unknown) => void
69+
zoomOut: (opts?: unknown) => void
70+
reset: (opts?: unknown) => void
71+
zoomWithWheel: (event: WheelEvent) => void
72+
destroy: () => void
73+
}
74+
75+
const open = ref(false)
76+
const stageRef = ref<HTMLElement | null>(null)
77+
const targetRef = ref<HTMLElement | null>(null)
78+
const firstBtnRef = ref<HTMLElement | null>(null)
79+
80+
let panzoom: PanzoomInstance | null = null
81+
let payload: MermaidLightboxPayload | null = null
82+
let wheelHandler: ((e: WheelEvent) => void) | null = null
83+
let keyHandler: ((e: KeyboardEvent) => void) | null = null
84+
let prevOverflow = ''
85+
86+
// 挂载即注册 opener;mermaid-client 点 maximize 会触发 openDialog。
87+
const unregister = registerMermaidLightboxOpener((p) => {
88+
payload = p
89+
openDialog()
90+
})
91+
92+
function openDialog() {
93+
open.value = true
94+
prevOverflow = document.body.style.overflow
95+
document.body.style.overflow = 'hidden'
96+
97+
keyHandler = (e: KeyboardEvent) => {
98+
if (!open.value) return
99+
if (e.key === 'Escape') {
100+
e.preventDefault()
101+
close()
102+
} else if (e.key === 'Tab') {
103+
trapFocus(e)
104+
}
105+
}
106+
window.addEventListener('keydown', keyHandler)
107+
108+
// 等 Teleport 内容挂到 DOM 后再 clone SVG + 挂 panzoom。
109+
void nextTick(() => {
110+
void mountDiagram().then(() => firstBtnRef.value?.focus())
111+
})
112+
}
113+
114+
async function mountDiagram() {
115+
if (!payload || !stageRef.value || !targetRef.value) return
116+
const svg = payload.svg.cloneNode(true) as SVGElement
117+
targetRef.value.innerHTML = ''
118+
targetRef.value.appendChild(svg)
119+
120+
// 关键:必须给 clone 显式像素宽高,否则 SVG 要么塌成 300x150、要么按 mermaid 的
121+
// width="100%" 撑成内禀尺寸(巨大、只剩左上角)。注意 svg.viewBox.baseVal 在某些浏览器
122+
// 的 detached clone 上会返回 0 → 直接解析 viewBox 属性字符串最稳("minX minY W H")。
123+
svg.removeAttribute('style')
124+
const vbAttr = svg.getAttribute('viewBox')
125+
let vbW = 0
126+
let vbH = 0
127+
if (vbAttr) {
128+
const p = vbAttr.trim().split(/[\s,]+/).map(Number)
129+
if (p.length >= 4 && Number.isFinite(p[2]) && Number.isFinite(p[3])) {
130+
vbW = p[2]
131+
vbH = p[3]
132+
}
133+
}
134+
if (vbW > 0 && vbH > 0) {
135+
// 居中(flex 已处理)+ 占舞台 75%,留出舒服边距;min(...,1) 大图缩到 75%、小图不放大。
136+
const maxW = Math.max(160, stageRef.value.clientWidth * 0.75)
137+
const maxH = Math.max(160, stageRef.value.clientHeight * 0.75)
138+
const scale = Math.min(maxW / vbW, maxH / vbH, 1)
139+
svg.setAttribute('width', String(Math.round(vbW * scale)))
140+
svg.setAttribute('height', String(Math.round(vbH * scale)))
141+
}
142+
svg.style.display = 'block'
143+
144+
const { default: createPanzoom } = await import('@panzoom/panzoom')
145+
// targetRef(包 SVG 的 div)做 panzoom 目标:CSS transform 挂 HTML div,
146+
// 绕开 SVG 坐标系/viewBox/foreignObject 一切争议(研究阶段核验过)。
147+
panzoom = createPanzoom(targetRef.value, {
148+
maxScale: 8,
149+
minScale: 0.3, // 允许缩到比 fit 更小(minScale:1 时缩小按钮被钳住、点了没反应)
150+
step: 0.25,
151+
cursor: 'grab',
152+
})
153+
154+
wheelHandler = (e: WheelEvent) => panzoom?.zoomWithWheel(e)
155+
stageRef.value.addEventListener('wheel', wheelHandler, { passive: false })
156+
}
157+
158+
function trapFocus(e: KeyboardEvent) {
159+
const root = stageRef.value?.parentElement
160+
if (!root) return
161+
const focusables = Array.from(
162+
root.querySelectorAll<HTMLElement>('button, [href], input, [tabindex]:not([tabindex="-1"])'),
163+
).filter((el) => el.offsetParent !== null)
164+
if (focusables.length === 0) return
165+
const first = focusables[0]
166+
const last = focusables[focusables.length - 1]
167+
if (e.shiftKey && document.activeElement === first) {
168+
e.preventDefault()
169+
last.focus()
170+
} else if (!e.shiftKey && document.activeElement === last) {
171+
e.preventDefault()
172+
first.focus()
173+
}
174+
}
175+
176+
function close() {
177+
if (!open.value) return
178+
open.value = false
179+
teardown()
180+
payload?.trigger?.focus?.()
181+
payload = null
182+
}
183+
184+
function teardown() {
185+
if (wheelHandler && stageRef.value) {
186+
stageRef.value.removeEventListener('wheel', wheelHandler)
187+
}
188+
wheelHandler = null
189+
panzoom?.destroy()
190+
panzoom = null
191+
if (keyHandler) {
192+
window.removeEventListener('keydown', keyHandler)
193+
keyHandler = null
194+
}
195+
document.body.style.overflow = prevOverflow
196+
if (targetRef.value) targetRef.value.innerHTML = ''
197+
}
198+
199+
function zoomIn() {
200+
panzoom?.zoomIn()
201+
}
202+
function zoomOut() {
203+
panzoom?.zoomOut()
204+
}
205+
function reset() {
206+
panzoom?.reset()
207+
}
208+
209+
onBeforeUnmount(() => {
210+
unregister()
211+
if (open.value) teardown()
212+
})
213+
</script>

site/.vitepress/theme/custom.css

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,6 +1673,123 @@ html.dark .online-compiler-demo__source-highlight .shiki span {
16731673
font-size: 0.85em;
16741674
}
16751675

1676+
/* ── maximize 按钮:图被容器挤窄时浮现,点开进全屏模态 ── */
1677+
1678+
.mermaid-diagram--zoomable {
1679+
position: relative;
1680+
}
1681+
1682+
.mermaid-maximize-btn {
1683+
position: absolute;
1684+
top: 6px;
1685+
right: 6px;
1686+
z-index: 2;
1687+
display: inline-flex;
1688+
align-items: center;
1689+
justify-content: center;
1690+
width: 28px;
1691+
height: 28px;
1692+
padding: 0;
1693+
border: 1px solid var(--vp-c-border);
1694+
border-radius: 6px;
1695+
background: var(--vp-c-bg-elv);
1696+
color: var(--vp-c-text-2);
1697+
cursor: pointer;
1698+
opacity: 0.55;
1699+
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1700+
}
1701+
1702+
/* hover 图 / 键盘聚焦图内 → 按钮完全显形并高亮 */
1703+
.mermaid-diagram--zoomable:hover .mermaid-maximize-btn,
1704+
.mermaid-diagram--zoomable:focus-within .mermaid-maximize-btn {
1705+
opacity: 1;
1706+
}
1707+
1708+
.mermaid-maximize-btn:hover,
1709+
.mermaid-maximize-btn:focus-visible {
1710+
opacity: 1;
1711+
color: var(--vp-c-brand-1);
1712+
border-color: var(--vp-c-brand-1);
1713+
outline: none;
1714+
}
1715+
1716+
/* ── 全屏查看模态(MermaidLightbox.vue,Teleport 到 body) ── */
1717+
1718+
.mermaid-lightbox {
1719+
position: fixed;
1720+
inset: 0;
1721+
z-index: 999;
1722+
display: flex;
1723+
flex-direction: column;
1724+
background: rgba(15, 12, 8, 0.78);
1725+
backdrop-filter: blur(6px);
1726+
-webkit-backdrop-filter: blur(6px);
1727+
}
1728+
1729+
.mermaid-lightbox__toolbar {
1730+
display: flex;
1731+
align-items: center;
1732+
gap: 8px;
1733+
flex-wrap: wrap;
1734+
padding: 10px 14px;
1735+
background: var(--vp-c-bg-soft);
1736+
border-bottom: 1px solid var(--vp-c-border);
1737+
color: var(--vp-c-text-2);
1738+
}
1739+
1740+
.mermaid-lightbox__btn {
1741+
appearance: none;
1742+
border: 1px solid var(--vp-c-border);
1743+
border-radius: 6px;
1744+
background: var(--vp-c-bg-elv);
1745+
color: var(--vp-c-text-1);
1746+
font-size: 13px;
1747+
line-height: 1;
1748+
padding: 7px 10px;
1749+
cursor: pointer;
1750+
transition: color 0.15s ease, border-color 0.15s ease;
1751+
}
1752+
1753+
.mermaid-lightbox__btn:hover,
1754+
.mermaid-lightbox__btn:focus-visible {
1755+
color: var(--vp-c-brand-1);
1756+
border-color: var(--vp-c-brand-1);
1757+
outline: none;
1758+
}
1759+
1760+
.mermaid-lightbox__close {
1761+
margin-left: auto;
1762+
}
1763+
1764+
.mermaid-lightbox__hint {
1765+
font-size: 12px;
1766+
color: var(--vp-c-text-3);
1767+
margin-left: 8px;
1768+
}
1769+
1770+
.mermaid-lightbox__stage {
1771+
flex: 1;
1772+
min-height: 0;
1773+
display: flex;
1774+
align-items: center;
1775+
justify-content: center;
1776+
overflow: hidden;
1777+
padding: 32px;
1778+
}
1779+
1780+
.mermaid-lightbox__target {
1781+
display: inline-block;
1782+
touch-action: none; /* panzoom 自己管 pointer,关掉浏览器默认触摸手势 */
1783+
user-select: none;
1784+
}
1785+
1786+
.mermaid-lightbox__target svg {
1787+
display: block;
1788+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
1789+
background: var(--vp-c-bg-elv);
1790+
border-radius: 4px;
1791+
}
1792+
16761793
/* ================================================================
16771794
Resizable Sidebar(可拖拽侧栏宽度)
16781795
组件: theme/components/ResizableSidebar.vue

0 commit comments

Comments
 (0)