import { NodeEditor } from 'rete'; import { AreaPlugin, AreaExtensions } from 'rete-area-plugin'; import { ConnectionPlugin, Presets as ConnectionPresets, } from 'rete-connection-plugin'; import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin'; import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin'; import { DataflowEngine } from 'rete-engine'; import type { DataflowEngineScheme } from 'rete-engine'; import { html } from 'lit'; import type { BaseWorkflowNode } from './nodes/base-node'; // @ts-ignore -- Vite ?inline import for injecting into Shadow DOM import phosphorCSS from '@phosphor-icons/web/regular?inline'; // Shared stylesheet for Phosphor icons (font-face already loaded globally, strip it) const phosphorSheet = new CSSStyleSheet(); phosphorSheet.replaceSync(phosphorCSS.replace(/@font-face[^}]*\}/g, '')); type AreaExtra = LitArea2D; export interface WorkflowEditor { editor: NodeEditor; area: AreaPlugin; engine: DataflowEngine; destroy: () => void; } const categoryColors: Record = { Input: '#60a5fa', 'Edit & Annotate': '#a5b4fc', 'Organize & Manage': '#c4b5fd', 'Optimize & Repair': '#fcd34d', 'Secure PDF': '#fda4af', Output: '#5eead4', }; function getStatusInfo(status: string, connected: boolean) { if (status === 'running') return { color: '#eab308', label: 'Running...', animate: true }; if (status === 'completed') return { color: '#22c55e', label: 'Complete', animate: false }; if (status === 'error') return { color: '#ef4444', label: 'Failed', animate: false }; return { color: connected ? '#22c55e' : '#6b7280', label: connected ? 'Connected' : 'Not connected', animate: false, }; } export function updateNodeDisplay( nodeId: string, editor: NodeEditor, area: AreaPlugin ) { const view = area.nodeViews.get(nodeId); if (!view) return; const el = view.element; const node = editor.getNode(nodeId) as BaseWorkflowNode; if (!node) return; const conns = editor.getConnections(); const connected = conns.some( (c) => c.target === nodeId || c.source === nodeId ); const status = node.execStatus || 'idle'; const st = getStatusInfo(status, connected); const bar = el.querySelector('[data-wf="bar"]'); const dot = el.querySelector('[data-wf="dot"]'); const label = el.querySelector('[data-wf="label"]'); if (bar) { bar.className = st.animate ? 'wf-bar-slide' : ''; bar.style.background = st.animate ? `linear-gradient(90deg, #1f2937 0%, ${st.color} 50%, #1f2937 100%)` : st.color; bar.style.opacity = status === 'idle' && !connected ? '0.25' : status === 'idle' ? '0.5' : '1'; bar.style.backgroundSize = st.animate ? '200% 100%' : ''; } if (dot) { dot.className = st.animate ? 'wf-dot-pulse' : ''; dot.style.background = st.color; dot.style.boxShadow = 'none'; } if (label) { label.style.color = st.color; label.textContent = st.label; } } export async function createWorkflowEditor( container: HTMLElement ): Promise { const editor = new NodeEditor(); const area = new AreaPlugin(container); const connection = new ConnectionPlugin(); const litPlugin = new LitPlugin(); const engine = new DataflowEngine(); litPlugin.addPreset( LitPresets.classic.setup({ customize: { node(data) { return ({ emit }: { emit: (data: unknown) => void }) => { const node = data.payload as BaseWorkflowNode; const inputs = Object.entries(node.inputs || {}); const outputs = Object.entries(node.outputs || {}); const color = categoryColors[node.category] || '#6b7280'; return html`
${inputs.length > 0 ? html`
${inputs.map(([key, input]) => input ? html`
` : null )}
` : null}
Not connected
${node.label}
${node.description}
${outputs.length > 0 ? html`
${outputs.map(([key, output]) => output ? html`
` : null )}
` : null}
`; }; }, socket() { return () => { return html`
`; }; }, }, }) ); connection.addPreset(ConnectionPresets.classic.setup()); // Override connection path to use vertical bezier curves (top-to-bottom flow) litPlugin.addPipe((context) => { if ((context as any).type === 'connectionpath') { const { points } = (context as any).data; const [start, end] = points as [ { x: number; y: number }, { x: number; y: number }, ]; const curvature = 0.3; const horizontal = Math.abs(start.x - end.x); const dy = Math.max(horizontal / 2, Math.abs(end.y - start.y)) * curvature; const path = `M ${start.x} ${start.y} C ${start.x} ${start.y + dy} ${end.x} ${end.y - dy} ${end.x} ${end.y}`; return { ...context, data: { ...(context as any).data, path }, } as typeof context; } return context; }); editor.use(area); area.use(connection); area.use(litPlugin); (editor as NodeEditor).use(engine); AreaExtensions.selectableNodes(area, AreaExtensions.selector(), { accumulating: AreaExtensions.accumulateOnCtrl(), }); AreaExtensions.simpleNodesOrder(area); // Inject Phosphor icon styles into Shadow DOM roots created by the Lit plugin let phosphorTimer: ReturnType | null = null; const injectPhosphor = () => { if (phosphorTimer) return; phosphorTimer = setTimeout(() => { phosphorTimer = null; for (const el of container.querySelectorAll('*')) { const sr = (el as HTMLElement).shadowRoot; if (sr && !sr.adoptedStyleSheets.includes(phosphorSheet)) { sr.adoptedStyleSheets = [...sr.adoptedStyleSheets, phosphorSheet]; } } }, 50); }; const observer = new MutationObserver(injectPhosphor); observer.observe(container, { childList: true, subtree: true }); const onPointerDown = (e: Event) => { const target = (e.target as HTMLElement).closest( '[data-wf-delete]' ); if (!target) return; e.stopPropagation(); e.preventDefault(); const nodeId = target.getAttribute('data-wf-delete'); if (nodeId) { document.dispatchEvent( new CustomEvent('wf-delete-node', { detail: { nodeId } }) ); } }; const onMouseEnter = (e: Event) => { const target = (e.target as HTMLElement).closest( '[data-wf-delete]' ); if (!target) return; target.style.color = '#f87171'; target.style.background = 'rgba(248,113,113,0.1)'; }; const onMouseLeave = (e: Event) => { const target = (e.target as HTMLElement).closest( '[data-wf-delete]' ); if (!target) return; target.style.color = '#6b7280'; target.style.background = 'transparent'; }; container.addEventListener('pointerdown', onPointerDown, true); container.addEventListener('mouseenter', onMouseEnter, true); container.addEventListener('mouseleave', onMouseLeave, true); return { editor, area, engine, destroy: () => { observer.disconnect(); if (phosphorTimer) { clearTimeout(phosphorTimer); phosphorTimer = null; } container.removeEventListener('pointerdown', onPointerDown, true); container.removeEventListener('mouseenter', onMouseEnter, true); container.removeEventListener('mouseleave', onMouseLeave, true); area.destroy(); }, }; }