Skip to content

Commit fbc6d1f

Browse files
committed
Improve Mermaid diagrams and viewer UX
1 parent 67dfa59 commit fbc6d1f

27 files changed

Lines changed: 791 additions & 419 deletions

File tree

  • docs
    • .vitepress
    • en/lectures
      • lecture-01-why-capable-agents-still-fail
      • lecture-02-what-a-harness-actually-is
      • lecture-03-why-the-repository-must-become-the-system-of-record
      • lecture-04-why-one-giant-instruction-file-fails
      • lecture-05-why-long-running-tasks-lose-continuity
      • lecture-06-why-initialization-needs-its-own-phase
      • lecture-07-why-agents-overreach-and-under-finish
      • lecture-08-why-feature-lists-are-harness-primitives
      • lecture-09-why-agents-declare-victory-too-early
      • lecture-10-why-end-to-end-testing-changes-results
      • lecture-11-why-observability-belongs-inside-the-harness
      • lecture-12-why-every-session-must-leave-a-clean-state
    • zh/lectures
      • lecture-01-why-capable-agents-still-fail
      • lecture-02-what-a-harness-actually-is
      • lecture-03-why-the-repository-must-become-the-system-of-record
      • lecture-04-why-one-giant-instruction-file-fails
      • lecture-05-why-long-running-tasks-lose-continuity
      • lecture-06-why-initialization-needs-its-own-phase
      • lecture-07-why-agents-overreach-and-under-finish
      • lecture-08-why-feature-lists-are-harness-primitives
      • lecture-09-why-agents-declare-victory-too-early
      • lecture-10-why-end-to-end-testing-changes-results
      • lecture-11-why-observability-belongs-inside-the-harness
      • lecture-12-why-every-session-must-leave-a-clean-state

