diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1dc9cbb9..165446580 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/static/style.css b/static/style.css
index cd976b278..92990ad3e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;}
diff --git a/static/ui.js b/static/ui.js
index 3a73794be..5c36972c7 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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: '',
+ zoomOut: '',
+ reset: '',
+ fit: '',
+ fullscreen: '',
+ };
+ 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);
diff --git a/tests/test_issue4075_mermaid_lightbox.py b/tests/test_issue4075_mermaid_lightbox.py
index 8913e6fbf..137f9abd8 100644
--- a/tests/test_issue4075_mermaid_lightbox.py
+++ b/tests/test_issue4075_mermaid_lightbox.py
@@ -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
diff --git a/tests/test_issue4814_mermaid_toolbar.py b/tests/test_issue4814_mermaid_toolbar.py
new file mode 100644
index 000000000..c5b88c877
--- /dev/null
+++ b/tests/test_issue4814_mermaid_toolbar.py
@@ -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"