Merge pull request #4783 from deepseek-harness/feat/docs-image-viewer

为文档截图复用 Mermaid 弹层与缩放逻辑
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-09-21 15:04:42 +08:00
commit d44d899c71
16 files changed
+820 -232

No files matched your search

@@ -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: 03aecb7b4df1c79d2eb254375f6b2c68145079ac
2026-09-14-docs-mermaid-viewer.zh.md: b66f427a4820eef0d6fa25b163cfc8e3548ec0c6
2026-09-14-docs-mermaid-viewer.md: 5cb4bae84fcafb49c559b7678e9a9dc1aa961ab0
2026-09-14-docs-mermaid-viewer.zh.md: 665ab65e48e15651ebb9323e96e6a950c0b48deb
@@ -1,4 +1,4 @@
# Agent Note: Documentation Mermaid viewer
# Agent Note: Documentation diagram and image viewer
Status: implemented
@@ -6,15 +6,17 @@ English | [中文](2026-09-14-docs-mermaid-viewer.zh.md)
## Problem
Complex Mermaid diagrams lose readable detail when scaled to the documentation column. Long sequence diagrams need both magnification and movement to inspect interactions while retaining an overview.
Complex Mermaid diagrams and interface screenshots lose readable detail when scaled to the documentation column. Readers need both magnification and movement to inspect interactions while retaining an overview.
## Decision
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. Its visible title also names it for assistive technology; a missing or blank page heading uses the localized viewer title. 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.
Ordinary images share one modal and zoom controller with Mermaid. Loaded images with nonempty alt text that occupy their own paragraph open through an image click or corner button; linked, inline, decorative, and `data-no-zoom` images retain their existing interaction. Image alt text names the modal, and the scale button returns to centered natural size (100%) while its accessible name also includes the current scale; keyboard focus cycles through six buttons in image views.
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 body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes 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.
Viewer resources belong to the mounted theme. Route, language, theme, and source-content replacement close the active view; asynchronous Mermaid renders and loaded images receive fresh entries. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [shared modal](../../../../website/.vitepress/theme/media-viewer.ts), [Mermaid adapter](../../../../website/.vitepress/theme/mermaid-viewer.ts), and [image adapter](../../../../website/.vitepress/theme/image-viewer.ts) leave Markdown, raw page copies, and `llms.txt` generation with their existing owners.
## Alternatives considered
@@ -26,8 +28,8 @@ Viewer resources belong to the mounted theme. Route, language, theme, and source
## Consequences
The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source.
The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the content, and navigating or changing the theme closes it. Existing page images and diagrams remain the reading and link-navigation source.
The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering, accessible titles, initial and resized fit, control wiring, keyboard cycling, replacement, and resource release.
The [Mermaid tests](../../../../website/tests/mermaid-viewer.spec.ts) and [image tests](../../../../website/tests/image-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering and loading, accessible names, initial and resized fit, the 100% action, keyboard cycling, switching between images and diagrams, source replacement, and resource release.
**CI coverage gap.** The DOM tests mock Panzoom and do not execute browser layout. Native modal behavior, SVG markers, pointer-anchored wheel zoom, canvas dragging, theme colors, and narrow-screen geometry require real-browser verification. The recorded browser demonstration supplies evidence for the current implementation, but it is not an automated regression check.
@@ -1,4 +1,4 @@
# Agent Note: 文档站 Mermaid 查看器
# Agent Note: 文档站图表与图片查看器
Status: implemented
@@ -6,15 +6,17 @@ Status: implemented
## 问题
复杂 Mermaid 图表缩放到文档正文列宽后,细节难以辨认。阅读长时序图需要在保留全图概览的同时放大和平移,以检查交互细节。
复杂 Mermaid 图表和界面截图缩放到文档正文列宽后,细节难以辨认。阅读时需要在保留全图概览的同时放大和平移,以检查交互细节。
## 决策
[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。可见标题也用于辅助技术识别对话框;页面标题缺失或为空白时,使用本地化的查看器标题。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。
普通图片与 Mermaid 共用一个弹层和缩放控制器。已加载、具有非空 alt 文本且独占段落的图片支持点击图片或角落按钮打开;链接图片、行内图片、装饰图片和 `data-no-zoom` 图片保留原有交互。图片的 alt 文本作为弹层标题,比例按钮返回并居中显示自然尺寸(100%),其辅助名称同时包含当前比例;图片视图的键盘焦点在六个按钮之间循环。
查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。
查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。
查看器资源由已挂载的主题持有。路由、语言、主题和源内容替换都会关闭当前视图;异步渲染的 Mermaid 图表和加载完成的图片会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[共享弹层](../../../../website/.vitepress/theme/media-viewer.ts)、[Mermaid 适配器](../../../../website/.vitepress/theme/mermaid-viewer.ts) 与[图片适配器](../../../../website/.vitepress/theme/image-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。
## 考虑过的替代方案
@@ -26,8 +28,8 @@ Status: implemented
## 影响
文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。
文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配内容,导航或主题变化会关闭视图。原有页面图片和图表仍然是阅读和链接导航的来源。
[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染、无障碍标题、初始及窗口变化后的适配、控件连接、键盘焦点循环、图表替换和资源释放。
[Mermaid 测试](../../../../website/tests/mermaid-viewer.spec.ts) 与[图片测试](../../../../website/tests/image-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染和加载、无障碍名称、初始及窗口变化后的适配、100% 操作、键盘焦点循环、图片与图表互相切换、源内容替换和资源释放。
**CI 覆盖缺口。** DOM 测试模拟了 Panzoom,不执行浏览器布局。原生模态行为、SVG 标记、以指针为锚点的滚轮缩放、画布拖动、主题颜色和窄屏几何布局需要真实浏览器验证。浏览器演示记录提供了当前实现的证据,但不属于自动回归检查。
+1 -1
View File
@@ -127,7 +127,7 @@
"docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
"docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
"docs:preview": "pnpm --filter @deepseek-ai/website run preview",
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build",
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build",
"website:dev": "pnpm run docs:dev",
"website:build": "pnpm run docs:build",
"verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",
+1
View File
@@ -826,6 +826,7 @@ function docSyncLeafGates(options: {
pnpmExec('docs-site-projection', [
'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts',
'website/tests/mermaid-viewer.spec.ts',
'website/tests/image-viewer.spec.ts',
'website/tests/code-groups.spec.ts',
'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts',
], {
+127
View File
@@ -0,0 +1,127 @@
/** Full-viewport viewing of loaded, standalone content images in documentation pages. */
import { labelButton, type MediaViewer, viewerButton } from './media-viewer.ts'
const messages = {
en: 'View image fullscreen: {alt}',
zh: '全屏查看图片:{alt}',
}
interface ImageEntry {
container: HTMLElement
button: HTMLButtonElement
events: AbortController
source: string
}
/** Enhances standalone image paragraphs without moving or replacing their source images. */
export class ImageViewer {
readonly #entries = new Map<HTMLImageElement, ImageEntry>()
readonly #observer: MutationObserver
readonly #events = new AbortController()
#active: HTMLImageElement | undefined
#close: (() => void) | undefined
/**
* @param doc Browser document containing VitePress content.
* @param language Current VitePress language, read again when entries refresh.
* @param viewer Shared modal owner for images and diagrams.
*/
constructor(
private readonly doc: Document,
private readonly language: () => string,
private readonly viewer: Pick<MediaViewer, 'open'>,
) {
const root = doc.querySelector('#VPContent') ?? doc.body
const scan = (): void => { this.#scan() }
this.#observer = new MutationObserver(scan)
this.#observer.observe(root, {
childList: true, subtree: true, attributes: true,
attributeFilter: ['src', 'srcset', 'alt', 'role', 'aria-hidden', 'data-no-zoom'],
})
const options = { capture: true, signal: this.#events.signal }
root.addEventListener('load', scan, options)
root.addEventListener('error', scan, options)
this.#scan()
}
/** Close the image view and refresh controls after route, language, or theme changes. */
refresh(): void {
this.#closeActive()
this.#scan()
}
/** Remove image enhancements, event handlers, observers, and any active image modal. */
dispose(): void {
this.#observer.disconnect()
this.#events.abort()
this.#closeActive()
for (const [image, entry] of this.#entries) this.#remove(image, entry)
}
#closeActive(): void {
this.#close?.()
this.#close = undefined
this.#active = undefined
}
#remove(image: HTMLImageElement, entry: ImageEntry): void {
if (image === this.#active) this.#closeActive()
entry.events.abort()
entry.button.remove()
entry.container.classList.remove('dsh-image-container')
image.classList.remove('dsh-image-zoomable')
this.#entries.delete(image)
}
#scan(): void {
const images = new Set(Array.from(this.doc.querySelectorAll<HTMLImageElement>('.vp-doc p > img')).filter((image) => {
const entry = this.#entries.get(image)
return image.complete && image.naturalWidth > 0 && image.naturalHeight > 0 && image.alt.trim()
&& !image.closest('a, button, .mermaid, [data-no-zoom], [aria-hidden="true"], [role="presentation"], [role="none"]')
&& Array.from(image.parentNode?.childNodes ?? []).every(node => node === image || node === entry?.button
|| (node.nodeType === 3 && !node.textContent?.trim()))
}))
for (const [image, entry] of this.#entries) {
if (!images.has(image) || image.parentElement !== entry.container || !entry.button.isConnected
|| entry.source !== (image.currentSrc || image.src)) {
this.#remove(image, entry)
}
}
const copy = this.language().startsWith('zh') ? messages.zh : messages.en
for (const image of images) {
const label = copy.replace('{alt}', () => image.alt)
const existing = this.#entries.get(image)
if (existing) {
if (existing.button.getAttribute('aria-label') !== label) labelButton(existing.button, label)
continue
}
const container = image.parentElement
if (!container) continue
const button = viewerButton(this.doc, label, 'open')
button.className = 'dsh-image-open'
button.setAttribute('aria-haspopup', 'dialog')
const events = new AbortController()
const open = (): void => {
this.#closeActive()
const content = this.doc.createElement('img')
content.src = image.currentSrc || image.src
content.alt = image.alt
content.draggable = false
this.#close = this.viewer.open({
element: content, width: image.naturalWidth, height: image.naturalHeight,
title: image.alt, originalSize: true,
}, button, () => {
this.#close = undefined
this.#active = undefined
})
this.#active = image
}
button.addEventListener('click', open, { signal: events.signal })
image.addEventListener('click', open, { signal: events.signal })
container.classList.add('dsh-image-container')
image.classList.add('dsh-image-zoomable')
container.append(button)
this.#entries.set(image, { container, button, events, source: image.currentSrc || image.src })
}
}
}
+19 -5
View File
@@ -1,10 +1,12 @@
/** Default documentation theme with Markdown actions and a client-only Mermaid viewer. */
/** Default documentation theme with Markdown actions and a client-only image and diagram viewer. */
import DefaultTheme from 'vitepress/theme'
import { useData, useRoute, type Theme } from 'vitepress'
import { defineComponent, h, onBeforeUnmount, onMounted, watch } from 'vue'
import type { MermaidViewer } from './mermaid-viewer.ts'
import type { ImageViewer } from './image-viewer.ts'
import type { MediaViewer } from './media-viewer.ts'
import { PageMarkdownActions } from './page-markdown-actions.ts'
import './mermaid-viewer.css'
import './media-viewer.css'
import './code-group.css'
import './page-markdown-actions.css'
@@ -16,15 +18,27 @@ export default {
const { lang, isDark, frontmatter, site, page } = useData()
const route = useRoute()
let viewer: MermaidViewer | undefined
let images: ImageViewer | undefined
let media: MediaViewer | undefined
let disposed = false
onMounted(async () => {
const { installMermaidViewer } = await import('./mermaid-viewer.ts')
if (!disposed) viewer = installMermaidViewer(document, () => lang.value)
const [{ installMermaidViewer }, { ImageViewer }, { MediaViewer }] = await Promise.all([
import('./mermaid-viewer.ts'), import('./image-viewer.ts'), import('./media-viewer.ts'),
])
if (disposed) return
media = new MediaViewer(document, () => lang.value)
viewer = installMermaidViewer(document, () => lang.value, media)
images = new ImageViewer(document, () => lang.value, media)
})
watch([() => route.path, lang, isDark], () => viewer?.refresh(), { flush: 'post' })
watch([() => route.path, lang, isDark], () => {
viewer?.refresh()
images?.refresh()
}, { flush: 'post' })
onBeforeUnmount(() => {
disposed = true
viewer?.dispose()
images?.dispose()
media?.close()
})
return () => h(DefaultTheme.Layout, null, {
'doc-before': () => {
@@ -4,7 +4,8 @@
}
.vp-doc .dsh-diagram-open,
.dsh-diagram-viewer button {
.vp-doc .dsh-image-open,
.dsh-media-viewer button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -25,7 +26,7 @@
right: 0;
}
.dsh-diagram-icon {
.dsh-media-icon {
width: 20px;
height: 20px;
fill: none;
@@ -36,19 +37,21 @@
}
.dsh-diagram-open:hover,
.dsh-diagram-viewer button:hover,
.dsh-diagram-viewer button[aria-expanded="true"] {
.dsh-image-open:hover,
.dsh-media-viewer button:hover,
.dsh-media-viewer button[aria-expanded="true"] {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg-soft);
}
.dsh-diagram-open:focus-visible,
.dsh-diagram-viewer button:focus-visible {
.dsh-image-open:focus-visible,
.dsh-media-viewer button:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 2px;
}
.dsh-diagram-viewer {
.dsh-media-viewer {
position: fixed;
inset: 0;
width: 100%;
@@ -64,7 +67,7 @@
box-sizing: border-box;
}
.dsh-diagram-title {
.dsh-media-title {
position: absolute;
top: 24px;
left: 24px;
@@ -76,9 +79,9 @@
font-size: 13px;
}
.dsh-diagram-close { position: absolute; top: 12px; right: 16px; }
.dsh-media-close { position: absolute; top: 12px; right: 16px; }
.dsh-diagram-toolbar {
.dsh-media-toolbar {
position: absolute;
bottom: 20px;
left: 50%;
@@ -93,7 +96,7 @@
box-shadow: 0 3px 12px #0000000a;
}
.dsh-diagram-scale {
.dsh-media-scale {
min-width: 48px;
text-align: center;
color: var(--vp-c-text-2);
@@ -101,16 +104,16 @@
font-variant-numeric: tabular-nums;
}
.dsh-diagram-toolbar .dsh-diagram-fit {
.dsh-media-toolbar .dsh-media-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-media-help-toggle { position: absolute; bottom: 25px; right: 16px; }
.dsh-diagram-help {
.dsh-media-help {
position: absolute;
right: 16px;
bottom: 80px;
@@ -124,19 +127,23 @@
font-size: 13px;
}
.dsh-diagram-viewport {
.dsh-media-viewport {
position: absolute;
inset: 64px 16px 88px;
overflow: hidden;
overscroll-behavior: contain;
}
.dsh-diagram-paper { position: absolute; inset: 0; }
.dsh-media-paper { position: absolute; inset: 0; }
@media (max-width: 600px) {
.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; }
.dsh-media-title { top: 20px; left: 16px; }
.dsh-media-close { top: 8px; right: 8px; }
.dsh-media-viewport { inset: 56px 8px 80px; }
.dsh-media-toolbar { bottom: 12px; }
.dsh-media-help-toggle { right: 8px; bottom: 17px; }
}
.vp-doc .dsh-image-container { position: relative; width: fit-content; max-width: 100%; }
.vp-doc .dsh-image-zoomable { cursor: zoom-in; }
.vp-doc .dsh-image-open { position: absolute; bottom: 8px; right: 8px; }
+232
View File
@@ -0,0 +1,232 @@
/** Shared modal, focus, and pan/zoom controls for documentation images and diagrams. */
import Panzoom from '@panzoom/panzoom'
const messages = {
en: {
zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', original: 'Original size (100%), currently {scale}',
close: 'Close', helpLabel: 'Viewer help',
help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close',
},
zh: {
zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', original: '原始尺寸(100%),当前{scale}',
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',
}
/**
* Set the accessible name and hover hint of a viewer control.
* @param element Button to label.
* @param label Localized control name.
*/
export function labelButton(element: HTMLButtonElement, label: string): void {
element.setAttribute('aria-label', label)
element.title = label
}
/**
* Create a viewer control with a decorative SVG icon.
* @param doc Document that owns the control.
* @param label Localized control name.
* @param icon Viewer icon to display.
* @returns A native button ready for its click handler.
*/
export function viewerButton(doc: Document, label: string, icon: keyof typeof icons): HTMLButtonElement {
const element = doc.createElement('button')
element.type = 'button'
labelButton(element, label)
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.classList.add('dsh-media-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
}
/** Detached content with intrinsic dimensions in CSS pixels; the viewer owns its lifetime. */
interface MediaContent {
element: HTMLElement | SVGSVGElement
width: number
height: number
title: string
originalSize?: boolean
}
/** Owns at most one modal and restores page scrolling and trigger focus when it closes. */
export class MediaViewer {
#close: (() => void) | undefined
/**
* @param doc Browser document containing the viewer's triggers.
* @param language Current VitePress language, read when opening the modal.
*/
constructor(private readonly doc: Document, private readonly language: () => string) {}
/** Close the active modal and release its observers, listeners, and scroll lock. */
close(): void {
this.#close?.()
}
/**
* Open content at a scale that fits the viewport, closing any previous modal.
* @param content Detached image or SVG and its display metadata.
* @param trigger Connected control to focus after the modal closes.
* @param onClose Notify the adapter once after releasing the modal, including setup failure.
* @returns Idempotent closer for this modal only.
*/
open(content: MediaContent, trigger: HTMLElement, onClose: () => void): () => void {
this.close()
const doc = this.doc
const copy = this.language().startsWith('zh') ? messages.zh : messages.en
const dialog = doc.createElement('dialog')
dialog.className = 'dsh-media-viewer'
dialog.setAttribute('aria-labelledby', 'dsh-media-title')
const toolbar = doc.createElement('div')
toolbar.className = 'dsh-media-toolbar'
const title = doc.createElement('span')
title.className = 'dsh-media-title'
title.id = 'dsh-media-title'
title.textContent = content.title
const zoomOut = viewerButton(doc, copy.zoomOut, 'zoomOut')
const zoomIn = viewerButton(doc, copy.zoomIn, 'zoomIn')
const original = content.originalSize ? doc.createElement('button') : undefined
if (original) original.type = 'button'
const scaleLabel = original ?? doc.createElement('span')
scaleLabel.className = 'dsh-media-scale'
const fit = viewerButton(doc, copy.fit, 'fit')
fit.className = 'dsh-media-fit'
const close = viewerButton(doc, copy.close, 'close')
close.className = 'dsh-media-close'
close.autofocus = true
toolbar.append(zoomOut, scaleLabel, zoomIn, fit)
const helpToggle = viewerButton(doc, copy.helpLabel, 'help')
helpToggle.className = 'dsh-media-help-toggle'
helpToggle.setAttribute('aria-expanded', 'false')
helpToggle.setAttribute('aria-controls', 'dsh-media-help-text')
const help = doc.createElement('p')
help.className = 'dsh-media-help'
help.id = 'dsh-media-help-text'
help.hidden = true
help.textContent = copy.help
const viewport = doc.createElement('div')
viewport.className = 'dsh-media-viewport'
const paper = doc.createElement('div')
paper.className = 'dsh-media-paper'
// Shared content lives in a shadow root so Mermaid copies cannot resolve their
// embedded styles and fragment IDs against the original diagram.
const shadow = paper.attachShadow({ mode: 'open' })
const clone = content.element
Object.assign(clone.style, {
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
width: `${content.width}px`, height: `${content.height}px`, maxWidth: 'none', display: 'block',
})
shadow.append(clone)
viewport.append(paper)
dialog.append(viewport, title, close, toolbar, helpToggle, help)
doc.body.append(dialog)
const overflow = doc.body.style.overflow
const listeners = new AbortController()
let panzoom: ReturnType<typeof Panzoom> | undefined
let resize: ResizeObserver | undefined
let closed = false
const cleanup = (): void => {
if (closed) return
closed = true
listeners.abort()
resize?.disconnect()
panzoom?.destroy()
dialog.close()
dialog.remove()
doc.body.style.overflow = overflow
if (trigger.isConnected) trigger.focus({ preventScroll: true })
this.#close = undefined
onClose()
}
this.#close = cleanup
try {
dialog.showModal()
doc.body.style.overflow = 'hidden'
const fitScale = (): number => Math.min(
1, Math.max(1, viewport.clientWidth - 32) / content.width,
Math.max(1, viewport.clientHeight - 32) / content.height,
)
const controller = Panzoom(paper, {
canvas: true, startScale: fitScale(), minScale: fitScale() / 2,
maxScale: 8, animate: false, pinchAndPan: true,
})
panzoom = controller
const refit = (): void => {
const scale = fitScale()
controller.setOptions({ minScale: scale / 2 })
controller.zoom(scale, { animate: false })
controller.pan(0, 0, { animate: false })
}
const options = { signal: listeners.signal }
const updateScale = (): void => {
const scale = `${Math.round(controller.getScale() * 100)}%`
scaleLabel.textContent = scale
if (original) labelButton(original, copy.original.replace('{scale}', scale))
}
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) => {
event.preventDefault()
cleanup()
}, options)
zoomIn.addEventListener('click', () => controller.zoomIn({ animate: false }), options)
zoomOut.addEventListener('click', () => controller.zoomOut({ animate: false }), options)
fit.addEventListener('click', refit, options)
original?.addEventListener('click', () => {
controller.zoom(1, { animate: false })
controller.pan(0, 0, { animate: false })
}, options)
viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false })
dialog.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
const controls = [close, zoomOut, ...(original ? [original] : []), 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
event.preventDefault()
controls[next]?.focus()
return
}
const distance = 64 / controller.getScale()
const offsets: Record<string, [number, number]> = {
ArrowLeft: [distance, 0], ArrowRight: [-distance, 0],
ArrowUp: [0, distance], ArrowDown: [0, -distance],
}
const offset = offsets[event.key]
if (offset && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault()
controller.pan(...offset, { relative: true, animate: false })
}
}, options)
resize = new ResizeObserver(refit)
resize.observe(viewport)
return cleanup
} catch (error) {
cleanup()
throw error
}
}
}
+18 -179
View File
@@ -1,25 +1,9 @@
/** Full-viewport viewing of asynchronously rendered documentation diagrams. */
import Panzoom from '@panzoom/panzoom'
import { labelButton, type MediaViewer, viewerButton } from './media-viewer.ts'
const messages = {
en: {
open: 'View diagram fullscreen', title: 'Diagram viewer',
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: '关闭', 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',
en: { open: 'View diagram fullscreen', title: 'Diagram viewer' },
zh: { open: '全屏查看图表', title: '图表查看器' },
}
/** Theme-owned resources; source SVG replacement automatically closes the current view. */
@@ -30,27 +14,6 @@ export interface MermaidViewer {
dispose(): void
}
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'
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
}
function dimensions(svg: SVGSVGElement): { width: number; height: number } | undefined {
const { width, height } = svg.viewBox.baseVal
if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) {
@@ -59,147 +22,16 @@ function dimensions(svg: SVGSVGElement): { width: number; height: number } | und
return undefined
}
function openDiagram(
svg: SVGSVGElement, trigger: HTMLButtonElement, copy: typeof messages.en, onClose: () => void,
): () => void {
const size = dimensions(svg)
if (!size) return () => {}
const doc = svg.ownerDocument
const dialog = doc.createElement('dialog')
dialog.className = 'dsh-diagram-viewer'
dialog.setAttribute('aria-labelledby', 'dsh-diagram-title')
const toolbar = doc.createElement('div')
toolbar.className = 'dsh-diagram-toolbar'
const title = doc.createElement('span')
title.className = 'dsh-diagram-title'
title.id = '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(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'
const paper = doc.createElement('div')
paper.className = 'dsh-diagram-paper'
// Each Mermaid SVG embeds ID-scoped styles and fragment references. A shadow root
// keeps the enlarged copy's IDs and styles separate from the original diagram.
const shadow = paper.attachShadow({ mode: 'open' })
const clone = svg.cloneNode(true) as SVGSVGElement
Object.assign(clone.style, {
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
width: `${size.width}px`, height: `${size.height}px`, maxWidth: 'none', display: 'block',
})
shadow.append(clone)
viewport.append(paper)
dialog.append(viewport, title, close, toolbar, helpToggle, help)
doc.body.append(dialog)
const overflow = doc.body.style.overflow
const listeners = new AbortController()
let panzoom: ReturnType<typeof Panzoom> | undefined
let resize: ResizeObserver | undefined
let closed = false
function cleanup(): void {
if (closed) return
closed = true
listeners.abort()
resize?.disconnect()
panzoom?.destroy()
dialog.close()
dialog.remove()
doc.body.style.overflow = overflow
if (trigger.isConnected) trigger.focus({ preventScroll: true })
onClose()
}
try {
dialog.showModal()
doc.body.style.overflow = 'hidden'
const fitScale = (): number => Math.min(
1, Math.max(1, viewport.clientWidth - 32) / size.width,
Math.max(1, viewport.clientHeight - 32) / size.height,
)
const controller = Panzoom(paper, {
canvas: true, startScale: fitScale(), minScale: fitScale() / 2,
maxScale: 8, animate: false, pinchAndPan: true,
})
panzoom = controller
const refit = (): void => {
const scale = fitScale()
controller.setOptions({ minScale: scale / 2 })
controller.zoom(scale, { animate: false })
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) => {
event.preventDefault()
cleanup()
}, options)
zoomIn.addEventListener('click', () => controller.zoomIn({ animate: false }), options)
zoomOut.addEventListener('click', () => controller.zoomOut({ animate: false }), options)
fit.addEventListener('click', refit, options)
viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false })
dialog.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
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
event.preventDefault()
controls[next]?.focus()
return
}
const distance = 64 / controller.getScale()
const offsets: Record<string, [number, number]> = {
ArrowLeft: [distance, 0], ArrowRight: [-distance, 0],
ArrowUp: [0, distance], ArrowDown: [0, -distance],
}
const offset = offsets[event.key]
if (offset && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault()
controller.pan(...offset, { relative: true, animate: false })
}
}, options)
resize = new ResizeObserver(refit)
resize.observe(viewport)
return cleanup
} catch (error) {
cleanup()
throw error
}
}
/**
* Enhance rendered Mermaid SVGs without modifying the canonical Markdown or renderer.
* @param doc Browser document containing VitePress content.
* @param language Current VitePress language, read again when entries refresh.
* @param viewer Shared modal owner for images and diagrams.
* @returns Resources owned by the mounted theme.
*/
export function installMermaidViewer(doc: Document, language: () => string): MermaidViewer {
export function installMermaidViewer(
doc: Document, language: () => string, viewer: Pick<MediaViewer, 'open'>,
): MermaidViewer {
const entries = new Map<Element, { svg: SVGSVGElement; button: HTMLButtonElement }>()
let active: SVGSVGElement | undefined
let close: (() => void) | undefined
@@ -212,7 +44,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:not(.dsh-diagram-icon)') !== entry.svg || !entry.button.isConnected) {
if (!containers.has(container) || container.querySelector('svg:not(.dsh-media-icon)') !== entry.svg || !entry.button.isConnected) {
if (entry.svg === active) closeActive()
entry.button.remove()
entries.delete(container)
@@ -224,14 +56,21 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer
if (existing.button.getAttribute('aria-label') !== copy.open) labelButton(existing.button, copy.open)
continue
}
const svg = container.querySelector<SVGSVGElement>('svg:not(.dsh-diagram-icon)')
const svg = container.querySelector<SVGSVGElement>('svg:not(.dsh-media-icon)')
if (!svg || !dimensions(svg)) continue
const trigger = button(doc, copy.open, 'open')
const trigger = viewerButton(doc, copy.open, 'open')
trigger.className = 'dsh-diagram-open'
trigger.setAttribute('aria-haspopup', 'dialog')
trigger.addEventListener('click', () => {
closeActive()
close = openDiagram(svg, trigger, language().startsWith('zh') ? messages.zh : messages.en, () => {
const size = dimensions(svg)
if (!size) return
const copy = language().startsWith('zh') ? messages.zh : messages.en
close = viewer.open({
element: svg.cloneNode(true) as SVGSVGElement,
...size,
title: doc.querySelector('.vp-doc h1')?.textContent.trim() || copy.title,
}, trigger, () => {
close = undefined
active = undefined
})
+1 -1
View File
@@ -18,6 +18,6 @@ Run `pnpm docs:check` after changing this subtree; the gate rejects additional n
Content-page Markdown actions use the projector's `rawMarkdownPath` and the site base, including full `index.md` routes. Copy fetches carry `?dsh-raw=1`; development script imports still belong to Vite. Keep clipboard work within its initiating gesture and page lifetime; the [Markdown actions decision](../.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md) owns browser limitations and verification.
The default-theme extension owns the Mermaid fullscreen viewer. Keep its enhancements separate from Markdown projection and preserve the original SVG. Route, language, theme, and rendered-SVG changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification.
The default-theme extension shares one fullscreen modal and pan/zoom controller between Mermaid diagrams and standalone content images. Keep enhancements separate from Markdown projection and preserve source elements. Images must be loaded, have nonempty alt text, and occupy their own paragraph; linked, inline, decorative, and `data-no-zoom` images keep their existing interaction. Image views expose intrinsic size through the 100% control. Route, language, theme, and source replacement changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification.
Native code groups keep their radio controls in separate forms so copied search excerpts cannot clear the page's selected platform. Search excerpts and MPA builds display every command block because tab switching is unavailable; the [platform-tab decision](../.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md) owns the interaction and verification.
@@ -0,0 +1,3 @@
<button type="button" aria-label="View image fullscreen: DeepSeek API settings" title="View image fullscreen: DeepSeek API settings" class="dsh-image-open" aria-haspopup="dialog"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7"></path></svg></button>
<dialog class="dsh-media-viewer" aria-labelledby="dsh-media-title" open=""><div class="dsh-media-viewport"><div class="dsh-media-paper"></div></div><span class="dsh-media-title" id="dsh-media-title">DeepSeek API settings</span><button type="button" aria-label="Close" title="Close" class="dsh-media-close" autofocus=""><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"></path></svg></button><div class="dsh-media-toolbar"><button type="button" aria-label="Zoom out" title="Zoom out"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14"></path></svg></button><button type="button" class="dsh-media-scale" aria-label="Original size (100%), currently 63%" title="Original size (100%), currently 63%">63%</button><button type="button" aria-label="Zoom in" title="Zoom in"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M12 5v14"></path></svg></button><button type="button" aria-label="Fit view" title="Fit view" class="dsh-media-fit"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5"></path></svg></button></div><button type="button" aria-label="Viewer help" title="Viewer help" class="dsh-media-help-toggle" aria-expanded="false" aria-controls="dsh-media-help-text"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1"></path></svg></button><p class="dsh-media-help" id="dsh-media-help-text" hidden="">Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close</p></dialog>
<img src="https://docs.example/[email protected]" alt="DeepSeek API settings" draggable="false" style="position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 1600px; height: 800px; max-width: none; display: block;">
@@ -0,0 +1,3 @@
<button type="button" aria-label="全屏查看图片:DeepSeek API 配置" title="全屏查看图片:DeepSeek API 配置" class="dsh-image-open" aria-haspopup="dialog"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7"></path></svg></button>
<dialog class="dsh-media-viewer" aria-labelledby="dsh-media-title" open=""><div class="dsh-media-viewport"><div class="dsh-media-paper"></div></div><span class="dsh-media-title" id="dsh-media-title">DeepSeek API 配置</span><button type="button" aria-label="关闭" title="关闭" class="dsh-media-close" autofocus=""><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"></path></svg></button><div class="dsh-media-toolbar"><button type="button" aria-label="缩小" title="缩小"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14"></path></svg></button><button type="button" class="dsh-media-scale" aria-label="原始尺寸(100%),当前63%" title="原始尺寸(100%),当前63%">63%</button><button type="button" aria-label="放大" title="放大"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M12 5v14"></path></svg></button><button type="button" aria-label="适应窗口" title="适应窗口" class="dsh-media-fit"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5"></path></svg></button></div><button type="button" aria-label="查看器帮助" title="查看器帮助" class="dsh-media-help-toggle" aria-expanded="false" aria-controls="dsh-media-help-text"><svg class="dsh-media-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1"></path></svg></button><p class="dsh-media-help" id="dsh-media-help-text" hidden="">滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭</p></dialog>
<img src="https://docs.example/[email protected]" alt="DeepSeek API 配置" draggable="false" style="position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 1600px; height: 800px; max-width: none; display: block;">
+350
View File
@@ -0,0 +1,350 @@
/** @vitest-environment jsdom */
/** Image loading, accessible controls, and ownership of the shared documentation viewer. */
import assert from 'node:assert/strict'
import type { PanzoomOptions } from '@panzoom/panzoom'
import { getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ImageViewer } from '../.vitepress/theme/image-viewer.ts'
import { MediaViewer } from '../.vitepress/theme/media-viewer.ts'
import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/mermaid-viewer.ts'
const panzoom = vi.hoisted(() => ({ create: vi.fn<(element: HTMLElement, options: PanzoomOptions) => unknown>() }))
vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create }))
function controller(scale: number) {
return {
destroy: vi.fn(), setOptions: vi.fn(), zoom: vi.fn(), pan: vi.fn(),
zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: vi.fn(() => scale),
}
}
let viewer: ImageViewer | undefined
let mermaid: MermaidViewer | undefined
let media: MediaViewer
let language = 'en-US'
let overflow: string
const controllers: ReturnType<typeof controller>[] = []
const disconnects: ReturnType<typeof vi.fn>[] = []
const dialogMethods = new Map(['showModal', 'close'].map(name => [
name, Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, name),
]))
beforeEach(() => {
panzoom.create.mockReset().mockImplementation((_element, options) => {
const instance = controller(options.startScale ?? 1)
controllers.push(instance)
return instance
})
language = 'en-US'
overflow = document.body.style.overflow
document.body.innerHTML = '<main id="VPContent" class="vp-doc"></main>'
document.body.style.overflow = 'auto'
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1032)
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(632)
vi.spyOn(SVGSVGElement.prototype, 'viewBox', 'get').mockReturnValue({ baseVal: { width: 2000, height: 3000 } } as SVGAnimatedRect)
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { configurable: true, value: function (this: HTMLDialogElement) {
this.open = true
this.querySelector<HTMLButtonElement>('[autofocus]')?.focus()
} })
Object.defineProperty(HTMLDialogElement.prototype, 'close', { configurable: true, value: function (this: HTMLDialogElement) {
this.open = false
this.dispatchEvent(new Event('close'))
} })
vi.stubGlobal('ResizeObserver', class {
disconnect = vi.fn()
constructor() { disconnects.push(this.disconnect) }
observe(): void {}
})
media = new MediaViewer(document, () => language)
})
afterEach(() => {
viewer?.dispose()
mermaid?.dispose()
media.close()
viewer = undefined
mermaid = undefined
controllers.length = 0
disconnects.length = 0
document.body.replaceChildren()
document.body.style.overflow = overflow
vi.restoreAllMocks()
vi.unstubAllGlobals()
for (const [name, descriptor] of dialogMethods) {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor)
else Reflect.deleteProperty(HTMLDialogElement.prototype, name)
}
})
function required<T>(value: T | null | undefined): T {
assert(value != null)
return value
}
function imageState(image: HTMLImageElement, complete = true) {
const state = { complete, width: 1600, height: 800, currentSrc: '' }
vi.spyOn(image, 'complete', 'get').mockImplementation(() => state.complete)
vi.spyOn(image, 'naturalWidth', 'get').mockImplementation(() => state.width)
vi.spyOn(image, 'naturalHeight', 'get').mockImplementation(() => state.height)
vi.spyOn(image, 'currentSrc', 'get').mockImplementation(() => state.currentSrc)
return state
}
function render(complete = true) {
const container = document.createElement('p')
const image = document.createElement('img')
image.src = 'https://docs.example/provider.png'
image.alt = 'DeepSeek API settings'
container.append(image)
required(document.querySelector('main')).append(container)
return { image, container, state: imageState(image, complete) }
}
function install(): ImageViewer {
viewer = new ImageViewer(document, () => language, media)
return viewer
}
function entry(): HTMLElement {
return getByRole(document.body, 'button', { name: 'View image fullscreen: DeepSeek API settings' })
}
function open(): HTMLDialogElement {
entry().click()
return getByRole(document.body, 'dialog', { name: 'DeepSeek API settings' }) as HTMLDialogElement
}
function enlarged(dialog: HTMLElement): HTMLImageElement {
return required(dialog.querySelector('.dsh-media-paper')?.shadowRoot?.querySelector('img'))
}
describe('documentation image viewer', () => {
it('adds one entry after a delayed image loads and removes it when loading fails', async () => {
const { image, state } = render(false)
install()
expect(queryAllByRole(document.body, 'button')).toHaveLength(0)
state.complete = true
image.dispatchEvent(new Event('load'))
const trigger = entry()
required(viewer).refresh()
await waitFor(() => { expect(queryAllByRole(document.body, 'button')).toHaveLength(1) })
expect(entry()).toBe(trigger)
state.width = 0
image.dispatchEvent(new Event('error'))
expect(queryAllByRole(document.body, 'button')).toHaveLength(0)
expect(image.classList.contains('dsh-image-zoomable')).toBe(false)
image.click()
trigger.click()
expect(queryByRole(document.body, 'dialog')).toBeNull()
})
it('leaves linked, decorative, inline, grouped, and failed images unchanged', () => {
const root = required(document.querySelector('main'))
root.innerHTML = [
'<p><a href="https://example.com"><img alt="Linked screenshot"></a></p>',
'<p><img alt=""></p>',
'<p><img alt=" "></p>',
'<p><img alt="Decorative" role="presentation"></p>',
'<p><img alt="Decorative" role="none"></p>',
'<p aria-hidden="true"><img alt="Hidden"></p>',
'<p><img alt="Opt out" data-no-zoom></p>',
'<div data-no-zoom><p><img alt="Container opt out"></p></div>',
'<p>Inline <img alt="Badge"></p>',
'<p><img alt="First"><img alt="Second"></p>',
'<img alt="Outside paragraph">',
'<div class="mermaid"><p><img alt="Diagram image"></p></div>',
'<p><img alt="Failed"></p>',
].join('')
for (const image of root.querySelectorAll('img')) {
const state = imageState(image)
if (image.alt === 'Failed') state.height = 0
}
const original = root.innerHTML
install()
expect(root.innerHTML).toBe(original)
expect(queryAllByRole(root, 'button')).toHaveLength(0)
})
it.each([
{ locale: 'en-US', title: 'DeepSeek API settings', name: 'View image fullscreen: DeepSeek API settings', trigger: 'image' },
{ locale: 'zh-CN', title: 'DeepSeek API 配置', name: '全屏查看图片:DeepSeek API 配置', trigger: 'button' },
])('opens the loaded image with accessible controls in $locale', async ({ locale, title, name, trigger }) => {
const { image, state } = render()
language = locale
image.alt = title
state.currentSrc = 'https://docs.example/[email protected]'
install()
const button = getByRole(document.body, 'button', { name })
expect(button.getAttribute('aria-haspopup')).toBe('dialog')
if (trigger === 'image') image.click()
else button.click()
const dialog = getByRole(document.body, 'dialog', { name: title })
const clone = enlarged(dialog)
expect(clone).not.toBe(image)
expect(clone.src).toBe(state.currentSrc)
expect(clone.alt).toBe(title)
expect(clone.style.width).toBe('1600px')
expect(clone.style.height).toBe('800px')
expect(clone.draggable).toBe(false)
expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.625, minScale: 0.3125 })
expect(document.body.style.overflow).toBe('hidden')
await expect(`${button.outerHTML}\n${dialog.outerHTML}\n${clone.outerHTML}\n`)
.toMatchFileSnapshot(`./expected/image-viewer.${locale}.html`)
dialog.dispatchEvent(new Event('cancel', { cancelable: true }))
expect(queryByRole(document.body, 'dialog')).toBeNull()
expect(document.activeElement).toBe(button)
expect(document.body.style.overflow).toBe('auto')
})
it('centers the original size and includes its control in the keyboard focus cycle', () => {
render()
install()
const dialog = open()
const original = getByRole(dialog, 'button', { name: /^Original size \(100%\), currently / })
expect(original.textContent).toBe('63%')
expect(original.getAttribute('aria-label')).toBe('Original size (100%), currently 63%')
original.click()
const current = required(controllers[0])
expect(current.zoom).toHaveBeenCalledWith(1, { animate: false })
expect(current.pan).toHaveBeenCalledWith(0, 0, { animate: false })
current.getScale.mockReturnValue(1)
required(dialog.querySelector('.dsh-media-paper')).dispatchEvent(new Event('panzoomchange'))
expect(original.textContent).toBe('100%')
expect(original.getAttribute('aria-label')).toBe('Original size (100%), currently 100%')
const controls = ['Zoom out', 'Original size (100%), currently 100%', 'Zoom in', 'Fit view', 'Viewer help', 'Close']
expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Close' }))
for (const name of controls) {
const event = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })
dialog.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(getByRole(dialog, 'button', { name }))
}
for (const name of controls.slice(0, -1).reverse().concat('Close')) {
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }))
expect(document.activeElement).toBe(getByRole(dialog, 'button', { name }))
}
})
it.each(['image', 'diagram'])('replaces an active %s view without leaking its controls or scroll lock', (first) => {
render()
required(document.querySelector('main')).insertAdjacentHTML('beforeend', '<div class="mermaid"><svg viewBox="0 0 2000 3000"></svg></div>')
install()
mermaid = installMermaidViewer(document, () => language, media)
const imageTrigger = entry()
const diagramTrigger = getByRole(document.body, 'button', { name: 'View diagram fullscreen' })
const [oldTrigger, newTrigger] = first === 'image' ? [imageTrigger, diagramTrigger] : [diagramTrigger, imageTrigger]
oldTrigger.click()
const oldDialog = getByRole(document.body, 'dialog')
const oldZoom = getByRole(oldDialog, 'button', { name: 'Zoom in' })
newTrigger.click()
const dialog = getByRole(document.body, 'dialog')
expect(dialog).not.toBe(oldDialog)
expect(queryAllByRole(document.body, 'dialog')).toHaveLength(1)
expect(required(controllers[0]).destroy).toHaveBeenCalledOnce()
expect(required(disconnects[0])).toHaveBeenCalledOnce()
expect(document.body.style.overflow).toBe('hidden')
expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Close' }))
oldZoom.click()
expect(required(controllers[0]).zoomIn).not.toHaveBeenCalled()
if (first === 'image') required(viewer).refresh()
else required(mermaid).refresh()
expect(getByRole(document.body, 'dialog')).toBe(dialog)
expect(document.body.style.overflow).toBe('hidden')
expect(queryByRole(dialog, 'button', { name: /^Original size \(100%\), currently / }) === null).toBe(first === 'image')
getByRole(dialog, 'button', { name: 'Close' }).click()
expect(document.activeElement).toBe(newTrigger)
expect(document.body.style.overflow).toBe('auto')
expect(required(controllers[1]).destroy).toHaveBeenCalledOnce()
expect(required(disconnects[1])).toHaveBeenCalledOnce()
})
it.each(['src', 'srcset'])('closes stale content after %s changes and opens the replacement', async (attribute) => {
const { image, state } = render()
install()
const oldTrigger = entry()
open()
const replacement = 'https://docs.example/replacement.png'
if (attribute === 'srcset') state.currentSrc = replacement
image.setAttribute(attribute, attribute === 'srcset' ? `${replacement} 2x` : replacement)
await waitFor(() => { expect(queryByRole(document.body, 'dialog')).toBeNull() })
expect(document.body.style.overflow).toBe('auto')
expect(required(controllers[0]).destroy).toHaveBeenCalledOnce()
oldTrigger.click()
expect(queryByRole(document.body, 'dialog')).toBeNull()
expect(entry()).not.toBe(oldTrigger)
expect(enlarged(open()).src).toBe(replacement)
})
it('closes stale content when currentSrc changes after the srcset mutation', async () => {
const { image, state } = render()
state.currentSrc = image.src
install()
const oldTrigger = entry()
const dialog = open()
const replacement = 'https://docs.example/[email protected]'
image.srcset = `${replacement} 2x`
await new Promise<void>((resolve) => { queueMicrotask(resolve) })
expect(getByRole(document.body, 'dialog')).toBe(dialog)
expect(enlarged(dialog).src).toBe(state.currentSrc)
state.currentSrc = replacement
image.dispatchEvent(new Event('load'))
expect(queryByRole(document.body, 'dialog')).toBeNull()
expect(document.body.style.overflow).toBe('auto')
expect(required(controllers[0]).destroy).toHaveBeenCalledOnce()
expect(entry()).not.toBe(oldTrigger)
oldTrigger.click()
expect(queryByRole(document.body, 'dialog')).toBeNull()
expect(enlarged(open()).src).toBe(replacement)
})
it.each(['refresh', 'dispose', 'remove', 'error'] as const)('releases an open image on %s', async (action) => {
const { image, container, state } = render()
install()
const trigger = entry()
const dialog = open()
const zoom = getByRole(dialog, 'button', { name: 'Zoom in' })
if (action === 'remove') container.remove()
else if (action === 'error') {
state.width = 0
image.dispatchEvent(new Event('error'))
} else required(viewer)[action]()
await waitFor(() => { expect(queryByRole(document.body, 'dialog')).toBeNull() })
expect(document.body.style.overflow).toBe('auto')
expect(required(controllers[0]).destroy).toHaveBeenCalledOnce()
expect(required(disconnects[0])).toHaveBeenCalledOnce()
zoom.click()
expect(required(controllers[0]).zoomIn).not.toHaveBeenCalled()
if (action === 'refresh') expect(document.activeElement).toBe(trigger)
else {
expect(image.classList.contains('dsh-image-zoomable')).toBe(false)
expect(container.classList.contains('dsh-image-container')).toBe(false)
image.click()
trigger.click()
expect(queryByRole(document.body, 'dialog')).toBeNull()
}
required(viewer).dispose()
expect(required(controllers[0]).destroy).toHaveBeenCalledOnce()
})
it('refreshes localized entry names and stops enhancing new and delayed images after disposal', async () => {
const ready = render()
const pending = render(false)
install()
const trigger = entry()
language = 'zh-CN'
ready.image.alt = 'API 配置'
required(viewer).refresh()
expect(getByRole(document.body, 'button', { name: '全屏查看图片:API 配置' })).toBe(trigger)
required(viewer).dispose()
pending.state.complete = true
pending.image.dispatchEvent(new Event('load'))
render()
await new Promise<void>((resolve) => { queueMicrotask(resolve) })
expect(queryAllByRole(document.body, 'button')).toHaveLength(0)
ready.image.click()
pending.image.click()
trigger.click()
expect(panzoom.create).not.toHaveBeenCalled()
})
})
+20 -14
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict'
import type { PanzoomOptions } from '@panzoom/panzoom'
import { getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MediaViewer } from '../.vitepress/theme/media-viewer.ts'
import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/mermaid-viewer.ts'
const panzoom = vi.hoisted(() => ({
@@ -14,6 +15,8 @@ const panzoom = vi.hoisted(() => ({
vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create }))
let viewer: MermaidViewer | undefined
let media: MediaViewer
let locale = 'en-US'
let resize: (() => void) | undefined
let viewportWidth = 1032
let viewportHeight = 632
@@ -24,6 +27,8 @@ const dialogMethods = new Map(['showModal', 'close'].map(name => [
beforeEach(() => {
vi.clearAllMocks()
locale = 'en-US'
media = new MediaViewer(document, () => locale)
panzoom.create.mockImplementation((_element, options) => {
panzoom.getScale.mockReturnValue(options.startScale ?? 1)
return panzoom
@@ -55,6 +60,7 @@ beforeEach(() => {
afterEach(() => {
viewer?.dispose()
media.close()
viewer = undefined
resize = undefined
document.body.replaceChildren()
@@ -95,7 +101,8 @@ describe('documentation Mermaid viewer', () => {
h1.textContent = heading
required(document.querySelector('.vp-doc')).prepend(h1)
}
viewer = installMermaidViewer(document, () => language)
locale = language
viewer = installMermaidViewer(document, () => locale, media)
getByRole(document.body, 'button').click()
const dialog = getByRole(document.body, 'dialog', { name: expected })
const title = required(document.getElementById(required(dialog.getAttribute('aria-labelledby'))))
@@ -103,7 +110,7 @@ describe('documentation Mermaid viewer', () => {
})
it('adds one entry only after an SVG with usable dimensions renders', async () => {
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
expect(queryAllByRole(document.body, 'button')).toHaveLength(0)
render('0 0 0 3000')
viewer.refresh()
@@ -124,10 +131,10 @@ describe('documentation Mermaid viewer', () => {
it('fits the natural SVG into the viewport and isolates copied styles and fragment IDs', () => {
const source = render()
const original = source.outerHTML
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
const dialog = open()
expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.2, minScale: 0.1 })
const paper = required(dialog.querySelector<HTMLElement>('.dsh-diagram-paper'))
const paper = required(dialog.querySelector<HTMLElement>('.dsh-media-paper'))
const clone = required(paper.shadowRoot?.querySelector('svg'))
expect(clone.style.width).toBe('2000px')
expect(clone.style.height).toBe('3000px')
@@ -148,13 +155,13 @@ describe('documentation Mermaid viewer', () => {
it('connects zoom, wheel, keyboard panning and fit without dismissing a canvas click', () => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
const dialog = open()
getByRole(dialog, 'button', { name: 'Zoom in' }).click()
getByRole(dialog, 'button', { name: 'Zoom out' }).click()
expect(panzoom.zoomIn).toHaveBeenCalledOnce()
expect(panzoom.zoomOut).toHaveBeenCalledOnce()
const viewport = required(dialog.querySelector<HTMLElement>('.dsh-diagram-viewport'))
const viewport = required(dialog.querySelector<HTMLElement>('.dsh-media-viewport'))
const wheel = new WheelEvent('wheel', { deltaY: -100 })
viewport.dispatchEvent(wheel)
expect(panzoom.zoomWithWheel).toHaveBeenCalledWith(wheel)
@@ -170,7 +177,7 @@ describe('documentation Mermaid viewer', () => {
it('wraps keyboard focus through the viewer controls in both directions', () => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
const dialog = open()
const close = getByRole(dialog, 'button', { name: 'Close' })
const zoomOut = getByRole(dialog, 'button', { name: 'Zoom out' })
@@ -187,10 +194,10 @@ describe('documentation Mermaid viewer', () => {
it('shows zoom changes and reveals help on demand without retaining listeners after close', () => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
const dialog = open()
const scale = required(dialog.querySelector('.dsh-diagram-scale'))
const paper = required(dialog.querySelector('.dsh-diagram-paper'))
const scale = required(dialog.querySelector('.dsh-media-scale'))
const paper = required(dialog.querySelector('.dsh-media-paper'))
expect(scale.textContent).toBe('20%')
panzoom.getScale.mockReturnValue(0.75)
paper.dispatchEvent(new Event('panzoomchange'))
@@ -213,7 +220,7 @@ describe('documentation Mermaid viewer', () => {
it.each(['button', 'cancel', 'refresh', 'dispose'] as const)('releases resources on %s and restores focus and scrolling', (method) => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
const trigger = getByRole(document.body, 'button')
const dialog = open()
const zoom = getByRole(dialog, 'button', { name: 'Zoom in' })
@@ -233,8 +240,7 @@ describe('documentation Mermaid viewer', () => {
it('closes a replaced diagram and permits repeated opens with the updated locale', async () => {
render()
let locale = 'en-US'
viewer = installMermaidViewer(document, () => locale)
viewer = installMermaidViewer(document, () => locale, media)
open()
render('0 0 400 200')
await waitFor(() => {
@@ -252,7 +258,7 @@ describe('documentation Mermaid viewer', () => {
it('closes when route content is removed and stops enhancing after disposal', async () => {
render()
viewer = installMermaidViewer(document, () => 'en-US')
viewer = installMermaidViewer(document, () => locale, media)
open()
required(document.querySelector('.mermaid')).remove()
await waitFor(() => {
@@ -12,6 +12,8 @@ vi.mock('vitepress/theme', () => ({ default: { Layout: {
setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()),
} } }))
vi.mock('../.vitepress/theme/mermaid-viewer.ts', () => ({ installMermaidViewer: () => ({ refresh() {}, dispose() {} }) }))
vi.mock('../.vitepress/theme/image-viewer.ts', () => ({ ImageViewer: class { refresh() {} dispose() {} } }))
vi.mock('../.vitepress/theme/media-viewer.ts', () => ({ MediaViewer: class { close() {} } }))
const data = {
lang: ref('en-US'), isDark: ref(false),