mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Replaces planner/classifier/repair/quarantine helpers and their tests with a single proxyWidgetMigration module exercised through black-box round-trip tests. Hook registry indirection replaced with a static LGraph.proxyWidgetMigrationFlush field assigned in main.ts. Includes a real semantic fix: classifier now preserves surviving primitive targets when other targets are dangling. Net: -16 files, ~-2,300 LoC in src/core/graph/subgraph/migration/.
3165 lines
94 KiB
TypeScript
3165 lines
94 KiB
TypeScript
import { toString } from 'es-toolkit/compat'
|
|
|
|
import {
|
|
SUBGRAPH_INPUT_ID,
|
|
SUBGRAPH_OUTPUT_ID
|
|
} from '@/lib/litegraph/src/constants'
|
|
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
|
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
|
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
|
import { forEachNode } from '@/utils/graphTraversalUtil'
|
|
|
|
import {
|
|
groupLinksByTuple,
|
|
purgeOrphanedLinks,
|
|
repairInputLinks,
|
|
selectSurvivorLink
|
|
} from './linkDeduplication'
|
|
|
|
import type { DragAndScaleState } from './DragAndScale'
|
|
import { LGraphCanvas } from './LGraphCanvas'
|
|
import { LGraphGroup } from './LGraphGroup'
|
|
import type { GroupId } from './LGraphGroup'
|
|
import { LGraphNode } from './LGraphNode'
|
|
import type { NodeId } from './LGraphNode'
|
|
import { LLink } from './LLink'
|
|
import type { LinkId, SerialisedLLinkArray } from './LLink'
|
|
import { MapProxyHandler } from './MapProxyHandler'
|
|
import { Reroute } from './Reroute'
|
|
import type { RerouteId } from './Reroute'
|
|
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
|
|
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
|
|
import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
|
import type {
|
|
DefaultConnectionColors,
|
|
Dictionary,
|
|
HasBoundingRect,
|
|
IContextMenuValue,
|
|
INodeInputSlot,
|
|
INodeOutputSlot,
|
|
LinkNetwork,
|
|
LinkSegment,
|
|
MethodNames,
|
|
OptionalProps,
|
|
Point,
|
|
Positionable,
|
|
Size
|
|
} from './interfaces'
|
|
import { LiteGraph, SubgraphNode } from './litegraph'
|
|
import {
|
|
alignOutsideContainer,
|
|
alignToContainer,
|
|
createBounds,
|
|
snapPoint
|
|
} from './measure'
|
|
import { SubgraphInput } from './subgraph/SubgraphInput'
|
|
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
|
import { SubgraphOutput } from './subgraph/SubgraphOutput'
|
|
import { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
|
|
import {
|
|
findUsedSubgraphIds,
|
|
getBoundaryLinks,
|
|
groupResolvedByOutput,
|
|
mapSubgraphInputsAndLinks,
|
|
mapSubgraphOutputsAndLinks,
|
|
multiClone,
|
|
splitPositionables
|
|
} from './subgraph/subgraphUtils'
|
|
import { Alignment, LGraphEventMode } from './types/globalEnums'
|
|
import type {
|
|
LGraphTriggerAction,
|
|
LGraphTriggerEvent,
|
|
LGraphTriggerHandler,
|
|
LGraphTriggerParam
|
|
} from './types/graphTriggers'
|
|
import type {
|
|
ExportedSubgraph,
|
|
ExposedWidget,
|
|
ISerialisedGraph,
|
|
ISerialisedNode,
|
|
Serialisable,
|
|
SerialisableGraph,
|
|
SerialisableReroute
|
|
} from './types/serialisation'
|
|
import { getAllNestedItems } from './utils/collections'
|
|
import {
|
|
deduplicateSubgraphNodeIds,
|
|
topologicalSortSubgraphs
|
|
} from './subgraph/subgraphDeduplication'
|
|
|
|
export type {
|
|
LGraphTriggerAction,
|
|
LGraphTriggerParam
|
|
} from './types/graphTriggers'
|
|
|
|
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
|
|
|
/**
|
|
* Unique identifier for a subgraph definition. Structurally a {@link UUID};
|
|
* provided as a domain-specific alias for clarity at adoption sites.
|
|
*/
|
|
export type SubgraphId = UUID
|
|
|
|
export interface LGraphState {
|
|
lastGroupId: GroupId
|
|
lastNodeId: number
|
|
lastLinkId: LinkId
|
|
lastRerouteId: RerouteId
|
|
}
|
|
|
|
type ParamsArray<T, K extends MethodNames<T>> = Parameters<
|
|
Extract<T[K], (...args: never[]) => unknown>
|
|
>[1] extends undefined
|
|
?
|
|
| Parameters<Extract<T[K], (...args: never[]) => unknown>>
|
|
| Parameters<Extract<T[K], (...args: never[]) => unknown>>[0]
|
|
: Parameters<Extract<T[K], (...args: never[]) => unknown>>
|
|
|
|
/** Configuration used by {@link LGraph} `config`. */
|
|
export interface LGraphConfig {
|
|
/** @deprecated Legacy config - unused */
|
|
align_to_grid?: boolean
|
|
links_ontop?: boolean
|
|
}
|
|
|
|
/** Options for {@link LGraph.add} method. */
|
|
export interface GraphAddOptions {
|
|
/** If true, skip recomputing execution order after adding the node. */
|
|
skipComputeOrder?: boolean
|
|
/** If true, the node will be semi-transparent and follow the cursor until placed or cancelled. */
|
|
ghost?: boolean
|
|
/** Mouse event for ghost placement. Used to position node under cursor. */
|
|
dragEvent?: MouseEvent
|
|
}
|
|
|
|
export interface GroupNodeConfigEntry {
|
|
input?: Record<string, { name?: string; visible?: boolean }>
|
|
output?: Record<number, { name?: string; visible?: boolean }>
|
|
}
|
|
|
|
export interface GroupNodeWorkflowData {
|
|
external: (number | string)[][]
|
|
links: SerialisedLLinkArray[]
|
|
nodes: {
|
|
index?: number
|
|
type?: string
|
|
title?: string
|
|
inputs?: unknown[]
|
|
outputs?: unknown[]
|
|
widgets_values?: unknown[]
|
|
}[]
|
|
config?: Record<number, GroupNodeConfigEntry>
|
|
}
|
|
|
|
export interface LGraphExtra extends Dictionary<unknown> {
|
|
reroutes?: SerialisableReroute[]
|
|
linkExtensions?: { id: LinkId; parentId: RerouteId | undefined }[]
|
|
ds?: DragAndScaleState
|
|
workflowRendererVersion?: RendererType
|
|
groupNodes?: Record<string, GroupNodeWorkflowData>
|
|
}
|
|
|
|
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<SerialisableGraph>
|
|
{
|
|
static serialisedSchemaVersion = 1 as const
|
|
|
|
static STATUS_STOPPED = 1
|
|
static STATUS_RUNNING = 2
|
|
|
|
/**
|
|
* Late-bound migration hook. Set once during app init from the wiring layer
|
|
* to avoid a circular dependency through PreviewExposureStore. Left undefined
|
|
* in tests that exercise `configure()` without the migration pipeline.
|
|
*/
|
|
static proxyWidgetMigrationFlush?: (
|
|
hostNode: SubgraphNode,
|
|
nodeData: ISerialisedNode | undefined
|
|
) => void
|
|
|
|
/** 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<LinkId, LLink> = 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<LinkId, LLink> & Record<LinkId, LLink>
|
|
list_of_graphcanvas: LGraphCanvas[] | null
|
|
status: number = LGraph.STATUS_STOPPED
|
|
|
|
private _state: LGraphState = {
|
|
lastGroupId: 0,
|
|
lastNodeId: 0,
|
|
lastLinkId: 0,
|
|
lastRerouteId: 0
|
|
}
|
|
|
|
get state(): LGraphState {
|
|
return this._state
|
|
}
|
|
|
|
set state(value: LGraphState) {
|
|
this._state = value
|
|
}
|
|
|
|
readonly events = new CustomEventTarget<LGraphEventMap>()
|
|
readonly _subgraphs: Map<SubgraphId, Subgraph> = new Map()
|
|
|
|
_nodes: (LGraphNode | SubgraphNode)[] = []
|
|
_nodes_by_id: Record<NodeId, LGraphNode> = {}
|
|
_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<unknown> = {}
|
|
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<LGraphNode | LGraphGroup | Reroute> {
|
|
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. */
|
|
private _lastFloatingLinkId: number = 0
|
|
|
|
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
|
|
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
|
|
return this.floatingLinksInternal
|
|
}
|
|
|
|
private readonly reroutesInternal = new Map<RerouteId, Reroute>()
|
|
/** All reroutes in this graph. */
|
|
public get reroutes(): Map<RerouteId, Reroute> {
|
|
return this.reroutesInternal
|
|
}
|
|
|
|
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?: LGraphTriggerHandler
|
|
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<unknown> | null)[],
|
|
node: LGraphNode
|
|
): void
|
|
|
|
// @ts-expect-error - Private property type needs fixing
|
|
private _input_nodes?: LGraphNode[]
|
|
|
|
/**
|
|
* See {@link LGraph}
|
|
* @param o data from previous serialization [optional]
|
|
*/
|
|
constructor(o?: ISerialisedGraph | SerialisableGraph) {
|
|
/** @see MapProxyHandler */
|
|
const links = this._links
|
|
MapProxyHandler.bindAllMethods(links)
|
|
const handler = new MapProxyHandler<LLink>()
|
|
this.links = new Proxy(links, handler) as Map<LinkId, LLink> &
|
|
Record<LinkId, LLink>
|
|
|
|
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
|
|
|
|
const graphId = this.id
|
|
if (this.isRootGraph && graphId !== zeroUuid) {
|
|
usePreviewExposureStore().clearGraph(graphId)
|
|
useWidgetValueStore().clearGraph(graphId)
|
|
}
|
|
|
|
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?.()
|
|
this.onNodeRemoved?.(_node)
|
|
}
|
|
}
|
|
|
|
// 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.floatingLinksInternal.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<SubgraphId, Subgraph> {
|
|
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
|
|
// @ts-expect-error - Timer ID type mismatch needs fixing
|
|
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.error('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<LGraphNode> = {}
|
|
// to avoid repeating links
|
|
const visited_links: Record<NodeId, boolean> = {}
|
|
const remaining_links: Record<NodeId, number> = {}
|
|
|
|
// 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.setPos(
|
|
layout == LiteGraph.VERTICAL_LAYOUT ? y : x,
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Increments the internal version counter.
|
|
* Currently only read for debug display in {@link LGraphCanvas.renderInfo}.
|
|
* Centralized so a future VersionSystem can intercept, batch, or replace it.
|
|
*/
|
|
incrementVersion(): void {
|
|
this._version++
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
// eslint-disable-next-line prefer-spread
|
|
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<T extends MethodNames<LGraphCanvas>>(
|
|
action: T,
|
|
params?: ParamsArray<LGraphCanvas, T>
|
|
): void {
|
|
const { list_of_graphcanvas } = this
|
|
if (!list_of_graphcanvas) return
|
|
|
|
for (const c of list_of_graphcanvas) {
|
|
const method = c[action]
|
|
|
|
if (typeof method === 'function') {
|
|
const args =
|
|
params == null ? [] : Array.isArray(params) ? params : [params]
|
|
;(method as (...args: unknown[]) => unknown).apply(c, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a new node instance to this graph
|
|
* @param node the instance of the node
|
|
* @param options Additional options for adding the node
|
|
*/
|
|
add(
|
|
node: LGraphNode | LGraphGroup,
|
|
options?: GraphAddOptions
|
|
): LGraphNode | null | undefined
|
|
/**
|
|
* Adds a new node instance to this graph
|
|
* @param node the instance of the node
|
|
* @param skipComputeOrder If true, skip recomputing execution order
|
|
* @deprecated Use options object instead
|
|
*/
|
|
add(
|
|
node: LGraphNode | LGraphGroup | null,
|
|
skipComputeOrder?: boolean
|
|
): LGraphNode | null | undefined
|
|
add(
|
|
node: LGraphNode | LGraphGroup,
|
|
skipComputeOrderOrOptions?: boolean | GraphAddOptions
|
|
): LGraphNode | null | undefined {
|
|
if (!node) return
|
|
|
|
// Handle backwards compatibility: 2nd arg can be boolean or options
|
|
const opts: GraphAddOptions =
|
|
typeof skipComputeOrderOrOptions === 'object'
|
|
? skipComputeOrderOrOptions
|
|
: { skipComputeOrder: skipComputeOrderOrOptions ?? false }
|
|
const shouldSkipComputeOrder = opts.skipComputeOrder ?? false
|
|
|
|
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.incrementVersion()
|
|
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
|
|
}
|
|
}
|
|
|
|
// Set ghost flag before registration so VueNodeData picks it up
|
|
if (opts.ghost) {
|
|
node.flags.ghost = true
|
|
}
|
|
|
|
node.graph = this
|
|
this.incrementVersion()
|
|
|
|
// Register all widgets with the WidgetValueStore now that node has a
|
|
// valid ID and graph reference.
|
|
if (node.widgets) {
|
|
for (const widget of node.widgets) {
|
|
if (isNodeBindable(widget)) widget.setNodeId(node.id)
|
|
}
|
|
}
|
|
|
|
this._nodes.push(node)
|
|
this._nodes_by_id[node.id] = node
|
|
|
|
node.onAdded?.(this)
|
|
|
|
if (this.config.align_to_grid) node.alignToGrid()
|
|
|
|
if (!shouldSkipComputeOrder) this.updateExecutionOrder()
|
|
|
|
this.onNodeAdded?.(node)
|
|
|
|
this.setDirtyCanvas(true)
|
|
this.change()
|
|
|
|
if (opts.ghost) {
|
|
this.canvasAction((c) => c.startGhostPlacement(node, opts.dragEvent))
|
|
}
|
|
|
|
if (node.isSubgraphNode?.()) {
|
|
forEachNode(node.subgraph, (innerNode) => {
|
|
if (innerNode.isSubgraphNode())
|
|
this.subgraphs.set(innerNode.subgraph.id, innerNode.subgraph)
|
|
})
|
|
}
|
|
|
|
// 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.incrementVersion()
|
|
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)
|
|
}
|
|
}
|
|
|
|
if (node.isSubgraphNode()) {
|
|
const allGraphs = [this.rootGraph, ...this.rootGraph.subgraphs.values()]
|
|
const hasRemainingReferences = allGraphs.some((graph) =>
|
|
graph.nodes.some(
|
|
(candidate) =>
|
|
candidate !== node &&
|
|
candidate.isSubgraphNode() &&
|
|
candidate.type === node.subgraph.id
|
|
)
|
|
)
|
|
|
|
if (!hasRemainingReferences) {
|
|
forEachNode(node.subgraph, (innerNode) => {
|
|
innerNode.onRemoved?.()
|
|
innerNode.graph?.onNodeRemoved?.(innerNode)
|
|
})
|
|
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
|
}
|
|
}
|
|
|
|
// callback
|
|
node.onRemoved?.()
|
|
|
|
node.graph = null
|
|
this.incrementVersion()
|
|
|
|
// 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 {
|
|
// Iterate backwards through groups to find top-most
|
|
for (let i = this._groups.length - 1; i >= 0; i--) {
|
|
const group = this._groups[i]
|
|
if (group.isPointInside(x, y)) {
|
|
return group
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Iterate backwards through groups to find top-most
|
|
for (let i = this._groups.length - 1; i >= 0; i--) {
|
|
const group = this._groups[i]
|
|
if (group.isPointInTitlebar(x, y)) {
|
|
return group
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
): 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 rounded 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<Positionable>): 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.warn('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<A extends LGraphTriggerAction>(
|
|
action: A,
|
|
param: LGraphTriggerParam<A>
|
|
): void
|
|
trigger(action: string, param: unknown): void
|
|
trigger(action: string, param: unknown) {
|
|
// Convert to discriminated union format for typed handlers
|
|
const validEventTypes = new Set([
|
|
'node:slot-links:changed',
|
|
'node:slot-errors:changed',
|
|
'node:property:changed',
|
|
'node:slot-label:changed'
|
|
])
|
|
|
|
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
|
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
|
}
|
|
// Don't handle unknown events - just ignore them
|
|
}
|
|
|
|
/** @todo Clean up - never implemented. */
|
|
triggerInput(name: string, value: unknown): void {
|
|
const nodes = this.findNodesByTitle(name)
|
|
for (const node of nodes) {
|
|
// @ts-expect-error - onTrigger method may not exist on all node types
|
|
node.onTrigger(value)
|
|
}
|
|
}
|
|
|
|
/** @todo Clean up - never implemented. */
|
|
setCallback(name: string, func?: () => void): void {
|
|
const nodes = this.findNodesByTitle(name)
|
|
for (const node of nodes) {
|
|
// @ts-expect-error - setTrigger method may not exist on all node types
|
|
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 {
|
|
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.floatingLinksInternal.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.floatingLinksInternal.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<SerialisableReroute, 'id'>): 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 layoutMutations = useLayoutMutations()
|
|
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)
|
|
|
|
// Register reroute in Layout Store for spatial tracking
|
|
layoutMutations.setSource(LayoutSource.Canvas)
|
|
layoutMutations.createReroute(
|
|
rerouteId,
|
|
{ x: pos[0], y: pos[1] },
|
|
before.parentId,
|
|
Array.from(linkIds)
|
|
)
|
|
|
|
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 layoutMutations = useLayoutMutations()
|
|
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)
|
|
|
|
// Delete reroute from Layout Store
|
|
layoutMutations.setSource(LayoutSource.Canvas)
|
|
layoutMutations.deleteReroute(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)
|
|
}
|
|
|
|
/**
|
|
* Removes duplicate links that share the same connection tuple
|
|
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
|
|
* referenced by input.link and removes orphaned duplicates from
|
|
* output.links and the graph's _links map.
|
|
*
|
|
* Three phases: group links by tuple, select the survivor, purge duplicates.
|
|
*/
|
|
_removeDuplicateLinks(): void {
|
|
const groups = groupLinksByTuple(this._links)
|
|
|
|
for (const ids of groups.values()) {
|
|
if (ids.length <= 1) continue
|
|
|
|
const sampleLink = this._links.get(ids[0])!
|
|
const node = this.getNodeById(sampleLink.target_id)
|
|
const keepId = selectSurvivorLink(ids, node)
|
|
|
|
purgeOrphanedLinks(ids, keepId, this._links, (id) => this.getNodeById(id))
|
|
repairInputLinks(ids, keepId, node)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<Positionable>): {
|
|
subgraph: Subgraph
|
|
node: SubgraphNode
|
|
} {
|
|
if (items.size === 0)
|
|
throw new Error('Cannot convert to subgraph: nothing to convert')
|
|
|
|
// Record state before conversion for proper undo support
|
|
this.beforeChange()
|
|
|
|
try {
|
|
return this._convertToSubgraphImpl(items)
|
|
} finally {
|
|
// Mark state change complete for proper undo support
|
|
this.afterChange()
|
|
}
|
|
}
|
|
|
|
private _convertToSubgraphImpl(items: Set<Positionable>): {
|
|
subgraph: Subgraph
|
|
node: SubgraphNode
|
|
} {
|
|
const { state, revision, config } = this
|
|
const firstChild = [...items][0]
|
|
if (items.size === 1 && firstChild instanceof LGraphGroup) {
|
|
items = new Set([firstChild])
|
|
firstChild.recomputeInsideNodes()
|
|
firstChild.children.forEach((n) => items.add(n))
|
|
}
|
|
|
|
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 internalReroutes = new Map([...reroutes].map((r) => [r.id, r]))
|
|
const externalReroutes = new Map(
|
|
[...this.reroutes].filter(([id]) => !internalReroutes.has(id))
|
|
)
|
|
const inputs = mapSubgraphInputsAndLinks(
|
|
resolvedInputLinks,
|
|
links,
|
|
internalReroutes
|
|
)
|
|
const outputs = mapSubgraphOutputsAndLinks(
|
|
resolvedOutputLinks,
|
|
links,
|
|
externalReroutes
|
|
)
|
|
|
|
// 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)
|
|
for (const node of subgraph.nodes) node.onGraphConfigured?.()
|
|
for (const node of subgraph.nodes) node.onAfterGraphConfigured?.()
|
|
|
|
// 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, {
|
|
outputs: structuredClone(outputs)
|
|
})
|
|
if (!subgraphNode) throw new Error('Failed to create subgraph node')
|
|
for (let i = 0; i < inputs.length; i++) {
|
|
Object.assign(subgraphNode.inputs[i], inputs[i])
|
|
}
|
|
|
|
// Resize to inputs/outputs
|
|
subgraphNode.setSize(subgraphNode.computeSize())
|
|
|
|
// Center the subgraph node
|
|
alignToContainer(
|
|
subgraphNode._posSize,
|
|
Alignment.Centre | Alignment.Middle,
|
|
boundingRect
|
|
)
|
|
|
|
//Correct for title height. It's included in bounding box, but not _posSize
|
|
subgraphNode.setPos(
|
|
subgraphNode.pos[0],
|
|
subgraphNode.pos[1] + LiteGraph.NODE_TITLE_HEIGHT / 2
|
|
)
|
|
|
|
// 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')
|
|
}
|
|
|
|
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.inputs[i - 1]
|
|
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()) {
|
|
i++
|
|
for (const connection of connections) {
|
|
const { input, inputNode, link, subgraphOutput } = connection
|
|
// Special handling: Subgraph output node
|
|
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)
|
|
}
|
|
}
|
|
|
|
subgraphNode._setConcreteSlots()
|
|
subgraphNode.arrange()
|
|
|
|
this.canvasAction((c) =>
|
|
c.canvas.dispatchEvent(
|
|
new CustomEvent('subgraph-converted', {
|
|
bubbles: true,
|
|
detail: { subgraphNode: subgraphNode as SubgraphNode }
|
|
})
|
|
)
|
|
)
|
|
|
|
return { subgraph, node: subgraphNode as SubgraphNode }
|
|
}
|
|
|
|
unpackSubgraph(
|
|
subgraphNode: SubgraphNode,
|
|
options?: { skipMissingNodes?: boolean }
|
|
) {
|
|
if (!(subgraphNode instanceof SubgraphNode))
|
|
throw new Error('Can only unpack Subgraph Nodes')
|
|
|
|
// Record state before unpacking for proper undo support
|
|
this.beforeChange()
|
|
|
|
try {
|
|
this._unpackSubgraphImpl(subgraphNode, options)
|
|
} finally {
|
|
// Mark state change complete for proper undo support
|
|
this.afterChange()
|
|
}
|
|
}
|
|
|
|
private _unpackSubgraphImpl(
|
|
subgraphNode: SubgraphNode,
|
|
options?: { skipMissingNodes?: boolean }
|
|
) {
|
|
const skipMissingNodes = options?.skipMissingNodes ?? false
|
|
|
|
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
|
|
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
|
|
const positionables = [
|
|
...subgraphNode.subgraph.nodes,
|
|
...subgraphNode.subgraph.reroutes.values(),
|
|
...subgraphNode.subgraph.groups
|
|
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
|
|
return {
|
|
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
|
|
}
|
|
})
|
|
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
|
|
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
|
|
|
|
const toSelect: Positionable[] = []
|
|
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
|
|
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
|
|
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
|
|
const nodeIdMap = new Map<NodeId, NodeId>()
|
|
for (const n_info of movedNodes) {
|
|
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
|
if (!node) {
|
|
if (skipMissingNodes) {
|
|
console.warn(
|
|
`Cannot unpack node of type "${n_info.type}" - node type not found. Creating placeholder node.`
|
|
)
|
|
node = new LGraphNode(n_info.title || n_info.type || 'Missing Node')
|
|
node.last_serialization = n_info
|
|
node.has_errors = true
|
|
node.type = String(n_info.type)
|
|
} else {
|
|
throw new Error(
|
|
`Cannot unpack: node type "${n_info.type}" is not registered`
|
|
)
|
|
}
|
|
}
|
|
|
|
nodeIdMap.set(n_info.id, ++this.last_node_id)
|
|
node.id = this.last_node_id
|
|
n_info.id = this.last_node_id
|
|
|
|
// Strip links from serialized data before configure to prevent
|
|
// onConnectionsChange from resolving subgraph-internal link IDs
|
|
// against the parent graph's link map (which may contain unrelated
|
|
// links with the same numeric IDs).
|
|
for (const input of n_info.inputs ?? []) {
|
|
input.link = null
|
|
}
|
|
for (const output of n_info.outputs ?? []) {
|
|
output.links = []
|
|
}
|
|
|
|
this.add(node, true)
|
|
node.configure(n_info)
|
|
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
|
|
toSelect.push(node)
|
|
}
|
|
const groups = structuredClone(
|
|
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
|
|
)
|
|
for (const g_info of groups) {
|
|
const group = new LGraphGroup(g_info.title, g_info.id)
|
|
this.add(group, true)
|
|
group.configure(g_info)
|
|
group.pos[0] += offsetX
|
|
group.pos[1] += offsetY
|
|
toSelect.push(group)
|
|
}
|
|
//cleanup reoute.linkIds now, but leave link.parentIds dangling
|
|
for (const islot of subgraphNode.inputs) {
|
|
if (!islot.link) continue
|
|
const link = this.links.get(islot.link)
|
|
if (!link) {
|
|
console.warn('Broken link', islot, islot.link)
|
|
continue
|
|
}
|
|
for (const reroute of LLink.getReroutes(this, link)) {
|
|
reroute.linkIds.delete(link.id)
|
|
}
|
|
}
|
|
for (const oslot of subgraphNode.outputs) {
|
|
for (const linkId of oslot.links ?? []) {
|
|
const link = this.links.get(linkId)
|
|
if (!link) {
|
|
console.warn('Broken link', oslot, linkId)
|
|
continue
|
|
}
|
|
for (const reroute of LLink.getReroutes(this, link)) {
|
|
reroute.linkIds.delete(link.id)
|
|
}
|
|
}
|
|
}
|
|
const newLinks: {
|
|
oid: NodeId
|
|
oslot: number
|
|
tid: NodeId
|
|
tslot: number
|
|
id: LinkId
|
|
iparent?: RerouteId
|
|
eparent?: RerouteId
|
|
externalFirst: boolean
|
|
}[] = []
|
|
for (const [, link] of subgraphNode.subgraph._links) {
|
|
let externalParentId: RerouteId | undefined
|
|
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
|
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
|
|
if (!outerLinkId) {
|
|
console.error('Missing Link ID when unpacking')
|
|
continue
|
|
}
|
|
const outerLink = this.links[outerLinkId]
|
|
link.origin_id = outerLink.origin_id
|
|
link.origin_slot = outerLink.origin_slot
|
|
externalParentId = outerLink.parentId
|
|
} else {
|
|
const origin_id = nodeIdMap.get(link.origin_id)
|
|
if (!origin_id) {
|
|
console.error('Missing Link ID when unpacking')
|
|
continue
|
|
}
|
|
link.origin_id = origin_id
|
|
}
|
|
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
|
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
|
[]) {
|
|
const sublink = this.links[linkId]
|
|
newLinks.push({
|
|
oid: link.origin_id,
|
|
oslot: link.origin_slot,
|
|
tid: sublink.target_id,
|
|
tslot: sublink.target_slot,
|
|
id: link.id,
|
|
iparent: link.parentId,
|
|
eparent: sublink.parentId,
|
|
externalFirst: true
|
|
})
|
|
sublink.parentId = undefined
|
|
}
|
|
continue
|
|
} else {
|
|
const target_id = nodeIdMap.get(link.target_id)
|
|
if (!target_id) {
|
|
console.error('Missing Link ID when unpacking')
|
|
continue
|
|
}
|
|
link.target_id = target_id
|
|
}
|
|
newLinks.push({
|
|
oid: link.origin_id,
|
|
oslot: link.origin_slot,
|
|
tid: link.target_id,
|
|
tslot: link.target_slot,
|
|
id: link.id,
|
|
iparent: link.parentId,
|
|
eparent: externalParentId,
|
|
externalFirst: false
|
|
})
|
|
}
|
|
this.remove(subgraphNode)
|
|
|
|
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
|
|
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
|
|
const seenLinks = new Set<string>()
|
|
const dedupedNewLinks = newLinks.filter((link) => {
|
|
const key = `${link.oid}\0${link.oslot}\0${link.tid}\0${link.tslot}`
|
|
if (seenLinks.has(key)) return false
|
|
seenLinks.add(key)
|
|
return true
|
|
})
|
|
|
|
const linkIdMap = new Map<LinkId, LinkId[]>()
|
|
for (const newLink of dedupedNewLinks) {
|
|
let created: LLink | null | undefined
|
|
if (newLink.oid == SUBGRAPH_INPUT_ID) {
|
|
if (!(this instanceof Subgraph)) {
|
|
console.error('Ignoring link to subgraph outside subgraph')
|
|
continue
|
|
}
|
|
const tnode = this._nodes_by_id[newLink.tid]
|
|
created = this.inputNode.slots[newLink.oslot].connect(
|
|
tnode.inputs[newLink.tslot],
|
|
tnode
|
|
)
|
|
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
|
|
if (!(this instanceof Subgraph)) {
|
|
console.error('Ignoring link to subgraph outside subgraph')
|
|
continue
|
|
}
|
|
const tnode = this._nodes_by_id[newLink.oid]
|
|
created = this.outputNode.slots[newLink.tslot].connect(
|
|
tnode.outputs[newLink.oslot],
|
|
tnode
|
|
)
|
|
} else {
|
|
created = this._nodes_by_id[newLink.oid].connect(
|
|
newLink.oslot,
|
|
this._nodes_by_id[newLink.tid],
|
|
newLink.tslot
|
|
)
|
|
}
|
|
if (!created) {
|
|
console.error('Failed to create link')
|
|
continue
|
|
}
|
|
//This is a little unwieldy since Map.has isn't a type guard
|
|
const linkIds = linkIdMap.get(newLink.id) ?? []
|
|
linkIds.push(created.id)
|
|
if (!linkIdMap.has(newLink.id)) {
|
|
linkIdMap.set(newLink.id, linkIds)
|
|
}
|
|
newLink.id = created.id
|
|
}
|
|
const rerouteIdMap = new Map<RerouteId, RerouteId>()
|
|
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
|
|
if (
|
|
reroute.parentId !== undefined &&
|
|
rerouteIdMap.get(reroute.parentId) === undefined
|
|
) {
|
|
console.error('Missing Parent ID')
|
|
}
|
|
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
|
|
reroute.pos[0] + offsetX,
|
|
reroute.pos[1] + offsetY
|
|
])
|
|
rerouteIdMap.set(reroute.id, migratedReroute.id)
|
|
this.reroutes.set(migratedReroute.id, migratedReroute)
|
|
toSelect.push(migratedReroute)
|
|
}
|
|
//iterate over newly created links to update reroute parentIds
|
|
for (const newLink of dedupedNewLinks) {
|
|
const linkInstance = this.links.get(newLink.id)
|
|
if (!linkInstance) {
|
|
continue
|
|
}
|
|
let instance: Reroute | LLink | undefined = linkInstance
|
|
let parentId: RerouteId | undefined = undefined
|
|
if (newLink.externalFirst) {
|
|
parentId = newLink.eparent
|
|
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
|
|
while (parentId) {
|
|
instance.parentId = parentId
|
|
instance = this.reroutes.get(parentId)
|
|
if (!instance) {
|
|
console.error('Broken Id link when unpacking')
|
|
break
|
|
}
|
|
if (instance.linkIds.has(linkInstance.id))
|
|
throw new Error('Infinite parentId loop')
|
|
instance.linkIds.add(linkInstance.id)
|
|
parentId = instance.parentId
|
|
}
|
|
}
|
|
if (!instance) continue
|
|
parentId = newLink.iparent
|
|
while (parentId) {
|
|
const migratedId = rerouteIdMap.get(parentId)
|
|
if (!migratedId) {
|
|
console.error('Broken Id link when unpacking')
|
|
break
|
|
}
|
|
instance.parentId = migratedId
|
|
instance = this.reroutes.get(migratedId)
|
|
if (!instance) {
|
|
console.error('Broken Id link when unpacking')
|
|
break
|
|
}
|
|
if (instance.linkIds.has(linkInstance.id))
|
|
throw new Error('Infinite parentId loop')
|
|
instance.linkIds.add(linkInstance.id)
|
|
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
|
|
if (!oldReroute) {
|
|
console.error('Broken Id link when unpacking')
|
|
break
|
|
}
|
|
parentId = oldReroute.parentId
|
|
}
|
|
if (!instance) break
|
|
if (!newLink.externalFirst) {
|
|
parentId = newLink.eparent
|
|
while (parentId) {
|
|
instance.parentId = parentId
|
|
instance = this.reroutes.get(parentId)
|
|
if (!instance) {
|
|
console.error('Broken Id link when unpacking')
|
|
break
|
|
}
|
|
if (instance.linkIds.has(linkInstance.id))
|
|
throw new Error('Infinite parentId loop')
|
|
instance.linkIds.add(linkInstance.id)
|
|
parentId = instance.parentId
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const nodeId of nodeIdMap.values()) {
|
|
const node = this._nodes_by_id[nodeId]
|
|
node._setConcreteSlots()
|
|
node.arrange()
|
|
}
|
|
|
|
this.canvasAction((c) => c.selectItems(toSelect))
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom JSON serialization to prevent circular reference errors.
|
|
* Called automatically by JSON.stringify().
|
|
*/
|
|
toJSON(): ISerialisedGraph {
|
|
return this.serialize()
|
|
}
|
|
|
|
/** @returns The drag and scale state of the first attached canvas, otherwise `undefined`. */
|
|
private _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<Pick<SerialisableGraph, 'nodes' | 'groups' | 'extra'>> {
|
|
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<typeof this.asSerialisable> = {
|
|
id,
|
|
revision,
|
|
version: LGraph.serialisedSchemaVersion,
|
|
config,
|
|
state,
|
|
groups,
|
|
nodes,
|
|
links,
|
|
floatingLinks,
|
|
reroutes,
|
|
extra
|
|
}
|
|
|
|
if (this.isRootGraph && this._subgraphs.size) {
|
|
const usedSubgraphIds = findUsedSubgraphIds(this, this._subgraphs)
|
|
const usedSubgraphs = [...this._subgraphs.values()]
|
|
.filter((subgraph) => usedSubgraphIds.has(subgraph.id))
|
|
.map((x) => x.asSerialisable())
|
|
if (usedSubgraphs.length > 0) {
|
|
data.definitions = { subgraphs: usedSubgraphs }
|
|
}
|
|
}
|
|
|
|
this.onSerialize?.(data)
|
|
return data
|
|
}
|
|
|
|
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
|
|
const { id, extra } = data
|
|
|
|
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
|
|
if (id && !(this.isRootGraph && id === zeroUuid)) {
|
|
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 layoutMutations = useLayoutMutations()
|
|
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 - use max to prevent ID collisions across root and subgraphs
|
|
if (data.state) {
|
|
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } =
|
|
data.state
|
|
const { state } = this
|
|
if (lastGroupId != null)
|
|
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
|
|
if (lastLinkId != null)
|
|
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
|
|
if (lastNodeId != null)
|
|
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
|
|
if (lastRerouteId != null)
|
|
state.lastRerouteId = Math.max(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 — deduplicate node IDs before configuring.
|
|
// deduplicateSubgraphNodeIds clones internally to avoid mutating
|
|
// the caller's data (e.g. reactive Pinia state).
|
|
const subgraphs = data.definitions?.subgraphs
|
|
let effectiveNodesData = nodesData
|
|
if (subgraphs) {
|
|
const reservedNodeIds = new Set<number>()
|
|
for (const node of this._nodes) {
|
|
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
|
}
|
|
for (const sg of this.subgraphs.values()) {
|
|
for (const node of sg.nodes) {
|
|
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
|
}
|
|
}
|
|
for (const n of nodesData ?? []) {
|
|
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
|
|
}
|
|
|
|
const deduplicated = this.isRootGraph
|
|
? deduplicateSubgraphNodeIds(
|
|
subgraphs,
|
|
reservedNodeIds,
|
|
this.state,
|
|
nodesData
|
|
)
|
|
: undefined
|
|
|
|
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
|
|
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
|
|
|
|
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
|
|
|
|
// Configure in leaf-first order so that when a SubgraphNode is
|
|
// configured, its referenced subgraph definition already has its
|
|
// nodes/links/inputs populated.
|
|
const configureOrder = topologicalSortSubgraphs(finalSubgraphs)
|
|
for (const subgraph of configureOrder)
|
|
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
|
}
|
|
|
|
let error = false
|
|
const nodeDataMap = new Map<NodeId, ISerialisedNode>()
|
|
|
|
// create nodes
|
|
this._nodes = []
|
|
if (effectiveNodesData) {
|
|
for (const n_info of effectiveNodesData) {
|
|
// stored info
|
|
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
|
if (!node) {
|
|
if (LiteGraph.debug)
|
|
console.warn('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) {
|
|
const node = this.getNodeById(id)
|
|
node?.configure(nodeData)
|
|
|
|
if (LiteGraph.alwaysSnapToGrid && node) {
|
|
const snapTo = this.getSnapToGridSize()
|
|
if (node.snapToGrid(snapTo)) {
|
|
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
|
|
// This reassignment triggers the pos setter to sync to the Vue layout store
|
|
node.pos = [node.pos[0], node.pos[1]]
|
|
}
|
|
snapPoint(node.size, snapTo, 'ceil')
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
// Clean up layout store
|
|
layoutMutations.setSource(LayoutSource.Canvas)
|
|
layoutMutations.deleteReroute(reroute.id)
|
|
}
|
|
}
|
|
|
|
// Remove duplicate links: links in output.links that share the same
|
|
// (origin_id, origin_slot, target_id, target_slot) tuple.
|
|
// This repairs corrupted data where extra link objects were created
|
|
// without proper cleanup of the previous connection.
|
|
this._removeDuplicateLinks()
|
|
|
|
// 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()
|
|
|
|
// ADR 0009: forward-ratchet legacy properties.proxyWidgets on each
|
|
// host SubgraphNode. Late-bound hook (registered in app init) so the
|
|
// LGraph layer doesn't pull in the PreviewExposureStore at module
|
|
// load — that would create a circular dependency.
|
|
for (const node of this._nodes) {
|
|
if (!(node instanceof SubgraphNode)) continue
|
|
if (node.properties?.proxyWidgets === undefined) continue
|
|
const nodeData = nodeDataMap.get(node.id)
|
|
if (LGraph.proxyWidgetMigrationFlush) {
|
|
LGraph.proxyWidgetMigrationFlush(node, nodeData)
|
|
} else if (
|
|
node.properties.proxyWidgets !== undefined &&
|
|
(import.meta.env.DEV || import.meta.env.MODE === 'test')
|
|
) {
|
|
console.warn(
|
|
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
|
|
{
|
|
hostNodeId: node.id,
|
|
proxyWidgets: node.properties.proxyWidgets
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
this.onConfigure?.(data)
|
|
this.incrementVersion()
|
|
|
|
// 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')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures all node IDs are globally unique across the root graph and all
|
|
* subgraphs. Reassigns any colliding IDs found in subgraphs, preserving
|
|
* root graph IDs as canonical. Updates link references (`origin_id`,
|
|
* `target_id`) within the affected graph to match the new node IDs.
|
|
*/
|
|
ensureGlobalIdUniqueness(reservedNodeIds?: Iterable<number>): void {
|
|
const { state } = this
|
|
|
|
const allGraphs: LGraph[] = [this, ...this._subgraphs.values()]
|
|
|
|
const usedNodeIds = new Set<number>(reservedNodeIds)
|
|
for (const graph of allGraphs) {
|
|
const remappedIds = new Map<NodeId, NodeId>()
|
|
|
|
for (const node of graph._nodes) {
|
|
if (typeof node.id !== 'number') continue
|
|
|
|
if (usedNodeIds.has(node.id)) {
|
|
const oldId = node.id
|
|
while (usedNodeIds.has(++state.lastNodeId));
|
|
const newId = state.lastNodeId
|
|
delete graph._nodes_by_id[oldId]
|
|
node.id = newId
|
|
graph._nodes_by_id[newId] = node
|
|
usedNodeIds.add(newId)
|
|
remappedIds.set(oldId, newId)
|
|
console.warn(
|
|
`LiteGraph: duplicate node ID ${oldId} reassigned to ${newId} in graph ${graph.id}`
|
|
)
|
|
} else {
|
|
usedNodeIds.add(node.id as number)
|
|
if ((node.id as number) > state.lastNodeId)
|
|
state.lastNodeId = node.id as number
|
|
}
|
|
}
|
|
|
|
if (remappedIds.size > 0) {
|
|
patchLinkNodeIds(graph._links, remappedIds)
|
|
patchLinkNodeIds(graph.floatingLinksInternal, remappedIds)
|
|
}
|
|
}
|
|
}
|
|
|
|
private _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) {
|
|
// from file
|
|
if (url instanceof Blob || url instanceof File) {
|
|
const reader = new FileReader()
|
|
reader.addEventListener('load', (event) => {
|
|
const result = toString(event.target?.result)
|
|
const data = JSON.parse(result)
|
|
this.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', () => {
|
|
if (req.status !== 200) {
|
|
console.error('Error loading graph:', req.status, req.response)
|
|
return
|
|
}
|
|
const data = JSON.parse(req.response)
|
|
this.configure(data)
|
|
callback?.()
|
|
})
|
|
req.addEventListener('error', (err) => {
|
|
console.error('Error loading graph:', err)
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Internal; simplifies type definitions. */
|
|
export type GraphOrSubgraph = LGraph | Subgraph
|
|
|
|
// ============================================================================
|
|
// TEMPORARY: Subgraph class moved here to resolve circular dependency
|
|
// This is a temporary solution until the architecture can be refactored
|
|
// TODO: Move back to separate file once circular dependencies are resolved
|
|
// ============================================================================
|
|
|
|
/** A subgraph definition. */
|
|
export class Subgraph
|
|
extends LGraph
|
|
implements BaseLGraph, Serialisable<ExportedSubgraph>
|
|
{
|
|
override readonly events = new CustomEventTarget<SubgraphEventMap>()
|
|
|
|
/** Limits the number of levels / depth that subgraphs may be nested. Prevents uncontrolled programmatic nesting. */
|
|
static MAX_NESTED_SUBGRAPHS = 1000
|
|
|
|
/** The display name of the subgraph. */
|
|
name: string = 'Unnamed Subgraph'
|
|
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
|
description?: string
|
|
|
|
readonly inputNode = new SubgraphInputNode(this)
|
|
readonly outputNode = new SubgraphOutputNode(this)
|
|
|
|
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
|
readonly inputs: SubgraphInput[] = []
|
|
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
|
readonly outputs: SubgraphOutput[] = []
|
|
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
|
readonly widgets: ExposedWidget[] = []
|
|
|
|
private _rootGraph: LGraph
|
|
override get rootGraph(): LGraph {
|
|
return this._rootGraph
|
|
}
|
|
|
|
override get state(): LGraphState {
|
|
return this._rootGraph.state
|
|
}
|
|
|
|
override set state(_value: LGraphState) {
|
|
// No-op: subgraphs share the root graph's state.
|
|
}
|
|
|
|
constructor(rootGraph: LGraph, data: ExportedSubgraph) {
|
|
if (!rootGraph) throw new Error('Root graph is required')
|
|
|
|
super()
|
|
|
|
this._rootGraph = rootGraph
|
|
|
|
const cloned = structuredClone(data)
|
|
this._configureBase(cloned)
|
|
this._configureSubgraph(cloned)
|
|
}
|
|
|
|
getIoNodeOnPos(
|
|
x: number,
|
|
y: number
|
|
): SubgraphInputNode | SubgraphOutputNode | undefined {
|
|
const { inputNode, outputNode } = this
|
|
if (inputNode.containsPoint([x, y])) return inputNode
|
|
if (outputNode.containsPoint([x, y])) return outputNode
|
|
}
|
|
|
|
private _configureSubgraph(
|
|
data:
|
|
| (ISerialisedGraph & ExportedSubgraph)
|
|
| (SerialisableGraph & ExportedSubgraph)
|
|
): void {
|
|
const { name, description, inputs, outputs, widgets } = data
|
|
|
|
this.name = name
|
|
this.description = description
|
|
if (inputs) {
|
|
this.inputs.length = 0
|
|
for (const input of inputs) {
|
|
const subgraphInput = new SubgraphInput(input, this.inputNode)
|
|
this.inputs.push(subgraphInput)
|
|
this.events.dispatch('input-added', { input: subgraphInput })
|
|
}
|
|
}
|
|
|
|
if (outputs) {
|
|
this.outputs.length = 0
|
|
for (const output of outputs) {
|
|
this.outputs.push(new SubgraphOutput(output, this.outputNode))
|
|
}
|
|
}
|
|
|
|
// Repair IO slot linkIds that reference links removed by
|
|
// _removeDuplicateLinks during super.configure().
|
|
this._repairIOSlotLinkIds()
|
|
|
|
if (widgets) {
|
|
this.widgets.length = 0
|
|
for (const widget of widgets) {
|
|
this.widgets.push(widget)
|
|
}
|
|
}
|
|
|
|
this.inputNode.configure(data.inputNode)
|
|
this.outputNode.configure(data.outputNode)
|
|
for (const node of this.nodes) node.updateComputedDisabled()
|
|
}
|
|
|
|
override configure(
|
|
data:
|
|
| (ISerialisedGraph & ExportedSubgraph)
|
|
| (SerialisableGraph & ExportedSubgraph),
|
|
keep_old?: boolean
|
|
): boolean | undefined {
|
|
const r = super.configure(data, keep_old)
|
|
|
|
this._configureSubgraph(data)
|
|
return r
|
|
}
|
|
|
|
/**
|
|
* Repairs SubgraphInput/Output `linkIds` that reference links removed
|
|
* by `_removeDuplicateLinks` during `super.configure()`.
|
|
*
|
|
* For each stale link ID, finds the surviving link that connects to the
|
|
* same IO node and slot index, and substitutes it.
|
|
*/
|
|
private _repairIOSlotLinkIds(): void {
|
|
for (const [slotIndex, slot] of this.inputs.entries()) {
|
|
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_INPUT_ID, slotIndex)
|
|
}
|
|
for (const [slotIndex, slot] of this.outputs.entries()) {
|
|
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_OUTPUT_ID, slotIndex)
|
|
}
|
|
}
|
|
|
|
private _repairSlotLinkIds(
|
|
linkIds: LinkId[],
|
|
ioNodeId: number,
|
|
slotIndex: number
|
|
): void {
|
|
const repaired = linkIds.map((id) =>
|
|
this._links.has(id)
|
|
? id
|
|
: (this._findLinkBySlot(ioNodeId, slotIndex)?.id ?? id)
|
|
)
|
|
repaired.forEach((id, i) => {
|
|
linkIds[i] = id
|
|
})
|
|
}
|
|
|
|
private _findLinkBySlot(
|
|
nodeId: number,
|
|
slotIndex: number
|
|
): LLink | undefined {
|
|
for (const link of this._links.values()) {
|
|
if (
|
|
(link.origin_id === nodeId && link.origin_slot === slotIndex) ||
|
|
(link.target_id === nodeId && link.target_slot === slotIndex)
|
|
)
|
|
return link
|
|
}
|
|
}
|
|
|
|
override attachCanvas(canvas: LGraphCanvas): void {
|
|
super.attachCanvas(canvas)
|
|
canvas.subgraph = this
|
|
}
|
|
|
|
addInput(name: string, type: string): SubgraphInput {
|
|
if (name === null || type === null) {
|
|
throw new Error('Name and type are required for subgraph input')
|
|
}
|
|
|
|
this.events.dispatch('adding-input', { name, type })
|
|
|
|
const input = new SubgraphInput(
|
|
{
|
|
id: createUuidv4(),
|
|
name,
|
|
type
|
|
},
|
|
this.inputNode
|
|
)
|
|
|
|
this.inputs.push(input)
|
|
this.events.dispatch('input-added', { input })
|
|
|
|
return input
|
|
}
|
|
|
|
addOutput(name: string, type: string): SubgraphOutput {
|
|
if (name === null || type === null) {
|
|
throw new Error('Name and type are required for subgraph output')
|
|
}
|
|
|
|
this.events.dispatch('adding-output', { name, type })
|
|
|
|
const output = new SubgraphOutput(
|
|
{
|
|
id: createUuidv4(),
|
|
name,
|
|
type
|
|
},
|
|
this.outputNode
|
|
)
|
|
|
|
this.outputs.push(output)
|
|
this.events.dispatch('output-added', { output })
|
|
|
|
return output
|
|
}
|
|
|
|
/**
|
|
* Renames an input slot in the subgraph.
|
|
* @param input The input slot to rename.
|
|
* @param name The new name for the input slot.
|
|
*/
|
|
renameInput(input: SubgraphInput, name: string): void {
|
|
const index = this.inputs.indexOf(input)
|
|
if (index === -1) throw new Error('Input not found')
|
|
|
|
const oldName = input.displayName
|
|
this.events.dispatch('renaming-input', {
|
|
input,
|
|
index,
|
|
oldName,
|
|
newName: name
|
|
})
|
|
|
|
input.label = name
|
|
}
|
|
|
|
/**
|
|
* Renames an output slot in the subgraph.
|
|
* @param output The output slot to rename.
|
|
* @param name The new name for the output slot.
|
|
*/
|
|
renameOutput(output: SubgraphOutput, name: string): void {
|
|
const index = this.outputs.indexOf(output)
|
|
if (index === -1) throw new Error('Output not found')
|
|
|
|
const oldName = output.displayName
|
|
this.events.dispatch('renaming-output', {
|
|
output,
|
|
index,
|
|
oldName,
|
|
newName: name
|
|
})
|
|
|
|
output.label = name
|
|
}
|
|
|
|
/**
|
|
* Removes an input slot from the subgraph.
|
|
* @param input The input slot to remove.
|
|
*/
|
|
removeInput(input: SubgraphInput): void {
|
|
const index = this.inputs.indexOf(input)
|
|
if (index === -1) throw new Error('Input not found')
|
|
|
|
const mayContinue = this.events.dispatch('removing-input', { input, index })
|
|
if (!mayContinue) return
|
|
|
|
input.disconnect()
|
|
|
|
this.inputs.splice(index, 1)
|
|
|
|
const { length } = this.inputs
|
|
for (let i = index; i < length; i++) {
|
|
this.inputs[i].decrementSlots('inputs')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes an output slot from the subgraph.
|
|
* @param output The output slot to remove.
|
|
*/
|
|
removeOutput(output: SubgraphOutput): void {
|
|
const index = this.outputs.indexOf(output)
|
|
if (index === -1) throw new Error('Output not found')
|
|
|
|
const mayContinue = this.events.dispatch('removing-output', {
|
|
output,
|
|
index
|
|
})
|
|
if (!mayContinue) return
|
|
|
|
output.disconnect()
|
|
|
|
this.outputs.splice(index, 1)
|
|
|
|
const { length } = this.outputs
|
|
for (let i = index; i < length; i++) {
|
|
this.outputs[i].decrementSlots('outputs')
|
|
}
|
|
}
|
|
|
|
draw(
|
|
ctx: CanvasRenderingContext2D,
|
|
colorContext: DefaultConnectionColors,
|
|
fromSlot?:
|
|
| INodeInputSlot
|
|
| INodeOutputSlot
|
|
| SubgraphInput
|
|
| SubgraphOutput,
|
|
editorAlpha?: number
|
|
): void {
|
|
this.inputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
|
|
this.outputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
|
|
}
|
|
|
|
/**
|
|
* Clones the subgraph, creating an identical copy with a new ID.
|
|
* @returns A new subgraph with the same configuration, but a new ID.
|
|
*/
|
|
clone(keepId: boolean = false): Subgraph {
|
|
const exported = this.asSerialisable()
|
|
if (!keepId) exported.id = createUuidv4()
|
|
|
|
const subgraph = new Subgraph(this.rootGraph, exported)
|
|
subgraph.configure(exported)
|
|
return subgraph
|
|
}
|
|
|
|
override asSerialisable(): ExportedSubgraph &
|
|
Required<Pick<SerialisableGraph, 'nodes' | 'groups' | 'extra'>> {
|
|
return {
|
|
id: this.id,
|
|
version: LGraph.serialisedSchemaVersion,
|
|
state: this.state,
|
|
revision: this.revision,
|
|
config: this.config,
|
|
name: this.name,
|
|
...(this.description && { description: this.description }),
|
|
inputNode: this.inputNode.asSerialisable(),
|
|
outputNode: this.outputNode.asSerialisable(),
|
|
inputs: this.inputs.map((x) => x.asSerialisable()),
|
|
outputs: this.outputs.map((x) => x.asSerialisable()),
|
|
widgets: [...this.widgets],
|
|
nodes: this.nodes.map((node) => node.serialize()),
|
|
groups: this.groups.map((group) => group.serialize()),
|
|
links: [...this.links.values()].map((x) => x.asSerialisable()),
|
|
reroutes: this.reroutes.size
|
|
? [...this.reroutes.values()].map((x) => x.asSerialisable())
|
|
: undefined,
|
|
extra: this.extra
|
|
}
|
|
}
|
|
}
|
|
|
|
function patchLinkNodeIds(
|
|
links: Map<LinkId, LLink>,
|
|
remappedIds: Map<NodeId, NodeId>
|
|
): void {
|
|
for (const link of links.values()) {
|
|
const newOrigin = remappedIds.get(link.origin_id)
|
|
if (newOrigin !== undefined) link.origin_id = newOrigin
|
|
|
|
const newTarget = remappedIds.get(link.target_id)
|
|
if (newTarget !== undefined) link.target_id = newTarget
|
|
}
|
|
}
|