refactor(docs): simplify Mermaid fullscreen viewer controls

This commit is contained in:
gengruilin
2026-09-14 13:52:42 +08:00
parent cd0cc91382
commit ece25ff8e6
6 changed files with 202 additions and 72 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md
2026-09-14-docs-mermaid-viewer.md: 3c014000bccd25908f699333efbcb551c1ee1599
2026-09-14-docs-mermaid-viewer.zh.md: 48ce642b312cd8881fda437e8cb9ee93e21698ef
2026-09-14-docs-mermaid-viewer.md: 21d3b0be620d452bfe7024e2eb9e931db4d302b4
2026-09-14-docs-mermaid-viewer.zh.md: f7400c5e5d1ba9b58ca05eeaaca422a9583a4a47
@@ -10,9 +10,9 @@ Complex Mermaid diagrams lose readable detail when scaled to the documentation c
## Decision
The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a fullscreen entry above each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal; the toolbar cycles keyboard focus through zoom, fit, and close controls. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting.
The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting.
The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column.
The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram.
Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The scroll lock belongs on the body because the Mermaid plugin observes every attribute change on the HTML element and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners.
@@ -10,9 +10,9 @@ Status: implemented
## 决策
[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 上方增加全屏入口。原生模态对话框使背景不可交互,并支持 Escape 退出;工具栏将键盘焦点循环限定在缩放、适应窗口和关闭控件之间。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。
[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。
查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。
查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。
查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。滚动锁设置在 body 上,因为 Mermaid 插件会观察 HTML 元素的每次属性变化并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。
+99 -47
View File
@@ -1,29 +1,49 @@
.vp-doc .mermaid:has(.dsh-diagram-open) {
position: relative;
padding-top: 48px;
}
.vp-doc .dsh-diagram-open,
.dsh-diagram-toolbar button {
min-height: 44px;
padding: 6px 14px;
border: 1px solid var(--vp-c-divider);
.dsh-diagram-viewer button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
flex: none;
border: 0;
border-radius: 8px;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
font: inherit;
font-size: 14px;
color: var(--vp-c-text-2);
background: var(--vp-c-bg);
cursor: pointer;
}
.vp-doc .dsh-diagram-open {
display: block;
margin: 8px 0 16px auto;
position: absolute;
top: 0;
right: 0;
}
.dsh-diagram-icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.dsh-diagram-open:hover,
.dsh-diagram-toolbar button:hover {
border-color: var(--vp-c-brand-1);
.dsh-diagram-viewer button:hover,
.dsh-diagram-viewer button[aria-expanded="true"] {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg-soft);
}
.dsh-diagram-open:focus-visible,
.dsh-diagram-toolbar button:focus-visible {
.dsh-diagram-viewer button:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 2px;
}
@@ -36,55 +56,87 @@
max-width: none;
max-height: none;
margin: 0;
padding: 16px;
padding: 0;
border: 0;
overflow: hidden;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
box-sizing: border-box;
}
.dsh-diagram-viewer[open] {
display: flex;
flex-direction: column;
gap: 8px;
}
.dsh-diagram-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: none;
}
.dsh-diagram-toolbar strong {
margin-right: auto;
}
.dsh-diagram-help {
margin: 0;
.dsh-diagram-title {
position: absolute;
top: 24px;
left: 24px;
max-width: calc(100% - 104px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vp-c-text-2);
font-size: 13px;
}
.dsh-diagram-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
.dsh-diagram-close { position: absolute; top: 12px; right: 16px; }
.dsh-diagram-toolbar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
box-shadow: 0 3px 12px #0000000a;
}
.dsh-diagram-scale {
min-width: 48px;
text-align: center;
color: var(--vp-c-text-2);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.dsh-diagram-toolbar .dsh-diagram-fit {
margin-left: 6px;
border-left: 1px solid var(--vp-c-divider);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dsh-diagram-help-toggle { position: absolute; bottom: 25px; right: 16px; }
.dsh-diagram-help {
position: absolute;
right: 16px;
bottom: 80px;
max-width: min(360px, calc(100% - 32px));
margin: 0;
padding: 12px 16px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
box-shadow: 0 3px 12px #0000000a;
font-size: 13px;
}
.dsh-diagram-viewport {
position: absolute;
inset: 64px 16px 88px;
overflow: hidden;
overscroll-behavior: contain;
}
.dsh-diagram-paper {
position: absolute;
inset: 0;
}
.dsh-diagram-paper { position: absolute; inset: 0; }
@media (max-width: 600px) {
.dsh-diagram-viewer { padding: 8px; }
.dsh-diagram-toolbar { align-items: stretch; }
.dsh-diagram-toolbar strong { width: 100%; }
.dsh-diagram-toolbar button { flex: 1; padding-inline: 8px; }
.dsh-diagram-title { top: 20px; left: 16px; }
.dsh-diagram-close { top: 8px; right: 8px; }
.dsh-diagram-viewport { inset: 56px 8px 80px; }
.dsh-diagram-toolbar { bottom: 12px; }
.dsh-diagram-help-toggle { right: 8px; bottom: 17px; }
}
+57 -17
View File
@@ -4,16 +4,24 @@ import Panzoom from '@panzoom/panzoom'
const messages = {
en: {
open: 'View diagram fullscreen', title: 'Diagram viewer',
zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close',
zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close', helpLabel: 'Viewer help',
help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close',
},
zh: {
open: '全屏查看图表', title: '图表查看器',
zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭',
zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭', helpLabel: '查看器帮助',
help: '滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭',
},
} satisfies Record<string, Record<string, string>>
const icons = {
open: 'M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7',
zoomIn: 'M5 12h14M12 5v14', zoomOut: 'M5 12h14',
fit: 'M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5',
close: 'M6 6l12 12M18 6 6 18',
help: 'M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1',
}
/** Theme-owned viewer resources; refresh closes the current view before rescanning. */
export interface MermaidViewer {
/** Close the view and update entries after a route, language, or theme change. */
@@ -22,10 +30,24 @@ export interface MermaidViewer {
dispose(): void
}
function button(doc: Document, label: string): HTMLButtonElement {
function labelButton(element: HTMLButtonElement, label: string): void {
element.setAttribute('aria-label', label)
element.title = label
}
function button(doc: Document, label: string, icon: keyof typeof icons): HTMLButtonElement {
const element = doc.createElement('button')
element.type = 'button'
element.textContent = label
labelButton(element, label)
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.classList.add('dsh-diagram-icon')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('focusable', 'false')
const path = doc.createElementNS(svg.namespaceURI, 'path')
path.setAttribute('d', icons[icon])
svg.append(path)
element.append(svg)
return element
}
@@ -48,16 +70,27 @@ function openDiagram(
dialog.setAttribute('aria-label', copy.title)
const toolbar = doc.createElement('div')
toolbar.className = 'dsh-diagram-toolbar'
const title = doc.createElement('strong')
title.textContent = copy.title
const zoomOut = button(doc, copy.zoomOut)
const zoomIn = button(doc, copy.zoomIn)
const fit = button(doc, copy.fit)
const close = button(doc, copy.close)
const title = doc.createElement('span')
title.className = 'dsh-diagram-title'
title.textContent = doc.querySelector('.vp-doc h1')?.textContent.trim() ?? copy.title
const zoomOut = button(doc, copy.zoomOut, 'zoomOut')
const zoomIn = button(doc, copy.zoomIn, 'zoomIn')
const scaleLabel = doc.createElement('span')
scaleLabel.className = 'dsh-diagram-scale'
const fit = button(doc, copy.fit, 'fit')
fit.className = 'dsh-diagram-fit'
const close = button(doc, copy.close, 'close')
close.className = 'dsh-diagram-close'
close.autofocus = true
toolbar.append(title, zoomOut, zoomIn, fit, close)
toolbar.append(zoomOut, scaleLabel, zoomIn, fit)
const helpToggle = button(doc, copy.helpLabel, 'help')
helpToggle.className = 'dsh-diagram-help-toggle'
helpToggle.setAttribute('aria-expanded', 'false')
helpToggle.setAttribute('aria-controls', 'dsh-diagram-help-text')
const help = doc.createElement('p')
help.className = 'dsh-diagram-help'
help.id = 'dsh-diagram-help-text'
help.hidden = true
help.textContent = copy.help
const viewport = doc.createElement('div')
viewport.className = 'dsh-diagram-viewport'
@@ -73,7 +106,7 @@ function openDiagram(
})
shadow.append(clone)
viewport.append(paper)
dialog.append(toolbar, help, viewport)
dialog.append(viewport, title, close, toolbar, helpToggle, help)
doc.body.append(dialog)
const overflow = doc.body.style.overflow
@@ -112,6 +145,13 @@ function openDiagram(
controller.pan(0, 0, { animate: false })
}
const options = { signal: listeners.signal }
const updateScale = (): void => { scaleLabel.textContent = `${Math.round(controller.getScale() * 100)}%` }
updateScale()
paper.addEventListener('panzoomchange', updateScale, options)
helpToggle.addEventListener('click', () => {
help.hidden = !help.hidden
helpToggle.setAttribute('aria-expanded', String(!help.hidden))
}, options)
close.addEventListener('click', cleanup, options)
dialog.addEventListener('close', cleanup, options)
dialog.addEventListener('cancel', (event) => {
@@ -124,7 +164,7 @@ function openDiagram(
viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false })
dialog.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
const controls = [zoomOut, zoomIn, fit, close]
const controls = [close, zoomOut, zoomIn, fit, helpToggle]
const current = controls.indexOf(doc.activeElement as HTMLButtonElement)
const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0)
: (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length
@@ -171,7 +211,7 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer
const copy = language().startsWith('zh') ? messages.zh : messages.en
const containers = new Set(doc.querySelectorAll('.vp-doc .mermaid'))
for (const [container, entry] of entries) {
if (!containers.has(container) || container.querySelector('svg') !== entry.svg || !entry.button.isConnected) {
if (!containers.has(container) || container.querySelector('svg:not(.dsh-diagram-icon)') !== entry.svg || !entry.button.isConnected) {
if (entry.svg === active) closeActive()
entry.button.remove()
entries.delete(container)
@@ -180,12 +220,12 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer
for (const container of containers) {
const existing = entries.get(container)
if (existing) {
if (existing.button.textContent !== copy.open) existing.button.textContent = copy.open
if (existing.button.getAttribute('aria-label') !== copy.open) labelButton(existing.button, copy.open)
continue
}
const svg = container.querySelector('svg')
const svg = container.querySelector<SVGSVGElement>('svg:not(.dsh-diagram-icon)')
if (!svg || !dimensions(svg)) continue
const trigger = button(doc, copy.open)
const trigger = button(doc, copy.open, 'open')
trigger.className = 'dsh-diagram-open'
trigger.setAttribute('aria-haspopup', 'dialog')
trigger.addEventListener('click', () => {
+40 -2
View File
@@ -9,7 +9,7 @@ import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/me
const panzoom = vi.hoisted(() => ({
create: vi.fn<(element: HTMLElement, options: PanzoomOptions) => unknown>(),
destroy: vi.fn(), setOptions: vi.fn(), zoom: vi.fn(), pan: vi.fn(),
zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: () => 2,
zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: vi.fn<() => number>(),
}))
vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create }))
@@ -24,7 +24,10 @@ const dialogMethods = new Map(['showModal', 'close'].map(name => [
beforeEach(() => {
vi.clearAllMocks()
panzoom.create.mockReturnValue(panzoom)
panzoom.create.mockImplementation((_element, options) => {
panzoom.getScale.mockReturnValue(options.startScale ?? 1)
return panzoom
})
viewportWidth = 1032
viewportHeight = 632
document.body.innerHTML = '<main id="VPContent" class="vp-doc"><div class="mermaid"></div></main>'
@@ -94,6 +97,10 @@ describe('documentation Mermaid viewer', () => {
viewer.refresh()
expect(queryAllByRole(document.body, 'button')).toHaveLength(1)
expect(required(document.querySelector('.mermaid')).firstElementChild?.tagName).toBe('BUTTON')
const trigger = getByRole(document.body, 'button', { name: 'View diagram fullscreen' })
expect(trigger.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
await new Promise<void>((resolve) => { queueMicrotask(resolve) })
expect(getByRole(document.body, 'button', { name: 'View diagram fullscreen' })).toBe(trigger)
})
it('fits the natural SVG into the viewport and isolates copied styles and fragment IDs', () => {
@@ -130,6 +137,7 @@ describe('documentation Mermaid viewer', () => {
const wheel = new WheelEvent('wheel', { deltaY: -100 })
viewport.dispatchEvent(wheel)
expect(panzoom.zoomWithWheel).toHaveBeenCalledWith(wheel)
panzoom.getScale.mockReturnValue(2)
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(panzoom.pan).toHaveBeenLastCalledWith(-32, 0, { relative: true, animate: false })
getByRole(dialog, 'button', { name: 'Fit view' }).click()
@@ -150,6 +158,36 @@ describe('documentation Mermaid viewer', () => {
expect(document.activeElement).toBe(zoomOut)
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }))
expect(document.activeElement).toBe(close)
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }))
expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Viewer help' }))
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }))
expect(document.activeElement).toBe(close)
})
it('shows zoom changes and reveals help on demand without retaining listeners after close', () => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
const dialog = open()
const scale = required(dialog.querySelector('.dsh-diagram-scale'))
const paper = required(dialog.querySelector('.dsh-diagram-paper'))
expect(scale.textContent).toBe('20%')
panzoom.getScale.mockReturnValue(0.75)
paper.dispatchEvent(new Event('panzoomchange'))
expect(scale.textContent).toBe('75%')
const toggle = getByRole(dialog, 'button', { name: 'Viewer help' })
const help = required(document.getElementById(required(toggle.getAttribute('aria-controls'))))
expect(help.hidden).toBe(true)
toggle.click()
expect(help.hidden).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
toggle.click()
expect(help.hidden).toBe(true)
getByRole(dialog, 'button', { name: 'Close' }).click()
toggle.click()
expect(help.hidden).toBe(true)
panzoom.getScale.mockReturnValue(1.5)
paper.dispatchEvent(new Event('panzoomchange'))
expect(scale.textContent).toBe('75%')
})
it.each(['button', 'cancel', 'refresh', 'dispose'] as const)('releases resources on %s and restores focus and scrolling', (method) => {