docs/.vitepress/config.mts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,13 @@ export default withMermaid(
104104
primaryBorderColor: '#D1D1D1',
105105
primaryTextColor: '#1A1A1A',
106106
lineColor: '#B3B3B3',
107-
fontFamily: 'Inter, sans-serif'
107+
fontFamily: 'Inter, sans-serif',
108+
fontSize: '18px'
109+
},
110+
flowchart: {
111+
nodeSpacing: 40,
112+
rankSpacing: 56,
113+
padding: 12
108114
}
109115
},
110116
locales: {

docs/.vitepress/theme/index.js

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import DefaultTheme from 'vitepress/theme'
22
import './style.css'
33

44
let rootEnglishLabel = 'English'
5+
let mermaidViewer
6+
let mermaidViewerBody
7+
let mermaidViewerStage
8+
let mermaidObserver
9+
let mermaidViewerUrl
10+
let mermaidViewerImage
11+
let mermaidViewerNaturalWidth = 0
12+
let mermaidViewerNaturalHeight = 0
13+
let mermaidViewerFitScale = 1
14+
let mermaidViewerScale = 1
515

616
function dedupeLocaleMenuLinks() {
717
if (typeof document === 'undefined') return
@@ -60,16 +70,257 @@ function syncRootLocaleLabel(siteData, href = '') {
6070
locales.root.label = relative.startsWith('/zh/') ? locales.zh.label : rootEnglishLabel
6171
}
6272

73+
function ensureMermaidViewer() {
74+
if (typeof document === 'undefined') return
75+
if (mermaidViewer && mermaidViewerBody && mermaidViewerStage) return
76+
77+
mermaidViewer = document.createElement('div')
78+
mermaidViewer.className = 'mermaid-viewer'
79+
mermaidViewer.innerHTML = `
80+
<div class="mermaid-viewer__backdrop" data-mermaid-close="true"></div>
81+
<div class="mermaid-viewer__panel" role="dialog" aria-modal="true" aria-label="Expanded Mermaid diagram">
82+
<div class="mermaid-viewer__toolbar">
83+
<button type="button" class="mermaid-viewer__tool" aria-label="Zoom out" data-mermaid-action="zoom-out">−</button>
84+
<button type="button" class="mermaid-viewer__tool mermaid-viewer__tool--label" aria-label="Reset zoom" data-mermaid-action="reset">100%</button>
85+
<button type="button" class="mermaid-viewer__tool" aria-label="Zoom in" data-mermaid-action="zoom-in">+</button>
86+
<button type="button" class="mermaid-viewer__close" aria-label="Close diagram" data-mermaid-close="true">×</button>
87+
</div>
88+
<div class="mermaid-viewer__body">
89+
<div class="mermaid-viewer__stage"></div>
90+
</div>
91+
</div>
92+
`
93+
94+
mermaidViewerBody = mermaidViewer.querySelector('.mermaid-viewer__body')
95+
mermaidViewerStage = mermaidViewer.querySelector('.mermaid-viewer__stage')
96+
97+
mermaidViewer.addEventListener('click', (event) => {
98+
const target = event.target
99+
if (!(target instanceof HTMLElement)) return
100+
101+
if (
102+
target.dataset.mermaidClose === 'true' ||
103+
target === mermaidViewerBody ||
104+
target === mermaidViewerStage
105+
) {
106+
closeMermaidViewer()
107+
return
108+
}
109+
110+
const action = target.dataset.mermaidAction
111+
if (action === 'zoom-in') {
112+
setMermaidViewerScale(mermaidViewerScale + 0.2)
113+
} else if (action === 'zoom-out') {
114+
setMermaidViewerScale(mermaidViewerScale - 0.2)
115+
} else if (action === 'reset') {
116+
setMermaidViewerScale(1)
117+
}
118+
})
119+
120+
document.addEventListener('keydown', (event) => {
121+
if (event.key === 'Escape' && mermaidViewer?.classList.contains('is-open')) {
122+
closeMermaidViewer()
123+
} else if ((event.key === '+' || event.key === '=') && mermaidViewer?.classList.contains('is-open')) {
124+
setMermaidViewerScale(mermaidViewerScale + 0.2)
125+
} else if (event.key === '-' && mermaidViewer?.classList.contains('is-open')) {
126+
setMermaidViewerScale(mermaidViewerScale - 0.2)
127+
}
128+
})
129+
130+
mermaidViewerBody?.addEventListener('wheel', (event) => {
131+
if (!mermaidViewer?.classList.contains('is-open')) return
132+
event.preventDefault()
133+
const delta = event.deltaY < 0 ? 0.12 : -0.12
134+
setMermaidViewerScale(mermaidViewerScale + delta)
135+
}, { passive: false })
136+
137+
window.addEventListener('resize', () => {
138+
if (mermaidViewer?.classList.contains('is-open')) {
139+
updateMermaidViewerFitScale()
140+
setMermaidViewerScale(mermaidViewerScale)
141+
}
142+
})
143+
144+
document.body.appendChild(mermaidViewer)
145+
}
146+
147+
function serializeMermaidSvg(svg) {
148+
const clone = svg.cloneNode(true)
149+
if (!(clone instanceof SVGElement)) return null
150+
151+
if (!clone.getAttribute('xmlns')) {
152+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
153+
}
154+
if (!clone.getAttribute('xmlns:xlink')) {
155+
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
156+
}
157+
158+
const rect = svg.getBoundingClientRect()
159+
const viewBox = svg.viewBox?.baseVal
160+
const rawWidth = rect.width || viewBox?.width || Number.parseFloat(svg.getAttribute('width') || '') || 1200
161+
const rawHeight = rect.height || viewBox?.height || Number.parseFloat(svg.getAttribute('height') || '') || 800
162+
163+
if (!clone.getAttribute('viewBox')) {
164+
clone.setAttribute('viewBox', `0 0 ${Math.max(1, Math.round(rawWidth))} ${Math.max(1, Math.round(rawHeight))}`)
165+
}
166+
167+
clone.setAttribute('width', String(Math.max(1, Math.round(rawWidth))))
168+
clone.setAttribute('height', String(Math.max(1, Math.round(rawHeight))))
169+
170+
return {
171+
markup: new XMLSerializer().serializeToString(clone),
172+
width: Math.max(1, rawWidth),
173+
height: Math.max(1, rawHeight)
174+
}
175+
}
176+
177+
function updateMermaidViewerFitScale() {
178+
if (!mermaidViewerNaturalWidth || !mermaidViewerNaturalHeight || typeof window === 'undefined') return
179+
180+
const maxWidth = Math.max(320, window.innerWidth - 16)
181+
const maxHeight = Math.max(240, window.innerHeight - 16)
182+
mermaidViewerFitScale = Math.max(
183+
0.25,
184+
Math.min(maxWidth / mermaidViewerNaturalWidth, maxHeight / mermaidViewerNaturalHeight)
185+
)
186+
}
187+
188+
function setMermaidViewerScale(nextScale) {
189+
if (!mermaidViewerStage || !mermaidViewerNaturalWidth || !mermaidViewerNaturalHeight) return
190+
191+
mermaidViewerScale = Math.max(0.5, Math.min(nextScale, 4))
192+
193+
const width = mermaidViewerNaturalWidth * mermaidViewerFitScale * mermaidViewerScale
194+
const height = mermaidViewerNaturalHeight * mermaidViewerFitScale * mermaidViewerScale
195+
196+
mermaidViewerStage.style.width = `${width}px`
197+
mermaidViewerStage.style.height = `${height}px`
198+
199+
const resetButton = mermaidViewer?.querySelector('[data-mermaid-action="reset"]')
200+
if (resetButton instanceof HTMLElement) {
201+
resetButton.textContent = `${Math.round(mermaidViewerScale * 100)}%`
202+
}
203+
}
204+
205+
function openMermaidViewer(diagram) {
206+
ensureMermaidViewer()
207+
if (!mermaidViewerBody || !mermaidViewer || !mermaidViewerStage) return
208+
209+
const svg = diagram.querySelector('svg')
210+
if (!svg) return
211+
212+
if (mermaidViewerUrl) {
213+
URL.revokeObjectURL(mermaidViewerUrl)
214+
mermaidViewerUrl = undefined
215+
}
216+
217+
const serialized = serializeMermaidSvg(svg)
218+
if (!serialized) return
219+
220+
const blob = new Blob([serialized.markup], { type: 'image/svg+xml;charset=utf-8' })
221+
mermaidViewerUrl = URL.createObjectURL(blob)
222+
223+
const image = document.createElement('img')
224+
image.className = 'mermaid-viewer__image'
225+
image.alt = 'Expanded Mermaid diagram'
226+
image.src = mermaidViewerUrl
227+
image.addEventListener('load', () => {
228+
mermaidViewerNaturalWidth = serialized.width
229+
mermaidViewerNaturalHeight = serialized.height
230+
updateMermaidViewerFitScale()
231+
setMermaidViewerScale(1)
232+
if (mermaidViewerBody) {
233+
mermaidViewerBody.scrollTo({ top: 0, left: 0 })
234+
}
235+
})
236+
237+
mermaidViewerImage = image
238+
mermaidViewerStage.replaceChildren(image)
239+
mermaidViewer.classList.add('is-open')
240+
document.documentElement.classList.add('has-mermaid-viewer')
241+
}
242+
243+
function closeMermaidViewer() {
244+
if (!mermaidViewer || !mermaidViewerBody || !mermaidViewerStage) return
245+
mermaidViewer.classList.remove('is-open')
246+
mermaidViewerStage.replaceChildren()
247+
mermaidViewerStage.style.width = ''
248+
mermaidViewerStage.style.height = ''
249+
if (mermaidViewerUrl) {
250+
URL.revokeObjectURL(mermaidViewerUrl)
251+
mermaidViewerUrl = undefined
252+
}
253+
mermaidViewerImage = undefined
254+
mermaidViewerNaturalWidth = 0
255+
mermaidViewerNaturalHeight = 0
256+
mermaidViewerFitScale = 1
257+
mermaidViewerScale = 1
258+
document.documentElement.classList.remove('has-mermaid-viewer')
259+
}
260+
261+
function bindMermaidZoomables(root = document) {
262+
if (typeof document === 'undefined') return
263+
ensureMermaidViewer()
264+
265+
const diagrams = root.querySelectorAll('.vp-doc .mermaid')
266+
for (const diagram of diagrams) {
267+
if (!(diagram instanceof HTMLElement) || diagram.dataset.mermaidZoomBound === 'true') {
268+
continue
269+
}
270+
271+
diagram.dataset.mermaidZoomBound = 'true'
272+
diagram.tabIndex = 0
273+
diagram.setAttribute('role', 'button')
274+
diagram.setAttribute('aria-label', 'Open Mermaid diagram in fullscreen')
275+
276+
diagram.addEventListener('click', () => {
277+
openMermaidViewer(diagram)
278+
})
279+
280+
diagram.addEventListener('keydown', (event) => {
281+
if (event.key === 'Enter' || event.key === ' ') {
282+
event.preventDefault()
283+
openMermaidViewer(diagram)
284+
}
285+
})
286+
}
287+
}
288+
289+
function queueMermaidBinding() {
290+
if (typeof window === 'undefined') return
291+
window.requestAnimationFrame(() => {
292+
window.requestAnimationFrame(() => {
293+
bindMermaidZoomables()
294+
})
295+
})
296+
}
297+
298+
function ensureMermaidObserver() {
299+
if (typeof document === 'undefined' || mermaidObserver) return
300+
mermaidObserver = new MutationObserver(() => {
301+
bindMermaidZoomables()
302+
})
303+
304+
mermaidObserver.observe(document.body, {
305+
childList: true,
306+
subtree: true
307+
})
308+
}
309+
63310
export default {
64311
extends: DefaultTheme,
65312
enhanceApp(ctx) {
66313
syncRootLocaleLabel(ctx.siteData)
67314
queueLocaleMenuDedupe()
315+
queueMermaidBinding()
316+
ensureMermaidObserver()
68317

69318
const previous = ctx.router.onAfterRouteChange
70319
ctx.router.onAfterRouteChange = async (to) => {
71320
syncRootLocaleLabel(ctx.siteData, to)
72321
queueLocaleMenuDedupe()
322+
closeMermaidViewer()
323+
queueMermaidBinding()
73324
if (previous) {
74325
await previous(to)
75326
}

0 commit comments

Comments
 (0)