diff --git a/src/js/logic/pdf-workflow-page.ts b/src/js/logic/pdf-workflow-page.ts index 97ffb74..a6acaf4 100644 --- a/src/js/logic/pdf-workflow-page.ts +++ b/src/js/logic/pdf-workflow-page.ts @@ -2,7 +2,11 @@ import { showAlert } from '../ui.js'; import { tesseractLanguages } from '../config/tesseract-languages.js'; import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor'; import { executeWorkflow } from '../workflow/engine'; -import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry'; +import { + nodeRegistry, + getNodesByCategory, + createNodeByType, +} from '../workflow/nodes/registry'; import type { BaseWorkflowNode } from '../workflow/nodes/base-node'; import type { WorkflowEditor } from '../workflow/editor'; import { @@ -43,6 +47,7 @@ import { let workflowEditor: WorkflowEditor | null = null; let selectedNodeId: string | null = null; +let deleteNodeHandler: EventListener | null = null; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); @@ -245,10 +250,14 @@ async function initializePage() { } }); - document.addEventListener('wf-delete-node', ((e: CustomEvent) => { + if (deleteNodeHandler) { + document.removeEventListener('wf-delete-node', deleteNodeHandler); + } + deleteNodeHandler = ((e: CustomEvent) => { const nodeId = e.detail?.nodeId; if (nodeId) deleteNodeById(nodeId); - }) as EventListener); + }) as EventListener; + document.addEventListener('wf-delete-node', deleteNodeHandler); } async function deleteNodeById(nodeId: string) { @@ -539,14 +548,12 @@ async function addNodeToCanvas( if (!workflowEditor) return; const { editor, area } = workflowEditor; - const entry = nodeRegistry[type]; - if (!entry) { - console.error('Node type not found in registry:', type); - return; - } - try { - const node = entry.factory(); + const node = createNodeByType(type); + if (!node) { + console.error('Node type not found in registry:', type); + return; + } await editor.addNode(node); const pos = position || getCanvasCenter(area); diff --git a/src/js/workflow/editor.ts b/src/js/workflow/editor.ts index 676c3d0..0725660 100644 --- a/src/js/workflow/editor.ts +++ b/src/js/workflow/editor.ts @@ -8,7 +8,7 @@ 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 { LitElement, 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'; @@ -49,6 +49,175 @@ function getStatusInfo(status: string, connected: boolean) { }; } +class WorkflowNodeElement extends LitElement { + static properties = { + data: { attribute: false }, + emit: { attribute: false }, + }; + + declare data: BaseWorkflowNode | undefined; + declare emit: ((data: unknown) => void) | undefined; + + createRenderRoot(): HTMLElement | ShadowRoot { + return this; + } + + render() { + if (!this.data) return html``; + const node = this.data; + const inputs = Object.entries(node.inputs || {}); + const outputs = Object.entries(node.outputs || {}); + const color = categoryColors[node.category] || '#6b7280'; + const emitFn = this.emit; + + 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} +
+ `; + } +} + +if (!customElements.get('wf-node')) { + customElements.define('wf-node', WorkflowNodeElement); +} + export function updateNodeDisplay( nodeId: string, editor: NodeEditor, @@ -111,154 +280,10 @@ export async function createWorkflowEditor( 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} -
- `; + return html``; }; }, socket() { diff --git a/src/js/workflow/nodes/base-node.ts b/src/js/workflow/nodes/base-node.ts index fad82f3..563d750 100644 --- a/src/js/workflow/nodes/base-node.ts +++ b/src/js/workflow/nodes/base-node.ts @@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node { width = 280; height = 140; execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle'; + nodeType: string = ''; constructor(label: string) { super(label); diff --git a/src/js/workflow/nodes/registry.ts b/src/js/workflow/nodes/registry.ts index ecbd8fe..5f944ec 100644 --- a/src/js/workflow/nodes/registry.ts +++ b/src/js/workflow/nodes/registry.ts @@ -593,7 +593,9 @@ export const nodeRegistry: Record = { export function createNodeByType(type: string): BaseWorkflowNode | null { const entry = nodeRegistry[type]; if (!entry) return null; - return entry.factory(); + const node = entry.factory(); + node.nodeType = type; + return node; } export function getNodesByCategory(): Record< diff --git a/src/js/workflow/serialization.ts b/src/js/workflow/serialization.ts index ae4ba34..c0aaba9 100644 --- a/src/js/workflow/serialization.ts +++ b/src/js/workflow/serialization.ts @@ -25,8 +25,7 @@ interface SerializedConnection { } function getNodeType(node: BaseWorkflowNode): string | null { - const constructorName = node.constructor.name; - return constructorName || null; + return node.nodeType || null; } function serializeWorkflow( @@ -79,6 +78,22 @@ async function deserializeWorkflow( editor: NodeEditor, area: AreaPlugin ): Promise { + if ( + !data || + !Array.isArray((data as any).nodes) || + !Array.isArray((data as any).connections) + ) { + throw new Error( + 'Invalid workflow file: missing nodes or connections array.' + ); + } + + if ((data as any).version !== WORKFLOW_VERSION) { + console.warn( + `Workflow version mismatch: expected ${WORKFLOW_VERSION}, got ${(data as any).version}. Attempting load anyway.` + ); + } + for (const conn of editor.getConnections()) { await editor.removeConnection(conn.id); } @@ -129,9 +144,6 @@ async function deserializeWorkflow( if (skippedTypes.length > 0) { console.warn('Skipped unknown node types during load:', skippedTypes); - throw new Error( - `Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.` - ); } } @@ -215,14 +227,11 @@ export function exportWorkflow( const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); - try { - const a = document.createElement('a'); - a.href = url; - a.download = 'workflow.json'; - a.click(); - } finally { - URL.revokeObjectURL(url); - } + const a = document.createElement('a'); + a.href = url; + a.download = 'workflow.json'; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); } export async function importWorkflow( @@ -233,13 +242,16 @@ export async function importWorkflow( const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; + + let settled = false; + input.onchange = async () => { + settled = true; const file = input.files?.[0]; if (!file) { resolve(); return; } - try { const text = await file.text(); const data = JSON.parse(text) as SerializedWorkflow; @@ -250,6 +262,15 @@ export async function importWorkflow( reject(new Error(`Failed to import workflow: ${message}`)); } }; + + const onFocus = () => { + window.removeEventListener('focus', onFocus); + setTimeout(() => { + if (!settled) resolve(); + }, 300); + }; + window.addEventListener('focus', onFocus); + input.click(); }); }