UNPKG

@praxisui/page-builder

Version:

Page and widget builder utilities for Praxis UI (grid, dynamic widgets, editors).

978 lines (969 loc) 64.7 kB
import * as i3 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i0 from '@angular/core'; import { EventEmitter, HostListener, Output, Input, Directive, Injectable, signal, Optional, Inject, ChangeDetectionStrategy, Component } from '@angular/core'; import * as i4 from '@angular/forms'; import { FormsModule } from '@angular/forms'; import * as i2$2 from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button'; import * as i3$1 from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon'; import * as i3$2 from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BehaviorSubject } from 'rxjs'; import * as i2$1 from '@praxisui/settings-panel'; import { SETTINGS_PANEL_DATA } from '@praxisui/settings-panel'; import { ConnectionBuilderComponent } from './praxisui-page-builder.mjs'; import * as i2 from '@praxisui/core'; /** * Simple elbow router (L-shape): horizontal then vertical. * Produces an SVG path string. */ function routeElbow(from, to) { const midX = (from.x + to.x) / 2; const p = [ `M ${from.x} ${from.y}`, `L ${midX} ${from.y}`, `L ${midX} ${to.y}`, `L ${to.x} ${to.y}`, ]; return p.join(' '); } /** Straight line path */ function routeStraight(from, to) { return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; } /** Simple cubic bezier: horizontal tangents from endpoints */ function routeBezier(from, to) { const dx = (to.x - from.x) * 0.5; const c1 = { x: from.x + dx, y: from.y }; const c2 = { x: to.x - dx, y: to.y }; return `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`; } /** Basic geometry helpers for auto-routing */ function rectContains(r, p) { return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height; } function segmentsIntersect(p1, p2, p3, p4) { // cross product approach const d = (p4.y - p3.y) * (p2.x - p1.x) - (p4.x - p3.x) * (p2.y - p1.y); if (d === 0) return false; const ua = ((p4.x - p3.x) * (p1.y - p3.y) - (p4.y - p3.y) * (p1.x - p3.x)) / d; const ub = ((p2.x - p1.x) * (p1.y - p3.y) - (p2.y - p1.y) * (p1.x - p3.x)) / d; return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1; } function lineIntersectsRect(p1, p2, r) { if (rectContains(r, p1) || rectContains(r, p2)) return true; const tl = { x: r.x, y: r.y }; const tr = { x: r.x + r.width, y: r.y }; const bl = { x: r.x, y: r.y + r.height }; const br = { x: r.x + r.width, y: r.y + r.height }; return (segmentsIntersect(p1, p2, tl, tr) || segmentsIntersect(p1, p2, tr, br) || segmentsIntersect(p1, p2, br, bl) || segmentsIntersect(p1, p2, bl, tl)); } /** * Auto router: choose straight/bezier if clear, else elbow to avoid boxes. * Obstacles are rectangles in world space. Optional padding inflates obstacles. */ function routeAuto(from, to, obstacles = [], padding = 6) { const inflated = obstacles.map(r => ({ x: r.x - padding, y: r.y - padding, width: r.width + padding * 2, height: r.height + padding * 2 })); const intersects = inflated.some(r => lineIntersectsRect(from, to, r)); if (intersects) return routeElbow(from, to); // prefer bezier for aesthetics when unobstructed return routeBezier(from, to); } class DragConnectDirective { el; from; connectDragStart = new EventEmitter(); connectDragMove = new EventEmitter(); connectDragEnd = new EventEmitter(); active = false; onMove = (e) => this.handleMove(e); onUp = (e) => this.handleUp(e); constructor(el) { this.el = el; } onMouseDown(ev) { if (!this.from) return; ev.preventDefault(); ev.stopPropagation(); this.active = true; const rect = this.el.nativeElement.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; try { console.debug('[DragConnect] mousedown start', { from: this.from, x, y, target: ev.target?.tagName }); } catch { } this.connectDragStart.emit({ from: this.from, x, y }); document.addEventListener('mousemove', this.onMove); document.addEventListener('mouseup', this.onUp, { once: true }); } handleMove(e) { if (!this.active) return; try { console.debug('[DragConnect] mousemove', { x: e.clientX, y: e.clientY }); } catch { } this.connectDragMove.emit({ x: e.clientX, y: e.clientY }); } handleUp(e) { if (!this.active) return; this.active = false; document.removeEventListener('mousemove', this.onMove); try { console.debug('[DragConnect] mouseup end', { x: e.clientX, y: e.clientY, target: e.target?.tagName, classes: e.target?.className }); } catch { } this.connectDragEnd.emit({ x: e.clientX, y: e.clientY, endEvent: e }); } ngOnDestroy() { document.removeEventListener('mousemove', this.onMove); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DragConnectDirective, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.1.4", type: DragConnectDirective, isStandalone: true, selector: "[dragConnect]", inputs: { from: ["dragConnect", "from"] }, outputs: { connectDragStart: "connectDragStart", connectDragMove: "connectDragMove", connectDragEnd: "connectDragEnd" }, host: { listeners: { "mousedown": "onMouseDown($event)" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DragConnectDirective, decorators: [{ type: Directive, args: [{ selector: '[dragConnect]' }] }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { from: [{ type: Input, args: ['dragConnect'] }], connectDragStart: [{ type: Output }], connectDragMove: [{ type: Output }], connectDragEnd: [{ type: Output }], onMouseDown: [{ type: HostListener, args: ['mousedown', ['$event']] }] } }); class GraphMapperService { registry; constructor(registry) { this.registry = registry; } /** Build nodes and edges for a given page definition and widgets. */ mapToGraph(page, widgets) { const nodes = []; const nodeById = new Map(); const tabsCfgByKey = new Map(); const edges = []; // Layout: simple columns — sources left, targets right const colWidth = 240; const rowHeight = 96; const gap = 28; let leftY = 0; let rightY = 0; // First pass: create a node per widget for (const w of widgets) { const meta = this.registry.get(w.definition.id); const metaOutputs = new Map(); for (const o of meta?.outputs || []) metaOutputs.set(o.name, { label: o.label, description: o.description }); const outputs = new Set([(meta?.outputs || []).map((o) => o.name)].flat()); if (w.definition.id === 'praxis-tabs') outputs.add('widgetEvent'); if (w.definition.id === 'praxis-stepper') outputs.add('widgetEvent'); const metaInputs = new Map(); for (const i of meta?.inputs || []) metaInputs.set(i.name, { label: i.label, description: i.description }); // Show both instance-defined inputs and metadata-declared inputs (e.g., resourceId) const inputNames = Array.from(new Set([ ...Object.keys(w.definition.inputs || {}), ...(meta?.inputs || []).map((m) => m.name), ])); const inputPorts = inputNames.map((name, i) => ({ id: `in:${name}`, label: metaInputs.get(name)?.label || name, description: metaInputs.get(name)?.description, kind: 'input', anchor: { x: colWidth, y: 24 + i * 22 }, })); // Generic port for new inputs inputPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', anchor: { x: colWidth, y: 24 + inputNames.length * 22 } }); const outputPorts = Array.from(outputs.values()).map((name, i) => ({ id: `out:${name}`, label: metaOutputs.get(name)?.label || name, description: metaOutputs.get(name)?.description, kind: 'output', anchor: { x: 0, y: 24 + i * 22 }, })); const left = outputPorts.length > 0; const pos = left ? { x: gap, y: gap + leftY } : { x: colWidth + gap * 3, y: gap + rightY }; const node = { id: w.key, label: meta?.friendlyName || w.definition.id || w.key, icon: meta?.icon, type: w.definition.id, parentId: null, collapsed: true, bounds: { x: pos.x, y: pos.y, width: colWidth, height: rowHeight }, ports: [...outputPorts.map((p) => ({ ...p, anchor: { x: pos.x, y: pos.y + p.anchor.y } })), ...inputPorts.map((p) => ({ ...p, anchor: { x: pos.x + colWidth, y: pos.y + p.anchor.y } }))], }; nodes.push(node); nodeById.set(node.id, node); if (left) leftY += rowHeight + gap; else rightY += rowHeight + gap; // Expand Tabs internals as child nodes (collapsed by default) if (w.definition.id === 'praxis-tabs') { const cfg = w.definition?.inputs?.config; tabsCfgByKey.set(w.key, cfg); // Group tabs const tabs = (cfg?.tabs || []); for (let ti = 0; ti < tabs.length; ti++) { const inner = tabs[ti]?.widgets || []; for (let wi = 0; wi < inner.length; wi++) { const innerDef = inner[wi]; const innerMeta = this.registry.get(innerDef?.id); const innerInputs = Object.keys(innerDef?.inputs || {}); const innerMetaInputs = new Map(); for (const i of innerMeta?.inputs || []) innerMetaInputs.set(i.name, { label: i.label, description: i.description }); const sectionLabel = cfg?.tabs?.[ti]?.textLabel; const containerTitle = sectionLabel ? `Aba: ${sectionLabel}` : `Aba ${ti + 1}`; const widgetLabel = innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`; const inPorts = innerInputs.map((name, ii) => { const path = `inputs.config.tabs[${ti}].widgets[${wi}].inputs.${name}`; const base = innerMetaInputs.get(name); const label = base?.label || name; const description = (base?.description ? base.description + '\n' : '') + `${containerTitle} • ${widgetLabel} • ${name}\n${path}`; return { id: `in:${name}`, label, description, kind: 'input', path, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (ti * 72) + ii * 18 } }; }); if (!inPorts.length) inPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', path: `inputs.config.tabs[${ti}].widgets[${wi}].inputs.`, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (ti * 72) } }); const child = { id: `${w.key}#group[${ti}]/${wi}`, label: innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`, icon: innerMeta?.icon, type: innerDef?.id || 'inner', parentId: w.key, collapsed: false, bounds: { x: node.bounds.x + 24, y: node.bounds.y + rowHeight + (ti * 72) + wi * 32, width: colWidth - 48, height: 48 }, ports: inPorts, }; nodes.push(child); nodeById.set(child.id, child); } } // Nav links const links = (cfg?.nav?.links || []); for (let li = 0; li < links.length; li++) { const inner = links[li]?.widgets || []; for (let wi = 0; wi < inner.length; wi++) { const innerDef = inner[wi]; const innerMeta = this.registry.get(innerDef?.id); const innerInputs = Object.keys(innerDef?.inputs || {}); const innerMetaInputs = new Map(); for (const i of innerMeta?.inputs || []) innerMetaInputs.set(i.name, { label: i.label, description: i.description }); const linkLabel = cfg?.nav?.links?.[li]?.label; const containerTitle = linkLabel ? `Link: ${linkLabel}` : `Link ${li + 1}`; const widgetLabel = innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`; const inPorts = innerInputs.map((name, ii) => { const path = `inputs.config.nav.links[${li}].widgets[${wi}].inputs.${name}`; const base = innerMetaInputs.get(name); const label = base?.label || name; const description = (base?.description ? base.description + '\n' : '') + `${containerTitle} • ${widgetLabel} • ${name}\n${path}`; return { id: `in:${name}`, label, description, kind: 'input', path, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (li * 72) + ii * 18 } }; }); if (!inPorts.length) inPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', path: `inputs.config.nav.links[${li}].widgets[${wi}].inputs.`, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (li * 72) } }); const child = { id: `${w.key}#nav[${li}]/${wi}`, label: innerMeta?.friendlyName || innerDef?.id || `link ${wi}`, icon: innerMeta?.icon, type: innerDef?.id || 'inner', parentId: w.key, collapsed: false, bounds: { x: node.bounds.x + 24, y: node.bounds.y + rowHeight + (li * 72) + wi * 32, width: colWidth - 48, height: 48 }, ports: inPorts, }; nodes.push(child); nodeById.set(child.id, child); } } } } // Second pass: map edges from page.connections const conns = (page?.connections || []); for (let i = 0; i < conns.length; i++) { const c = conns[i]; const fromNodeId = c.from.widget; const fromPortId = `out:${c.from.output}`; let toNodeId = c.to.widget; let toPortId = `in:${c.to.input}`; const deep = this.parseTabsPath(c.to.input); let friendlyPath; if (deep) { const childId = `${c.to.widget}#${deep.kind}[${deep.index}]/${deep.widgetIndex}`; // If such child exists, route edge to it and its input name if (nodes.some((n) => n.id === childId)) { toNodeId = childId; toPortId = `in:${deep.input}`; } else { // Keep to widget but still expose label/path toPortId = `in:${deep.input}`; } const cfg = tabsCfgByKey.get(c.to.widget); const labelFromCfg = deep.kind === 'group' ? cfg?.tabs?.[deep.index]?.textLabel : cfg?.nav?.links?.[deep.index]?.label; const containerLabel = labelFromCfg ? (deep.kind === 'group' ? `Aba: ${labelFromCfg}` : `Link: ${labelFromCfg}`) : (deep.kind === 'group' ? `Aba ${deep.index + 1}` : `Link ${deep.index + 1}`); const child = nodes.find(n => n.id === childId); friendlyPath = `${containerLabel} • ${(child?.label || 'Widget')} • ${deep.input}`; } // Ensure ports exist for visualization (create synthetic when missing) this.ensurePort(nodeById, fromNodeId, fromPortId, 'output', colWidth); this.ensurePort(nodeById, toNodeId, toPortId, 'input', colWidth); const edge = { id: `e${i}`, from: { nodeId: fromNodeId, portId: fromPortId }, to: { nodeId: toNodeId, portId: toPortId }, label: c.map || undefined, meta: { map: c.map, bindingOrder: c.to.bindingOrder, ...(friendlyPath ? { friendlyPath } : {}) }, }; edges.push(edge); } return { nodes, edges }; } ensurePort(nodeById, nodeId, portId, kind, colWidth) { const n = nodeById.get(nodeId); if (!n) return; const exists = (n.ports || []).some(p => p.id === portId); if (exists) return; const label = portId.replace(/^(in:|out:)/, ''); const portsOfKind = (n.ports || []).filter(p => p.kind === kind); const idx = portsOfKind.length; const y = n.bounds.y + 24 + idx * 22; const x = kind === 'output' ? n.bounds.x : n.bounds.x + n.bounds.width; const p = { id: portId, label, kind, anchor: { x, y } }; n.ports = [...(n.ports || []), p]; // Optionally grow node height to accommodate more ports const approxBottom = y + 28 - n.bounds.y; if (approxBottom > n.bounds.height) n.bounds = { ...n.bounds, height: approxBottom }; } /** * Parse dot-path for Tabs internals. Supported: * - inputs.config.tabs[<i>].widgets[<j>].inputs.<input> * - inputs.config.nav.links[<i>].widgets[<j>].inputs.<input> */ parseTabsPath(path) { if (!path) return null; const groupRe = /^inputs\.config\.tabs\[(\d+)\]\.widgets\[(\d+)\]\.inputs\.(.+)$/; const navRe = /^inputs\.config\.nav\.links\[(\d+)\]\.widgets\[(\d+)\]\.inputs\.(.+)$/; const g = path.match(groupRe); if (g) return { kind: 'group', index: Number(g[1]), widgetIndex: Number(g[2]), input: g[3] }; const n = path.match(navRe); if (n) return { kind: 'nav', index: Number(n[1]), widgetIndex: Number(n[2]), input: n[3] }; return null; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: GraphMapperService, deps: [{ token: i2.ComponentMetadataRegistry }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: GraphMapperService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: GraphMapperService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i2.ComponentMetadataRegistry }] }); class ConnectionGraphComponent { mapper; settingsPanel; injectedData; page; widgets; pageChange = new EventEmitter(); nodes = signal([], ...(ngDevMode ? [{ debugName: "nodes" }] : [])); edges = signal([], ...(ngDevMode ? [{ debugName: "edges" }] : [])); connections = signal([], ...(ngDevMode ? [{ debugName: "connections" }] : [])); showPortLabels = true; showEdgeLabels = false; showUnconnectedPorts = true; showDetails = true; showFriendlyLabels = true; selectedEdgeIndex = signal(-1, ...(ngDevMode ? [{ debugName: "selectedEdgeIndex" }] : [])); // Panning/zoom pan = { x: 0, y: 0 }; zoom = 1; transform = signal('', ...(ngDevMode ? [{ debugName: "transform" }] : [])); // Hover state hoverEdgeId = ''; // Drag state dragFrom; dragPath = signal('', ...(ngDevMode ? [{ debugName: "dragPath" }] : [])); // Pending popover pendingPopover = signal(null, ...(ngDevMode ? [{ debugName: "pendingPopover" }] : [])); // Settings Panel integration isDirty$ = new BehaviorSubject(false); isValid$ = new BehaviorSubject(true); isBusy$ = new BehaviorSubject(false); initialSnapshot = '[]'; placeholderSample = 'payload | payload.id | ${payload.id}'; connectedPorts = new Map(); isPortConnected(nodeId, portId) { const set = this.connectedPorts.get(nodeId); return !!set && set.has(portId); } outputPorts(n) { const ports = (n.ports || []).filter((p) => p.kind === 'output'); return this.showUnconnectedPorts ? ports : ports.filter(p => this.isPortConnected(n.id, p.id)); } inputPorts(n) { const ports = (n.ports || []).filter((p) => p.kind === 'input'); return this.showUnconnectedPorts ? ports : ports.filter(p => this.isPortConnected(n.id, p.id)); } constructor(mapper, settingsPanel, injectedData) { this.mapper = mapper; this.settingsPanel = settingsPanel; this.injectedData = injectedData; } ngOnInit() { const data = this.injectedData || {}; const p = this.parsePage(data.page); const ws = data.widgets || this.widgets || p?.widgets || []; this.widgets = ws; this.page = data.page || this.page; const conns = [...(p?.connections || [])]; this.connections.set(conns); this.initialSnapshot = JSON.stringify(conns); this.rebuildGraph(); } ngOnChanges(changes) { if (changes['page'] || changes['widgets']) { const p = this.parsePage(this.page); this.connections.set([...(p?.connections || [])]); this.rebuildGraph(); this.initialSnapshot = JSON.stringify(this.connections()); this.isDirty$.next(false); } } parsePage(input) { if (!input) return undefined; if (typeof input === 'string') { try { return JSON.parse(input); } catch { return undefined; } } return input; } rebuildGraph() { const p = this.parsePage(this.page) || { widgets: this.widgets || [], connections: this.connections() }; const { nodes, edges } = this.mapper.mapToGraph({ ...p, connections: this.connections() }, this.widgets || []); this.nodes.set(nodes); this.edges.set(edges); this.recomputeConnectedPorts(); this.updateTransform(); } recomputeConnectedPorts() { const map = new Map(); const add = (nodeId, portId) => { const s = map.get(nodeId) || new Set(); s.add(portId); map.set(nodeId, s); }; for (const e of this.edges()) { add(e.from.nodeId, e.from.portId); add(e.to.nodeId, e.to.portId); } this.connectedPorts = map; } // External focus by connection (from Builder or other callers) focusConnection(conn) { const fromNodeId = conn.from.widget; const fromPortId = `out:${conn.from.output}`; let toNodeId = conn.to.widget; let toPortId = `in:${conn.to.input}`; const deep = this.parseTabsPath(conn.to.input); if (deep) { const childId = `${conn.to.widget}#${deep.kind}[${deep.index}]/${deep.widgetIndex}`; if (this.nodes().some(n => n.id === childId)) { toNodeId = childId; toPortId = `in:${deep.input}`; } else { toPortId = `in:${deep.input}`; } } const idx = this.edges().findIndex(e => e.from.nodeId === fromNodeId && e.from.portId === fromPortId && e.to.nodeId === toNodeId && e.to.portId === toPortId); if (idx >= 0) this.selectEdge(idx); } parseTabsPath(path) { if (!path) return null; const g = path.match(/^inputs\.config\.tabs\[(\d+)\]\.widgets\[(\d+)\]\.inputs\.(.+)$/); if (g) return { kind: 'group', index: Number(g[1]), widgetIndex: Number(g[2]), input: g[3] }; const n = path.match(/^inputs\.config\.nav\.links\[(\d+)\]\.widgets\[(\d+)\]\.inputs\.(.+)$/); if (n) return { kind: 'nav', index: Number(n[1]), widgetIndex: Number(n[2]), input: n[3] }; return null; } // Pan/Zoom handlers onWheel(ev) { ev.preventDefault(); const delta = Math.sign(ev.deltaY) * -0.05; // invert: wheel down zoom out this.zoom = Math.min(2, Math.max(0.5, this.zoom + delta)); this.updateTransform(); } panning = false; panStart = { x: 0, y: 0 }; onPanStart(ev) { if (ev.target.closest('.port-hit')) return; // don't pan when starting on port this.panning = true; this.panStart = { x: ev.clientX - this.pan.x, y: ev.clientY - this.pan.y }; const onMove = (e) => this.onPanMove(e); const onUp = () => { this.panning = false; document.removeEventListener('mousemove', onMove); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once: true }); } onPanMove(ev) { if (!this.panning) return; this.pan = { x: ev.clientX - this.panStart.x, y: ev.clientY - this.panStart.y }; this.updateTransform(); } updateTransform() { this.transform.set(`translate(${this.pan.x},${this.pan.y}) scale(${this.zoom})`); } // Edge/path helpers edgePath(e) { const from = this.findPortAnchor(e.from.nodeId, e.from.portId); const to = this.findPortAnchor(e.to.nodeId, e.to.portId); return routeElbow(from, to); } edgeTooltip(e) { const toNode = this.nodes().find(n => n.id === e.to.nodeId); const fromNode = this.nodes().find(n => n.id === e.from.nodeId); const parts = [ `De: ${fromNode?.label || e.from.nodeId}.${e.from.portId.replace('out:', '')}`, `Para: ${toNode?.label || e.to.nodeId}.${e.to.portId.replace('in:', '')}`, ]; if (e.meta && e.meta.friendlyPath) parts.push(`Destino: ${e.meta.friendlyPath}`); if (e.meta?.map) parts.push(`map: ${e.meta.map}`); return parts.join('\n'); } // Port label/tooltip helpers technicalName(p) { const fromId = p.id?.replace(/^in:|^out:/, '') || ''; if (fromId) return fromId; if (p.path) { const m = p.path.match(/\.inputs\.([^\.\[]+)$/); if (m) return m[1]; } return p.label || ''; } portText(p) { return this.showFriendlyLabels ? (p.label || this.technicalName(p)) : this.technicalName(p); } portTooltip(p) { const tech = this.technicalName(p); const friendly = p.label || tech; const desc = p.description ? `\n${p.description}` : ''; if (this.showFriendlyLabels) return `${friendly}${desc}`; // when showing technical, include friendly for contexto if different return friendly !== tech ? `${tech} \n(${friendly})${desc}` : `${tech}${desc}`; } edgeMid(e) { const from = this.findPortAnchor(e.from.nodeId, e.from.portId); const to = this.findPortAnchor(e.to.nodeId, e.to.portId); return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; } findPortAnchor(nodeId, portId) { const n = this.nodes().find((x) => x.id === nodeId); if (!n) return { x: 0, y: 0 }; const p = n.ports.find((pp) => pp.id === portId); return p?.anchor || { x: n.bounds.x, y: n.bounds.y }; } // Drag connect flow onDragStart(ev) { this.dragFrom = ev.from; this.dragPath.set(`M ${ev.x} ${ev.y} L ${ev.x} ${ev.y}`); try { console.debug('[ConnectionGraph] drag:start', { from: ev.from, at: { x: ev.x, y: ev.y } }); } catch { } } onDragMove(ev) { if (!this.dragFrom) return; const from = this.findPortAnchor(this.dragFrom.nodeId, this.dragFrom.portId); this.dragPath.set(routeElbow(from, { x: ev.x, y: ev.y })); try { console.debug('[ConnectionGraph] drag:move', { to: { x: ev.x, y: ev.y } }); } catch { } } onDragEnd(ev) { if (!this.dragFrom) return; this.dragPath.set(''); // Find target input port element (robust: handle circle or its container) const endEl = ev.endEvent.target; const targetInfo = { tag: endEl?.tagName, class: endEl?.className }; let circleEl = endEl?.closest?.('.port.input circle'); if (!circleEl) { const portGroup = endEl?.closest?.('.port.input'); if (portGroup) circleEl = portGroup.querySelector('circle') || null; } if (!circleEl && endEl && endEl.getAttribute) { const hasData = Boolean(endEl.getAttribute('data-port-id')); if (hasData) circleEl = endEl; } if (!circleEl) { try { console.warn('[ConnectionGraph] drag:end — no target port found', targetInfo); } catch { } this.dragFrom = undefined; return; } const toNodeId = circleEl.getAttribute('data-node-id') || ''; const toPortId = circleEl.getAttribute('data-port-id') || ''; const path = circleEl.getAttribute('data-port-path') || undefined; try { console.debug('[ConnectionGraph] drag:end found target', { toNodeId, toPortId, path, at: { x: ev.x, y: ev.y } }); } catch { } this.pendingPopover.set({ x: ev.x, y: ev.y, from: this.dragFrom, to: { nodeId: toNodeId, portId: toPortId, path }, map: 'payload' }); try { console.debug('[ConnectionGraph] popover:open', this.pendingPopover()); } catch { } this.dragFrom = undefined; } cancelPending() { this.pendingPopover.set(null); } confirmPending() { const pop = this.pendingPopover(); if (!pop) return; const fromOutput = pop.from.portId.replace(/^out:/, ''); const toInputName = pop.to.portId.replace(/^in:/, ''); const toPath = pop.to.path || toInputName; const newConn = { from: { widget: pop.from.nodeId, output: fromOutput }, to: { widget: this.resolveToWidget(pop.to.nodeId), input: toPath }, map: pop.map, meta: { filterExpr: pop.filter || undefined, debounceMs: pop.debounceMs || undefined, distinct: pop.distinct || undefined, distinctBy: pop.distinctBy || undefined, }, }; const next = [...this.connections(), newConn]; try { console.debug('[ConnectionGraph] connection:add', newConn); } catch { } this.connections.set(next); this.pendingPopover.set(null); try { console.debug('[ConnectionGraph] popover:close'); } catch { } this.rebuildGraph(); this.emitPageChange(); this.updateDirty(next); } applyPreset(map) { const pop = this.pendingPopover(); if (!pop) return; this.pendingPopover.set({ ...pop, map }); } computePreview(map) { try { const sample = { payload: { id: 123, data: { id: 123, name: 'Exemplo' }, row: { id: 123 } } }; const path = (map || 'payload').split('.'); let cur = sample; for (const k of path) { if (!k) continue; cur = cur?.[k]; } return JSON.stringify(cur); } catch { return '—'; } } computeFilterPreview(expr) { try { if (!expr) return '—'; const sample = { payload: { id: 123, data: { id: 123, name: 'Exemplo' }, row: { id: 123 } } }; if (typeof expr === 'string' && expr.trim().startsWith('=')) { const code = expr.trim().slice(1); // Controlled eval limited to sample context // eslint-disable-next-line no-new-func const fn = new Function('payload', `"use strict"; return (${code});`); const ok = !!fn(sample.payload); return ok ? 'pass' : 'skip'; } return '—'; } catch { return 'erro'; } } resolveToWidget(nodeId) { // Child nodes are encoded as parent#kind[index]/widgetIndex const hash = nodeId.indexOf('#'); if (hash > 0) return nodeId.slice(0, hash); return nodeId; } removeEdge(index) { const next = this.connections().slice(); next.splice(index, 1); this.connections.set(next); this.rebuildGraph(); this.emitPageChange(); this.updateDirty(next); } // Select edge and open details selectEdge(index) { this.selectedEdgeIndex.set(index); this.showDetails = true; } currentConn() { const i = this.selectedEdgeIndex(); return (i >= 0 ? this.connections()[i] : undefined); } emitPageChange() { const parsed = this.parsePage(this.page) || { widgets: this.widgets || [] }; const updated = { ...parsed, connections: this.connections() }; this.pageChange.emit(updated); } // Settings panel value provider methods getSettingsValue() { return { page: { ...(this.parsePage(this.page) || {}), connections: this.connections() } }; } reset() { try { this.connections.set(JSON.parse(this.initialSnapshot || '[]')); } catch { this.connections.set([]); } this.rebuildGraph(); this.isDirty$.next(false); this.isValid$.next(true); } // Node dragging nodeDrag = { active: false, index: -1, startX: 0, startY: 0, origX: 0, origY: 0 }; onNodeDragStart(ev, index) { ev.preventDefault(); ev.stopPropagation(); const n = this.nodes()[index]; if (!n) return; this.nodeDrag = { active: true, index, startX: ev.clientX, startY: ev.clientY, origX: n.bounds.x, origY: n.bounds.y }; const onMove = (e) => this.onNodeDragMove(e); const onUp = () => { this.nodeDrag.active = false; document.removeEventListener('mousemove', onMove); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once: true }); } onNodeDragMove(ev) { if (!this.nodeDrag.active) return; const dx = (ev.clientX - this.nodeDrag.startX) / this.zoom; const dy = (ev.clientY - this.nodeDrag.startY) / this.zoom; const nodes = this.nodes().slice(); const n = { ...nodes[this.nodeDrag.index] }; const newX = this.nodeDrag.origX + dx; const newY = this.nodeDrag.origY + dy; const ddx = newX - n.bounds.x; const ddy = newY - n.bounds.y; n.bounds = { ...n.bounds, x: newX, y: newY }; // Shift port anchors by delta n.ports = (n.ports || []).map(p => ({ ...p, anchor: { x: p.anchor.x + ddx, y: p.anchor.y + ddy } })); nodes[this.nodeDrag.index] = n; this.nodes.set(nodes); } trackPort(_idx, p) { return p.id; } updateDirty(next) { this.isDirty$.next(JSON.stringify(next) !== this.initialSnapshot); this.isValid$.next(true); } // Builder integration openBuilder() { const base = this.parsePage(this.page) || { widgets: this.widgets || [] }; const p = { ...base, connections: this.connections() }; try { console.debug('[ConnectionGraph] openBuilder()', { baseHasConns: !!base.connections, graphConns: this.connections().length, widgets: this.widgets?.length }); } catch { } const ref = this.settingsPanel.open({ id: 'grid-connections', title: 'Conexões (Builder)', content: { component: ConnectionBuilderComponent, inputs: { page: p, widgets: this.widgets } } }); ref.applied$.subscribe((val) => { const next = (val && val.page) ? val.page : undefined; if (next) this.applyConnections(next); }); ref.saved$.subscribe((val) => { const next = (val && val.page) ? val.page : undefined; if (next) this.applyConnections(next); }); } applyConnections(next) { const conns = [...(next.connections || [])]; try { console.debug('[ConnectionGraph] applyConnections()', { conns: conns.length }); } catch { } this.connections.set(conns); this.rebuildGraph(); this.emitPageChange(); this.initialSnapshot = JSON.stringify(conns); this.isDirty$.next(false); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ConnectionGraphComponent, deps: [{ token: GraphMapperService }, { token: i2$1.SettingsPanelService }, { token: SETTINGS_PANEL_DATA, optional: true }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.4", type: ConnectionGraphComponent, isStandalone: true, selector: "praxis-connection-graph", inputs: { page: "page", widgets: "widgets" }, outputs: { pageChange: "pageChange" }, usesOnChanges: true, ngImport: i0, template: ` <div class="cg-root" (wheel)="onWheel($event)" (mousedown)="onPanStart($event)"> <div class="cg-head"> <div class="cg-title">Conexões</div> <div class="spacer"></div> <button mat-button [color]="showPortLabels ? 'primary' : undefined" (click)="showPortLabels = !showPortLabels" matTooltip="Mostrar/ocultar labels de portas" aria-label="Mostrar/ocultar labels de portas"> <mat-icon>label</mat-icon> </button> <button mat-button [color]="showFriendlyLabels ? 'primary' : undefined" (click)="showFriendlyLabels = !showFriendlyLabels" matTooltip="Alternar nome amigável/técnico" aria-label="Alternar nome amigável/técnico"> <mat-icon>translate</mat-icon> </button> <button mat-button [color]="showEdgeLabels ? 'primary' : undefined" (click)="showEdgeLabels = !showEdgeLabels" matTooltip="Mostrar/ocultar rótulos de arestas" aria-label="Mostrar/ocultar rótulos de arestas"> <mat-icon>text_fields</mat-icon> </button> <button mat-button [color]="showUnconnectedPorts ? 'primary' : undefined" (click)="showUnconnectedPorts = !showUnconnectedPorts" matTooltip="Mostrar/ocultar portas sem conexão" aria-label="Mostrar/ocultar portas sem conexão"> <mat-icon>tune</mat-icon> </button> <button mat-stroked-button color="primary" (click)="openBuilder()" aria-label="Editar no Builder"><mat-icon>tune</mat-icon><span>Editar no Builder</span></button> <div class="cg-count" [attr.aria-label]="'Total de conexões: ' + connections().length">{{ connections().length }}</div> </div> <div class="cg-canvas"> <!-- Edge details panel --> <div class="cg-details" *ngIf="selectedEdgeIndex() >= 0 && showDetails"> <div class="details-head"> <div class="details-title">Detalhes da conexão</div> <button mat-icon-button (click)="showDetails=false" aria-label="Fechar"><mat-icon>close</mat-icon></button> </div> <div class="details-body" *ngIf="currentConn() as c"> <div><b>De:</b> {{ c.from.widget }}.{{ c.from.output }}</div> <div><b>Para:</b> {{ c.to.widget }}.{{ c.to.input }}</div> <div><b>map:</b> {{ c.map || 'payload' }}</div> <div *ngIf="c.to.bindingOrder?.length"><b>bindingOrder:</b> {{ c.to.bindingOrder?.join(', ') }}</div> <div class="details-actions"> <button mat-button color="primary" (click)="openBuilder()"><mat-icon>tune</mat-icon> Editar no Builder</button> <button mat-button color="warn" (click)="removeEdge(selectedEdgeIndex())"><mat-icon>delete</mat-icon> Remover</button> </div> </div> </div> <svg [attr.width]="'100%'" [attr.height]="'100%'"> <defs> <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor"></path> </marker> </defs> <g [attr.transform]="transform()"> <!-- Edges --> <g class="edges"> <g *ngFor="let e of edges(); let i = index" class="edge-group" role="button" tabindex="0" (mouseenter)="hoverEdgeId = e.id" (mouseleave)="hoverEdgeId = ''" (click)="selectEdge(i)"> <path class="edge" [attr.d]="edgePath(e)" marker-end="url(#arrow)" [class.hover]="hoverEdgeId===e.id" [attr.aria-label]="e.label || ''" [matTooltip]="edgeTooltip(e)"></path> <ng-container *ngIf="showEdgeLabels && e.label"> <g [attr.transform]="'translate(' + edgeMid(e).x + ',' + edgeMid(e).y + ')'"> <text class="edge-label" x="0" y="0">{{ e.label }}</text> </g> </ng-container> <g *ngIf="hoverEdgeId===e.id" class="edge-toolbar"> <rect [attr.x]="edgeMid(e).x-28" [attr.y]="edgeMid(e).y-14" width="56" height="28" rx="6" ry="6" class="edge-toolbar-bg"></rect> <text [attr.x]="edgeMid(e).x-14" [attr.y]="edgeMid(e).y+4" class="icon" (click)="removeEdge(i)" aria-label="Remover">🗑</text> <text [attr.x]="edgeMid(e).x+8" [attr.y]="edgeMid(e).y+4" class="icon" (click)="openBuilder()" aria-label="Editar">✎</text> </g> </g> </g> <!-- Temporary drag edge --> <path *ngIf="dragPath()" class="edge temp" [attr.d]="dragPath()" marker-end="url(#arrow)"></path> <!-- Nodes --> <g class="nodes"> <g *ngFor="let n of nodes(); let ni = index" class="node" [attr.transform]="'translate(' + n.bounds.x + ',' + n.bounds.y + ')'" role="group" [attr.aria-label]="n.label"> <rect class="node-box" [attr.width]="n.bounds.width" [attr.height]="n.bounds.height" rx="8" ry="8" (mousedown)="onNodeDragStart($event, ni)"></rect> <text class="node-title" x="8" y="20">{{ n.label }}</text> <!-- Output ports (left) --> <g *ngFor="let p of outputPorts(n); trackBy: trackPort" class="port output" [attr.transform]="'translate(' + (0) + ',' + (p.anchor.y - n.bounds.y - 4) + ')'"> <circle r="6" cx="-6" cy="8" class="port-dot" [attr.data-node-id]="n.id" [attr.data-port-id]="p.id" [matTooltip]="portTooltip(p)"></circle> <rect class="port-hit" x="-16" y="0" width="20" height="16" [dragConnect]="{ nodeId: n.id, portId: p.id }" [matTooltip]="portTooltip(p)" (connectDragStart)="onDragStart($event)" (connectDragMove)="onDragMove($event)" (connectDragEnd)="onDragEnd($event)"></rect> <text *ngIf="showPortLabels" x="-4" y="12" class="port-label" text-anchor="end" [attr.title]="portTooltip(p)">{{ portText(p) }}</text> </g> <!-- Input ports (right) --> <g *ngFor="let p of inputPorts(n); trackBy: trackPort" class="port input" [attr.transform]="'translate(' + (n.bounds.width) + ',' + (p.anchor.y - n.bounds.y - 4) + ')'"> <circle r="6" cx="6" cy="8" class="port-dot" [attr.data-node-id]="n.id" [attr.data-port-id]="p.id" data-port-kind="input" [attr.data-port-path]="p.path || ''" [matTooltip]="portTooltip(p)"></circle> <rect class="port-hit" x="-4" y="0" width="20" height="16" [matTooltip]="portTooltip(p)"></rect> <text *ngIf="showPortLabels" x="8" y="12" class="port-label" [attr.title]="portTooltip(p)">{{ portText(p) }}</text> </g> </g> </g> </g> </svg> <!-- Pending connection popover --> <div class="popover" *ngIf="pendingPopover() as pop" [ngStyle]="{ left: pop.x + 'px', top: pop.y + 'px' }"> <div class="popover-body"> <div class="popover-title">Nova conexão</div> <div style="display:grid; gap:6px; min-width:260px;"> <label>map: <input [(ngModel)]="pop.map" [attr.placeholder]="placeholderSample" /> </label> <div style="display:flex; gap:6px; flex-wrap: wrap;"> <button mat-stroked-button (click)="applyPreset('payload')">payload</button> <button mat-stroked-button (click)="applyPreset('payload.id')">payload.id</button> <button mat-stroked-button (click)="applyPreset('payload.data')">payload.data</button> </div> <label>filter (opcional): <input [(ngModel)]="pop.filter" placeholder="ex.: = payload != null" /> </label> <div style="display:grid; gap:6px;"> <div style="display:flex; gap:8px; align-items:center;"> <label style="display:flex; align-items:center; gap:6px;">debounce (ms): <input type="number" min="0" style="width:90px" [(ngModel)]="pop.debounceMs" /></label> <label style="display:flex; align-items:center; gap:6px;"> <input type="checkbox" [(ngModel)]="pop.distinct" /> distinct </label> </div> <label>distinctBy (opcional): <input [(ngModel)]="pop['distinctBy']" placeholder="ex.: payload.id" /> </label> </div> <div style="font-size:12px; opacity:.8;">Filtro: {{ computeFilterPreview(pop.filter) }}</div> <div style="font-size:12px; opacity:.8;">Prévia: {{ computePreview(pop.map) }}</div> </div> <div class="actions"> <button mat-button (click)="cancelPending()">Cancelar</button> <button mat-flat-button color="primary" (click)="confirmPending()">Confirmar</button> </div> </div> </div> </div> </div> `, isInline: true, styles: [".cg-root{display:flex;flex-direction:column;height:100%;width:100%}.cg-head{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--md-sys-color-outline, #444)}.cg-title{font-weight:600}.spacer{flex:1}.cg-count{opacity:.8}.cg-canvas{position:relative;flex:1;min-height:300px}svg{background:var(--md-sys-color-surface-container-lowest, #111);color:var(--md-sys-color-on-surface, #eee)}.node-box{fill:var(--md-sys-color-surface-container, #1b1b1b);stroke:var(--md-sys-color-outline, #4a4a4a)}.node-title{fill:var(--md-sys-color-on-surface, #eee);font-size:14px;font-weight:600}.port-dot{fill:currentColor;r:6}.port-label{fill:var(--md-sys-color-on-surface-variant, #bbb);font-size:13px}.edge{stroke:var(--md-sys-color-primary, #56a0ff);stroke-width:2;fill:none}.edge.hover{stroke:var(--md-sys-color-tertiary, #ffa04d)}.edge.temp{stroke-dasharray:4 4;opacity:.7}.edge-label{fill:var(--md-sys-color-on-surface, #ffffff);font-size:12px;dominant-baseline:middle;text-anchor:middle}.edge-label-bg{fill:#00000059}.edge-toolbar .edge-toolbar-bg{fill:var(--md-sys-color-surface, #222);stroke:var(--md-sys-color-outline, #444)}.edge-toolbar .icon{fill:var(--md-sys-color-on-surface, #ffffff);font-size:12px;cursor:pointer;-webkit-user-select:none;user-select:none}.popover{position:absolute;transform:translate(-50%,-50%);z-index:10}.popover-body{background:var(--md-sys-color-surface, #222);color:var(--md-sys-color-on-surface, #eee);border:1px solid var(--md-sys-color-outline, #444);border-radius:8px;padding:8px;min-width:220px;box-shadow:0 8px 24px #0006}.popover-title{font-weight:600;margin-bottom:8px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.port-hit{fill:transparent;cursor:crosshair;height:24px}.cg-details{position:absolute;right:8px;top:48px;width:320px;background:var(--md-sys-color-surface, #222);color:var(--md-sys-color-on-surface, #eee);border:1px solid var(--md-sys-color-outline, #444);border-radius:8px;box-shadow:0 8px 24px #00000059;z-index:12}.details-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid var(--md-sys-color-outline, #444)}.details-title{font-weight:600}.details-body{padding:8px 10px;display:grid;gap:6px}.details-actions{display:flex;gap:8px;margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kin