@the-forge-flow/visual-explainer-pi 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/templates/mermaid.d.ts +23 -1
- package/dist/templates/mermaid.d.ts.map +1 -1
- package/dist/templates/mermaid.js +79 -67
- package/dist/templates/shared.d.ts +2 -2
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +408 -69
- package/package.json +1 -1
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAgKlE;;GAEG;AACH,MAAM,CAAC,OAAO,UAAU,wBAAwB,CAAC,EAAE,EAAE,YAAY,QAiJhE"}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Based on nicobailon/visual-explainer design principles.
|
|
8
8
|
*/
|
|
9
9
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
10
|
-
import { defineTool
|
|
10
|
+
import { defineTool } from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import { Type } from "@sinclair/typebox";
|
|
12
12
|
import { generateArchitectureTemplate } from "./templates/architecture.js";
|
|
13
13
|
import { generateTableTemplate } from "./templates/data-table.js";
|
|
@@ -81,14 +81,16 @@ async function generateVisual(params, pi) {
|
|
|
81
81
|
default:
|
|
82
82
|
throw new Error(`Unsupported visual type: ${params.type}`);
|
|
83
83
|
}
|
|
84
|
-
// Safety check: truncate if too large
|
|
85
|
-
const truncated = truncateHead(html, { maxBytes: 50000, maxLines: 2000 });
|
|
86
84
|
// Generate filename
|
|
87
85
|
const filename = params.filename
|
|
88
86
|
? sanitizeFilename(params.filename)
|
|
89
87
|
: generateDefaultFilename(params.title);
|
|
90
|
-
// Write
|
|
91
|
-
|
|
88
|
+
// Write the full HTML to disk. We intentionally do NOT run the pi
|
|
89
|
+
// truncateHead helper here — that utility clips LLM-facing tool output,
|
|
90
|
+
// not files on disk. Cutting the HTML at an arbitrary byte boundary would
|
|
91
|
+
// leave unclosed tags/scripts and silently corrupt every non-trivial
|
|
92
|
+
// diagram. See bug report: "mermaid graphs are always bugged".
|
|
93
|
+
const filePath = await writeHtmlFile(filename, html, state);
|
|
92
94
|
// Open in browser
|
|
93
95
|
await openInBrowser(filePath, pi);
|
|
94
96
|
// Generate preview snippet (first 500 chars of content summary)
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mermaid template for flowcharts, sequence diagrams, ER diagrams, etc.
|
|
3
|
-
* Based on nicobailon/visual-explainer mermaid-flowchart.html
|
|
3
|
+
* Based on nicobailon/visual-explainer mermaid-flowchart.html.
|
|
4
|
+
*
|
|
5
|
+
* Key invariants (each one exists because it was a real bug):
|
|
6
|
+
* 1. Mermaid source is placed inside `<script type="text/plain" class="diagram-source">`
|
|
7
|
+
* — the HTML parser treats this as opaque data, so `<br/>`, `<`, `>`,
|
|
8
|
+
* `&` in the source survive untouched. Never use `<pre class="mermaid">`.
|
|
9
|
+
* 2. Rendering is explicit via `mermaid.render(id, code)` with
|
|
10
|
+
* `startOnLoad: false`. This avoids the timing race between "mermaid
|
|
11
|
+
* finished mutating the DOM" and "our zoom/pan handlers bind".
|
|
12
|
+
* 3. `themeVariables.fontFamily` uses a concrete font stack, not a CSS
|
|
13
|
+
* custom property. Mermaid bakes the value into SVG inline styles and
|
|
14
|
+
* CSS vars don't resolve when the SVG is exported to a new tab.
|
|
15
|
+
* 4. The new-tab export reads the page background from a `data-bg`
|
|
16
|
+
* attribute on `.mermaid-wrap` so the exported view matches the
|
|
17
|
+
* baked-in SVG colors.
|
|
4
18
|
*/
|
|
5
19
|
import type { Aesthetic } from "../types.js";
|
|
6
20
|
export interface MermaidContent {
|
|
@@ -8,4 +22,12 @@ export interface MermaidContent {
|
|
|
8
22
|
caption?: string;
|
|
9
23
|
}
|
|
10
24
|
export declare function generateMermaidTemplate(title: string, content: MermaidContent, aesthetic: Aesthetic, isDark: boolean): string;
|
|
25
|
+
/**
|
|
26
|
+
* Escape mermaid source for safe inclusion in a `<script type="text/plain">`.
|
|
27
|
+
* Browsers parse a script element's contents only looking for `</script>` —
|
|
28
|
+
* so we only need to neutralize the literal sequence `</script` anywhere in
|
|
29
|
+
* the source. Everything else (HTML entities, `<br/>`, `<`, `>`, `&`) passes
|
|
30
|
+
* through untouched, which is exactly what Mermaid wants.
|
|
31
|
+
*/
|
|
32
|
+
export declare function escapeMermaidSource(source: string): string;
|
|
11
33
|
//# sourceMappingURL=mermaid.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../../src/templates/mermaid.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../../src/templates/mermaid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAW7C,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,uBAAuB,CACtC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,OAAO,GACb,MAAM,CAgBR;AAoCD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1D"}
|
|
@@ -1,64 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mermaid template for flowcharts, sequence diagrams, ER diagrams, etc.
|
|
3
|
-
* Based on nicobailon/visual-explainer mermaid-flowchart.html
|
|
3
|
+
* Based on nicobailon/visual-explainer mermaid-flowchart.html.
|
|
4
|
+
*
|
|
5
|
+
* Key invariants (each one exists because it was a real bug):
|
|
6
|
+
* 1. Mermaid source is placed inside `<script type="text/plain" class="diagram-source">`
|
|
7
|
+
* — the HTML parser treats this as opaque data, so `<br/>`, `<`, `>`,
|
|
8
|
+
* `&` in the source survive untouched. Never use `<pre class="mermaid">`.
|
|
9
|
+
* 2. Rendering is explicit via `mermaid.render(id, code)` with
|
|
10
|
+
* `startOnLoad: false`. This avoids the timing race between "mermaid
|
|
11
|
+
* finished mutating the DOM" and "our zoom/pan handlers bind".
|
|
12
|
+
* 3. `themeVariables.fontFamily` uses a concrete font stack, not a CSS
|
|
13
|
+
* custom property. Mermaid bakes the value into SVG inline styles and
|
|
14
|
+
* CSS vars don't resolve when the SVG is exported to a new tab.
|
|
15
|
+
* 4. The new-tab export reads the page background from a `data-bg`
|
|
16
|
+
* attribute on `.mermaid-wrap` so the exported view matches the
|
|
17
|
+
* baked-in SVG colors.
|
|
4
18
|
*/
|
|
5
19
|
import { FONT_PAIRINGS, MERMAID_SHELL_CSS, MERMAID_SHELL_JS, PALETTES, escapeHtml, generateCSSVariables, generateHtmlShell, } from "./shared.js";
|
|
6
20
|
export function generateMermaidTemplate(title, content, aesthetic, isDark) {
|
|
7
21
|
const palette = isDark ? PALETTES[aesthetic].dark : PALETTES[aesthetic].light;
|
|
8
22
|
const fonts = FONT_PAIRINGS[aesthetic];
|
|
9
23
|
const cssVars = generateCSSVariables(palette, fonts, isDark);
|
|
10
|
-
const
|
|
11
|
-
const bodyContent = generateBody(title, content);
|
|
12
|
-
return generateHtmlShell(title, bodyContent, aesthetic, cssVars, `${MERMAID_SHELL_CSS}${getExtraCSS()}`, getMermaidScript(
|
|
24
|
+
const themeConfig = generateMermaidThemeConfig(palette, fonts);
|
|
25
|
+
const bodyContent = generateBody(title, content, palette.bg);
|
|
26
|
+
return generateHtmlShell(title, bodyContent, aesthetic, cssVars, `${MERMAID_SHELL_CSS}${getExtraCSS()}`, getMermaidScript(themeConfig));
|
|
13
27
|
}
|
|
14
|
-
function
|
|
15
|
-
//
|
|
28
|
+
function generateMermaidThemeConfig(palette, fonts) {
|
|
29
|
+
// IMPORTANT: use concrete font strings — not `var(--font-body)` — because
|
|
30
|
+
// Mermaid bakes the value into SVG attributes, and CSS custom properties
|
|
31
|
+
// don't resolve inside an extracted SVG (see openInNewTab).
|
|
32
|
+
const fontFamily = fonts.body.replace(/'/g, "\\'");
|
|
16
33
|
return `
|
|
17
34
|
theme: 'base',
|
|
35
|
+
look: 'classic',
|
|
36
|
+
layout: 'elk',
|
|
18
37
|
themeVariables: {
|
|
38
|
+
fontFamily: '${fontFamily}',
|
|
39
|
+
fontSize: '16px',
|
|
19
40
|
primaryColor: '${palette.surface}',
|
|
20
41
|
primaryTextColor: '${palette.text}',
|
|
21
42
|
primaryBorderColor: '${palette.accent}',
|
|
22
43
|
lineColor: '${palette.textDim}',
|
|
23
44
|
secondaryColor: '${palette.surface2}',
|
|
45
|
+
secondaryBorderColor: '${palette.teal ?? palette.accent}',
|
|
46
|
+
secondaryTextColor: '${palette.text}',
|
|
24
47
|
tertiaryColor: '${palette.surfaceElevated}',
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
tertiaryBorderColor: '${palette.orange ?? palette.accent}',
|
|
49
|
+
tertiaryTextColor: '${palette.text}',
|
|
50
|
+
noteBkgColor: '${palette.surfaceElevated}',
|
|
51
|
+
noteTextColor: '${palette.text}',
|
|
52
|
+
noteBorderColor: '${palette.accent}'
|
|
27
53
|
},
|
|
28
|
-
flowchart: {
|
|
29
|
-
|
|
30
|
-
htmlLabels: true,
|
|
31
|
-
curve: 'basis'
|
|
32
|
-
},
|
|
33
|
-
sequence: {
|
|
34
|
-
useMaxWidth: true,
|
|
35
|
-
diagramMarginX: 50,
|
|
36
|
-
diagramMarginY: 10
|
|
37
|
-
}
|
|
54
|
+
flowchart: { useMaxWidth: false, htmlLabels: true, curve: 'basis' },
|
|
55
|
+
sequence: { useMaxWidth: false, diagramMarginX: 50, diagramMarginY: 10 }
|
|
38
56
|
`;
|
|
39
57
|
}
|
|
40
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Escape mermaid source for safe inclusion in a `<script type="text/plain">`.
|
|
60
|
+
* Browsers parse a script element's contents only looking for `</script>` —
|
|
61
|
+
* so we only need to neutralize the literal sequence `</script` anywhere in
|
|
62
|
+
* the source. Everything else (HTML entities, `<br/>`, `<`, `>`, `&`) passes
|
|
63
|
+
* through untouched, which is exactly what Mermaid wants.
|
|
64
|
+
*/
|
|
65
|
+
export function escapeMermaidSource(source) {
|
|
66
|
+
return source.replace(/<\/script/gi, "<\\/script");
|
|
67
|
+
}
|
|
68
|
+
function generateBody(title, content, bg) {
|
|
41
69
|
const captionHtml = content.caption
|
|
42
70
|
? `<p class="caption">${escapeHtml(content.caption)}</p>`
|
|
43
71
|
: "";
|
|
72
|
+
const safeSource = escapeMermaidSource(content.mermaidSyntax);
|
|
73
|
+
// Escape `"` in the bg value for the attribute — palette values are
|
|
74
|
+
// hex/rgba, but belt-and-braces.
|
|
75
|
+
const bgAttr = bg.replace(/"/g, """);
|
|
44
76
|
return `
|
|
45
77
|
<div class="container">
|
|
46
78
|
<h1>${escapeHtml(title)}</h1>
|
|
47
|
-
<
|
|
48
|
-
<
|
|
79
|
+
<section class="diagram-shell">
|
|
80
|
+
<p class="diagram-shell__hint">
|
|
81
|
+
Ctrl/Cmd + wheel to zoom. Drag to pan when zoomed. Double-click to fit.
|
|
82
|
+
</p>
|
|
83
|
+
<div class="mermaid-wrap" data-bg="${bgAttr}">
|
|
49
84
|
<div class="zoom-controls">
|
|
50
|
-
<button
|
|
51
|
-
<button
|
|
52
|
-
<button
|
|
53
|
-
<button
|
|
85
|
+
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
|
|
86
|
+
<button type="button" data-action="zoom-out" title="Zoom out">−</button>
|
|
87
|
+
<button type="button" data-action="zoom-fit" title="Smart fit">↺</button>
|
|
88
|
+
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
|
|
89
|
+
<button type="button" data-action="zoom-expand" title="Open full size">⛶</button>
|
|
90
|
+
<span class="zoom-label">Loading…</span>
|
|
54
91
|
</div>
|
|
55
92
|
<div class="mermaid-viewport">
|
|
56
|
-
<div class="mermaid-canvas">
|
|
57
|
-
<pre class="mermaid">${content.mermaidSyntax}</pre>
|
|
58
|
-
</div>
|
|
93
|
+
<div class="mermaid mermaid-canvas"></div>
|
|
59
94
|
</div>
|
|
60
95
|
</div>
|
|
61
|
-
|
|
96
|
+
<script type="text/plain" class="diagram-source">
|
|
97
|
+
${safeSource}
|
|
98
|
+
</script>
|
|
99
|
+
</section>
|
|
62
100
|
${captionHtml}
|
|
63
101
|
</div>
|
|
64
102
|
`;
|
|
@@ -93,57 +131,31 @@ h1 {
|
|
|
93
131
|
text-align: center;
|
|
94
132
|
}
|
|
95
133
|
|
|
96
|
-
/* Mermaid overrides for theming */
|
|
97
|
-
.mermaid .node rect,
|
|
98
|
-
.mermaid .node circle,
|
|
99
|
-
.mermaid .node ellipse,
|
|
100
|
-
.mermaid .node polygon {
|
|
101
|
-
fill: var(--surface) !important;
|
|
102
|
-
stroke: var(--accent) !important;
|
|
103
|
-
stroke-width: 2px !important;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.mermaid .node .label {
|
|
107
|
-
color: var(--text) !important;
|
|
108
|
-
font-family: var(--font-mono) !important;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.mermaid .edgePath .path {
|
|
112
|
-
stroke: var(--text-dim) !important;
|
|
113
|
-
stroke-width: 2px !important;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
.mermaid .edgeLabel {
|
|
117
|
-
background: var(--surface) !important;
|
|
118
|
-
color: var(--text) !important;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/* Responsive */
|
|
122
134
|
@media (max-width: 768px) {
|
|
123
135
|
body { padding: 20px; }
|
|
124
136
|
h1 { font-size: 24px; }
|
|
125
137
|
}
|
|
126
138
|
`;
|
|
127
139
|
}
|
|
128
|
-
function getMermaidScript(themeConfig
|
|
140
|
+
function getMermaidScript(themeConfig) {
|
|
129
141
|
return `
|
|
130
142
|
<script type="module">
|
|
131
|
-
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@
|
|
132
|
-
|
|
143
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
144
|
+
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
|
|
145
|
+
|
|
146
|
+
mermaid.registerLayoutLoaders(elkLayouts);
|
|
133
147
|
mermaid.initialize({
|
|
134
|
-
startOnLoad:
|
|
148
|
+
startOnLoad: false,
|
|
135
149
|
securityLevel: 'loose',
|
|
136
150
|
${themeConfig}
|
|
137
151
|
});
|
|
138
152
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
146
|
-
}, 500);
|
|
153
|
+
// Expose on window so the shell JS (classic script, IIFE) can await it.
|
|
154
|
+
window.mermaid = mermaid;
|
|
155
|
+
window.dispatchEvent(new Event('mermaid-ready'));
|
|
156
|
+
</script>
|
|
157
|
+
<script>
|
|
158
|
+
${MERMAID_SHELL_JS}
|
|
147
159
|
</script>
|
|
148
160
|
`;
|
|
149
161
|
}
|
|
@@ -9,8 +9,8 @@ export declare const PALETTES: Record<Aesthetic, {
|
|
|
9
9
|
dark: Palette;
|
|
10
10
|
}>;
|
|
11
11
|
export declare const SHARED_CSS = "\n/* Reset */\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\n/* Animation keyframes */\n@keyframes fadeUp {\n from { opacity: 0; transform: translateY(12px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@keyframes fadeScale {\n from { opacity: 0; transform: scale(0.95); }\n to { opacity: 1; transform: scale(1); }\n}\n\n/* Reduced motion */\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-delay: 0ms !important;\n transition-duration: 0.01ms !important;\n }\n}\n\n/* Base styles */\nbody {\n min-height: 100vh;\n line-height: 1.6;\n}\n\n/* Section card base */\n.section {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 20px 24px;\n animation: fadeUp 0.4s ease-out both;\n animation-delay: calc(var(--i, 0) * 0.06s);\n}\n\n.section--hero {\n background: var(--surface-elevated);\n border-color: color-mix(in srgb, var(--border) 50%, var(--accent) 50%);\n box-shadow: 0 4px 20px rgba(0,0,0,0.06);\n padding: 28px 32px;\n}\n\n.section--recessed {\n background: var(--surface2);\n box-shadow: inset 0 1px 3px rgba(0,0,0,0.04);\n}\n\n/* Section labels with dot indicators */\n.section-label {\n font-family: var(--font-mono);\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n margin-bottom: 16px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.section-label .dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n}\n\n/* Flow arrows */\n.flow-arrow {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 8px;\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 12px;\n padding: 4px 0;\n animation: fadeUp 0.4s ease-out both;\n animation-delay: calc(var(--i, 0) * 0.06s);\n}\n\n.flow-arrow svg {\n width: 20px;\n height: 20px;\n fill: none;\n stroke: var(--border-bright);\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n/* Code styling */\ncode {\n font-family: var(--font-mono);\n font-size: 0.9em;\n background: var(--accent-dim);\n color: var(--accent);\n padding: 2px 6px;\n border-radius: 4px;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n body { padding: 20px; }\n .section { padding: 16px 20px; }\n .section--hero { padding: 20px 24px; }\n}\n";
|
|
12
|
-
export declare const MERMAID_SHELL_CSS = "\n.diagram-shell {\n
|
|
13
|
-
export declare const MERMAID_SHELL_JS = "\nfunction initMermaidControls(wrap) {\n const viewport = wrap.querySelector('.mermaid-viewport');\n const canvas = wrap.querySelector('.mermaid-canvas');\n let scale = 1;\n let isDragging = false;\n let startX, startY, translateX = 0, translateY = 0;\n\n function updateTransform() {\n canvas.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;\n }\n\n wrap.querySelector('[data-zoom=\"in\"]').onclick = () => { scale *= 1.2; updateTransform(); };\n wrap.querySelector('[data-zoom=\"out\"]').onclick = () => { scale /= 1.2; updateTransform(); };\n wrap.querySelector('[data-zoom=\"reset\"]').onclick = () => { scale = 1; translateX = 0; translateY = 0; updateTransform(); };\n wrap.querySelector('[data-zoom=\"expand\"]').onclick = () => openMermaidInNewTab(wrap);\n\n viewport.addEventListener('mousedown', (e) => {\n isDragging = true;\n startX = e.clientX - translateX;\n startY = e.clientY - translateY;\n viewport.style.cursor = 'grabbing';\n });\n\n window.addEventListener('mousemove', (e) => {\n if (!isDragging) return;\n translateX = e.clientX - startX;\n translateY = e.clientY - startY;\n updateTransform();\n });\n\n window.addEventListener('mouseup', () => {\n isDragging = false;\n viewport.style.cursor = 'grab';\n });\n\n viewport.addEventListener('wheel', (e) => {\n if (e.ctrlKey || e.metaKey) {\n e.preventDefault();\n scale *= e.deltaY > 0 ? 0.9 : 1.1;\n updateTransform();\n }\n }, { passive: false });\n}\n\nfunction openMermaidInNewTab(wrap) {\n const svg = wrap.querySelector('svg');\n if (!svg) return;\n const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });\n const url = URL.createObjectURL(blob);\n window.open(url, '_blank');\n}\n";
|
|
12
|
+
export declare const MERMAID_SHELL_CSS = "\n.diagram-shell {\n position: relative;\n margin-bottom: 24px;\n}\n\n.diagram-shell__hint {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 8px;\n opacity: 0.7;\n}\n\n.mermaid-wrap {\n position: relative;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n min-height: 360px;\n}\n\n.mermaid-viewport {\n position: relative;\n overflow: hidden;\n width: 100%;\n height: 100%;\n min-height: 300px;\n cursor: grab;\n}\n\n.mermaid-wrap.is-panning .mermaid-viewport {\n cursor: grabbing;\n user-select: none;\n}\n\n.mermaid-canvas {\n position: absolute;\n top: 0;\n left: 0;\n transform-origin: 0 0;\n}\n\n.mermaid-canvas svg {\n display: block;\n max-width: none;\n}\n\n.zoom-controls {\n position: absolute;\n top: 8px;\n right: 8px;\n display: flex;\n align-items: center;\n gap: 2px;\n z-index: 10;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 2px 4px;\n}\n\n.zoom-controls button {\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 14px;\n cursor: pointer;\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n.zoom-controls button:hover {\n background: var(--border);\n color: var(--text);\n}\n\n.zoom-label {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 0 6px;\n white-space: nowrap;\n}\n\n.mermaid .nodeLabel {\n font-family: var(--font-body) !important;\n}\n\n.mermaid .edgeLabel {\n font-family: var(--font-mono) !important;\n}\n";
|
|
13
|
+
export declare const MERMAID_SHELL_JS = "\n(function initAllDiagrams() {\n const config = {\n fitPadding: 24,\n minHeight: 360,\n maxHeightPx: 960,\n maxHeightVh: 0.84,\n maxInitialZoom: 1.8,\n minZoom: 0.08,\n maxZoom: 6.5,\n zoomStep: 0.14,\n readabilityFloor: 0.58\n };\n\n const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\n let activeDrag = null;\n\n addEventListener('mousemove', (e) => activeDrag && activeDrag.onMove(e));\n addEventListener('mouseup', () => {\n if (activeDrag) activeDrag.onEnd();\n activeDrag = null;\n });\n\n function initDiagram(shell) {\n const wrap = shell.querySelector('.mermaid-wrap');\n const viewport = shell.querySelector('.mermaid-viewport');\n const canvas = shell.querySelector('.mermaid-canvas');\n const source = shell.querySelector('.diagram-source');\n const label = shell.querySelector('.zoom-label');\n if (!wrap || !viewport || !canvas || !source || !label) {\n console.error('visual-explainer: missing required elements in', shell);\n return;\n }\n\n let zoom = 1;\n let fitMode = 'contain';\n let panX = 0;\n let panY = 0;\n let svgW = 0;\n let svgH = 0;\n let sx = 0;\n let sy = 0;\n let spx = 0;\n let spy = 0;\n let touchDist = 0;\n let touchCx = 0;\n let touchCy = 0;\n\n function constrainPan() {\n const vpW = viewport.clientWidth;\n const vpH = viewport.clientHeight;\n const rW = svgW * zoom;\n const rH = svgH * zoom;\n const pad = config.fitPadding;\n panX = (rW + pad * 2 <= vpW) ? (vpW - rW) / 2 : clamp(panX, vpW - rW - pad, pad);\n panY = (rH + pad * 2 <= vpH) ? (vpH - rH) / 2 : clamp(panY, vpH - rH - pad, pad);\n }\n\n function applyTransform() {\n const svg = canvas.querySelector('svg');\n if (!svg || !svgW) return;\n constrainPan();\n svg.style.width = (svgW * zoom) + 'px';\n svg.style.height = (svgH * zoom) + 'px';\n canvas.style.transform = 'translate(' + panX + 'px, ' + panY + 'px)';\n label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;\n }\n\n function canPan() {\n const rW = svgW * zoom;\n const rH = svgH * zoom;\n return rW + config.fitPadding * 2 > viewport.clientWidth\n || rH + config.fitPadding * 2 > viewport.clientHeight;\n }\n\n function computeSmartFit() {\n const vpW = viewport.clientWidth;\n const vpH = viewport.clientHeight;\n const aW = Math.max(80, vpW - config.fitPadding * 2);\n const aH = Math.max(80, vpH - config.fitPadding * 2);\n const contain = Math.min(aW / svgW, aH / svgH);\n let z = contain;\n let mode = 'contain';\n if (contain < config.readabilityFloor) {\n const chartR = svgH / svgW;\n const vpR = vpH / Math.max(vpW, 1);\n if (chartR >= vpR) { z = aW / svgW; mode = 'width-priority'; }\n else { z = aH / svgH; mode = 'height-priority'; }\n }\n return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };\n }\n\n function fitDiagram() {\n if (!svgW) return;\n const fit = computeSmartFit();\n zoom = fit.zoom;\n fitMode = fit.mode;\n panX = (viewport.clientWidth - svgW * zoom) / 2;\n panY = (viewport.clientHeight - svgH * zoom) / 2;\n applyTransform();\n }\n\n function setOneToOne() {\n zoom = clamp(1, config.minZoom, config.maxZoom);\n fitMode = '1:1';\n panX = (viewport.clientWidth - svgW * zoom) / 2;\n panY = (viewport.clientHeight - svgH * zoom) / 2;\n applyTransform();\n }\n\n function zoomAround(factor, cx, cy) {\n const next = clamp(zoom * factor, config.minZoom, config.maxZoom);\n const ratio = next / zoom;\n panX = cx - ratio * (cx - panX);\n panY = cy - ratio * (cy - panY);\n zoom = next;\n fitMode = 'custom';\n applyTransform();\n }\n\n function readSvgNaturalSize(svg) {\n let w = 0;\n let h = 0;\n if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.width > 0) {\n w = svg.viewBox.baseVal.width;\n h = svg.viewBox.baseVal.height;\n }\n if (!w) {\n w = parseFloat(svg.getAttribute('width')) || 0;\n h = parseFloat(svg.getAttribute('height')) || 0;\n }\n if (!w) {\n try { const b = svg.getBBox(); w = b.width; h = b.height; } catch {}\n }\n if (!w) {\n const r = svg.getBoundingClientRect();\n w = r.width || 1000;\n h = r.height || 700;\n }\n if (!svg.getAttribute('viewBox')) {\n svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);\n }\n return { w, h };\n }\n\n function setAdaptiveHeight() {\n if (!svgW) return;\n const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);\n const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;\n const maxVp = Math.floor(innerHeight * config.maxHeightVh);\n const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));\n wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';\n }\n\n function openInNewTab() {\n const svg = canvas.querySelector('svg');\n if (!svg) return;\n const clone = svg.cloneNode(true);\n clone.style.width = '';\n clone.style.height = '';\n // Read the page background from the wrap's data-bg attribute (written\n // server-side from the current palette). This guarantees the new tab\n // matches the diagram's baked-in theme.\n const bg = wrap.getAttribute('data-bg') || '#ffffff';\n const html = '<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">'\n + '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">'\n + '<title>Diagram</title><style>'\n + 'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;'\n + 'background:' + bg + ';padding:40px;box-sizing:border-box}'\n + 'svg{max-width:100%;max-height:90vh;height:auto}'\n + '</style></head><body>' + clone.outerHTML + '</body></html>';\n open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');\n }\n\n function waitForMermaid() {\n return new Promise((resolve, reject) => {\n if (window.mermaid && typeof window.mermaid.render === 'function') {\n resolve(window.mermaid);\n return;\n }\n const started = Date.now();\n const timer = setInterval(() => {\n if (window.mermaid && typeof window.mermaid.render === 'function') {\n clearInterval(timer);\n resolve(window.mermaid);\n } else if (Date.now() - started > 10000) {\n clearInterval(timer);\n reject(new Error('Mermaid failed to load from CDN (10s timeout)'));\n }\n }, 50);\n });\n }\n\n async function render() {\n try {\n const code = (source.textContent || '').trim();\n if (!code) {\n label.textContent = 'Error: empty source';\n return;\n }\n const mermaid = await waitForMermaid();\n const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);\n const { svg } = await mermaid.render(id, code);\n canvas.innerHTML = svg;\n const svgNode = canvas.querySelector('svg');\n if (!svgNode) {\n label.textContent = 'Error: no SVG';\n return;\n }\n const size = readSvgNaturalSize(svgNode);\n svgW = size.w;\n svgH = size.h;\n svgNode.removeAttribute('width');\n svgNode.removeAttribute('height');\n svgNode.style.maxWidth = 'none';\n svgNode.style.display = 'block';\n setAdaptiveHeight();\n fitDiagram();\n } catch (err) {\n console.error('visual-explainer mermaid render failed:', err);\n label.textContent = 'Error: ' + (err && err.message ? err.message : 'render failed');\n canvas.innerHTML = '<pre style=\"padding:20px;color:#b00020;white-space:pre-wrap;font-family:monospace;font-size:12px\">'\n + String(err && err.message ? err.message : err).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]))\n + '</pre>';\n }\n }\n\n const actions = {\n 'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),\n 'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),\n 'zoom-fit': fitDiagram,\n 'zoom-one': setOneToOne,\n 'zoom-expand': openInNewTab\n };\n Object.keys(actions).forEach((action) => {\n const btn = wrap.querySelector('[data-action=\"' + action + '\"]');\n if (btn) btn.addEventListener('click', actions[action]);\n });\n\n viewport.addEventListener('dblclick', fitDiagram);\n\n viewport.addEventListener('wheel', (e) => {\n if (e.ctrlKey || e.metaKey) {\n e.preventDefault();\n const rect = viewport.getBoundingClientRect();\n const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);\n zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);\n return;\n }\n if (canPan()) {\n e.preventDefault();\n panX -= e.deltaX;\n panY -= e.deltaY;\n applyTransform();\n }\n }, { passive: false });\n\n viewport.addEventListener('mousedown', (e) => {\n if (e.target.closest('.zoom-controls') || !canPan()) return;\n wrap.classList.add('is-panning');\n sx = e.clientX;\n sy = e.clientY;\n spx = panX;\n spy = panY;\n e.preventDefault();\n activeDrag = {\n onMove: (ev) => {\n panX = spx + (ev.clientX - sx);\n panY = spy + (ev.clientY - sy);\n applyTransform();\n },\n onEnd: () => { wrap.classList.remove('is-panning'); }\n };\n });\n\n viewport.addEventListener('touchstart', (e) => {\n if (e.touches.length === 1) {\n sx = e.touches[0].clientX;\n sy = e.touches[0].clientY;\n spx = panX;\n spy = panY;\n } else if (e.touches.length === 2) {\n const dx = e.touches[0].clientX - e.touches[1].clientX;\n const dy = e.touches[0].clientY - e.touches[1].clientY;\n touchDist = Math.sqrt(dx * dx + dy * dy);\n const r = viewport.getBoundingClientRect();\n touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;\n touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;\n }\n }, { passive: true });\n\n viewport.addEventListener('touchmove', (e) => {\n if (e.touches.length === 1 && canPan()) {\n if (touchDist > 0) {\n sx = e.touches[0].clientX;\n sy = e.touches[0].clientY;\n spx = panX;\n spy = panY;\n touchDist = 0;\n }\n e.preventDefault();\n panX = spx + (e.touches[0].clientX - sx);\n panY = spy + (e.touches[0].clientY - sy);\n applyTransform();\n } else if (e.touches.length === 2 && touchDist > 0) {\n e.preventDefault();\n const dx = e.touches[0].clientX - e.touches[1].clientX;\n const dy = e.touches[0].clientY - e.touches[1].clientY;\n const d = Math.sqrt(dx * dx + dy * dy);\n zoomAround(d / touchDist, touchCx, touchCy);\n touchDist = d;\n }\n }, { passive: false });\n\n new ResizeObserver(() => {\n if (svgW) { setAdaptiveHeight(); fitDiagram(); }\n }).observe(wrap);\n\n render();\n }\n\n function bootstrap() {\n document.querySelectorAll('.diagram-shell').forEach(initDiagram);\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', bootstrap);\n } else {\n bootstrap();\n }\n})();\n";
|
|
14
14
|
export declare function generateCSSVariables(palette: Palette, fonts: FontPairing, isDark: boolean): string;
|
|
15
15
|
export declare function generateHtmlShell(title: string, bodyContent: string, aesthetic: string, cssVariables: string, extraHead?: string, extraScripts?: string): string;
|
|
16
16
|
export declare function escapeHtml(str: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/templates/shared.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGnE,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAiCxD,CAAC;AAGF,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAiVzE,CAAC;AAGF,eAAO,MAAM,UAAU,63EAgHtB,CAAC;
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/templates/shared.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGnE,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAiCxD,CAAC;AAGF,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAiVzE,CAAC;AAGF,eAAO,MAAM,UAAU,63EAgHtB,CAAC;AAkBF,eAAO,MAAM,iBAAiB,owDAmG7B,CAAC;AAKF,eAAO,MAAM,gBAAgB,+nXA2U5B,CAAC;AAGF,wBAAgB,oBAAoB,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,OAAO,GACb,MAAM,CA0BR;AAGD,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,SAAS,SAAK,EACd,YAAY,SAAK,GACf,MAAM,CAqBR;AAGD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C"}
|
package/dist/templates/shared.js
CHANGED
|
@@ -490,117 +490,456 @@ code {
|
|
|
490
490
|
.section--hero { padding: 20px 24px; }
|
|
491
491
|
}
|
|
492
492
|
`;
|
|
493
|
-
// Mermaid zoom/pan CSS and JS
|
|
493
|
+
// Mermaid zoom/pan CSS and JS.
|
|
494
|
+
//
|
|
495
|
+
// This is the proven pattern from nicobailon/visual-explainer's reference
|
|
496
|
+
// mermaid-flowchart.html. The earlier implementation had several subtle bugs:
|
|
497
|
+
// - mermaid source lived in <pre class="mermaid"> so the HTML parser would
|
|
498
|
+
// mangle `<br/>`, `<`, `>`, `&` before Mermaid ever saw the text
|
|
499
|
+
// - `startOnLoad: true` + a 500ms setTimeout to bind zoom controls is a
|
|
500
|
+
// race — on large diagrams the controls would never bind
|
|
501
|
+
// - the viewport had no height, so pan and fit were meaningless
|
|
502
|
+
// - new-tab export produced a blank page (bare SVG blob, no background)
|
|
503
|
+
//
|
|
504
|
+
// Now the source lives in a `<script type="text/plain" class="diagram-source">`
|
|
505
|
+
// which the HTML parser treats as opaque data; we render explicitly via
|
|
506
|
+
// `mermaid.render(id, code)` and await it before binding handlers. Adaptive
|
|
507
|
+
// height is computed from the SVG's intrinsic aspect ratio. The new-tab
|
|
508
|
+
// export wraps the SVG in a minimal HTML shell with the page background.
|
|
494
509
|
export const MERMAID_SHELL_CSS = `
|
|
495
510
|
.diagram-shell {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
511
|
+
position: relative;
|
|
512
|
+
margin-bottom: 24px;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.diagram-shell__hint {
|
|
516
|
+
font-family: var(--font-mono);
|
|
517
|
+
font-size: 11px;
|
|
518
|
+
color: var(--text-dim);
|
|
519
|
+
margin-bottom: 8px;
|
|
520
|
+
opacity: 0.7;
|
|
500
521
|
}
|
|
501
522
|
|
|
502
523
|
.mermaid-wrap {
|
|
503
524
|
position: relative;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
525
|
+
background: var(--surface);
|
|
526
|
+
border: 1px solid var(--border);
|
|
527
|
+
border-radius: 12px;
|
|
528
|
+
overflow: hidden;
|
|
529
|
+
min-height: 360px;
|
|
507
530
|
}
|
|
508
531
|
|
|
509
532
|
.mermaid-viewport {
|
|
533
|
+
position: relative;
|
|
510
534
|
overflow: hidden;
|
|
535
|
+
width: 100%;
|
|
536
|
+
height: 100%;
|
|
537
|
+
min-height: 300px;
|
|
511
538
|
cursor: grab;
|
|
512
539
|
}
|
|
513
540
|
|
|
514
|
-
.mermaid-viewport
|
|
541
|
+
.mermaid-wrap.is-panning .mermaid-viewport {
|
|
515
542
|
cursor: grabbing;
|
|
543
|
+
user-select: none;
|
|
516
544
|
}
|
|
517
545
|
|
|
518
546
|
.mermaid-canvas {
|
|
519
|
-
|
|
520
|
-
|
|
547
|
+
position: absolute;
|
|
548
|
+
top: 0;
|
|
549
|
+
left: 0;
|
|
550
|
+
transform-origin: 0 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.mermaid-canvas svg {
|
|
554
|
+
display: block;
|
|
555
|
+
max-width: none;
|
|
521
556
|
}
|
|
522
557
|
|
|
523
558
|
.zoom-controls {
|
|
524
559
|
position: absolute;
|
|
525
|
-
top:
|
|
526
|
-
right:
|
|
560
|
+
top: 8px;
|
|
561
|
+
right: 8px;
|
|
527
562
|
display: flex;
|
|
528
|
-
|
|
563
|
+
align-items: center;
|
|
564
|
+
gap: 2px;
|
|
529
565
|
z-index: 10;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
.zoom-btn {
|
|
533
|
-
width: 32px;
|
|
534
|
-
height: 32px;
|
|
566
|
+
background: var(--surface);
|
|
535
567
|
border: 1px solid var(--border);
|
|
536
568
|
border-radius: 6px;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
569
|
+
padding: 2px 4px;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.zoom-controls button {
|
|
573
|
+
width: 28px;
|
|
574
|
+
height: 28px;
|
|
575
|
+
border: none;
|
|
576
|
+
background: transparent;
|
|
577
|
+
color: var(--text-dim);
|
|
578
|
+
font-family: var(--font-mono);
|
|
579
|
+
font-size: 14px;
|
|
540
580
|
cursor: pointer;
|
|
581
|
+
border-radius: 4px;
|
|
541
582
|
display: flex;
|
|
542
583
|
align-items: center;
|
|
543
584
|
justify-content: center;
|
|
544
|
-
transition:
|
|
585
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
545
586
|
}
|
|
546
587
|
|
|
547
|
-
.zoom-
|
|
548
|
-
background: var(--
|
|
549
|
-
|
|
588
|
+
.zoom-controls button:hover {
|
|
589
|
+
background: var(--border);
|
|
590
|
+
color: var(--text);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
.zoom-label {
|
|
594
|
+
font-family: var(--font-mono);
|
|
595
|
+
font-size: 10px;
|
|
596
|
+
color: var(--text-dim);
|
|
597
|
+
padding: 0 6px;
|
|
598
|
+
white-space: nowrap;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.mermaid .nodeLabel {
|
|
602
|
+
font-family: var(--font-body) !important;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.mermaid .edgeLabel {
|
|
606
|
+
font-family: var(--font-mono) !important;
|
|
550
607
|
}
|
|
551
608
|
`;
|
|
609
|
+
// Vanilla IIFE — no module scope needed. Handles multiple `.diagram-shell`
|
|
610
|
+
// instances on the same page. Each instance has its own `<script
|
|
611
|
+
// type="text/plain" class="diagram-source">` holding the raw mermaid source.
|
|
552
612
|
export const MERMAID_SHELL_JS = `
|
|
553
|
-
function
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
613
|
+
(function initAllDiagrams() {
|
|
614
|
+
const config = {
|
|
615
|
+
fitPadding: 24,
|
|
616
|
+
minHeight: 360,
|
|
617
|
+
maxHeightPx: 960,
|
|
618
|
+
maxHeightVh: 0.84,
|
|
619
|
+
maxInitialZoom: 1.8,
|
|
620
|
+
minZoom: 0.08,
|
|
621
|
+
maxZoom: 6.5,
|
|
622
|
+
zoomStep: 0.14,
|
|
623
|
+
readabilityFloor: 0.58
|
|
624
|
+
};
|
|
563
625
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
wrap.querySelector('[data-zoom="reset"]').onclick = () => { scale = 1; translateX = 0; translateY = 0; updateTransform(); };
|
|
567
|
-
wrap.querySelector('[data-zoom="expand"]').onclick = () => openMermaidInNewTab(wrap);
|
|
626
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
627
|
+
let activeDrag = null;
|
|
568
628
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
viewport.style.cursor = 'grabbing';
|
|
629
|
+
addEventListener('mousemove', (e) => activeDrag && activeDrag.onMove(e));
|
|
630
|
+
addEventListener('mouseup', () => {
|
|
631
|
+
if (activeDrag) activeDrag.onEnd();
|
|
632
|
+
activeDrag = null;
|
|
574
633
|
});
|
|
575
634
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
635
|
+
function initDiagram(shell) {
|
|
636
|
+
const wrap = shell.querySelector('.mermaid-wrap');
|
|
637
|
+
const viewport = shell.querySelector('.mermaid-viewport');
|
|
638
|
+
const canvas = shell.querySelector('.mermaid-canvas');
|
|
639
|
+
const source = shell.querySelector('.diagram-source');
|
|
640
|
+
const label = shell.querySelector('.zoom-label');
|
|
641
|
+
if (!wrap || !viewport || !canvas || !source || !label) {
|
|
642
|
+
console.error('visual-explainer: missing required elements in', shell);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
582
645
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
646
|
+
let zoom = 1;
|
|
647
|
+
let fitMode = 'contain';
|
|
648
|
+
let panX = 0;
|
|
649
|
+
let panY = 0;
|
|
650
|
+
let svgW = 0;
|
|
651
|
+
let svgH = 0;
|
|
652
|
+
let sx = 0;
|
|
653
|
+
let sy = 0;
|
|
654
|
+
let spx = 0;
|
|
655
|
+
let spy = 0;
|
|
656
|
+
let touchDist = 0;
|
|
657
|
+
let touchCx = 0;
|
|
658
|
+
let touchCy = 0;
|
|
587
659
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
660
|
+
function constrainPan() {
|
|
661
|
+
const vpW = viewport.clientWidth;
|
|
662
|
+
const vpH = viewport.clientHeight;
|
|
663
|
+
const rW = svgW * zoom;
|
|
664
|
+
const rH = svgH * zoom;
|
|
665
|
+
const pad = config.fitPadding;
|
|
666
|
+
panX = (rW + pad * 2 <= vpW) ? (vpW - rW) / 2 : clamp(panX, vpW - rW - pad, pad);
|
|
667
|
+
panY = (rH + pad * 2 <= vpH) ? (vpH - rH) / 2 : clamp(panY, vpH - rH - pad, pad);
|
|
593
668
|
}
|
|
594
|
-
}, { passive: false });
|
|
595
|
-
}
|
|
596
669
|
|
|
597
|
-
function
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
670
|
+
function applyTransform() {
|
|
671
|
+
const svg = canvas.querySelector('svg');
|
|
672
|
+
if (!svg || !svgW) return;
|
|
673
|
+
constrainPan();
|
|
674
|
+
svg.style.width = (svgW * zoom) + 'px';
|
|
675
|
+
svg.style.height = (svgH * zoom) + 'px';
|
|
676
|
+
canvas.style.transform = 'translate(' + panX + 'px, ' + panY + 'px)';
|
|
677
|
+
label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function canPan() {
|
|
681
|
+
const rW = svgW * zoom;
|
|
682
|
+
const rH = svgH * zoom;
|
|
683
|
+
return rW + config.fitPadding * 2 > viewport.clientWidth
|
|
684
|
+
|| rH + config.fitPadding * 2 > viewport.clientHeight;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function computeSmartFit() {
|
|
688
|
+
const vpW = viewport.clientWidth;
|
|
689
|
+
const vpH = viewport.clientHeight;
|
|
690
|
+
const aW = Math.max(80, vpW - config.fitPadding * 2);
|
|
691
|
+
const aH = Math.max(80, vpH - config.fitPadding * 2);
|
|
692
|
+
const contain = Math.min(aW / svgW, aH / svgH);
|
|
693
|
+
let z = contain;
|
|
694
|
+
let mode = 'contain';
|
|
695
|
+
if (contain < config.readabilityFloor) {
|
|
696
|
+
const chartR = svgH / svgW;
|
|
697
|
+
const vpR = vpH / Math.max(vpW, 1);
|
|
698
|
+
if (chartR >= vpR) { z = aW / svgW; mode = 'width-priority'; }
|
|
699
|
+
else { z = aH / svgH; mode = 'height-priority'; }
|
|
700
|
+
}
|
|
701
|
+
return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function fitDiagram() {
|
|
705
|
+
if (!svgW) return;
|
|
706
|
+
const fit = computeSmartFit();
|
|
707
|
+
zoom = fit.zoom;
|
|
708
|
+
fitMode = fit.mode;
|
|
709
|
+
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
|
710
|
+
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
|
711
|
+
applyTransform();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function setOneToOne() {
|
|
715
|
+
zoom = clamp(1, config.minZoom, config.maxZoom);
|
|
716
|
+
fitMode = '1:1';
|
|
717
|
+
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
|
718
|
+
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
|
719
|
+
applyTransform();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function zoomAround(factor, cx, cy) {
|
|
723
|
+
const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
|
|
724
|
+
const ratio = next / zoom;
|
|
725
|
+
panX = cx - ratio * (cx - panX);
|
|
726
|
+
panY = cy - ratio * (cy - panY);
|
|
727
|
+
zoom = next;
|
|
728
|
+
fitMode = 'custom';
|
|
729
|
+
applyTransform();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function readSvgNaturalSize(svg) {
|
|
733
|
+
let w = 0;
|
|
734
|
+
let h = 0;
|
|
735
|
+
if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.width > 0) {
|
|
736
|
+
w = svg.viewBox.baseVal.width;
|
|
737
|
+
h = svg.viewBox.baseVal.height;
|
|
738
|
+
}
|
|
739
|
+
if (!w) {
|
|
740
|
+
w = parseFloat(svg.getAttribute('width')) || 0;
|
|
741
|
+
h = parseFloat(svg.getAttribute('height')) || 0;
|
|
742
|
+
}
|
|
743
|
+
if (!w) {
|
|
744
|
+
try { const b = svg.getBBox(); w = b.width; h = b.height; } catch {}
|
|
745
|
+
}
|
|
746
|
+
if (!w) {
|
|
747
|
+
const r = svg.getBoundingClientRect();
|
|
748
|
+
w = r.width || 1000;
|
|
749
|
+
h = r.height || 700;
|
|
750
|
+
}
|
|
751
|
+
if (!svg.getAttribute('viewBox')) {
|
|
752
|
+
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
|
753
|
+
}
|
|
754
|
+
return { w, h };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function setAdaptiveHeight() {
|
|
758
|
+
if (!svgW) return;
|
|
759
|
+
const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
|
|
760
|
+
const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
|
|
761
|
+
const maxVp = Math.floor(innerHeight * config.maxHeightVh);
|
|
762
|
+
const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
|
|
763
|
+
wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function openInNewTab() {
|
|
767
|
+
const svg = canvas.querySelector('svg');
|
|
768
|
+
if (!svg) return;
|
|
769
|
+
const clone = svg.cloneNode(true);
|
|
770
|
+
clone.style.width = '';
|
|
771
|
+
clone.style.height = '';
|
|
772
|
+
// Read the page background from the wrap's data-bg attribute (written
|
|
773
|
+
// server-side from the current palette). This guarantees the new tab
|
|
774
|
+
// matches the diagram's baked-in theme.
|
|
775
|
+
const bg = wrap.getAttribute('data-bg') || '#ffffff';
|
|
776
|
+
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
|
|
777
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
|
778
|
+
+ '<title>Diagram</title><style>'
|
|
779
|
+
+ 'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;'
|
|
780
|
+
+ 'background:' + bg + ';padding:40px;box-sizing:border-box}'
|
|
781
|
+
+ 'svg{max-width:100%;max-height:90vh;height:auto}'
|
|
782
|
+
+ '</style></head><body>' + clone.outerHTML + '</body></html>';
|
|
783
|
+
open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function waitForMermaid() {
|
|
787
|
+
return new Promise((resolve, reject) => {
|
|
788
|
+
if (window.mermaid && typeof window.mermaid.render === 'function') {
|
|
789
|
+
resolve(window.mermaid);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const started = Date.now();
|
|
793
|
+
const timer = setInterval(() => {
|
|
794
|
+
if (window.mermaid && typeof window.mermaid.render === 'function') {
|
|
795
|
+
clearInterval(timer);
|
|
796
|
+
resolve(window.mermaid);
|
|
797
|
+
} else if (Date.now() - started > 10000) {
|
|
798
|
+
clearInterval(timer);
|
|
799
|
+
reject(new Error('Mermaid failed to load from CDN (10s timeout)'));
|
|
800
|
+
}
|
|
801
|
+
}, 50);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function render() {
|
|
806
|
+
try {
|
|
807
|
+
const code = (source.textContent || '').trim();
|
|
808
|
+
if (!code) {
|
|
809
|
+
label.textContent = 'Error: empty source';
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const mermaid = await waitForMermaid();
|
|
813
|
+
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
814
|
+
const { svg } = await mermaid.render(id, code);
|
|
815
|
+
canvas.innerHTML = svg;
|
|
816
|
+
const svgNode = canvas.querySelector('svg');
|
|
817
|
+
if (!svgNode) {
|
|
818
|
+
label.textContent = 'Error: no SVG';
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const size = readSvgNaturalSize(svgNode);
|
|
822
|
+
svgW = size.w;
|
|
823
|
+
svgH = size.h;
|
|
824
|
+
svgNode.removeAttribute('width');
|
|
825
|
+
svgNode.removeAttribute('height');
|
|
826
|
+
svgNode.style.maxWidth = 'none';
|
|
827
|
+
svgNode.style.display = 'block';
|
|
828
|
+
setAdaptiveHeight();
|
|
829
|
+
fitDiagram();
|
|
830
|
+
} catch (err) {
|
|
831
|
+
console.error('visual-explainer mermaid render failed:', err);
|
|
832
|
+
label.textContent = 'Error: ' + (err && err.message ? err.message : 'render failed');
|
|
833
|
+
canvas.innerHTML = '<pre style="padding:20px;color:#b00020;white-space:pre-wrap;font-family:monospace;font-size:12px">'
|
|
834
|
+
+ String(err && err.message ? err.message : err).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]))
|
|
835
|
+
+ '</pre>';
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const actions = {
|
|
840
|
+
'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
|
|
841
|
+
'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
|
|
842
|
+
'zoom-fit': fitDiagram,
|
|
843
|
+
'zoom-one': setOneToOne,
|
|
844
|
+
'zoom-expand': openInNewTab
|
|
845
|
+
};
|
|
846
|
+
Object.keys(actions).forEach((action) => {
|
|
847
|
+
const btn = wrap.querySelector('[data-action="' + action + '"]');
|
|
848
|
+
if (btn) btn.addEventListener('click', actions[action]);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
viewport.addEventListener('dblclick', fitDiagram);
|
|
852
|
+
|
|
853
|
+
viewport.addEventListener('wheel', (e) => {
|
|
854
|
+
if (e.ctrlKey || e.metaKey) {
|
|
855
|
+
e.preventDefault();
|
|
856
|
+
const rect = viewport.getBoundingClientRect();
|
|
857
|
+
const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
|
|
858
|
+
zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (canPan()) {
|
|
862
|
+
e.preventDefault();
|
|
863
|
+
panX -= e.deltaX;
|
|
864
|
+
panY -= e.deltaY;
|
|
865
|
+
applyTransform();
|
|
866
|
+
}
|
|
867
|
+
}, { passive: false });
|
|
868
|
+
|
|
869
|
+
viewport.addEventListener('mousedown', (e) => {
|
|
870
|
+
if (e.target.closest('.zoom-controls') || !canPan()) return;
|
|
871
|
+
wrap.classList.add('is-panning');
|
|
872
|
+
sx = e.clientX;
|
|
873
|
+
sy = e.clientY;
|
|
874
|
+
spx = panX;
|
|
875
|
+
spy = panY;
|
|
876
|
+
e.preventDefault();
|
|
877
|
+
activeDrag = {
|
|
878
|
+
onMove: (ev) => {
|
|
879
|
+
panX = spx + (ev.clientX - sx);
|
|
880
|
+
panY = spy + (ev.clientY - sy);
|
|
881
|
+
applyTransform();
|
|
882
|
+
},
|
|
883
|
+
onEnd: () => { wrap.classList.remove('is-panning'); }
|
|
884
|
+
};
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
viewport.addEventListener('touchstart', (e) => {
|
|
888
|
+
if (e.touches.length === 1) {
|
|
889
|
+
sx = e.touches[0].clientX;
|
|
890
|
+
sy = e.touches[0].clientY;
|
|
891
|
+
spx = panX;
|
|
892
|
+
spy = panY;
|
|
893
|
+
} else if (e.touches.length === 2) {
|
|
894
|
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
895
|
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
896
|
+
touchDist = Math.sqrt(dx * dx + dy * dy);
|
|
897
|
+
const r = viewport.getBoundingClientRect();
|
|
898
|
+
touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
|
|
899
|
+
touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
|
|
900
|
+
}
|
|
901
|
+
}, { passive: true });
|
|
902
|
+
|
|
903
|
+
viewport.addEventListener('touchmove', (e) => {
|
|
904
|
+
if (e.touches.length === 1 && canPan()) {
|
|
905
|
+
if (touchDist > 0) {
|
|
906
|
+
sx = e.touches[0].clientX;
|
|
907
|
+
sy = e.touches[0].clientY;
|
|
908
|
+
spx = panX;
|
|
909
|
+
spy = panY;
|
|
910
|
+
touchDist = 0;
|
|
911
|
+
}
|
|
912
|
+
e.preventDefault();
|
|
913
|
+
panX = spx + (e.touches[0].clientX - sx);
|
|
914
|
+
panY = spy + (e.touches[0].clientY - sy);
|
|
915
|
+
applyTransform();
|
|
916
|
+
} else if (e.touches.length === 2 && touchDist > 0) {
|
|
917
|
+
e.preventDefault();
|
|
918
|
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
919
|
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
920
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
921
|
+
zoomAround(d / touchDist, touchCx, touchCy);
|
|
922
|
+
touchDist = d;
|
|
923
|
+
}
|
|
924
|
+
}, { passive: false });
|
|
925
|
+
|
|
926
|
+
new ResizeObserver(() => {
|
|
927
|
+
if (svgW) { setAdaptiveHeight(); fitDiagram(); }
|
|
928
|
+
}).observe(wrap);
|
|
929
|
+
|
|
930
|
+
render();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function bootstrap() {
|
|
934
|
+
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (document.readyState === 'loading') {
|
|
938
|
+
document.addEventListener('DOMContentLoaded', bootstrap);
|
|
939
|
+
} else {
|
|
940
|
+
bootstrap();
|
|
941
|
+
}
|
|
942
|
+
})();
|
|
604
943
|
`;
|
|
605
944
|
// Helper to generate CSS variables from palette
|
|
606
945
|
export function generateCSSVariables(palette, fonts, isDark) {
|
package/package.json
CHANGED