|
| 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> |
0 commit comments