feat(docs): add click-to-zoom lightbox for Mermaid diagrams

Swizzled @docusaurus/theme-mermaid's Mermaid component to wrap
every diagram with a portal-based lightbox overlay.

An expand icon appears on diagram hover. Clicking opens the SVG
in a full-screen overlay (90vw × 85vh, scrollable). Closes via
backdrop click, Escape key, or close button. SSR-safe, no
external dependencies. Matches Tibber electric color theme.

Impact: Users can inspect complex flowcharts (e.g. the Options
Flow wizard) without squinting at small embedded diagrams.
This commit is contained in:
Julian Pawlowski 2026-04-07 14:09:19 +00:00
parent 1c25ac1fb0
commit 552db6ef7d
2 changed files with 228 additions and 0 deletions

View file

@ -226,3 +226,113 @@ h1, h2, h3, h4, h5, h6 {
[data-theme='dark'] .bmc-logo-dark { [data-theme='dark'] .bmc-logo-dark {
display: block; display: block;
} }
/* ── Mermaid diagram zoom / lightbox ──────────────────────────── */
/* Wrapper that holds the diagram + the expand button */
.mermaid-zoom-wrapper {
position: relative;
display: block;
}
/* Expand button — hidden until the diagram is hovered */
.mermaid-zoom-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.4rem;
color: var(--ifm-color-emphasis-600);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease,
border-color 0.15s ease;
z-index: 1;
}
.mermaid-zoom-wrapper:hover .mermaid-zoom-btn,
.mermaid-zoom-btn:focus-visible {
opacity: 1;
}
.mermaid-zoom-btn:hover {
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
color: #fff;
}
/* Full-screen backdrop */
.mermaid-lightbox {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
cursor: zoom-out;
animation: mermaid-fadein 0.15s ease;
}
@keyframes mermaid-fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* The white/dark card containing the diagram */
.mermaid-lightbox-inner {
position: relative;
max-width: min(90vw, 1200px);
max-height: 85vh;
overflow: auto;
padding: 1.5rem;
background: var(--ifm-background-color);
border-radius: var(--ifm-card-border-radius);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
cursor: default;
animation: mermaid-scalein 0.15s ease;
}
@keyframes mermaid-scalein {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.mermaid-lightbox-inner svg {
display: block;
width: 100%;
height: auto;
}
/* Close button (top-right corner of the card) */
.mermaid-lightbox-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.4rem;
color: var(--ifm-color-emphasis-700);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.mermaid-lightbox-close:hover {
background: var(--ifm-color-danger);
border-color: var(--ifm-color-danger);
color: #fff;
}

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import OriginalMermaid from '@theme-original/Mermaid';
import type MermaidType from '@theme/Mermaid';
import type { WrapperProps } from '@docusaurus/types';
type Props = WrapperProps<typeof MermaidType>;
export default function MermaidWrapper(props: Props): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const [svgMarkup, setSvgMarkup] = useState('');
const [mounted, setMounted] = useState(false);
// Only run portals client-side (SSR safety)
useEffect(() => {
setMounted(true);
}, []);
const handleOpen = useCallback(() => {
const svg = containerRef.current?.querySelector('svg');
if (!svg) return;
// Clone the SVG and strip fixed dimensions so it scales freely
const clone = svg.cloneNode(true) as SVGElement;
clone.removeAttribute('width');
clone.removeAttribute('height');
clone.style.cssText = 'width:100%;height:auto;display:block;';
setSvgMarkup(clone.outerHTML);
setOverlayOpen(true);
}, []);
const handleClose = useCallback(() => {
setOverlayOpen(false);
}, []);
// Keyboard + body-scroll lock while overlay is open
useEffect(() => {
if (!overlayOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [overlayOpen, handleClose]);
return (
<div ref={containerRef} className="mermaid-zoom-wrapper">
<OriginalMermaid {...props} />
<button
className="mermaid-zoom-btn"
onClick={handleOpen}
aria-label="View diagram enlarged"
title="View enlarged"
>
{/* Expand / fullscreen icon */}
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
{mounted &&
overlayOpen &&
createPortal(
<div
className="mermaid-lightbox"
role="dialog"
aria-modal="true"
aria-label="Enlarged diagram"
onClick={handleClose}
>
<div
className="mermaid-lightbox-inner"
onClick={(e) => e.stopPropagation()}
// Safe: SVG content is cloned from our own rendered DOM node
dangerouslySetInnerHTML={{ __html: svgMarkup }}
/>
<button
className="mermaid-lightbox-close"
onClick={handleClose}
aria-label="Close enlarged view"
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>,
document.body,
)}
</div>
);
}