init
This commit is contained in:
768
.opencode/skills/preview/templates/mermaid-flowchart.html
Normal file
768
.opencode/skills/preview/templates/mermaid-flowchart.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CI/CD Pipeline — Reference Template</title>
|
||||
<!--
|
||||
Reference template for the ck:preview skill: Mermaid diagrams.
|
||||
Teal/cyan palette — distinctly different from terracotta (architecture)
|
||||
and rose (data-table) templates so agents absorb variety.
|
||||
Key patterns demonstrated:
|
||||
- Teal/cyan palette (NOT indigo/violet)
|
||||
- Dot-grid background atmosphere (different from radial gradient)
|
||||
- Large heading (38px) for typographic contrast
|
||||
- ESM import of Mermaid + @mermaid-js/layout-elk
|
||||
- Mermaid theme: 'base' + full themeVariables, fontSize: 16px
|
||||
- CSS overrides: .nodeLabel 16px, .edgeLabel 13px
|
||||
- look: 'classic' for clean lines
|
||||
- layout: 'elk' for better node positioning
|
||||
- Zoom controls with scroll-to-zoom and drag-to-pan
|
||||
- Both light and dark themes via prefers-color-scheme
|
||||
- Staggered fade-in, reduced motion respect
|
||||
-->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700&family=Fragment+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ============ THEME ============ */
|
||||
:root {
|
||||
--font-body: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
--font-mono: 'Fragment Mono', 'SF Mono', Consolas, monospace;
|
||||
|
||||
--bg: #f0fdfa;
|
||||
--surface: #ffffff;
|
||||
--border: rgba(0, 0, 0, 0.07);
|
||||
--text: #134e4a;
|
||||
--text-dim: #5f8a85;
|
||||
|
||||
--primary: #0d9488;
|
||||
--primary-dim: rgba(13, 148, 136, 0.08);
|
||||
--secondary: #0369a1;
|
||||
--tertiary: #d97706;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #042f2e;
|
||||
--surface: #0a3d3a;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #ccfbf1;
|
||||
--text-dim: #5eead4;
|
||||
|
||||
--primary: #2dd4bf;
|
||||
--primary-dim: rgba(45, 212, 191, 0.14);
|
||||
--secondary: #38bdf8;
|
||||
--tertiary: #fbbf24;
|
||||
--danger: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark (manual toggle override) ── */
|
||||
[data-theme="dark"] {
|
||||
--bg: #042f2e;
|
||||
--surface: #0a3d3a;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #ccfbf1;
|
||||
--text-dim: #5eead4;
|
||||
--primary: #2dd4bf;
|
||||
--primary-dim: rgba(45, 212, 191, 0.14);
|
||||
--secondary: #38bdf8;
|
||||
--tertiary: #fbbf24;
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
/* ============ THEME TOGGLE ============ */
|
||||
.theme-toggle {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 300;
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--text-dim); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; transition: background 0.15s, color 0.15s;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.theme-toggle:hover { background: var(--surface); color: var(--text); }
|
||||
|
||||
/* ============ RESET + BASE ============ */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============ ANIMATION ============ */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate {
|
||||
animation: fadeUp 0.4s ease-out both;
|
||||
animation-delay: calc(var(--i, 0) * 0.06s);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-delay: 0ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ LAYOUT ============ */
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 6px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 24px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.description code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ============ MERMAID CONTAINER ============ */
|
||||
.mermaid-wrap {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
overflow: auto;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 10;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mermaid-wrap { cursor: grab; }
|
||||
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
|
||||
|
||||
/* ============ MULTI-DIAGRAM STRUCTURE ============ */
|
||||
.diagram-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diagram-shell__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mermaid-viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.mermaid-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.zoom-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ MERMAID SVG OVERRIDES ============ */
|
||||
.mermaid .nodeLabel {
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.mermaid .edgeLabel {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.mermaid .node rect,
|
||||
.mermaid .node circle,
|
||||
.mermaid .node polygon {
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
.mermaid .edge-pattern-solid {
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
/* ============ LEGEND ============ */
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ============ CALLOUT ============ */
|
||||
.callout {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: 0 10px 10px 0;
|
||||
padding: 16px 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.callout strong { color: var(--text); }
|
||||
|
||||
.callout code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ============ RESPONSIVE ============ */
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 16px; }
|
||||
h1 { font-size: 22px; }
|
||||
.mermaid-wrap { padding: 16px 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" id="themeToggle" title="Toggle theme" aria-label="Toggle light/dark theme"></button>
|
||||
<script>
|
||||
(function() {
|
||||
var t = document.getElementById('themeToggle');
|
||||
var s = localStorage.getItem('theme');
|
||||
var i = s || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
if (s) document.documentElement.setAttribute('data-theme', i);
|
||||
t.textContent = i === 'dark' ? '\u2600' : '\u263E';
|
||||
t.addEventListener('click', function() {
|
||||
var c = document.documentElement.getAttribute('data-theme')
|
||||
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
var n = c === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', n);
|
||||
localStorage.setItem('theme', n);
|
||||
t.textContent = n === 'dark' ? '\u2600' : '\u263E';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1 class="animate" style="--i:0">CI/CD Pipeline</h1>
|
||||
<p class="subtitle animate" style="--i:1">github actions → staging → production</p>
|
||||
|
||||
<p class="description animate" style="--i:2">
|
||||
Every push to <code>main</code> triggers the full pipeline. Tests and linting run in parallel,
|
||||
then the build step produces a Docker image. Staging deploys automatically; production requires
|
||||
manual approval via a GitHub environment gate.
|
||||
</p>
|
||||
|
||||
<section class="diagram-shell animate" style="--i:3">
|
||||
<p class="diagram-shell__hint">
|
||||
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
|
||||
</p>
|
||||
<div class="mermaid-wrap">
|
||||
<div class="zoom-controls">
|
||||
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
|
||||
<button type="button" data-action="zoom-out" title="Zoom out">−</button>
|
||||
<button type="button" data-action="zoom-fit" title="Smart fit">↺</button>
|
||||
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
|
||||
<button type="button" data-action="zoom-expand" title="Open full size">⛶</button>
|
||||
<span class="zoom-label">Loading...</span>
|
||||
</div>
|
||||
<div class="mermaid-viewport">
|
||||
<div class="mermaid mermaid-canvas"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/plain" class="diagram-source">
|
||||
graph TD
|
||||
A[Push to main] --> B{Branch?}
|
||||
B -->|main| C[Run Tests]
|
||||
B -->|feature| I[Run Tests]
|
||||
I --> J[Preview Deploy]
|
||||
C --> D[Lint + Type Check]
|
||||
C --> E[Unit Tests]
|
||||
C --> F[Integration Tests]
|
||||
D --> G[Build Docker Image]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H[Deploy to Staging]
|
||||
H --> K{Smoke Tests Pass?}
|
||||
K -->|Yes| L[Manual Approval]
|
||||
K -->|No| M[Alert + Rollback]
|
||||
L --> N[Deploy to Production]
|
||||
N --> O[Health Check]
|
||||
O --> P[Done]
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<div class="legend animate" style="--i:4">
|
||||
<div class="legend-item"><div class="legend-swatch" style="background:var(--primary)"></div> Automated step</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background:var(--tertiary)"></div> Decision gate</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background:var(--danger)"></div> Failure path</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background:var(--secondary)"></div> Success</div>
|
||||
</div>
|
||||
|
||||
<div class="callout animate" style="--i:5">
|
||||
<strong>ELK layout.</strong> The <code>layout: 'elk'</code> engine provides better node positioning
|
||||
for complex graphs — it requires the separate <code>@mermaid-js/layout-elk</code> package (imported above).
|
||||
Without it, Mermaid silently falls back to dagre.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
|
||||
|
||||
const config = {
|
||||
fitPadding: 28,
|
||||
minHeight: 360,
|
||||
maxHeightPx: 960,
|
||||
maxHeightVh: 0.84,
|
||||
maxInitialZoom: 1.8,
|
||||
minZoom: 0.08,
|
||||
maxZoom: 6.5,
|
||||
zoomStep: 0.14,
|
||||
readabilityFloor: 0.58
|
||||
};
|
||||
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
|
||||
let activeDrag = null;
|
||||
|
||||
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
|
||||
addEventListener('mouseup', () => {
|
||||
activeDrag?.onEnd();
|
||||
activeDrag = null;
|
||||
});
|
||||
|
||||
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'base',
|
||||
look: 'classic',
|
||||
layout: 'elk',
|
||||
themeVariables: {
|
||||
fontFamily: "'Bricolage Grotesque', system-ui, sans-serif",
|
||||
fontSize: '16px',
|
||||
primaryColor: isDark ? '#115e59' : '#ccfbf1',
|
||||
primaryBorderColor: isDark ? '#2dd4bf' : '#0d9488',
|
||||
primaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
||||
secondaryColor: isDark ? '#0c4a6e' : '#e0f2fe',
|
||||
secondaryBorderColor: isDark ? '#38bdf8' : '#0369a1',
|
||||
secondaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
||||
tertiaryColor: isDark ? '#2e2618' : '#fffbeb',
|
||||
tertiaryBorderColor: isDark ? '#fbbf24' : '#d97706',
|
||||
tertiaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
||||
lineColor: isDark ? '#5eead4' : '#5f8a85',
|
||||
noteBkgColor: isDark ? '#115e59' : '#fefce8',
|
||||
noteTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
||||
noteBorderColor: isDark ? '#fbbf24' : '#d97706',
|
||||
}
|
||||
});
|
||||
|
||||
function initDiagram(shell) {
|
||||
const wrap = shell.querySelector('.mermaid-wrap');
|
||||
const viewport = shell.querySelector('.mermaid-viewport');
|
||||
const canvas = shell.querySelector('.mermaid-canvas');
|
||||
const source = shell.querySelector('.diagram-source');
|
||||
const label = shell.querySelector('.zoom-label');
|
||||
|
||||
if (!wrap || !viewport || !canvas || !source || !label) {
|
||||
console.error('initDiagram: missing required elements in', shell);
|
||||
return;
|
||||
}
|
||||
|
||||
let zoom = 1;
|
||||
let fitMode = 'contain';
|
||||
let panX = 0;
|
||||
let panY = 0;
|
||||
let svgW = 0;
|
||||
let svgH = 0;
|
||||
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let spx = 0;
|
||||
let spy = 0;
|
||||
|
||||
let touchDist = 0;
|
||||
let touchCx = 0;
|
||||
let touchCy = 0;
|
||||
|
||||
function constrainPan() {
|
||||
const vpW = viewport.clientWidth;
|
||||
const vpH = viewport.clientHeight;
|
||||
const rW = svgW * zoom;
|
||||
const rH = svgH * zoom;
|
||||
const pad = config.fitPadding;
|
||||
|
||||
panX = (rW + pad * 2 <= vpW)
|
||||
? (vpW - rW) / 2
|
||||
: clamp(panX, vpW - rW - pad, pad);
|
||||
panY = (rH + pad * 2 <= vpH)
|
||||
? (vpH - rH) / 2
|
||||
: clamp(panY, vpH - rH - pad, pad);
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (!svg || !svgW) return;
|
||||
|
||||
constrainPan();
|
||||
svg.style.width = (svgW * zoom) + 'px';
|
||||
svg.style.height = (svgH * zoom) + 'px';
|
||||
canvas.style.transform = `translate(${panX}px, ${panY}px)`;
|
||||
label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
|
||||
}
|
||||
|
||||
function canPan() {
|
||||
const rW = svgW * zoom;
|
||||
const rH = svgH * zoom;
|
||||
return rW + config.fitPadding * 2 > viewport.clientWidth
|
||||
|| rH + config.fitPadding * 2 > viewport.clientHeight;
|
||||
}
|
||||
|
||||
function computeSmartFit() {
|
||||
const vpW = viewport.clientWidth;
|
||||
const vpH = viewport.clientHeight;
|
||||
const aW = Math.max(80, vpW - config.fitPadding * 2);
|
||||
const aH = Math.max(80, vpH - config.fitPadding * 2);
|
||||
const contain = Math.min(aW / svgW, aH / svgH);
|
||||
|
||||
let z = contain;
|
||||
let mode = 'contain';
|
||||
if (contain < config.readabilityFloor) {
|
||||
const chartR = svgH / svgW;
|
||||
const vpR = vpH / Math.max(vpW, 1);
|
||||
if (chartR >= vpR) {
|
||||
z = aW / svgW;
|
||||
mode = 'width-priority';
|
||||
} else {
|
||||
z = aH / svgH;
|
||||
mode = 'height-priority';
|
||||
}
|
||||
}
|
||||
return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
|
||||
}
|
||||
|
||||
function fitDiagram() {
|
||||
if (!svgW) return;
|
||||
const fit = computeSmartFit();
|
||||
zoom = fit.zoom;
|
||||
fitMode = fit.mode;
|
||||
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
||||
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function setOneToOne() {
|
||||
zoom = clamp(1, config.minZoom, config.maxZoom);
|
||||
fitMode = '1:1';
|
||||
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
||||
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomAround(factor, cx, cy) {
|
||||
const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
|
||||
const ratio = next / zoom;
|
||||
panX = cx - ratio * (cx - panX);
|
||||
panY = cy - ratio * (cy - panY);
|
||||
zoom = next;
|
||||
fitMode = 'custom';
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function readSvgNaturalSize(svg) {
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
if (svg.viewBox?.baseVal?.width > 0) {
|
||||
w = svg.viewBox.baseVal.width;
|
||||
h = svg.viewBox.baseVal.height;
|
||||
}
|
||||
if (!w) {
|
||||
w = parseFloat(svg.getAttribute('width')) || 0;
|
||||
h = parseFloat(svg.getAttribute('height')) || 0;
|
||||
}
|
||||
if (!w) {
|
||||
const b = svg.getBBox();
|
||||
w = b.width;
|
||||
h = b.height;
|
||||
}
|
||||
if (!w) {
|
||||
const r = svg.getBoundingClientRect();
|
||||
w = r.width || 1000;
|
||||
h = r.height || 700;
|
||||
}
|
||||
if (!svg.getAttribute('viewBox')) {
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
}
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function setAdaptiveHeight() {
|
||||
if (!svgW) return;
|
||||
const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
|
||||
const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
|
||||
const maxVp = Math.floor(innerHeight * config.maxHeightVh);
|
||||
const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
|
||||
wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.style.width = '';
|
||||
clone.style.height = '';
|
||||
|
||||
// Use the same isDark value that was used to render the Mermaid theme
|
||||
// This ensures the background matches the baked-in SVG colors
|
||||
const bg = isDark ? '#042f2e' : '#f0fdfa';
|
||||
|
||||
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Diagram</title><style>
|
||||
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background:${bg};padding:40px;box-sizing:border-box}
|
||||
svg{max-width:100%;max-height:90vh;height:auto}
|
||||
</style></head><body>${clone.outerHTML}</body></html>`;
|
||||
|
||||
open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
|
||||
}
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
const code = source.textContent.trim();
|
||||
if (!code) {
|
||||
label.textContent = 'Error: Empty source';
|
||||
return;
|
||||
}
|
||||
|
||||
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
||||
const { svg } = await mermaid.render(id, code);
|
||||
canvas.innerHTML = svg;
|
||||
|
||||
const svgNode = canvas.querySelector('svg');
|
||||
if (!svgNode) {
|
||||
label.textContent = 'Error: No SVG';
|
||||
return;
|
||||
}
|
||||
|
||||
const size = readSvgNaturalSize(svgNode);
|
||||
svgW = size.w;
|
||||
svgH = size.h;
|
||||
|
||||
svgNode.removeAttribute('width');
|
||||
svgNode.removeAttribute('height');
|
||||
svgNode.style.maxWidth = 'none';
|
||||
svgNode.style.display = 'block';
|
||||
|
||||
setAdaptiveHeight();
|
||||
fitDiagram();
|
||||
} catch (err) {
|
||||
console.error('Mermaid render failed:', err);
|
||||
label.textContent = 'Error: ' + (err.message || 'Render failed');
|
||||
}
|
||||
}
|
||||
|
||||
const actions = {
|
||||
'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
|
||||
'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
|
||||
'zoom-fit': fitDiagram,
|
||||
'zoom-one': setOneToOne,
|
||||
'zoom-expand': openInNewTab
|
||||
};
|
||||
|
||||
Object.entries(actions).forEach(([action, handler]) => {
|
||||
wrap.querySelector(`[data-action="${action}"]`)?.addEventListener('click', handler);
|
||||
});
|
||||
|
||||
viewport.addEventListener('dblclick', fitDiagram);
|
||||
|
||||
viewport.addEventListener('wheel', (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
|
||||
zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
|
||||
return;
|
||||
}
|
||||
if (canPan()) {
|
||||
e.preventDefault();
|
||||
panX -= e.deltaX;
|
||||
panY -= e.deltaY;
|
||||
applyTransform();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
viewport.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.zoom-controls') || !canPan()) return;
|
||||
wrap.classList.add('is-panning');
|
||||
sx = e.clientX;
|
||||
sy = e.clientY;
|
||||
spx = panX;
|
||||
spy = panY;
|
||||
e.preventDefault();
|
||||
|
||||
activeDrag = {
|
||||
onMove: (ev) => {
|
||||
panX = spx + (ev.clientX - sx);
|
||||
panY = spy + (ev.clientY - sy);
|
||||
applyTransform();
|
||||
},
|
||||
onEnd: () => {
|
||||
wrap.classList.remove('is-panning');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
viewport.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
spx = panX;
|
||||
spy = panY;
|
||||
} else if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
touchDist = Math.sqrt(dx * dx + dy * dy);
|
||||
const r = viewport.getBoundingClientRect();
|
||||
touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
|
||||
touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
viewport.addEventListener('touchmove', (e) => {
|
||||
if (e.touches.length === 1 && canPan()) {
|
||||
if (touchDist > 0) {
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
spx = panX;
|
||||
spy = panY;
|
||||
touchDist = 0;
|
||||
}
|
||||
e.preventDefault();
|
||||
panX = spx + (e.touches[0].clientX - sx);
|
||||
panY = spy + (e.touches[0].clientY - sy);
|
||||
applyTransform();
|
||||
} else if (e.touches.length === 2 && touchDist > 0) {
|
||||
e.preventDefault();
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
zoomAround(d / touchDist, touchCx, touchCy);
|
||||
touchDist = d;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
new ResizeObserver(() => {
|
||||
if (svgW) {
|
||||
setAdaptiveHeight();
|
||||
fitDiagram();
|
||||
}
|
||||
}).observe(wrap);
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user