Merge pull request #5286 from nesquena/release/stage-4871

Release v0.51.773 — Mermaid toolbar controls in chat (#4871)
This commit is contained in:
nesquena-hermes
2026-06-30 12:43:18 -07:00
committed by GitHub
5 changed files with 641 additions and 9 deletions
+2
View File
@@ -35,6 +35,8 @@
### Fixed
- **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814)
- **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520)
- **Mobile approval dialog and touch targets are easier to tap.** At mobile width the approval card's action buttons now lay out as a clean 2-column grid (Allow once / Allow session / Always allow / Deny) with the "Skip all this session" YOLO button full-width below, every button at a 44px minimum touch target; the titlebar new-chat/reload and the mobile sidebar-close controls are also bumped to 44px. Mobile-scoped (inside the mobile media query) — desktop layout is unchanged. Thanks @disco32r. (#5019)
+13 -2
View File
@@ -2240,7 +2240,18 @@
.img-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:lb-in .15s ease;}
@keyframes lb-in{from{opacity:0}to{opacity:1}}
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;width:auto;height:auto;display:block;background:var(--code-bg);box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.mermaid-viewer{display:flex;flex-direction:column;gap:6px;max-width:100%;min-width:0;}
.mermaid-viewer-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px;align-items:center;flex-shrink:0;}
.mermaid-viewer-btn{width:28px;height:28px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s,color .15s,border-color .15s,transform .15s;flex:0 0 auto;padding:0;}
.mermaid-viewer-btn:hover{background:var(--hover-bg);color:var(--text);}
.mermaid-viewer-btn svg{width:14px;height:14px;display:block;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;}
.mermaid-viewer-viewport{position:relative;overflow:hidden;max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--code-bg);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;}
.mermaid-viewer-viewport.is-panning{cursor:grabbing;}
.mermaid-viewer--inline .mermaid-viewer-viewport{width:100%;max-height:70vh;}
.mermaid-viewer--lightbox .mermaid-viewer-viewport{max-width:90vw;max-height:90vh;border:none;box-shadow:0 8px 48px rgba(0,0,0,.6);}
.mermaid-viewer-canvas{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform;}
.mermaid-viewer-svg{display:block;width:100%;height:100%;}
.img-lightbox .mermaid-lightbox-svg{background:var(--code-bg);cursor:default;}
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
.img-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,opacity .15s;z-index:1;opacity:.6;}
@@ -5345,7 +5356,7 @@ main.main > #mainPlugin{display:none;}
/* ── Mermaid diagrams ── */
.mermaid-block{background:var(--code-bg);border-radius:8px;padding:16px;margin:8px 0;overflow-x:auto;}
.mermaid-rendered{background:transparent;padding:8px 0;}
.mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;}
.mermaid-rendered svg{max-width:100%;height:auto;cursor:default;}
/* ── Session projects ── */
.session-source-tabs{display:flex;gap:4px;padding:4px 10px 8px;flex-shrink:0;}
+272 -3
View File
@@ -1343,6 +1343,274 @@ function _openImgLightbox(imgEl) {
}
_openImgLightboxWithNav(src, alt, allImages, startIndex);
}
const _MERMAID_VIEWER_MIN_SCALE = 0.25;
const _MERMAID_VIEWER_MAX_SCALE = 8;
const _MERMAID_VIEWER_ZOOM_STEP = 1.2;
function _mermaidViewerIcon(kind) {
const icons = {
zoomIn: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10" cy="10" r="6"></circle><path d="M10 7v6M7 10h6"></path><path d="M15 15l4 4"></path></svg>',
zoomOut: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10" cy="10" r="6"></circle><path d="M7 10h6"></path><path d="M15 15l4 4"></path></svg>',
reset: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9V4H1"></path><path d="M1 4l4 4"></path><path d="M10 4a8 8 0 1 1-5.66 13.66"></path></svg>',
fit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"></path></svg>',
fullscreen: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"></path></svg>',
};
return icons[kind] || '';
}
function _createMermaidViewerButton(label, iconKind, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mermaid-viewer-btn';
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
btn.innerHTML = _mermaidViewerIcon(iconKind);
btn.onclick = e => {
e.preventDefault();
e.stopPropagation();
onClick(e);
};
return btn;
}
function _mermaidSvgBox(svgEl) {
const box = {x: 0, y: 0, width: 0, height: 0};
if(!svgEl) return box;
const viewBox = svgEl.viewBox && svgEl.viewBox.baseVal;
if(viewBox && viewBox.width && viewBox.height){
box.x = Number(viewBox.x) || 0;
box.y = Number(viewBox.y) || 0;
box.width = Number(viewBox.width) || 0;
box.height = Number(viewBox.height) || 0;
return box;
}
const rawViewBox = svgEl.getAttribute && svgEl.getAttribute('viewBox');
if(rawViewBox){
const parts = rawViewBox.trim().split(/[,\s]+/).map(Number);
if(parts.length >= 4 && parts.every(n => Number.isFinite(n))){
box.x = parts[0] || 0;
box.y = parts[1] || 0;
box.width = parts[2] || 0;
box.height = parts[3] || 0;
return box;
}
}
const width = Number.parseFloat(svgEl.getAttribute && svgEl.getAttribute('width')) || (svgEl.getBoundingClientRect ? svgEl.getBoundingClientRect().width : 0) || 0;
const height = Number.parseFloat(svgEl.getAttribute && svgEl.getAttribute('height')) || (svgEl.getBoundingClientRect ? svgEl.getBoundingClientRect().height : 0) || 0;
box.width = width || 800;
box.height = height || 450;
return box;
}
function _mountMermaidViewer(svgEl, options = {}) {
if(!svgEl) return null;
const mode = options.mode === 'lightbox' ? 'lightbox' : 'inline';
const openLightbox = typeof options.openLightbox === 'function' ? options.openLightbox : () => _openMermaidLightbox(svgEl);
const box = _mermaidSvgBox(svgEl);
const host = svgEl.parentNode;
const root = document.createElement('div');
root.className = 'mermaid-viewer mermaid-viewer--' + mode;
const toolbar = document.createElement('div');
toolbar.className = 'mermaid-viewer-toolbar';
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewer-viewport';
const canvas = document.createElement('div');
canvas.className = 'mermaid-viewer-canvas';
canvas.style.width = Math.max(1, Math.round(box.width)) + 'px';
canvas.style.height = Math.max(1, Math.round(box.height)) + 'px';
svgEl.classList.add('mermaid-viewer-svg');
if(mode === 'lightbox') svgEl.classList.add('mermaid-lightbox-svg');
svgEl.style.width = '100%';
svgEl.style.height = '100%';
svgEl.style.display = 'block';
viewport.appendChild(canvas);
root.appendChild(toolbar);
root.appendChild(viewport);
if(host) host.replaceChild(root, svgEl);
canvas.appendChild(svgEl);
const state = {
box,
canvas,
dragging: false,
dragOriginX: 0,
dragOriginY: 0,
dragPointerId: null,
dragStartX: 0,
dragStartY: 0,
dragged: false,
mode,
root,
toolbar,
scale: 1,
svg: svgEl,
viewport,
x: 0,
y: 0,
};
root._mermaidViewer = state;
function _viewportSize(){
const rect = viewport.getBoundingClientRect ? viewport.getBoundingClientRect() : null;
const width = viewport.clientWidth || (rect && rect.width) || (mode === 'lightbox' ? Math.round((window.innerWidth || box.width) * 0.9) : Math.round(window.innerWidth || box.width));
const height = viewport.clientHeight || (rect && rect.height) || (mode === 'lightbox' ? Math.round((window.innerHeight || box.height) * 0.9) : Math.round((window.innerHeight || box.height) * 0.7));
return {
width: Math.max(1, Number(width) || box.width || 1),
height: Math.max(1, Number(height) || box.height || 1),
};
}
function _applyTransform(){
canvas.style.transform = `translate(${Math.round(state.x)}px, ${Math.round(state.y)}px) scale(${state.scale})`;
canvas.style.transformOrigin = '0 0';
}
function _centerForScale(nextScale){
const size = _viewportSize();
const scaledWidth = box.width * nextScale;
const scaledHeight = box.height * nextScale;
state.x = scaledWidth < size.width ? Math.round((size.width - scaledWidth) / 2) : 0;
state.y = scaledHeight < size.height ? Math.round((size.height - scaledHeight) / 2) : 0;
}
function _fitScale(){
const size = _viewportSize();
return Math.max(_MERMAID_VIEWER_MIN_SCALE, Math.min(_MERMAID_VIEWER_MAX_SCALE, Math.min(size.width / box.width, size.height / box.height)));
}
function _setScale(nextScale, anchorX, anchorY){
const bounded = Math.max(_MERMAID_VIEWER_MIN_SCALE, Math.min(_MERMAID_VIEWER_MAX_SCALE, nextScale));
if(!Number.isFinite(bounded) || !box.width || !box.height) return;
const focusX = Number.isFinite(anchorX) ? anchorX : _viewportSize().width / 2;
const focusY = Number.isFinite(anchorY) ? anchorY : _viewportSize().height / 2;
if(state.scale){
const ratio = bounded / state.scale;
state.x = focusX - (focusX - state.x) * ratio;
state.y = focusY - (focusY - state.y) * ratio;
}
state.scale = bounded;
_applyTransform();
}
function _fitViewer(){
const nextScale = _fitScale();
state.scale = nextScale;
_centerForScale(nextScale);
_applyTransform();
}
function _resetViewer(){
state.scale = 1;
_centerForScale(1);
_applyTransform();
}
function _zoomIn(){
const size = _viewportSize();
_setScale(state.scale * _MERMAID_VIEWER_ZOOM_STEP, size.width / 2, size.height / 2);
}
function _zoomOut(){
const size = _viewportSize();
_setScale(state.scale / _MERMAID_VIEWER_ZOOM_STEP, size.width / 2, size.height / 2);
}
function _zoomFromWheel(e){
if(e.preventDefault) e.preventDefault();
const rect = viewport.getBoundingClientRect ? viewport.getBoundingClientRect() : {left: 0, top: 0};
const anchorX = Number.isFinite(e.clientX) ? e.clientX - rect.left : undefined;
const anchorY = Number.isFinite(e.clientY) ? e.clientY - rect.top : undefined;
const deltaMode = Number(e.deltaMode) || 0;
const lineScale = deltaMode === 1 ? 30 : deltaMode === 2 ? 600 : 1;
const factor = Math.exp((-(Number(e.deltaY) || 0)) * lineScale * 0.0015);
_setScale(state.scale * factor, anchorX, anchorY);
}
function _onPointerDown(e){
if(e.button != null && e.button !== 0) return;
state.dragging = true;
state.dragged = false;
state.dragOriginX = Number(e.clientX) || 0;
state.dragOriginY = Number(e.clientY) || 0;
state.dragPointerId = e.pointerId != null ? e.pointerId : null;
state.dragStartX = state.x;
state.dragStartY = state.y;
viewport.classList.add('is-panning');
if(state.dragPointerId != null && viewport.setPointerCapture) viewport.setPointerCapture(state.dragPointerId);
if(e.preventDefault) e.preventDefault();
}
function _onPointerMove(e){
if(!state.dragging) return;
const dx = (Number(e.clientX) || 0) - state.dragOriginX;
const dy = (Number(e.clientY) || 0) - state.dragOriginY;
if(Math.abs(dx) + Math.abs(dy) > 3) state.dragged = true;
state.x = state.dragStartX + dx;
state.y = state.dragStartY + dy;
_applyTransform();
}
function _endPointerDrag(){
if(!state.dragging) return;
state.dragging = false;
if(state.dragPointerId != null && viewport.releasePointerCapture){
try{ viewport.releasePointerCapture(state.dragPointerId); }catch(_){}
}
state.dragPointerId = null;
viewport.classList.remove('is-panning');
}
function _openViewerOnClick(e){
if(mode !== 'inline') return;
if(state.dragged){
state.dragged = false;
return;
}
if(e.preventDefault) e.preventDefault();
if(e.stopPropagation) e.stopPropagation();
openLightbox();
}
viewport.onpointerdown = _onPointerDown;
viewport.onpointermove = _onPointerMove;
viewport.onpointerup = _endPointerDrag;
viewport.onpointercancel = _endPointerDrag;
viewport.onpointerleave = _endPointerDrag;
viewport.onwheel = _zoomFromWheel;
viewport.onclick = _openViewerOnClick;
root.onclick = e => e.stopPropagation();
state.fit = _fitViewer;
state.reset = _resetViewer;
state.zoomIn = _zoomIn;
state.zoomOut = _zoomOut;
state.zoomAt = _setScale;
state.applyTransform = _applyTransform;
state.openLightbox = openLightbox;
toolbar.appendChild(_createMermaidViewerButton('Zoom in', 'zoomIn', _zoomIn));
toolbar.appendChild(_createMermaidViewerButton('Zoom out', 'zoomOut', _zoomOut));
toolbar.appendChild(_createMermaidViewerButton('Reset view', 'reset', _resetViewer));
toolbar.appendChild(_createMermaidViewerButton('Fit to screen', 'fit', _fitViewer));
if(mode === 'inline'){
toolbar.appendChild(_createMermaidViewerButton('Fullscreen', 'fullscreen', openLightbox));
}
const initialFit = _fitScale();
state.scale = initialFit;
_centerForScale(initialFit);
_applyTransform();
if(mode === 'lightbox'){
viewport.style.width = Math.max(1, Math.round(box.width * initialFit)) + 'px';
viewport.style.height = Math.max(1, Math.round(box.height * initialFit)) + 'px';
} else {
viewport.style.width = '100%';
viewport.style.height = Math.max(1, Math.round(box.height * initialFit)) + 'px';
}
return root;
}
function _openMermaidLightbox(svgEl) {
if(!svgEl) return;
const lb = document.createElement('div');
@@ -1384,16 +1652,15 @@ function _openMermaidLightbox(svgEl) {
styleEl.textContent = styleText;
});
}
clone.classList.add('mermaid-lightbox-svg');
clone.removeAttribute('width');
clone.removeAttribute('height');
clone.onclick = e => e.stopPropagation();
const viewer = _mountMermaidViewer(clone, {mode:'lightbox'});
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(clone);
lb.appendChild(viewer);
lb.appendChild(cls);
lb.onclick = () => _closeImgLightbox(lb);
lb._keyHandler = e => {
@@ -15231,6 +15498,8 @@ function renderMermaidBlocks(container){
const tmp=document.getElementById('d'+id);
if(tmp) tmp.remove();
block.innerHTML=svg;
const renderedSvg = block.querySelector('svg');
if(renderedSvg) _mountMermaidViewer(renderedSvg, {mode:'inline'});
block.classList.add('mermaid-rendered');
}catch(e){
const tmp=document.getElementById('d'+id);
+10 -4
View File
@@ -20,10 +20,16 @@ def _style_css() -> str:
class TestMermaidLightboxHelper:
def test_mermaid_lightbox_has_dedicated_helper(self):
src = _ui_js()
assert re.search(r"function\s+_mountMermaidViewer\(svgEl,\s*options\s*=\s*\{\}\)\s*\{", src)
assert re.search(r"function\s+_openMermaidLightbox\(svgEl\)\s*\{", src)
assert "svgEl.cloneNode(true)" in src
assert "const viewer = _mountMermaidViewer(clone, {mode:'lightbox'});" in src
assert "mermaid-lightbox-svg" in src
def test_mermaid_render_path_mounts_inline_viewer_shell(self):
src = _ui_js()
assert "const renderedSvg = block.querySelector('svg');" in src
assert "if(renderedSvg) _mountMermaidViewer(renderedSvg, {mode:'inline'});" in src
def test_mermaid_lightbox_reuses_existing_modal_chrome(self):
src = _ui_js()
assert "img-lightbox" in src
@@ -67,12 +73,12 @@ class TestDocumentClickDelegate:
class TestMermaidLightboxCss:
def test_rendered_mermaid_svg_advertises_zoom(self):
src = _style_css()
assert ".mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;}" in src
assert ".mermaid-rendered svg{max-width:100%;height:auto;cursor:default;}" in src
def test_lightbox_svg_uses_modal_viewport_limits(self):
src = _style_css()
rule = ".img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;"
assert rule in src
assert ".mermaid-viewer--lightbox .mermaid-viewer-viewport{max-width:90vw;max-height:90vh;" in src
assert ".img-lightbox .mermaid-lightbox-svg{background:var(--code-bg);cursor:default;}" in src
assert "background:var(--code-bg);" in src
+344
View File
@@ -0,0 +1,344 @@
"""Behavioral coverage for issue #4814 Mermaid toolbar controls."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "static" / "ui.js"
NODE = shutil.which("node")
_DRIVER_SRC = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
const helperStart = ui.indexOf('const _MERMAID_VIEWER_MIN_SCALE');
const helperEnd = ui.indexOf('function _openMermaidLightbox');
if (helperStart < 0 || helperEnd < 0) {
throw new Error('could not locate Mermaid viewer helpers');
}
const documentListeners = {};
function makeClassList(node) {
const classes = new Set();
return {
add(...items) { items.forEach((item) => item && classes.add(item)); node.className = [...classes].join(' '); },
remove(...items) { items.forEach((item) => classes.delete(item)); node.className = [...classes].join(' '); },
contains(item) { return classes.has(item); },
toggle(item, force) {
if (force === true) { classes.add(item); node.className = [...classes].join(' '); return true; }
if (force === false) { classes.delete(item); node.className = [...classes].join(' '); return false; }
if (classes.has(item)) { classes.delete(item); node.className = [...classes].join(' '); return false; }
classes.add(item); node.className = [...classes].join(' '); return true;
},
toJSON() { return [...classes]; },
};
}
function makeElement(tagName) {
const node = {
tagName: String(tagName || '').toUpperCase(),
children: [],
parentNode: null,
className: '',
dataset: {},
style: {},
attributes: {},
textContent: '',
innerHTML: '',
clientWidth: 0,
clientHeight: 0,
onclick: null,
onpointerdown: null,
onpointermove: null,
onpointerup: null,
onpointercancel: null,
onpointerleave: null,
onwheel: null,
classList: null,
capturedPointerId: null,
releasedPointerId: null,
appendChild(child) {
if (child.parentNode) child.parentNode.removeChild(child);
this.children.push(child);
child.parentNode = this;
return child;
},
replaceChild(next, prev) {
const idx = this.children.indexOf(prev);
if (idx < 0) throw new Error('replaceChild target missing');
if (next.parentNode) next.parentNode.removeChild(next);
this.children[idx] = next;
next.parentNode = this;
prev.parentNode = null;
return prev;
},
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx >= 0) this.children.splice(idx, 1);
child.parentNode = null;
return child;
},
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'class') this.className = String(value);
if (name === 'aria-label') this.ariaLabel = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
},
removeAttribute(name) {
delete this.attributes[name];
},
getBoundingClientRect() {
return {left: 0, top: 0, width: this.clientWidth, height: this.clientHeight};
},
setPointerCapture(pointerId) {
this.capturedPointerId = pointerId;
},
releasePointerCapture(pointerId) {
this.releasedPointerId = pointerId;
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
},
cloneNode() {
const copy = makeElement(this.tagName);
copy.className = this.className;
copy.dataset = {...this.dataset};
copy.style = {...this.style};
copy.attributes = {...this.attributes};
copy.textContent = this.textContent;
copy.innerHTML = this.innerHTML;
copy.clientWidth = this.clientWidth;
copy.clientHeight = this.clientHeight;
copy.classList = makeClassList(copy);
for (const cls of String(this.className || '').split(/\s+/).filter(Boolean)) copy.classList.add(cls);
return copy;
},
};
node.classList = makeClassList(node);
return node;
}
function makeSvg(width, height) {
const svg = makeElement('svg');
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
svg.viewBox = {baseVal: {x: 0, y: 0, width, height}};
svg.getBBox = () => ({x: 0, y: 0, width, height});
return svg;
}
const document = {
body: makeElement('body'),
createElement(tagName) {
return makeElement(tagName);
},
addEventListener(type, handler) {
(documentListeners[type] ||= []).push(handler);
},
removeEventListener(type, handler) {
const list = documentListeners[type] || [];
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
},
};
const window = {innerWidth: 1200, innerHeight: 800};
global.document = document;
global.window = window;
global.requestAnimationFrame = (fn) => fn();
global.cancelAnimationFrame = () => {};
eval(ui.slice(helperStart, helperEnd));
function makeHost(svg) {
const host = makeElement('div');
host.appendChild(svg);
return host;
}
function labelsFromToolbar(viewer) {
return viewer._mermaidViewer.toolbar.children.map((child) => child.getAttribute('aria-label'));
}
function runScenario(payload) {
const svg = makeSvg(payload.width || 480, payload.height || 320);
const host = makeHost(svg);
const viewer = _mountMermaidViewer(svg, payload.options || {});
const state = viewer._mermaidViewer;
state.viewport.clientWidth = payload.viewportWidth || 960;
state.viewport.clientHeight = payload.viewportHeight || 540;
state.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: state.viewport.clientWidth, height: state.viewport.clientHeight});
if (payload.fitBefore) state.fit();
const result = {
className: viewer.className,
labels: labelsFromToolbar(viewer),
scale: state.scale,
x: state.x,
y: state.y,
canvasWidth: state.canvas.style.width,
canvasHeight: state.canvas.style.height,
viewportWidth: state.viewport.style.width,
viewportHeight: state.viewport.style.height,
};
if (payload.scenario === 'toolbar') {
result.hasInlineFullscreen = result.labels.includes('Fullscreen');
return result;
}
if (payload.scenario === 'zoom') {
state.fit();
const fitScale = state.scale;
state.zoomIn();
const zoomInScale = state.scale;
state.zoomOut();
const zoomOutScale = state.scale;
for (let i = 0; i < 20; i += 1) state.zoomIn();
const maxScale = state.scale;
state.reset();
const reset = {scale: state.scale, x: state.x, y: state.y};
state.fit();
const beforeWheel = {scale: state.scale, x: state.x, y: state.y};
state.viewport.onwheel({deltaY: -120, clientX: 240, clientY: 160, preventDefault() {}});
const afterWheel = {scale: state.scale, x: state.x, y: state.y};
state.fit();
const beforeLineWheel = {scale: state.scale, x: state.x, y: state.y};
state.viewport.onwheel({deltaY: -3, deltaMode: 1, clientX: 240, clientY: 160, preventDefault() {}});
const afterLineWheel = {scale: state.scale, x: state.x, y: state.y};
return {fitScale, zoomInScale, zoomOutScale, maxScale, reset, beforeWheel, afterWheel, beforeLineWheel, afterLineWheel};
}
if (payload.scenario === 'drag') {
const opens = [];
const interactive = _mountMermaidViewer(svg, {
mode: 'inline',
openLightbox() { opens.push('open'); },
});
const interactiveState = interactive._mermaidViewer;
interactiveState.viewport.clientWidth = 960;
interactiveState.viewport.clientHeight = 540;
interactiveState.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: interactiveState.viewport.clientWidth, height: interactiveState.viewport.clientHeight});
interactiveState.fit();
const start = {x: interactiveState.x, y: interactiveState.y, scale: interactiveState.scale};
interactiveState.viewport.onpointerdown({button: 0, clientX: 100, clientY: 100, pointerId: 7, preventDefault() {}});
interactiveState.viewport.onpointermove({clientX: 160, clientY: 150});
interactiveState.viewport.onpointerup({pointerId: 7, preventDefault() {}});
interactiveState.viewport.onclick({preventDefault() {}, stopPropagation() {}, target: interactiveState.viewport});
const afterDrag = {
x: interactiveState.x,
y: interactiveState.y,
dragged: interactiveState.dragged,
opens: opens.length,
releasedPointerId: interactiveState.viewport.releasedPointerId,
capturedPointerId: interactiveState.viewport.capturedPointerId,
};
interactiveState.dragged = false;
interactiveState.viewport.onclick({preventDefault() {}, stopPropagation() {}, target: interactiveState.viewport});
const afterClick = {opens: opens.length};
return {start, afterDrag, afterClick};
}
if (payload.scenario === 'fullscreen') {
const opens = [];
const inlineViewer = _mountMermaidViewer(svg, {
mode: 'inline',
openLightbox() { opens.push('inline-lightbox'); },
});
const inlineLabels = labelsFromToolbar(inlineViewer);
const fullscreen = inlineViewer._mermaidViewer.toolbar.children.find((btn) => btn.getAttribute('aria-label') === 'Fullscreen');
fullscreen.onclick({preventDefault() {}, stopPropagation() {}});
const lightboxSvg = makeSvg(payload.width || 480, payload.height || 320);
const lightboxViewer = _mountMermaidViewer(lightboxSvg, {mode: 'lightbox'});
const lightboxLabels = labelsFromToolbar(lightboxViewer);
return {
inlineLabels,
lightboxLabels,
opens,
lightboxClassName: lightboxViewer.className,
};
}
throw new Error('unknown scenario: ' + payload.scenario);
}
const payload = JSON.parse(process.argv[3]);
process.stdout.write(JSON.stringify(runScenario(payload)));
"""
def _run_node(driver_path: str, payload: dict) -> dict:
result = subprocess.run(
[NODE, driver_path, str(UI), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
return json.loads(result.stdout)
@pytest.fixture(scope="module")
def _driver_path(tmp_path_factory):
path = tmp_path_factory.mktemp("mermaid_toolbar_driver") / "driver.js"
path.write_text(_DRIVER_SRC, encoding="utf-8")
return str(path)
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_inline_viewer_mounts_toolbar_and_shell(_driver_path):
result = _run_node(_driver_path, {
"scenario": "toolbar",
"options": {"mode": "inline", "openLightbox": "ignored"},
})
assert result["className"] == "mermaid-viewer mermaid-viewer--inline"
assert result["labels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen", "Fullscreen"]
assert result["canvasWidth"] == "480px"
assert result["viewportWidth"] == "100%"
assert result["hasInlineFullscreen"] is True
def test_zoom_fit_reset_and_wheel_update_state(_driver_path):
result = _run_node(_driver_path, {"scenario": "zoom", "options": {"mode": "inline"}})
assert 1.0 < result["fitScale"] < 8.0
assert result["zoomInScale"] > result["fitScale"]
assert result["zoomOutScale"] < result["zoomInScale"]
assert result["maxScale"] <= 8.0
assert result["reset"]["scale"] == 1
assert result["afterWheel"]["scale"] > result["beforeWheel"]["scale"]
assert (result["afterWheel"]["x"], result["afterWheel"]["y"]) != (result["beforeWheel"]["x"], result["beforeWheel"]["y"])
assert result["afterLineWheel"]["scale"] > result["beforeLineWheel"]["scale"] * 1.1
def test_drag_suppresses_accidental_lightbox_open(_driver_path):
result = _run_node(_driver_path, {"scenario": "drag", "options": {"mode": "inline"}})
assert result["start"]["scale"] > 0
assert (result["afterDrag"]["x"], result["afterDrag"]["y"]) != (result["start"]["x"], result["start"]["y"])
assert result["afterDrag"]["opens"] == 0
assert result["afterDrag"]["releasedPointerId"] == 7
assert result["afterDrag"]["capturedPointerId"] is None
assert result["afterClick"]["opens"] == 1
def test_lightbox_mode_uses_same_viewer_helper_without_fullscreen(_driver_path):
result = _run_node(_driver_path, {"scenario": "fullscreen", "options": {"mode": "inline"}})
assert result["inlineLabels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen", "Fullscreen"]
assert result["lightboxLabels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen"]
assert result["opens"] == ["inline-lightbox"]
assert result["lightboxClassName"] == "mermaid-viewer mermaid-viewer--lightbox"