diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 0c527da..257b89e 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,8 +3,107 @@ import { useRoute } from 'vitepress'; import { nextTick, onMounted, watch } from 'vue'; import mermaid from 'mermaid'; import '@catppuccin/vitepress/theme/macchiato/mauve.css'; +import './mermaid-modal.css'; let mermaidLoader: Promise | null = null; +const MERMAID_MODAL_ID = 'mermaid-diagram-modal'; + +function closeMermaidModal() { + if (typeof document === 'undefined') { + return; + } + + const modal = document.getElementById(MERMAID_MODAL_ID); + if (!modal) { + return; + } + + modal.classList.remove('is-open'); + document.body.classList.remove('mermaid-modal-open'); +} + +function ensureMermaidModal(): HTMLDivElement { + const existing = document.getElementById(MERMAID_MODAL_ID); + if (existing) { + return existing as HTMLDivElement; + } + + const modal = document.createElement('div'); + modal.id = MERMAID_MODAL_ID; + modal.className = 'mermaid-modal'; + modal.innerHTML = ` +
+ + `; + + modal.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + if (target.closest('[data-mermaid-close="true"]') || target.closest('.mermaid-modal__close')) { + closeMermaidModal(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeMermaidModal(); + } + }); + + document.body.appendChild(modal); + return modal; +} + +function openMermaidModal(sourceNode: HTMLElement) { + if (typeof document === 'undefined') { + return; + } + + const modal = ensureMermaidModal(); + const content = modal.querySelector('.mermaid-modal__content'); + if (!content) { + return; + } + + content.replaceChildren(sourceNode.cloneNode(true)); + modal.classList.add('is-open'); + document.body.classList.add('mermaid-modal-open'); +} + +function attachMermaidInteractions(nodes: HTMLElement[]) { + for (const node of nodes) { + if (node.dataset.mermaidInteractive === 'true') { + continue; + } + + const svg = node.querySelector('svg'); + if (!svg) { + continue; + } + + node.classList.add('mermaid-interactive'); + node.setAttribute('role', 'button'); + node.setAttribute('tabindex', '0'); + node.setAttribute('aria-label', 'Open Mermaid diagram in full view'); + + const open = () => openMermaidModal(svg); + node.addEventListener('click', open); + node.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }); + + node.dataset.mermaidInteractive = 'true'; + } +} async function getMermaid() { if (!mermaidLoader) { @@ -54,6 +153,7 @@ async function renderMermaidBlocks() { if (nodes.length > 0) { await mermaid.run({ nodes }); + attachMermaidInteractions(nodes); } } diff --git a/docs/.vitepress/theme/mermaid-modal.css b/docs/.vitepress/theme/mermaid-modal.css new file mode 100644 index 0000000..0c844d2 --- /dev/null +++ b/docs/.vitepress/theme/mermaid-modal.css @@ -0,0 +1,69 @@ +.mermaid-interactive { + cursor: zoom-in; +} + +.mermaid-interactive:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 4px; + border-radius: 6px; +} + +.mermaid-modal { + position: fixed; + inset: 0; + z-index: 200; + display: none; +} + +.mermaid-modal.is-open { + display: block; +} + +.mermaid-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.72); +} + +.mermaid-modal__dialog { + position: relative; + z-index: 1; + margin: 4vh auto; + width: min(96vw, 1800px); + max-height: 92vh; + border: 1px solid var(--vp-c-border); + border-radius: 12px; + background: var(--vp-c-bg); + box-shadow: var(--vp-shadow-4); + overflow: hidden; +} + +.mermaid-modal__close { + display: block; + margin-left: auto; + margin-right: 16px; + margin-top: 12px; + border: 1px solid var(--vp-c-border); + border-radius: 6px; + padding: 4px 10px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + font-size: 14px; +} + +.mermaid-modal__content { + overflow: auto; + max-height: calc(92vh - 56px); + padding: 8px 16px 16px; +} + +.mermaid-modal__content svg { + max-width: none; + width: max-content; + height: auto; + min-width: 100%; +} + +body.mermaid-modal-open { + overflow: hidden; +}