@praxisui/page-builder
Version:
Page and widget builder utilities for Praxis UI (grid, dynamic widgets, editors).
978 lines (969 loc) • 64.7 kB
JavaScript
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