Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
182 changes: 182 additions & 0 deletions website/src/theme/Mermaid/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleClick(); }}
title="Click to zoom"
/>

{modalOpen && (
<div
className={styles.overlay}
onClick={() => setModalOpen(false)}
role="presentation"
>
<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}
onClick={(e) => {
e.stopPropagation();
instanceRef.current?.reset({ animate: true });
}}
title="Reset"
>
</button>
<button
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
setModalOpen(false);
}}
title="Close (Esc)"
>
</button>
<span className={styles.toolHint}>
Scroll to zoom · Drag to pan
</span>
</div>
<div
className={styles.modalBody}
onClick={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={modalRef}
className={styles.zoomContainer}
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
/>
</div>
</div>
)}
</>
);
}

function MermaidRenderer({ value }) {
const renderResult = useMermaidRenderResult({ text: value });
if (renderResult === null) {
return null;
}
return <MermaidRenderResult renderResult={renderResult} />;
}

export default function Mermaid(props) {
return (
<ErrorBoundary
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}
>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}
116 changes: 116 additions & 0 deletions website/src/theme/Mermaid/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading