From 33ee373257199cb603e45f973531a4175e84ede7 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 21 Jun 2026 15:03:10 +0800 Subject: [PATCH 1/2] fix(docs): add retry backoff for SVG fit + scroll fallback in mermaid modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large mermaid diagrams like 'ClientStrategy Execution Flow' generate wide SVGs (2000+px). On open, fitToScreen sometimes reads 0 from viewBox because the DOM layout isn't done yet, bailing out early. This left the SVG at native size with overflow:hidden — invisible. Fixes: - Exponential backoff retry for fitToScreen (up to 8 attempts) - Fallback to width/height attributes then getBoundingClientRect - Changed modal body from overflow:hidden to overflow:auto for scroll fallback when panzoom hasn't fitted yet - Wrapped SVG in .svgWrapper for clean DOM interaction Co-Authored-By: Claude --- website/src/theme/Mermaid/index.js | 229 +++++++++----------- website/src/theme/Mermaid/styles.module.css | 34 ++- 2 files changed, 118 insertions(+), 145 deletions(-) diff --git a/website/src/theme/Mermaid/index.js b/website/src/theme/Mermaid/index.js index a7532b5..7a95a55 100644 --- a/website/src/theme/Mermaid/index.js +++ b/website/src/theme/Mermaid/index.js @@ -9,7 +9,7 @@ import panzoom from '@panzoom/panzoom'; import styles from './styles.module.css'; -const PADDING = 80; // pixels of padding inside modal +const PADDING = 60; function MermaidRenderResult({ renderResult }) { const ref = useRef(null); @@ -19,109 +19,115 @@ function MermaidRenderResult({ renderResult }) { useEffect(() => { const div = ref.current; - renderResult.bindFunctions?.(div); + if (div) renderResult.bindFunctions?.(div); }, [renderResult]); const handleClick = useCallback(() => { setModalOpen(true); }, []); - // Initialize panzoom and auto-fit SVG when modal opens + // ── Modal: panzoom init + auto-fit ────────────────── useEffect(() => { if (!modalOpen || !modalRef.current) return; - const modalEl = modalRef.current; - const svgEl = modalEl.querySelector('svg'); - if (!svgEl) return; + const body = modalRef.current; + const svg = body.querySelector('svg'); + if (!svg) return; - // Clean up previous + // Clean up previous instance if (instanceRef.current) { instanceRef.current.destroy(); } - const instance = panzoom(svgEl, { - maxScale: 10, + // 1. Create panzoom instance + const instance = panzoom(svg, { + maxScale: 12, minScale: 0.05, - step: 0.15, + step: 0.2, contain: false, pinchAndPan: true, + // Let SVG keep its native size initially; we'll zoom below + cursor: 'grab', }); instanceRef.current = instance; - // Wheel zoom: forward wheel events from modal body to panzoom - const handleWheel = (e) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.3 : 0.3; - // Calculate zoom around cursor position - const rect = svgEl.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - instance.zoomWithWheel(e, { - step: 0.3, - minScale: 0.05, - maxScale: 10, - }); - }; - modalEl.addEventListener('wheel', handleWheel, { passive: false }); + // 2. Fit to screen – retry with exponential backoff + let fitAttempts = 0; + const maxAttempts = 8; - // Auto-fit: scale the SVG to fill the modal while keeping aspect ratio const fitToScreen = () => { - const parent = modalRef.current; - if (!parent) return; - const svg = parent.querySelector('svg'); + const svg = body.querySelector('svg'); if (!svg) return; - const containerW = parent.clientWidth; - const containerH = parent.clientHeight; - const svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width; - const svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height; + const w = body.clientWidth; + const h = body.clientHeight; + if (!w || !h) return; + + // SVG native dimensions + let svgW = svg.viewBox?.baseVal?.width; + let svgH = svg.viewBox?.baseVal?.height; + + // Fallback to width/height attributes + if (!svgW) svgW = svg.width?.baseVal?.value; + if (!svgH) svgH = svg.height?.baseVal?.value; + + // Fallback to computed bounding rect + if (!svgW || !svgH) { + const rect = svg.getBoundingClientRect(); + svgW = rect.width; + svgH = rect.height; + } + + // If still zero and we have retries left, try again + if ((!svgW || !svgH) && fitAttempts < maxAttempts) { + fitAttempts++; + setTimeout(fitToScreen, 100 * fitAttempts); + return; + } if (!svgW || !svgH) return; - const availableW = containerW - PADDING; - const availableH = containerH - PADDING; - const scale = Math.min(availableW / svgW, availableH / svgH, 3); // cap at 3x + const availW = w - PADDING * 2; + const availH = h - PADDING * 2; + const scale = Math.min(availW / svgW, availH / svgH, 3); if (scale > 0) { instance.zoom(scale, { animate: false }); - // Center the SVG - const scaledW = svgW * scale; - const scaledH = svgH * scale; - const offsetX = (containerW - scaledW) / 2; - const offsetY = (containerH - scaledH) / 2; - instance.pan(offsetX, offsetY, { animate: false }); + instance.pan( + (w - svgW * scale) / 2, + (h - svgH * scale) / 2, + { animate: false } + ); } }; - // Wait for layout, then fit - const fitTimer = setTimeout(fitToScreen, 80); + // Kick off fit with first attempt + const fitTimer = setTimeout(fitToScreen, 100); - // Re-fit on resize + // 3. Wheel zoom on the body + const handleWheel = (e) => { + e.preventDefault(); + const svgEl = body.querySelector('svg'); + if (!svgEl) return; + instance.zoomWithWheel(e, { step: 0.3, minScale: 0.05, maxScale: 12 }); + }; + body.addEventListener('wheel', handleWheel, { passive: false }); + + // 4. Resize listener window.addEventListener('resize', fitToScreen); + // 5. Keyboard shortcuts const handleKeyDown = (e) => { - if (e.key === 'Escape') { - setModalOpen(false); - } - if (e.key === '+' || e.key === '=') { - e.preventDefault(); - instance.zoomIn(); - } - if (e.key === '-') { - e.preventDefault(); - instance.zoomOut(); - } - if (e.key === '0') { - e.preventDefault(); - fitToScreen(); // reset to fit - } + if (e.key === 'Escape') { setModalOpen(false); return; } + if (e.key === '+' || e.key === '=') { e.preventDefault(); instance.zoomIn(); } + if (e.key === '-') { e.preventDefault(); instance.zoomOut(); } + if (e.key === '0') { e.preventDefault(); fitToScreen(); } }; - window.addEventListener('keydown', handleKeyDown); return () => { clearTimeout(fitTimer); - modalEl.removeEventListener('wheel', handleWheel); + body.removeEventListener('wheel', handleWheel); window.removeEventListener('resize', fitToScreen); window.removeEventListener('keydown', handleKeyDown); if (instanceRef.current) { @@ -133,6 +139,7 @@ function MermaidRenderResult({ renderResult }) { return ( <> + {/* ── Inline diagram ─────────────────────────── */}
+ {/* ── Fullscreen modal ─────────────────────────── */} {modalOpen && (
setModalOpen(false)} role="presentation" > + {/* Toolbar */}
- - - + + - - - Scroll zoom · Drag pan · 0 fit - + title="Fit to screen (0)">⊡ + + Scroll zoom · Drag pan · 0 fit
+ + {/* Body – scrollable fallback so content is never hidden */}
e.stopPropagation()} role="presentation" - dangerouslySetInnerHTML={{ __html: renderResult.svg }} - /> + > +
+
)} @@ -231,17 +211,14 @@ function MermaidRenderResult({ renderResult }) { function MermaidRenderer({ value }) { const renderResult = useMermaidRenderResult({ text: value }); - if (renderResult === null) { - return null; - } + if (renderResult === null) return null; return ; } export default function Mermaid(props) { return ( } - > + fallback={(params) => }> ); diff --git a/website/src/theme/Mermaid/styles.module.css b/website/src/theme/Mermaid/styles.module.css index 32ed7c5..8627aaa 100644 --- a/website/src/theme/Mermaid/styles.module.css +++ b/website/src/theme/Mermaid/styles.module.css @@ -28,7 +28,6 @@ align-items: center; justify-content: center; animation: overlayIn 0.2s ease; - /* Prevent body scroll when modal is open */ overflow: hidden; touch-action: none; } @@ -38,7 +37,7 @@ to { opacity: 1; } } -/* ── Toolbar at top-right ──────────────────────────────── */ +/* ── Toolbar ──────────────────────────────────────────── */ .toolbar { position: fixed; top: 12px; @@ -86,35 +85,32 @@ white-space: nowrap; } -/* ── Modal body = the fullscreen SVG container ──────────── */ +/* ── Modal body: fullscreen, scroll fallback ────────── */ .modalBody { position: absolute; - /* Leave room for toolbar */ inset: 0; - overflow: hidden; + overflow: auto; + /* Scroll fallback: user can always scroll if panzoom fails */ cursor: grab; - touch-action: none; + touch-action: pan-x pan-y pinch-zoom; + background: #f8f9fa; } .modalBody:active { cursor: grabbing; } -/* The SVG gets transform-origin 0 0 by panzoom internally */ -.modalBody svg { - position: absolute; - top: 0; - left: 0; - display: block; - transform-origin: 0 0; - /* Panzoom handles the transform */ +[data-theme='dark'] .modalBody { + background: #121220; } -/* Light background for the SVG canvas itself */ -.modalBody { - background: #f8f9fa; +/* ── SVG wrapper ─────────────────────────────────────── */ +.svgWrapper { + display: inline-block; + position: relative; } -[data-theme='dark'] .modalBody { - background: #121220; +.svgWrapper svg { + display: block; + max-width: none; } From 65344371532d152871e90fb0e1f914c1878d501c Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 21 Jun 2026 16:11:55 +0800 Subject: [PATCH 2/2] feat(docs): rewrite mermaid modal with click-to-view, zoom buttons, and drag-to-scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace panzoom-based modal with a simpler, more reliable approach: - Click inline diagram to open a fullscreen modal - + / - buttons step through zoom levels - 1:1 reset button and current zoom % display - Drag-to-scroll (pan) via scrollLeft/scrollTop (no panzoom) - Two-layer wrapper: * Outer: explicit px size = natural × scale → correct scroll range * Inner: transform:scale() at natural dimensions → fills outer - Esc to close, locked body scroll - Dark theme support throughout Removes @panzoom/panzoom dependency. Co-Authored-By: Claude --- website/package-lock.json | 7 - website/package.json | 1 - website/src/css/custom.css | 62 +++++ website/src/theme/Mermaid/index.js | 264 +++++++------------- website/src/theme/Mermaid/styles.module.css | 116 --------- 5 files changed, 147 insertions(+), 303 deletions(-) delete mode 100644 website/src/theme/Mermaid/styles.module.css diff --git a/website/package-lock.json b/website/package-lock.json index 7b711de..ac68185 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12,7 +12,6 @@ "@docusaurus/preset-classic": "3.1.1", "@docusaurus/theme-mermaid": "^3.1.1", "@mdx-js/react": "^3.0.0", - "@panzoom/panzoom": "^4.6.2", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", @@ -2959,12 +2958,6 @@ "node": ">= 8" } }, - "node_modules/@panzoom/panzoom": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@panzoom/panzoom/-/panzoom-4.6.2.tgz", - "integrity": "sha512-Zn3B5/hwa6eYIPRSKX0xf2clv8nviTX8AnAU5kU/EugiTDhG41ya2wlBqYrZJYCWQROr/5XkWObZhIkepi89qw==", - "license": "MIT" - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", diff --git a/website/package.json b/website/package.json index b28824d..2cdb0b1 100644 --- a/website/package.json +++ b/website/package.json @@ -18,7 +18,6 @@ "@docusaurus/preset-classic": "3.1.1", "@docusaurus/theme-mermaid": "^3.1.1", "@mdx-js/react": "^3.0.0", - "@panzoom/panzoom": "^4.6.2", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 7579d80..8af2840 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -636,6 +636,68 @@ color: var(--ifm-color-primary); } +/* ════════════════════════════════════════════════════════════════ + MERMAID FULLSCREEN MODAL + ════════════════════════════════════════════════════════════════ */ + +/* Inline hint */ +.gu-mermaid-inline { max-width: 100%; cursor: zoom-in; } +.gu-mermaid-inline > svg { max-width: 100%; } + +/* Backdrop */ +.gu-backdrop { + position: fixed; inset: 0; z-index: 9999; + background: rgba(0, 0, 0, 0.78); + backdrop-filter: blur(6px); + display: flex; flex-direction: column; + align-items: center; justify-content: center; +} + +/* Toolbar */ +.gu-toolbar { + position: fixed; top: 12px; right: 12px; z-index: 10001; + display: flex; align-items: center; gap: 8px; + background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(8px); + border-radius: 10px; padding: 8px 14px; user-select: none; +} +.gu-tb-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 34px; height: 34px; border: none; border-radius: 8px; + background: transparent; color: #ddd; font-size: 18px; cursor: pointer; + transition: background 0.15s; +} +.gu-tb-btn:hover { background: rgba(255, 255, 255, 0.18); } +.gu-tb-pct { + color: rgba(255, 255, 255, 0.6); font-size: 13px; min-width: 42px; text-align: center; +} + +/* Viewer — scroll to pan, wide diagrams start from left, tall from top */ +.gu-viewer { + width: 95vw; height: 90vh; + overflow: auto; + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4); + cursor: grab; + user-select: none; +} +.gu-viewer:active { cursor: grabbing; } +[data-theme='dark'] .gu-viewer { background: #1a1a2e; } + +/* Outer wrapper: explicit px size = scale × natural dimensions. + Viewer overflow:auto sees this → always correct scroll range. */ +.gu-wrap-outer { + overflow: hidden; +} +/* Inner: transform:scale fills outer visually */ +.gu-wrap-inner { + transform-origin: 0 0; +} +.gu-wrap-inner > div > svg { + display: block; + max-width: none; +} + /* ════════════════════════════════════════════════════════════════ PRINT-FRIENDLY ════════════════════════════════════════════════════════════════ */ diff --git a/website/src/theme/Mermaid/index.js b/website/src/theme/Mermaid/index.js index 7a95a55..4aef64e 100644 --- a/website/src/theme/Mermaid/index.js +++ b/website/src/theme/Mermaid/index.js @@ -5,203 +5,110 @@ import { MermaidContainerClassName, useMermaidRenderResult, } from '@docusaurus/theme-mermaid/client'; -import panzoom from '@panzoom/panzoom'; -import styles from './styles.module.css'; - -const PADDING = 60; +const LEVELS = [0.25, 0.33, 0.5, 0.67, 0.8, 1, 1.25, 1.5, 2, 2.5, 3, 4]; function MermaidRenderResult({ renderResult }) { - const ref = useRef(null); - const [modalOpen, setModalOpen] = useState(false); - const modalRef = useRef(null); - const instanceRef = useRef(null); + const inlineRef = useRef(null); + const viewerRef = useRef(null); + const [open, setOpen] = useState(false); + const [scale, setScale] = useState(1); + const [nat, setNat] = useState({ w: 800, h: 600 }); + const dragRef = useRef({ on: false, x: 0, y: 0, sx: 0, sy: 0 }); useEffect(() => { - const div = ref.current; + const div = inlineRef.current; if (div) renderResult.bindFunctions?.(div); }, [renderResult]); - const handleClick = useCallback(() => { - setModalOpen(true); - }, []); - - // ── Modal: panzoom init + auto-fit ────────────────── - useEffect(() => { - if (!modalOpen || !modalRef.current) return; - - const body = modalRef.current; - const svg = body.querySelector('svg'); - if (!svg) return; - - // Clean up previous instance - if (instanceRef.current) { - instanceRef.current.destroy(); + const onOpen = useCallback(() => { + setScale(1); + setOpen(true); + // measure the inline SVG (already rendered in page DOM) + const svg = inlineRef.current?.querySelector('svg'); + if (svg) { + setNat({ + w: svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || 800, + h: svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || 600, + }); } + }, []); + const onClose = useCallback(() => setOpen(false), []); - // 1. Create panzoom instance - const instance = panzoom(svg, { - maxScale: 12, - minScale: 0.05, - step: 0.2, - contain: false, - pinchAndPan: true, - // Let SVG keep its native size initially; we'll zoom below - cursor: 'grab', - }); - instanceRef.current = instance; - - // 2. Fit to screen – retry with exponential backoff - let fitAttempts = 0; - const maxAttempts = 8; - - const fitToScreen = () => { - const svg = body.querySelector('svg'); - if (!svg) return; - - const w = body.clientWidth; - const h = body.clientHeight; - if (!w || !h) return; - - // SVG native dimensions - let svgW = svg.viewBox?.baseVal?.width; - let svgH = svg.viewBox?.baseVal?.height; - - // Fallback to width/height attributes - if (!svgW) svgW = svg.width?.baseVal?.value; - if (!svgH) svgH = svg.height?.baseVal?.value; - - // Fallback to computed bounding rect - if (!svgW || !svgH) { - const rect = svg.getBoundingClientRect(); - svgW = rect.width; - svgH = rect.height; - } - - // If still zero and we have retries left, try again - if ((!svgW || !svgH) && fitAttempts < maxAttempts) { - fitAttempts++; - setTimeout(fitToScreen, 100 * fitAttempts); - return; - } - - if (!svgW || !svgH) return; - - const availW = w - PADDING * 2; - const availH = h - PADDING * 2; - const scale = Math.min(availW / svgW, availH / svgH, 3); - - if (scale > 0) { - instance.zoom(scale, { animate: false }); - instance.pan( - (w - svgW * scale) / 2, - (h - svgH * scale) / 2, - { animate: false } - ); - } - }; - - // Kick off fit with first attempt - const fitTimer = setTimeout(fitToScreen, 100); + const zoomIn = useCallback(() => setScale((s) => { const i = LEVELS.findIndex((l) => l > s); return i >= 0 ? LEVELS[i] : s; }), []); + const zoomOut = useCallback(() => setScale((s) => { const i = [...LEVELS].reverse().findIndex((l) => l < s); return i >= 0 ? LEVELS[LEVELS.length - 1 - i] : s; }), []); - // 3. Wheel zoom on the body - const handleWheel = (e) => { - e.preventDefault(); - const svgEl = body.querySelector('svg'); - if (!svgEl) return; - instance.zoomWithWheel(e, { step: 0.3, minScale: 0.05, maxScale: 12 }); - }; - body.addEventListener('wheel', handleWheel, { passive: false }); - - // 4. Resize listener - window.addEventListener('resize', fitToScreen); + // Drag → scroll viewer + const onMouseDown = useCallback((e) => { + const v = viewerRef.current; + dragRef.current = { on: true, x: e.clientX, y: e.clientY, sx: v?.scrollLeft || 0, sy: v?.scrollTop || 0 }; + e.preventDefault(); + }, []); - // 5. Keyboard shortcuts - const handleKeyDown = (e) => { - if (e.key === 'Escape') { setModalOpen(false); return; } - if (e.key === '+' || e.key === '=') { e.preventDefault(); instance.zoomIn(); } - if (e.key === '-') { e.preventDefault(); instance.zoomOut(); } - if (e.key === '0') { e.preventDefault(); fitToScreen(); } + useEffect(() => { + if (!open) return; + const move = (e) => { + if (!dragRef.current.on || !viewerRef.current) return; + viewerRef.current.scrollLeft = dragRef.current.sx - (e.clientX - dragRef.current.x); + viewerRef.current.scrollTop = dragRef.current.sy - (e.clientY - dragRef.current.y); }; - window.addEventListener('keydown', handleKeyDown); - + const up = () => { dragRef.current.on = false; }; + const key = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + document.addEventListener('keydown', key); + document.body.style.overflow = 'hidden'; return () => { - clearTimeout(fitTimer); - body.removeEventListener('wheel', handleWheel); - window.removeEventListener('resize', fitToScreen); - window.removeEventListener('keydown', handleKeyDown); - if (instanceRef.current) { - instanceRef.current.destroy(); - instanceRef.current = null; - } + document.removeEventListener('mousemove', move); + document.removeEventListener('mouseup', up); + document.removeEventListener('keydown', key); + document.body.style.overflow = ''; + dragRef.current.on = false; }; - }, [modalOpen]); + }, [open]); + + // Wrapper: explicit px = nat × scale → viewer overflow sees full zoomed size + // Inner div: transform: scale() renders the SVG at the zoomed size + const pw = Math.round(nat.w * scale); + const ph = Math.round(nat.h * scale); return ( <> - {/* ── Inline diagram ─────────────────────────── */} + {/* Inline */}
{ if (e.key === 'Enter') onOpen(); }} + title="Click to view fullscreen" dangerouslySetInnerHTML={{ __html: renderResult.svg }} - onClick={handleClick} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter') handleClick(); }} - title="Click to zoom" /> - {/* ── Fullscreen modal ─────────────────────────── */} - {modalOpen && ( -
setModalOpen(false)} - role="presentation" - > - {/* Toolbar */} -
- - - - - Scroll zoom · Drag pan · 0 fit + {/* Modal */} + {open && ( +
+
e.stopPropagation()}> + + + {Math.round(scale * 100)}% + +
- - {/* Body – scrollable fallback so content is never hidden */} -
e.stopPropagation()} - role="presentation" - > -
+
e.stopPropagation()} onMouseDown={onMouseDown}> + {/* Wrapper: real px size → viewer overflow tracks it */} +
+ {/* Inner: transforms to visually scale while outer handles scroll range */} +
+
+
+
)} @@ -210,15 +117,14 @@ function MermaidRenderResult({ renderResult }) { } function MermaidRenderer({ value }) { - const renderResult = useMermaidRenderResult({ text: value }); - if (renderResult === null) return null; - return ; + const result = useMermaidRenderResult({ text: value }); + if (result === null) return null; + return ; } export default function Mermaid(props) { return ( - }> + }> ); diff --git a/website/src/theme/Mermaid/styles.module.css b/website/src/theme/Mermaid/styles.module.css deleted file mode 100644 index 8627aaa..0000000 --- a/website/src/theme/Mermaid/styles.module.css +++ /dev/null @@ -1,116 +0,0 @@ -/* ── Inline diagram (clickable) ─────────────────────────── */ -.container { - max-width: 100%; - cursor: zoom-in; - transition: box-shadow 0.2s ease; -} - -.container > svg { - max-width: 100%; -} - -.container:hover { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); -} - -[data-theme='dark'] .container:hover { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); -} - -/* ── Fullscreen modal overlay ─────────────────────────── */ -.overlay { - position: fixed; - inset: 0; - z-index: 9999; - background: rgba(0, 0, 0, 0.78); - backdrop-filter: blur(6px); - display: flex; - align-items: center; - justify-content: center; - animation: overlayIn 0.2s ease; - overflow: hidden; - touch-action: none; -} - -@keyframes overlayIn { - from { opacity: 0; } - to { opacity: 1; } -} - -/* ── Toolbar ──────────────────────────────────────────── */ -.toolbar { - position: fixed; - top: 12px; - right: 12px; - display: flex; - align-items: center; - gap: 4px; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(8px); - border-radius: 10px; - padding: 6px 10px; - z-index: 10001; - user-select: none; -} - -.toolBtn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border: none; - border-radius: 8px; - background: transparent; - color: #ddd; - font-size: 18px; - cursor: pointer; - transition: background 0.15s; -} - -.toolBtn:hover { - background: rgba(255, 255, 255, 0.18); -} - -.toolBtn:active { - background: rgba(255, 255, 255, 0.3); -} - -.toolHint { - color: rgba(255, 255, 255, 0.45); - font-size: 11px; - margin-left: 8px; - padding-left: 10px; - border-left: 1px solid rgba(255, 255, 255, 0.15); - white-space: nowrap; -} - -/* ── Modal body: fullscreen, scroll fallback ────────── */ -.modalBody { - position: absolute; - inset: 0; - overflow: auto; - /* Scroll fallback: user can always scroll if panzoom fails */ - cursor: grab; - touch-action: pan-x pan-y pinch-zoom; - background: #f8f9fa; -} - -.modalBody:active { - cursor: grabbing; -} - -[data-theme='dark'] .modalBody { - background: #121220; -} - -/* ── SVG wrapper ─────────────────────────────────────── */ -.svgWrapper { - display: inline-block; - position: relative; -} - -.svgWrapper svg { - display: block; - max-width: none; -}