Files
blog-nextjs/components/mermaid-renderer.tsx
gbanyan 08117a11c5 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>
2026-02-20 12:29:53 +08:00

313 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
}