1008 lines
33 KiB
TypeScript
1008 lines
33 KiB
TypeScript
/**
|
|
* <folk-automation-canvas> — n8n-style automation workflow builder for rSchedule.
|
|
*
|
|
* Renders workflow nodes (triggers, conditions, actions) on an SVG canvas
|
|
* with ports, Bezier wiring, node palette, config panel, and REST persistence.
|
|
*
|
|
* Attributes:
|
|
* space — space slug (default "demo")
|
|
*/
|
|
|
|
import { NODE_CATALOG } from '../schemas';
|
|
import type { AutomationNodeDef, AutomationNodeCategory, WorkflowNode, WorkflowEdge, Workflow } from '../schemas';
|
|
|
|
// ── Constants ──
|
|
|
|
const NODE_WIDTH = 220;
|
|
const NODE_HEIGHT = 80;
|
|
const PORT_RADIUS = 5;
|
|
|
|
const CATEGORY_COLORS: Record<AutomationNodeCategory, string> = {
|
|
trigger: '#3b82f6',
|
|
condition: '#f59e0b',
|
|
action: '#10b981',
|
|
};
|
|
|
|
const PORT_COLORS: Record<string, string> = {
|
|
trigger: '#ef4444',
|
|
data: '#3b82f6',
|
|
boolean: '#f59e0b',
|
|
};
|
|
|
|
// ── Helpers ──
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement('div');
|
|
d.textContent = s || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function getNodeDef(type: string): AutomationNodeDef | undefined {
|
|
return NODE_CATALOG.find(n => n.type === type);
|
|
}
|
|
|
|
function getPortX(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number {
|
|
return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
|
|
}
|
|
|
|
function getPortY(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number {
|
|
const def = getNodeDef(node.type);
|
|
if (!def) return node.position.y + NODE_HEIGHT / 2;
|
|
const ports = direction === 'input' ? def.inputs : def.outputs;
|
|
const idx = ports.findIndex(p => p.name === portName);
|
|
if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
|
|
const spacing = NODE_HEIGHT / (ports.length + 1);
|
|
return node.position.y + spacing * (idx + 1);
|
|
}
|
|
|
|
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
|
|
const dx = Math.abs(x2 - x1) * 0.5;
|
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
}
|
|
|
|
// ── Component ──
|
|
|
|
class FolkAutomationCanvas extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = '';
|
|
|
|
private get basePath() {
|
|
const host = window.location.hostname;
|
|
if (host.endsWith('.rspace.online')) return '/rschedule/';
|
|
return `/${this.space}/rschedule/`;
|
|
}
|
|
|
|
// Data
|
|
private workflows: Workflow[] = [];
|
|
private currentWorkflowId = '';
|
|
private nodes: WorkflowNode[] = [];
|
|
private edges: WorkflowEdge[] = [];
|
|
private workflowName = 'New Workflow';
|
|
private workflowEnabled = true;
|
|
|
|
// Canvas state
|
|
private canvasZoom = 1;
|
|
private canvasPanX = 0;
|
|
private canvasPanY = 0;
|
|
|
|
// Interaction
|
|
private isPanning = false;
|
|
private panStartX = 0;
|
|
private panStartY = 0;
|
|
private panStartPanX = 0;
|
|
private panStartPanY = 0;
|
|
private draggingNodeId: string | null = null;
|
|
private dragStartX = 0;
|
|
private dragStartY = 0;
|
|
private dragNodeStartX = 0;
|
|
private dragNodeStartY = 0;
|
|
|
|
// Selection & config
|
|
private selectedNodeId: string | null = null;
|
|
private configOpen = false;
|
|
|
|
// Wiring
|
|
private wiringActive = false;
|
|
private wiringSourceNodeId: string | null = null;
|
|
private wiringSourcePortName: string | null = null;
|
|
private wiringSourceDir: 'input' | 'output' | null = null;
|
|
private wiringPointerX = 0;
|
|
private wiringPointerY = 0;
|
|
|
|
// Persistence
|
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private saveIndicator = '';
|
|
|
|
// Execution log
|
|
private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = [];
|
|
|
|
// Bound listeners
|
|
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
|
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
|
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute('space') || 'demo';
|
|
this.initData();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
|
|
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
|
|
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
|
|
}
|
|
|
|
// ── Data init ──
|
|
|
|
private async initData() {
|
|
try {
|
|
const res = await fetch(`${this.basePath}api/workflows`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.workflows = data.results || [];
|
|
if (this.workflows.length > 0) {
|
|
this.loadWorkflow(this.workflows[0]);
|
|
}
|
|
}
|
|
} catch {
|
|
console.warn('[AutomationCanvas] Failed to load workflows');
|
|
}
|
|
this.render();
|
|
requestAnimationFrame(() => this.fitView());
|
|
}
|
|
|
|
private loadWorkflow(wf: Workflow) {
|
|
this.currentWorkflowId = wf.id;
|
|
this.workflowName = wf.name;
|
|
this.workflowEnabled = wf.enabled;
|
|
this.nodes = wf.nodes.map(n => ({ ...n, position: { ...n.position } }));
|
|
this.edges = wf.edges.map(e => ({ ...e }));
|
|
this.selectedNodeId = null;
|
|
this.configOpen = false;
|
|
this.execLog = [];
|
|
}
|
|
|
|
// ── Persistence ──
|
|
|
|
private scheduleSave() {
|
|
this.saveIndicator = 'Saving...';
|
|
this.updateSaveIndicator();
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
|
|
}
|
|
|
|
private async executeSave() {
|
|
if (!this.currentWorkflowId) return;
|
|
try {
|
|
await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: this.workflowName,
|
|
enabled: this.workflowEnabled,
|
|
nodes: this.nodes,
|
|
edges: this.edges,
|
|
}),
|
|
});
|
|
this.saveIndicator = 'Saved';
|
|
} catch {
|
|
this.saveIndicator = 'Save failed';
|
|
}
|
|
this.updateSaveIndicator();
|
|
setTimeout(() => { this.saveIndicator = ''; this.updateSaveIndicator(); }, 2000);
|
|
}
|
|
|
|
private async createWorkflow() {
|
|
try {
|
|
const res = await fetch(`${this.basePath}api/workflows`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'New Workflow' }),
|
|
});
|
|
if (res.ok) {
|
|
const wf = await res.json();
|
|
this.workflows.push(wf);
|
|
this.loadWorkflow(wf);
|
|
this.render();
|
|
requestAnimationFrame(() => this.fitView());
|
|
}
|
|
} catch {
|
|
console.error('[AutomationCanvas] Failed to create workflow');
|
|
}
|
|
}
|
|
|
|
private async deleteWorkflow() {
|
|
if (!this.currentWorkflowId) return;
|
|
try {
|
|
await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, { method: 'DELETE' });
|
|
this.workflows = this.workflows.filter(w => w.id !== this.currentWorkflowId);
|
|
if (this.workflows.length > 0) {
|
|
this.loadWorkflow(this.workflows[0]);
|
|
} else {
|
|
this.currentWorkflowId = '';
|
|
this.nodes = [];
|
|
this.edges = [];
|
|
this.workflowName = '';
|
|
}
|
|
this.render();
|
|
} catch {
|
|
console.error('[AutomationCanvas] Failed to delete workflow');
|
|
}
|
|
}
|
|
|
|
// ── Canvas transform ──
|
|
|
|
private updateCanvasTransform() {
|
|
const g = this.shadow.getElementById('canvas-transform');
|
|
if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
|
|
this.updateZoomDisplay();
|
|
}
|
|
|
|
private updateZoomDisplay() {
|
|
const el = this.shadow.getElementById('zoom-level');
|
|
if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
|
|
}
|
|
|
|
private updateSaveIndicator() {
|
|
const el = this.shadow.getElementById('save-indicator');
|
|
if (el) el.textContent = this.saveIndicator;
|
|
}
|
|
|
|
private fitView() {
|
|
const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null;
|
|
if (!svg || this.nodes.length === 0) return;
|
|
const rect = svg.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of this.nodes) {
|
|
minX = Math.min(minX, n.position.x);
|
|
minY = Math.min(minY, n.position.y);
|
|
maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
|
|
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
|
|
}
|
|
|
|
const pad = 60;
|
|
const contentW = maxX - minX + pad * 2;
|
|
const contentH = maxY - minY + pad * 2;
|
|
const scaleX = rect.width / contentW;
|
|
const scaleY = rect.height / contentH;
|
|
this.canvasZoom = Math.min(scaleX, scaleY, 1.5);
|
|
this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
|
|
this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
|
|
this.updateCanvasTransform();
|
|
}
|
|
|
|
private zoomAt(screenX: number, screenY: number, factor: number) {
|
|
const oldZoom = this.canvasZoom;
|
|
const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
|
|
this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
|
|
this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
|
|
this.canvasZoom = newZoom;
|
|
this.updateCanvasTransform();
|
|
}
|
|
|
|
// ── Rendering ──
|
|
|
|
private render() {
|
|
const paletteGroups = ['trigger', 'condition', 'action'] as AutomationNodeCategory[];
|
|
|
|
this.shadow.innerHTML = `
|
|
<link rel="stylesheet" href="/modules/rschedule/automation-canvas.css">
|
|
<div class="ac-root">
|
|
<div class="ac-toolbar">
|
|
<div class="ac-toolbar__title">
|
|
<span>Automations</span>
|
|
<select class="ac-workflow-select" id="workflow-select">
|
|
${this.workflows.map(w => `<option value="${w.id}" ${w.id === this.currentWorkflowId ? 'selected' : ''}>${esc(w.name)}</option>`).join('')}
|
|
</select>
|
|
<input id="wf-name" type="text" value="${esc(this.workflowName)}" placeholder="Workflow name">
|
|
</div>
|
|
<div class="ac-toolbar__actions">
|
|
<div class="ac-toggle">
|
|
<input type="checkbox" id="wf-enabled" ${this.workflowEnabled ? 'checked' : ''}>
|
|
<label for="wf-enabled">Enabled</label>
|
|
</div>
|
|
<span class="ac-save-indicator" id="save-indicator">${this.saveIndicator}</span>
|
|
<button class="ac-btn ac-btn--run" id="btn-run">▶ Run All</button>
|
|
<button class="ac-btn" id="btn-new">+ New</button>
|
|
<button class="ac-btn" id="btn-delete">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ac-canvas-area">
|
|
<div class="ac-palette" id="palette">
|
|
${paletteGroups.map(cat => `
|
|
<div>
|
|
<div class="ac-palette__group-title">${cat}s</div>
|
|
${NODE_CATALOG.filter(n => n.category === cat).map(n => `
|
|
<div class="ac-palette__card" data-node-type="${n.type}" draggable="true">
|
|
<span class="ac-palette__card-icon">${n.icon}</span>
|
|
<span class="ac-palette__card-label">${esc(n.label)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="ac-canvas" id="ac-canvas">
|
|
<svg id="ac-svg" xmlns="http://www.w3.org/2000/svg">
|
|
<g id="canvas-transform">
|
|
<g id="edge-layer">${this.renderAllEdges()}</g>
|
|
<g id="wire-layer"></g>
|
|
<g id="node-layer">${this.renderAllNodes()}</g>
|
|
</g>
|
|
</svg>
|
|
<div class="ac-zoom-controls">
|
|
<button class="ac-zoom-btn" id="zoom-out">-</button>
|
|
<span class="ac-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
|
|
<button class="ac-zoom-btn" id="zoom-in">+</button>
|
|
<button class="ac-zoom-btn" id="zoom-fit" title="Fit view">⇄</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ac-config ${this.configOpen ? 'open' : ''}" id="config-panel">
|
|
${this.renderConfigPanel()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.attachEventListeners();
|
|
}
|
|
|
|
private renderAllNodes(): string {
|
|
return this.nodes.map(node => this.renderNode(node)).join('');
|
|
}
|
|
|
|
private renderNode(node: WorkflowNode): string {
|
|
const def = getNodeDef(node.type);
|
|
if (!def) return '';
|
|
const catColor = CATEGORY_COLORS[def.category];
|
|
const status = node.runtimeStatus || 'idle';
|
|
const isSelected = node.id === this.selectedNodeId;
|
|
|
|
// Ports
|
|
let portsHtml = '';
|
|
for (const inp of def.inputs) {
|
|
const y = getPortY(node, inp.name, 'input');
|
|
const x = node.position.x;
|
|
const color = PORT_COLORS[inp.type] || '#6b7280';
|
|
portsHtml += `
|
|
<g class="ac-port-group" data-node-id="${node.id}" data-port-name="${inp.name}" data-port-dir="input">
|
|
<circle class="ac-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="${color}" stroke="#0f0f23" stroke-width="2"/>
|
|
<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="ac-port-hit"/>
|
|
</g>`;
|
|
}
|
|
for (const out of def.outputs) {
|
|
const y = getPortY(node, out.name, 'output');
|
|
const x = node.position.x + NODE_WIDTH;
|
|
const color = PORT_COLORS[out.type] || '#6b7280';
|
|
portsHtml += `
|
|
<g class="ac-port-group" data-node-id="${node.id}" data-port-name="${out.name}" data-port-dir="output">
|
|
<circle class="ac-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="${color}" stroke="#0f0f23" stroke-width="2"/>
|
|
<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="ac-port-hit"/>
|
|
</g>`;
|
|
}
|
|
|
|
// Input port labels
|
|
let portLabelHtml = '';
|
|
for (const inp of def.inputs) {
|
|
const y = getPortY(node, inp.name, 'input');
|
|
portLabelHtml += `<text x="${node.position.x + 14}" y="${y + 4}" fill="#94a3b8" font-size="9">${inp.name}</text>`;
|
|
}
|
|
for (const out of def.outputs) {
|
|
const y = getPortY(node, out.name, 'output');
|
|
portLabelHtml += `<text x="${node.position.x + NODE_WIDTH - 14}" y="${y + 4}" fill="#94a3b8" font-size="9" text-anchor="end">${out.name}</text>`;
|
|
}
|
|
|
|
return `
|
|
<g class="ac-node ${isSelected ? 'selected' : ''}" data-node-id="${node.id}">
|
|
<foreignObject x="${node.position.x}" y="${node.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}">
|
|
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%">
|
|
<div class="ac-node-header" style="border-left: 3px solid ${catColor}">
|
|
<span class="ac-node-icon">${def.icon}</span>
|
|
<span class="ac-node-label">${esc(node.label)}</span>
|
|
<span class="ac-node-status ${status}"></span>
|
|
</div>
|
|
<div class="ac-node-ports">
|
|
<div class="ac-node-inputs">
|
|
${def.inputs.map(p => `<span class="ac-port-label">${p.name}</span>`).join('')}
|
|
</div>
|
|
<div class="ac-node-outputs">
|
|
${def.outputs.map(p => `<span class="ac-port-label">${p.name}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</foreignObject>
|
|
${portsHtml}
|
|
${portLabelHtml}
|
|
</g>`;
|
|
}
|
|
|
|
private renderAllEdges(): string {
|
|
return this.edges.map(edge => {
|
|
const fromNode = this.nodes.find(n => n.id === edge.fromNode);
|
|
const toNode = this.nodes.find(n => n.id === edge.toNode);
|
|
if (!fromNode || !toNode) return '';
|
|
|
|
const x1 = getPortX(fromNode, edge.fromPort, 'output');
|
|
const y1 = getPortY(fromNode, edge.fromPort, 'output');
|
|
const x2 = getPortX(toNode, edge.toPort, 'input');
|
|
const y2 = getPortY(toNode, edge.toPort, 'input');
|
|
|
|
const fromDef = getNodeDef(fromNode.type);
|
|
const outPort = fromDef?.outputs.find(p => p.name === edge.fromPort);
|
|
const color = outPort ? (PORT_COLORS[outPort.type] || '#6b7280') : '#6b7280';
|
|
const d = bezierPath(x1, y1, x2, y2);
|
|
|
|
return `
|
|
<g class="ac-edge-group" data-edge-id="${edge.id}">
|
|
<path class="ac-edge-hit" d="${d}"/>
|
|
<path class="ac-edge-path" d="${d}" stroke="${color}" stroke-opacity="0.6"/>
|
|
</g>`;
|
|
}).join('');
|
|
}
|
|
|
|
private renderConfigPanel(): string {
|
|
if (!this.selectedNodeId) {
|
|
return `
|
|
<div class="ac-config__header">
|
|
<span>No node selected</span>
|
|
<button class="ac-config__header-close" id="config-close">×</button>
|
|
</div>
|
|
<div class="ac-config__body">
|
|
<p style="color:#64748b;font-size:12px">Click a node to configure it.</p>
|
|
</div>`;
|
|
}
|
|
|
|
const node = this.nodes.find(n => n.id === this.selectedNodeId);
|
|
if (!node) return '';
|
|
const def = getNodeDef(node.type);
|
|
if (!def) return '';
|
|
|
|
const fieldsHtml = def.configSchema.map(field => {
|
|
const val = node.config[field.key] ?? '';
|
|
if (field.type === 'select') {
|
|
const options = (field.options || []).map(o =>
|
|
`<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`
|
|
).join('');
|
|
return `
|
|
<div class="ac-config__field">
|
|
<label>${esc(field.label)}</label>
|
|
<select data-config-key="${field.key}">${options}</select>
|
|
</div>`;
|
|
}
|
|
if (field.type === 'textarea') {
|
|
return `
|
|
<div class="ac-config__field">
|
|
<label>${esc(field.label)}</label>
|
|
<textarea data-config-key="${field.key}" placeholder="${esc(field.placeholder || '')}">${esc(String(val))}</textarea>
|
|
</div>`;
|
|
}
|
|
return `
|
|
<div class="ac-config__field">
|
|
<label>${esc(field.label)}</label>
|
|
<input type="${field.type === 'number' ? 'number' : 'text'}" data-config-key="${field.key}" value="${esc(String(val))}" placeholder="${esc(field.placeholder || '')}">
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => `
|
|
<div class="ac-exec-log__entry">
|
|
<span class="ac-exec-log__dot ${e.status}"></span>
|
|
<span>${esc(e.message)} (${e.durationMs}ms)</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
return `
|
|
<div class="ac-config__header">
|
|
<span>${def.icon} ${esc(node.label)}</span>
|
|
<button class="ac-config__header-close" id="config-close">×</button>
|
|
</div>
|
|
<div class="ac-config__body">
|
|
<div class="ac-config__field">
|
|
<label>Label</label>
|
|
<input type="text" id="config-label" value="${esc(node.label)}">
|
|
</div>
|
|
${fieldsHtml}
|
|
${logHtml ? `<div class="ac-exec-log"><div class="ac-exec-log__title">Execution Log</div>${logHtml}</div>` : ''}
|
|
<button class="ac-config__delete" id="config-delete-node">Delete Node</button>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Redraw helpers ──
|
|
|
|
private drawCanvasContent() {
|
|
const edgeLayer = this.shadow.getElementById('edge-layer');
|
|
const nodeLayer = this.shadow.getElementById('node-layer');
|
|
const wireLayer = this.shadow.getElementById('wire-layer');
|
|
if (!edgeLayer || !nodeLayer) return;
|
|
edgeLayer.innerHTML = this.renderAllEdges();
|
|
nodeLayer.innerHTML = this.renderAllNodes();
|
|
if (wireLayer) wireLayer.innerHTML = '';
|
|
}
|
|
|
|
private redrawEdges() {
|
|
const edgeLayer = this.shadow.getElementById('edge-layer');
|
|
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
|
|
}
|
|
|
|
private updateNodePosition(node: WorkflowNode) {
|
|
const nodeLayer = this.shadow.getElementById('node-layer');
|
|
if (!nodeLayer) return;
|
|
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
|
|
if (!g) return;
|
|
const fo = g.querySelector('foreignObject');
|
|
if (fo) {
|
|
fo.setAttribute('x', String(node.position.x));
|
|
fo.setAttribute('y', String(node.position.y));
|
|
}
|
|
// Update port circle positions
|
|
const def = getNodeDef(node.type);
|
|
if (!def) return;
|
|
const portGroups = g.querySelectorAll('.ac-port-group');
|
|
portGroups.forEach(pg => {
|
|
const portName = (pg as HTMLElement).dataset.portName!;
|
|
const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output';
|
|
const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
|
|
const ports = dir === 'input' ? def.inputs : def.outputs;
|
|
const idx = ports.findIndex(p => p.name === portName);
|
|
const spacing = NODE_HEIGHT / (ports.length + 1);
|
|
const y = node.position.y + spacing * (idx + 1);
|
|
pg.querySelectorAll('circle').forEach(c => {
|
|
c.setAttribute('cx', String(x));
|
|
c.setAttribute('cy', String(y));
|
|
});
|
|
});
|
|
// Update port labels
|
|
const labels = g.querySelectorAll('text');
|
|
let labelIdx = 0;
|
|
for (const inp of def.inputs) {
|
|
if (labels[labelIdx]) {
|
|
const y = getPortY(node, inp.name, 'input');
|
|
labels[labelIdx].setAttribute('x', String(node.position.x + 14));
|
|
labels[labelIdx].setAttribute('y', String(y + 4));
|
|
}
|
|
labelIdx++;
|
|
}
|
|
for (const out of def.outputs) {
|
|
if (labels[labelIdx]) {
|
|
const y = getPortY(node, out.name, 'output');
|
|
labels[labelIdx].setAttribute('x', String(node.position.x + NODE_WIDTH - 14));
|
|
labels[labelIdx].setAttribute('y', String(y + 4));
|
|
}
|
|
labelIdx++;
|
|
}
|
|
}
|
|
|
|
private refreshConfigPanel() {
|
|
const panel = this.shadow.getElementById('config-panel');
|
|
if (!panel) return;
|
|
panel.className = `ac-config ${this.configOpen ? 'open' : ''}`;
|
|
panel.innerHTML = this.renderConfigPanel();
|
|
this.attachConfigListeners();
|
|
}
|
|
|
|
// ── Node operations ──
|
|
|
|
private addNode(type: string, x: number, y: number) {
|
|
const def = getNodeDef(type);
|
|
if (!def) return;
|
|
const id = `n-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
const node: WorkflowNode = {
|
|
id,
|
|
type: def.type,
|
|
label: def.label,
|
|
position: { x, y },
|
|
config: {},
|
|
};
|
|
this.nodes.push(node);
|
|
this.drawCanvasContent();
|
|
this.selectNode(id);
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private deleteNode(nodeId: string) {
|
|
this.nodes = this.nodes.filter(n => n.id !== nodeId);
|
|
this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId);
|
|
if (this.selectedNodeId === nodeId) {
|
|
this.selectedNodeId = null;
|
|
this.configOpen = false;
|
|
}
|
|
this.drawCanvasContent();
|
|
this.refreshConfigPanel();
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private selectNode(nodeId: string) {
|
|
this.selectedNodeId = nodeId;
|
|
this.configOpen = true;
|
|
// Update selection in SVG
|
|
const nodeLayer = this.shadow.getElementById('node-layer');
|
|
if (nodeLayer) {
|
|
nodeLayer.querySelectorAll('.ac-node').forEach(g => {
|
|
g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId);
|
|
});
|
|
}
|
|
this.refreshConfigPanel();
|
|
}
|
|
|
|
// ── Wiring ──
|
|
|
|
private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') {
|
|
// Only start wiring from output ports
|
|
if (dir !== 'output') return;
|
|
this.wiringActive = true;
|
|
this.wiringSourceNodeId = nodeId;
|
|
this.wiringSourcePortName = portName;
|
|
this.wiringSourceDir = dir;
|
|
const canvas = this.shadow.getElementById('ac-canvas');
|
|
if (canvas) canvas.classList.add('wiring');
|
|
}
|
|
|
|
private cancelWiring() {
|
|
this.wiringActive = false;
|
|
this.wiringSourceNodeId = null;
|
|
this.wiringSourcePortName = null;
|
|
this.wiringSourceDir = null;
|
|
const canvas = this.shadow.getElementById('ac-canvas');
|
|
if (canvas) canvas.classList.remove('wiring');
|
|
const wireLayer = this.shadow.getElementById('wire-layer');
|
|
if (wireLayer) wireLayer.innerHTML = '';
|
|
}
|
|
|
|
private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') {
|
|
if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; }
|
|
if (targetDir !== 'input') { this.cancelWiring(); return; }
|
|
if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; }
|
|
|
|
// Check for duplicate edges
|
|
const exists = this.edges.some(e =>
|
|
e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName &&
|
|
e.toNode === targetNodeId && e.toPort === targetPortName
|
|
);
|
|
if (exists) { this.cancelWiring(); return; }
|
|
|
|
const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
this.edges.push({
|
|
id: edgeId,
|
|
fromNode: this.wiringSourceNodeId,
|
|
fromPort: this.wiringSourcePortName,
|
|
toNode: targetNodeId,
|
|
toPort: targetPortName,
|
|
});
|
|
|
|
this.cancelWiring();
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private updateWiringTempLine() {
|
|
const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null;
|
|
const wireLayer = this.shadow.getElementById('wire-layer');
|
|
if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return;
|
|
|
|
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
|
|
if (!sourceNode) return;
|
|
|
|
const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output');
|
|
const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output');
|
|
|
|
const rect = svg.getBoundingClientRect();
|
|
const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
|
|
const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
|
|
|
|
const d = bezierPath(x1, y1, x2, y2);
|
|
wireLayer.innerHTML = `<path class="ac-wiring-temp" d="${d}"/>`;
|
|
}
|
|
|
|
// ── Execution ──
|
|
|
|
private async runWorkflow() {
|
|
if (!this.currentWorkflowId) return;
|
|
// Reset runtime statuses
|
|
for (const n of this.nodes) {
|
|
n.runtimeStatus = 'running';
|
|
}
|
|
this.drawCanvasContent();
|
|
|
|
try {
|
|
const res = await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}/run`, { method: 'POST' });
|
|
const data = await res.json();
|
|
this.execLog = data.results || [];
|
|
|
|
// Update node statuses
|
|
for (const n of this.nodes) {
|
|
const logEntry = this.execLog.find(e => e.nodeId === n.id);
|
|
n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle';
|
|
n.runtimeMessage = logEntry?.message;
|
|
}
|
|
} catch {
|
|
for (const n of this.nodes) {
|
|
n.runtimeStatus = 'error';
|
|
}
|
|
}
|
|
|
|
this.drawCanvasContent();
|
|
if (this.selectedNodeId) this.refreshConfigPanel();
|
|
|
|
// Reset after 5s
|
|
setTimeout(() => {
|
|
for (const n of this.nodes) {
|
|
n.runtimeStatus = 'idle';
|
|
n.runtimeMessage = undefined;
|
|
}
|
|
this.drawCanvasContent();
|
|
}, 5000);
|
|
}
|
|
|
|
// ── Event listeners ──
|
|
|
|
private attachEventListeners() {
|
|
const canvas = this.shadow.getElementById('ac-canvas')!;
|
|
const svg = this.shadow.getElementById('ac-svg')!;
|
|
const palette = this.shadow.getElementById('palette')!;
|
|
|
|
// Toolbar
|
|
this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => {
|
|
this.workflowName = (e.target as HTMLInputElement).value;
|
|
this.scheduleSave();
|
|
});
|
|
|
|
this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => {
|
|
this.workflowEnabled = (e.target as HTMLInputElement).checked;
|
|
this.scheduleSave();
|
|
});
|
|
|
|
this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow());
|
|
this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow());
|
|
this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow());
|
|
|
|
this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => {
|
|
const id = (e.target as HTMLSelectElement).value;
|
|
const wf = this.workflows.find(w => w.id === id);
|
|
if (wf) {
|
|
this.loadWorkflow(wf);
|
|
this.drawCanvasContent();
|
|
this.refreshConfigPanel();
|
|
requestAnimationFrame(() => this.fitView());
|
|
}
|
|
});
|
|
|
|
// Zoom controls
|
|
this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
|
|
const rect = svg.getBoundingClientRect();
|
|
this.zoomAt(rect.width / 2, rect.height / 2, 1.2);
|
|
});
|
|
this.shadow.getElementById('zoom-out')?.addEventListener('click', () => {
|
|
const rect = svg.getBoundingClientRect();
|
|
this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
|
|
});
|
|
this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());
|
|
|
|
// Canvas mouse wheel
|
|
canvas.addEventListener('wheel', (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const rect = svg.getBoundingClientRect();
|
|
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor);
|
|
}, { passive: false });
|
|
|
|
// Palette drag
|
|
palette.querySelectorAll('.ac-palette__card').forEach(card => {
|
|
card.addEventListener('dragstart', (e: Event) => {
|
|
const de = e as DragEvent;
|
|
const type = (card as HTMLElement).dataset.nodeType!;
|
|
de.dataTransfer?.setData('text/plain', type);
|
|
});
|
|
});
|
|
|
|
// Canvas drop
|
|
canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); });
|
|
canvas.addEventListener('drop', (e: DragEvent) => {
|
|
e.preventDefault();
|
|
const type = e.dataTransfer?.getData('text/plain');
|
|
if (!type) return;
|
|
const rect = svg.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
|
|
const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
|
|
this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2);
|
|
});
|
|
|
|
// SVG pointer events
|
|
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
|
|
|
|
// Global move/up
|
|
this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e);
|
|
this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e);
|
|
this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
|
|
document.addEventListener('pointermove', this._boundPointerMove);
|
|
document.addEventListener('pointerup', this._boundPointerUp);
|
|
document.addEventListener('keydown', this._boundKeyDown);
|
|
|
|
// Config panel
|
|
this.attachConfigListeners();
|
|
}
|
|
|
|
private attachConfigListeners() {
|
|
this.shadow.getElementById('config-close')?.addEventListener('click', () => {
|
|
this.configOpen = false;
|
|
this.selectedNodeId = null;
|
|
const panel = this.shadow.getElementById('config-panel');
|
|
if (panel) panel.className = 'ac-config';
|
|
this.drawCanvasContent();
|
|
});
|
|
|
|
this.shadow.getElementById('config-label')?.addEventListener('input', (e) => {
|
|
const node = this.nodes.find(n => n.id === this.selectedNodeId);
|
|
if (node) {
|
|
node.label = (e.target as HTMLInputElement).value;
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
}
|
|
});
|
|
|
|
this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => {
|
|
if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
|
|
});
|
|
|
|
// Config field inputs
|
|
const configPanel = this.shadow.getElementById('config-panel');
|
|
if (configPanel) {
|
|
configPanel.querySelectorAll('[data-config-key]').forEach(el => {
|
|
el.addEventListener('input', (e) => {
|
|
const key = (el as HTMLElement).dataset.configKey!;
|
|
const node = this.nodes.find(n => n.id === this.selectedNodeId);
|
|
if (node) {
|
|
node.config[key] = (e.target as HTMLInputElement).value;
|
|
this.scheduleSave();
|
|
}
|
|
});
|
|
el.addEventListener('change', (e) => {
|
|
const key = (el as HTMLElement).dataset.configKey!;
|
|
const node = this.nodes.find(n => n.id === this.selectedNodeId);
|
|
if (node) {
|
|
node.config[key] = (e.target as HTMLSelectElement).value;
|
|
this.scheduleSave();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
private handlePointerDown(e: PointerEvent) {
|
|
const svg = this.shadow.getElementById('ac-svg') as unknown as SVGSVGElement;
|
|
const target = e.target as Element;
|
|
|
|
// Port click — start/complete wiring
|
|
const portGroup = target.closest('.ac-port-group') as SVGElement | null;
|
|
if (portGroup) {
|
|
e.stopPropagation();
|
|
const nodeId = portGroup.dataset.nodeId!;
|
|
const portName = portGroup.dataset.portName!;
|
|
const dir = portGroup.dataset.portDir as 'input' | 'output';
|
|
|
|
if (this.wiringActive) {
|
|
this.completeWiring(nodeId, portName, dir);
|
|
} else {
|
|
this.enterWiring(nodeId, portName, dir);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Edge click — delete
|
|
const edgeGroup = target.closest('.ac-edge-group') as SVGElement | null;
|
|
if (edgeGroup) {
|
|
e.stopPropagation();
|
|
const edgeId = edgeGroup.dataset.edgeId!;
|
|
this.edges = this.edges.filter(ed => ed.id !== edgeId);
|
|
this.redrawEdges();
|
|
this.scheduleSave();
|
|
return;
|
|
}
|
|
|
|
// Node click — select + start drag
|
|
const nodeGroup = target.closest('.ac-node') as SVGElement | null;
|
|
if (nodeGroup) {
|
|
e.stopPropagation();
|
|
if (this.wiringActive) {
|
|
this.cancelWiring();
|
|
return;
|
|
}
|
|
const nodeId = nodeGroup.dataset.nodeId!;
|
|
this.selectNode(nodeId);
|
|
|
|
const node = this.nodes.find(n => n.id === nodeId);
|
|
if (node) {
|
|
this.draggingNodeId = nodeId;
|
|
this.dragStartX = e.clientX;
|
|
this.dragStartY = e.clientY;
|
|
this.dragNodeStartX = node.position.x;
|
|
this.dragNodeStartY = node.position.y;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Canvas click — pan or deselect
|
|
if (this.wiringActive) {
|
|
this.cancelWiring();
|
|
return;
|
|
}
|
|
|
|
this.isPanning = true;
|
|
this.panStartX = e.clientX;
|
|
this.panStartY = e.clientY;
|
|
this.panStartPanX = this.canvasPanX;
|
|
this.panStartPanY = this.canvasPanY;
|
|
const canvas = this.shadow.getElementById('ac-canvas');
|
|
if (canvas) canvas.classList.add('grabbing');
|
|
|
|
// Deselect
|
|
if (this.selectedNodeId) {
|
|
this.selectedNodeId = null;
|
|
this.configOpen = false;
|
|
this.drawCanvasContent();
|
|
this.refreshConfigPanel();
|
|
}
|
|
}
|
|
|
|
private handlePointerMove(e: PointerEvent) {
|
|
if (this.wiringActive) {
|
|
this.wiringPointerX = e.clientX;
|
|
this.wiringPointerY = e.clientY;
|
|
this.updateWiringTempLine();
|
|
return;
|
|
}
|
|
|
|
if (this.draggingNodeId) {
|
|
const node = this.nodes.find(n => n.id === this.draggingNodeId);
|
|
if (node) {
|
|
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
|
|
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
|
|
node.position.x = this.dragNodeStartX + dx;
|
|
node.position.y = this.dragNodeStartY + dy;
|
|
this.updateNodePosition(node);
|
|
this.redrawEdges();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.isPanning) {
|
|
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
|
|
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
|
|
this.updateCanvasTransform();
|
|
}
|
|
}
|
|
|
|
private handlePointerUp(_e: PointerEvent) {
|
|
if (this.draggingNodeId) {
|
|
this.draggingNodeId = null;
|
|
this.scheduleSave();
|
|
}
|
|
if (this.isPanning) {
|
|
this.isPanning = false;
|
|
const canvas = this.shadow.getElementById('ac-canvas');
|
|
if (canvas) canvas.classList.remove('grabbing');
|
|
}
|
|
}
|
|
|
|
private handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (this.wiringActive) this.cancelWiring();
|
|
}
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) {
|
|
// Don't delete if focused on an input
|
|
if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return;
|
|
this.deleteNode(this.selectedNodeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-automation-canvas', FolkAutomationCanvas);
|