/** * — 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 = { trigger: '#3b82f6', condition: '#f59e0b', action: '#10b981', }; const PORT_COLORS: Record = { 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 | 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 = `
Automations
${this.saveIndicator}
${paletteGroups.map(cat => `
${cat}s
${NODE_CATALOG.filter(n => n.category === cat).map(n => `
${n.icon} ${esc(n.label)}
`).join('')}
`).join('')}
${this.renderAllEdges()} ${this.renderAllNodes()}
${Math.round(this.canvasZoom * 100)}%
${this.renderConfigPanel()}
`; 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 += ` `; } 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 += ` `; } // Input port labels let portLabelHtml = ''; for (const inp of def.inputs) { const y = getPortY(node, inp.name, 'input'); portLabelHtml += `${inp.name}`; } for (const out of def.outputs) { const y = getPortY(node, out.name, 'output'); portLabelHtml += `${out.name}`; } return `
${def.icon} ${esc(node.label)}
${def.inputs.map(p => `${p.name}`).join('')}
${def.outputs.map(p => `${p.name}`).join('')}
${portsHtml} ${portLabelHtml}
`; } 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 ` `; }).join(''); } private renderConfigPanel(): string { if (!this.selectedNodeId) { return `
No node selected

Click a node to configure it.

`; } 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 => `` ).join(''); return `
`; } if (field.type === 'textarea') { return `
`; } return `
`; }).join(''); const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => `
${esc(e.message)} (${e.durationMs}ms)
`).join(''); return `
${def.icon} ${esc(node.label)}
${fieldsHtml} ${logHtml ? `
Execution Log
${logHtml}
` : ''}
`; } // ── 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 = ``; } // ── 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);