Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 103 additions & 126 deletions website/src/theme/Mermaid/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -133,6 +139,7 @@ function MermaidRenderResult({ renderResult }) {

return (
<>
{/* ── Inline diagram ─────────────────────────── */}
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
Expand All @@ -144,85 +151,58 @@ function MermaidRenderResult({ renderResult }) {
title="Click to zoom"
/>

{/* ── Fullscreen modal ─────────────────────────── */}
{modalOpen && (
<div
className={styles.overlay}
onClick={() => setModalOpen(false)}
role="presentation"
>
{/* Toolbar */}
<div className={styles.toolbar}>
<button
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
instanceRef.current?.zoomIn();
}}
title="Zoom in (+)"
>
</button>
<button
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
instanceRef.current?.zoomOut();
}}
title="Zoom out (-)"
>
</button>
<button
className={styles.toolBtn}
<button className={styles.toolBtn}
onClick={(e) => { e.stopPropagation(); instanceRef.current?.zoomIn(); }}
title="Zoom in (+)">+</button>
<button className={styles.toolBtn}
onClick={(e) => { e.stopPropagation(); instanceRef.current?.zoomOut(); }}
title="Zoom out (-)">−</button>
<button className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
// Re-fit to screen
const parent = modalRef.current;
const svg = parent?.querySelector('svg');
if (!svg || !instanceRef.current) 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 body = modalRef.current;
if (!body || !instanceRef.current) return;
const svg = body.querySelector('svg');
if (!svg) return;
const w = body.clientWidth, h = body.clientHeight;
let svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
let 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);
const availW = w - PADDING * 2, availH = h - PADDING * 2;
const scale = Math.min(availW / svgW, availH / svgH, 3);
if (scale > 0) {
instanceRef.current.zoom(scale, { animate: true });
const scaledW = svgW * scale;
const scaledH = svgH * scale;
instanceRef.current.pan(
(containerW - scaledW) / 2,
(containerH - scaledH) / 2,
{ animate: true }
);
instanceRef.current.pan((w - svgW * scale) / 2, (h - svgH * scale) / 2, { animate: true });
}
}}
title="Fit to screen (0)"
>
</button>
<button
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
setModalOpen(false);
}}
title="Close (Esc)"
>
</button>
<span className={styles.toolHint}>
Scroll zoom · Drag pan · 0 fit
</span>
title="Fit to screen (0)">⊡</button>
<button className={styles.toolBtn}
onClick={(e) => { e.stopPropagation(); setModalOpen(false); }}
title="Close (Esc)">✕</button>
<span className={styles.toolHint}>Scroll zoom · Drag pan · 0 fit</span>
</div>

{/* Body – scrollable fallback so content is never hidden */}
<div
ref={modalRef}
className={styles.modalBody}
onClick={(e) => e.stopPropagation()}
role="presentation"
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
/>
>
<div
className={styles.svgWrapper}
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
/>
</div>
</div>
)}
</>
Expand All @@ -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 <MermaidRenderResult renderResult={renderResult} />;
}

export default function Mermaid(props) {
return (
<ErrorBoundary
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}
>
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
Expand Down
34 changes: 15 additions & 19 deletions website/src/theme/Mermaid/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -38,7 +37,7 @@
to { opacity: 1; }
}

/* ── Toolbar at top-right ──────────────────────────────── */
/* ── Toolbar ──────────────────────────────────────────── */
.toolbar {
position: fixed;
top: 12px;
Expand Down Expand Up @@ -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;
}
Loading