mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
Merge pull request #5286 from nesquena/release/stage-4871
Release v0.51.773 — Mermaid toolbar controls in chat (#4871)
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user