/* 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 = ''; 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(); } }());