mirror of
https://github.com/tt-a1i/archify.git
synced 2026-09-28 23:16:23 +08:00
Remove Share Card and Copy Share Card from the export menu, their ordinary export APIs, and unused rendering fallbacks. Preserve full-diagram exports and route/reach cards, update regression coverage, and regenerate affected examples and packaged artifacts. Fixes tt-a1i/archify#580
1380 lines
67 KiB
JavaScript
1380 lines
67 KiB
JavaScript
/* ============================================================
|
|
Export — Share Card / PNG / JPEG / WebP / SVG / WebM and clipboard
|
|
|
|
Raster exports are always rendered at 4x source resolution for
|
|
maximum sharpness. The trick: we set the serialized SVG's
|
|
`width`/`height` to viewBox * 4 so the browser rasterizes the
|
|
vectors at that resolution natively. drawImage then draws at
|
|
the image's natural size (no upscaling = no blur).
|
|
|
|
JPEG/WebP paint the current theme's background explicitly since
|
|
those formats have no alpha channel.
|
|
============================================================ */
|
|
(function () {
|
|
var RASTER_SCALE = 4;
|
|
var MOTION_DURATION = 6000;
|
|
var MOTION_FPS = 30;
|
|
var SHARE_CARD_WIDTH = 1200;
|
|
var SHARE_CARD_HEIGHT = 630;
|
|
var SHARE_CARD_PADDING = 40;
|
|
var SHARE_CARD_HEADER = 124;
|
|
|
|
function exportError(key, values) {
|
|
var error = new Error(viewerText(key, values));
|
|
error.archifyViewerMessage = true;
|
|
return error;
|
|
}
|
|
|
|
function exportMessage(error) {
|
|
return error && error.archifyViewerMessage
|
|
? error.message
|
|
: viewerText('viewer.export.unknown');
|
|
}
|
|
|
|
function diagramFilename() {
|
|
var title = (document.title || 'diagram')
|
|
.replace(/\s+Architecture(\s+Diagram)?$/i, '')
|
|
.replace(/\s+Diagram$/i, '')
|
|
.trim();
|
|
return title.replace(/[^a-z0-9_\-]+/gi, '-')
|
|
.toLowerCase()
|
|
.replace(/^-+|-+$/g, '') || 'diagram';
|
|
}
|
|
|
|
function currentBg() {
|
|
return getComputedStyle(document.body).backgroundColor || '#ffffff';
|
|
}
|
|
|
|
/* ARCHIFY:EXPORT_CLEANUP */
|
|
|
|
/**
|
|
* Serialize the main SVG into a standalone document.
|
|
*
|
|
* Two modes:
|
|
* - Default (opts.autoTheme=false) — locks the serialized SVG to the
|
|
* viewer's current theme. Used by the raster pipeline
|
|
* (PNG/JPEG/WebP/clipboard) because canvas rasterization needs
|
|
* deterministic colors; a raster cannot react to
|
|
* prefers-color-scheme after encoding.
|
|
* - theme=auto — emits BOTH dark and light variable sets plus a
|
|
* `@media (prefers-color-scheme)` rule so the resulting SVG
|
|
* self-themes when embedded in GitHub READMEs or other hosts that
|
|
* expose a color scheme. Used for the default "Download SVG".
|
|
* - theme=light|dark — locks the standalone SVG to the requested
|
|
* theme, independently of its source Viewer and host OS themes.
|
|
*/
|
|
function applyRouteSnapshot(clone, snapshot) {
|
|
if (!snapshot || !Array.isArray(snapshot.nodeIds) || !Array.isArray(snapshot.edges) ||
|
|
snapshot.nodeIds.length < 2 || snapshot.edges.length !== snapshot.nodeIds.length - 1 ||
|
|
snapshot.hops !== snapshot.edges.length ||
|
|
!snapshot.source || !snapshot.target ||
|
|
snapshot.source.id !== snapshot.nodeIds[0] ||
|
|
snapshot.target.id !== snapshot.nodeIds[snapshot.nodeIds.length - 1]) return false;
|
|
|
|
clone.removeAttribute('data-animation');
|
|
Array.prototype.forEach.call(clone.querySelectorAll('[data-animate]'), function (element) {
|
|
element.removeAttribute('data-animate');
|
|
element.style.removeProperty('--step');
|
|
});
|
|
|
|
var cloneNodes = Array.prototype.slice.call(clone.querySelectorAll('[data-node-id]'));
|
|
var cloneEdges = Array.prototype.slice.call(clone.querySelectorAll('[data-edge-key]'));
|
|
var nodeIds = Object.create(null);
|
|
var edgeKeys = Object.create(null);
|
|
var nodeMatches = [];
|
|
var edgeMatches = [];
|
|
|
|
for (var nodeIndex = 0; nodeIndex < snapshot.nodeIds.length; nodeIndex++) {
|
|
var nodeId = snapshot.nodeIds[nodeIndex];
|
|
if (typeof nodeId !== 'string' || !nodeId || nodeIds[nodeId]) return false;
|
|
var matchedNodes = cloneNodes.filter(function (candidate) {
|
|
return candidate.getAttribute('data-node-id') === nodeId;
|
|
});
|
|
if (matchedNodes.length !== 1) return false;
|
|
nodeIds[nodeId] = true;
|
|
nodeMatches.push(matchedNodes);
|
|
}
|
|
|
|
for (var edgeIndex = 0; edgeIndex < snapshot.edges.length; edgeIndex++) {
|
|
var edge = snapshot.edges[edgeIndex];
|
|
var fromId = snapshot.nodeIds[edgeIndex];
|
|
var toId = snapshot.nodeIds[edgeIndex + 1];
|
|
if (!edge || typeof edge.key !== 'string' || !edge.key || edgeKeys[edge.key] ||
|
|
edge.from !== fromId || edge.to !== toId) return false;
|
|
var matchedEdges = cloneEdges.filter(function (candidate) {
|
|
return candidate.getAttribute('data-edge-key') === edge.key;
|
|
});
|
|
var drawableMatches = matchedEdges.filter(hasDrawableGeometry);
|
|
if (!matchedEdges.length || drawableMatches.length !== 1 || !matchedEdges.every(function (candidate) {
|
|
return candidate.getAttribute('data-edge-from') === edge.from &&
|
|
candidate.getAttribute('data-edge-to') === edge.to &&
|
|
(candidate.getAttribute('data-edge-id') || '') === (edge.id || '');
|
|
})) return false;
|
|
edgeKeys[edge.key] = true;
|
|
edgeMatches.push(matchedEdges);
|
|
}
|
|
|
|
clone.setAttribute('data-share-route', '');
|
|
nodeMatches.forEach(function (matches, step) {
|
|
matches.forEach(function (element) {
|
|
element.setAttribute('data-share-route-match', '');
|
|
element.setAttribute('data-share-route-step', String(step));
|
|
if (step === 0) element.setAttribute('data-share-route-start', '');
|
|
else if (step === snapshot.nodeIds.length - 1) element.setAttribute('data-share-route-end', '');
|
|
else element.setAttribute('data-share-route-middle', '');
|
|
});
|
|
});
|
|
edgeMatches.forEach(function (matches, step) {
|
|
matches.forEach(function (element) {
|
|
element.setAttribute('data-share-route-match', '');
|
|
element.setAttribute('data-share-route-step', String(step));
|
|
});
|
|
});
|
|
|
|
return clone.hasAttribute('data-share-route') &&
|
|
!clone.hasAttribute('data-animation') &&
|
|
!clone.hasAttribute('data-route-active') &&
|
|
!clone.hasAttribute('data-route-journey') &&
|
|
clone.querySelectorAll('[data-animate], [data-route-match], [data-route-step], [data-route-start], [data-route-end], [data-route-journey-state], [data-route-journey-current], [data-route-journey-overlay]').length === 0 &&
|
|
clone.querySelectorAll('[data-share-route-match]').length ===
|
|
nodeMatches.reduce(function (count, matches) { return count + matches.length; }, 0) +
|
|
edgeMatches.reduce(function (count, matches) { return count + matches.length; }, 0);
|
|
}
|
|
|
|
function applyReachSnapshot(clone, snapshot) {
|
|
if (!snapshot || (snapshot.direction !== 'upstream' && snapshot.direction !== 'downstream') ||
|
|
!snapshot.origin || typeof snapshot.origin.id !== 'string' || !snapshot.origin.id ||
|
|
typeof snapshot.origin.label !== 'string' || !snapshot.origin.label.trim() ||
|
|
!Array.isArray(snapshot.nodeIds) || snapshot.nodeIds.length < 2 ||
|
|
!Array.isArray(snapshot.edges) || !snapshot.edges.length ||
|
|
!snapshot.depths || typeof snapshot.depths !== 'object' ||
|
|
!Number.isInteger(snapshot.maxDepth) || snapshot.maxDepth < 1 ||
|
|
snapshot.nodeIds[0] !== snapshot.origin.id) return false;
|
|
|
|
clone.removeAttribute('data-animation');
|
|
Array.prototype.forEach.call(clone.querySelectorAll('[data-animate]'), function (element) {
|
|
element.removeAttribute('data-animate');
|
|
element.style.removeProperty('--step');
|
|
});
|
|
|
|
var cloneNodes = Array.prototype.slice.call(clone.querySelectorAll('[data-node-id]'));
|
|
var cloneEdges = Array.prototype.slice.call(clone.querySelectorAll('[data-edge-key]'));
|
|
var nodeIds = Object.create(null);
|
|
var edgeKeys = Object.create(null);
|
|
var nodeMatches = [];
|
|
var edgeMatches = [];
|
|
var measuredMaxDepth = 0;
|
|
|
|
for (var nodeIndex = 0; nodeIndex < snapshot.nodeIds.length; nodeIndex++) {
|
|
var nodeId = snapshot.nodeIds[nodeIndex];
|
|
var depth = snapshot.depths[nodeId];
|
|
if (typeof nodeId !== 'string' || !nodeId || nodeIds[nodeId] ||
|
|
!Number.isInteger(depth) || depth < 0 || depth > snapshot.maxDepth ||
|
|
(nodeId === snapshot.origin.id ? depth !== 0 : depth < 1)) return false;
|
|
var matchedNodes = cloneNodes.filter(function (candidate) {
|
|
return candidate.getAttribute('data-node-id') === nodeId;
|
|
});
|
|
if (matchedNodes.length !== 1) return false;
|
|
nodeIds[nodeId] = true;
|
|
measuredMaxDepth = Math.max(measuredMaxDepth, depth);
|
|
nodeMatches.push({ elements: matchedNodes, id: nodeId, depth: depth });
|
|
}
|
|
if (measuredMaxDepth !== snapshot.maxDepth) return false;
|
|
|
|
for (var edgeIndex = 0; edgeIndex < snapshot.edges.length; edgeIndex++) {
|
|
var edge = snapshot.edges[edgeIndex];
|
|
if (!edge || typeof edge.key !== 'string' || !edge.key || edgeKeys[edge.key] ||
|
|
!nodeIds[edge.from] || !nodeIds[edge.to] ||
|
|
!Number.isInteger(edge.depth) || edge.depth < 1 || edge.depth > snapshot.maxDepth ||
|
|
edge.depth !== Math.max(snapshot.depths[edge.from], snapshot.depths[edge.to])) return false;
|
|
var matchedEdges = cloneEdges.filter(function (candidate) {
|
|
return candidate.getAttribute('data-edge-key') === edge.key;
|
|
});
|
|
var drawableMatches = matchedEdges.filter(hasDrawableGeometry);
|
|
if (!matchedEdges.length || drawableMatches.length !== 1 || !matchedEdges.every(function (candidate) {
|
|
return candidate.getAttribute('data-edge-from') === edge.from &&
|
|
candidate.getAttribute('data-edge-to') === edge.to &&
|
|
(candidate.getAttribute('data-edge-id') || '') === (edge.id || '');
|
|
})) return false;
|
|
edgeKeys[edge.key] = true;
|
|
edgeMatches.push({ elements: matchedEdges, depth: edge.depth });
|
|
}
|
|
|
|
clone.setAttribute('data-share-reach', snapshot.direction);
|
|
nodeMatches.forEach(function (match) {
|
|
match.elements.forEach(function (element) {
|
|
element.setAttribute('data-share-reach-match', '');
|
|
element.setAttribute('data-share-reach-depth', String(match.depth));
|
|
if (match.id === snapshot.origin.id) element.setAttribute('data-share-reach-origin', '');
|
|
});
|
|
});
|
|
edgeMatches.forEach(function (match) {
|
|
match.elements.forEach(function (element) {
|
|
element.setAttribute('data-share-reach-match', '');
|
|
element.setAttribute('data-share-reach-depth', String(match.depth));
|
|
});
|
|
});
|
|
|
|
return clone.getAttribute('data-share-reach') === snapshot.direction &&
|
|
!clone.hasAttribute('data-animation') &&
|
|
!clone.hasAttribute('data-reach-active') &&
|
|
clone.querySelectorAll('[data-animate], [data-reach-match], [data-reach-origin], [data-reach-depth]').length === 0 &&
|
|
clone.querySelectorAll('[data-share-reach-origin]').length === 1 &&
|
|
clone.querySelectorAll('[data-share-reach-match]').length ===
|
|
nodeMatches.reduce(function (count, match) { return count + match.elements.length; }, 0) +
|
|
edgeMatches.reduce(function (count, match) { return count + match.elements.length; }, 0);
|
|
}
|
|
|
|
function serializeSvg(scale, opts) {
|
|
// scale: multiplier (integer unless the figure is oversized) for intrinsic SVG pixel dimensions used by
|
|
// the raster path. Defaults to 1 (natural size) for SVG download.
|
|
scale = scale || 1;
|
|
opts = opts || {};
|
|
var requestedTheme = opts.theme || (opts.autoTheme === true ? 'auto' : null);
|
|
if (requestedTheme && requestedTheme !== 'auto' && requestedTheme !== 'light' && requestedTheme !== 'dark') {
|
|
throw new Error('Unsupported SVG theme: ' + requestedTheme);
|
|
}
|
|
var autoTheme = requestedTheme === 'auto';
|
|
var svg = document.querySelector('.diagram-container svg');
|
|
var clone = svg.cloneNode(true);
|
|
|
|
var canonicalStateClean = cleanExportClone(clone);
|
|
|
|
var vb = svg.viewBox.baseVal;
|
|
var finiteSvgDimensions = Number.isFinite(vb.x) && Number.isFinite(vb.y) &&
|
|
Number.isFinite(vb.width) && Number.isFinite(vb.height) && vb.width > 0 && vb.height > 0;
|
|
var routeStateClean = opts.routeSnapshot
|
|
? canonicalStateClean && finiteSvgDimensions && applyRouteSnapshot(clone, opts.routeSnapshot)
|
|
: null;
|
|
var reachStateClean = opts.reachSnapshot
|
|
? canonicalStateClean && finiteSvgDimensions && !opts.routeSnapshot && applyReachSnapshot(clone, opts.reachSnapshot)
|
|
: null;
|
|
// Scale width/height so the browser rasterizes the vectors at target
|
|
// resolution directly. viewBox stays unchanged so coordinates don't
|
|
// shift. This is the key to sharp rasters — do NOT scale later via
|
|
// canvas upscaling.
|
|
clone.setAttribute('width', vb.width * scale);
|
|
clone.setAttribute('height', vb.height * scale);
|
|
// Keep copied Viewer layout rules from overriding the export's size.
|
|
clone.style.width = vb.width * scale + 'px';
|
|
clone.style.height = vb.height * scale + 'px';
|
|
clone.style.minWidth = '0';
|
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
|
|
// Only the SVG-relevant rules: semantic classes, markers, and the
|
|
// theme variable blocks. Toolbar/cards/print CSS can never apply
|
|
// inside a standalone SVG and would only bloat the export.
|
|
var hostStyle = (function () {
|
|
var out = [];
|
|
Array.prototype.forEach.call(document.styleSheets, function (sheet) {
|
|
var rules;
|
|
try { rules = sheet.cssRules; } catch (_) { return; } // cross-origin
|
|
if (!rules) return;
|
|
Array.prototype.forEach.call(rules, function (rule) {
|
|
if (rule.type === 7 && /^archify-/.test(rule.name || '')) {
|
|
out.push(rule.cssText);
|
|
return;
|
|
}
|
|
if (rule.type !== 1) return; // plain style rules only
|
|
var sel = rule.selectorText || '';
|
|
if (/(^|,)\s*(svg|:root|\[data-theme|\[data-preset|\.c-|\.t-|\.a-|\.m-)/.test(sel)) {
|
|
out.push(rule.cssText);
|
|
} else if (opts.figure && sel.indexOf('.diagram-container > svg') !== -1) {
|
|
// Figures look like the Viewer canvas: keep its preset/theme paint
|
|
// (quiet grid, lifted nodes) by rescoping matching rules to svg.
|
|
var scoped = sel.split(',').map(function (part) {
|
|
var at = part.indexOf('.diagram-container > svg');
|
|
if (at === -1) return null;
|
|
var prefix = part.slice(0, at).trim();
|
|
try { if (prefix && !document.documentElement.matches(prefix)) return null; } catch (_) { return null; }
|
|
return 'svg' + part.slice(at + '.diagram-container > svg'.length);
|
|
}).filter(Boolean);
|
|
if (scoped.length) out.push(scoped.join(', ') + ' { ' + rule.style.cssText + ' }');
|
|
}
|
|
});
|
|
});
|
|
return out.join('\n');
|
|
})();
|
|
|
|
// Derive the variable list from the stylesheet so newly added theme
|
|
// variables can never be missed by the export pipeline again
|
|
// (--lane-fill/--lane-stroke once were).
|
|
var varNames = (function () {
|
|
var seen = {};
|
|
var names = [];
|
|
(hostStyle.match(/--[a-zA-Z0-9-]+(?=\s*:)/g) || []).forEach(function (n) {
|
|
if (!seen[n]) { seen[n] = true; names.push(n); }
|
|
});
|
|
return names;
|
|
})();
|
|
|
|
// Resolve the full variable set for a given data-theme via an
|
|
// off-DOM probe, independent of what the viewer is currently set to.
|
|
function resolveVars(themeAttr) {
|
|
var probe = document.createElement('div');
|
|
probe.setAttribute('data-theme', themeAttr);
|
|
probe.setAttribute('data-preset', document.documentElement.getAttribute('data-preset') || 'classic');
|
|
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;';
|
|
document.body.appendChild(probe);
|
|
try {
|
|
var c = getComputedStyle(probe);
|
|
return {
|
|
background: c.getPropertyValue('--bg').trim(),
|
|
vars: varNames.map(function (n) {
|
|
return n + ': ' + c.getPropertyValue(n).trim() + ';';
|
|
}).join(' ')
|
|
};
|
|
} finally {
|
|
document.body.removeChild(probe);
|
|
}
|
|
}
|
|
|
|
// Keep the same font bytes, character coverage, and attribution in
|
|
// standalone SVGs and the SVG images used by every raster export.
|
|
var fontCss = document.getElementById('archify-fonts').textContent;
|
|
|
|
var style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
var bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
// Cover the real viewBox: auto-cropped canvases start below y=0, and a
|
|
// 100% rect anchored at the origin left an unpainted band at the bottom.
|
|
bgRect.setAttribute('x', vb.x);
|
|
bgRect.setAttribute('y', vb.y);
|
|
bgRect.setAttribute('width', vb.width);
|
|
bgRect.setAttribute('height', vb.height);
|
|
|
|
if (autoTheme) {
|
|
// Dual-theme SVG. Dark is the default (so hosts without
|
|
// prefers-color-scheme still render), light swaps in via media
|
|
// query, and svg[data-theme="..."] still lets downstream
|
|
// consumers force a specific theme.
|
|
var darkTheme = resolveVars('dark');
|
|
var lightTheme = resolveVars('light');
|
|
|
|
style.textContent =
|
|
fontCss + "\n" +
|
|
"svg { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', 'Noto Sans Mono CJK SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', monospace; }\n" +
|
|
hostStyle + "\n" +
|
|
":root, svg { " + darkTheme.vars + " }\n" +
|
|
"@media (prefers-color-scheme: light) { :root, svg { " + lightTheme.vars + " } }\n" +
|
|
"svg[data-theme=\"light\"] { " + lightTheme.vars + " }\n" +
|
|
"svg[data-theme=\"dark\"] { " + darkTheme.vars + " }\n" +
|
|
"rect.c-bg-rect { fill: var(--bg); }\n";
|
|
|
|
// Don't lock the serialized SVG to the viewer's current theme.
|
|
clone.removeAttribute('data-theme');
|
|
// Background follows the CSS variable, not a fixed color, so it
|
|
// swaps with the media query.
|
|
bgRect.setAttribute('class', 'c-bg-rect');
|
|
} else {
|
|
// Raster exports keep the viewer's current theme. Explicit SVG
|
|
// exports instead select their own stable light/dark source.
|
|
var lockedTheme = requestedTheme || document.documentElement.getAttribute('data-theme') || 'dark';
|
|
var resolvedTheme = requestedTheme && resolveVars(lockedTheme);
|
|
var computed = getComputedStyle(document.documentElement);
|
|
var vars = resolvedTheme ? resolvedTheme.vars : varNames.map(function (n) {
|
|
return n + ': ' + computed.getPropertyValue(n).trim() + ';';
|
|
}).join(' ');
|
|
|
|
// IMPORTANT: inject the resolved variables AFTER hostStyle,
|
|
// otherwise hostStyle's ":root, [data-theme=\"dark\"] { ... }" rule
|
|
// overrides our chosen theme via later-in-cascade equal-specificity.
|
|
// Keep this order.
|
|
style.textContent =
|
|
fontCss + "\n" +
|
|
"svg { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', 'Noto Sans Mono CJK SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', monospace; }\n" +
|
|
hostStyle + "\n" +
|
|
":root, svg { " + vars + " }\n";
|
|
|
|
if (requestedTheme) clone.setAttribute('data-theme', lockedTheme);
|
|
bgRect.setAttribute('fill', (resolvedTheme ? resolvedTheme.background : computed.getPropertyValue('--bg').trim()) || '#ffffff');
|
|
}
|
|
|
|
if (opts.routeSnapshot) {
|
|
style.textContent += "\nsvg[data-share-route] [data-node-id], svg[data-share-route] [data-edge-from], svg[data-share-route] [data-graph-role=\"automatic-crossover-underlay\"] { opacity: 0.18; }\n" +
|
|
"svg[data-share-route] [data-share-route-match], svg[data-share-route] [data-graph-role=\"automatic-crossover\"]:has(> [data-share-route-match]) > [data-graph-role=\"automatic-crossover-underlay\"] { opacity: 1; }\n" +
|
|
"svg[data-share-route] [data-share-route-start] > :is(rect, circle, polygon):not(.c-mask) { stroke-width: 3; stroke-dasharray: 5 3; }\n" +
|
|
"svg[data-share-route] [data-share-route-middle] > :is(rect, circle, polygon):not(.c-mask) { stroke-width: 2.2; }\n" +
|
|
"svg[data-share-route] [data-share-route-end] > :is(rect, circle, polygon):not(.c-mask) { stroke-width: 3.4; stroke-dasharray: 1 0; }\n";
|
|
}
|
|
if (opts.reachSnapshot) {
|
|
style.textContent += "\nsvg[data-share-reach] [data-node-id], svg[data-share-reach] [data-edge-from], svg[data-share-reach] [data-graph-role=\"automatic-crossover-underlay\"] { opacity: 0.14; }\n" +
|
|
"svg[data-share-reach] [data-share-reach-match], svg[data-share-reach] [data-graph-role=\"automatic-crossover\"]:has(> [data-share-reach-match]) > [data-graph-role=\"automatic-crossover-underlay\"] { opacity: 1; }\n" +
|
|
"svg[data-share-reach] [data-edge-from][data-share-reach-match] { stroke-width: 1.55; }\n" +
|
|
"svg[data-share-reach=\"upstream\"] [data-share-reach-origin] > :is(rect, circle, polygon):not(.c-mask) { stroke: var(--database-stroke); stroke-width: 3.4; stroke-dasharray: 5 3; }\n" +
|
|
"svg[data-share-reach=\"downstream\"] [data-share-reach-origin] > :is(rect, circle, polygon):not(.c-mask) { stroke: var(--backend-stroke); stroke-width: 3.4; stroke-dasharray: 1 0; }\n" +
|
|
"svg[data-preset=\"blueprint\"][data-share-reach] [data-share-reach-origin], svg[data-preset=\"blueprint\"][data-share-reach] [data-edge-from][data-share-reach-match] { filter: none; }\n";
|
|
}
|
|
|
|
// Figures draw the diagram on a painted canvas card, like the Viewer.
|
|
if (opts.figure) bgRect.setAttribute('fill', 'none');
|
|
|
|
clone.insertBefore(style, clone.firstChild);
|
|
clone.insertBefore(bgRect, style.nextSibling);
|
|
|
|
// The XML declaration pins UTF-8: without it, consumers that guess an
|
|
// encoding instead of defaulting to UTF-8 mangle non-ASCII text.
|
|
return {
|
|
svgString: '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
new XMLSerializer().serializeToString(clone),
|
|
width: vb.width * scale,
|
|
height: vb.height * scale,
|
|
canonicalStateClean: canonicalStateClean,
|
|
routeStateClean: routeStateClean,
|
|
reachStateClean: reachStateClean
|
|
};
|
|
}
|
|
|
|
function download(blob, filename) {
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
}
|
|
|
|
// Conservative upper bound on total canvas pixels. Older Safari on iOS
|
|
// silently produces a blank canvas above ~16 Mpx; Chrome / Firefox /
|
|
// desktop Safari are far higher but start failing on memory-constrained
|
|
// devices. We pick the largest integer scale in {4,3,2,1} whose target
|
|
// pixel count fits under this cap; a figure too large even at 1x is
|
|
// downscaled to fit rather than allocating an oversized canvas.
|
|
var MAX_CANVAS_PIXELS = 16 * 1024 * 1024;
|
|
|
|
function pickSafeScale(vbW, vbH) {
|
|
for (var s = RASTER_SCALE; s >= 1; s--) {
|
|
if (vbW * s * vbH * s <= MAX_CANVAS_PIXELS) return s;
|
|
}
|
|
return Math.sqrt(MAX_CANVAS_PIXELS / (vbW * vbH)) * 0.999;
|
|
}
|
|
|
|
// Raster exports reproduce the Viewer page without its controls: the
|
|
// title row with its accent dot, then the diagram on the same rounded
|
|
// canvas card (fill, hairline, shadow, dot field). CSS pixels.
|
|
var FIGURE_MARGIN = 28;
|
|
var FIGURE_CARD_PADDING = 24;
|
|
var FIGURE_TITLE_SIZE = 24;
|
|
var FIGURE_SUBTITLE_SIZE = 14;
|
|
|
|
function figureLayout(vb) {
|
|
var titleNode = document.querySelector('.header h1');
|
|
var subtitleNode = document.querySelector('.header .subtitle');
|
|
var container = document.querySelector('.diagram-container');
|
|
var root = getComputedStyle(document.documentElement);
|
|
var card = container ? getComputedStyle(container) : null;
|
|
var title = titleNode ? titleNode.textContent.trim() : '';
|
|
var subtitle = subtitleNode ? subtitleNode.textContent.trim() : '';
|
|
var header = title ? 32 + (subtitle ? 24 : 0) + 18 : 0;
|
|
var cardWidth = vb.width + FIGURE_CARD_PADDING * 2;
|
|
var cardHeight = vb.height + FIGURE_CARD_PADDING * 2;
|
|
var cardFill = card && card.backgroundColor && !/rgba\(0, 0, 0, 0\)|transparent/.test(card.backgroundColor)
|
|
? card.backgroundColor : (root.getPropertyValue('--panel').trim() || 'transparent');
|
|
return {
|
|
title: title,
|
|
subtitle: subtitle,
|
|
family: titleNode ? getComputedStyle(titleNode).fontFamily : 'sans-serif',
|
|
text: root.getPropertyValue('--text').trim() || '#111827',
|
|
muted: root.getPropertyValue('--text-muted').trim() || '#64748b',
|
|
accent: root.getPropertyValue('--frontend-stroke').trim() || '#0891b2',
|
|
bg: root.getPropertyValue('--bg').trim() || currentBg(),
|
|
cardFill: cardFill,
|
|
cardBorder: root.getPropertyValue('--panel-border').trim() || 'transparent',
|
|
dot: card && /radial-gradient/.test(card.backgroundImage) ? (root.getPropertyValue('--canvas-dot').trim() || '') : '',
|
|
light: (document.documentElement.getAttribute('data-theme') || 'dark') === 'light',
|
|
header: header,
|
|
cardWidth: cardWidth,
|
|
cardHeight: cardHeight,
|
|
width: cardWidth + FIGURE_MARGIN * 2,
|
|
height: cardHeight + FIGURE_MARGIN * 2 + header
|
|
};
|
|
}
|
|
|
|
function paintFigure(ctx, layout) {
|
|
var m = FIGURE_MARGIN;
|
|
ctx.fillStyle = layout.bg;
|
|
ctx.fillRect(0, 0, layout.width, layout.height);
|
|
if (layout.title) {
|
|
var titleMid = m + 16;
|
|
ctx.fillStyle = layout.accent;
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.beginPath(); ctx.arc(m + 5, titleMid, 9, 0, Math.PI * 2); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
ctx.beginPath(); ctx.arc(m + 5, titleMid, 5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = layout.text;
|
|
ctx.fillText(fitCanvasText(ctx, layout.title, layout.width - m * 2 - 22, FIGURE_TITLE_SIZE, 14, '650', layout.family), m + 22, titleMid + 1);
|
|
if (layout.subtitle) {
|
|
ctx.fillStyle = layout.muted;
|
|
ctx.fillText(fitCanvasText(ctx, layout.subtitle, layout.width - m * 2, FIGURE_SUBTITLE_SIZE, 11, '400', layout.family), m, titleMid + 30);
|
|
}
|
|
ctx.textBaseline = 'alphabetic';
|
|
}
|
|
var x = m, y = m + layout.header, w = layout.cardWidth, h = layout.cardHeight, r = 16;
|
|
function cardPath() {
|
|
ctx.beginPath();
|
|
if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, w, h, r);
|
|
else ctx.rect(x, y, w, h);
|
|
}
|
|
ctx.save();
|
|
if (layout.light) {
|
|
ctx.shadowColor = 'rgba(15, 23, 42, 0.08)';
|
|
ctx.shadowBlur = 28;
|
|
ctx.shadowOffsetY = 10;
|
|
}
|
|
cardPath();
|
|
ctx.fillStyle = layout.bg;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
cardPath();
|
|
ctx.fillStyle = layout.cardFill;
|
|
ctx.fill();
|
|
if (layout.dot) {
|
|
ctx.save();
|
|
cardPath();
|
|
ctx.clip();
|
|
ctx.fillStyle = layout.dot;
|
|
for (var dy = y + 1; dy < y + h; dy += 20) {
|
|
for (var dx = x + 1; dx < x + w; dx += 20) {
|
|
ctx.beginPath(); ctx.arc(dx, dy, 1, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
cardPath();
|
|
ctx.strokeStyle = layout.cardBorder;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
return { x: x + FIGURE_CARD_PADDING, y: y + FIGURE_CARD_PADDING };
|
|
}
|
|
|
|
function rasterize(format) {
|
|
// Serialize at a safe scale (RASTER_SCALE=4 by default, reduced if the
|
|
// resulting canvas would exceed MAX_CANVAS_PIXELS). The SVG itself is
|
|
// rasterized at target resolution natively; drawImage draws at natural
|
|
// size — no upsampling blur.
|
|
var svg = document.querySelector('.diagram-container svg');
|
|
var vb = svg.viewBox.baseVal;
|
|
var layout = figureLayout(vb);
|
|
var scale = pickSafeScale(layout.width, layout.height);
|
|
var data = serializeSvg(scale, { figure: true });
|
|
var svgBlob = new Blob([data.svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
var svgUrl = URL.createObjectURL(svgBlob);
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
try {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = Math.round(layout.width * scale);
|
|
canvas.height = Math.round(layout.height * scale);
|
|
var ctx = canvas2dOrThrow(canvas, format);
|
|
// Paint in CSS pixels; the SVG image is already rendered at scale,
|
|
// so drawing it at CSS size under this transform stays 1:1.
|
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
var origin = paintFigure(ctx, layout);
|
|
ctx.drawImage(img, origin.x, origin.y, vb.width, vb.height);
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
URL.revokeObjectURL(svgUrl);
|
|
var mime = format === 'jpeg' ? 'image/jpeg' :
|
|
format === 'webp' ? 'image/webp' : 'image/png';
|
|
var quality = format === 'png' ? undefined : 0.95;
|
|
canvas.toBlob(function (blob) {
|
|
if (!blob) reject(exportError('viewer.export.error.toBlobNull', { label: format }));
|
|
else resolve(blob);
|
|
}, mime, quality);
|
|
} catch (error) {
|
|
URL.revokeObjectURL(svgUrl);
|
|
reject(error);
|
|
}
|
|
};
|
|
img.onerror = function (e) {
|
|
URL.revokeObjectURL(svgUrl);
|
|
reject(e);
|
|
};
|
|
img.src = svgUrl;
|
|
});
|
|
}
|
|
|
|
function fitCanvasText(ctx, text, maxWidth, startSize, minSize, weight, fontFamily) {
|
|
var value = String(text || '').trim();
|
|
var size = startSize;
|
|
var family = fontFamily || "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
while (size > minSize) {
|
|
ctx.font = (weight || '600') + ' ' + size + 'px ' + family;
|
|
if (ctx.measureText(value).width <= maxWidth) return value;
|
|
size -= 1;
|
|
}
|
|
ctx.font = (weight || '600') + ' ' + minSize + 'px ' + family;
|
|
if (ctx.measureText(value).width <= maxWidth) return value;
|
|
var suffix = '\u2026';
|
|
while (value.length > 1 && ctx.measureText(value + suffix).width > maxWidth) {
|
|
value = value.slice(0, -1);
|
|
}
|
|
return value + suffix;
|
|
}
|
|
|
|
function canvas2dOrThrow(canvas, label) {
|
|
if (!canvas || typeof canvas.getContext !== 'function') {
|
|
throw exportError('viewer.export.error.canvasUnavailable', { label: label });
|
|
}
|
|
var ctx = canvas.getContext('2d');
|
|
if (!ctx) throw exportError('viewer.export.error.contextUnavailable', { label: label });
|
|
if (typeof canvas.toBlob !== 'function') throw exportError('viewer.export.error.toBlobUnavailable', { label: label });
|
|
return ctx;
|
|
}
|
|
|
|
function renderShareCard(options) {
|
|
var routeSnapshot = options.routeSnapshot || null;
|
|
var reachSnapshot = options.reachSnapshot || null;
|
|
if (routeSnapshot && reachSnapshot) return Promise.reject(exportError('viewer.export.error.variantsCombined'));
|
|
var svg = document.querySelector('.diagram-container svg');
|
|
var vb = svg.viewBox.baseVal;
|
|
var sourceScale = Math.min(2, pickSafeScale(vb.width, vb.height));
|
|
var data = serializeSvg(sourceScale, { routeSnapshot: routeSnapshot, reachSnapshot: reachSnapshot, figure: true });
|
|
if (!data.canonicalStateClean) return Promise.reject(exportError('viewer.export.error.viewerState'));
|
|
if (routeSnapshot && !data.routeStateClean) return Promise.reject(exportError('viewer.export.error.routeState'));
|
|
if (reachSnapshot && !data.reachStateClean) return Promise.reject(exportError('viewer.export.error.reachState'));
|
|
var svgBlob = new Blob([data.svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
var svgUrl = URL.createObjectURL(svgBlob);
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
try {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = SHARE_CARD_WIDTH;
|
|
canvas.height = SHARE_CARD_HEIGHT;
|
|
var ctx = canvas2dOrThrow(canvas, viewerText('viewer.export.shareCard'));
|
|
var computed = getComputedStyle(document.documentElement);
|
|
var bg = computed.getPropertyValue('--bg').trim() || currentBg();
|
|
var text = computed.getPropertyValue('--text').trim() || '#ffffff';
|
|
var muted = computed.getPropertyValue('--text-muted').trim() || '#94a3b8';
|
|
var border = computed.getPropertyValue('--panel-border').trim() || '#334155';
|
|
var accentProperty = reachSnapshot
|
|
? (reachSnapshot.direction === 'upstream' ? '--database-stroke' : '--backend-stroke')
|
|
: '--frontend-stroke';
|
|
var accent = computed.getPropertyValue(accentProperty).trim() || '#22d3ee';
|
|
var titleNode = document.querySelector('.header h1');
|
|
var title = titleNode ? titleNode.textContent : document.title;
|
|
var directionLabel = reachSnapshot
|
|
? viewerText('viewer.export.direction.' + reachSnapshot.direction)
|
|
: '';
|
|
var subtitle = routeSnapshot
|
|
? viewerCount('viewer.export.card.routeSummary', routeSnapshot.hops, {
|
|
source: routeSnapshot.source.label,
|
|
target: routeSnapshot.target.label
|
|
})
|
|
: viewerText('viewer.export.card.reachSummary', {
|
|
direction: directionLabel,
|
|
origin: reachSnapshot.origin.label,
|
|
nodes: viewerCount('viewer.export.card.node', reachSnapshot.nodeIds.length - 1),
|
|
links: viewerCount('viewer.export.card.link', reachSnapshot.edges.length),
|
|
hops: viewerCount('viewer.export.card.hop', reachSnapshot.maxDepth)
|
|
});
|
|
var cardLabel = routeSnapshot
|
|
? viewerText('viewer.export.card.routeBadge', {
|
|
hops: viewerCount('viewer.export.card.hop', routeSnapshot.hops).toUpperCase()
|
|
})
|
|
: viewerText('viewer.export.card.reachBadge', { direction: directionLabel.toUpperCase() });
|
|
|
|
var family = titleNode ? getComputedStyle(titleNode).fontFamily : 'sans-serif';
|
|
var panelFill = bg;
|
|
|
|
ctx.fillStyle = bg;
|
|
ctx.fillRect(0, 0, SHARE_CARD_WIDTH, SHARE_CARD_HEIGHT);
|
|
|
|
// Header: a short accent rule and badge over the title and summary.
|
|
ctx.fillStyle = accent;
|
|
ctx.fillRect(SHARE_CARD_PADDING, 34, 28, 3);
|
|
ctx.textBaseline = 'alphabetic';
|
|
ctx.font = '600 12px ' + family;
|
|
ctx.textAlign = 'right';
|
|
ctx.fillStyle = muted;
|
|
ctx.fillText(cardLabel, SHARE_CARD_WIDTH - SHARE_CARD_PADDING, 40);
|
|
ctx.textAlign = 'left';
|
|
|
|
ctx.fillStyle = text;
|
|
var fittedTitle = fitCanvasText(ctx, title, SHARE_CARD_WIDTH - SHARE_CARD_PADDING * 2 - 260, 30, 18, '650', family);
|
|
ctx.fillText(fittedTitle, SHARE_CARD_PADDING, 74);
|
|
|
|
ctx.fillStyle = muted;
|
|
var fittedSubtitle = fitCanvasText(ctx, subtitle, SHARE_CARD_WIDTH - SHARE_CARD_PADDING * 2, 15, 12, '400', family);
|
|
ctx.fillText(fittedSubtitle, SHARE_CARD_PADDING, 99);
|
|
|
|
// The diagram sits on one soft rounded panel with an even inset.
|
|
var panelX = SHARE_CARD_PADDING;
|
|
var panelY = SHARE_CARD_HEADER;
|
|
var panelWidth = SHARE_CARD_WIDTH - SHARE_CARD_PADDING * 2;
|
|
var panelHeight = SHARE_CARD_HEIGHT - SHARE_CARD_HEADER - SHARE_CARD_PADDING;
|
|
var inset = 18;
|
|
ctx.beginPath();
|
|
if (typeof ctx.roundRect === 'function') ctx.roundRect(panelX, panelY, panelWidth, panelHeight, 14);
|
|
else ctx.rect(panelX, panelY, panelWidth, panelHeight);
|
|
ctx.fillStyle = panelFill;
|
|
ctx.fill();
|
|
ctx.strokeStyle = border;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
var availableWidth = panelWidth - inset * 2;
|
|
var availableHeight = panelHeight - inset * 2;
|
|
var fit = Math.min(availableWidth / data.width, availableHeight / data.height);
|
|
var drawWidth = data.width * fit;
|
|
var drawHeight = data.height * fit;
|
|
var drawX = panelX + inset + (availableWidth - drawWidth) / 2;
|
|
var drawY = panelY + inset + (availableHeight - drawHeight) / 2;
|
|
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
|
URL.revokeObjectURL(svgUrl);
|
|
|
|
canvas.toBlob(function (blob) {
|
|
if (!blob) reject(exportError('viewer.export.error.toBlobNull', { label: viewerText('viewer.export.shareCard') }));
|
|
else resolve(blob);
|
|
}, 'image/png');
|
|
} catch (error) {
|
|
URL.revokeObjectURL(svgUrl);
|
|
reject(error);
|
|
}
|
|
};
|
|
img.onerror = function (e) {
|
|
URL.revokeObjectURL(svgUrl);
|
|
reject(e);
|
|
};
|
|
img.src = svgUrl;
|
|
});
|
|
}
|
|
|
|
function rasterizeShareCard(options) {
|
|
options = options || {};
|
|
if (options.variant !== 'route' && options.variant !== 'reach') {
|
|
return Promise.reject(exportError('viewer.export.unknownVariant', { variant: options.variant }));
|
|
}
|
|
if (options.variant === 'route') {
|
|
var routeSnapshot = Archify.routeProbe && Archify.routeProbe.exportSnapshot();
|
|
if (!routeSnapshot) return Promise.reject(exportError('viewer.export.routeRequired'));
|
|
return renderShareCard({ routeSnapshot: routeSnapshot });
|
|
}
|
|
var snapshot = Archify.focus && typeof Archify.focus.reachabilitySnapshot === 'function'
|
|
? Archify.focus.reachabilitySnapshot()
|
|
: null;
|
|
if (!snapshot) return Promise.reject(exportError('viewer.export.reachRequired'));
|
|
return renderShareCard({ reachSnapshot: snapshot });
|
|
}
|
|
|
|
function motionMimeType() {
|
|
if (typeof MediaRecorder === 'undefined') return '';
|
|
var candidates = [
|
|
'video/webm;codecs=vp9',
|
|
'video/webm;codecs=vp8',
|
|
'video/webm'
|
|
];
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
if (!MediaRecorder.isTypeSupported || MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function canRecordMotion() {
|
|
var svg = document.querySelector('.diagram-container svg');
|
|
return !!(svg && svg.getAttribute('data-animation') === 'trace' &&
|
|
typeof MediaRecorder !== 'undefined' && motionMimeType() &&
|
|
typeof HTMLCanvasElement !== 'undefined' &&
|
|
typeof HTMLCanvasElement.prototype.captureStream === 'function');
|
|
}
|
|
|
|
// Record the live CSS animation without Puppeteer, ffmpeg, or a network
|
|
// dependency. SVG files loaded through Image are commonly rasterized as
|
|
// one cached bitmap, so repeatedly drawing that Image does not reliably
|
|
// advance its CSS animation. Keep one crisp static SVG background, then
|
|
// render an explicit time-varying signal scene over the real authored
|
|
// relationship geometry on every captured canvas frame.
|
|
function recordWebm(options) {
|
|
options = options || {};
|
|
if (!canRecordMotion()) {
|
|
return Promise.reject(exportError('viewer.export.error.webmRequirements'));
|
|
}
|
|
var duration = Math.max(250, Number(options.duration) || MOTION_DURATION);
|
|
var fps = Math.max(1, Number(options.fps) || MOTION_FPS);
|
|
var svg = document.querySelector('.diagram-container svg');
|
|
var vb = svg.viewBox.baseVal;
|
|
var scale = Math.min(1, 1280 / vb.width);
|
|
var data = serializeSvg(scale);
|
|
var sourceUrl = URL.createObjectURL(new Blob([data.svgString], { type: 'image/svg+xml;charset=utf-8' }));
|
|
|
|
function createMotionScene(root) {
|
|
var rootMatrix = root.getCTM ? root.getCTM() : null;
|
|
|
|
function pointInRoot(element, point) {
|
|
if (!rootMatrix || !element.getCTM || !point.matrixTransform) return { x: point.x, y: point.y };
|
|
try {
|
|
var elementMatrix = element.getCTM();
|
|
if (!elementMatrix) return { x: point.x, y: point.y };
|
|
var mapped = point.matrixTransform(rootMatrix.inverse().multiply(elementMatrix));
|
|
return { x: mapped.x, y: mapped.y };
|
|
} catch (_) {
|
|
return { x: point.x, y: point.y };
|
|
}
|
|
}
|
|
|
|
function samplesFor(element) {
|
|
if (typeof element.getTotalLength !== 'function' || typeof element.getPointAtLength !== 'function') return [];
|
|
var length;
|
|
try { length = element.getTotalLength(); } catch (_) { return []; }
|
|
if (!Number.isFinite(length) || length <= 0) return [];
|
|
var count = Math.max(12, Math.min(72, Math.ceil(length / 12)));
|
|
var points = [];
|
|
for (var i = 0; i <= count; i++) {
|
|
points.push(pointInRoot(element, element.getPointAtLength(length * i / count)));
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function authoredStep(element, fallback) {
|
|
var raw = element.style.getPropertyValue('--step') || getComputedStyle(element).getPropertyValue('--step');
|
|
var value = Number(raw);
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
var edges = Array.prototype.slice.call(root.querySelectorAll('[data-animate="edge"]')).map(function (element, index) {
|
|
var computed = getComputedStyle(element);
|
|
return {
|
|
points: samplesFor(element),
|
|
color: computed.stroke && computed.stroke !== 'none' ? computed.stroke : '#22d3ee',
|
|
width: Math.max(1.5, parseFloat(computed.strokeWidth) || 1.5),
|
|
delay: Math.min(12, authoredStep(element, index)) * 0.16
|
|
};
|
|
}).filter(function (edge) { return edge.points.length > 1; });
|
|
|
|
var nodes = Array.prototype.slice.call(root.querySelectorAll('[data-node-id][data-animate="node"]')).map(function (element, index) {
|
|
var box;
|
|
try { box = element.getBBox(); } catch (_) { return null; }
|
|
var painted = element.querySelector('[class*="c-"]') || element;
|
|
var computed = getComputedStyle(painted);
|
|
return {
|
|
x: box.x,
|
|
y: box.y,
|
|
width: box.width,
|
|
height: box.height,
|
|
color: computed.stroke && computed.stroke !== 'none' ? computed.stroke : '#22d3ee',
|
|
delay: Math.min(12, authoredStep(element, index)) * 0.16
|
|
};
|
|
}).filter(Boolean);
|
|
|
|
return { edges: edges, nodes: nodes, x: vb.x, y: vb.y, width: vb.width, height: vb.height };
|
|
}
|
|
|
|
function pointAlong(points, progress) {
|
|
var scaled = Math.max(0, Math.min(1, progress)) * (points.length - 1);
|
|
var index = Math.min(points.length - 2, Math.floor(scaled));
|
|
var mix = scaled - index;
|
|
return {
|
|
x: points[index].x + (points[index + 1].x - points[index].x) * mix,
|
|
y: points[index].y + (points[index + 1].y - points[index].y) * mix
|
|
};
|
|
}
|
|
|
|
function roundedRectPath(ctx, x, y, width, height, radius) {
|
|
var r = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r, y);
|
|
ctx.lineTo(x + width - r, y);
|
|
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
ctx.lineTo(x + width, y + height - r);
|
|
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
ctx.lineTo(x + r, y + height);
|
|
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
ctx.lineTo(x, y + r);
|
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
function drawMotionFrame(ctx, backgroundImage, motionScene, elapsed) {
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
ctx.drawImage(backgroundImage, 0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
ctx.save();
|
|
ctx.scale(ctx.canvas.width / motionScene.width, ctx.canvas.height / motionScene.height);
|
|
ctx.translate(-motionScene.x, -motionScene.y);
|
|
|
|
motionScene.edges.forEach(function (edge) {
|
|
var duration = 1.75;
|
|
var progress = (elapsed - edge.delay) / duration;
|
|
if (progress < 0 || progress > 1) return;
|
|
var head = Math.max(0, Math.min(1, progress));
|
|
var tail = Math.max(0, head - 0.24);
|
|
var opacity = Math.sin(Math.PI * Math.min(1, progress));
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = edge.color;
|
|
ctx.fillStyle = edge.color;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineWidth = Math.max(3.2, edge.width * 2.2);
|
|
ctx.globalAlpha = 0.42 + opacity * 0.5;
|
|
ctx.shadowColor = edge.color;
|
|
ctx.shadowBlur = 10;
|
|
ctx.beginPath();
|
|
for (var i = 0; i <= 14; i++) {
|
|
var trailPoint = pointAlong(edge.points, tail + (head - tail) * i / 14);
|
|
if (i === 0) ctx.moveTo(trailPoint.x, trailPoint.y);
|
|
else ctx.lineTo(trailPoint.x, trailPoint.y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
var point = pointAlong(edge.points, head);
|
|
ctx.globalAlpha = 0.95;
|
|
ctx.beginPath();
|
|
ctx.arc(point.x, point.y, 4.8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
});
|
|
|
|
motionScene.nodes.forEach(function (node) {
|
|
var progress = (elapsed - node.delay) / 2.6;
|
|
if (progress < 0 || progress > 1) return;
|
|
var pulse = Math.sin(Math.PI * progress);
|
|
if (pulse <= 0.02) return;
|
|
ctx.save();
|
|
ctx.strokeStyle = node.color;
|
|
ctx.lineWidth = 1.4 + pulse * 1.8;
|
|
ctx.globalAlpha = pulse * 0.5;
|
|
ctx.shadowColor = node.color;
|
|
ctx.shadowBlur = 12 * pulse;
|
|
var inset = 2 + pulse * 3;
|
|
roundedRectPath(ctx, node.x - inset, node.y - inset, node.width + inset * 2, node.height + inset * 2, 8);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
var motionScene = createMotionScene(svg);
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
var backgroundImage = new Image();
|
|
backgroundImage.onload = function () {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(2, Math.round(data.width / 2) * 2);
|
|
canvas.height = Math.max(2, Math.round(data.height / 2) * 2);
|
|
var ctx = canvas.getContext('2d');
|
|
var stream = canvas.captureStream(fps);
|
|
var mime = motionMimeType();
|
|
var recorder;
|
|
try {
|
|
recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 6000000 });
|
|
} catch (err) {
|
|
URL.revokeObjectURL(sourceUrl);
|
|
stream.getTracks().forEach(function (track) { track.stop(); });
|
|
reject(err);
|
|
return;
|
|
}
|
|
var chunks = [];
|
|
var raf = 0;
|
|
var stopped = false;
|
|
var startedAt = performance.now();
|
|
function cleanup() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
cancelAnimationFrame(raf);
|
|
URL.revokeObjectURL(sourceUrl);
|
|
stream.getTracks().forEach(function (track) { track.stop(); });
|
|
}
|
|
function draw(now) {
|
|
var elapsed = Math.max(0, ((Number(now) || performance.now()) - startedAt) / 1000);
|
|
drawMotionFrame(ctx, backgroundImage, motionScene, elapsed);
|
|
raf = requestAnimationFrame(draw);
|
|
}
|
|
recorder.ondataavailable = function (event) {
|
|
if (event.data && event.data.size) chunks.push(event.data);
|
|
};
|
|
recorder.onerror = function (event) {
|
|
cleanup();
|
|
reject(event.error || exportError('viewer.export.error.mediaRecorder'));
|
|
};
|
|
recorder.onstop = function () {
|
|
cleanup();
|
|
var blob = new Blob(chunks, { type: recorder.mimeType || mime });
|
|
if (!blob.size) reject(exportError('viewer.export.error.emptyWebm'));
|
|
else resolve(blob);
|
|
};
|
|
drawMotionFrame(ctx, backgroundImage, motionScene, 0);
|
|
recorder.start(250);
|
|
startedAt = performance.now();
|
|
raf = requestAnimationFrame(draw);
|
|
setTimeout(function () {
|
|
if (recorder.state === 'inactive') return;
|
|
// Chromium normally emits the final chunk during stop(), but
|
|
// some embedded browser shells need an explicit encoder flush
|
|
// first. Keep the short grace window bounded and harmless for
|
|
// browsers that already delivered timeslice chunks.
|
|
try { recorder.requestData(); } catch (_) {}
|
|
setTimeout(function () {
|
|
if (recorder.state !== 'inactive') recorder.stop();
|
|
}, 120);
|
|
}, duration);
|
|
};
|
|
backgroundImage.onerror = function () {
|
|
URL.revokeObjectURL(sourceUrl);
|
|
reject(exportError('viewer.export.error.webmBackground'));
|
|
};
|
|
backgroundImage.src = sourceUrl;
|
|
});
|
|
}
|
|
|
|
var menu = document.getElementById('export-menu');
|
|
var btn = document.getElementById('btn-export');
|
|
var routeShareItem = menu.querySelector('button[data-action="route-share-card"]');
|
|
var reachShareItem = menu.querySelector('button[data-action="reach-share-card"]');
|
|
var items = function () {
|
|
return Array.prototype.slice.call(menu.querySelectorAll('button[role="menuitem"]'));
|
|
};
|
|
|
|
function syncRouteShareItem() {
|
|
var snapshot = Archify.routeProbe && typeof Archify.routeProbe.exportSnapshot === 'function'
|
|
? Archify.routeProbe.exportSnapshot()
|
|
: null;
|
|
routeShareItem.hidden = !snapshot;
|
|
routeShareItem.disabled = !snapshot;
|
|
return snapshot;
|
|
}
|
|
|
|
function syncReachShareItem() {
|
|
var snapshot = Archify.focus && typeof Archify.focus.reachabilitySnapshot === 'function'
|
|
? Archify.focus.reachabilitySnapshot()
|
|
: null;
|
|
reachShareItem.hidden = !snapshot;
|
|
reachShareItem.disabled = !snapshot;
|
|
return snapshot;
|
|
}
|
|
|
|
// ---- Clipboard support ----------------------------------------------
|
|
function canCopyImage() {
|
|
return typeof ClipboardItem !== 'undefined' &&
|
|
navigator.clipboard &&
|
|
typeof navigator.clipboard.write === 'function';
|
|
}
|
|
|
|
// ---- Raster format detection ----------------------------------------
|
|
// canvas.toBlob('image/webp') silently returns a PNG on browsers without
|
|
// WebP encoding (older Safari), so detect explicitly.
|
|
function supports(format) {
|
|
if (format === 'svg' || format === 'svg-light' || format === 'svg-dark' || format === 'png') return true;
|
|
if (format === 'webm') return canRecordMotion();
|
|
var mime = format === 'jpeg' ? 'image/jpeg' : 'image/webp';
|
|
try {
|
|
var c = document.createElement('canvas');
|
|
c.width = c.height = 2;
|
|
return c.toDataURL(mime).indexOf('data:' + mime) === 0;
|
|
} catch (_) { return false; }
|
|
}
|
|
|
|
// Gray out unsupported items.
|
|
items().forEach(function (it) {
|
|
if (it.dataset.format && !supports(it.dataset.format)) {
|
|
it.disabled = true;
|
|
it.title = viewerText('viewer.export.unsupported');
|
|
}
|
|
if (it.dataset.action === 'copy' && !canCopyImage()) {
|
|
it.disabled = true;
|
|
it.title = viewerText('viewer.export.clipboardUnsupported');
|
|
}
|
|
});
|
|
|
|
// ---- Toast ----------------------------------------------------------
|
|
// A live region only announces CHANGES to an existing node, so the toast
|
|
// element is created once up front and updated in place.
|
|
var toastEl = document.createElement('div');
|
|
toastEl.className = 'archify-toast';
|
|
toastEl.setAttribute('role', 'status');
|
|
document.body.appendChild(toastEl);
|
|
var toastTimer = null;
|
|
|
|
function toast(msg) {
|
|
toastEl.textContent = '';
|
|
requestAnimationFrame(function () {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.add('show');
|
|
});
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1500);
|
|
}
|
|
|
|
function open(focusLast) {
|
|
if (Archify.preset && Archify.preset.isOpen()) Archify.preset.close(false);
|
|
if (Archify.semanticLens && typeof Archify.semanticLens.clearPreview === 'function') Archify.semanticLens.clearPreview();
|
|
if (Archify.semanticLens && Archify.semanticLens.isOpen()) Archify.semanticLens.close({ restoreFocus: false });
|
|
syncRouteShareItem();
|
|
syncReachShareItem();
|
|
menu.classList.add('open');
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
var available = items().filter(function (i) { return !i.hidden && !i.disabled; });
|
|
var target = focusLast ? available[available.length - 1] : available[0];
|
|
if (target) target.focus();
|
|
}
|
|
function close(focusTrigger) {
|
|
menu.classList.remove('open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
if (focusTrigger) btn.focus();
|
|
}
|
|
function isOpen() { return menu.classList.contains('open'); }
|
|
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (isOpen()) close(false);
|
|
else open();
|
|
});
|
|
// APG menu-button pattern: ArrowDown/ArrowUp on the trigger opens the menu.
|
|
btn.addEventListener('keydown', function (e) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!isOpen()) open(e.key === 'ArrowUp');
|
|
}
|
|
});
|
|
document.addEventListener('click', function (e) {
|
|
if (!menu.contains(e.target) && e.target !== btn) close(false);
|
|
});
|
|
|
|
// Keyboard navigation inside the menu.
|
|
// Menu items: Up/Down, Home/End.
|
|
// Esc closes + returns focus to trigger; Tab closes.
|
|
menu.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); close(true); return; }
|
|
if (e.key === 'Tab') { close(false); return; }
|
|
|
|
var available = items().filter(function (i) { return !i.hidden && !i.disabled; });
|
|
var current = available.indexOf(document.activeElement);
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
if (available.length) available[(current + 1 + available.length) % available.length].focus();
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
if (available.length) available[(current - 1 + available.length) % available.length].focus();
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
if (available[0]) available[0].focus();
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
if (available.length) available[available.length - 1].focus();
|
|
break;
|
|
}
|
|
});
|
|
|
|
function runExport(format) {
|
|
if (['svg', 'svg-light', 'svg-dark', 'png', 'jpeg', 'webp', 'webm'].indexOf(format) === -1) {
|
|
return Promise.reject(exportError('viewer.export.unsupported'));
|
|
}
|
|
var base = diagramFilename();
|
|
var svgTheme = format === 'svg' ? 'auto' :
|
|
format === 'svg-light' ? 'light' :
|
|
format === 'svg-dark' ? 'dark' : null;
|
|
close(true);
|
|
clearExportReceipt();
|
|
if (format === 'webm') toast(viewerText('viewer.export.recording'));
|
|
return (svgTheme
|
|
? Promise.resolve(serializeSvg(1, { theme: svgTheme })).then(function (d) {
|
|
var blob = new Blob([d.svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
recordExportReceipt('svg', blob, d.canonicalStateClean);
|
|
download(blob, base + (svgTheme === 'auto' ? '' : '-' + svgTheme) + '.svg');
|
|
})
|
|
: format === 'webm'
|
|
? recordWebm().then(function (blob) {
|
|
document.documentElement.setAttribute('data-last-motion-bytes', String(blob.size));
|
|
recordExportReceipt('webm', blob, true);
|
|
download(blob, base + '.webm');
|
|
toast(viewerText('viewer.export.downloadedWebm'));
|
|
})
|
|
: rasterize(format).then(function (blob) {
|
|
recordExportReceipt(format, blob, true);
|
|
download(blob, base + '.' + format);
|
|
})
|
|
).catch(function (err) {
|
|
console.error(err);
|
|
var technicalMessage = err && err.message ? err.message : format;
|
|
var message = exportMessage(err);
|
|
document.documentElement.setAttribute('data-last-export-error-format', format);
|
|
document.documentElement.setAttribute('data-last-export-error', technicalMessage);
|
|
if (format === 'webm') {
|
|
var motionItem = menu.querySelector('button[data-format="webm"]');
|
|
if (motionItem) {
|
|
motionItem.disabled = true;
|
|
motionItem.title = viewerText('viewer.export.motionUnavailable');
|
|
motionItem.style.opacity = '0.5';
|
|
}
|
|
toast(viewerText('viewer.export.webmUnavailable'));
|
|
return;
|
|
}
|
|
alert(viewerText('viewer.export.failed', { message: message }));
|
|
});
|
|
}
|
|
|
|
function runRouteShareCard() {
|
|
close(false);
|
|
clearExportReceipt();
|
|
var snapshot = Archify.routeProbe && Archify.routeProbe.exportSnapshot();
|
|
var blobPromise = snapshot
|
|
? renderShareCard({ routeSnapshot: snapshot })
|
|
: Promise.reject(exportError('viewer.export.routeRequired'));
|
|
return blobPromise.then(function (blob) {
|
|
recordExportReceipt('share-card', blob, false, { width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT }, 'route', true);
|
|
download(blob, diagramFilename() + '-route-share-card.png');
|
|
toast(viewerText('viewer.export.downloadedRoute'));
|
|
return blob;
|
|
}).catch(function (err) {
|
|
console.error(err);
|
|
var technicalMessage = err && err.message ? err.message : 'share-card';
|
|
var message = exportMessage(err);
|
|
document.documentElement.setAttribute('data-last-export-error-format', 'share-card');
|
|
document.documentElement.setAttribute('data-last-export-error', technicalMessage);
|
|
alert(viewerText('viewer.export.routeFailed', { message: message }));
|
|
});
|
|
}
|
|
|
|
function runReachShareCard() {
|
|
close(false);
|
|
clearExportReceipt();
|
|
var snapshot = Archify.focus && typeof Archify.focus.reachabilitySnapshot === 'function'
|
|
? Archify.focus.reachabilitySnapshot()
|
|
: null;
|
|
var blobPromise = snapshot
|
|
? renderShareCard({ reachSnapshot: snapshot })
|
|
: Promise.reject(exportError('viewer.export.reachRequired'));
|
|
return blobPromise.then(function (blob) {
|
|
recordExportReceipt('share-card', blob, false, { width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT }, 'reach', false, true);
|
|
download(blob, diagramFilename() + '-' + snapshot.direction + '-reach-share-card.png');
|
|
toast(viewerText('viewer.export.downloadedReach'));
|
|
return blob;
|
|
}).catch(function (err) {
|
|
console.error(err);
|
|
var technicalMessage = err && err.message ? err.message : 'share-card';
|
|
var message = exportMessage(err);
|
|
document.documentElement.setAttribute('data-last-export-error-format', 'share-card');
|
|
document.documentElement.setAttribute('data-last-export-error', technicalMessage);
|
|
alert(viewerText('viewer.export.reachFailed', { message: message }));
|
|
});
|
|
}
|
|
|
|
// A compact, machine-readable receipt for local QA and generated proof
|
|
// galleries. It exposes no diagram contents, only the completed format,
|
|
// byte count, and whether temporary viewer state was excluded.
|
|
function recordExportReceipt(format, blob, canonical, dimensions, variant, routeStateClean, reachStateClean) {
|
|
document.documentElement.setAttribute('data-last-export-format', format);
|
|
document.documentElement.setAttribute('data-last-export-bytes', String(blob.size));
|
|
document.documentElement.setAttribute('data-last-export-canonical', canonical ? 'true' : 'false');
|
|
if (variant) {
|
|
document.documentElement.setAttribute('data-last-export-variant', variant);
|
|
} else {
|
|
document.documentElement.removeAttribute('data-last-export-variant');
|
|
}
|
|
if (routeStateClean === true) {
|
|
document.documentElement.setAttribute('data-last-export-route-state-clean', 'true');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-last-export-route-state-clean');
|
|
}
|
|
if (reachStateClean === true) {
|
|
document.documentElement.setAttribute('data-last-export-reach-state-clean', 'true');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-last-export-reach-state-clean');
|
|
}
|
|
if (dimensions) {
|
|
document.documentElement.setAttribute('data-last-export-width', String(dimensions.width));
|
|
document.documentElement.setAttribute('data-last-export-height', String(dimensions.height));
|
|
} else {
|
|
document.documentElement.removeAttribute('data-last-export-width');
|
|
document.documentElement.removeAttribute('data-last-export-height');
|
|
}
|
|
}
|
|
|
|
function clearExportReceipt() {
|
|
document.documentElement.removeAttribute('data-last-export-format');
|
|
document.documentElement.removeAttribute('data-last-export-bytes');
|
|
document.documentElement.removeAttribute('data-last-export-canonical');
|
|
document.documentElement.removeAttribute('data-last-export-width');
|
|
document.documentElement.removeAttribute('data-last-export-height');
|
|
document.documentElement.removeAttribute('data-last-export-variant');
|
|
document.documentElement.removeAttribute('data-last-export-route-state-clean');
|
|
document.documentElement.removeAttribute('data-last-export-reach-state-clean');
|
|
document.documentElement.removeAttribute('data-last-export-error-format');
|
|
document.documentElement.removeAttribute('data-last-export-error');
|
|
}
|
|
|
|
function writePngToClipboard(blobPromise) {
|
|
// WebKit requires ClipboardItem to be constructed synchronously inside
|
|
// the user gesture. Chromium accepts the same pending Promise<Blob>;
|
|
// engines that reject promise values fall back to an awaited Blob.
|
|
try {
|
|
return navigator.clipboard.write([new ClipboardItem({ 'image/png': blobPromise })]);
|
|
} catch (_) {
|
|
return blobPromise.then(function (blob) {
|
|
return navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
});
|
|
}
|
|
}
|
|
|
|
function runCopy() {
|
|
close(true);
|
|
clearExportReceipt();
|
|
if (!canCopyImage()) {
|
|
alert(viewerText('viewer.export.clipboardUnsupported.short'));
|
|
return;
|
|
}
|
|
var blobPromise = rasterize('png');
|
|
return writePngToClipboard(blobPromise).then(function () {
|
|
toast(viewerText('viewer.export.copiedPng'));
|
|
}).catch(function (err) {
|
|
console.error(err);
|
|
alert(viewerText('viewer.export.copyFailed', {
|
|
message: exportMessage(err)
|
|
}));
|
|
});
|
|
}
|
|
|
|
menu.addEventListener('click', function (e) {
|
|
var routeShareCardBtn = e.target.closest('button[data-action="route-share-card"]');
|
|
if (routeShareCardBtn && !routeShareCardBtn.disabled && !routeShareCardBtn.hidden) { runRouteShareCard(); return; }
|
|
|
|
var reachShareCardBtn = e.target.closest('button[data-action="reach-share-card"]');
|
|
if (reachShareCardBtn && !reachShareCardBtn.disabled && !reachShareCardBtn.hidden) { runReachShareCard(); return; }
|
|
|
|
var copyBtn = e.target.closest('button[data-action="copy"]');
|
|
if (copyBtn && !copyBtn.disabled) { runCopy(); return; }
|
|
|
|
var formatBtn = e.target.closest('button[data-format]');
|
|
if (formatBtn && !formatBtn.disabled) { runExport(formatBtn.dataset.format); }
|
|
});
|
|
|
|
Archify.motion = { canRecord: canRecordMotion, recordWebm: recordWebm };
|
|
Archify.exportMenu = {
|
|
open: open,
|
|
close: close,
|
|
isOpen: isOpen,
|
|
run: runExport,
|
|
shareCard: rasterizeShareCard,
|
|
downloadRouteShareCard: runRouteShareCard,
|
|
downloadReachShareCard: runReachShareCard,
|
|
syncRouteShare: syncRouteShareItem,
|
|
syncReachShare: syncReachShareItem
|
|
};
|
|
|
|
// Auto-open on page load for demo/screenshot purposes: ?openExport=1
|
|
// Wait for fonts (so the menu doesn't flash before typography lands)
|
|
// and paint before opening. Fallback timeout for browsers without the
|
|
// Font Loading API.
|
|
try {
|
|
if (new URLSearchParams(window.location.search).get('openExport') === '1') {
|
|
var openWhenReady = function () {
|
|
requestAnimationFrame(function () { requestAnimationFrame(open); });
|
|
};
|
|
if (document.fonts && document.fonts.ready) {
|
|
document.fonts.ready.then(openWhenReady);
|
|
} else {
|
|
setTimeout(openWhenReady, 200);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
})();
|