rspace-online/modules/rschedule/components/folk-automation-canvas.ts

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">&#9654; 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">&#8644;</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">&times;</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">&times;</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);