@selenite/graph-editor
Version:
A graph editor for visual programming, based on rete and svelte.
278 lines (277 loc) • 8.99 kB
JavaScript
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()
: []
};
}
}