import DefaultTheme from 'vitepress/theme' import mediumZoom from 'medium-zoom' import { onContentUpdated } from 'vitepress' import { onMounted } from 'vue' import type { Theme } from 'vitepress' import './custom.css' /** * 参与「点击放大」的图片范围。 * * `.vp-doc` 是 VitePress 默认主题给正文容器加的类名,使用手册 9 页与接口文档 * 全部页面共用这一套布局,所以在这里定义一次即对全站生效。 * * 排除项: * a img 图片本身是链接(如带外链的截图),点击应跳转而不是放大 * .no-zoom 需要单独关闭放大的图片,示例: * `xx` */ const ZOOM_SELECTOR = '.vp-doc img:not(a img):not(.no-zoom)' /** * 全站只保留一个 medium-zoom 实例,但【必须惰性创建】。 * * 两个坑叠在一起,才得到这个写法: * * 1. 不能在模块顶层创建实例。medium-zoom 1.1.0 的工厂函数第一件事就是读 * `window.Promise`(dist/medium-zoom.min.js 里即 `var Promise = window.Promise || ...`), * 而主题模块在 `vitepress build` 时会被 Node 端 SSR 打包并执行 —— 没有 window, * 直接 `ReferenceError: window is not defined` 构建失败。 * 所以实例只能在浏览器生命周期回调里创建。 * * 2. 也不能每次路由切换都 `mediumZoom(...)` 新建。每个实例都会在 document 上注册 * click / keyup / scroll、在 window 上注册 resize 监听器,且没有卸载入口 * (见 dist/medium-zoom.esm.js:576-581)。翻 N 个页面就会留下 N 组永不释放的 * 全局监听器。正确做法是复用实例,路由切换时只做 attach —— attach 内部会跳过 * 已挂载的图片(dist/medium-zoom.esm.js:199-204),因此重复调用是幂等的。 */ let zoom: ReturnType | null = null const attachZoom = () => { if (typeof window === 'undefined') return if (!zoom) { zoom = mediumZoom({ background: 'var(--vp-c-bg)', margin: 24, }) } zoom.attach(ZOOM_SELECTOR) } const closeZoomOnClick = (event: MouseEvent) => { const target = event.target as Element | null if (!zoom || !target) return if (target.closest('.medium-zoom-image--opened, .medium-zoom-overlay')) { void zoom.close() } } const theme: Theme = { extends: DefaultTheme, setup() { // onContentUpdated 由 VitePress 在「新页面内容挂载完成后」触发,覆盖首次进入 // 与 SPA 客户端路由切换。它挂在路由组件的 onVnodeMounted 上 // (见 vitepress/dist/client/app/components/Content.js:13-20), // 比 watch(route.path) + nextTick 更可靠 —— 后者可能在异步页面组件挂载前就执行, // 导致某些页面图片没挂上监听、点击放大静默失效。 onContentUpdated(attachZoom) // 兜底:主题 setup 时内容已就绪(不经过 onVnodeMounted 的极端情况)也能挂上 onMounted(attachZoom) if (typeof document !== 'undefined') { document.addEventListener('click', closeZoomOnClick, true) } }, } export default theme