Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5e15e69
fix
wxiaoguang Feb 8, 2026
ed8d27b
Style mermaid view-controller buttons to match code-copy and show on …
silverwind Feb 10, 2026
e92705b
Apply suggestion from @silverwind
silverwind Feb 10, 2026
2c45604
Rename --transition-fade to --transition-hover-fade
silverwind Feb 10, 2026
b35760c
Always show copy and mermaid buttons on touch devices
silverwind Feb 10, 2026
f44409a
don't call iframeStyleText in a loop
wxiaoguang Feb 10, 2026
c724dc9
Use fadein/fadeout animations consistently, remove --transition-hover…
silverwind Feb 10, 2026
6c76c22
Extract getCssKeyFrame helper into dom.ts
silverwind Feb 10, 2026
421ea61
use snapshots
silverwind Feb 10, 2026
8c5fe6c
use try-finally to ensure style cleanup in test
silverwind Feb 10, 2026
fbed58f
Use opaque colors for mermaid button hover and active states
silverwind Feb 10, 2026
84d7d4a
Rename getCssKeyFrame to getCssKeyframe, add cache and docstring
silverwind Feb 10, 2026
f684b9d
rename cache var
silverwind Feb 10, 2026
203dfc8
change cssKeyframeCache to object to avoid `!`
silverwind Feb 10, 2026
814659d
fix
wxiaoguang Feb 10, 2026
d675a87
fix
wxiaoguang Feb 10, 2026
cb5d18f
fix
wxiaoguang Feb 10, 2026
a775b34
fix
wxiaoguang Feb 10, 2026
57889bb
fix
wxiaoguang Feb 10, 2026
1f2c9f0
fix
wxiaoguang Feb 10, 2026
83a9f9a
fix comment
wxiaoguang Feb 10, 2026
b6fc6d6
Merge branch 'main' into support-mermaid-zoom
wxiaoguang Feb 10, 2026
00481ec
fix drag
wxiaoguang Feb 10, 2026
d431651
Use CSS transition variable instead of keyframe animations for hover …
silverwind Feb 10, 2026
5671e92
Replace fadein keyframe with @starting-style transitions for modals
silverwind Feb 10, 2026
a453add
Revert "Replace fadein keyframe with @starting-style transitions for …
wxiaoguang Feb 10, 2026
d153ea8
fix
wxiaoguang Feb 10, 2026
d4fe8c7
fix comment
wxiaoguang Feb 10, 2026
017925a
Add visibility:hidden to mermaid view controller for click inhibition
silverwind Feb 10, 2026
756ba1d
Merge branch 'main' into support-mermaid-zoom
wxiaoguang Feb 10, 2026
e20a438
avoid ui flicker
wxiaoguang Feb 10, 2026
3f7a9d5
apply min height
wxiaoguang Feb 10, 2026
8af918e
Align mermaid view controller buttons with code-copy button
silverwind Feb 10, 2026
d915554
add comment
wxiaoguang Feb 10, 2026
b2ab4a9
fix html
wxiaoguang Feb 10, 2026
1e7ef65
tweak min height
silverwind Feb 10, 2026
fb08054
fine-tune
silverwind Feb 10, 2026
558e7f6
fine-tune
silverwind Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web_src/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
16 changes: 13 additions & 3 deletions web_src/css/markup/codecopy.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
top: 8px;
right: 6px;
padding: 9px;
visibility: hidden;
Comment thread
wxiaoguang marked this conversation as resolved.
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 */
Expand All @@ -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;
}
}
9 changes: 0 additions & 9 deletions web_src/css/modules/animations.css
Original file line number Diff line number Diff line change
Expand Up @@ -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% {
Expand Down
145 changes: 136 additions & 9 deletions web_src/js/markup/mermaid.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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})`;
});
Comment thread
wxiaoguang marked this conversation as resolved.
}

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';
});
Comment thread
wxiaoguang marked this conversation as resolved.

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');
});
Comment thread
wxiaoguang marked this conversation as resolved.
}
Comment thread
wxiaoguang marked this conversation as resolved.

let elkLayoutsRegistered = false;

export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
Expand Down Expand Up @@ -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) {
Expand All @@ -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}));
Expand Down
16 changes: 16 additions & 0 deletions web_src/js/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++}`;
Expand Down