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 a7532b5..4aef64e 100644 --- a/website/src/theme/Mermaid/index.js +++ b/website/src/theme/Mermaid/index.js @@ -5,224 +5,111 @@ import { MermaidContainerClassName, useMermaidRenderResult, } from '@docusaurus/theme-mermaid/client'; -import panzoom from '@panzoom/panzoom'; -import styles from './styles.module.css'; - -const PADDING = 80; // pixels of padding inside modal +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; - renderResult.bindFunctions?.(div); + const div = inlineRef.current; + if (div) renderResult.bindFunctions?.(div); }, [renderResult]); - const handleClick = useCallback(() => { - setModalOpen(true); - }, []); - - // Initialize panzoom and auto-fit SVG when modal opens - useEffect(() => { - if (!modalOpen || !modalRef.current) return; - - const modalEl = modalRef.current; - const svgEl = modalEl.querySelector('svg'); - if (!svgEl) return; - - // Clean up previous - if (instanceRef.current) { - instanceRef.current.destroy(); - } - - const instance = panzoom(svgEl, { - maxScale: 10, - minScale: 0.05, - step: 0.15, - contain: false, - pinchAndPan: true, - }); - 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, + 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, }); - }; - modalEl.addEventListener('wheel', handleWheel, { passive: false }); - - // 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'); - 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; - - if (!svgW || !svgH) return; - - const availableW = containerW - PADDING; - const availableH = containerH - PADDING; - const scale = Math.min(availableW / svgW, availableH / svgH, 3); // cap at 3x - - 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 }); - } - }; + } + }, []); + const onClose = useCallback(() => setOpen(false), []); - // Wait for layout, then fit - const fitTimer = setTimeout(fitToScreen, 80); + 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; }), []); - // Re-fit on resize - 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(); + }, []); - 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 - } + 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); - modalEl.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 */}