feat(chat): add Mermaid diagram zoom controls (#2100)

* feat(chat): add mermaid diagram zoom controls

* fix(chat): handle malformed mermaid data urls

* fix(chat): preserve mermaid load error stack

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Carson
2026-07-08 20:43:23 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent dfed121bf1
commit 3d32ac7989
18 changed files with 1069 additions and 115 deletions
@@ -1,6 +1,8 @@
import { copyTextToClipboard } from '@/lib/clipboard';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl } from '@/lib/url';
import { dropdownMenuItemClass, dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
import type { IconName } from '@/components/icon/icons';
import { getMermaidViewerController } from './mermaidViewer';
// ---------------------------------------------------------------------------
// Shared decoration context
@@ -17,12 +19,22 @@ export type DecorateLabels = {
downloadTable: string;
copyDiagram: string;
downloadDiagram: string;
zoomInDiagram: string;
zoomOutDiagram: string;
resetDiagramView: string;
previewLabel: string;
previewTitle: string;
};
export type MermaidControlOptions = {
download: boolean;
copy: boolean;
showPanZoomControls: boolean;
};
export type DecorateContext = {
labels: DecorateLabels;
mermaidControls: MermaidControlOptions;
codeBlockLineWrap: boolean;
deferCodeLineNumberSync?: boolean;
onToggleCodeBlockLineWrap?: () => void;
@@ -34,20 +46,23 @@ export type DecorateContext = {
// Reference the app's icon sprite (injected into <body> by the shared Icon
// component) so DOM-built controls use the same themed icons as the rest of
// the app. Sprite symbols are registered under `#oc-<name>`.
const spriteIcon = (name: string): string =>
const spriteIcon = (name: IconName): string =>
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
const ICONS = {
copy: spriteIcon('file-copy'),
check: spriteIcon('check'),
download: spriteIcon('download'),
zoomIn: spriteIcon('add'),
zoomOut: spriteIcon('subtract'),
fit: spriteIcon('refresh'),
textWrap: spriteIcon('text-wrap'),
} as const;
const ICON_BTN_CLASS =
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors';
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
const setHtml = (el: Element, html: string): void => {
const setIconHtml = (el: Element, html: string): void => {
el.innerHTML = html;
};
@@ -58,7 +73,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
button.setAttribute('data-md-action', slot);
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
setHtml(button, ICONS[icon]);
setIconHtml(button, ICONS[icon]);
return button;
};
@@ -207,11 +222,13 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
};
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
setHtml(button, ICONS.check);
setIconHtml(button, ICONS.check);
button.setAttribute('title', copiedTitle);
button.setAttribute('aria-label', copiedTitle);
window.setTimeout(() => {
setHtml(button, ICONS[restore]);
setIconHtml(button, ICONS[restore]);
button.setAttribute('title', restoreTitle);
button.setAttribute('aria-label', restoreTitle);
}, 2000);
};
@@ -410,33 +427,52 @@ const decorateMermaid = (root: HTMLElement, ctx: DecorateContext): void => {
const block = document.createElement('div');
block.setAttribute('data-markdown', 'mermaid-block');
block.setAttribute('data-md-source', source);
block.className = 'group relative';
const scroll = document.createElement('div');
scroll.setAttribute('data-markdown', 'mermaid-scroll');
const toolbar = document.createElement('div');
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity';
toolbar.setAttribute('data-markdown', 'mermaid-toolbar');
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity';
if (rendered.svg) {
block.setAttribute('data-mermaid-render', 'svg');
const viewport = document.createElement('div');
viewport.setAttribute('data-markdown', 'mermaid-viewport');
const svgHost = document.createElement('div');
svgHost.setAttribute('data-markdown', 'mermaid');
setHtml(svgHost, rendered.svg);
scroll.appendChild(svgHost);
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', source);
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
download.setAttribute('data-md-svg', '1');
toolbar.appendChild(copy);
toolbar.appendChild(download);
svgHost.setAttribute('data-md-original-svg', rendered.svg);
svgHost.innerHTML = rendered.svg;
viewport.appendChild(svgHost);
scroll.appendChild(viewport);
if (ctx.mermaidControls.showPanZoomControls) {
toolbar.appendChild(makeIconButton('zoomIn', ctx.labels.zoomInDiagram, 'mermaid-zoom-in'));
toolbar.appendChild(makeIconButton('zoomOut', ctx.labels.zoomOutDiagram, 'mermaid-zoom-out'));
toolbar.appendChild(makeIconButton('fit', ctx.labels.resetDiagramView, 'mermaid-fit'));
}
if (ctx.mermaidControls.copy) {
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', source);
toolbar.appendChild(copy);
}
if (ctx.mermaidControls.download) {
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
download.setAttribute('data-md-svg', '1');
toolbar.appendChild(download);
}
} else {
block.setAttribute('data-mermaid-render', 'ascii');
const asciiPre = document.createElement('pre');
asciiPre.setAttribute('data-markdown', 'mermaid-ascii');
asciiPre.textContent = rendered.ascii || source;
scroll.appendChild(asciiPre);
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', rendered.ascii || source);
toolbar.appendChild(copy);
if (ctx.mermaidControls.copy) {
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', rendered.ascii || source);
toolbar.appendChild(copy);
}
}
block.appendChild(scroll);
@@ -486,7 +522,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
preview.setAttribute('data-md-url', href);
preview.setAttribute('title', ctx.labels.previewTitle);
preview.setAttribute('aria-label', ctx.labels.previewLabel);
setHtml(preview, ICONS.download);
setIconHtml(preview, ICONS.download);
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
}
}
@@ -600,10 +636,25 @@ export const attachMarkdownInteractions = (
return;
}
// Mermaid local pan/zoom controls
if (action === 'mermaid-zoom-in' || action === 'mermaid-zoom-out' || action === 'mermaid-fit') {
event.preventDefault();
const block = actionEl.closest('[data-markdown="mermaid-block"]');
const controller = getMermaidViewerController(block);
if (action === 'mermaid-zoom-in') {
controller?.zoomIn();
} else if (action === 'mermaid-zoom-out') {
controller?.zoomOut();
} else {
controller?.fit();
}
return;
}
// Mermaid download svg
if (action === 'mermaid-download') {
const svgHost = actionEl.closest('[data-markdown="mermaid-block"]')?.querySelector('[data-markdown="mermaid"]');
const svg = svgHost?.innerHTML ?? '';
const svg = svgHost?.getAttribute('data-md-original-svg') ?? svgHost?.innerHTML ?? '';
if (svg) downloadBlob('diagram.svg', svg, 'image/svg+xml;charset=utf-8');
return;
}
@@ -0,0 +1,198 @@
import { describe, expect, test } from 'bun:test';
import {
fitMermaidViewBox,
formatMermaidViewBox,
getMermaidSvgContentBox,
getMermaidViewerSignature,
hasMermaidPointerDragMoved,
MERMAID_BLOCK_SELECTOR,
panMermaidViewBox,
shouldRefreshMermaidViewers,
zoomMermaidViewBoxAtPoint,
} from './mermaidViewer';
describe('mermaidViewer', () => {
test('extracts content bounds from the root SVG viewBox', () => {
expect(getMermaidSvgContentBox({ viewBox: '10 20 400 200', width: '999', height: '999' })).toEqual({
x: 10,
y: 20,
width: 400,
height: 200,
});
});
test('falls back to numeric SVG width and height when viewBox is missing', () => {
expect(getMermaidSvgContentBox({ width: '640', height: '320' })).toEqual({
x: 0,
y: 0,
width: 640,
height: 320,
});
});
test('accepts bare and px SVG width and height values without parsing unresolved units', () => {
expect(getMermaidSvgContentBox({ width: '1e3', height: '2.5e2px' })).toEqual({
x: 0,
y: 0,
width: 1000,
height: 250,
});
expect(getMermaidSvgContentBox({ width: '100%', height: '200' })).toBeNull();
expect(getMermaidSvgContentBox({ width: '100em', height: '200' })).toBeNull();
});
test('fits content into a viewport while preserving aspect ratio', () => {
expect(fitMermaidViewBox({ x: 0, y: 0, width: 400, height: 200 }, { width: 300, height: 300 })).toEqual({
x: 0,
y: -100,
width: 400,
height: 400,
});
});
test('zooms around a pointer so the SVG point under the pointer stays stable', () => {
const current = { x: 0, y: 0, width: 400, height: 400 };
const next = zoomMermaidViewBoxAtPoint({
currentBox: current,
contentBox: { x: 0, y: 0, width: 400, height: 200 },
viewport: { width: 300, height: 300 },
pointer: { x: 75, y: 150 },
zoomFactor: 2,
minScale: 0.5,
maxScale: 4,
});
const before = {
x: current.x + (75 / 300) * current.width,
y: current.y + (150 / 300) * current.height,
};
const after = {
x: next.x + (75 / 300) * next.width,
y: next.y + (150 / 300) * next.height,
};
expect(Math.abs(after.x - before.x) < 1e-6).toBe(true);
expect(Math.abs(after.y - before.y) < 1e-6).toBe(true);
expect(next).toEqual({ x: 50, y: 100, width: 200, height: 200 });
});
test('clamps zoom to the configured viewBox scale bounds', () => {
const next = zoomMermaidViewBoxAtPoint({
currentBox: { x: 0, y: 0, width: 400, height: 400 },
contentBox: { x: 0, y: 0, width: 400, height: 200 },
viewport: { width: 300, height: 300 },
pointer: { x: 150, y: 150 },
zoomFactor: 100,
minScale: 0.5,
maxScale: 4,
});
expect(next.width).toBe(100);
expect(next.height).toBe(100);
expect(next.x).toBe(150);
expect(next.y).toBe(150);
});
test('clamps zoom scale relative to the fitted viewport box', () => {
const next = zoomMermaidViewBoxAtPoint({
currentBox: { x: -100, y: 0, width: 400, height: 400 },
contentBox: { x: 0, y: 0, width: 200, height: 400 },
viewport: { width: 300, height: 300 },
pointer: { x: 150, y: 150 },
zoomFactor: 100,
minScale: 0.5,
maxScale: 4,
});
expect(next).toEqual({ x: 50, y: 150, width: 100, height: 100 });
});
test('returns the current viewBox for invalid zoom scale bounds', () => {
const current = { x: 0, y: 0, width: 400, height: 400 };
expect(zoomMermaidViewBoxAtPoint({
currentBox: current,
contentBox: { x: 0, y: 0, width: 400, height: 200 },
viewport: { width: 300, height: 300 },
pointer: { x: 150, y: 150 },
zoomFactor: 2,
minScale: 0,
maxScale: 4,
})).toBe(current);
expect(zoomMermaidViewBoxAtPoint({
currentBox: current,
contentBox: { x: 0, y: 0, width: 400, height: 200 },
viewport: { width: 300, height: 300 },
pointer: { x: 150, y: 150 },
zoomFactor: 2,
minScale: 0.5,
maxScale: Number.POSITIVE_INFINITY,
})).toBe(current);
});
test('formats viewBox numbers without noisy floating point tails', () => {
expect(formatMermaidViewBox({
x: 1 / 3,
y: -2.5,
width: 100.0000001,
height: 40,
})).toBe('0.333333 -2.5 100 40');
});
test('pans viewBox by viewport pixel deltas in SVG coordinates', () => {
expect(panMermaidViewBox({
currentBox: { x: 10, y: 20, width: 400, height: 200 },
viewport: { width: 200, height: 100 },
delta: { x: 25, y: -10 },
})).toEqual({
x: -40,
y: 40,
width: 400,
height: 200,
});
});
test('distinguishes real pointer drag from click jitter', () => {
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 12, y: 11 })).toBe(false);
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(true);
});
test('viewer signature changes when SVG identity changes within an existing block', () => {
const first = getMermaidViewerSignature({
renderMode: 'svg',
svgMarkup: '<svg viewBox="0 0 100 50"></svg>',
viewBox: '0 0 100 50',
width: null,
height: null,
});
const second = getMermaidViewerSignature({
renderMode: 'svg',
svgMarkup: '<svg viewBox="0 0 240 120"></svg>',
viewBox: '0 0 240 120',
width: null,
height: null,
});
expect(second).not.toBe(first);
});
test('viewer signature distinguishes render mode flips and missing SVGs', () => {
expect(getMermaidViewerSignature({ renderMode: 'ascii' })).toBe('ascii:no-svg');
expect(getMermaidViewerSignature({ renderMode: 'svg' })).toBe('svg:no-svg');
});
test('only requests renderer refresh work for existing mermaid DOM blocks', () => {
const withoutMermaidBlock = { querySelector: () => null };
const withMermaidBlock = { querySelector: () => ({}) as Element };
expect(shouldRefreshMermaidViewers(withoutMermaidBlock)).toBe(false);
expect(shouldRefreshMermaidViewers(withMermaidBlock)).toBe(true);
});
test('exports the shared Mermaid block selector', () => {
expect(MERMAID_BLOCK_SELECTOR).toBe('[data-markdown="mermaid-block"]');
});
});
@@ -0,0 +1,467 @@
type MermaidViewBox = {
x: number;
y: number;
width: number;
height: number;
};
type MermaidViewport = {
width: number;
height: number;
};
type MermaidPoint = {
x: number;
y: number;
};
type MermaidViewerController = {
zoomIn: () => void;
zoomOut: () => void;
fit: () => void;
cleanup: () => void;
};
type MermaidSvgBoundsSource = {
viewBox?: string | null;
width?: string | number | null;
height?: string | number | null;
};
type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
renderMode?: string | null;
svgMarkup?: string | null;
};
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
if (typeof value === 'number') {
return isPositiveFinite(value) ? value : null;
}
if (typeof value !== 'string') {
return null;
}
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
if (!match) {
return null;
}
const parsed = Number(match[1]);
return isPositiveFinite(parsed) ? parsed : null;
};
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
const VIEW_BOX_PRECISION = 6;
const ZOOM_STEP = 1.25;
const WHEEL_ZOOM_BASE = 1.0015;
const MIN_SCALE = 0.5;
const MAX_SCALE = 12;
const DRAG_CLICK_SUPPRESSION_THRESHOLD_PX = 3;
const DRAG_CLICK_SUPPRESSION_CLEAR_MS = 400;
export const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
export const shouldRefreshMermaidViewers = (container: Pick<HTMLElement, 'querySelector'>): boolean => (
container.querySelector(MERMAID_BLOCK_SELECTOR) !== null
);
export const formatMermaidViewBox = (box: MermaidViewBox): string => (
[box.x, box.y, box.width, box.height]
.map((value) => {
const rounded = Number(value.toFixed(VIEW_BOX_PRECISION));
return Object.is(rounded, -0) ? '0' : String(rounded);
})
.join(' ')
);
export const getMermaidSvgContentBox = (source: MermaidSvgBoundsSource): MermaidViewBox | null => {
const viewBoxParts = source.viewBox
?.trim()
.split(/[\s,]+/)
.map((part) => Number.parseFloat(part));
if (viewBoxParts?.length === 4) {
const [x, y, width, height] = viewBoxParts;
if (
Number.isFinite(x)
&& Number.isFinite(y)
&& isPositiveFinite(width)
&& isPositiveFinite(height)
) {
return { x, y, width, height };
}
}
const width = parseSvgNumber(source.width);
const height = parseSvgNumber(source.height);
if (width === null || height === null) {
return null;
}
return { x: 0, y: 0, width, height };
};
const hashMermaidSignaturePart = (value: string): string => {
let hash = 2166136261;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
};
export const getMermaidViewerSignature = (source: MermaidViewerSignatureSource): string => {
const renderMode = source.renderMode || 'unknown';
const svgMarkup = source.svgMarkup ?? '';
if (!svgMarkup) {
const bounds = [source.viewBox ?? '', source.width ?? '', source.height ?? ''];
return bounds.some((part) => part !== '') ? `${renderMode}:bounds:${bounds.join(':')}` : `${renderMode}:no-svg`;
}
return [
renderMode,
hashMermaidSignaturePart(svgMarkup),
].join(':');
};
export const fitMermaidViewBox = (contentBox: MermaidViewBox, viewport: MermaidViewport): MermaidViewBox => {
if (!isPositiveFinite(viewport.width) || !isPositiveFinite(viewport.height)) {
return contentBox;
}
const contentAspect = contentBox.width / contentBox.height;
const viewportAspect = viewport.width / viewport.height;
if (contentAspect > viewportAspect) {
const height = contentBox.width / viewportAspect;
return {
x: contentBox.x,
y: contentBox.y - (height - contentBox.height) / 2,
width: contentBox.width,
height,
};
}
const width = contentBox.height * viewportAspect;
return {
x: contentBox.x - (width - contentBox.width) / 2,
y: contentBox.y,
width,
height: contentBox.height,
};
};
export const panMermaidViewBox = ({
currentBox,
viewport,
delta,
}: {
currentBox: MermaidViewBox;
viewport: MermaidViewport;
delta: MermaidPoint;
}): MermaidViewBox => {
if (
!isPositiveFinite(viewport.width)
|| !isPositiveFinite(viewport.height)
|| !isPositiveFinite(currentBox.width)
|| !isPositiveFinite(currentBox.height)
) {
return currentBox;
}
return {
x: currentBox.x - (delta.x / viewport.width) * currentBox.width,
y: currentBox.y - (delta.y / viewport.height) * currentBox.height,
width: currentBox.width,
height: currentBox.height,
};
};
export const hasMermaidPointerDragMoved = (start: MermaidPoint, current: MermaidPoint): boolean => {
const deltaX = current.x - start.x;
const deltaY = current.y - start.y;
return (deltaX * deltaX) + (deltaY * deltaY) > (DRAG_CLICK_SUPPRESSION_THRESHOLD_PX * DRAG_CLICK_SUPPRESSION_THRESHOLD_PX);
};
export const zoomMermaidViewBoxAtPoint = ({
currentBox,
contentBox,
viewport,
pointer,
zoomFactor,
minScale,
maxScale,
}: {
currentBox: MermaidViewBox;
contentBox: MermaidViewBox;
viewport: MermaidViewport;
pointer: MermaidPoint;
zoomFactor: number;
minScale: number;
maxScale: number;
}): MermaidViewBox => {
if (
!isPositiveFinite(viewport.width)
|| !isPositiveFinite(viewport.height)
|| !isPositiveFinite(zoomFactor)
|| !isPositiveFinite(currentBox.width)
|| !isPositiveFinite(currentBox.height)
|| !isPositiveFinite(contentBox.width)
|| !isPositiveFinite(contentBox.height)
|| !isPositiveFinite(minScale)
|| !isPositiveFinite(maxScale)
) {
return currentBox;
}
const min = Math.min(minScale, maxScale);
const max = Math.max(minScale, maxScale);
const fittedBox = fitMermaidViewBox(contentBox, viewport);
const currentScale = fittedBox.width / currentBox.width;
const nextScale = clamp(currentScale * zoomFactor, min, max);
const nextWidth = fittedBox.width / nextScale;
const nextHeight = nextWidth / (currentBox.width / currentBox.height);
const pointerRatioX = clamp(pointer.x / viewport.width, 0, 1);
const pointerRatioY = clamp(pointer.y / viewport.height, 0, 1);
const svgPointX = currentBox.x + pointerRatioX * currentBox.width;
const svgPointY = currentBox.y + pointerRatioY * currentBox.height;
return {
x: svgPointX - pointerRatioX * nextWidth,
y: svgPointY - pointerRatioY * nextHeight,
width: nextWidth,
height: nextHeight,
};
};
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
);
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
?? block.querySelector<HTMLElement>('[data-markdown="mermaid"]')
);
const getBlockViewerSignature = (block: HTMLElement): string => {
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
const svgHost = block.querySelector<HTMLElement>('[data-markdown="mermaid"]');
return getMermaidViewerSignature({
renderMode: block.getAttribute('data-mermaid-render'),
svgMarkup: svgHost?.getAttribute('data-md-original-svg') ?? svg?.outerHTML ?? null,
viewBox: svg?.getAttribute('viewBox'),
width: svg?.getAttribute('width'),
height: svg?.getAttribute('height'),
});
};
const getViewportSize = (viewport: HTMLElement): MermaidViewport => {
const rect = viewport.getBoundingClientRect();
return { width: rect.width, height: rect.height };
};
const getPointerInViewport = (event: Pick<PointerEvent | WheelEvent, 'clientX' | 'clientY'>, viewport: HTMLElement): MermaidPoint => {
const rect = viewport.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
const isPanExcludedTarget = (target: EventTarget | null): boolean => (
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
);
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
const viewport = getSvgViewport(block);
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
if (!viewport || !svg) {
return null;
}
const contentBox = getMermaidSvgContentBox({
viewBox: svg.getAttribute('viewBox'),
width: svg.getAttribute('width'),
height: svg.getAttribute('height'),
});
if (!contentBox) {
return null;
}
let currentBox = contentBox;
let activePointerId: number | null = null;
let dragStartPointer: MermaidPoint | null = null;
let lastPointer: MermaidPoint | null = null;
let clearClickSuppressionTimer: number | null = null;
const applyViewBox = (box: MermaidViewBox): void => {
currentBox = box;
svg.setAttribute('viewBox', formatMermaidViewBox(box));
svg.removeAttribute('width');
svg.removeAttribute('height');
};
const fit = (): void => {
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
};
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
applyViewBox(zoomMermaidViewBoxAtPoint({
currentBox,
contentBox,
viewport: getViewportSize(viewport),
pointer,
zoomFactor,
minScale: MIN_SCALE,
maxScale: MAX_SCALE,
}));
};
const zoomIn = (): void => {
const size = getViewportSize(viewport);
zoomAt({ x: size.width / 2, y: size.height / 2 }, ZOOM_STEP);
};
const zoomOut = (): void => {
const size = getViewportSize(viewport);
zoomAt({ x: size.width / 2, y: size.height / 2 }, 1 / ZOOM_STEP);
};
const onWheel = (event: WheelEvent): void => {
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
zoomAt(getPointerInViewport(event, viewport), Math.pow(WHEEL_ZOOM_BASE, -event.deltaY));
};
const onPointerDown = (event: PointerEvent): void => {
if (event.button !== 0 || isPanExcludedTarget(event.target)) {
return;
}
activePointerId = event.pointerId;
dragStartPointer = { x: event.clientX, y: event.clientY };
lastPointer = dragStartPointer;
if (clearClickSuppressionTimer !== null) {
window.clearTimeout(clearClickSuppressionTimer);
clearClickSuppressionTimer = null;
}
block.removeAttribute('data-mermaid-suppress-click');
viewport.setPointerCapture?.(event.pointerId);
block.setAttribute('data-mermaid-panning', 'true');
event.preventDefault();
};
const onPointerMove = (event: PointerEvent): void => {
if (activePointerId !== event.pointerId || !lastPointer) {
return;
}
const nextPointer = { x: event.clientX, y: event.clientY };
applyViewBox(panMermaidViewBox({
currentBox,
viewport: getViewportSize(viewport),
delta: {
x: nextPointer.x - lastPointer.x,
y: nextPointer.y - lastPointer.y,
},
}));
lastPointer = nextPointer;
if (dragStartPointer && hasMermaidPointerDragMoved(dragStartPointer, nextPointer)) {
block.setAttribute('data-mermaid-suppress-click', 'true');
}
event.preventDefault();
};
const stopPan = (event: PointerEvent): void => {
if (activePointerId !== event.pointerId) {
return;
}
viewport.releasePointerCapture?.(event.pointerId);
activePointerId = null;
dragStartPointer = null;
lastPointer = null;
block.removeAttribute('data-mermaid-panning');
if (block.hasAttribute('data-mermaid-suppress-click')) {
clearClickSuppressionTimer = window.setTimeout(() => {
block.removeAttribute('data-mermaid-suppress-click');
clearClickSuppressionTimer = null;
}, DRAG_CLICK_SUPPRESSION_CLEAR_MS);
}
};
const onResize = (): void => {
fit();
};
viewport.addEventListener('wheel', onWheel, { passive: false });
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', stopPan);
viewport.addEventListener('pointercancel', stopPan);
window.addEventListener('resize', onResize);
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
observer?.observe(viewport);
fit();
return {
zoomIn,
zoomOut,
fit,
cleanup: () => {
viewport.removeEventListener('wheel', onWheel);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', stopPan);
viewport.removeEventListener('pointercancel', stopPan);
window.removeEventListener('resize', onResize);
observer?.disconnect();
if (clearClickSuppressionTimer !== null) {
window.clearTimeout(clearClickSuppressionTimer);
}
block.removeAttribute('data-mermaid-panning');
block.removeAttribute('data-mermaid-suppress-click');
controllerByBlock.delete(block);
},
};
};
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
const controllers = new Map<HTMLElement, MermaidViewerController>();
const signatures = new Map<HTMLElement, string>();
const refresh = (): void => {
for (const [block, controller] of Array.from(controllers.entries())) {
const signature = getBlockViewerSignature(block);
if (!container.contains(block) || signature !== signatures.get(block)) {
controller.cleanup();
controllers.delete(block);
signatures.delete(block);
}
}
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
continue;
}
const controller = createMermaidViewerController(block);
if (!controller) {
continue;
}
controllers.set(block, controller);
signatures.set(block, getBlockViewerSignature(block));
controllerByBlock.set(block, controller);
}
};
const cleanup = (): void => {
for (const controller of controllers.values()) {
controller.cleanup();
}
controllers.clear();
signatures.clear();
};
refresh();
return { refresh, cleanup };
};