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>
1544 lines
54 KiB
JavaScript
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();
|
|
}
|
|
|
|
}());
|