Skip to content

Commit 92b8e45

Browse files
nicobailonpeak-flow
andcommitted
feat: multi-diagram support with vector-based zoom/pan engine
Replace CSS zoom with direct SVG sizing. Closure-based initDiagram() pattern supports unlimited diagrams per page with no ID collisions. Includes adaptive viewport height, smart fit algorithm, touch pinch-to-zoom, and double-click to fit. Co-authored-by: David Abraham <dave@springbasemedia.com>
1 parent f41c48f commit 92b8e45

File tree

4 files changed

+513
-250
lines changed

4 files changed

+513
-250
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Changelog
22

3+
## [0.6.0] - 2026-03-08
4+
5+
Thanks to [@peak-flow](https://github.com/peak-flow) (David Abraham) for the adaptive zoom/pan engine PR (#25).
6+
7+
### Multi-Diagram Support
8+
- New vector-based zoom/pan engine replacing CSS `zoom` with direct SVG sizing
9+
- Closure-based `initDiagram(shell)` pattern — per-diagram state in closures, shared drag listeners at module scope
10+
- Unlimited diagrams per page with no ID collisions (each diagram gets a unique generated ID)
11+
- New HTML structure: `.diagram-shell` > `.mermaid-wrap` > `.mermaid-viewport` > `.mermaid-canvas`
12+
- Source Mermaid code lives in `<script type="text/plain" class="diagram-source">` to avoid parsing issues
13+
- Adaptive viewport height based on diagram aspect ratio
14+
- Smart fit algorithm with readability floor (prevents tiny unreadable diagrams)
15+
- New zoom controls: 1:1 button, zoom percentage label
16+
- Touch pinch-to-zoom support with proper pan transition
17+
- Double-click to fit diagram
18+
19+
### Bug Fixes
20+
- Fixed touch pinch→pan transition (reset start coords after pinch ends)
21+
- Removed dead `fitZoom` variable from previous implementation
22+
- Removed 12 lines of dead scrollbar CSS (no longer needed with new viewport approach)
23+
24+
### Documentation
25+
- Updated `css-patterns.md` with new multi-diagram structure and JavaScript pattern
26+
- Simplified Mermaid section to reference `mermaid-flowchart.html` as canonical source
27+
328
## [0.5.1] - 2026-03-05
429

530
### Claude Code Marketplace Structure

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "visual-explainer",
3-
"version": "0.5.1",
3+
"version": "0.6.0",
44
"description": "Agent skill that generates beautiful HTML pages for diagrams, diff reviews, plan reviews, and data tables",
55
"keywords": [
66
"claude-code-plugin",

plugins/visual-explainer/references/css-patterns.md

Lines changed: 110 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -528,32 +528,14 @@ Add zoom controls to every `.mermaid-wrap` container for complex diagrams.
528528
align-items: center;
529529
/* Prevent vertical flowcharts from compressing into unreadable thumbnails */
530530
min-height: 400px;
531-
scrollbar-width: thin;
532-
scrollbar-color: var(--border) transparent;
533531
}
534-
.mermaid-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
535-
.mermaid-wrap::-webkit-scrollbar-track { background: transparent; }
536-
.mermaid-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
537-
.mermaid-wrap::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
538532

539533
/* For shorter diagrams that don't need the full height */
540534
.mermaid-wrap--compact { min-height: 200px; }
541535

542536
/* For very tall vertical flowcharts */
543537
.mermaid-wrap--tall { min-height: 600px; }
544538

545-
.mermaid-wrap .mermaid {
546-
/* Use CSS zoom instead of transform: scale().
547-
Zoom changes actual layout size, so overflow scrolls normally in all directions.
548-
Transform only changes visual appearance — content expanding upward/leftward
549-
goes into negative space which can't be scrolled to.
550-
Supported in all browsers (Firefox added support in v126, June 2024).
551-
Note: zoom is not animatable, so no transition. */
552-
/* Optional: start at >1 for complex diagrams that render too small.
553-
The diagram stays centered, renders larger, and zoom controls still work. */
554-
zoom: 1.4;
555-
}
556-
557539
.zoom-controls {
558540
position: absolute;
559541
top: 8px;
@@ -590,138 +572,136 @@ Add zoom controls to every `.mermaid-wrap` container for complex diagrams.
590572

591573
.mermaid-wrap { cursor: grab; }
592574
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
593-
```
594575

595-
**Why zoom instead of transform?**
576+
/* Multi-diagram structure */
577+
.diagram-shell {
578+
position: relative;
579+
}
580+
581+
.diagram-shell__hint {
582+
font-family: var(--font-mono);
583+
font-size: 11px;
584+
color: var(--text-dim);
585+
margin-bottom: 8px;
586+
opacity: 0.7;
587+
}
588+
589+
.mermaid-viewport {
590+
position: relative;
591+
overflow: hidden;
592+
width: 100%;
593+
height: 100%;
594+
min-height: 300px;
595+
}
596+
597+
.mermaid-canvas {
598+
position: absolute;
599+
top: 0;
600+
left: 0;
601+
}
602+
603+
.zoom-label {
604+
font-family: var(--font-mono);
605+
font-size: 10px;
606+
color: var(--text-dim);
607+
padding: 0 6px;
608+
white-space: nowrap;
609+
}
610+
```
596611

597-
CSS `transform: scale()` only changes visual appearance — the element's layout box stays the same size. When you scale from `center center`, content expands upward and leftward into negative coordinate space. Scroll containers can't scroll to negative positions, so the top and left of the zoomed content get clipped.
612+
**How the new zoom/pan engine works:**
598613

599-
CSS `zoom` actually changes the element's layout size. The content grows downward and rightward like any other growing element, staying fully scrollable.
614+
The SVG is rendered into `.mermaid-canvas` which is absolutely positioned inside `.mermaid-viewport`. Zooming sets the SVG's `width` and `height` styles directly. Panning applies `transform: translate()` to the canvas. The viewport has `overflow: hidden` to clip the panned content. This approach avoids CSS `zoom` (which had cross-browser quirks) and gives precise control over the diagram's size and position.
600615

601616
### HTML
602617

603618
```html
604-
<div class="mermaid-wrap">
605-
<div class="zoom-controls">
606-
<button onclick="zoomDiagram(this, 1.2)" title="Zoom in">+</button>
607-
<button onclick="zoomDiagram(this, 0.8)" title="Zoom out">&minus;</button>
608-
<button onclick="resetZoom(this)" title="Reset zoom">&#8634;</button>
609-
<button onclick="openDiagramFullscreen(this)" title="Open full size in new tab">&#x26F6;</button>
619+
<section class="diagram-shell">
620+
<p class="diagram-shell__hint">
621+
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
622+
</p>
623+
<div class="mermaid-wrap">
624+
<div class="zoom-controls">
625+
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
626+
<button type="button" data-action="zoom-out" title="Zoom out">&minus;</button>
627+
<button type="button" data-action="zoom-fit" title="Smart fit">&#8634;</button>
628+
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
629+
<button type="button" data-action="zoom-expand" title="Open full size">&#x26F6;</button>
630+
<span class="zoom-label">Loading...</span>
631+
</div>
632+
<div class="mermaid-viewport">
633+
<div class="mermaid mermaid-canvas"></div>
634+
</div>
610635
</div>
611-
<pre class="mermaid">
636+
<script type="text/plain" class="diagram-source">
612637
graph TD
613638
A --> B
614-
</pre>
615-
</div>
639+
</script>
640+
</section>
616641
```
617642

618-
**Click to expand.** Clicking anywhere on the diagram (without dragging) opens it full-size in a new tab. The expand button (⛶) in the zoom controls does the same thing.
643+
Use one `.diagram-shell` per diagram. The source Mermaid text lives in `<script type="text/plain" class="diagram-source">`, so multiple diagrams can coexist on a page without ID collisions.
619644

620645
### JavaScript
621646

622-
Add once at the end of the page. Handles button clicks and scroll-to-zoom on all `.mermaid-wrap` containers:
647+
Use a closure-based initializer. Per-diagram state lives inside `initDiagram(shell)`, while shared drag listeners stay at module scope:
623648

624649
```javascript
625-
// Match this to the CSS zoom value (or 1 if not set)
626-
var INITIAL_ZOOM = 1.4;
627-
628-
function zoomDiagram(btn, factor) {
629-
var wrap = btn.closest('.mermaid-wrap');
630-
var target = wrap.querySelector('.mermaid');
631-
var current = parseFloat(target.dataset.zoom || INITIAL_ZOOM);
632-
var next = Math.min(Math.max(current * factor, 0.5), 5);
633-
target.dataset.zoom = next;
634-
target.style.zoom = next;
635-
}
636-
637-
function resetZoom(btn) {
638-
var wrap = btn.closest('.mermaid-wrap');
639-
var target = wrap.querySelector('.mermaid');
640-
target.dataset.zoom = INITIAL_ZOOM;
641-
target.style.zoom = INITIAL_ZOOM;
642-
}
643-
644-
function openDiagramFullscreen(btn) {
645-
var wrap = btn.closest('.mermaid-wrap');
646-
openMermaidInNewTab(wrap);
647-
}
648-
649-
function openMermaidInNewTab(wrap) {
650-
var svg = wrap.querySelector('.mermaid svg');
651-
if (!svg) return;
652-
653-
// Clone the SVG and remove any inline transforms from zoom
654-
var clone = svg.cloneNode(true);
655-
clone.style.zoom = '';
656-
clone.style.transform = '';
657-
658-
// Get computed styles for theming
659-
var styles = getComputedStyle(document.documentElement);
660-
var bg = styles.getPropertyValue('--bg').trim() || '#ffffff';
661-
662-
// Build standalone HTML page
663-
var html = '<!DOCTYPE html>' +
664-
'<html lang="en"><head><meta charset="UTF-8">' +
665-
'<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
666-
'<title>Diagram</title>' +
667-
'<style>' +
668-
'body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: ' + bg + '; padding: 40px; box-sizing: border-box; }' +
669-
'svg { max-width: 100%; max-height: 90vh; height: auto; }' +
670-
'</style></head><body>' +
671-
clone.outerHTML +
672-
'</body></html>';
673-
674-
var blob = new Blob([html], { type: 'text/html' });
675-
var url = URL.createObjectURL(blob);
676-
window.open(url, '_blank');
677-
}
678-
679-
document.querySelectorAll('.mermaid-wrap').forEach(function(wrap) {
680-
// Ctrl/Cmd + scroll to zoom
681-
wrap.addEventListener('wheel', function(e) {
682-
if (!e.ctrlKey && !e.metaKey) return;
683-
e.preventDefault();
684-
var target = wrap.querySelector('.mermaid');
685-
var current = parseFloat(target.dataset.zoom || INITIAL_ZOOM);
686-
var factor = e.deltaY < 0 ? 1.1 : 0.9;
687-
var next = Math.min(Math.max(current * factor, 0.5), 5);
688-
target.dataset.zoom = next;
689-
target.style.zoom = next;
690-
}, { passive: false });
691-
692-
// Click-and-drag to pan, click (without drag) to open full-size
693-
var startX, startY, scrollL, scrollT, startTime, didPan;
694-
wrap.addEventListener('mousedown', function(e) {
695-
if (e.target.closest('.zoom-controls')) return;
696-
wrap.classList.add('is-panning');
697-
startX = e.clientX;
698-
startY = e.clientY;
699-
scrollL = wrap.scrollLeft;
700-
scrollT = wrap.scrollTop;
701-
startTime = Date.now();
702-
didPan = false;
703-
});
704-
window.addEventListener('mousemove', function(e) {
705-
if (!wrap.classList.contains('is-panning')) return;
706-
var dx = e.clientX - startX;
707-
var dy = e.clientY - startY;
708-
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) didPan = true;
709-
wrap.scrollLeft = scrollL - dx;
710-
wrap.scrollTop = scrollT - dy;
711-
});
712-
window.addEventListener('mouseup', function() {
713-
if (!wrap.classList.contains('is-panning')) return;
714-
wrap.classList.remove('is-panning');
715-
// If click was quick and didn't move much, open full-size
716-
var elapsed = Date.now() - startTime;
717-
if (!didPan && elapsed < 300) {
718-
openMermaidInNewTab(wrap);
650+
const config = { /* fitPadding, zoom bounds, readabilityFloor */ };
651+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
652+
let activeDrag = null;
653+
654+
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
655+
addEventListener('mouseup', () => { activeDrag?.onEnd(); activeDrag = null; });
656+
657+
function initDiagram(shell) {
658+
const wrap = shell.querySelector('.mermaid-wrap');
659+
const viewport = shell.querySelector('.mermaid-viewport');
660+
const canvas = shell.querySelector('.mermaid-canvas');
661+
const source = shell.querySelector('.diagram-source');
662+
const label = shell.querySelector('.zoom-label');
663+
664+
if (!wrap || !viewport || !canvas || !source || !label) {
665+
console.error('initDiagram: missing required elements in', shell);
666+
return;
667+
}
668+
669+
// Per-diagram state in closure
670+
let zoom = 1;
671+
let fitMode = 'contain';
672+
let panX = 0;
673+
let panY = 0;
674+
let svgW = 0;
675+
let svgH = 0;
676+
677+
async function render() {
678+
try {
679+
const code = source.textContent.trim();
680+
if (!code) {
681+
label.textContent = 'Error: Empty source';
682+
return;
683+
}
684+
685+
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
686+
const { svg } = await mermaid.render(id, code);
687+
canvas.innerHTML = svg;
688+
689+
// readSvgNaturalSize(svgNode) + setAdaptiveHeight() + fitDiagram()
690+
// wire controls from data-action attributes
691+
// wire wheel/drag/touch handlers scoped to this shell
692+
} catch (err) {
693+
console.error('Mermaid render failed:', err);
694+
label.textContent = 'Error: ' + (err.message || 'Render failed');
719695
}
720-
});
721-
});
696+
}
697+
698+
render();
699+
}
700+
701+
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
722702
```
723703
724-
Scroll-to-zoom requires Ctrl/Cmd+scroll to avoid hijacking normal page scroll. Cursor changes to `grab`/`grabbing` to signal pan mode. The zoom range is capped at 0.5x–5x. **Clicking without dragging opens the diagram full-size in a new browser tab.**
704+
This pattern removes all hardcoded IDs and supports unlimited diagrams per page. For the full implementation (including smart fit, pinch zoom, and shared drag state), use `templates/mermaid-flowchart.html` as the canonical source.
725705
726706
## Grid Layouts
727707

0 commit comments

Comments
 (0)