| 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'; |
| |
| import phosphorCSS from '@phosphor-icons/web/regular?inline'; |
|
|
| |
| const phosphorSheet = new CSSStyleSheet(); |
| phosphorSheet.replaceSync(phosphorCSS.replace(/@font-face[^}]*\}/g, '')); |
|
|
| type AreaExtra = LitArea2D<ClassicScheme>; |
|
|
| export interface WorkflowEditor { |
| editor: NodeEditor<ClassicScheme>; |
| area: AreaPlugin<ClassicScheme, AreaExtra>; |
| engine: DataflowEngine<DataflowEngineScheme>; |
| destroy: () => void; |
| } |
|
|
| const categoryColors: Record<string, string> = { |
| 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<ClassicScheme>, |
| area: AreaPlugin<ClassicScheme, AreaExtra> |
| ) { |
| 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<HTMLElement>('[data-wf="bar"]'); |
| const dot = el.querySelector<HTMLElement>('[data-wf="dot"]'); |
| const label = el.querySelector<HTMLElement>('[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<WorkflowEditor> { |
| const editor = new NodeEditor<ClassicScheme>(); |
| const area = new AreaPlugin<ClassicScheme, AreaExtra>(container); |
| const connection = new ConnectionPlugin<ClassicScheme, AreaExtra>(); |
| const litPlugin = new LitPlugin<ClassicScheme, AreaExtra>(); |
| const engine = new DataflowEngine<DataflowEngineScheme>(); |
|
|
| 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` |
| <div |
| style=" |
| position: relative; display: flex; flex-direction: column; |
| align-items: center; width: 280px; |
| " |
| > |
| ${inputs.length > 0 |
| ? html` |
| <div |
| style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;" |
| > |
| ${inputs.map(([key, input]) => |
| input |
| ? html` |
| <div |
| style="display: flex; align-items: center; justify-content: center;" |
| > |
| <rete-ref |
| .data=${{ |
| type: 'socket', |
| side: 'input', |
| key, |
| nodeId: node.id, |
| payload: input.socket, |
| }} |
| .emit=${emit} |
| ></rete-ref> |
| </div> |
| ` |
| : null |
| )} |
| </div> |
| ` |
| : null} |
| <div |
| style=" |
| background: #1f2937; border: 1px solid #374151; |
| border-radius: 12px; width: 100%; overflow: hidden; |
| " |
| > |
| <div |
| style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;" |
| > |
| <div |
| data-wf="bar" |
| style=" |
| height: 100%; width: 100%; |
| background: #6b7280; opacity: 0.25; |
| " |
| ></div> |
| </div> |
| <div |
| style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;" |
| > |
| <span |
| data-wf="dot" |
| style=" |
| width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0; |
| " |
| ></span> |
| <span |
| data-wf="label" |
| style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;" |
| >Not connected</span |
| > |
| <span |
| data-wf-delete="${node.id}" |
| style=" |
| cursor: pointer; display: flex; align-items: center; justify-content: center; |
| width: 18px; height: 18px; border-radius: 4px; |
| color: #6b7280; transition: all 0.15s; |
| " |
| > |
| <svg |
| width="12" |
| height="12" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| stroke-width="2" |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| > |
| <line x1="18" y1="6" x2="6" y2="18"></line> |
| <line x1="6" y1="6" x2="18" y2="18"></line> |
| </svg> |
| </span> |
| </div> |
| <div |
| style="height: 1px; background: #374151; margin: 0 14px;" |
| ></div> |
| <div |
| style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;" |
| > |
| <i |
| class="ph ${node.icon}" |
| style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;" |
| ></i> |
| <div style="flex: 1; min-width: 0;"> |
| <div |
| style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;" |
| > |
| ${node.label} |
| </div> |
| <div |
| style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;" |
| > |
| ${node.description} |
| </div> |
| </div> |
| </div> |
| </div> |
| ${outputs.length > 0 |
| ? html` |
| <div |
| style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;" |
| > |
| ${outputs.map(([key, output]) => |
| output |
| ? html` |
| <div |
| style="display: flex; align-items: center; justify-content: center;" |
| > |
| <rete-ref |
| .data=${{ |
| type: 'socket', |
| side: 'output', |
| key, |
| nodeId: node.id, |
| payload: output.socket, |
| }} |
| .emit=${emit} |
| ></rete-ref> |
| </div> |
| ` |
| : null |
| )} |
| </div> |
| ` |
| : null} |
| </div> |
| `; |
| }; |
| }, |
| socket() { |
| return () => { |
| return html`<div |
| style=" |
| width: 14px; height: 14px; border-radius: 50%; |
| background: #6366f1; border: 2px solid #1f2937; |
| box-shadow: 0 0 0 1px #6366f1; cursor: crosshair; |
| " |
| ></div>`; |
| }; |
| }, |
| }, |
| }) |
| ); |
|
|
| connection.addPreset(ConnectionPresets.classic.setup()); |
|
|
| |
| 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<DataflowEngineScheme>).use(engine); |
|
|
| AreaExtensions.selectableNodes(area, AreaExtensions.selector(), { |
| accumulating: AreaExtensions.accumulateOnCtrl(), |
| }); |
|
|
| AreaExtensions.simpleNodesOrder(area); |
|
|
| |
| let phosphorTimer: ReturnType<typeof setTimeout> | 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<HTMLElement>( |
| '[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<HTMLElement>( |
| '[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<HTMLElement>( |
| '[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(); |
| }, |
| }; |
| } |
|
|