Add viewer controller for mermaid (zoom, drag) (#36557)

Fix #25803

Now the rendered mermaid chart can be dragged or zoomed.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wxiaoguang
2026-02-10 14:36:31 +08:00
committed by GitHub
parent 09a88fb17e
commit 5e5703694d
5 changed files with 166 additions and 21 deletions

View File

@@ -30,6 +30,7 @@
--page-spacing: 16px; /* space between page elements */
--page-margin-x: 32px; /* minimum space on left and right side of page */
--page-space-bottom: 64px; /* space between last page element and footer */
--transition-hover-fade: opacity 0.2s ease; /* fade transition for elements that show on hover */
/* z-index */
--z-index-modal: 1001; /* modal dialog, hard-coded from Fomantic modal.css */

View File

@@ -3,8 +3,9 @@
top: 8px;
right: 6px;
padding: 9px;
visibility: hidden;
animation: fadeout 0.2s both;
visibility: hidden; /* prevent from click events even opacity=0 */
opacity: 0;
transition: var(--transition-hover-fade);
}
/* adjustments for comment content having only 14px font size */
@@ -23,8 +24,17 @@
background: var(--color-secondary-dark-1) !important;
}
/* all rendered code-block elements are in their container,
the manually written code-block elements on "packages" pages don't have the container */
.markup .code-block-container:hover .code-copy,
.markup .code-block:hover .code-copy {
visibility: visible;
animation: fadein 0.2s both;
opacity: 1;
}
@media (hover: none) {
.markup .code-copy {
visibility: visible;
opacity: 1;
}
}

View File

