feat: node creation and serialization logic in workflow editor

This commit is contained in:
alam00000
2026-02-26 20:28:16 +05:30
parent 88260c26ab
commit 85d90c3382
5 changed files with 230 additions and 174 deletions

View File

@@ -2,7 +2,11 @@ import { showAlert } from '../ui.js';
import { tesseractLanguages } from '../config/tesseract-languages.js'; import { tesseractLanguages } from '../config/tesseract-languages.js';
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor'; import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
import { executeWorkflow } from '../workflow/engine'; 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 { BaseWorkflowNode } from '../workflow/nodes/base-node';
import type { WorkflowEditor } from '../workflow/editor'; import type { WorkflowEditor } from '../workflow/editor';
import { import {
@@ -43,6 +47,7 @@ import {
let workflowEditor: WorkflowEditor | null = null; let workflowEditor: WorkflowEditor | null = null;
let selectedNodeId: string | null = null; let selectedNodeId: string | null = null;
let deleteNodeHandler: EventListener | null = null;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage); 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; const nodeId = e.detail?.nodeId;
if (nodeId) deleteNodeById(nodeId); if (nodeId) deleteNodeById(nodeId);
}) as EventListener); }) as EventListener;
document.addEventListener('wf-delete-node', deleteNodeHandler);
} }
async function deleteNodeById(nodeId: string) { async function deleteNodeById(nodeId: string) {
@@ -539,14 +548,12 @@ async function addNodeToCanvas(
if (!workflowEditor) return; if (!workflowEditor) return;
const { editor, area } = workflowEditor; const { editor, area } = workflowEditor;
const entry = nodeRegistry[type];
if (!entry) {
console.error('Node type not found in registry:', type);
return;
}
try { 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); await editor.addNode(node);
const pos = position || getCanvasCenter(area); const pos = position || getCanvasCenter(area);

View File

@@ -8,7 +8,7 @@ import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin'; import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
import { DataflowEngine } from 'rete-engine'; import { DataflowEngine } from 'rete-engine';
import type { DataflowEngineScheme } 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'; import type { BaseWorkflowNode } from './nodes/base-node';
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM // @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
import phosphorCSS from '@phosphor-icons/web/regular?inline'; 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`
<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=${emitFn}
></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=${emitFn}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
</div>
`;
}
}
if (!customElements.get('wf-node')) {
customElements.define('wf-node', WorkflowNodeElement);
}
export function updateNodeDisplay( export function updateNodeDisplay(
nodeId: string, nodeId: string,
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
@@ -111,154 +280,10 @@ export async function createWorkflowEditor(
customize: { customize: {
node(data) { node(data) {
return ({ emit }: { emit: (data: unknown) => void }) => { return ({ emit }: { emit: (data: unknown) => void }) => {
const node = data.payload as BaseWorkflowNode; return html`<wf-node
const inputs = Object.entries(node.inputs || {}); .data=${data.payload}
const outputs = Object.entries(node.outputs || {}); .emit=${emit}
const color = categoryColors[node.category] || '#6b7280'; ></wf-node>`;
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() { socket() {

View File

@@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
width = 280; width = 280;
height = 140; height = 140;
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle'; execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
nodeType: string = '';
constructor(label: string) { constructor(label: string) {
super(label); super(label);

View File

@@ -593,7 +593,9 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
export function createNodeByType(type: string): BaseWorkflowNode | null { export function createNodeByType(type: string): BaseWorkflowNode | null {
const entry = nodeRegistry[type]; const entry = nodeRegistry[type];
if (!entry) return null; if (!entry) return null;
return entry.factory(); const node = entry.factory();
node.nodeType = type;
return node;
} }
export function getNodesByCategory(): Record< export function getNodesByCategory(): Record<

View File

@@ -25,8 +25,7 @@ interface SerializedConnection {
} }
function getNodeType(node: BaseWorkflowNode): string | null { function getNodeType(node: BaseWorkflowNode): string | null {
const constructorName = node.constructor.name; return node.nodeType || null;
return constructorName || null;
} }
function serializeWorkflow( function serializeWorkflow(
@@ -79,6 +78,22 @@ async function deserializeWorkflow(
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
area: AreaPlugin<ClassicScheme, AreaExtra> area: AreaPlugin<ClassicScheme, AreaExtra>
): Promise<void> { ): Promise<void> {
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()) { for (const conn of editor.getConnections()) {
await editor.removeConnection(conn.id); await editor.removeConnection(conn.id);
} }
@@ -129,9 +144,6 @@ async function deserializeWorkflow(
if (skippedTypes.length > 0) { if (skippedTypes.length > 0) {
console.warn('Skipped unknown node types during load:', skippedTypes); 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 json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try { const a = document.createElement('a');
const a = document.createElement('a'); a.href = url;
a.href = url; a.download = 'workflow.json';
a.download = 'workflow.json'; a.click();
a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
} finally {
URL.revokeObjectURL(url);
}
} }
export async function importWorkflow( export async function importWorkflow(
@@ -233,13 +242,16 @@ export async function importWorkflow(
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
let settled = false;
input.onchange = async () => { input.onchange = async () => {
settled = true;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (!file) {
resolve(); resolve();
return; return;
} }
try { try {
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text) as SerializedWorkflow; const data = JSON.parse(text) as SerializedWorkflow;
@@ -250,6 +262,15 @@ export async function importWorkflow(
reject(new Error(`Failed to import workflow: ${message}`)); 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(); input.click();
}); });
} }