@selenite/graph-editor
Version:
A graph editor for visual programming, based on rete and svelte.
754 lines (753 loc) • 28.5 kB
JavaScript
import { AreaExtensions, AreaPlugin } from 'rete-area-plugin';
import { ControlFlowEngine, DataflowEngine } from 'rete-engine';
import { ExecSocket } from '../socket/ExecSocket';
import { structures } from 'rete-structures';
import { Connection, Node, nodeRegistry } from '../nodes/Node.svelte';
import { readable } from 'svelte/store';
import { PythonDataflowEngine } from '../engine/PythonDataflowEngine';
import { newLocalId } from '../../utils';
import { ErrorWNotif, _ } from '../../global/todo.svelte';
import { persisted } from 'svelte-persisted-store';
import { defaultConnectionPath } from '../connection-path';
import { tick } from 'svelte';
import { downloadJSON, Rect, XmlSchema } from '@selenite/commons';
import { Modal, modals } from '@selenite/commons';
import { NodeLayout } from './NodeLayout';
import { NodeSelection as NodeSelector } from './NodeSelection.svelte';
import { NodeStorage } from '../storage';
import { CodeIntegration } from './CodeIntegration.svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { NodeSearch } from './NodeSearch.svelte';
import { ElementRect } from 'runed';
function createDataflowEngine() {
return new DataflowEngine(({ inputs, outputs }) => {
return {
inputs: () => Object.entries(inputs)
.filter(([_, input]) => input && !(input.socket instanceof ExecSocket))
.map(([name]) => name),
outputs: () => Object.entries(outputs)
.filter(([_, output]) => output && !(output.socket instanceof ExecSocket))
.map(([name]) => name)
};
});
}
function createPythonDataflowEngine() {
return new PythonDataflowEngine(({ inputs, outputs }) => {
return {
inputs: () => Object.entries(inputs)
.filter(([_, input]) => input && !(input.socket instanceof ExecSocket))
.map(([name]) => name),
outputs: () => Object.entries(outputs)
.filter(([_, output]) => output && !(output.socket instanceof ExecSocket))
.map(([name]) => name)
};
});
}
function createControlflowEngine() {
return new ControlFlowEngine(({ inputs, outputs }) => {
return {
inputs: () => Object.entries(inputs)
.filter(([_, input]) => input && input.socket instanceof ExecSocket)
.map(([name]) => name),
outputs: () => Object.entries(outputs)
.filter(([_, output]) => output && output.socket instanceof ExecSocket)
.map(([name]) => name)
};
});
}
// export function registerNode() {}
export class NodeFactory {
components = [];
addComponentByClass(componentClass, params) {
const component = new componentClass({ ...params, owner: this });
this.components.push(component);
return component;
}
minimapEnabled = $state(true);
notifications = {
show: (notif) => {
let res = '';
if (notif.title)
res += notif.title + ':';
console.log(res, notif.message);
},
error(notif) {
let res = '';
if (notif.title)
res += notif.title + ':';
console.error(res, notif.message);
},
info(notif) {
let res = '';
if (notif.title)
res += notif.title + ':';
console.info(res, notif.message);
},
success(notif) {
let res = '';
if (notif.title)
res += notif.title + ':';
console.log('Succes', res, notif.message);
},
warn(notif) {
let res = '';
if (notif.title)
res += notif.title + ':';
console.warn(res, notif.message);
},
hide() { }
};
connectionPathType = persisted('connectionPathType', defaultConnectionPath);
surfaceRect = $state(new Rect());
lastSelectedNode = $state();
editor;
get previewedNodes() {
return this.editor.previewedNodes;
}
modalStore = readable(Modal.instance);
state = new Map();
id = newLocalId('node-factory');
useState(id, key, value) {
const stateKey = id + '_' + key;
if (!this.state.has(stateKey))
this.state.set(stateKey, value);
const state = this.state;
return {
get: () => this.state.get(stateKey),
set: (value) => this.state.set(stateKey, value),
get value() {
return state.get(stateKey);
},
set value(v) {
state.set(stateKey, v);
}
};
}
getState(id, key, initial) {
const stateKey = id + '_' + key;
if (!this.state.has(stateKey))
this.state.set(stateKey, initial);
return this.state.get(stateKey);
}
setState(id, key, value) {
this.state.set(id + '_' + key, value);
}
lastAddedNode;
async addNode(nodeClass, params = {}) {
const paramsWithFactory = { ...params, factory: this };
await this.editor.addNode(new nodeClass(paramsWithFactory));
if (!this.lastAddedNode)
throw new Error('lastAddedNode is undefined');
return this.lastAddedNode;
}
async addNodes(nodes) {
await this.bulkOperation(async () => {
for (const node of nodes) {
await this.editor.addNode(node instanceof Node ? node : await Node.fromJSON(node, { factory: this }));
}
});
}
getNodes() {
return this.editor.getNodes();
}
get storage() {
return NodeStorage.instance;
}
pythonDataflowEngine = createPythonDataflowEngine();
async loadNode(nodeSaveData) {
const nodeClass = nodeRegistry.get(nodeSaveData.type);
if (nodeClass) {
const node = new nodeClass({
...nodeSaveData.params,
factory: this,
initialValues: nodeSaveData.inputControlValues,
state: nodeSaveData.state,
id: nodeSaveData.id
});
if (node.initializePromise) {
await node.initializePromise;
if (node.afterInitialize)
node.afterInitialize();
}
// node.setState({ ...node.getState(), ...nodeSaveData.state });
node.applyState();
// for (const key in nodeSaveData.inputControlValues) {
// const inputControl = node.inputs[key]?.control;
// if (
// inputControl instanceof ClassicPreset.InputControl ||
// inputControl instanceof InputControl
// ) {
// console.log("key", key)
// inputControl.value = nodeSaveData.inputControlValues[key];
// }
// }
for (const key of nodeSaveData.selectedInputs) {
node.selectInput(key);
}
for (const key of nodeSaveData.selectedOutputs) {
node.selectOutput(key);
}
await this.editor.addNode(node);
if (nodeSaveData.position && this.area) {
this.area.translate(nodeSaveData.id, {
x: nodeSaveData.position.x,
y: nodeSaveData.position.y
});
}
else if (this.area) {
console.error("Node doesn't have a position", nodeSaveData);
}
return node;
}
else {
console.debug('Node class not found', nodeSaveData);
throw new Error(`Node class ${nodeSaveData.type} not found`);
}
}
effetRootCleanup;
cleanup() {
this.effetRootCleanup?.();
}
destroyArea() {
this.destroy();
}
destroy() {
console.log('Destroying area.');
this.area?.destroy();
this.cleanup();
for (const c of this.components) {
c.cleanup?.();
}
}
/**
* Moves the view to the center of the nodes, with a zoom such that all nodes are visible.
* @param nodes - Nodes to center the view on. If not provided, all nodes are used.
*/
centerView(nodes) {
if (!this.area)
return;
return AreaExtensions.zoomAt(this.area, nodes ?? this.editor.getNodes());
}
async openGraphForm(defaultName = 'New Graph') {
const MacroBlockForm = (await import('../storage/MacroBlockForm.svelte')).default;
const existingGraph = await NodeStorage.getGraph(this.editor.graphId);
const nodesSelected = this.selection.nodes.length > 0;
const action = existingGraph && !nodesSelected ? 'Update' : 'Create';
const editor = this.editor;
modals.show({
component: MacroBlockForm,
props: { editor: this.editor, existingGraph },
get title() {
if (nodesSelected)
return 'Create macro from selection';
else
return `${action} ${editor.graphName.trim().length === 0 || (!existingGraph && editor.graphName === defaultName) ? 'macro-block' : editor.graphName}`;
},
buttons: [
'cancel',
{
label: action,
type: 'submit'
}
],
response: async (r) => {
if (!r || !(typeof r === 'object') || !('id' in r))
throw new ErrorWNotif('Graph form response not found');
const g = r;
console.log('Saving graph to storage', g);
try {
await NodeStorage.saveGraph(g);
this.notifications.success({
title: 'Graph Storage',
message: 'Graph saved!'
});
}
catch (e) {
console.error(e);
this.notifications.error({
title: 'Graph Storage',
message: 'Failed to save graph.'
});
}
}
});
}
/**
* Loads a graph from a save.
* @param editorSaveData - Save data to load.
*/
async loadGraph(editorSaveData) {
await this.bulkOperation(async () => {
console.log(`Load graph : ${editorSaveData.editorName} (id: ${editorSaveData.id})`);
await this.editor.clear();
this.editor.graphId = editorSaveData.id;
// Load variables
for (const v of Array.isArray(editorSaveData.variables)
? editorSaveData.variables
: Object.values(editorSaveData.variables)) {
this.editor.variables[v.id] = v;
}
this.editor.setName(editorSaveData.editorName);
for (const nodeSaveData of editorSaveData.nodes) {
try {
const node = await this.loadNode(nodeSaveData);
if (editorSaveData.previewedNodes?.includes(node.id)) {
this.editor.previewedNodes.add(node);
}
}
catch (e) {
console.error('Failed to load node', e);
const name = nodeSaveData.state?.name ?? nodeSaveData.type;
this.notifications.error({
message: `Failed to load node ${name}.`,
title: 'Graph Loading'
});
}
}
for (const commentSaveData of editorSaveData.comments ?? []) {
if (!this.comment) {
console.warn('No comment plugin');
return;
}
console.log('load comment ', commentSaveData.text);
try {
this.comment.addFrame(commentSaveData.text, commentSaveData.links, {
id: commentSaveData.id
});
}
catch (e) {
console.error('Failed to load comment', e);
}
}
editorSaveData.connections.forEach(async (connectionSaveData) => {
try {
const source = this.editor.getNode(connectionSaveData.source);
if (!source) {
console.error('Source node not found for connection', connectionSaveData);
throw new ErrorWNotif('Source node not found for connection');
}
const target = this.editor.getNode(connectionSaveData.target);
if (!target) {
console.error('Target node not found for connection', connectionSaveData);
throw new ErrorWNotif('Target node not found for connection.');
}
const conn = new Connection(source, connectionSaveData.sourceOutput, target, connectionSaveData.targetInput);
conn.id = connectionSaveData.id;
conn.factory = this;
await this.editor.addConnection(conn);
}
catch (e) {
console.error('Failed to load connection', e);
}
});
setTimeout(() => {
if (this.area)
AreaExtensions.zoomAt(this.area, this.editor.getNodes());
});
});
}
#area = $state();
get area() {
return this.#area;
}
transform = $state({ zoom: 1 });
set area(area) {
if (area === this.#area)
return;
this.#area = area;
this.#area?.addPipe((ctx) => {
if (ctx.type === 'zoomed') {
this.transform.zoom = ctx.data.zoom;
}
if (ctx.type === 'translated' || ctx.type === 'zoomed') {
this.surfaceRect = this.#area?.area.content.holder.getBoundingClientRect() ?? new Rect();
}
return ctx;
});
if (area?.area.transform.k !== undefined)
this.transform.zoom = area.area.transform.k;
this.surfaceRect = this.#area?.area.content.holder.getBoundingClientRect() ?? new Rect();
}
makutuClasses;
dataflowEngine = createDataflowEngine();
controlflowEngine = createControlflowEngine();
// public selector?: AreaExtensions.Selector<SelectorEntity>;
selector;
get selection() {
return this.selector;
}
search = this.addComponentByClass(NodeSearch, {});
// public accumulating?: ReturnType<typeof AreaExtensions.accumulateOnCtrl>;
// public selectableNodes?: ReturnType<typeof AreaExtensions.selectableNodes>;
arrange;
history;
connectionPlugin;
comment;
#isDataflowEnabled = true;
#xmlSchemas = new SvelteMap();
get xmlSchemas() {
return this.#xmlSchemas;
}
reactivateDataflowTimeout = null;
/**
* Nodes in the editor.
*/
get nodes() {
return this.editor.getNodes();
}
/**
* Connections in the editor.
*/
get connections() {
return this.editor.getConnections();
}
/**
* Executes callback without running dataflow engines.
*
* It is useful to execute multiple operations without unnecessarily running dataflow engines.
* @param callback Callback to execute
*/
async bulkOperation(callback) {
this.#isDataflowEnabled = false;
try {
await callback();
}
catch (e) {
console.error('Bulk operation error', e);
}
if (this.reactivateDataflowTimeout)
clearTimeout(this.reactivateDataflowTimeout);
this.reactivateDataflowTimeout = setTimeout(() => {
this.reactivateDataflowTimeout = null;
this.#isDataflowEnabled = true;
this.dataflowEngine.reset();
this.runDataflowEngines();
}, 100);
}
/**
* Removes all nodes and connections from the editor.
*/
async clear() {
await this.bulkOperation(async () => {
await this.editor.clear();
});
}
layout;
codeIntegration;
constructor(params) {
const { editor, area, makutuClasses, arrange, xmlSchemas = {} } = params;
this.comment = params.comment;
// this.accumulating = params.accumulating;
this.history = params.history;
this.layout = this.addComponentByClass(NodeLayout, {});
// this.selector = selector;
this.selector = this.addComponentByClass(NodeSelector, {});
this.codeIntegration = this.addComponentByClass(CodeIntegration, {});
this.area = area;
this.arrange = arrange;
this.makutuClasses = makutuClasses;
this.editor = editor;
this.editor.factory = this;
for (const [key, schema] of Object.entries(xmlSchemas)) {
if (schema)
this.#xmlSchemas.set(key, schema);
}
editor.use(this.dataflowEngine);
editor.use(this.controlflowEngine);
editor.use(this.pythonDataflowEngine);
// Assign connections to nodes
editor.addPipe((context) => {
if (context.type === 'nodecreated') {
this.lastAddedNode = context.data;
}
if (context.type !== 'connectioncreated' && context.type !== 'connectionremoved')
return context;
const conn = context.data;
const sourceNode = editor.getNode(conn.source);
const targetNode = editor.getNode(conn.target);
if (targetNode) {
this.pythonDataflowEngine.reset(targetNode.id);
this.resetDataflow(targetNode);
}
const socket = sourceNode?.outputs[conn.sourceOutput]?.socket;
const outgoingConnections = socket instanceof ExecSocket || socket?.type == 'exec'
? (sourceNode?.outgoingExecConnections ?? {})
: (sourceNode?.outgoingDataConnections ?? {});
const ingoingConnections = socket instanceof ExecSocket || socket?.type == 'exec'
? (targetNode?.ingoingExecConnections ?? {})
: (targetNode?.ingoingDataConnections ?? {});
if (context.type === 'connectioncreated') {
if (!sourceNode || !targetNode) {
console.error('Connection created node not found', conn);
return context;
}
if (!(conn.sourceOutput in outgoingConnections))
outgoingConnections[conn.sourceOutput] = [];
if (!(conn.targetInput in ingoingConnections))
ingoingConnections[conn.targetInput] = [];
outgoingConnections[conn.sourceOutput].push(conn);
ingoingConnections[conn.targetInput].push(conn);
}
else if (context.type === 'connectionremoved') {
if (targetNode && targetNode.onRemoveIngoingConnection)
targetNode.onRemoveIngoingConnection(conn);
if (conn.sourceOutput in outgoingConnections) {
const outgoingIndex = outgoingConnections[conn.sourceOutput]?.findIndex((c) => c.id == conn.id);
if (outgoingIndex === -1)
throw new ErrorWNotif("Couldn't find outgoing connection");
outgoingConnections[conn.sourceOutput].splice(outgoingIndex, 1);
if (outgoingConnections[conn.sourceOutput].length === 0)
delete outgoingConnections[conn.sourceOutput];
}
if (conn.targetInput in ingoingConnections) {
const ingoingIndex = ingoingConnections[conn.targetInput]?.findIndex((c) => c.id == conn.id);
if (ingoingIndex === -1)
throw new ErrorWNotif("Couldn't find ingoing connection");
ingoingConnections[conn.targetInput].splice(ingoingIndex, 1);
if (ingoingConnections[conn.targetInput].length === 0)
delete ingoingConnections[conn.targetInput];
}
}
return context;
});
}
commentSelectedNodes(params = {}) {
console.log('factory:commentSelectedNodes');
if (!this.comment) {
console.warn('No comment plugin');
return;
}
const nodes = this.selector.nodes;
if (!nodes)
return;
this.comment.addFrame(params.text, nodes.map((node) => node.id), { editPrompt: true });
}
/** Delete all selected elements */
async deleteSelectedElements() {
console.debug('Delete selected elements.');
const selector = this.selector;
const editor = this.getEditor();
// const allComments = wu(selector.entities.values()).every(({ label }) => label === 'comment');
// for (const { id, label } of selector.entities.values()) {
// switch (label) {
// case 'comment':
// const comment = this.comment?.comments.get(id);
// if (!comment) throw new ErrorWNotif('Comment not found');
// const commentText = comment.text;
// const links = comment.links;
// const commentId = comment.id;
// const redo = () => {
// this.comment?.delete(id);
// };
// if (allComments)
// this.history?.add({
// redo,
// undo: () => {
// this.comment?.addFrame(commentText, links, { id: commentId });
// }
// });
// redo();
// // this.comment?.delete(id);
// break;
// }
// }
// this.history?.separate();
console.debug('removing', selector.typedEntities);
for (const { entity: { id }, type } of selector.typedEntities) {
switch (type) {
case 'connection':
if (editor.getConnection(id))
await editor.removeConnection(id);
break;
case 'node':
const node = editor.getNode(id);
if (!node)
continue;
for (const conn of node.getConnections()) {
if (editor.getConnection(conn.id))
await editor.removeConnection(conn.id);
}
await editor.removeNode(id);
break;
case 'comment':
this.comment?.delete(id);
break;
default:
console.warn(`Delete: Unknown label ${type}`);
}
}
this.selector.unselectAll();
// this.history?.separate();
}
/**
* Removes a node from the editor, as well as its connections.
* @param target node or node id
*/
async removeNode(target) {
let node;
if (typeof target === 'string') {
const attempt = this.editor.getNode(target);
if (!attempt) {
console.warn("Can't remove, node not found");
return;
}
node = attempt;
}
else {
node = target;
}
for (const conn of node.getConnections()) {
if (this.editor.getConnection(conn.id))
await this.editor.removeConnection(conn.id);
}
await this.editor.removeNode(node.id);
}
enable() {
Node.activeFactory = this;
}
disable() {
Node.activeFactory = undefined;
}
getNode(id) {
return this.editor.getNode(id);
}
create(type) {
return new type();
}
getEditor() {
return this.editor;
}
getControlFlowEngine() {
return this.controlflowEngine;
}
getArea() {
return this.area;
}
resetSuccessors(node) {
structures(this.editor)
.successors(node.id)
.nodes()
.forEach((n) => this.dataflowEngine.reset(n.id));
}
downloadGraph() {
downloadJSON(this.editor.graphName, this.editor);
}
loadFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
if (!input.files)
return;
const file = input.files[0];
const reader = new FileReader();
reader.onload = async () => {
const data = reader.result;
try {
const json = JSON.parse(data);
await this.loadGraph(json);
}
catch (e) {
console.error('Failed to load graph', e);
}
};
reader.readAsText(file);
};
input.click();
}
lastSearchNodeIndex = -1;
/**
* Finds a node whose label or name matches the query.
* Repeated calls will cycle through the nodes.
* @param query
* @returns found node or undefined
*/
findNode(query) {
console.log('mozza', this.lastSearchNodeIndex);
query = query.toLowerCase();
let nodes = this.editor.getNodes();
const m = Math.min(this.lastSearchNodeIndex + 1, nodes.length);
console.log('m', m);
nodes = [nodes.slice(m), nodes.slice(0, m)].flat();
console.log('mozza nodes', nodes);
const resIndex = nodes.findIndex((n) => {
return (n.label.toLowerCase().includes(query) || (n.name && n.name.toLowerCase().includes(query)));
});
this.lastSearchNodeIndex = resIndex === -1 ? -1 : (resIndex + m) % nodes.length;
return resIndex !== -1 ? nodes[resIndex] : undefined;
}
focusNode(node) {
if (!node) {
console.warn('Tried to focus an undefined node.');
return;
}
if (this.area)
AreaExtensions.zoomAt(this.area, [node], { scale: undefined });
}
focusNodes(nodes, options = {}) {
if (this.area)
AreaExtensions.zoomAt(this.area, nodes ?? this.editor.getNodes(), options);
}
select(entity, options = {}) {
this.selector.select(entity, options);
}
selectConnection(id) {
const conn = this.editor.getConnection(id);
if (!conn) {
console.warn('selectConnection: Connection not found', id);
return;
}
this.selector.select(conn);
}
selectAll() {
this.selector.selectAll();
}
unselectAll() {
this.selector.unselectAll();
}
dataflowCache = new SvelteMap();
async runDataflowEngines() {
if (!this.#isDataflowEnabled) {
console.warn('Dataflow engines are disabled');
return;
}
await tick();
console.debug('Running dataflow engines');
try {
this.editor
.getNodes()
// .filter((n) => n instanceof AddNode || n instanceof DisplayNode)
.forEach(async (n) => {
try {
await this.dataflowEngine.fetch(n.id);
}
catch (e) {
const err = e;
if (err.message === 'cancelled')
return;
console.error(e);
}
this.dataflowEngine.cache.get(n.id)?.then((res) => {
// console.log('Dataflow engine finished', n.label, res);
this.dataflowCache.set(n, res);
});
n.needsProcessing = false;
});
}
catch (e) {
console.error('Dataflow engine cancelled', e);
}
}
async resetDataflow(node) {
if (!this.#isDataflowEnabled)
return;
if (node) {
if (this.dataflowCache.has(node))
this.dataflowEngine.reset(node.id);
}
else {
this.dataflowEngine.reset();
}
await this.runDataflowEngines();
}
}