breznflow/assets/renderer.js
Michael fd83e4810b BreznFlow 1.0.0 — WordPress.org submission
Initial public release of BreznFlow, an n8n workflow renderer for WordPress.
Fully PHPCS-compliant (WordPress Coding Standards), security-hardened,
and ready for WordPress.org plugin review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:36 +00:00

1544 lines
54 KiB
JavaScript

/* BreznFlow — Frontend SVG Renderer (ES2020, zero dependencies) */
/* global breznflowData, breznflowIcons, breznflowI18n */
(function () {
'use strict';
var i18n = (typeof breznflowI18n !== 'undefined') ? breznflowI18n : {};
const NODE_W = 180;
const NODE_H = 60;
const ICON_SIZE = 36;
const ICON_MARGIN = 12;
const PADDING = 60;
const MIN_SCALE = 0.1;
const MAX_SCALE = 5.0;
const ZOOM_STEP = 0.1;
const SVG_NS = 'http://www.w3.org/2000/svg';
// ── Helpers ──────────────────────────────────────────────────────────────
function svgEl(tag, attrs) {
const el = document.createElementNS(SVG_NS, tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, String(v));
}
}
return el;
}
function htmlEl(tag, cls) {
const el = document.createElement(tag);
if (cls) el.className = cls;
return el;
}
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function connectionPath(sx, sy, tx, ty) {
const cp = Math.max(Math.abs(tx - sx) * 0.5, 60);
return 'M ' + sx + ' ' + sy + ' C ' + (sx + cp) + ' ' + sy + ' ' + (tx - cp) + ' ' + ty + ' ' + tx + ' ' + ty;
}
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.slice(0, max - 1) + '\u2026' : str;
}
// ── Node type lookup ──────────────────────────────────────────────────────
function extractSlug(type) {
if (!type) return '';
const parts = type.split('.');
return parts[parts.length - 1] || type;
}
// ── Category System ───────────────────────────────────────────────────────
const CATEGORY_STROKE = {
trigger: '#22c55e',
http: '#3b82f6',
code: '#f97316',
logic: '#a855f7',
database: '#eab308',
ai: '#ec4899',
};
const CATEGORY_LABEL = {
trigger: 'Trigger', http: 'HTTP',
code: 'Code', logic: 'Logic',
database: 'Database', ai: 'AI',
transform: 'Transform', action: '',
};
const LOGIC_SLUGS = new Set(['if','switch','filter','merge','splitinbatches','splitout','sort','limit','removeduplicates','aggregate','comparedatasets','itemlists']);
const TRANSFORM_SLUGS = new Set(['code','function','executeworkflow','set','editfields','html','xml','markdown','crypto','tofile','converttofile','extractfromfile','compression']);
const DATABASE_SLUGS = new Set(['mysql','postgres','redis','mongodb','sqlite','microsoftsql','supabase']);
const AI_KEYWORDS_CAT = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere','lmchat','chainllm','memorybuffer'];
function getNodeCategory(slug) {
const s = (slug || '').toLowerCase().replace(/[^a-z]/g, '');
if (s === 'httprequest') return 'http';
if (s.includes('trigger') || s.includes('webhook') || s === 'respondtowebhook') return 'trigger';
if (LOGIC_SLUGS.has(s)) return 'logic';
if (DATABASE_SLUGS.has(s)) return 'database';
if (AI_KEYWORDS_CAT.some(function(kw) { return s.includes(kw); })) return 'ai';
if (TRANSFORM_SLUGS.has(s)) return 'code';
return 'action';
}
function getTooltipSummary(node, category) {
const params = node.parameters || {};
if (category === 'code') {
const src = params.jsCode || params.functionCode || '';
if (src) {
const lines = src.split('\n').filter(function(l) { return l.trim(); }).length;
return lines + ' ' + (lines === 1 ? (i18n.line || 'line') : (i18n.lines || 'lines'));
}
} else if (category === 'http') {
const method = params.method || 'GET';
const url = params.url ? String(params.url).slice(0, 40) : '';
return method + (url ? ' \u00b7 ' + url : '');
} else if (category === 'trigger') {
const rule = params.rule;
if (rule && Array.isArray(rule.interval) && rule.interval[0]) {
const item = rule.interval[0];
const IMAP = { minutes: 'minutesInterval', hours: 'hoursInterval', days: 'daysInterval', weeks: 'weeksInterval', months: 'monthsInterval' };
const ik = IMAP[item.field];
if (ik && item[ik]) return 'Every ' + item[ik] + ' ' + item.field;
if (item.cronExpression) return 'Cron: ' + item.cronExpression;
}
} else if (category === 'database') {
const tbl = params.table;
if (tbl && typeof tbl === 'object' && tbl.__rl) return tbl.cachedResultName || tbl.value || '';
if (typeof tbl === 'string' && tbl) return tbl;
}
return '';
}
function lookupNode(type) {
const icons = (typeof breznflowIcons !== 'undefined') ? breznflowIcons : {};
const slug = extractSlug(type);
if (icons[slug]) return icons[slug];
const slugLower = slug.toLowerCase();
for (const [key, entry] of Object.entries(icons)) {
if (key.toLowerCase() === slugLower) return entry;
}
return generateFallback(type, slug);
}
function djb2Hue(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash >>> 0;
}
return hash % 360;
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const a = s * Math.min(l, 1 - l);
const f = function(n) {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * Math.max(0, Math.min(1, color)));
};
return '#' + [f(0), f(8), f(4)].map(function(x) { return x.toString(16).padStart(2, '0'); }).join('');
}
function deriveInitials(slug) {
const parts = slug.split(/(?=[A-Z])/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return slug.slice(0, 2).toUpperCase();
}
function generateFallback(type, slug) {
const hue = djb2Hue(type);
return {
label: slug,
icon: 'initials',
symbol: deriveInitials(slug),
color: hslToHex(hue, 60, 55),
bg: hslToHex(hue, 40, 15),
};
}
// ── Layout computation ────────────────────────────────────────────────────
function computeLayout(nodes, containerW) {
if (!nodes || nodes.length === 0) return { nodes: [], scale: 1, contentW: 400, contentH: 200 };
const xs = nodes.map(function(n) { return n.position[0]; });
const ys = nodes.map(function(n) { return n.position[1]; });
const minX = Math.min.apply(null, xs);
const minY = Math.min.apply(null, ys);
const maxX = Math.max.apply(null, xs);
const maxY = Math.max.apply(null, ys);
const contentW = (maxX - minX) + NODE_W + PADDING * 2;
const contentH = (maxY - minY) + NODE_H + PADDING * 2;
// Pure fit-to-width scale. userZoom is applied separately in _applyTransform
// so it is never double-counted.
const scale = containerW / contentW;
const mapped = nodes.map(function(n) {
return Object.assign({}, n, {
x: (n.position[0] - minX + PADDING),
y: (n.position[1] - minY + PADDING),
});
});
return { nodes: mapped, scale: scale, contentW: contentW, contentH: contentH };
}
// ── SVG Rendering ─────────────────────────────────────────────────────────
function renderNodeSVG(node) {
const info = lookupNode(node.type || '');
const slug = extractSlug(node.type || '');
const category = getNodeCategory(slug);
const g = svgEl('g', {
class: 'breznflow-node',
'data-node-id': node.id || '',
'data-category': category,
'data-label': info.label,
});
g.setAttribute('transform', 'translate(' + node.x + ', ' + node.y + ')');
const box = svgEl('rect', {
class: 'breznflow-node-box',
x: 0, y: 0, width: NODE_W, height: NODE_H,
rx: 6, ry: 6,
});
g.appendChild(box);
const iconBg = svgEl('rect', {
class: 'breznflow-node-icon-rect',
x: ICON_MARGIN,
y: (NODE_H - ICON_SIZE) / 2,
width: ICON_SIZE,
height: ICON_SIZE,
fill: info.bg || '#333',
rx: 4, ry: 4,
});
g.appendChild(iconBg);
const iconX = ICON_MARGIN + ICON_SIZE / 2;
const iconY = NODE_H / 2;
const iconText = svgEl('text', {
class: 'breznflow-node-icon-text',
x: iconX, y: iconY,
fill: info.color || '#fff',
'font-size': info.symbol && info.symbol.length > 2 ? '11' : '14',
});
iconText.textContent = info.symbol || '?';
g.appendChild(iconText);
const textX = ICON_MARGIN + ICON_SIZE + 10;
const nameEl = svgEl('text', {
class: 'breznflow-node-name',
x: textX,
y: NODE_H / 2 - 8,
});
nameEl.textContent = truncate(node.name || '', 16);
g.appendChild(nameEl);
const typeEl = svgEl('text', {
class: 'breznflow-node-type',
x: textX,
y: NODE_H / 2 + 10,
});
typeEl.textContent = truncate(info.label || slug, 18);
g.appendChild(typeEl);
const dotIn = svgEl('circle', {
cx: 0, cy: NODE_H / 2, r: 4,
fill: '#444', stroke: '#555', 'stroke-width': 1.5,
});
const dotOut = svgEl('circle', {
cx: NODE_W, cy: NODE_H / 2, r: 4,
fill: '#444', stroke: '#555', 'stroke-width': 1.5,
});
g.appendChild(dotIn);
g.appendChild(dotOut);
return { el: g, category: category };
}
function renderConnections(nodes, connections, defs, renderId) {
const nodeMap = {};
for (const n of nodes) nodeMap[n.name] = n;
const markerId = 'breznflow-arrow-' + renderId;
const marker = svgEl('marker', {
id: markerId,
markerWidth: 8, markerHeight: 8,
refX: 6, refY: 3,
orient: 'auto',
});
const arrow = svgEl('path', {
d: 'M0,0 L0,6 L8,3 z',
class: 'breznflow-arrow-head',
});
marker.appendChild(arrow);
defs.appendChild(marker);
const g = svgEl('g', { class: 'breznflow-connections' });
if (!connections) return g;
for (const [sourceName, outputs] of Object.entries(connections)) {
const src = nodeMap[sourceName];
if (!src || !outputs || !outputs.main) continue;
for (const outputSlot of outputs.main) {
if (!Array.isArray(outputSlot)) continue;
for (const conn of outputSlot) {
const tgt = nodeMap[conn.node];
if (!tgt) continue;
const sx = src.x + NODE_W;
const sy = src.y + NODE_H / 2;
const tx = tgt.x;
const ty = tgt.y + NODE_H / 2;
const path = svgEl('path', {
class: 'breznflow-connection-path',
d: connectionPath(sx, sy, tx, ty),
'marker-end': 'url(#' + markerId + ')',
});
g.appendChild(path);
}
}
}
return g;
}
// ── Tooltip ───────────────────────────────────────────────────────────────
function buildTooltip(diagramContainer) {
const tip = htmlEl('div', 'breznflow-tooltip');
tip.setAttribute('aria-hidden', 'true');
diagramContainer.appendChild(tip);
let hideTimer = null;
return {
show: function(x, y, text) {
clearTimeout(hideTimer);
tip.textContent = text;
tip.style.left = x + 'px';
tip.style.top = (y - 40) + 'px';
tip.classList.add('visible');
},
hide: function() {
hideTimer = setTimeout(function() { tip.classList.remove('visible'); }, 80);
},
};
}
// ── Minimap ───────────────────────────────────────────────────────────────
const MINIMAP_W = 160, MINIMAP_H = 100;
function buildMinimap(diagramContainer, onNavigate) {
const wrap = htmlEl('div', 'breznflow-minimap');
const svg = svgEl('svg', { width: MINIMAP_W, height: MINIMAP_H });
wrap.appendChild(svg);
diagramContainer.appendChild(wrap);
let savedLayout = null;
let dragging = false;
function getPos(e) {
const r = svg.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function navigate(e) {
if (!savedLayout) return;
const pos = getPos(e);
const fitScale = Math.min(MINIMAP_W / savedLayout.contentW, MINIMAP_H / savedLayout.contentH);
onNavigate(pos.x / fitScale, pos.y / fitScale);
}
svg.addEventListener('mousedown', function(e) { dragging = true; navigate(e); e.stopPropagation(); });
svg.addEventListener('mousemove', function(e) { if (dragging) navigate(e); });
document.addEventListener('mouseup', function() { dragging = false; });
return {
el: wrap,
update: function(layout, tx, ty, effectiveScale, containerW, containerH) {
savedLayout = layout;
clearElement(svg);
if (!layout || !layout.nodes.length) return;
const fitScale = Math.min(MINIMAP_W / layout.contentW, MINIMAP_H / layout.contentH);
for (const node of layout.nodes) {
const cat = getNodeCategory(extractSlug(node.type || ''));
const col = CATEGORY_STROKE[cat] || '#555';
svg.appendChild(svgEl('rect', {
x: node.x * fitScale, y: node.y * fitScale,
width: Math.max(NODE_W * fitScale, 4),
height: Math.max(NODE_H * fitScale, 3),
fill: col, opacity: 0.75, rx: 1,
}));
}
const vx = (-tx / effectiveScale) * fitScale;
const vy = (-ty / effectiveScale) * fitScale;
const vw = (containerW / effectiveScale) * fitScale;
const vh = (containerH / effectiveScale) * fitScale;
svg.appendChild(svgEl('rect', {
x: vx, y: vy, width: vw, height: vh,
fill: 'none', stroke: 'rgba(255,255,255,0.6)', 'stroke-width': 1.5, rx: 2,
}));
},
};
}
// ── Detail Panel Helpers ─────────────────────────────────────────────────
const PARAM_LABELS = {
url: 'URL',
method: 'Method',
authentication: 'Authentication',
numberInputs: 'Inputs',
rule: 'Rule',
amount: 'Amount',
jsCode: 'Code',
functionCode: 'Code',
table: 'Table',
dataMode: 'Data Mode',
valuesToSend: 'Columns',
sendBody: 'Send Body',
sendHeaders: 'Send Headers',
contentType: 'Content Type',
rawContentType: 'Content Type (raw)',
body: 'Body',
webhookId: 'Webhook ID',
nodeCredentialType: 'Credential Type',
headerParameters: 'Header Parameters',
queryParameters: 'Query Parameters',
bodyParameters: 'Body Parameters',
conditions: 'Conditions',
modelId: 'Model',
operation: 'Operation',
resource: 'Resource',
query: 'Query',
specifyBody: 'Specify Body',
jsonBody: 'JSON Body',
};
// Field names whose values should always be redacted in the frontend display.
const SENSITIVE_HEADER_NAMES = ['authorization', 'token', 'api-key', 'apikey', 'x-api-key', 'x-auth', 'secret', 'password', 'bearer'];
function isSensitiveHeaderName(name) {
var lower = (name || '').toLowerCase();
return SENSITIVE_HEADER_NAMES.some(function(kw) { return lower.indexOf(kw) !== -1; });
}
function humanizeKey(k) {
if (PARAM_LABELS[k]) return PARAM_LABELS[k];
return k.replace(/([A-Z])/g, ' $1').replace(/^./, function(s) { return s.toUpperCase(); }).trim();
}
function isEmptyValue(v) {
if (v === null || v === undefined || v === '') return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') return Object.keys(v).length === 0;
return false;
}
function humanizeScheduleRule(rule) {
if (!rule || !Array.isArray(rule.interval)) return null;
const parts = [];
const FIELD_MAP = { minutes: 'minutesInterval', hours: 'hoursInterval', days: 'daysInterval', weeks: 'weeksInterval', months: 'monthsInterval' };
for (const item of rule.interval) {
const intervalKey = FIELD_MAP[item.field];
if (intervalKey && item[intervalKey]) {
parts.push('Every ' + item[intervalKey] + ' ' + item.field);
} else if (item.cronExpression) {
parts.push('Cron: ' + item.cronExpression);
} else if (item.field) {
parts.push(item.field);
}
}
return parts.join(', ') || null;
}
// Format [{name, value}, ...] arrays (HTTP headers, body params, etc.)
function humanizeNameValueArray(arr) {
if (!Array.isArray(arr) || arr.length === 0) return null;
const lines = [];
for (const item of arr) {
const name = item.name || item.key || item.parameterName || '';
const raw = item.value !== undefined ? item.value : (item.parameterValue || '');
const val = isSensitiveHeaderName(name) ? '[REDACTED]' : String(raw).slice(0, 150);
if (name) lines.push(name + ': ' + val);
}
return lines.length > 0 ? lines.join('\n') : null;
}
// Format conditions list into readable summary
function humanizeConditionsList(conditions) {
if (!Array.isArray(conditions) || conditions.length === 0) return null;
const lines = conditions.map(function(c) {
const left = String(c.leftValue || '').slice(0, 50);
const right = String(c.rightValue || '').slice(0, 50);
const opType = c.operator ? (c.operator.operation || c.operator.type || '') : '';
return left + (opType ? ' [' + opType + '] ' : ' = ') + right;
});
return lines.join('\n');
}
// Flatten nested options objects into key: value pairs (max 3 levels deep)
function flattenOptionsObject(obj, depth) {
if (!depth) depth = 0;
if (depth > 3 || typeof obj !== 'object' || obj === null) return [];
const pairs = [];
for (const k of Object.keys(obj)) {
const v = obj[k];
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
const str = String(v);
if (str.length < 200 && str !== '') pairs.push(humanizeKey(k) + ': ' + str);
} else if (typeof v === 'object' && v !== null) {
const nested = flattenOptionsObject(v, depth + 1);
for (const p of nested) pairs.push(p);
}
if (pairs.length >= 8) break;
}
return pairs;
}
function humanizeParamValue(k, v) {
// Schedule rule
if (k === 'rule' && typeof v === 'object' && v !== null) {
return humanizeScheduleRule(v);
}
// n8n Resource Locator (__rl: true) — any field
if (typeof v === 'object' && v !== null && v.__rl === true) {
return v.cachedResultName || String(v.value || '') || null;
}
// {parameters: [{name, value}]} wrapper (headerParameters, queryParameters, etc.)
if (typeof v === 'object' && v !== null && Array.isArray(v.parameters)) {
return humanizeNameValueArray(v.parameters);
}
// Direct array of {name, value} pairs
if (Array.isArray(v) && v.length > 0 && v[0] && (v[0].name !== undefined || v[0].key !== undefined)) {
return humanizeNameValueArray(v);
}
// {values: [...]} wrapper — messages (OpenAI), columns, etc.
if (typeof v === 'object' && v !== null && Array.isArray(v.values)) {
if (v.values.length === 0) return null;
// Message array (role + content)
if (v.values[0] && v.values[0].role !== undefined) {
return v.values.map(function(m) {
return (m.role || '?') + ': ' + String(m.content || '');
}).join('\n---\n');
}
// Column list
if (v.values[0] && v.values[0].column !== undefined) {
return v.values.map(function(c) { return c.column; }).join(', ');
}
return v.values.length + ' items';
}
// Conditions object {conditions: [...], combinator, options}
if (k === 'conditions' && typeof v === 'object' && v !== null && Array.isArray(v.conditions)) {
return humanizeConditionsList(v.conditions);
}
// Options: flatten to readable key: value pairs
if (k === 'options' && typeof v === 'object' && v !== null) {
const pairs = flattenOptionsObject(v);
return pairs.length > 0 ? pairs.join('\n') : null;
}
// valuesToSend (legacy)
if (k === 'valuesToSend' && typeof v === 'object' && v !== null && Array.isArray(v.values)) {
return v.values.map(function(col) { return col.column; }).join(', ');
}
return null;
}
// ── Detail Panel ──────────────────────────────────────────────────────────
function buildDetailPanel(maxCodeLines) {
const panel = htmlEl('div', 'breznflow-detail-panel');
const inner = htmlEl('div', 'breznflow-detail-panel-inner');
const header = htmlEl('div', 'breznflow-detail-header');
const titleEl = htmlEl('div', 'breznflow-detail-title');
const closeBtn = htmlEl('button', 'breznflow-detail-close');
closeBtn.textContent = '\u00d7';
closeBtn.setAttribute('aria-label', i18n.close || 'Close');
header.appendChild(titleEl);
header.appendChild(closeBtn);
const content = htmlEl('div', 'breznflow-detail-content');
inner.appendChild(header);
inner.appendChild(content);
panel.appendChild(inner);
function addRow(dl, key, value, multiline) {
const dt = htmlEl('dt', 'breznflow-detail-dt');
const dd = htmlEl('dd', 'breznflow-detail-dd');
dt.textContent = key;
if (multiline && value.indexOf('\n') !== -1) {
const pre = htmlEl('pre', 'breznflow-detail-multiline');
pre.textContent = value;
dd.appendChild(pre);
} else {
dd.textContent = value; // always textContent, never innerHTML
}
dl.appendChild(dt);
dl.appendChild(dd);
}
function openPanel(node) {
titleEl.textContent = node.name || '';
clearElement(content);
const info = lookupNode(node.type || '');
const dl = htmlEl('dl', 'breznflow-detail-dl');
addRow(dl, i18n.type || 'Type', info.label || extractSlug(node.type || ''));
// ID intentionally omitted — internal n8n field, not useful for readers
if (node.parameters && typeof node.parameters === 'object') {
for (const [k, v] of Object.entries(node.parameters)) {
// Code blocks: special rendering
if (k === 'jsCode' || k === 'functionCode') {
const dt = htmlEl('dt', 'breznflow-detail-dt');
dt.textContent = i18n.code || 'Code';
const dd = htmlEl('dd', 'breznflow-detail-dd');
const pre = htmlEl('pre', 'breznflow-detail-code');
const code = htmlEl('code');
const codeStr = String(v || '');
code.textContent = codeStr; // textContent only, never interpreted
const lineCount = codeStr.split('\n').length;
pre.style.maxHeight = 'calc(' + Math.min(lineCount, maxCodeLines || 50) + ' * 1.4em + 16px)';
pre.appendChild(code);
dd.appendChild(pre);
dl.appendChild(dt);
dl.appendChild(dd);
continue;
}
// Skip empty values: null, '', [], {}
if (isEmptyValue(v)) continue;
const label = humanizeKey(k);
if (typeof v === 'object' && v !== null) {
const smart = humanizeParamValue(k, v);
if (smart !== null) {
addRow(dl, label, smart, true);
} else {
// Generic flattening — no raw JSON ever shown
const pairs = flattenOptionsObject(v);
if (pairs.length > 0) {
addRow(dl, label, pairs.join('\n'), true);
}
}
} else {
const str = String(v !== null && v !== undefined ? v : '');
// n8n expression strings (={{ ... }} or ={ ... }) -> code block
if (str.startsWith('={{') || str.startsWith('={')) {
const dtExpr = htmlEl('dt', 'breznflow-detail-dt');
const ddExpr = htmlEl('dd', 'breznflow-detail-dd');
dtExpr.textContent = label;
const preExpr = htmlEl('pre', 'breznflow-detail-code');
const codeExpr = htmlEl('code');
codeExpr.textContent = str;
const exprLines = str.split('\n').length;
preExpr.style.maxHeight = 'calc(' + Math.min(exprLines + 1, maxCodeLines || 15) + ' * 1.4em + 16px)';
preExpr.appendChild(codeExpr);
ddExpr.appendChild(preExpr);
dl.appendChild(dtExpr);
dl.appendChild(ddExpr);
} else {
addRow(dl, label, str.slice(0, 500));
}
}
}
}
// Credentials: name only, no internal ID
if (node.credentials && typeof node.credentials === 'object') {
for (const [, v] of Object.entries(node.credentials)) {
const name = (v && typeof v === 'object') ? (v.name || '') : String(v || '');
if (name) addRow(dl, i18n.credential || 'Credential', name);
}
}
content.appendChild(dl);
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
panel.focus();
}
function closePanel() {
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
}
closeBtn.addEventListener('click', closePanel);
panel.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePanel();
});
panel.setAttribute('tabindex', '-1');
panel.setAttribute('aria-hidden', 'true');
return { panel: panel, open: openPanel, close: closePanel };
}
// ── InfoBox ───────────────────────────────────────────────────────────────
function buildInfoBox(workflow) {
const div = htmlEl('div', 'breznflow-infobox');
const AI_KW = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere'];
const counts = {};
let hasAi = false;
for (const node of (workflow.nodes || [])) {
const slug = extractSlug(node.type || '');
const info = lookupNode(node.type || '');
const label = info.label || slug;
counts[label] = (counts[label] || 0) + 1;
const slugLower = slug.toLowerCase();
if (!hasAi && AI_KW.some(function(kw) { return slugLower.indexOf(kw) !== -1; })) hasAi = true;
}
const sorted = Object.entries(counts).sort(function(a, b) { return b[1] - a[1]; });
const display = sorted.slice(0, 6);
const hidden = sorted.slice(6);
const more = hidden.length;
const nodesDiv = htmlEl('div', 'breznflow-infobox-nodes');
function makeSimpleSpan(label, count) {
const span = htmlEl('span', 'breznflow-infobox-node');
const strong = htmlEl('strong');
strong.textContent = count + 'x';
span.appendChild(strong);
span.appendChild(document.createTextNode(' ' + label));
return span;
}
for (const [label, count] of display) {
nodesDiv.appendChild(makeSimpleSpan(label, count));
}
if (more > 0) {
const moreBtn = htmlEl('button', 'breznflow-infobox-more');
moreBtn.textContent = '+ ' + more + ' ' + (i18n.more || 'more');
moreBtn.addEventListener('click', function() {
moreBtn.parentNode.removeChild(moreBtn);
for (const [lbl, cnt] of hidden) {
nodesDiv.appendChild(makeSimpleSpan(lbl, cnt));
}
});
nodesDiv.appendChild(moreBtn);
}
div.appendChild(nodesDiv);
if (hasAi) {
const aiDiv = htmlEl('div', 'breznflow-infobox-ai');
const badge = htmlEl('span', 'breznflow-ai-badge');
badge.textContent = i18n.aiPowered || 'AI-powered';
aiDiv.appendChild(badge);
div.appendChild(aiDiv);
}
const totalDiv = htmlEl('div', 'breznflow-infobox-total');
const total = (workflow.nodes || []).length;
totalDiv.textContent = total + ' ' + (total === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes'));
div.appendChild(totalDiv);
return div;
}
// ── Main Renderer Class ───────────────────────────────────────────────────
function BreznFlowRenderer(data) {
this.data = data;
this.id = data.id;
this.workflow = data.workflow;
this.mode = data.mode || 'visual';
this.userZoom = data.zoom || 100;
this.showInfobox = data.show_infobox !== false;
this.showDownload = data.show_download || false;
this.downloadUrl = data.download_url || '';
this.downloadLabel = data.download_label || 'Download JSON';
this.maxCodeLines = data.max_code_lines || 50;
this.scale = 1;
this.tx = 0;
this.ty = 0;
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
this.selectedNode = null;
this.layout = null;
this._canvas = null;
this._svg = null;
this.zoomLabel = null;
this.detailPanel = null;
this.minimap = null;
this.minimapVisible = false;
this.tooltip = null;
this._fsActive = false;
this._fsPortal = null;
this._fsOriginalParent = null;
this._fsOriginalNextSibling = null;
this._fsEscHandler = null;
this._fsClickOutside = null;
this._highlightSticky = null;
this._activeBadge = null;
this.showShare = data.show_share || false;
this.showEmbed = data.show_embed || false;
this.showGetJson = data.show_get_json || false;
this.permalink = data.permalink || '';
this.anchorId = data.anchor_id || '';
this.workflowTitle = data.workflow_title || (data.workflow && data.workflow.name) || '';
this.nodeCount = data.node_count || (data.workflow && (data.workflow.nodes || []).length) || 0;
this.isAiPowered = data.is_ai_powered || false;
this.blogName = data.blog_name || '';
this.blogUrl = data.blog_url || '';
this.embedUrl = data.embed_url || '';
this._activeModal = null;
this._modalEscHandler = null;
this.theme = data.theme || 'dark';
}
BreznFlowRenderer.prototype.mount = function(container) {
this.container = container;
container.classList.add('breznflow-wrap');
container.setAttribute('data-theme', this.theme);
if (this.mode === 'info') {
container.appendChild(buildInfoBox(this.workflow));
return;
}
if (this.mode !== 'compact') {
this.toolbar = this._buildToolbar();
container.appendChild(this.toolbar);
}
this.diagramContainer = htmlEl('div', 'breznflow-diagram-container');
container.appendChild(this.diagramContainer);
this._renderSVG();
// Auto-zoom large workflows: start zoomed in at the first (leftmost) node
const threshold = this.data.autofit_threshold || 0;
const nodeCount = (this.workflow.nodes || []).length;
if (threshold > 0 && nodeCount >= threshold && this.layout && this.layout.nodes.length > 0) {
const cr = this.diagramContainer.getBoundingClientRect();
const containerW = cr.width || 600;
const containerH = cr.height || 300;
// Find leftmost node (trigger / start)
const startNode = this.layout.nodes.reduce(function(min, n) {
return n.x < min.x ? n : min;
}, this.layout.nodes[0]);
// Target: show ~5 node-widths in the viewport
const targetScale = containerW / (NODE_W * 5 + PADDING * 4);
const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100,
Math.round(targetScale / this.layout.scale * 100)
));
this.userZoom = newZoom;
// Pan to start node (left-quarter of viewport)
const s = this.layout.scale * (newZoom / 100);
this.tx = containerW / 4 - (startNode.x + NODE_W / 2) * s;
this.ty = containerH / 2 - (startNode.y + NODE_H / 2) * s;
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%';
this._applyTransform();
} else if (this.layout && this.layout.nodes.length > 0) {
// Center workflows that are smaller than the viewport width
const cr = this.diagramContainer.getBoundingClientRect();
const containerW = cr.width || 600;
const s = this.layout.scale * (this.userZoom / 100);
if (this.layout.contentW * s < containerW) {
this.tx = (containerW - this.layout.contentW * s) / 2;
this._applyTransform();
}
}
const panelResult = buildDetailPanel(this.maxCodeLines);
this.detailPanel = panelResult;
container.appendChild(panelResult.panel);
if (this.mode !== 'compact') {
const actionBar = this._buildActionBar();
if (actionBar) container.appendChild(actionBar);
}
if (this.showInfobox && this.mode !== 'compact') {
container.appendChild(this._buildInfoBox());
}
this._attachEvents();
};
BreznFlowRenderer.prototype._buildToolbar = function() {
const self = this;
const toolbar = htmlEl('div', 'breznflow-toolbar');
const name = htmlEl('span', 'breznflow-toolbar-name');
name.textContent = this.workflow.name || '';
toolbar.appendChild(name);
const btnOut = htmlEl('button', 'breznflow-btn breznflow-btn-zoom');
btnOut.textContent = '\u2212';
btnOut.setAttribute('aria-label', i18n.zoomOut || 'Zoom out');
btnOut.title = i18n.zoomOut || 'Zoom out';
btnOut.addEventListener('click', function() { self._zoom(-ZOOM_STEP); });
const zoomLabel = htmlEl('span', 'breznflow-zoom-label');
zoomLabel.textContent = Math.round(this.userZoom) + '%';
this.zoomLabel = zoomLabel;
const btnIn = htmlEl('button', 'breznflow-btn breznflow-btn-zoom');
btnIn.textContent = '+';
btnIn.setAttribute('aria-label', i18n.zoomIn || 'Zoom in');
btnIn.title = i18n.zoomIn || 'Zoom in';
btnIn.addEventListener('click', function() { self._zoom(ZOOM_STEP); });
const btnReset = htmlEl('button', 'breznflow-btn');
btnReset.textContent = '\u21ba';
btnReset.setAttribute('aria-label', i18n.resetView || 'Reset view');
btnReset.title = i18n.resetView || 'Reset view';
btnReset.addEventListener('click', function() { self._resetView(); });
const btnFS = htmlEl('button', 'breznflow-btn');
btnFS.textContent = '\u26f6';
btnFS.setAttribute('aria-label', i18n.fullscreen || 'Fullscreen');
btnFS.title = i18n.fullscreen || 'Fullscreen';
btnFS.addEventListener('click', function() { self._toggleFullscreen(btnFS); });
const btnMM = htmlEl('button', 'breznflow-btn');
btnMM.textContent = '\u229e';
btnMM.setAttribute('aria-label', i18n.minimap || 'Minimap');
btnMM.title = i18n.minimap || 'Minimap';
btnMM.addEventListener('click', function() { self._toggleMinimap(btnMM); });
toolbar.appendChild(btnOut);
toolbar.appendChild(zoomLabel);
toolbar.appendChild(btnIn);
toolbar.appendChild(btnReset);
toolbar.appendChild(btnFS);
if (this.data.show_minimap !== false) {
toolbar.appendChild(btnMM);
}
return toolbar;
};
BreznFlowRenderer.prototype._renderSVG = function() {
const containerW = this.diagramContainer.getBoundingClientRect().width || 600;
const layout = computeLayout(this.workflow.nodes || [], containerW);
this.layout = layout;
const s0 = layout.scale * (this.userZoom / 100);
const svgW = Math.max(containerW, layout.contentW * s0);
const svgH = Math.max(200, (layout.contentH || 300) * s0 + 40);
const svg = svgEl('svg', {
class: 'breznflow-svg',
width: svgW,
height: svgH,
role: 'img',
'aria-label': this.workflow.name || 'Workflow diagram',
});
const defs = svgEl('defs');
svg.appendChild(defs);
const canvas = svgEl('g', { class: 'breznflow-canvas' });
this._canvas = canvas;
const connLayer = renderConnections(layout.nodes, this.workflow.connections, defs, this.id);
canvas.appendChild(connLayer);
const nodeLayer = svgEl('g', { class: 'breznflow-nodes' });
const self = this;
this.tooltip = buildTooltip(this.diagramContainer);
for (const node of layout.nodes) {
const result = renderNodeSVG(node);
const nodeEl = result.el;
const category = result.category;
const info = lookupNode(node.type || '');
const catLabel = CATEGORY_LABEL[category] || info.label || '';
const summary = getTooltipSummary(node, category);
const tipText = catLabel + (summary ? ' \u00b7 ' + summary : '');
(function(capturedNode, capturedEl, capturedTipText) {
capturedEl.addEventListener('click', function(e) {
e.stopPropagation();
self._selectNode(capturedNode, capturedEl);
});
capturedEl.addEventListener('mouseenter', function() {
const nr = capturedEl.getBoundingClientRect();
const cr = self.diagramContainer.getBoundingClientRect();
self.tooltip.show(nr.left - cr.left + nr.width / 2, nr.top - cr.top, capturedTipText);
});
capturedEl.addEventListener('mouseleave', function() { self.tooltip.hide(); });
}(node, nodeEl, tipText));
nodeLayer.appendChild(nodeEl);
}
canvas.appendChild(nodeLayer);
svg.appendChild(canvas);
this.diagramContainer.appendChild(svg);
this._svg = svg;
if (this.data.show_minimap !== false) {
this.minimap = buildMinimap(this.diagramContainer, function(lx, ly) {
const s = self.layout.scale * (self.userZoom / 100);
const cr = self.diagramContainer.getBoundingClientRect();
self.tx = cr.width / 2 - lx * s;
self.ty = cr.height / 2 - ly * s;
self._applyTransform();
});
this.minimap.el.style.display = 'none';
}
this._applyTransform();
};
BreznFlowRenderer.prototype._selectNode = function(node, el) {
if (this.selectedNode) this.selectedNode.classList.remove('selected');
el.classList.add('selected');
this.selectedNode = el;
if (this.detailPanel) this.detailPanel.open(node);
};
BreznFlowRenderer.prototype._zoom = function(delta) {
this.userZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, this.userZoom + delta * 100));
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(this.userZoom) + '%';
this._applyTransform();
};
BreznFlowRenderer.prototype._resetView = function() {
const cr = this.diagramContainer.getBoundingClientRect();
const containerW = cr.width || 600;
this.userZoom = this.data.zoom || 100;
this.layout = computeLayout(this.workflow.nodes || [], containerW);
this.tx = 0;
this.ty = 0;
// Center if content is narrower than the container
const s = this.layout.scale * (this.userZoom / 100);
if (this.layout.contentW * s < containerW) {
this.tx = (containerW - this.layout.contentW * s) / 2;
}
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(this.userZoom) + '%';
if (this._svg) {
const svgW = Math.max(containerW, this.layout.contentW * s);
const svgH = this._fsActive
? (cr.height || 400)
: Math.max(200, (this.layout.contentH || 300) * s + 40);
this._svg.setAttribute('width', svgW);
this._svg.setAttribute('height', svgH);
}
this._applyTransform();
};
BreznFlowRenderer.prototype._applyTransform = function() {
if (!this._canvas || !this.layout) return;
const s = this.layout.scale * (this.userZoom / 100);
this._canvas.setAttribute('transform', 'translate(' + this.tx + ', ' + this.ty + ') scale(' + s + ')');
if (this.minimap && this.minimapVisible) {
const cr = this.diagramContainer.getBoundingClientRect();
this.minimap.update(this.layout, this.tx, this.ty, s, cr.width, cr.height);
}
};
BreznFlowRenderer.prototype._attachEvents = function() {
const svg = this._svg;
if (!svg) return;
const self = this;
svg.addEventListener('wheel', function(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
const oldS = self.layout.scale * (self.userZoom / 100);
const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, self.userZoom + delta * 100));
const newS = self.layout.scale * (newZoom / 100);
if (oldS === 0) return;
// Keep the point under the cursor fixed
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
self.tx = mx - (mx - self.tx) * (newS / oldS);
self.ty = my - (my - self.ty) * (newS / oldS);
self.userZoom = newZoom;
if (self.zoomLabel) self.zoomLabel.textContent = Math.round(newZoom) + '%';
self._applyTransform();
}, { passive: false });
svg.addEventListener('pointerdown', function(e) {
const t = e.target;
const isBg = t === svg ||
(t.getAttribute && (t.getAttribute('class') === 'breznflow-canvas' || t.getAttribute('class') === 'breznflow-connection-path'));
if (!isBg) return;
self.isPanning = true;
self.panStart = { x: e.clientX - self.tx, y: e.clientY - self.ty };
svg.setPointerCapture(e.pointerId);
});
svg.addEventListener('pointermove', function(e) {
if (!self.isPanning) return;
self.tx = e.clientX - self.panStart.x;
self.ty = e.clientY - self.panStart.y;
self._applyTransform();
});
svg.addEventListener('pointerup', function() { self.isPanning = false; });
svg.addEventListener('pointercancel', function() { self.isPanning = false; });
svg.addEventListener('click', function(e) {
const t = e.target;
const isBg = t === svg || (t.getAttribute && t.getAttribute('class') === 'breznflow-canvas');
if (!isBg) return;
if (self.selectedNode) {
self.selectedNode.classList.remove('selected');
self.selectedNode = null;
}
if (self.detailPanel) self.detailPanel.close();
if (self._highlightSticky) {
self._highlightSticky = null;
if (self._activeBadge) { self._activeBadge.classList.remove('active'); self._activeBadge = null; }
self._clearHighlight(true);
}
});
};
BreznFlowRenderer.prototype._toggleFullscreen = function(btn) {
if (this._fsActive) {
this._exitFullscreen(btn);
} else {
this._enterFullscreen(btn);
}
};
BreznFlowRenderer.prototype._enterFullscreen = function(btn) {
this._fsActive = true;
btn.setAttribute('aria-pressed', 'true');
const self = this;
const portal = htmlEl('div', 'breznflow-fs-portal');
portal.setAttribute('data-theme', this.theme);
this._fsOriginalParent = this.container.parentElement;
this._fsOriginalNextSibling = this.container.nextSibling;
portal.appendChild(this.container);
document.body.appendChild(portal);
this._fsPortal = portal;
document.body.style.overflow = 'hidden';
this._fsClickOutside = function(e) {
if (e.target === portal) self._exitFullscreen(btn);
};
portal.addEventListener('click', this._fsClickOutside);
this._fsEscHandler = function(e) {
if (e.key === 'Escape') self._exitFullscreen(btn);
};
document.addEventListener('keydown', this._fsEscHandler);
requestAnimationFrame(function() { self._resetView(); });
};
BreznFlowRenderer.prototype._exitFullscreen = function(btn) {
if (!this._fsActive) return;
this._fsActive = false;
btn.setAttribute('aria-pressed', 'false');
const self = this;
if (this._fsEscHandler) {
document.removeEventListener('keydown', this._fsEscHandler);
this._fsEscHandler = null;
}
this._fsClickOutside = null;
if (this._fsOriginalNextSibling) {
this._fsOriginalParent.insertBefore(this.container, this._fsOriginalNextSibling);
} else {
this._fsOriginalParent.appendChild(this.container);
}
document.body.removeChild(this._fsPortal);
this._fsPortal = null;
document.body.style.overflow = '';
requestAnimationFrame(function() { self._resetView(); });
};
BreznFlowRenderer.prototype._toggleMinimap = function(btn) {
this.minimapVisible = !this.minimapVisible;
btn.setAttribute('aria-pressed', String(this.minimapVisible));
if (this.minimap) {
this.minimap.el.style.display = this.minimapVisible ? '' : 'none';
if (this.minimapVisible) {
const s = this.layout.scale * (this.userZoom / 100);
const cr = this.diagramContainer.getBoundingClientRect();
this.minimap.update(this.layout, this.tx, this.ty, s, cr.width, cr.height);
}
}
};
BreznFlowRenderer.prototype._buildInfoBox = function() {
const self = this;
const div = htmlEl('div', 'breznflow-infobox');
const AI_KW = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere'];
const counts = {};
let hasAi = false;
for (const node of (this.workflow.nodes || [])) {
const slug = extractSlug(node.type || '');
const info = lookupNode(node.type || '');
const label = info.label || slug;
const category = getNodeCategory(slug);
if (!counts[label]) counts[label] = { count: 0, category: category };
counts[label].count++;
const slugLower = slug.toLowerCase();
if (!hasAi && AI_KW.some(function(kw) { return slugLower.indexOf(kw) !== -1; })) hasAi = true;
}
const sorted = Object.entries(counts).sort(function(a, b) { return b[1].count - a[1].count; });
const display = sorted.slice(0, 6);
const hidden = sorted.slice(6);
const more = hidden.length;
const nodesDiv = htmlEl('div', 'breznflow-infobox-nodes');
function makeNodeSpan(label, entry) {
const span = htmlEl('span', 'breznflow-infobox-node');
span.setAttribute('data-category', entry.category);
span.title = i18n.highlightInDiagram || 'Highlight in diagram';
const strong = htmlEl('strong');
strong.textContent = entry.count + 'x';
span.appendChild(strong);
span.appendChild(document.createTextNode(' ' + label));
span.addEventListener('mouseenter', function() {
if (!self._highlightSticky) self._highlightByLabel(label);
});
span.addEventListener('mouseleave', function() {
if (!self._highlightSticky) self._clearHighlight(false);
});
span.addEventListener('click', function(e) {
e.stopPropagation();
if (self._highlightSticky === label) {
self._highlightSticky = null;
span.classList.remove('active');
self._activeBadge = null;
self._clearHighlight(true);
} else {
if (self._activeBadge) self._activeBadge.classList.remove('active');
self._highlightSticky = label;
self._activeBadge = span;
span.classList.add('active');
self._highlightByLabel(label);
}
});
return span;
}
for (const [label, entry] of display) {
nodesDiv.appendChild(makeNodeSpan(label, entry));
}
if (more > 0) {
const moreBtn = htmlEl('button', 'breznflow-infobox-more');
moreBtn.textContent = '+ ' + more + ' ' + (i18n.more || 'more');
moreBtn.addEventListener('click', function() {
moreBtn.parentNode.removeChild(moreBtn);
for (const [lbl, ent] of hidden) {
nodesDiv.appendChild(makeNodeSpan(lbl, ent));
}
});
nodesDiv.appendChild(moreBtn);
}
div.appendChild(nodesDiv);
if (hasAi) {
const aiDiv = htmlEl('div', 'breznflow-infobox-ai');
const badge = htmlEl('span', 'breznflow-ai-badge');
badge.textContent = i18n.aiPowered || 'AI-powered';
aiDiv.appendChild(badge);
div.appendChild(aiDiv);
}
const totalDiv = htmlEl('div', 'breznflow-infobox-total');
const total = (this.workflow.nodes || []).length;
totalDiv.textContent = total + ' ' + (total === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes'));
div.appendChild(totalDiv);
return div;
};
BreznFlowRenderer.prototype._highlightByLabel = function(label) {
if (!this._svg) return;
const nodeLayer = this._svg.querySelector('.breznflow-nodes');
if (!nodeLayer) return;
nodeLayer.classList.add('breznflow-dim');
for (const node of nodeLayer.querySelectorAll('.breznflow-node')) {
node.classList.toggle('breznflow-highlighted', node.getAttribute('data-label') === label);
}
};
BreznFlowRenderer.prototype._clearHighlight = function(force) {
if (!force && this._highlightSticky) return;
if (!this._svg) return;
const nodeLayer = this._svg.querySelector('.breznflow-nodes');
if (!nodeLayer) return;
nodeLayer.classList.remove('breznflow-dim');
for (const node of nodeLayer.querySelectorAll('.breznflow-node')) {
node.classList.remove('breznflow-highlighted');
}
};
// ── Action Bar ────────────────────────────────────────────────────────────
BreznFlowRenderer.prototype._buildActionBar = function() {
const self = this;
const hasShare = this.showShare;
const hasEmbed = this.showEmbed;
const hasGetJson = this.showGetJson;
const hasDownload = this.showDownload && this.downloadUrl;
if (!hasShare && !hasEmbed && !hasGetJson && !hasDownload) return null;
const bar = htmlEl('div', 'breznflow-action-bar');
if (hasShare) {
const btn = htmlEl('button', 'breznflow-btn');
btn.textContent = i18n.share || 'Share';
btn.addEventListener('click', function() { self._openModal('share'); });
bar.appendChild(btn);
}
if (hasEmbed) {
const btn = htmlEl('button', 'breznflow-btn');
btn.textContent = i18n.embed || 'Embed';
btn.addEventListener('click', function() { self._openModal('embed'); });
bar.appendChild(btn);
}
if (hasGetJson) {
const btn = htmlEl('button', 'breznflow-btn');
btn.textContent = i18n.getJson || 'Get JSON';
btn.addEventListener('click', function() { self._openModal('getjson'); });
bar.appendChild(btn);
}
if (hasDownload) {
const dl = htmlEl('button', 'breznflow-btn');
dl.textContent = this.downloadLabel;
dl.addEventListener('click', function() { window.open(self.downloadUrl, '_self'); });
bar.appendChild(dl);
}
return bar;
};
BreznFlowRenderer.prototype._openModal = function(type) {
const self = this;
if (this._activeModal) this._closeModal();
const overlay = htmlEl('div', 'breznflow-modal-overlay');
overlay.setAttribute('data-theme', this.theme);
const box = htmlEl('div', 'breznflow-modal-box');
const header = htmlEl('div', 'breznflow-modal-header');
const titleEl = htmlEl('span', 'breznflow-modal-title');
const labels = { share: i18n.share || 'Share', embed: i18n.embed || 'Embed', getjson: i18n.getJson || 'Get JSON' };
titleEl.textContent = labels[type] || '';
const closeBtn = htmlEl('button', 'breznflow-modal-close');
closeBtn.textContent = '\u00d7';
closeBtn.setAttribute('aria-label', i18n.close || 'Close');
closeBtn.addEventListener('click', function() { self._closeModal(); });
header.appendChild(titleEl);
header.appendChild(closeBtn);
const body = htmlEl('div', 'breznflow-modal-body');
const content = type === 'share' ? this._buildShareModalContent()
: type === 'embed' ? this._buildEmbedModalContent()
: this._buildGetJsonModalContent();
body.appendChild(content);
box.appendChild(header);
box.appendChild(body);
overlay.appendChild(box);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) self._closeModal();
});
this._modalEscHandler = function(e) {
if (e.key === 'Escape') self._closeModal();
};
document.addEventListener('keydown', this._modalEscHandler);
document.body.appendChild(overlay);
this._activeModal = overlay;
};
BreznFlowRenderer.prototype._closeModal = function() {
if (this._activeModal && this._activeModal.parentNode) {
this._activeModal.parentNode.removeChild(this._activeModal);
}
this._activeModal = null;
if (this._modalEscHandler) {
document.removeEventListener('keydown', this._modalEscHandler);
this._modalEscHandler = null;
}
};
BreznFlowRenderer.prototype._buildCopyBlock = function(label, copyText, previewText) {
const block = htmlEl('div', 'breznflow-share-block');
const lbl = htmlEl('div', 'breznflow-share-label');
lbl.textContent = label;
const pre = htmlEl('pre', 'breznflow-share-preview');
pre.textContent = previewText;
const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn');
btn.textContent = i18n.copy || 'Copy';
btn.addEventListener('click', function() { copyToClipboard(copyText, btn); });
block.appendChild(lbl);
block.appendChild(pre);
block.appendChild(btn);
return block;
};
BreznFlowRenderer.prototype._buildShareModalContent = function() {
const frag = document.createDocumentFragment();
const nodesLabel = this.nodeCount + ' ' + (this.nodeCount === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes'));
const metaSuffix = nodesLabel + (this.isAiPowered ? ' \u00b7 ' + (i18n.aiPowered || 'AI-powered') : '');
const text1 = this.permalink + '\n' + this.workflowTitle + '\n' + metaSuffix;
frag.appendChild(this._buildCopyBlock(i18n.articleLink || 'Article Link', text1, text1));
const anchorUrl = this.permalink + '#' + this.anchorId;
const text2 = anchorUrl + '\n' + this.workflowTitle + '\n' + metaSuffix;
frag.appendChild(this._buildCopyBlock(i18n.anchorLink || 'Workflow Anchor Link', text2, text2));
return frag;
};
BreznFlowRenderer.prototype._buildEmbedModalContent = function() {
const frag = document.createDocumentFragment();
const iframeCode = '<iframe src="' + this.embedUrl + '" width="100%" height="520" frameborder="0" allowfullscreen></iframe>';
const desc = htmlEl('p', 'breznflow-modal-desc');
desc.textContent = i18n.embedDesc || 'Embed this workflow on any website:';
const textarea = htmlEl('textarea', 'breznflow-modal-textarea');
textarea.setAttribute('readonly', '');
textarea.value = iframeCode;
const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn');
btn.textContent = i18n.copy || 'Copy';
btn.addEventListener('click', function() { copyToClipboard(iframeCode, btn); });
const paramsLabel = htmlEl('p', 'breznflow-modal-desc');
paramsLabel.textContent = i18n.optionalParams || 'Optional URL parameters:';
const paramsCode = htmlEl('pre', 'breznflow-share-preview');
paramsCode.textContent = '?theme=dark|light|minimal|tech|brezn\n?minimap=1 (default)\n?minimap=0 (hide minimap)';
frag.appendChild(desc);
frag.appendChild(textarea);
frag.appendChild(btn);
frag.appendChild(paramsLabel);
frag.appendChild(paramsCode);
return frag;
};
BreznFlowRenderer.prototype._buildGetJsonModalContent = function() {
const frag = document.createDocumentFragment();
const jsonStr = JSON.stringify(this.data.workflow, null, 2);
const sizeKB = (jsonStr.length / 1024).toFixed(1);
const meta = htmlEl('div', 'breznflow-json-meta');
const sizeSpan = htmlEl('span', 'breznflow-json-size');
sizeSpan.textContent = sizeKB + ' KB';
meta.appendChild(sizeSpan);
const textarea = htmlEl('textarea', 'breznflow-modal-textarea');
textarea.setAttribute('readonly', '');
textarea.value = jsonStr;
const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn');
btn.textContent = i18n.copy || 'Copy';
btn.addEventListener('click', function() { copyToClipboard(jsonStr, btn); });
frag.appendChild(meta);
frag.appendChild(textarea);
frag.appendChild(btn);
return frag;
};
// ── Clipboard helper ──────────────────────────────────────────────────────
function copyToClipboard(text, btn) {
const orig = btn.textContent;
function onSuccess() {
btn.textContent = i18n.copied || 'Copied!';
setTimeout(function() { btn.textContent = orig; }, 1500);
}
function onError() {
btn.textContent = i18n.error || 'Error';
setTimeout(function() { btn.textContent = orig; }, 1500);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onSuccess, onError);
} else {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
} catch (e) {
onError();
}
}
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
function init() {
if (typeof breznflowData === 'undefined' || !Array.isArray(breznflowData)) return;
for (const data of breznflowData) {
const container = document.getElementById('breznflow-wrap-' + data.id);
if (!container) continue;
try {
const renderer = new BreznFlowRenderer(data);
renderer.mount(container);
} catch (err) {
if (typeof console !== 'undefined' && console.error) {
console.error('[BreznFlow] Render error for id ' + data.id + ':', err);
}
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());