import type { DragAndScaleState } from "./DragAndScale" import type { LGraphEventMap } from "./infrastructure/LGraphEventMap" import type { Dictionary, IContextMenuValue, LinkNetwork, LinkSegment, MethodNames, OptionalProps, Point, Positionable, } from "./interfaces" import type { ExportedSubgraph, ISerialisedGraph, ISerialisedNode, Serialisable, SerialisableGraph, SerialisableReroute, } from "./types/serialisation" import type { UUID } from "@/utils/uuid" import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants" import { createUuidv4, zeroUuid } from "@/utils/uuid" import { CustomEventTarget } from "./infrastructure/CustomEventTarget" import { LGraphCanvas } from "./LGraphCanvas" import { LGraphGroup } from "./LGraphGroup" import { LGraphNode, type NodeId } from "./LGraphNode" import { LiteGraph, SubgraphNode } from "./litegraph" import { type LinkId, LLink } from "./LLink" import { MapProxyHandler } from "./MapProxyHandler" import { alignOutsideContainer, alignToContainer, createBounds } from "./measure" import { Reroute, type RerouteId } from "./Reroute" import { stringOrEmpty } from "./strings" import { type GraphOrSubgraph, Subgraph } from "./subgraph/Subgraph" import { SubgraphInput } from "./subgraph/SubgraphInput" import { SubgraphOutput } from "./subgraph/SubgraphOutput" import { getBoundaryLinks, groupResolvedByOutput, mapSubgraphInputsAndLinks, mapSubgraphOutputsAndLinks, multiClone, splitPositionables } from "./subgraph/subgraphUtils" import { Alignment, LGraphEventMode } from "./types/globalEnums" import { getAllNestedItems } from "./utils/collections" export interface LGraphState { lastGroupId: number lastNodeId: number lastLinkId: number lastRerouteId: number } type ParamsArray, K extends MethodNames> = Parameters[1] extends undefined ? Parameters | Parameters[0] : Parameters /** Configuration used by {@link LGraph} `config`. */ export interface LGraphConfig { /** @deprecated Legacy config - unused */ align_to_grid?: any links_ontop?: any } export interface LGraphExtra extends Dictionary { reroutes?: SerialisableReroute[] linkExtensions?: { id: number, parentId: number | undefined }[] ds?: DragAndScaleState } export interface BaseLGraph { /** The root graph. */ readonly rootGraph: LGraph } /** * LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop. * supported callbacks: * + onNodeAdded: when a new node is added to the graph * + onNodeRemoved: when a node inside this graph is removed */ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable { static serialisedSchemaVersion = 1 as const static STATUS_STOPPED = 1 static STATUS_RUNNING = 2 /** List of LGraph properties that are manually handled by {@link LGraph.configure}. */ static readonly ConfigureProperties = new Set([ "nodes", "groups", "links", "state", "reroutes", "floatingLinks", "id", "subgraphs", "definitions", "inputs", "outputs", "widgets", "inputNode", "outputNode", "extra", ]) id: UUID = zeroUuid revision: number = 0 _version: number = -1 /** The backing store for links. Keys are wrapped in String() */ _links: Map = new Map() /** * Indexed property access is deprecated. * Backwards compatibility with a Proxy has been added, but will eventually be removed. * * Use {@link Map} methods: * ``` * const linkId = 123 * const link = graph.links.get(linkId) * // Deprecated: const link = graph.links[linkId] * ``` */ links: Map & Record list_of_graphcanvas: LGraphCanvas[] | null status: number = LGraph.STATUS_STOPPED state: LGraphState = { lastGroupId: 0, lastNodeId: 0, lastLinkId: 0, lastRerouteId: 0, } readonly events = new CustomEventTarget() readonly _subgraphs: Map = new Map() _nodes: (LGraphNode | SubgraphNode)[] = [] _nodes_by_id: Record = {} _nodes_in_order: LGraphNode[] = [] _nodes_executable: LGraphNode[] | null = null _groups: LGraphGroup[] = [] iteration: number = 0 globaltime: number = 0 /** @deprecated Unused */ runningtime: number = 0 fixedtime: number = 0 fixedtime_lapse: number = 0.01 elapsed_time: number = 0.01 last_update_time: number = 0 starttime: number = 0 catch_errors: boolean = true execution_timer_id?: number | null errors_in_execution?: boolean /** @deprecated Unused */ execution_time!: number _last_trigger_time?: number filter?: string /** Must contain serialisable values, e.g. primitive types */ config: LGraphConfig = {} vars: Dictionary = {} nodes_executing: boolean[] = [] nodes_actioning: (string | boolean)[] = [] nodes_executedAction: string[] = [] extra: LGraphExtra = {} /** @deprecated Deserialising a workflow sets this unused property. */ version?: number /** @returns Whether the graph has no items */ get empty(): boolean { return this._nodes.length + this._groups.length + this.reroutes.size === 0 } /** @returns All items on the canvas that can be selected */ *positionableItems(): Generator { for (const node of this._nodes) yield node for (const group of this._groups) yield group for (const reroute of this.reroutes.values()) yield reroute return } /** Internal only. Not required for serialisation; calculated on deserialise. */ #lastFloatingLinkId: number = 0 #floatingLinks: Map = new Map() get floatingLinks(): ReadonlyMap { return this.#floatingLinks } #reroutes = new Map() /** All reroutes in this graph. */ public get reroutes(): Map { return this.#reroutes } get rootGraph(): LGraph { return this } get isRootGraph(): boolean { return this.rootGraph === this } /** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */ get last_node_id() { return this.state.lastNodeId } set last_node_id(value) { this.state.lastNodeId = value } /** @deprecated See {@link state}.{@link LGraphState.lastLinkId lastLinkId} */ get last_link_id() { return this.state.lastLinkId } set last_link_id(value) { this.state.lastLinkId = value } onAfterStep?(): void onBeforeStep?(): void onPlayEvent?(): void onStopEvent?(): void onAfterExecute?(): void onExecuteStep?(): void onNodeAdded?(node: LGraphNode): void onNodeRemoved?(node: LGraphNode): void onTrigger?(action: string, param: unknown): void onBeforeChange?(graph: LGraph, info?: LGraphNode): void onAfterChange?(graph: LGraph, info?: LGraphNode | null): void onConnectionChange?(node: LGraphNode): void on_change?(graph: LGraph): void onSerialize?(data: ISerialisedGraph | SerialisableGraph): void onConfigure?(data: ISerialisedGraph | SerialisableGraph): void onGetNodeMenuOptions?(options: (IContextMenuValue | null)[], node: LGraphNode): void private _input_nodes?: LGraphNode[] /** * See {@link LGraph} * @param o data from previous serialization [optional] */ constructor(o?: ISerialisedGraph | SerialisableGraph) { if (LiteGraph.debug) console.log("Graph created") /** @see MapProxyHandler */ const links = this._links MapProxyHandler.bindAllMethods(links) const handler = new MapProxyHandler() this.links = new Proxy(links, handler) as Map & Record this.list_of_graphcanvas = null this.clear() if (o) this.configure(o) } /** * Removes all nodes from this graph */ clear(): void { this.stop() this.status = LGraph.STATUS_STOPPED this.id = zeroUuid this.revision = 0 this.state = { lastGroupId: 0, lastNodeId: 0, lastLinkId: 0, lastRerouteId: 0, } // used to detect changes this._version = -1 this._subgraphs.clear() // safe clear if (this._nodes) { for (const _node of this._nodes) { _node.onRemoved?.() } } // nodes this._nodes = [] this._nodes_by_id = {} // nodes sorted in execution order this._nodes_in_order = [] // nodes that contain onExecute sorted in execution order this._nodes_executable = null this._links.clear() this.reroutes.clear() this.#floatingLinks.clear() this.#lastFloatingLinkId = 0 // other scene stuff this._groups = [] // iterations this.iteration = 0 // custom data this.config = {} this.vars = {} // to store custom data this.extra = {} // timing this.globaltime = 0 this.runningtime = 0 this.fixedtime = 0 this.fixedtime_lapse = 0.01 this.elapsed_time = 0.01 this.last_update_time = 0 this.starttime = 0 this.catch_errors = true this.nodes_executing = [] this.nodes_actioning = [] this.nodes_executedAction = [] // notify canvas to redraw this.change() this.canvasAction(c => c.clear()) } get subgraphs(): Map { return this.rootGraph._subgraphs } get nodes() { return this._nodes } get groups() { return this._groups } /** * Attach Canvas to this graph */ attachCanvas(canvas: LGraphCanvas): void { if (!(canvas instanceof LGraphCanvas)) { throw new TypeError("attachCanvas expects an LGraphCanvas instance") } this.primaryCanvas = canvas this.list_of_graphcanvas ??= [] if (!this.list_of_graphcanvas.includes(canvas)) { this.list_of_graphcanvas.push(canvas) } if (canvas.graph === this) return canvas.graph?.detachCanvas(canvas) canvas.graph = this canvas.subgraph = undefined } /** * Detach Canvas from this graph */ detachCanvas(canvas: LGraphCanvas): void { canvas.graph = null const canvases = this.list_of_graphcanvas if (canvases) { const pos = canvases.indexOf(canvas) if (pos !== -1) canvases.splice(pos, 1) } } /** * @deprecated Will be removed in 0.9 * Starts running this graph every interval milliseconds. * @param interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate */ start(interval?: number): void { if (this.status == LGraph.STATUS_RUNNING) return this.status = LGraph.STATUS_RUNNING this.onPlayEvent?.() this.sendEventToAllNodes("onStart") // launch this.starttime = LiteGraph.getTime() this.last_update_time = this.starttime interval ||= 0 // execute once per frame if ( interval == 0 && typeof window != "undefined" && window.requestAnimationFrame ) { const on_frame = () => { if (this.execution_timer_id != -1) return window.requestAnimationFrame(on_frame) this.onBeforeStep?.() this.runStep(1, !this.catch_errors) this.onAfterStep?.() } this.execution_timer_id = -1 on_frame() } else { // execute every 'interval' ms this.execution_timer_id = setInterval(() => { // execute this.onBeforeStep?.() this.runStep(1, !this.catch_errors) this.onAfterStep?.() }, interval) } } /** * @deprecated Will be removed in 0.9 * Stops the execution loop of the graph */ stop(): void { if (this.status == LGraph.STATUS_STOPPED) return this.status = LGraph.STATUS_STOPPED this.onStopEvent?.() if (this.execution_timer_id != null) { if (this.execution_timer_id != -1) { clearInterval(this.execution_timer_id) } this.execution_timer_id = null } this.sendEventToAllNodes("onStop") } /** * Run N steps (cycles) of the graph * @param num number of steps to run, default is 1 * @param do_not_catch_errors [optional] if you want to try/catch errors * @param limit max number of nodes to execute (used to execute from start to a node) */ runStep(num: number, do_not_catch_errors: boolean, limit?: number): void { num = num || 1 const start = LiteGraph.getTime() this.globaltime = 0.001 * (start - this.starttime) const nodes = this._nodes_executable || this._nodes if (!nodes) return limit = limit || nodes.length if (do_not_catch_errors) { // iterations for (let i = 0; i < num; i++) { for (let j = 0; j < limit; ++j) { const node = nodes[j] // FIXME: Looks like copy/paste broken logic - checks for "on", executes "do" if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) { // wrap node.onExecute(); node.doExecute?.() } } this.fixedtime += this.fixedtime_lapse this.onExecuteStep?.() } this.onAfterExecute?.() } else { try { // iterations for (let i = 0; i < num; i++) { for (let j = 0; j < limit; ++j) { const node = nodes[j] if (node.mode == LGraphEventMode.ALWAYS) { node.onExecute?.() } } this.fixedtime += this.fixedtime_lapse this.onExecuteStep?.() } this.onAfterExecute?.() this.errors_in_execution = false } catch (error) { this.errors_in_execution = true if (LiteGraph.throw_errors) throw error if (LiteGraph.debug) console.log("Error during execution:", error) this.stop() } } const now = LiteGraph.getTime() let elapsed = now - start if (elapsed == 0) elapsed = 1 this.execution_time = 0.001 * elapsed this.globaltime += 0.001 * elapsed this.iteration += 1 this.elapsed_time = (now - this.last_update_time) * 0.001 this.last_update_time = now this.nodes_executing = [] this.nodes_actioning = [] this.nodes_executedAction = [] } /** * Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than * nodes with only inputs. */ updateExecutionOrder(): void { this._nodes_in_order = this.computeExecutionOrder(false) this._nodes_executable = [] for (const node of this._nodes_in_order) { if (node.onExecute) { this._nodes_executable.push(node) } } } // This is more internal, it computes the executable nodes in order and returns it computeExecutionOrder( only_onExecute: boolean, set_level?: boolean, ): LGraphNode[] { const L: LGraphNode[] = [] const S: LGraphNode[] = [] const M: Dictionary = {} // to avoid repeating links const visited_links: Record = {} const remaining_links: Record = {} // search for the nodes without inputs (starting nodes) for (const node of this._nodes) { if (only_onExecute && !node.onExecute) { continue } // add to pending nodes M[node.id] = node // num of input connections let num = 0 if (node.inputs) { for (const input of node.inputs) { if (input?.link != null) { num += 1 } } } if (num == 0) { // is a starting node S.push(node) if (set_level) node._level = 1 } else { // num of input links if (set_level) node._level = 0 remaining_links[node.id] = num } } while (true) { // get an starting node const node = S.shift() if (node === undefined) break // add to ordered list L.push(node) // remove from the pending nodes delete M[node.id] if (!node.outputs) continue // for every output for (const output of node.outputs) { // not connected // TODO: Confirm functionality, clean condition if (output?.links == null || output.links.length == 0) continue // for every connection for (const link_id of output.links) { const link = this._links.get(link_id) if (!link) continue // already visited link (ignore it) if (visited_links[link.id]) continue const target_node = this.getNodeById(link.target_id) if (target_node == null) { visited_links[link.id] = true continue } if (set_level) { node._level ??= 0 if (!target_node._level || target_node._level <= node._level) { target_node._level = node._level + 1 } } // mark as visited visited_links[link.id] = true // reduce the number of links remaining remaining_links[target_node.id] -= 1 // if no more links, then add to starters array if (remaining_links[target_node.id] == 0) S.push(target_node) } } } // the remaining ones (loops) for (const i in M) { L.push(M[i]) } if (L.length != this._nodes.length && LiteGraph.debug) console.warn("something went wrong, nodes missing") /** Ensure type is set */ type OrderedLGraphNode = LGraphNode & { order: number } /** Sets the order property of each provided node to its index in {@link nodes}. */ function setOrder(nodes: LGraphNode[]): asserts nodes is OrderedLGraphNode[] { const l = nodes.length for (let i = 0; i < l; ++i) { nodes[i].order = i } } // save order number in the node setOrder(L) // sort now by priority L.sort(function (A, B) { // @ts-expect-error ctor props const Ap = A.constructor.priority || A.priority || 0 // @ts-expect-error ctor props const Bp = B.constructor.priority || B.priority || 0 // if same priority, sort by order return Ap == Bp ? A.order - B.order : Ap - Bp }) // save order number in the node, again... setOrder(L) return L } /** * Positions every node in a more readable manner */ arrange(margin?: number, layout?: string): void { margin = margin || 100 const nodes = this.computeExecutionOrder(false, true) const columns: LGraphNode[][] = [] for (const node of nodes) { const col = node._level || 1 columns[col] ||= [] columns[col].push(node) } let x = margin for (const column of columns) { if (!column) continue let max_size = 100 let y = margin + LiteGraph.NODE_TITLE_HEIGHT for (const node of column) { node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0 if (node.size[max_size_index] > max_size) { max_size = node.size[max_size_index] } const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1 y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT } x += max_size + margin } this.setDirtyCanvas(true, true) } /** * Returns the amount of time the graph has been running in milliseconds * @returns number of milliseconds the graph has been running */ getTime(): number { return this.globaltime } /** * Returns the amount of time accumulated using the fixedtime_lapse var. * This is used in context where the time increments should be constant * @returns number of milliseconds the graph has been running */ getFixedTime(): number { return this.fixedtime } /** * Returns the amount of time it took to compute the latest iteration. * Take into account that this number could be not correct * if the nodes are using graphical actions * @returns number of milliseconds it took the last cycle */ getElapsedTime(): number { return this.elapsed_time } /** * @deprecated Will be removed in 0.9 * Sends an event to all the nodes, useful to trigger stuff * @param eventname the name of the event (function to be called) * @param params parameters in array format */ sendEventToAllNodes( eventname: string, params?: object | object[], mode?: LGraphEventMode, ): void { mode = mode || LGraphEventMode.ALWAYS const nodes = this._nodes_in_order || this._nodes if (!nodes) return for (const node of nodes) { // @ts-expect-error deprecated if (!node[eventname] || node.mode != mode) continue if (params === undefined) { // @ts-expect-error deprecated node[eventname]() } else if (params && params.constructor === Array) { // @ts-expect-error deprecated node[eventname].apply(node, params) } else { // @ts-expect-error deprecated node[eventname](params) } } } /** * Runs an action on every canvas registered to this graph. * @param action Action to run for every canvas */ canvasAction(action: (canvas: LGraphCanvas) => void): void { const canvases = this.list_of_graphcanvas if (!canvases) return for (const canvas of canvases) action(canvas) } /** @deprecated See {@link LGraph.canvasAction} */ sendActionToCanvas>( action: T, params?: ParamsArray, ): void { const { list_of_graphcanvas } = this if (!list_of_graphcanvas) return for (const c of list_of_graphcanvas) { c[action]?.apply(c, params) } } /** * Adds a new node instance to this graph * @param node the instance of the node */ add( node: LGraphNode | LGraphGroup, skip_compute_order?: boolean, ): LGraphNode | null | undefined { if (!node) return const { state } = this // Ensure created items are snapped if (LiteGraph.alwaysSnapToGrid) { const snapTo = this.getSnapToGridSize() if (snapTo) node.snapToGrid(snapTo) } // LEGACY: This was changed from constructor === LGraphGroup // groups if (node instanceof LGraphGroup) { // Assign group ID if (node.id == null || node.id === -1) node.id = ++state.lastGroupId if (node.id > state.lastGroupId) state.lastGroupId = node.id this._groups.push(node) this.setDirtyCanvas(true) this.change() node.graph = this this._version++ return } // nodes if (node.id != -1 && this._nodes_by_id[node.id] != null) { console.warn( "LiteGraph: there is already a node with this ID, changing it", ) node.id = LiteGraph.use_uuids ? LiteGraph.uuidv4() : ++state.lastNodeId } if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) { throw "LiteGraph: max number of nodes in a graph reached" } // give him an id if (LiteGraph.use_uuids) { if (node.id == null || node.id == -1) node.id = LiteGraph.uuidv4() } else { if (node.id == null || node.id == -1) { node.id = ++state.lastNodeId } else if (typeof node.id === "number" && state.lastNodeId < node.id) { state.lastNodeId = node.id } } node.graph = this this._version++ this._nodes.push(node) this._nodes_by_id[node.id] = node node.onAdded?.(this) if (this.config.align_to_grid) node.alignToGrid() if (!skip_compute_order) this.updateExecutionOrder() this.onNodeAdded?.(node) this.setDirtyCanvas(true) this.change() // to chain actions return node } /** * Removes a node from the graph * @param node the instance of the node */ remove(node: LGraphNode | LGraphGroup): void { // LEGACY: This was changed from constructor === LiteGraph.LGraphGroup if (node instanceof LGraphGroup) { this.canvasAction(c => c.deselect(node)) const index = this._groups.indexOf(node) if (index != -1) { this._groups.splice(index, 1) } node.graph = undefined this._version++ this.setDirtyCanvas(true, true) this.change() return } // not found if (this._nodes_by_id[node.id] == null) { console.warn("LiteGraph: node not found", node) return } // cannot be removed if (node.ignore_remove) { console.warn("LiteGraph: node cannot be removed", node) return } // sure? - almost sure is wrong this.beforeChange() const { inputs, outputs } = node // disconnect inputs if (inputs) { for (const [i, slot] of inputs.entries()) { if (slot.link != null) node.disconnectInput(i, true) } } // disconnect outputs if (outputs) { for (const [i, slot] of outputs.entries()) { if (slot.links?.length) node.disconnectOutput(i) } } // Floating links for (const link of this.floatingLinks.values()) { if (link.origin_id === node.id || link.target_id === node.id) { this.removeFloatingLink(link) } } // callback node.onRemoved?.() node.graph = null this._version++ // remove from canvas render const { list_of_graphcanvas } = this if (list_of_graphcanvas) { for (const canvas of list_of_graphcanvas) { if (canvas.selected_nodes[node.id]) delete canvas.selected_nodes[node.id] canvas.deselect(node) } } // remove from containers const pos = this._nodes.indexOf(node) if (pos != -1) this._nodes.splice(pos, 1) delete this._nodes_by_id[node.id] this.onNodeRemoved?.(node) // close panels this.canvasAction(c => c.checkPanels()) this.setDirtyCanvas(true, true) // sure? - almost sure is wrong this.afterChange() this.change() this.updateExecutionOrder() } /** * Returns a node by its id. */ getNodeById(id: NodeId | null | undefined): LGraphNode | null { return id != null ? this._nodes_by_id[id] : null } /** * Returns a list of nodes that matches a class * @param classObject the class itself (not an string) * @returns a list with all the nodes of this type */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type findNodesByClass(classObject: Function, result?: LGraphNode[]): LGraphNode[] { result = result || [] result.length = 0 const { _nodes } = this for (const node of _nodes) { if (node.constructor === classObject) result.push(node) } return result } /** * Returns a list of nodes that matches a type * @param type the name of the node type * @returns a list with all the nodes of this type */ findNodesByType(type: string, result: LGraphNode[]): LGraphNode[] { const matchType = type.toLowerCase() result = result || [] result.length = 0 const { _nodes } = this for (const node of _nodes) { if (node.type?.toLowerCase() == matchType) result.push(node) } return result } /** * Returns the first node that matches a name in its title * @param title the name of the node to search * @returns the node or null */ findNodeByTitle(title: string): LGraphNode | null { const { _nodes } = this for (const node of _nodes) { if (node.title == title) return node } return null } /** * Returns a list of nodes that matches a name * @param title the name of the node to search * @returns a list with all the nodes with this name */ findNodesByTitle(title: string): LGraphNode[] { const result: LGraphNode[] = [] const { _nodes } = this for (const node of _nodes) { if (node.title == title) result.push(node) } return result } /** * Returns the top-most node in this position of the canvas * @param x the x coordinate in canvas space * @param y the y coordinate in canvas space * @param nodeList a list with all the nodes to search from, by default is all the nodes in the graph * @returns the node at this position or null */ getNodeOnPos( x: number, y: number, nodeList?: LGraphNode[], ): LGraphNode | null { const nodes = nodeList || this._nodes let i = nodes.length while (--i >= 0) { const node = nodes[i] if (node.isPointInside(x, y)) return node } return null } /** * Returns the top-most group in that position * @param x The x coordinate in canvas space * @param y The y coordinate in canvas space * @returns The group or null */ getGroupOnPos(x: number, y: number): LGraphGroup | undefined { return this._groups.toReversed().find(g => g.isPointInside(x, y)) } /** * Returns the top-most group with a titlebar in the provided position. * @param x The x coordinate in canvas space * @param y The y coordinate in canvas space * @returns The group or null */ getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined { return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y)) } /** * Finds a reroute a the given graph point * @param x X co-ordinate in graph space * @param y Y co-ordinate in graph space * @returns The first reroute under the given co-ordinates, or undefined */ getRerouteOnPos(x: number, y: number, reroutes?: Iterable): Reroute | undefined { for (const reroute of reroutes ?? this.reroutes.values()) { if (reroute.containsPoint([x, y])) return reroute } } /** * Snaps the provided items to a grid. * * Item positions are reounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}. * * When {@link LiteGraph.alwaysSnapToGrid} is enabled * and the grid size is falsy, a default of 1 is used. * @param items The items to be snapped to the grid * @todo Currently only snaps nodes. */ snapToGrid(items: Set): void { const snapTo = this.getSnapToGridSize() if (!snapTo) return for (const item of getAllNestedItems(items)) { if (!item.pinned) item.snapToGrid(snapTo) } } /** * Finds the size of the grid that items should be snapped to when moved. * @returns The size of the grid that items should be snapped to */ getSnapToGridSize(): number { // Default to 1 when always snapping return LiteGraph.alwaysSnapToGrid ? LiteGraph.CANVAS_GRID_SIZE || 1 : LiteGraph.CANVAS_GRID_SIZE } /** * @deprecated Will be removed in 0.9 * Checks that the node type matches the node type registered, * used when replacing a nodetype by a newer version during execution * this replaces the ones using the old version with the new version */ checkNodeTypes() { const { _nodes } = this for (const [i, node] of _nodes.entries()) { const ctor = LiteGraph.registered_node_types[node.type] if (node.constructor == ctor) continue console.log("node being replaced by newer version:", node.type) const newnode = LiteGraph.createNode(node.type) if (!newnode) continue _nodes[i] = newnode newnode.configure(node.serialize()) newnode.graph = this this._nodes_by_id[newnode.id] = newnode if (node.inputs) newnode.inputs = [...node.inputs] if (node.outputs) newnode.outputs = [...node.outputs] } this.updateExecutionOrder() } // ********** GLOBALS ***************** trigger(action: string, param: unknown) { this.onTrigger?.(action, param) } /** @todo Clean up - never implemented. */ triggerInput(name: string, value: any): void { const nodes = this.findNodesByTitle(name) for (const node of nodes) { // @ts-expect-error node.onTrigger(value) } } /** @todo Clean up - never implemented. */ setCallback(name: string, func: any): void { const nodes = this.findNodesByTitle(name) for (const node of nodes) { // @ts-expect-error node.setTrigger(func) } } // used for undo, called before any change is made to the graph beforeChange(info?: LGraphNode): void { this.onBeforeChange?.(this, info) this.canvasAction(c => c.onBeforeChange?.(this)) } // used to resend actions, called after any change is made to the graph afterChange(info?: LGraphNode | null): void { this.onAfterChange?.(this, info) this.canvasAction(c => c.onAfterChange?.(this)) } /** * clears the triggered slot animation in all links (stop visual animation) */ clearTriggeredSlots(): void { for (const link_info of this._links.values()) { if (!link_info) continue if (link_info._last_time) link_info._last_time = 0 } } /* Called when something visually changed (not the graph!) */ change(): void { if (LiteGraph.debug) { console.log("Graph changed") } this.canvasAction(c => c.setDirty(true, true)) this.on_change?.(this) } setDirtyCanvas(fg: boolean, bg?: boolean): void { this.canvasAction(c => c.setDirty(fg, bg)) } addFloatingLink(link: LLink): LLink { if (link.id === -1) { link.id = ++this.#lastFloatingLinkId } this.#floatingLinks.set(link.id, link) const slot = link.target_id !== -1 ? this.getNodeById(link.target_id)?.inputs?.[link.target_slot] : this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot] if (slot) { slot._floatingLinks ??= new Set() slot._floatingLinks.add(link) } else { console.warn(`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`) } const reroutes = LLink.getReroutes(this, link) for (const reroute of reroutes) { reroute.floatingLinkIds.add(link.id) } return link } removeFloatingLink(link: LLink): void { this.#floatingLinks.delete(link.id) const slot = link.target_id !== -1 ? this.getNodeById(link.target_id)?.inputs?.[link.target_slot] : this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot] if (slot) { slot._floatingLinks?.delete(link) } const reroutes = LLink.getReroutes(this, link) for (const reroute of reroutes) { reroute.floatingLinkIds.delete(link.id) if (reroute.floatingLinkIds.size === 0) { delete reroute.floating } if (reroute.totalLinks === 0) this.removeReroute(reroute.id) } } /** * Finds the link with the provided ID. * @param id ID of link to find * @returns The link with the provided {@link id}, otherwise `undefined`. Always returns `undefined` if `id` is nullish. */ getLink(id: null | undefined): undefined getLink(id: LinkId | null | undefined): LLink | undefined getLink(id: LinkId | null | undefined): LLink | undefined { return id == null ? undefined : this._links.get(id) } /** * Finds the reroute with the provided ID. * @param id ID of reroute to find * @returns The reroute with the provided {@link id}, otherwise `undefined`. Always returns `undefined` if `id` is nullish. */ getReroute(id: null | undefined): undefined getReroute(id: RerouteId | null | undefined): Reroute | undefined getReroute(id: RerouteId | null | undefined): Reroute | undefined { return id == null ? undefined : this.reroutes.get(id) } /** * Configures a reroute on the graph where ID is already known (probably deserialisation). * Creates the object if it does not exist. * @param serialisedReroute See {@link SerialisableReroute} */ setReroute({ id, parentId, pos, linkIds, floating }: OptionalProps): Reroute { id ??= ++this.state.lastRerouteId if (id > this.state.lastRerouteId) this.state.lastRerouteId = id const reroute = this.reroutes.get(id) ?? new Reroute(id, this) reroute.update(parentId, pos, linkIds, floating) this.reroutes.set(id, reroute) return reroute } /** * Creates a new reroute and adds it to the graph. * @param pos Position in graph space * @param before The existing link segment (reroute, link) that will be after this reroute, * going from the node output to input. * @returns The newly created reroute - typically ignored. */ createReroute(pos: Point, before: LinkSegment): Reroute { const rerouteId = ++this.state.lastRerouteId const linkIds = before instanceof Reroute ? before.linkIds : [before.id] const floatingLinkIds = before instanceof Reroute ? before.floatingLinkIds : [before.id] const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds, floatingLinkIds) this.reroutes.set(rerouteId, reroute) for (const linkId of linkIds) { const link = this._links.get(linkId) if (!link) continue if (link.parentId === before.parentId) link.parentId = rerouteId const reroutes = LLink.getReroutes(this, link) for (const x of reroutes.filter(x => x.parentId === before.parentId)) { x.parentId = rerouteId } } for (const linkId of floatingLinkIds) { const link = this.floatingLinks.get(linkId) if (!link) continue if (link.parentId === before.parentId) link.parentId = rerouteId const reroutes = LLink.getReroutes(this, link) for (const x of reroutes.filter(x => x.parentId === before.parentId)) { x.parentId = rerouteId } } return reroute } /** * Removes a reroute from the graph * @param id ID of reroute to remove */ removeReroute(id: RerouteId): void { const { reroutes } = this const reroute = reroutes.get(id) if (!reroute) return this.canvasAction(c => c.deselect(reroute)) // Extract reroute from the reroute chain const { parentId, linkIds, floatingLinkIds } = reroute for (const reroute of reroutes.values()) { if (reroute.parentId === id) reroute.parentId = parentId } for (const linkId of linkIds) { const link = this._links.get(linkId) if (link && link.parentId === id) link.parentId = parentId } for (const linkId of floatingLinkIds) { const link = this.floatingLinks.get(linkId) if (!link) { console.warn(`Removed reroute had floating link ID that did not exist [${linkId}]`) continue } // A floating link is a unique branch; if there is no parent reroute, or // the parent reroute has any other links, remove this floating link. const floatingReroutes = LLink.getReroutes(this, link) const lastReroute = floatingReroutes.at(-1) const secondLastReroute = floatingReroutes.at(-2) if (reroute !== lastReroute) { continue } else if (secondLastReroute?.totalLinks !== 1) { this.removeFloatingLink(link) } else if (link.parentId === id) { link.parentId = parentId secondLastReroute.floating = reroute.floating } } reroutes.delete(id) // This does not belong here; it should be handled by the caller, or run by a remove-many API. // https://github.com/Comfy-Org/litegraph.js/issues/898 this.setDirtyCanvas(false, true) } /** * Destroys a link */ removeLink(link_id: LinkId): void { const link = this._links.get(link_id) if (!link) return const node = this.getNodeById(link.target_id) node?.disconnectInput(link.target_slot, false) link.disconnect(this) } /** * Creates a new subgraph definition, and adds it to the graph. * @param data Exported data (typically serialised) to configure the new subgraph with * @returns The newly created subgraph definition. */ createSubgraph(data: ExportedSubgraph): Subgraph { const { id } = data const subgraph = new Subgraph(this.rootGraph, data) this.subgraphs.set(id, subgraph) // FE: Create node defs this.rootGraph.events.dispatch("subgraph-created", { subgraph, data }) return subgraph } convertToSubgraph(items: Set): { subgraph: Subgraph, node: SubgraphNode } { if (items.size === 0) throw new Error("Cannot convert to subgraph: nothing to convert") const { state, revision, config } = this const { boundaryLinks, boundaryFloatingLinks, internalLinks, boundaryInputLinks, boundaryOutputLinks } = getBoundaryLinks(this, items) const { nodes, reroutes, groups } = splitPositionables(items) const boundingRect = createBounds(items) if (!boundingRect) throw new Error("Failed to create bounding rect for subgraph") const resolvedInputLinks = boundaryInputLinks.map(x => x.resolve(this)) const resolvedOutputLinks = boundaryOutputLinks.map(x => x.resolve(this)) const clonedNodes = multiClone(nodes) // Inputs, outputs, and links const links = internalLinks.map(x => x.asSerialisable()) const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links) const outputs = mapSubgraphOutputsAndLinks(resolvedOutputLinks, links) // Prepare subgraph data const data = { id: createUuidv4(), name: "New Subgraph", inputNode: { id: SUBGRAPH_INPUT_ID, bounding: [0, 0, 75, 100], }, outputNode: { id: SUBGRAPH_OUTPUT_ID, bounding: [0, 0, 75, 100], }, inputs, outputs, widgets: [], version: LGraph.serialisedSchemaVersion, state, revision, config, links, nodes: clonedNodes, reroutes: structuredClone([...reroutes].map(reroute => reroute.asSerialisable())), groups: structuredClone([...groups].map(group => group.serialize())), } satisfies ExportedSubgraph const subgraph = this.createSubgraph(data) subgraph.configure(data) // Position the subgraph input nodes subgraph.inputNode.arrange() subgraph.outputNode.arrange() const { boundingRect: inputRect } = subgraph.inputNode const { boundingRect: outputRect } = subgraph.outputNode alignOutsideContainer(inputRect, Alignment.MidLeft, boundingRect, [50, 0]) alignOutsideContainer(outputRect, Alignment.MidRight, boundingRect, [50, 0]) // Remove items converted to subgraph for (const resolved of resolvedInputLinks) resolved.inputNode?.disconnectInput(resolved.inputNode.inputs.indexOf(resolved.input!), true) for (const resolved of resolvedOutputLinks) resolved.outputNode?.disconnectOutput(resolved.outputNode.outputs.indexOf(resolved.output!), resolved.inputNode) for (const node of nodes) this.remove(node) for (const reroute of reroutes) this.removeReroute(reroute.id) for (const group of groups) this.remove(group) this.rootGraph.events.dispatch("convert-to-subgraph", { subgraph, bounds: boundingRect, exportedSubgraph: data, boundaryLinks, resolvedInputLinks, resolvedOutputLinks, boundaryFloatingLinks, internalLinks, }) // Create subgraph node object const subgraphNode = LiteGraph.createNode(subgraph.id, subgraph.name, { inputs: structuredClone(inputs), outputs: structuredClone(outputs), }) if (!subgraphNode) throw new Error("Failed to create subgraph node") // Resize to inputs/outputs subgraphNode.setSize(subgraphNode.computeSize()) // Center the subgraph node alignToContainer(subgraphNode._posSize, Alignment.Centre | Alignment.Middle, boundingRect) // Add the subgraph node to the graph this.add(subgraphNode) // Group matching input links const groupedByOutput = groupResolvedByOutput(resolvedInputLinks) // Reconnect input links in parent graph let i = 0 for (const [, connections] of groupedByOutput.entries()) { const [firstResolved, ...others] = connections const { output, outputNode, link, subgraphInput } = firstResolved // Special handling: Subgraph input node i++ if (link.origin_id === SUBGRAPH_INPUT_ID) { link.target_id = subgraphNode.id link.target_slot = i - 1 if (subgraphInput instanceof SubgraphInput) { subgraphInput.connect(subgraphNode.findInputSlotByType(link.type, true, true), subgraphNode, link.parentId) } else { throw new TypeError("Subgraph input node is not a SubgraphInput") } console.debug("Reconnect input links in parent graph", { ...link }, this.links.get(link.id), this.links.get(link.id) === link) for (const resolved of others) { resolved.link.disconnect(this) } continue } if (!output || !outputNode) { console.warn("Convert to Subgraph reconnect: Failed to resolve input link", connections[0]) continue } const input = subgraphNode.findInputSlotByType(link.type, true, true) outputNode.connectSlots( output, subgraphNode, input, link.parentId, ) } // Group matching links const outputsGroupedByOutput = groupResolvedByOutput(resolvedOutputLinks) // Reconnect output links in parent graph i = 0 for (const [, connections] of outputsGroupedByOutput.entries()) { // Special handling: Subgraph output node i++ for (const connection of connections) { const { input, inputNode, link, subgraphOutput } = connection if (link.target_id === SUBGRAPH_OUTPUT_ID) { link.origin_id = subgraphNode.id link.origin_slot = i - 1 this.links.set(link.id, link) if (subgraphOutput instanceof SubgraphOutput) { subgraphOutput.connect(subgraphNode.findOutputSlotByType(link.type, true, true), subgraphNode, link.parentId) } else { throw new TypeError("Subgraph input node is not a SubgraphInput") } continue } if (!input || !inputNode) { console.warn("Convert to Subgraph reconnect: Failed to resolve output link", connection) continue } const output = subgraphNode.outputs[i - 1] subgraphNode.connectSlots( output, inputNode, input, link.parentId, ) } } return { subgraph, node: subgraphNode as SubgraphNode } } /** * Resolve a path of subgraph node IDs into a list of subgraph nodes. * Not intended to be run from subgraphs. * @param nodeIds An ordered list of node IDs, from the root graph to the most nested subgraph node * @returns An ordered list of nested subgraph nodes. */ resolveSubgraphIdPath(nodeIds: readonly NodeId[]): SubgraphNode[] { const result: SubgraphNode[] = [] let currentGraph: GraphOrSubgraph = this.rootGraph for (const nodeId of nodeIds) { const node: LGraphNode | null = currentGraph.getNodeById(nodeId) if (!node) throw new Error(`Node [${nodeId}] not found. ID Path: ${nodeIds.join(":")}`) if (!node.isSubgraphNode()) throw new Error(`Node [${nodeId}] is not a SubgraphNode. ID Path: ${nodeIds.join(":")}`) result.push(node) currentGraph = node.subgraph } return result } /** * Creates a Object containing all the info about this graph, it can be serialized * @deprecated Use {@link asSerialisable}, which returns the newer schema version. * @returns value of the node */ serialize(option?: { sortNodes: boolean }): ISerialisedGraph { const { config, state, groups, nodes, reroutes, extra, floatingLinks, definitions } = this.asSerialisable(option) const linkArray = [...this._links.values()] const links = linkArray.map(x => x.serialize()) if (reroutes?.length) { // Link parent IDs cannot go in 0.4 schema arrays extra.linkExtensions = linkArray .filter(x => x.parentId !== undefined) .map(x => ({ id: x.id, parentId: x.parentId })) } extra.reroutes = reroutes?.length ? reroutes : undefined return { id: this.id, revision: this.revision, last_node_id: state.lastNodeId, last_link_id: state.lastLinkId, nodes, links, floatingLinks, groups, definitions, config, extra, version: LiteGraph.VERSION, } } /** @returns The drag and scale state of the first attached canvas, otherwise `undefined`. */ #getDragAndScale(): DragAndScaleState | undefined { const ds = this.list_of_graphcanvas?.at(0)?.ds if (ds) return { scale: ds.scale, offset: ds.offset } } /** * Prepares a shallow copy of this object for immediate serialisation or structuredCloning. * The return value should be discarded immediately. * @param options Serialise options = currently `sortNodes: boolean`, whether to sort nodes by ID. * @returns A shallow copy of parts of this graph, with shallow copies of its serialisable objects. * Mutating the properties of the return object may result in changes to your graph. * It is intended for use with {@link structuredClone} or {@link JSON.stringify}. */ asSerialisable(options?: { sortNodes: boolean }): SerialisableGraph & Required> { const { id, revision, config, state } = this const nodeList = !LiteGraph.use_uuids && options?.sortNodes // @ts-expect-error If LiteGraph.use_uuids is false, ids are numbers. ? [...this._nodes].sort((a, b) => a.id - b.id) : this._nodes const nodes = nodeList.map(node => node.serialize()) const groups = this._groups.map(x => x.serialize()) const links = this._links.size ? [...this._links.values()].map(x => x.asSerialisable()) : undefined const floatingLinks = this.floatingLinks.size ? [...this.floatingLinks.values()].map(x => x.asSerialisable()) : undefined const reroutes = this.reroutes.size ? [...this.reroutes.values()].map(x => x.asSerialisable()) : undefined // Save scale and offset const extra = { ...this.extra } if (LiteGraph.saveViewportWithGraph) extra.ds = this.#getDragAndScale() if (!extra.ds) delete extra.ds const data: ReturnType = { id, revision, version: LGraph.serialisedSchemaVersion, config, state, groups, nodes, links, floatingLinks, reroutes, extra, } if (this.isRootGraph && this._subgraphs.size) { data.definitions = { subgraphs: [...this._subgraphs.values()].map(x => x.asSerialisable()) } } this.onSerialize?.(data) return data } protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void { const { id, extra } = data // Create a new graph ID if none is provided if (id) { this.id = id } else if (this.id === zeroUuid) { this.id = createUuidv4() } // Extra this.extra = extra ? structuredClone(extra) : {} // Ensure auto-generated serialisation data is removed from extra delete this.extra.linkExtensions } /** * Configure a graph from a JSON string * @param data The deserialised object to configure this graph from * @param keep_old If `true`, the graph will not be cleared prior to * adding the configuration. */ configure( data: ISerialisedGraph | SerialisableGraph, keep_old?: boolean, ): boolean | undefined { const options: LGraphEventMap["configuring"] = { data, clearGraph: !keep_old, } const mayContinue = this.events.dispatch("configuring", options) if (!mayContinue) return try { // TODO: Finish typing configure() if (!data) return if (options.clearGraph) this.clear() this._configureBase(data) let reroutes: SerialisableReroute[] | undefined // TODO: Determine whether this should this fall back to 0.4. if (data.version === 0.4) { const { extra } = data // Deprecated - old schema version, links are arrays if (Array.isArray(data.links)) { for (const linkData of data.links) { const link = LLink.createFromArray(linkData) this._links.set(link.id, link) } } // #region `extra` embeds for v0.4 // LLink parentIds if (Array.isArray(extra?.linkExtensions)) { for (const linkEx of extra.linkExtensions) { const link = this._links.get(linkEx.id) if (link) link.parentId = linkEx.parentId } } // Reroutes reroutes = extra?.reroutes // #endregion `extra` embeds for v0.4 } else { // New schema - one version so far, no check required. // State if (data.state) { const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = data.state const { state } = this if (lastGroupId != null) state.lastGroupId = lastGroupId if (lastLinkId != null) state.lastLinkId = lastLinkId if (lastNodeId != null) state.lastNodeId = lastNodeId if (lastRerouteId != null) state.lastRerouteId = lastRerouteId } // Links if (Array.isArray(data.links)) { for (const linkData of data.links) { const link = LLink.create(linkData) this._links.set(link.id, link) } } reroutes = data.reroutes } // Reroutes if (Array.isArray(reroutes)) { for (const rerouteData of reroutes) { this.setReroute(rerouteData) } } const nodesData = data.nodes // copy all stored fields for (const i in data) { if (LGraph.ConfigureProperties.has(i)) continue // @ts-expect-error #574 Legacy property assignment this[i] = data[i] } // Subgraph definitions const subgraphs = data.definitions?.subgraphs if (subgraphs) { for (const subgraph of subgraphs) this.createSubgraph(subgraph) for (const subgraph of subgraphs) this.subgraphs.get(subgraph.id)?.configure(subgraph) } let error = false const nodeDataMap = new Map() // create nodes this._nodes = [] if (nodesData) { for (const n_info of nodesData) { // stored info let node = LiteGraph.createNode(String(n_info.type), n_info.title) if (!node) { if (LiteGraph.debug) console.log("Node not found or has errors:", n_info.type) // in case of error we create a replacement node to avoid losing info node = new LGraphNode("") node.last_serialization = n_info node.has_errors = true error = true // continue; } // id it or it will create a new id node.id = n_info.id // add before configure, otherwise configure cannot create links this.add(node, true) nodeDataMap.set(node.id, n_info) } // configure nodes afterwards so they can reach each other for (const [id, nodeData] of nodeDataMap) { this.getNodeById(id)?.configure(nodeData) } } // Floating links if (Array.isArray(data.floatingLinks)) { for (const linkData of data.floatingLinks) { const floatingLink = LLink.create(linkData) this.addFloatingLink(floatingLink) if (floatingLink.id > this.#lastFloatingLinkId) this.#lastFloatingLinkId = floatingLink.id } } // Drop broken reroutes for (const reroute of this.reroutes.values()) { // Drop broken links, and ignore reroutes with no valid links if (!reroute.validateLinks(this._links, this.floatingLinks)) { this.reroutes.delete(reroute.id) } } // groups this._groups.length = 0 const groupData = data.groups if (groupData) { for (const data of groupData) { // TODO: Search/remove these global object refs const group = new LiteGraph.LGraphGroup() group.configure(data) this.add(group) } } this.updateExecutionOrder() this.onConfigure?.(data) this._version++ // Ensure the primary canvas is set to the correct graph const { primaryCanvas } = this const subgraphId = primaryCanvas?.subgraph?.id if (subgraphId) { const subgraph = this.subgraphs.get(subgraphId) if (subgraph) { primaryCanvas.setGraph(subgraph) } else { primaryCanvas.setGraph(this) } } this.setDirtyCanvas(true, true) return error } finally { this.events.dispatch("configured") } } #canvas?: LGraphCanvas get primaryCanvas(): LGraphCanvas | undefined { return this.rootGraph.#canvas } set primaryCanvas(canvas: LGraphCanvas) { this.rootGraph.#canvas = canvas } load(url: string | Blob | URL | File, callback: () => void) { const that = this // from file if (url instanceof Blob || url instanceof File) { const reader = new FileReader() reader.addEventListener("load", function (event) { const result = stringOrEmpty(event.target?.result) const data = JSON.parse(result) that.configure(data) callback?.() }) reader.readAsText(url) return } // is a string, then an URL const req = new XMLHttpRequest() req.open("GET", url, true) req.send(null) req.addEventListener("load", function () { if (req.status !== 200) { console.error("Error loading graph:", req.status, req.response) return } const data = JSON.parse(req.response) that.configure(data) callback?.() }) req.addEventListener("error", (err) => { console.error("Error loading graph:", err) }) } }