feat: client-side Mermaid diagram rendering with interactive viewer

Render mermaid code blocks as SVG diagrams instead of syntax-highlighted
source code. Includes a full pan/zoom viewer with drag, scroll wheel zoom,
pinch-to-zoom, fit-to-view, and fullscreen support. Theme-aware (dark/light).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 12:29:53 +08:00
parent 6ac6ea5545
commit 08117a11c5
7 changed files with 1693 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import { PostStorylineNav } from '@/components/post-storyline-nav';
import { SectionDivider } from '@/components/section-divider';
import { FooterCue } from '@/components/footer-cue';
import { JsonLd } from '@/components/json-ld';
import { MermaidRenderer } from '@/components/mermaid-renderer';
export function generateStaticParams() {
const params = allPosts.map((post) => ({
@@ -217,6 +218,7 @@ export default async function BlogPostPage({ params }: Props) {
</div>
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
<MermaidRenderer />
</article>
</ScrollReveal>
</SectionDivider>

View File

@@ -12,6 +12,7 @@ import { SectionDivider } from '@/components/section-divider';
import { JsonLd } from '@/components/json-ld';
import { DevEnvDeviceHero } from '@/components/dev-env-device-hero';
import { HomeLabDeviceHero } from '@/components/homelab-device-hero';
import { MermaidRenderer } from '@/components/mermaid-renderer';
export function generateStaticParams() {
const params = allPages.map((page) => ({
@@ -137,6 +138,7 @@ export default async function StaticPage({ params }: Props) {
)
)}
<div dangerouslySetInnerHTML={{ __html: page.body.html }} />
<MermaidRenderer />
</article>
</ScrollReveal>
</SectionDivider>

View File

@@ -0,0 +1,312 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useTheme } from 'next-themes';
const ZOOM_STEP = 0.2;
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const WHEEL_ZOOM_FACTOR = 0.001;
interface ViewState {
scale: number;
x: number;
y: number;
}
function clampScale(s: number) {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, s));
}
function attachViewer(wrapper: HTMLDivElement, viewport: HTMLDivElement) {
const state: ViewState = { scale: 1, x: 0, y: 0 };
let dragging = false;
let dragStart = { x: 0, y: 0 };
let originAtDragStart = { x: 0, y: 0 };
// --- Pinch state ---
let lastPinchDist = 0;
let lastPinchCenter = { x: 0, y: 0 };
let pinching = false;
const levelBtn = wrapper.querySelector<HTMLButtonElement>('.mermaid-zoom-level')!;
const apply = () => {
viewport.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
levelBtn.textContent = `${Math.round(state.scale * 100)}%`;
};
const zoomTo = (newScale: number, cx: number, cy: number) => {
const clamped = clampScale(newScale);
const rect = viewport.getBoundingClientRect();
const wrapRect = wrapper.querySelector<HTMLElement>('.mermaid-canvas')!.getBoundingClientRect();
// Point under cursor in viewport-local coords
const px = cx - wrapRect.left;
const py = cy - wrapRect.top;
// Adjust translate so the point under cursor stays put
const ratio = clamped / state.scale;
state.x = px - ratio * (px - state.x);
state.y = py - ratio * (py - state.y);
state.scale = clamped;
apply();
};
// --- Mouse drag ---
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
dragging = true;
dragStart = { x: e.clientX, y: e.clientY };
originAtDragStart = { x: state.x, y: state.y };
wrapper.classList.add('mermaid-grabbing');
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging) return;
state.x = originAtDragStart.x + (e.clientX - dragStart.x);
state.y = originAtDragStart.y + (e.clientY - dragStart.y);
apply();
};
const onMouseUp = () => {
dragging = false;
wrapper.classList.remove('mermaid-grabbing');
};
// --- Wheel zoom ---
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY * WHEEL_ZOOM_FACTOR;
const newScale = clampScale(state.scale * (1 + delta * state.scale));
zoomTo(newScale, e.clientX, e.clientY);
};
// --- Touch: pinch-to-zoom + drag ---
const pinchDist = (t: TouchList) => {
const dx = t[0].clientX - t[1].clientX;
const dy = t[0].clientY - t[1].clientY;
return Math.hypot(dx, dy);
};
const pinchCenter = (t: TouchList) => ({
x: (t[0].clientX + t[1].clientX) / 2,
y: (t[0].clientY + t[1].clientY) / 2,
});
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
pinching = true;
lastPinchDist = pinchDist(e.touches);
lastPinchCenter = pinchCenter(e.touches);
e.preventDefault();
} else if (e.touches.length === 1) {
dragging = true;
dragStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
originAtDragStart = { x: state.x, y: state.y };
}
};
const onTouchMove = (e: TouchEvent) => {
if (pinching && e.touches.length === 2) {
e.preventDefault();
const dist = pinchDist(e.touches);
const center = pinchCenter(e.touches);
const ratio = dist / lastPinchDist;
zoomTo(state.scale * ratio, center.x, center.y);
lastPinchDist = dist;
lastPinchCenter = center;
} else if (dragging && e.touches.length === 1) {
state.x = originAtDragStart.x + (e.touches[0].clientX - dragStart.x);
state.y = originAtDragStart.y + (e.touches[0].clientY - dragStart.y);
apply();
}
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2) pinching = false;
if (e.touches.length === 0) dragging = false;
};
// --- Canvas element (the pannable area) ---
const canvas = wrapper.querySelector<HTMLElement>('.mermaid-canvas')!;
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
canvas.addEventListener('touchend', onTouchEnd);
// --- Button handlers ---
wrapper.querySelector('.mermaid-btn-zoomout')!.addEventListener('click', () => {
const rect = canvas.getBoundingClientRect();
zoomTo(state.scale - ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
wrapper.querySelector('.mermaid-btn-zoomin')!.addEventListener('click', () => {
const rect = canvas.getBoundingClientRect();
zoomTo(state.scale + ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
levelBtn.addEventListener('click', () => {
state.scale = 1;
state.x = 0;
state.y = 0;
apply();
});
wrapper.querySelector('.mermaid-btn-fit')!.addEventListener('click', () => {
const svg = viewport.querySelector('svg');
if (!svg) return;
const canvasRect = canvas.getBoundingClientRect();
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width / state.scale;
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height / state.scale;
const padding = 32;
const fitScale = Math.min(
(canvasRect.width - padding) / svgW,
(canvasRect.height - padding) / svgH,
ZOOM_MAX
);
state.scale = clampScale(fitScale);
state.x = 0;
state.y = 0;
apply();
});
wrapper.querySelector('.mermaid-btn-fullscreen')!.addEventListener('click', () => {
if (document.fullscreenElement === wrapper) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen().catch(() => {});
}
});
// Cleanup
return () => {
canvas.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('wheel', onWheel);
canvas.removeEventListener('touchstart', onTouchStart);
canvas.removeEventListener('touchmove', onTouchMove);
canvas.removeEventListener('touchend', onTouchEnd);
};
}
function buildShell(): { wrapper: HTMLDivElement; viewport: HTMLDivElement } {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
const canvas = document.createElement('div');
canvas.className = 'mermaid-canvas';
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewport';
canvas.appendChild(viewport);
// Toolbar
const bar = document.createElement('div');
bar.className = 'mermaid-zoom-bar';
const btnZoomOut = document.createElement('button');
btnZoomOut.className = 'mermaid-zoom-btn mermaid-btn-zoomout';
btnZoomOut.textContent = '';
btnZoomOut.ariaLabel = '縮小';
const btnLevel = document.createElement('button');
btnLevel.className = 'mermaid-zoom-btn mermaid-zoom-level';
btnLevel.textContent = '100%';
btnLevel.ariaLabel = '重置';
const btnZoomIn = document.createElement('button');
btnZoomIn.className = 'mermaid-zoom-btn mermaid-btn-zoomin';
btnZoomIn.textContent = '+';
btnZoomIn.ariaLabel = '放大';
const sep1 = document.createElement('span');
sep1.className = 'mermaid-sep';
const btnFit = document.createElement('button');
btnFit.className = 'mermaid-zoom-btn mermaid-btn-fit';
btnFit.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="12" height="12" rx="2"/><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg>';
btnFit.ariaLabel = '適合畫面';
const btnFullscreen = document.createElement('button');
btnFullscreen.className = 'mermaid-zoom-btn mermaid-btn-fullscreen';
btnFullscreen.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg>';
btnFullscreen.ariaLabel = '全螢幕';
bar.append(btnZoomOut, btnLevel, btnZoomIn, sep1, btnFit, btnFullscreen);
wrapper.append(canvas, bar);
return { wrapper, viewport };
}
export function MermaidRenderer() {
const { resolvedTheme } = useTheme();
const containersRef = useRef<{ viewport: HTMLDivElement; wrapper: HTMLDivElement; source: string }[]>([]);
const cleanupRef = useRef<(() => void)[]>([]);
const renderDiagrams = useCallback(async () => {
if (containersRef.current.length === 0) return;
// Clean up previous event listeners
cleanupRef.current.forEach((fn) => fn());
cleanupRef.current = [];
const mermaid = (await import('mermaid')).default;
const theme = resolvedTheme === 'dark' ? 'dark' : 'default';
mermaid.initialize({
startOnLoad: false,
theme,
fontFamily: 'inherit',
});
for (const { viewport, wrapper, source } of containersRef.current) {
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
try {
const { svg } = await mermaid.render(id, source);
viewport.innerHTML = svg;
wrapper.classList.add('mermaid-rendered');
const cleanup = attachViewer(wrapper, viewport);
cleanupRef.current.push(cleanup);
} catch {
viewport.textContent = source;
}
}
}, [resolvedTheme]);
useEffect(() => {
const figures = document.querySelectorAll<HTMLElement>(
'figure[data-rehype-pretty-code-figure]'
);
const entries: typeof containersRef.current = [];
figures.forEach((figure) => {
const code = figure.querySelector('code[data-language="mermaid"]');
if (!code) return;
const source = code.textContent?.trim() ?? '';
if (!source) return;
const { wrapper, viewport } = buildShell();
figure.replaceWith(wrapper);
entries.push({ viewport, wrapper, source });
});
containersRef.current = entries;
renderDiagrams();
return () => {
cleanupRef.current.forEach((fn) => fn());
cleanupRef.current = [];
};
}, [renderDiagrams]);
return null;
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

1229
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,12 @@
"author": "",
"license": "ISC",
"type": "module",
"browserslist": ["chrome 111", "edge 111", "firefox 111", "safari 16.4"],
"browserslist": [
"chrome 111",
"edge 111",
"firefox 111",
"safari 16.4"
],
"dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@radix-ui/react-dialog": "^1.1.15",
@@ -25,6 +30,7 @@
"contentlayer2": "^0.5.8",
"gray-matter": "^4.0.3",
"markdown-wasm": "^1.2.0",
"mermaid": "^11.12.3",
"next": "^16.0.7",
"next-contentlayer2": "^0.5.8",
"next-themes": "^0.4.6",

View File

@@ -1936,3 +1936,143 @@ body {
--callout-title-color: #fca5a5;
@apply border-red-400;
}
/* Mermaid diagram viewer */
.mermaid-diagram {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 1.5rem 0;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: #fafbfc;
opacity: 0;
transition: opacity 0.3s ease-in;
}
.dark .mermaid-diagram {
border-color: #334155;
background: #0f172a;
}
.mermaid-diagram.mermaid-rendered {
opacity: 1;
}
/* Fullscreen overrides */
.mermaid-diagram:fullscreen {
border-radius: 0;
border: none;
}
.mermaid-diagram:fullscreen .mermaid-canvas {
height: calc(100vh - 40px);
}
/* Canvas — the pannable/zoomable area */
.mermaid-canvas {
position: relative;
z-index: 0;
overflow: hidden;
width: 100%;
height: 360px;
cursor: grab;
touch-action: none;
}
@media (min-width: 768px) {
.mermaid-canvas {
height: 420px;
}
}
.mermaid-grabbing .mermaid-canvas {
cursor: grabbing;
}
.mermaid-viewport {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 100%;
min-height: 100%;
transform-origin: 0 0;
pointer-events: none;
}
/* Toolbar */
.mermaid-zoom-bar {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-top: 1px solid #e2e8f0;
background: #f1f5f9;
justify-content: center;
flex-shrink: 0;
}
.dark .mermaid-zoom-bar {
border-top-color: #334155;
background: #1e293b;
}
.mermaid-zoom-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: #475569;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.mermaid-zoom-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.mermaid-zoom-btn:active {
background: #cbd5e1;
}
.dark .mermaid-zoom-btn {
color: #94a3b8;
}
.dark .mermaid-zoom-btn:hover {
background: #334155;
color: #e2e8f0;
}
.dark .mermaid-zoom-btn:active {
background: #475569;
}
.mermaid-zoom-level {
font-size: 12px;
font-variant-numeric: tabular-nums;
min-width: 48px;
}
.mermaid-sep {
width: 1px;
height: 16px;
margin: 0 4px;
background: #cbd5e1;
}
.dark .mermaid-sep {
background: #475569;
}