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();
});
}