diff --git a/website/package-lock.json b/website/package-lock.json index ac68185..7b711de 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12,6 +12,7 @@ "@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", @@ -2958,6 +2959,12 @@ "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 2cdb0b1..b28824d 100644 --- a/website/package.json +++ b/website/package.json @@ -18,6 +18,7 @@ "@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/theme/Mermaid/index.js b/website/src/theme/Mermaid/index.js new file mode 100644 index 0000000..6402672 --- /dev/null +++ b/website/src/theme/Mermaid/index.js @@ -0,0 +1,182 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import ErrorBoundary from '@docusaurus/ErrorBoundary'; +import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common'; +import { + MermaidContainerClassName, + useMermaidRenderResult, +} from '@docusaurus/theme-mermaid/client'; +import panzoom from '@panzoom/panzoom'; + +import styles from './styles.module.css'; + +function MermaidRenderResult({ renderResult }) { + const ref = useRef(null); + const [modalOpen, setModalOpen] = useState(false); + const modalRef = useRef(null); + const zoomRef = useRef(null); + const instanceRef = useRef(null); + + useEffect(() => { + const div = ref.current; + renderResult.bindFunctions?.(div); + }, [renderResult]); + + // Handle click on the mermaid diagram to open modal + const handleClick = useCallback(() => { + setModalOpen(true); + }, []); + + // Initialize panzoom when modal opens + useEffect(() => { + if (!modalOpen || !modalRef.current) return; + + const modalEl = modalRef.current; + const svgEl = modalEl.querySelector('svg'); + if (!svgEl) return; + + // Clean up any previous instance + if (instanceRef.current) { + instanceRef.current.destroy(); + } + + const instance = panzoom(svgEl, { + maxScale: 10, + minScale: 0.3, + step: 0.3, + contain: 'outside', + pinchAndPan: true, + }); + instanceRef.current = instance; + + // Reset zoom on open + // Use a small delay to let the modal render finish + const resetTimer = setTimeout(() => { + instance.reset({ animate: false }); + }, 50); + + // Keyboard handlers + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + setModalOpen(false); + } + // Zoom with +/- + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + instance.zoomIn(); + } + if (e.key === '-') { + e.preventDefault(); + instance.zoomOut(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + clearTimeout(resetTimer); + window.removeEventListener('keydown', handleKeyDown); + if (instanceRef.current) { + instanceRef.current.destroy(); + instanceRef.current = null; + } + }; + }, [modalOpen]); + + return ( + <> +
{ if (e.key === 'Enter') handleClick(); }} + title="Click to zoom" + /> + + {modalOpen && ( +
setModalOpen(false)} + role="presentation" + > +
+ + + + + + Scroll to zoom · Drag to pan + +
+
e.stopPropagation()} + role="presentation" + > +
+
+
+ )} + + ); +} + +function MermaidRenderer({ value }) { + const renderResult = useMermaidRenderResult({ text: value }); + if (renderResult === 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 new file mode 100644 index 0000000..94f2b28 --- /dev/null +++ b/website/src/theme/Mermaid/styles.module.css @@ -0,0 +1,116 @@ +/* ── 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.75); + backdrop-filter: blur(4px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + animation: overlayIn 0.2s ease; +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ── Toolbar at top of overlay ─────────────────────────── */ +.toolbar { + position: absolute; + top: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 6px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + border-radius: 8px; + padding: 6px 10px; + z-index: 10000; + user-select: none; +} + +.toolBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: #eee; + font-size: 16px; + cursor: pointer; + transition: background 0.15s; +} + +.toolBtn:hover { + background: rgba(255, 255, 255, 0.15); +} + +.toolBtn:active { + background: rgba(255, 255, 255, 0.25); +} + +.toolHint { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + margin-left: 6px; + padding-left: 8px; + border-left: 1px solid rgba(255, 255, 255, 0.15); +} + +/* ── Scrollable body area (for large diagrams) ─────────── */ +.modalBody { + width: 90vw; + height: 85vh; + overflow: auto; + border-radius: 12px; + background: #fff; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4); + cursor: grab; +} + +.modalBody:active { + cursor: grabbing; +} + +[data-theme='dark'] .modalBody { + background: #1a1a2e; +} + +/* ── Panzoom container inside modal body ───────────────── */ +.zoomContainer { + display: inline-block; + padding: 32px; + transform-origin: 0 0; + touch-action: none; +} + +.zoomContainer svg { + display: block; + max-width: none; +}