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"