@@ -82,15 +82,6 @@ code.language-math.is-loading::after {
}
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* 1p5 means 1-point-5. it can't use "pulse" here, otherwise the animation is not right (maybe due to some conflicts */
@keyframes pulse-1p5 {
0% {

View File

@@ -1,16 +1,56 @@
import {isDarkTheme, parseDom} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {createElementFromAttrs, queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {createElementFromAttrs, createElementFromHTML, getCssRootVariablesText, queryElems} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {load as loadYaml} from 'js-yaml';
import type {MermaidConfig} from 'mermaid';
const {mermaidMaxSourceCharacters} = window.config;
const iframeCss = `:root {color-scheme: normal}
body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}`;
function getIframeCss(): string {
// Inherit some styles (e.g.: root variables) from parent document.
// The buttons should use the same styles as `button.code-copy`, and align with it.
return `
${getCssRootVariablesText()}
html, body { height: 100%; }
body { margin: 0; padding: 0; overflow: hidden; }
#mermaid { display: block; margin: 0 auto; }
.view-controller {
position: absolute;
z-index: 1;
right: 5px;
bottom: 5px;
display: flex;
gap: 4px;
visibility: hidden;
opacity: 0;
transition: var(--transition-hover-fade);
margin-right: 0.25em;
}
body:hover .view-controller { visibility: visible; opacity: 1; }
@media (hover: none) {
.view-controller { visibility: visible; opacity: 1; }
}
.view-controller button {
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
line-height: 1;
padding: 7.5px 10px;
border: 1px solid var(--color-light-border);
border-radius: var(--border-radius);
background: var(--color-button);
color: var(--color-text);
user-select: none;
}
.view-controller button:hover { background: var(--color-secondary); }
.view-controller button:active { background: var(--color-secondary-dark-1); }
`;
}
function isSourceTooLarge(source: string) {
return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters;
@@ -77,6 +117,76 @@ async function loadMermaid(needElkRender: boolean) {
};
}
function initMermaidViewController(dragElement: SVGSVGElement) {
let inited = false, isDragging = false;
let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0;
const container = dragElement.parentElement!;
const resetView = () => {
currentScale = 1;
lastLeft = initLeft;
lastTop = 0;
dragElement.style.left = `${lastLeft}px`;
dragElement.style.top = `${lastTop}px`;
dragElement.style.position = 'absolute';
dragElement.style.margin = '0';
};
const initAbsolutePosition = () => {
if (inited) return;
// if we need to drag or zoom, use absolute position and get the current "left" from the "margin: auto" layout.
inited = true;
initLeft = container.getBoundingClientRect().width / 2 - dragElement.getBoundingClientRect().width / 2;
resetView();
};
for (const el of queryElems(container, '[data-control-action]')) {
el.addEventListener('click', () => {
initAbsolutePosition();
switch (el.getAttribute('data-control-action')) {
case 'zoom-in':
currentScale *= 1.2;
break;
case 'zoom-out':
currentScale /= 1.2;
break;
case 'reset':
resetView();
break;
}
dragElement.style.transform = `scale(${currentScale})`;
});
}
dragElement.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // only left mouse button can drag
const target = e.target as Element;
if (target.closest('div, p, a, span, button, input')) return; // don't start the drag if the click is on an interactive element (e.g.: link, button) or text element
initAbsolutePosition();
isDragging = true;
lastPageX = e.pageX;
lastPageY = e.pageY;
dragElement.style.cursor = 'grabbing';
});
dragElement.ownerDocument.addEventListener('mousemove', (e) => {
if (!isDragging) return;
lastLeft = e.pageX - lastPageX + lastLeft;
lastTop = e.pageY - lastPageY + lastTop;
dragElement.style.left = `${lastLeft}px`;
dragElement.style.top = `${lastTop}px`;
lastPageX = e.pageX;
lastPageY = e.pageY;
});
dragElement.ownerDocument.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
dragElement.style.removeProperty('cursor');
});
}
let elkLayoutsRegistered = false;
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
@@ -107,6 +217,13 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
suppressErrorRendering: true,
});
const iframeStyleText = getIframeCss();
const applyMermaidIframeHeight = (iframe: HTMLIFrameElement, height: number) => {
if (!height) return;
// use a min-height to make sure the buttons won't overlap.
iframe.style.height = `${Math.max(height, 85)}px`;
};
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
for (const block of mermaidBlocks) {
@@ -122,27 +239,37 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
const svgDoc = parseDom(svgText, 'image/svg+xml');
const svgNode = (svgDoc.documentElement as unknown) as SVGSVGElement;
const viewControllerHtml = html`<div class="view-controller"><button data-control-action="zoom-in">+</button><button data-control-action="reset">reset</button><button data-control-action="zoom-out">-</button></div>`;
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'is-loading');
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body></body></html>`;
// the styles are not ready, so don't really render anything before the "load" event, to avoid flicker of unstyled content
iframe.srcdoc = html`<html><head></head><body></body></html>`;
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0);
if (iframeHeightFromViewBox) iframe.style.height = `${iframeHeightFromViewBox}px`;
applyMermaidIframeHeight(iframe, iframeHeightFromViewBox);
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
iframe.addEventListener('load', () => {
// same origin, so we can operate "iframe body" and all elements directly
// same origin, so we can operate "iframe head/body" and all elements directly
const style = document.createElement('style');
style.textContent = iframeStyleText;
iframe.contentDocument!.head.append(style);
const iframeBody = iframe.contentDocument!.body;
iframeBody.append(svgNode);
bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container
iframeBody.append(createElementFromHTML(viewControllerHtml));
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
if (!iframeHeightFromViewBox && iframeBody.clientHeight) iframe.style.height = `${iframeBody.clientHeight}px`;
if (!iframeHeightFromViewBox) applyMermaidIframeHeight(iframe, iframeBody.clientHeight);
iframe.classList.remove('is-loading');
initMermaidViewController(svgNode);
});
const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, makeCodeCopyButton({'data-clipboard-text': source}));

View File

@@ -352,6 +352,22 @@ export function isPlainClick(e: MouseEvent) {
return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
}
let cssRootVariablesTextCache: string = '';
export function getCssRootVariablesText(): string {
if (cssRootVariablesTextCache) return cssRootVariablesTextCache;
const style = getComputedStyle(document.documentElement);
let text = ':root {\n';
for (let i = 0; i < style.length; i++) {
const name = style.item(i);
if (name.startsWith('--')) {
text += ` ${name}: ${style.getPropertyValue(name)};\n`;
}
}
text += '}\n';
cssRootVariablesTextCache = text;
return text;
}
let elemIdCounter = 0;
export function generateElemId(prefix: string = ''): string {
return `${prefix}${elemIdCounter++}`;