UNPKG

@selenite/graph-editor

Version:

A graph editor for visual programming, based on rete and svelte.

278 lines (277 loc) 8.99 kB
import { Scope, NodeEditor as BaseNodeEditor } from 'rete'; import { Connection, Node } from '../nodes'; import { newLocalId } from '../../utils'; import { get, writable } from 'svelte/store'; import { NodeFactory } from './NodeFactory.svelte'; import wu from 'wu'; import { _ } from '../../global/todo.svelte'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { animationFrame, browser, newUuid } from '@selenite/commons'; /** * A graph editor for visual programming. * * A low level class that manages nodes and connections. */ export class NodeEditor extends BaseNodeEditor { factory; get area() { return this.factory?.getArea(); } variables = $state({}); previewedNodes = new SvelteSet(); // constructor() { // } setName(name) { this.graphName = name; } name = 'Node Editor'; #graphId = $state(newUuid()); set graphId(id) { if (!id) this.#graphId = newUuid(); else this.#graphId = id; } get graphId() { return this.#graphId; } #graphName = $state('New Graph'); get graphName() { return this.#graphName; } set graphName(n) { this.#graphName = n.trim(); this.onChangeNameListeners.forEach((listener) => listener(n)); } nameStore = { subscribe: (run, invalidate) => { this.addOnChangeNameListener(run); run(this.graphName); return () => { this.onChangeNameListeners.splice(this.onChangeNameListeners.findIndex((v) => v === run), 1); }; } }; onChangeNameListeners = []; addOnChangeNameListener(listener) { this.onChangeNameListeners.push(listener); } id = newLocalId('node-editor'); nodesMap = new SvelteMap(); connectionsMap = new SvelteMap(); get nodes() { return this.getNodes(); } selectedInputs = $derived(Array.from(this.nodesMap.values()) .map((node) => ({ node, selected: node.selectedInputs })) .filter(({ selected: inputs }) => inputs.length > 0)); selectedOutputs = $derived(Array.from(this.nodesMap.values()) .map((node) => ({ node, selected: node.selectedOutputs })) .filter(({ selected }) => selected.length > 0)); get connections() { return this.getConnections(); } constructor({ id } = {}) { super(); // @ts-expect-error delete base class properties delete this.nodes; // @ts-expect-error delete base class properties delete this.connections; this.graphId = id ?? newUuid(); } /** * Gets a node by id. * @param id - id of the node * @returns The node or undefined */ // @ts-expect-error getNode(id) { return this.nodesMap.get(id); } /** * Gets all nodes. * @returns An array of all nodes in the editor */ getNodes() { return [...this.nodesMap.values()]; } /** * Gets a connection by id. * @param id - id of the connection * @returns The connection or undefined */ // @ts-expect-error getConnection(id) { return this.connectionsMap.get(id); } /** * Gets all connections. * @returns An array of all connections in the editor */ getConnections() { return [...this.connectionsMap.values()]; } hasNode(ref) { if (typeof ref === 'string') { return this.nodesMap.has(ref); } else { return this.nodesMap.has(ref.id); } } hasConnection(ref) { if (typeof ref === 'string') { return this.connectionsMap.has(ref); } else { return this.connectionsMap.has(ref.id); } } /** * Adds a node to the editor. * @param node - node to add * @returns Whether the node was added */ async addNode(node) { if (this.nodesMap.has(node.id)) { console.error('Node has already been added', node); return false; } if (!(await this.emit({ type: 'nodecreate', data: node }))) return false; this.nodesMap.set(node.id, node); await this.emit({ type: 'nodecreated', data: node }); return true; } /** * Adds a connection to the editor. * @param conn - connection to add * @returns Whether the connection was added */ async addConnection(conn) { if (this.hasConnection(conn)) { console.error('Connection has already been added', conn.id); return false; } if (!(await this.emit({ type: 'connectioncreate', data: conn }))) return false; this.connectionsMap.set(conn.id, conn); conn.factory = this.factory; await this.emit({ type: 'connectioncreated', data: conn }); return true; } async addExecConnection(source, target) { try { return await this.addConnection(new Connection(source, 'exec', target, 'exec')); } catch (e) { console.error('Error adding connection', e); return false; } } async addNewConnection(source, sourceOutput, target, targetInput) { const source_ = typeof source === 'string' ? this.getNode(source) : source; const target_ = typeof target === 'string' ? this.getNode(target) : target; if (!source_ || !target_) { console.error('Node not found'); return undefined; } try { const conn = new Connection(source_, sourceOutput, target_, targetInput); conn.factory = this.factory; await this.addConnection(new Connection(source_, sourceOutput, target_, targetInput)); return conn; } catch (e) { console.error('Error adding connection', source_.label + (source_.name ? '-' + source_.name : ''), sourceOutput, target_.label + (target_.name ? '-' + target_.name : ''), targetInput, e); return undefined; } } async removeNode(ref) { let node; if (typeof ref === 'string') { node = this.nodesMap.get(ref); } else { node = ref; } if (!node) { console.error("Couldn't find node to remove", node); return false; } if (!(await this.emit({ type: 'noderemove', data: node }))) return false; this.nodesMap.delete(node.id); this.previewedNodes.delete(node); await this.emit({ type: 'noderemoved', data: node }); return true; } async removeConnection(ref) { let conn; if (typeof ref === 'string') { conn = this.connectionsMap.get(ref); } else { conn = ref; } if (!conn) { console.error("Couldn't find connection to remove", conn); return false; } if (!(await this.emit({ type: 'connectionremove', data: conn }))) return false; this.connectionsMap.delete(conn.id); await this.emit({ type: 'connectionremoved', data: conn }); return true; } clearing = $state(false); async clear() { if (this.nodesMap.size === 0) return true; if (!(await this.emit({ type: 'clear' }))) { await this.emit({ type: 'clearcancelled' }); return false; } this.clearing = true; if (browser) { document.body.style.cursor = 'wait'; await animationFrame(2); } for (const connection of this.connectionsMap.values()) await this.removeConnection(connection); for (const node of this.nodesMap.values()) await this.removeNode(node); if (browser) { document.body.style.cursor = ''; } await this.emit({ type: 'cleared' }); this.clearing = false; return true; } toJSON() { const variables = []; for (const v of Object.values(this.variables)) { variables.push({ ...v, highlighted: false }); } return { editorName: this.graphName, graphName: this.graphName, id: this.graphId, variables: variables, previewedNodes: Array.from(this.previewedNodes).map((node) => node.id), nodes: this.getNodes().map((node) => node.toJSON()), connections: this.getConnections().map((conn) => conn.toJSON()), comments: this.factory?.comment ? wu(this.factory?.comment?.comments.values()) .map((t) => { return { id: t.id, text: t.text, links: t.links }; }) .toArray() : [] }; } }