Add Subgraphs (#1000)

This commit is contained in:
filtered
2025-06-28 15:21:56 -07:00
committed by GitHub
parent 3e7f9627b4
commit bcaaa00770
54 changed files with 3662 additions and 462 deletions

View File

@@ -1,4 +1,5 @@
import type { DragAndScaleState } from "./DragAndScale"
import type { LGraphEventMap } from "./infrastructure/LGraphEventMap"
import type {
Dictionary,
IContextMenuValue,
@@ -10,24 +11,33 @@ import type {
Positionable,
} from "./interfaces"
import type {
ExportedSubgraph,
ISerialisedGraph,
ISerialisedNode,
Serialisable,
SerialisableGraph,
SerialisableReroute,
} from "./types/serialisation"
import type { UUID } from "@/utils/uuid"
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
import { createUuidv4, zeroUuid } from "@/utils/uuid"
import { CustomEventTarget } from "./infrastructure/CustomEventTarget"
import { LGraphCanvas } from "./LGraphCanvas"
import { LGraphGroup } from "./LGraphGroup"
import { LGraphNode, type NodeId } from "./LGraphNode"
import { LiteGraph } from "./litegraph"
import { LiteGraph, SubgraphNode } from "./litegraph"
import { type LinkId, LLink } from "./LLink"
import { MapProxyHandler } from "./MapProxyHandler"
import { alignOutsideContainer, alignToContainer, createBounds } from "./measure"
import { Reroute, type RerouteId } from "./Reroute"
import { stringOrEmpty } from "./strings"
import { LGraphEventMode } from "./types/globalEnums"
import { type GraphOrSubgraph, Subgraph } from "./subgraph/Subgraph"
import { SubgraphInput } from "./subgraph/SubgraphInput"
import { SubgraphOutput } from "./subgraph/SubgraphOutput"
import { getBoundaryLinks, groupResolvedByOutput, mapSubgraphInputsAndLinks, mapSubgraphOutputsAndLinks, multiClone, splitPositionables } from "./subgraph/subgraphUtils"
import { Alignment, LGraphEventMode } from "./types/globalEnums"
import { getAllNestedItems } from "./utils/collections"
export interface LGraphState {
@@ -56,6 +66,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
}
export interface BaseLGraph {
/** The root graph. */
readonly rootGraph: LGraph
}
@@ -71,6 +82,25 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
"nodes",
"groups",
"links",
"state",
"reroutes",
"floatingLinks",
"id",
"subgraphs",
"definitions",
"inputs",
"outputs",
"widgets",
"inputNode",
"outputNode",
"extra",
])
id: UUID = zeroUuid
revision: number = 0
@@ -99,7 +129,10 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
lastRerouteId: 0,
}
_nodes: LGraphNode[] = []
readonly events = new CustomEventTarget<LGraphEventMap>()
readonly _subgraphs: Map<UUID, Subgraph> = new Map()
_nodes: (LGraphNode | SubgraphNode)[] = []
_nodes_by_id: Record<NodeId, LGraphNode> = {}
_nodes_in_order: LGraphNode[] = []
_nodes_executable: LGraphNode[] | null = null
@@ -241,6 +274,7 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
// used to detect changes
this._version = -1
this._subgraphs.clear()
// safe clear
if (this._nodes) {
@@ -296,6 +330,10 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
this.canvasAction(c => c.clear())
}
get subgraphs(): Map<UUID, Subgraph> {
return this.rootGraph._subgraphs
}
get nodes() {
return this._nodes
}
@@ -312,6 +350,8 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
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)
@@ -321,6 +361,7 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
canvas.graph?.detachCanvas(canvas)
canvas.graph = this
canvas.subgraph = undefined
}
/**
@@ -1129,15 +1170,6 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
this.canvasAction(c => c.onAfterChange?.(this))
}
connectionChange(node: LGraphNode): void {
this.updateExecutionOrder()
this.onConnectionChange?.(node)
this._version++
// TODO: Interface never implemented - any consumers?
// @ts-expect-error
this.canvasAction(c => c.onConnectionChange?.())
}
/**
* clears the triggered slot animation in all links (stop visual animation)
*/
@@ -1349,13 +1381,223 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
link.disconnect(this)
}
/**
* Creates a new subgraph definition, and adds it to the graph.
* @param data Exported data (typically serialised) to configure the new subgraph with
* @returns The newly created subgraph definition.
*/
createSubgraph(data: ExportedSubgraph): Subgraph {
const { id } = data
const subgraph = new Subgraph(this.rootGraph, data)
this.subgraphs.set(id, subgraph)
// FE: Create node defs
this.rootGraph.events.dispatch("subgraph-created", { subgraph, data })
return subgraph
}
convertToSubgraph(items: Set<Positionable>): { subgraph: Subgraph, node: SubgraphNode } {
if (items.size === 0) throw new Error("Cannot convert to subgraph: nothing to convert")
const { state, revision, config } = this
const { boundaryLinks, boundaryFloatingLinks, internalLinks, boundaryInputLinks, boundaryOutputLinks } = getBoundaryLinks(this, items)
const { nodes, reroutes, groups } = splitPositionables(items)
const boundingRect = createBounds(items)
if (!boundingRect) throw new Error("Failed to create bounding rect for subgraph")
const resolvedInputLinks = boundaryInputLinks.map(x => x.resolve(this))
const resolvedOutputLinks = boundaryOutputLinks.map(x => x.resolve(this))
const clonedNodes = multiClone(nodes)
// Inputs, outputs, and links
const links = internalLinks.map(x => x.asSerialisable())
const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links)
const outputs = mapSubgraphOutputsAndLinks(resolvedOutputLinks, links)
// Prepare subgraph data
const data = {
id: createUuidv4(),
name: "New Subgraph",
inputNode: {
id: SUBGRAPH_INPUT_ID,
bounding: [0, 0, 75, 100],
},
outputNode: {
id: SUBGRAPH_OUTPUT_ID,
bounding: [0, 0, 75, 100],
},
inputs,
outputs,
widgets: [],
version: LGraph.serialisedSchemaVersion,
state,
revision,
config,
links,
nodes: clonedNodes,
reroutes: structuredClone([...reroutes].map(reroute => reroute.asSerialisable())),
groups: structuredClone([...groups].map(group => group.serialize())),
} satisfies ExportedSubgraph
const subgraph = this.createSubgraph(data)
subgraph.configure(data)
// Position the subgraph input nodes
subgraph.inputNode.arrange()
subgraph.outputNode.arrange()
const { boundingRect: inputRect } = subgraph.inputNode
const { boundingRect: outputRect } = subgraph.outputNode
alignOutsideContainer(inputRect, Alignment.MidLeft, boundingRect, [50, 0])
alignOutsideContainer(outputRect, Alignment.MidRight, boundingRect, [50, 0])
// Remove items converted to subgraph
for (const resolved of resolvedInputLinks) resolved.inputNode?.disconnectInput(resolved.inputNode.inputs.indexOf(resolved.input!), true)
for (const resolved of resolvedOutputLinks) resolved.outputNode?.disconnectOutput(resolved.outputNode.outputs.indexOf(resolved.output!), resolved.inputNode)
for (const node of nodes) this.remove(node)
for (const reroute of reroutes) this.removeReroute(reroute.id)
for (const group of groups) this.remove(group)
this.rootGraph.events.dispatch("convert-to-subgraph", {
subgraph,
bounds: boundingRect,
exportedSubgraph: data,
boundaryLinks,
resolvedInputLinks,
resolvedOutputLinks,
boundaryFloatingLinks,
internalLinks,
})
// Create subgraph node object
const subgraphNode = LiteGraph.createNode(subgraph.id, subgraph.name, {
inputs: structuredClone(inputs),
outputs: structuredClone(outputs),
})
if (!subgraphNode) throw new Error("Failed to create subgraph node")
// Resize to inputs/outputs
subgraphNode.setSize(subgraphNode.computeSize())
// Center the subgraph node
alignToContainer(subgraphNode._posSize, Alignment.Centre | Alignment.Middle, boundingRect)
// Add the subgraph node to the graph
this.add(subgraphNode)
// Group matching input links
const groupedByOutput = groupResolvedByOutput(resolvedInputLinks)
// Reconnect input links in parent graph
let i = 0
for (const [, connections] of groupedByOutput.entries()) {
const [firstResolved, ...others] = connections
const { output, outputNode, link, subgraphInput } = firstResolved
// Special handling: Subgraph input node
i++
if (link.origin_id === SUBGRAPH_INPUT_ID) {
link.target_id = subgraphNode.id
link.target_slot = i - 1
if (subgraphInput instanceof SubgraphInput) {
subgraphInput.connect(subgraphNode.findInputSlotByType(link.type, true, true), subgraphNode, link.parentId)
} else {
throw new TypeError("Subgraph input node is not a SubgraphInput")
}
console.debug("Reconnect input links in parent graph", { ...link }, this.links.get(link.id), this.links.get(link.id) === link)
for (const resolved of others) {
resolved.link.disconnect(this)
}
continue
}
if (!output || !outputNode) {
console.warn("Convert to Subgraph reconnect: Failed to resolve input link", connections[0])
continue
}
const input = subgraphNode.findInputSlotByType(link.type, true, true)
outputNode.connectSlots(
output,
subgraphNode,
input,
link.parentId,
)
}
// Group matching links
const outputsGroupedByOutput = groupResolvedByOutput(resolvedOutputLinks)
// Reconnect output links in parent graph
i = 0
for (const [, connections] of outputsGroupedByOutput.entries()) {
// Special handling: Subgraph output node
i++
for (const connection of connections) {
const { input, inputNode, link, subgraphOutput } = connection
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(subgraphNode.findOutputSlotByType(link.type, true, true), subgraphNode, link.parentId)
} else {
throw new TypeError("Subgraph input node is not a SubgraphInput")
}
continue
}
if (!input || !inputNode) {
console.warn("Convert to Subgraph reconnect: Failed to resolve output link", connection)
continue
}
const output = subgraphNode.outputs[i - 1]
subgraphNode.connectSlots(
output,
inputNode,
input,
link.parentId,
)
}
}
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
* Not intended to be run from subgraphs.
* @param nodeIds An ordered list of node IDs, from the root graph to the most nested subgraph node
* @returns An ordered list of nested subgraph nodes.
*/
resolveSubgraphIdPath(nodeIds: readonly NodeId[]): SubgraphNode[] {
const result: SubgraphNode[] = []
let currentGraph: GraphOrSubgraph = this.rootGraph
for (const nodeId of nodeIds) {
const node: LGraphNode | null = currentGraph.getNodeById(nodeId)
if (!node) throw new Error(`Node [${nodeId}] not found. ID Path: ${nodeIds.join(":")}`)
if (!node.isSubgraphNode()) throw new Error(`Node [${nodeId}] is not a SubgraphNode. ID Path: ${nodeIds.join(":")}`)
result.push(node)
currentGraph = node.subgraph
}
return result
}
/**
* Creates a Object containing all the info about this graph, it can be serialized
* @deprecated Use {@link asSerialisable}, which returns the newer schema version.
* @returns value of the node
*/
serialize(option?: { sortNodes: boolean }): ISerialisedGraph {
const { config, state, groups, nodes, reroutes, extra, floatingLinks } = this.asSerialisable(option)
const { config, state, groups, nodes, reroutes, extra, floatingLinks, definitions } = this.asSerialisable(option)
const linkArray = [...this._links.values()]
const links = linkArray.map(x => x.serialize())
@@ -1376,6 +1618,7 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
links,
floatingLinks,
groups,
definitions,
config,
extra,
version: LiteGraph.VERSION,
@@ -1430,10 +1673,31 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
extra,
}
if (this.isRootGraph && this._subgraphs.size) {
data.definitions = { subgraphs: [...this._subgraphs.values()].map(x => x.asSerialisable()) }
}
this.onSerialize?.(data)
return data
}
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
const { id, extra } = data
// Create a new graph ID if none is provided
if (id) {
this.id = id
} else if (this.id === zeroUuid) {
this.id = createUuidv4()
}
// Extra
this.extra = extra ? structuredClone(extra) : {}
// Ensure auto-generated serialisation data is removed from extra
delete this.extra.linkExtensions
}
/**
* Configure a graph from a JSON string
* @param data The deserialised object to configure this graph from
@@ -1444,155 +1708,188 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean,
): boolean | undefined {
// TODO: Finish typing configure()
if (!data) return
if (!keep_old) this.clear()
const options: LGraphEventMap["configuring"] = {
data,
clearGraph: !keep_old,
}
const mayContinue = this.events.dispatch("configuring", options)
if (!mayContinue) return
// Create a new graph ID if none is provided
if (data.id) this.id = data.id
else if (this.id === zeroUuid) this.id = createUuidv4()
try {
// TODO: Finish typing configure()
if (!data) return
if (options.clearGraph) this.clear()
let reroutes: SerialisableReroute[] | undefined
this._configureBase(data)
// 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)
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
// #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
// LLink parentIds
if (Array.isArray(extra?.linkExtensions)) {
for (const linkEx of extra.linkExtensions) {
const link = this._links.get(linkEx.id)
if (link) link.parentId = linkEx.parentId
}
}
// Reroutes
reroutes = extra?.reroutes
// #endregion `extra` embeds for v0.4
} else {
// New schema - one version so far, no check required.
// State
if (data.state) {
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = data.state
const { state } = this
if (lastGroupId != null) state.lastGroupId = lastGroupId
if (lastLinkId != null) state.lastLinkId = lastLinkId
if (lastNodeId != null) state.lastNodeId = lastNodeId
if (lastRerouteId != null) state.lastRerouteId = lastRerouteId
}
// Links
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
}
}
reroutes = data.reroutes
}
// Reroutes
reroutes = extra?.reroutes
// #endregion `extra` embeds for v0.4
} else {
// New schema - one version so far, no check required.
// State
if (data.state) {
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = data.state
const { state } = this
if (lastGroupId != null) state.lastGroupId = lastGroupId
if (lastLinkId != null) state.lastLinkId = lastLinkId
if (lastNodeId != null) state.lastNodeId = lastNodeId
if (lastRerouteId != null) state.lastRerouteId = lastRerouteId
}
// Links
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
if (Array.isArray(reroutes)) {
for (const rerouteData of reroutes) {
this.setReroute(rerouteData)
}
}
reroutes = data.reroutes
}
const nodesData = data.nodes
// Reroutes
if (Array.isArray(reroutes)) {
for (const rerouteData of reroutes) {
this.setReroute(rerouteData)
// 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]
}
}
const nodesData = data.nodes
// copy all stored fields
for (const i in data) {
// links must be accepted
if (["nodes", "groups", "links", "state", "reroutes", "floatingLinks", "id"].includes(i)) {
continue
// Subgraph definitions
const subgraphs = data.definitions?.subgraphs
if (subgraphs) {
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
for (const subgraph of subgraphs) this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
// @ts-expect-error #574 Legacy property assignment
this[i] = data[i]
}
let error = false
let error = false
const nodeDataMap = new Map<NodeId, ISerialisedNode>()
// create nodes
this._nodes = []
if (nodesData) {
for (const n_info of nodesData) {
// stored info
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
if (LiteGraph.debug) console.log("Node not found or has errors:", n_info.type)
// create nodes
this._nodes = []
if (nodesData) {
for (const n_info of nodesData) {
// stored info
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
if (LiteGraph.debug) console.log("Node not found or has errors:", n_info.type)
// in case of error we create a replacement node to avoid losing info
node = new LGraphNode("")
node.last_serialization = n_info
node.has_errors = true
error = true
// continue;
// 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)
}
// 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)
// configure nodes afterwards so they can reach each other
for (const [id, nodeData] of nodeDataMap) {
this.getNodeById(id)?.configure(nodeData)
}
}
// configure nodes afterwards so they can reach each other
for (const n_info of nodesData) {
const node = this.getNodeById(n_info.id)
node?.configure(n_info)
// Floating links
if (Array.isArray(data.floatingLinks)) {
for (const linkData of data.floatingLinks) {
const floatingLink = LLink.create(linkData)
this.addFloatingLink(floatingLink)
if (floatingLink.id > this.#lastFloatingLinkId) this.#lastFloatingLinkId = floatingLink.id
}
}
// Drop broken reroutes
for (const reroute of this.reroutes.values()) {
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
}
}
// groups
this._groups.length = 0
const groupData = data.groups
if (groupData) {
for (const data of groupData) {
// TODO: Search/remove these global object refs
const group = new LiteGraph.LGraphGroup()
group.configure(data)
this.add(group)
}
}
this.updateExecutionOrder()
this.onConfigure?.(data)
this._version++
// Ensure the primary canvas is set to the correct graph
const { primaryCanvas } = this
const subgraphId = primaryCanvas?.subgraph?.id
if (subgraphId) {
const subgraph = this.subgraphs.get(subgraphId)
if (subgraph) {
primaryCanvas.setGraph(subgraph)
} else {
primaryCanvas.setGraph(this)
}
}
this.setDirtyCanvas(true, true)
return error
} finally {
this.events.dispatch("configured")
}
}
// Floating links
if (Array.isArray(data.floatingLinks)) {
for (const linkData of data.floatingLinks) {
const floatingLink = LLink.create(linkData)
this.addFloatingLink(floatingLink)
#canvas?: LGraphCanvas
get primaryCanvas(): LGraphCanvas | undefined {
return this.rootGraph.#canvas
}
if (floatingLink.id > this.#lastFloatingLinkId) this.#lastFloatingLinkId = floatingLink.id
}
}
// Drop broken reroutes
for (const reroute of this.reroutes.values()) {
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
}
}
// groups
this._groups.length = 0
const groupData = data.groups
if (groupData) {
for (const data of groupData) {
// TODO: Search/remove these global object refs
const group = new LiteGraph.LGraphGroup()
group.configure(data)
this.add(group)
}
}
this.updateExecutionOrder()
this.extra = data.extra || {}
// Ensure auto-generated serialisation data is removed from extra
delete this.extra.linkExtensions
this.onConfigure?.(data)
this._version++
this.setDirtyCanvas(true, true)
return error
set primaryCanvas(canvas: LGraphCanvas) {
this.rootGraph.#canvas = canvas
}
load(url: string | Blob | URL | File, callback: () => void) {

View File

@@ -1,4 +1,5 @@
import type { ContextMenu } from "./ContextMenu"
import type { CustomEventDispatcher, ICustomEventTarget } from "./infrastructure/CustomEventTarget"
import type { LGraphCanvasEventMap } from "./infrastructure/LGraphCanvasEventMap"
import type {
CanvasColour,
@@ -32,7 +33,9 @@ import type {
CanvasPointerEvent,
CanvasPointerExtensions,
} from "./types/events"
import type { ClipboardItems } from "./types/serialisation"
import type { ClipboardItems, SubgraphIO } from "./types/serialisation"
import type { NeverNever } from "./types/utility"
import type { PickNevers } from "./types/utility"
import type { IBaseWidget } from "./types/widgets"
import { LinkConnector } from "@/canvas/LinkConnector"
@@ -44,7 +47,7 @@ import { strokeShape } from "./draw"
import { NullGraphError } from "./infrastructure/NullGraphError"
import { LGraphGroup } from "./LGraphGroup"
import { LGraphNode, type NodeId, type NodeProperty } from "./LGraphNode"
import { LiteGraph, Rectangle } from "./litegraph"
import { LiteGraph, Rectangle, SubgraphNode } from "./litegraph"
import { type LinkId, LLink } from "./LLink"
import {
containsRect,
@@ -61,6 +64,9 @@ import { NodeInputSlot } from "./node/NodeInputSlot"
import { Reroute, type RerouteId } from "./Reroute"
import { stringOrEmpty } from "./strings"
import { Subgraph } from "./subgraph/Subgraph"
import { SubgraphInputNode } from "./subgraph/SubgraphInputNode"
import { SubgraphIONodeBase } from "./subgraph/SubgraphIONodeBase"
import { SubgraphOutputNode } from "./subgraph/SubgraphOutputNode"
import {
CanvasItem,
LGraphEventMode,
@@ -93,13 +99,13 @@ interface IShowSearchOptions {
interface ICreateNodeOptions {
/** input */
nodeFrom?: LGraphNode | null
nodeFrom?: SubgraphInputNode | LGraphNode | null
/** input */
slotFrom?: number | INodeOutputSlot | INodeInputSlot | null
slotFrom?: number | INodeOutputSlot | INodeInputSlot | SubgraphIO | null
/** output */
nodeTo?: LGraphNode | null
nodeTo?: SubgraphOutputNode | LGraphNode | null
/** output */
slotTo?: number | INodeOutputSlot | INodeInputSlot | null
slotTo?: number | INodeOutputSlot | INodeInputSlot | SubgraphIO | null
/** pass the event coords */
/** Create the connection from a reroute */
@@ -213,7 +219,7 @@ const cursors = {
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
*/
export class LGraphCanvas {
export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> {
// Optimised buffers used during rendering
static #temp = new Float32Array(4)
static #temp_vec2 = new Float32Array(2)
@@ -273,11 +279,39 @@ export class LGraphCanvas {
selectionChanged: false,
}
declare subgraph?: Subgraph
#subgraph?: Subgraph
get subgraph(): Subgraph | undefined {
return this.#subgraph
}
set subgraph(value: Subgraph | undefined) {
if (value !== this.#subgraph) {
this.#subgraph = value
if (value) this.dispatch("litegraph:set-graph", { oldGraph: this.#subgraph, newGraph: value })
}
}
/** Dispatches a custom event on the canvas. */
dispatch<T extends keyof NeverNever<LGraphCanvasEventMap>>(type: T, detail: LGraphCanvasEventMap[T]): boolean
dispatch<T extends keyof PickNevers<LGraphCanvasEventMap>>(type: T): boolean
dispatch<T extends keyof LGraphCanvasEventMap>(type: T, detail?: LGraphCanvasEventMap[T]) {
const event = new CustomEvent(type as string, { detail, bubbles: true })
return this.canvas.dispatchEvent(event)
}
dispatchEvent<TEvent extends keyof LGraphCanvasEventMap>(type: TEvent, detail: LGraphCanvasEventMap[TEvent]) {
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
}
#updateCursorStyle() {
if (!this.state.shouldSetCursor) return
const crosshairItems =
CanvasItem.Node |
CanvasItem.RerouteSlot |
CanvasItem.SubgraphIoNode |
CanvasItem.SubgraphIoSlot
let cursor = "default"
if (this.state.draggingCanvas) {
cursor = "grabbing"
@@ -285,12 +319,10 @@ export class LGraphCanvas {
cursor = "grab"
} else if (this.pointer.resizeDirection) {
cursor = cursors[this.pointer.resizeDirection] ?? cursors.SE
} else if (this.state.hoveringOver & CanvasItem.Node) {
} else if (this.state.hoveringOver & crosshairItems) {
cursor = "crosshair"
} else if (this.state.hoveringOver & CanvasItem.Reroute) {
cursor = "grab"
} else if (this.state.hoveringOver & CanvasItem.RerouteSlot) {
cursor = "crosshair"
}
this.canvas.style.cursor = cursor
@@ -530,8 +562,13 @@ export class LGraphCanvas {
node_in_panel?: LGraphNode | null
last_mouse: ReadOnlyPoint = [0, 0]
last_mouseclick: number = 0
graph: LGraph | null
canvas: HTMLCanvasElement
graph: LGraph | Subgraph | null
get _graph(): LGraph | Subgraph {
if (!this.graph) throw new NullGraphError()
return this.graph
}
canvas: HTMLCanvasElement & ICustomEventTarget<LGraphCanvasEventMap>
bgcanvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
_events_binded?: boolean
@@ -638,9 +675,12 @@ export class LGraphCanvas {
this.ds = new DragAndScale(canvas)
this.pointer = new CanvasPointer(canvas)
this.linkConnector.events.addEventListener("link-created", () => this.#dirty())
// @deprecated Workaround: Keep until connecting_links is removed.
this.linkConnector.events.addEventListener("reset", () => {
this.connecting_links = null
this.dirty_bgcanvas = true
})
// Dropped a link on the canvas
@@ -661,13 +701,13 @@ export class LGraphCanvas {
if (LiteGraph.release_link_on_empty_shows_menu) {
const linkReleaseContext = this.linkConnector.state.connectingTo === "input"
? {
node_from: firstLink.node,
slot_from: firstLink.fromSlot,
node_from: firstLink.node as LGraphNode,
slot_from: firstLink.fromSlot as INodeOutputSlot,
type_filter_in: firstLink.fromSlot.type,
}
: {
node_to: firstLink.node,
slot_from: firstLink.fromSlot,
node_to: firstLink.node as LGraphNode,
slot_to: firstLink.fromSlot as INodeInputSlot,
type_filter_out: firstLink.fromSlot.type,
}
@@ -675,12 +715,12 @@ export class LGraphCanvas {
if ("shiftKey" in e && e.shiftKey) {
if (this.allow_searchbox) {
this.showSearchBox(e as unknown as MouseEvent, linkReleaseContext)
this.showSearchBox(e as unknown as MouseEvent, linkReleaseContext as IShowSearchOptions)
}
} else if (this.linkConnector.state.connectingTo === "input") {
this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.fromSlot, e, afterRerouteId })
this.showConnectionMenu({ nodeFrom: firstLink.node as LGraphNode, slotFrom: firstLink.fromSlot as INodeOutputSlot, e, afterRerouteId })
} else {
this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.fromSlot, e, afterRerouteId })
this.showConnectionMenu({ nodeTo: firstLink.node as LGraphNode, slotTo: firstLink.fromSlot as INodeInputSlot, e, afterRerouteId })
}
}
})
@@ -1584,18 +1624,28 @@ export class LGraphCanvas {
const { graph } = this
if (newGraph === graph) return
const options = {
bubbles: true,
detail: { newGraph, oldGraph: graph },
}
this.clear()
newGraph.attachCanvas(this)
this.canvas.dispatchEvent(new CustomEvent("litegraph:set-graph", options))
this.dispatch("litegraph:set-graph", { newGraph, oldGraph: graph })
this.#dirty()
}
openSubgraph(subgraph: Subgraph): void {
const { graph } = this
if (!graph) throw new NullGraphError()
const options = { bubbles: true, detail: { subgraph, closingGraph: graph }, cancelable: true }
const mayContinue = this.canvas.dispatchEvent(new CustomEvent("subgraph-opening", options))
if (!mayContinue) return
this.clear()
this.subgraph = subgraph
this.setGraph(subgraph)
this.canvas.dispatchEvent(new CustomEvent("subgraph-opened", options))
}
/**
* @returns the visually active graph (in case there are more in the stack)
*/
@@ -1771,10 +1821,7 @@ export class LGraphCanvas {
if (!graph) throw new NullGraphError()
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
pointer.finally = () => {
this.linkConnector.reset(true)
this.#dirty()
}
pointer.finally = () => this.linkConnector.reset(true)
}
/**
@@ -1942,33 +1989,44 @@ export class LGraphCanvas {
!this.read_only
) {
// Right / aux button
const { linkConnector, subgraph } = this
// Sticky select - won't remove single nodes
if (node) {
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
if (subgraph?.inputNode.containsPoint(this.graph_mouse)) {
// Subgraph input node
this.processSelect(subgraph.inputNode, e, true)
subgraph.inputNode.onPointerDown(e, pointer, linkConnector)
} else if (subgraph?.outputNode.containsPoint(this.graph_mouse)) {
// Subgraph output node
this.processSelect(subgraph.outputNode, e, true)
subgraph.outputNode.onPointerDown(e, pointer, linkConnector)
} else {
if (node) {
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes)
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
if (upEvent.altKey) {
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes)
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
if (upEvent.altKey) {
// Ensure deselected
if (reroute.selected) {
this.deselect(reroute)
this.onSelectionChange?.(this.selected_nodes)
if (reroute.selected) {
this.deselect(reroute)
this.onSelectionChange?.(this.selected_nodes)
}
reroute.remove()
}
reroute.remove()
}
} else {
this.processSelect(reroute, e, true)
}
} else {
this.processSelect(reroute, e, true)
}
}
}
// Show context menu for the node or group under the pointer
pointer.onClick ??= () => this.processContextMenu(node, e)
// Show context menu for the node or group under the pointer
pointer.onClick ??= () => this.processContextMenu(node, e)
}
}
this.last_mouse = [x, y]
@@ -1990,8 +2048,30 @@ export class LGraphCanvas {
this.onMouseDown?.(e)
}
/**
* Returns the first matching positionable item at the given co-ordinates.
*
* Order of preference:
* - Subgraph IO Nodes
* - Reroutes
* - Group titlebars
* @param x The x coordinate in canvas space
* @param y The y coordinate in canvas space
* @returns The positionable item or undefined
*/
#getPositionableOnPos(x: number, y: number): Positionable | undefined {
const ioNode = this.subgraph?.getIoNodeOnPos(x, y)
if (ioNode) return ioNode
for (const reroute of this.#visibleReroutes) {
if (reroute.containsPoint([x, y])) return reroute
}
return this.graph?.getGroupTitlebarOnPos(x, y)
}
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
const { pointer, graph, linkConnector } = this
const { pointer, graph, linkConnector, subgraph } = this
if (!graph) throw new NullGraphError()
const x = e.canvasX
@@ -2010,9 +2090,7 @@ export class LGraphCanvas {
pointer.onClick = (eUp) => {
// Click, not drag
const clickedItem = node ??
graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY, this.#visibleReroutes) ??
graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY)
const clickedItem = node ?? this.#getPositionableOnPos(eUp.canvasX, eUp.canvasY)
this.processSelect(clickedItem, eUp)
}
pointer.onDragStart = () => this.dragging_rectangle = dragRect
@@ -2059,6 +2137,24 @@ export class LGraphCanvas {
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
this.#processNodeClick(e, ctrlOrMeta, node)
} else {
// Subgraph IO nodes
if (subgraph) {
const { inputNode, outputNode } = subgraph
if (processSubgraphIONode(this, inputNode)) return
if (processSubgraphIONode(this, outputNode)) return
function processSubgraphIONode(canvas: LGraphCanvas, ioNode: SubgraphInputNode | SubgraphOutputNode) {
if (!ioNode.containsPoint([x, y])) return false
ioNode.onPointerDown(e, pointer, linkConnector)
pointer.onClick ??= () => canvas.processSelect(ioNode, e)
pointer.onDragStart ??= () => canvas.#startDraggingItems(ioNode, pointer, true)
pointer.onDragEnd ??= eUp => canvas.#processDraggedItems(eUp)
return true
}
}
// Reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
for (const reroute of this.#visibleReroutes) {
@@ -2334,6 +2430,7 @@ export class LGraphCanvas {
this.#processWidgetClick(e, node, widget)
this.node_widget = [node, widget]
} else {
// Node background
pointer.onDoubleClick = () => {
// Double-click
// Check if it's a double click on the title bar
@@ -2341,7 +2438,10 @@ export class LGraphCanvas {
// If clicking on node header (title), pos[1] is negative
if (pos[1] < 0 && !inCollapse) {
node.onNodeTitleDblClick?.(e, pos, this)
} else if (node instanceof SubgraphNode) {
this.openSubgraph(node.subgraph)
}
node.onDblClick?.(e, pos, this)
this.emitEvent({
subType: "node-double-click",
@@ -2637,7 +2737,7 @@ export class LGraphCanvas {
if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true
const { graph, resizingGroup, linkConnector, pointer } = this
const { graph, resizingGroup, linkConnector, pointer, subgraph } = this
if (!graph) return
LGraphCanvas.active_canvas = this
@@ -2650,11 +2750,19 @@ export class LGraphCanvas {
mouse[1] - this.last_mouse[1],
]
this.last_mouse = mouse
this.graph_mouse[0] = e.canvasX
this.graph_mouse[1] = e.canvasY
const { canvasX: x, canvasY: y } = e
this.graph_mouse[0] = x
this.graph_mouse[1] = y
if (e.isPrimary) pointer.move(e)
/** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */
let underPointer = CanvasItem.Nothing
if (subgraph) {
underPointer |= subgraph.inputNode.onPointerMove(e)
underPointer |= subgraph.outputNode.onPointerMove(e)
}
if (this.block_click) {
e.preventDefault()
return
@@ -2667,26 +2775,24 @@ export class LGraphCanvas {
const [node, widget] = this.node_widget
if (widget?.mouse) {
const x = e.canvasX - node.pos[0]
const y = e.canvasY - node.pos[1]
const result = widget.mouse(e, [x, y], node)
const relativeX = x - node.pos[0]
const relativeY = y - node.pos[1]
const result = widget.mouse(e, [relativeX, relativeY], node)
if (result != null) this.dirty_canvas = result
}
}
/** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */
let underPointer = CanvasItem.Nothing
// get node over
const node = graph.getNodeOnPos(
e.canvasX,
e.canvasY,
x,
y,
this.visible_nodes,
)
const dragRect = this.dragging_rectangle
if (dragRect) {
dragRect[2] = e.canvasX - dragRect[0]
dragRect[3] = e.canvasY - dragRect[1]
dragRect[2] = x - dragRect[0]
dragRect[3] = y - dragRect[1]
this.dirty_canvas = true
} else if (resizingGroup) {
// Resizing a group
@@ -2714,9 +2820,9 @@ export class LGraphCanvas {
// For input/output hovering
// to store the output of isOverNodeInput
const pos: Point = [0, 0]
const inputId = isOverNodeInput(node, e.canvasX, e.canvasY, pos)
const outputId = isOverNodeOutput(node, e.canvasX, e.canvasY, pos)
const overWidget = node.getWidgetOnPos(e.canvasX, e.canvasY, true) ?? undefined
const inputId = isOverNodeInput(node, x, y, pos)
const outputId = isOverNodeOutput(node, x, y, pos)
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
if (!node.mouseOver) {
// mouse enter
@@ -2732,7 +2838,7 @@ export class LGraphCanvas {
}
// in case the node wants to do something
node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this)
node.onMouseMove?.(e, [x - node.pos[0], y - node.pos[1]], this)
// The input the mouse is over has changed
const { mouseOver } = node
@@ -2822,7 +2928,7 @@ export class LGraphCanvas {
// Resize direction - only show resize cursor if not over inputs/outputs/widgets
if (!pointer.eDown) {
if (inputId === -1 && outputId === -1 && !overWidget) {
pointer.resizeDirection = node.findResizeDirection(e.canvasX, e.canvasY)
pointer.resizeDirection = node.findResizeDirection(x, y)
} else {
// Clear resize direction when over inputs/outputs/widgets
pointer.resizeDirection &&= undefined
@@ -2841,12 +2947,12 @@ export class LGraphCanvas {
}
if (this.canvas) {
const group = graph.getGroupOnPos(e.canvasX, e.canvasY)
const group = graph.getGroupOnPos(x, y)
if (
group &&
!e.ctrlKey &&
!this.read_only &&
group.isInResize(e.canvasX, e.canvasY)
group.isInResize(x, y)
) {
pointer.resizeDirection = "SE"
} else {
@@ -2860,8 +2966,8 @@ export class LGraphCanvas {
this.node_capturing_input.onMouseMove?.(
e,
[
e.canvasX - this.node_capturing_input.pos[0],
e.canvasY - this.node_capturing_input.pos[1],
x - this.node_capturing_input.pos[0],
y - this.node_capturing_input.pos[1],
],
this,
)
@@ -3130,13 +3236,12 @@ export class LGraphCanvas {
// esc
if (this.linkConnector.isConnecting) {
this.linkConnector.reset()
this.#dirty()
e.preventDefault()
return
}
this.node_panel?.close()
this.options_panel?.close()
block_default = true
if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
@@ -3430,7 +3535,7 @@ export class LGraphCanvas {
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems } = this
const { graph, selectedItems, subgraph } = this
if (!graph) throw new NullGraphError()
const w = Math.abs(dragRect[2])
@@ -3444,6 +3549,17 @@ export class LGraphCanvas {
const isSelected = new Set<Positionable>()
const notSelected: Positionable[] = []
if (subgraph) {
const { inputNode, outputNode } = subgraph
if (overlapBounding(dragRect, inputNode.boundingRect)) {
addPositionable(inputNode)
}
if (overlapBounding(dragRect, outputNode.boundingRect)) {
addPositionable(outputNode)
}
}
for (const nodeX of graph._nodes) {
if (overlapBounding(dragRect, nodeX.boundingRect)) {
addPositionable(nodeX)
@@ -3918,6 +4034,13 @@ export class LGraphCanvas {
this.computeVisibleNodes(undefined, this.visible_nodes)
// Update visible node IDs
this.#visible_node_ids = new Set(this.visible_nodes.map(node => node.id))
// Arrange subgraph IO nodes
const { subgraph } = this
if (subgraph) {
subgraph.inputNode.arrange()
subgraph.outputNode.arrange()
}
}
if (
@@ -3942,7 +4065,7 @@ export class LGraphCanvas {
drawFrontCanvas(): void {
this.dirty_canvas = false
const { ctx, canvas, linkConnector } = this
const { ctx, canvas, graph, linkConnector } = this
// @ts-expect-error
if (ctx.start2D && !this.viewport) {
@@ -3995,7 +4118,7 @@ export class LGraphCanvas {
this.renderInfo(ctx, area ? area[0] : 0, area ? area[1] : 0)
}
if (this.graph) {
if (graph) {
// apply transformations
ctx.save()
this.ds.toCanvasContext(ctx)
@@ -4020,13 +4143,16 @@ export class LGraphCanvas {
ctx.restore()
}
// Draw subgraph IO nodes
this.subgraph?.draw(ctx, this.colourGetter)
// on top (debug)
if (this.render_execution_order) {
this.drawExecutionOrder(ctx)
}
// connections ontop?
if (this.graph.config.links_ontop) {
if (graph.config.links_ontop) {
this.drawConnections(ctx)
}
@@ -4505,7 +4631,7 @@ export class LGraphCanvas {
if (!node.collapsed) {
node.arrange()
node.drawSlots(ctx, {
fromSlot: this.linkConnector.renderLinks[0]?.fromSlot,
fromSlot: this.linkConnector.renderLinks[0]?.fromSlot as INodeOutputSlot | INodeInputSlot,
colorContext: this.colourGetter,
editorAlpha: this.editor_alpha,
lowQuality: this.low_quality,
@@ -4774,7 +4900,7 @@ export class LGraphCanvas {
this.renderedPaths.clear()
if (this.links_render_mode === LinkRenderType.HIDDEN_LINK) return
const { graph } = this
const { graph, subgraph } = this
if (!graph) throw new NullGraphError()
const visibleReroutes: Reroute[] = []
@@ -4824,6 +4950,40 @@ export class LGraphCanvas {
}
}
if (subgraph) {
for (const output of subgraph.inputNode.slots) {
if (!output.linkIds.length) continue
// find link info
for (const linkId of output.linkIds) {
const resolved = LLink.resolve(linkId, graph)
if (!resolved) continue
const { link, inputNode, input } = resolved
if (!inputNode || !input) continue
const endPos = inputNode.getInputPos(link.target_slot)
this.#renderAllLinkSegments(ctx, link, output.pos, endPos, visibleReroutes, now, input.dir, input.dir)
}
}
for (const input of subgraph.outputNode.slots) {
if (!input.linkIds.length) continue
// find link info
const resolved = LLink.resolve(input.linkIds[0], graph)
if (!resolved) continue
const { link, outputNode, output } = resolved
if (!outputNode || !output) continue
const startPos = outputNode.getOutputPos(link.origin_slot)
this.#renderAllLinkSegments(ctx, link, startPos, input.pos, visibleReroutes, now, output.dir, input.dir)
}
}
if (graph.floatingLinks.size > 0) {
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
}
@@ -5305,6 +5465,28 @@ export class LGraphCanvas {
ctx.fillStyle = fillStyle
}
ctx.fill()
if (LLink._drawDebug) {
const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx
ctx.globalAlpha = 1
ctx.lineWidth = 4
ctx.fillStyle = "white"
ctx.strokeStyle = "black"
ctx.font = "16px Arial"
const text = String(linkSegment.id)
const { width, actualBoundingBoxAscent } = ctx.measureText(text)
const x = pos[0] - width * 0.5
const y = pos[1] + actualBoundingBoxAscent * 0.5
ctx.strokeText(text, x, y)
ctx.fillText(text, x, y)
ctx.font = font
ctx.globalAlpha = globalAlpha
ctx.lineWidth = lineWidth
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
}
}
// render flowing points
@@ -5598,28 +5780,42 @@ export class LGraphCanvas {
let slotX = isFrom ? opts.slotFrom : opts.slotTo
let iSlotConn: number | false = false
switch (typeof slotX) {
case "string":
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false)
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "object":
if (slotX === null) {
if (nodeX instanceof SubgraphIONodeBase) {
if (typeof slotX !== "object" || !slotX) {
console.warn("Cant get slot information", slotX)
return false
}
const { name } = slotX
iSlotConn = nodeX.slots.findIndex(s => s.name === name)
slotX = nodeX.slots[iSlotConn]
if (!slotX) {
console.warn("Cant get slot information", slotX)
return false
}
} else {
switch (typeof slotX) {
case "string":
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false)
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "object":
if (slotX === null) {
console.warn("Cant get slot information", slotX)
return false
}
// ok slotX
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name)
break
case "number":
iSlotConn = slotX
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "undefined":
default:
console.warn("Cant get slot information", slotX)
return false
// ok slotX
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name)
break
case "number":
iSlotConn = slotX
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "undefined":
default:
console.warn("Cant get slot information", slotX)
return false
}
}
// check for defaults nodes for this slottype
@@ -5755,31 +5951,45 @@ export class LGraphCanvas {
let slotX = isFrom ? opts.slotFrom : opts.slotTo
let iSlotConn: number
switch (typeof slotX) {
case "string":
iSlotConn = isFrom
? nodeX.findOutputSlot(slotX, false)
: nodeX.findInputSlot(slotX, false)
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "object":
if (slotX === null) {
if (nodeX instanceof SubgraphIONodeBase) {
if (typeof slotX !== "object" || !slotX) {
console.warn("Cant get slot information", slotX)
return
}
const { name } = slotX
iSlotConn = nodeX.slots.findIndex(s => s.name === name)
slotX = nodeX.slots[iSlotConn]
if (!slotX) {
console.warn("Cant get slot information", slotX)
return
}
} else {
switch (typeof slotX) {
case "string":
iSlotConn = isFrom
? nodeX.findOutputSlot(slotX, false)
: nodeX.findInputSlot(slotX, false)
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
case "object":
if (slotX === null) {
console.warn("Cant get slot information", slotX)
return
}
// ok slotX
iSlotConn = isFrom
? nodeX.findOutputSlot(slotX.name)
: nodeX.findInputSlot(slotX.name)
break
case "number":
iSlotConn = slotX
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
default:
console.warn("Cant get slot information", slotX)
return
// ok slotX
iSlotConn = isFrom
? nodeX.findOutputSlot(slotX.name)
: nodeX.findInputSlot(slotX.name)
break
case "number":
iSlotConn = slotX
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
break
default:
console.warn("Cant get slot information", slotX)
return
}
}
const options = ["Add Node", "Add Reroute", null]
@@ -5824,9 +6034,13 @@ export class LGraphCanvas {
if (!node) return
if (isFrom) {
opts.nodeFrom?.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId })
if (!opts.nodeFrom) throw new TypeError("Cannot add node to SubgraphInputNode: nodeFrom was null")
const slot = opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId })
if (!slot) console.warn("Failed to make new connection.")
// }
} else {
opts.nodeTo?.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId })
if (!opts.nodeTo) throw new TypeError("Cannot add node to SubgraphInputNode: nodeTo was null")
opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId })
}
})
break
@@ -5839,16 +6053,22 @@ export class LGraphCanvas {
if (!slot) throw new TypeError("Cannot add reroute: slot was null")
if (!opts.e) throw new TypeError("Cannot add reroute: CanvasPointerEvent was null")
const reroute = node.connectFloatingReroute([opts.e.canvasX, opts.e.canvasY], slot, afterRerouteId)
if (!reroute) throw new Error("Failed to create reroute")
if (node instanceof SubgraphIONodeBase) {
throw new TypeError("Cannot add floating reroute to Subgraph IO Nodes")
} else {
const reroute = node.connectFloatingReroute([opts.e.canvasX, opts.e.canvasY], slot, afterRerouteId)
if (!reroute) throw new Error("Failed to create reroute")
}
dirty()
break
}
case "Search":
if (isFrom) {
// @ts-expect-error Subgraph
opts.showSearchBox(e, { node_from: opts.nodeFrom, slot_from: slotX, type_filter_in: fromSlotType })
} else {
// @ts-expect-error Subgraph
opts.showSearchBox(e, { node_to: opts.nodeTo, slot_from: slotX, type_filter_out: fromSlotType })
}
break
@@ -5860,8 +6080,8 @@ export class LGraphCanvas {
} satisfies Partial<ICreateDefaultNodeOptions>
const options = Object.assign(opts, customProps)
that.createDefaultNodeForSlot(options)
break
if (!that.createDefaultNodeForSlot(options))
break
}
}
}
@@ -7134,6 +7354,12 @@ export class LGraphCanvas {
]
if (Object.keys(this.selected_nodes).length > 1) {
options.push({
content: "Convert to Subgraph 🆕",
callback: () => {
if (!this.selectedItems.size) throw new Error("Convert to Subgraph: Nothing selected.")
this._graph.convertToSubgraph(this.selectedItems)
},
}, {
content: "Align",
has_submenu: true,
callback: LGraphCanvas.onGroupAlign,
@@ -7167,6 +7393,13 @@ export class LGraphCanvas {
callback: LGraphCanvas.showMenuNodeOptionalOutputs,
},
null,
{
content: "Convert to Subgraph 🆕",
callback: () => {
if (!this.selectedItems.size) throw new Error("Convert to Subgraph: Nothing selected.")
this._graph.convertToSubgraph(this.selectedItems)
},
},
{
content: "Properties",
has_submenu: true,
@@ -7285,7 +7518,7 @@ export class LGraphCanvas {
}
if (node) {
options.title = node.type ?? undefined
options.title = node.displayType ?? node.type ?? undefined
LGraphCanvas.active_node = node
// check if mouse is in input

View File

@@ -18,21 +18,26 @@ import type {
ISlotType,
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size,
} from "./interfaces"
import type { LGraph } from "./LGraph"
import type { Reroute, RerouteId } from "./Reroute"
import type { SubgraphInputNode } from "./subgraph/SubgraphInputNode"
import type { SubgraphOutputNode } from "./subgraph/SubgraphOutputNode"
import type { CanvasMouseEvent } from "./types/events"
import type { ISerialisedNode } from "./types/serialisation"
import type { NodeLike } from "./types/NodeLike"
import type { ISerialisedNode, SubgraphIO } from "./types/serialisation"
import type { IBaseWidget, IWidgetOptions, TWidgetType, TWidgetValue } from "./types/widgets"
import { getNodeInputOnPos, getNodeOutputOnPos } from "./canvas/measureSlots"
import { NullGraphError } from "./infrastructure/NullGraphError"
import { Rectangle } from "./infrastructure/Rectangle"
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
import { LGraphCanvas } from "./LGraphCanvas"
import { type LGraphNodeConstructor, LiteGraph, Rectangle } from "./litegraph"
import { type LGraphNodeConstructor, LiteGraph, type Subgraph, type SubgraphNode } from "./litegraph"
import { LLink } from "./LLink"
import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure"
import { NodeInputSlot } from "./node/NodeInputSlot"
@@ -183,7 +188,7 @@ export interface LGraphNode {
* @param type a type for the node
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class LGraphNode implements Positionable, IPinnable, IColorable {
export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable {
// Static properties used by dynamic child classes
static title?: string
static MAX_CONSOLE?: number
@@ -211,7 +216,11 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px ${LiteGraph.NODE_FONT}`
}
graph: LGraph | null = null
get displayType(): string {
return this.type
}
graph: LGraph | Subgraph | null = null
id: NodeId
type: string = ""
inputs: INodeInputSlot[] = []
@@ -346,6 +355,14 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
selected?: boolean
showAdvanced?: boolean
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void
isSubgraphNode(): this is SubgraphNode {
return false
}
/** @inheritdoc {@link renderArea} */
#renderArea: Float32Array = new Float32Array(4)
/**
@@ -367,6 +384,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
return this.#boundingRect
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
get boundingOffset(): ReadOnlyPoint {
const { pos: [posX, posY], boundingRect: [bX, bY] } = this
return [posX - bX, posY - bY]
}
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
_posSize: Float32Array = new Float32Array(4)
_pos: Point = this._posSize.subarray(0, 2)
@@ -451,16 +474,16 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
this: LGraphNode,
target_slot: number,
type: unknown,
output: INodeOutputSlot,
node: LGraphNode,
output: INodeOutputSlot | SubgraphIO,
node: LGraphNode | SubgraphInputNode,
slot: number,
): boolean
onConnectOutput?(
this: LGraphNode,
slot: number,
type: unknown,
input: INodeInputSlot,
target_node: number | LGraphNode,
input: INodeInputSlot | SubgraphIO,
target_node: number | LGraphNode | SubgraphOutputNode,
target_slot: number,
): boolean
onResize?(this: LGraphNode, size: Size): void
@@ -477,7 +500,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
index: number,
isConnected: boolean,
link_info: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot,
inputOrOutput: INodeInputSlot | INodeOutputSlot | SubgraphIO,
): void
onInputAdded?(this: LGraphNode, input: INodeInputSlot): void
onOutputAdded?(this: LGraphNode, output: INodeOutputSlot): void
@@ -2309,7 +2332,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
* @returns The index and slot if found, otherwise `undefined`.
*/
findOutputByType(type: ISlotType): { index: number, slot: INodeOutputSlot } | undefined {
return findFreeSlotOfType(this.outputs, type)
return findFreeSlotOfType(this.outputs, type, output => !output.links?.length)
}
/**
@@ -2323,7 +2346,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
* @returns The index and slot if found, otherwise `undefined`.
*/
findInputByType(type: ISlotType): { index: number, slot: INodeInputSlot } | undefined {
return findFreeSlotOfType(this.inputs, type)
return findFreeSlotOfType(this.inputs, type, input => input.link == null)
}
/**
@@ -2384,9 +2407,9 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
}
canConnectTo(
node: LGraphNode,
toSlot: INodeInputSlot,
fromSlot: INodeOutputSlot,
node: NodeLike,
toSlot: INodeInputSlot | SubgraphIO,
fromSlot: INodeOutputSlot | SubgraphIO,
) {
return this.id !== node.id && LiteGraph.isValidConnection(fromSlot.type, toSlot.type)
}
@@ -2596,7 +2619,6 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
this.setDirtyCanvas(false, true)
graph.afterChange()
graph.connectionChange(this)
return link
}
@@ -2764,7 +2786,6 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
}
this.setDirtyCanvas(false, true)
graph.connectionChange(this)
return true
}
@@ -2812,6 +2833,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
// remove other side
const link_info = graph._links.get(link_id)
if (link_info) {
// Let SubgraphInput do the disconnect.
if (link_info.origin_id === -10 && "inputNode" in graph) {
graph.inputNode._disconnectNodeInput(this, input, link_info)
return true
}
const target_node = graph.getNodeById(link_info.origin_id)
if (!target_node) {
console.debug("disconnectInput: target node not found", link_info.origin_id)
@@ -2854,7 +2881,6 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
}
this.setDirtyCanvas(false, true)
graph?.connectionChange(this)
return true
}

View File

@@ -9,7 +9,9 @@ import type {
} from "./interfaces"
import type { LGraphNode, NodeId } from "./LGraphNode"
import type { Reroute, RerouteId } from "./Reroute"
import type { Serialisable, SerialisableLLink } from "./types/serialisation"
import type { Serialisable, SerialisableLLink, SubgraphIO } from "./types/serialisation"
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
export type LinkId = number
@@ -22,18 +24,61 @@ export type SerialisedLLinkArray = [
type: ISlotType,
]
export interface ResolvedConnection {
inputNode: LGraphNode | undefined
outputNode: LGraphNode | undefined
input: INodeInputSlot | undefined
output: INodeOutputSlot | undefined
// Resolved connection union; eliminates subgraph in/out as a possibility
export type ResolvedConnection = BaseResolvedConnection &
(
(ResolvedSubgraphInput & ResolvedNormalOutput) |
(ResolvedNormalInput & ResolvedSubgraphOutput) |
(ResolvedNormalInput & ResolvedNormalOutput)
)
interface BaseResolvedConnection {
link: LLink
/** The node on the input side of the link (owns {@link input}) */
inputNode?: LGraphNode
/** The input the link is connected to (mutually exclusive with {@link subgraphOutput}) */
input?: INodeInputSlot
/** The node on the output side of the link (owns {@link output}) */
outputNode?: LGraphNode
/** The output the link is connected to (mutually exclusive with {@link subgraphInput}) */
output?: INodeOutputSlot
/** The subgraph output the link is connected to (mutually exclusive with {@link input}) */
subgraphOutput?: SubgraphIO
/** The subgraph input the link is connected to (mutually exclusive with {@link output}) */
subgraphInput?: SubgraphIO
}
type BasicReadonlyNetwork = Pick<ReadonlyLinkNetwork, "getNodeById" | "links" | "getLink">
interface ResolvedNormalInput {
inputNode: LGraphNode | undefined
input: INodeInputSlot | undefined
subgraphOutput?: undefined
}
interface ResolvedNormalOutput {
outputNode: LGraphNode | undefined
output: INodeOutputSlot | undefined
subgraphInput?: undefined
}
interface ResolvedSubgraphInput {
inputNode?: undefined
/** The actual input slot the link is connected to (mutually exclusive with {@link subgraphOutput}) */
input?: undefined
subgraphOutput: SubgraphIO
}
interface ResolvedSubgraphOutput {
outputNode?: undefined
output?: undefined
subgraphInput: SubgraphIO
}
type BasicReadonlyNetwork = Pick<ReadonlyLinkNetwork, "getNodeById" | "links" | "getLink" | "inputNode" | "outputNode">
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
/** Link ID */
id: LinkId
parentId?: RerouteId
@@ -83,6 +128,16 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return this.isFloatingOutput || this.isFloatingInput
}
/** `true` if this link is connected to a subgraph input node (the actual origin is in a different graph). */
get originIsIoNode(): boolean {
return this.origin_id === SUBGRAPH_INPUT_ID
}
/** `true` if this link is connected to a subgraph output node (the actual target is in a different graph). */
get targetIsIoNode(): boolean {
return this.target_id === SUBGRAPH_OUTPUT_ID
}
constructor(
id: LinkId,
type: ISlotType,
@@ -230,10 +285,20 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
*/
resolve(network: BasicReadonlyNetwork): ResolvedConnection {
const inputNode = this.target_id === -1 ? undefined : network.getNodeById(this.target_id) ?? undefined
const outputNode = this.origin_id === -1 ? undefined : network.getNodeById(this.origin_id) ?? undefined
const input = inputNode?.inputs[this.target_slot]
const subgraphInput = this.originIsIoNode ? network.inputNode?.slots[this.origin_slot] : undefined
if (subgraphInput) {
return { inputNode, input, subgraphInput, link: this }
}
const outputNode = this.origin_id === -1 ? undefined : network.getNodeById(this.origin_id) ?? undefined
const output = outputNode?.outputs[this.origin_slot]
return { inputNode, outputNode, input, output, link: this }
const subgraphOutput = this.targetIsIoNode ? network.outputNode?.slots[this.target_slot] : undefined
if (subgraphOutput) {
return { outputNode, output, subgraphInput: undefined, subgraphOutput, link: this }
}
return { inputNode, outputNode, input, output, subgraphInput, subgraphOutput, link: this }
}
configure(o: LLink | SerialisedLLinkArray) {

View File

@@ -5,6 +5,7 @@ import { ContextMenu } from "./ContextMenu"
import { CurveEditor } from "./CurveEditor"
import { DragAndScale } from "./DragAndScale"
import { LabelPosition, SlotDirection, SlotShape, SlotType } from "./draw"
import { Rectangle } from "./infrastructure/Rectangle"
import { LGraph } from "./LGraph"
import { LGraphCanvas } from "./LGraphCanvas"
import { LGraphGroup } from "./LGraphGroup"
@@ -12,6 +13,8 @@ import { LGraphNode } from "./LGraphNode"
import { LLink } from "./LLink"
import { distance, isInsideRectangle, overlapBounding } from "./measure"
import { Reroute } from "./Reroute"
import { SubgraphIONodeBase } from "./subgraph/SubgraphIONodeBase"
import { SubgraphSlot } from "./subgraph/SubgraphSlotBase"
import {
LGraphEventMode,
LinkDirection,
@@ -324,7 +327,21 @@ export class LiteGraphGlobal {
ContextMenu = ContextMenu
CurveEditor = CurveEditor
Reroute = Reroute
InputIndicators = InputIndicators
constructor() {
Object.defineProperty(this, "Classes", { writable: false })
}
Classes = {
get SubgraphSlot() { return SubgraphSlot },
get SubgraphIONodeBase() { return SubgraphIONodeBase },
// Rich drawing
get Rectangle() { return Rectangle },
// Debug / helpers
get InputIndicators() { return InputIndicators },
}
onNodeTypeRegistered?(type: string, base_class: typeof LGraphNode): void
onNodeTypeReplaced?(type: string, base_class: typeof LGraphNode, prev: unknown): void

View File

@@ -7,7 +7,10 @@ import type { Point } from "@/interfaces"
import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
import { LinkDirection } from "@/types/globalEnums"
/**
@@ -136,6 +139,26 @@ export class FloatingRenderLink implements RenderLink {
output._floatingLinks.add(floatingLink)
}
connectToSubgraphInput(input: SubgraphInput, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_INPUT_ID
floatingLink.origin_slot = input.parent.slots.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToSubgraphOutput(output: SubgraphOutput, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
floatingLink.origin_slot = output.parent.slots.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
}
connectToRerouteInput(
reroute: Reroute,
{ node: inputNode, input }: { node: LGraphNode, input: INodeInputSlot },

View File

@@ -2,20 +2,28 @@ import type { RenderLink } from "./RenderLink"
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
import type { ConnectingLink, ItemLocator, LinkNetwork, LinkSegment } from "@/interfaces"
import type { INodeInputSlot, INodeOutputSlot } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { CanvasPointerEvent } from "@/types/events"
import type { IBaseWidget } from "@/types/widgets"
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
import { LGraphNode } from "@/LGraphNode"
import { LLink } from "@/LLink"
import { Subgraph } from "@/subgraph/Subgraph"
import { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
import { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
import { LinkDirection } from "@/types/globalEnums"
import { FloatingRenderLink } from "./FloatingRenderLink"
import { MovingInputLink } from "./MovingInputLink"
import { MovingLinkBase } from "./MovingLinkBase"
import { MovingOutputLink } from "./MovingOutputLink"
import { ToInputFromIoNodeLink } from "./ToInputFromIoNodeLink"
import { ToInputRenderLink } from "./ToInputRenderLink"
import { ToOutputFromIoNodeLink } from "./ToOutputFromIoNodeLink"
import { ToOutputFromRerouteLink } from "./ToOutputFromRerouteLink"
import { ToOutputRenderLink } from "./ToOutputRenderLink"
@@ -39,7 +47,14 @@ export interface LinkConnectorState {
}
/** Discriminated union to simplify type narrowing. */
type RenderLinkUnion = MovingInputLink | MovingOutputLink | FloatingRenderLink | ToInputRenderLink | ToOutputRenderLink
type RenderLinkUnion =
| MovingInputLink
| MovingOutputLink
| FloatingRenderLink
| ToInputRenderLink
| ToOutputRenderLink
| ToInputFromIoNodeLink
| ToOutputFromIoNodeLink
export interface LinkConnectorExport {
renderLinks: RenderLink[]
@@ -261,6 +276,24 @@ export class LinkConnector {
this.#setLegacyLinks(true)
}
dragNewFromSubgraphInput(network: LinkNetwork, inputNode: SubgraphInputNode, input: SubgraphInput, fromReroute?: Reroute): void {
if (this.isConnecting) throw new Error("Already dragging links.")
const renderLink = new ToInputFromIoNodeLink(network, inputNode, input, fromReroute)
this.renderLinks.push(renderLink)
this.state.connectingTo = "input"
}
dragNewFromSubgraphOutput(network: LinkNetwork, outputNode: SubgraphOutputNode, output: SubgraphOutput, fromReroute?: Reroute): void {
if (this.isConnecting) throw new Error("Already dragging links.")
const renderLink = new ToOutputFromIoNodeLink(network, outputNode, output, fromReroute)
this.renderLinks.push(renderLink)
this.state.connectingTo = "output"
}
/**
* Drags a new link from a reroute to an input slot.
* @param network The network that the link being connected belongs to
@@ -275,6 +308,25 @@ export class LinkConnector {
return
}
if (link.origin_id === SUBGRAPH_INPUT_ID) {
if (!(network instanceof Subgraph)) {
console.warn("Subgraph input link found in non-subgraph network.")
return
}
const input = network.inputs.at(link.origin_slot)
if (!input) throw new Error("No subgraph input found for link.")
const renderLink = new ToInputFromIoNodeLink(network, network.inputNode, input, reroute)
renderLink.fromDirection = LinkDirection.NONE
this.renderLinks.push(renderLink)
this.state.connectingTo = "input"
this.#setLegacyLinks(false)
return
}
const outputNode = network.getNodeById(link.origin_id)
if (!outputNode) {
console.warn("No output node found for link.", link)
@@ -310,6 +362,25 @@ export class LinkConnector {
return
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (!(network instanceof Subgraph)) {
console.warn("Subgraph output link found in non-subgraph network.")
return
}
const output = network.outputs.at(link.target_slot)
if (!output) throw new Error("No subgraph output found for link.")
const renderLink = new ToOutputFromIoNodeLink(network, network.outputNode, output, reroute)
renderLink.fromDirection = LinkDirection.NONE
this.renderLinks.push(renderLink)
this.state.connectingTo = "output"
this.#setLegacyLinks(false)
return
}
const inputNode = network.getNodeById(link.target_id)
if (!inputNode) {
console.warn("No input node found for link.", link)
@@ -367,22 +438,55 @@ export class LinkConnector {
const mayContinue = this.events.dispatch("before-drop-links", { renderLinks, event })
if (mayContinue === false) return
const { canvasX, canvasY } = event
const node = locator.getNodeOnPos(canvasX, canvasY) ?? undefined
if (node) {
this.dropOnNode(node, event)
} else {
// Get reroute if no node is found
const reroute = locator.getRerouteOnPos(canvasX, canvasY)
// Drop output->input link on reroute is not impl.
if (reroute && this.isRerouteValidDrop(reroute)) {
this.dropOnReroute(reroute, event)
} else {
this.dropOnNothing(event)
}
}
try {
const { canvasX, canvasY } = event
this.events.dispatch("after-drop-links", { renderLinks, event })
const ioNode = locator.getIoNodeOnPos?.(canvasX, canvasY)
if (ioNode) {
this.dropOnIoNode(ioNode, event)
return
}
const node = locator.getNodeOnPos(canvasX, canvasY) ?? undefined
if (node) {
this.dropOnNode(node, event)
} else {
// Get reroute if no node is found
const reroute = locator.getRerouteOnPos(canvasX, canvasY)
// Drop output->input link on reroute is not impl.
if (reroute && this.isRerouteValidDrop(reroute)) {
this.dropOnReroute(reroute, event)
} else {
this.dropOnNothing(event)
}
}
} finally {
this.events.dispatch("after-drop-links", { renderLinks, event })
}
}
dropOnIoNode(ioNode: SubgraphInputNode | SubgraphOutputNode, event: CanvasPointerEvent) {
const { renderLinks, state } = this
const { connectingTo } = state
const { canvasX, canvasY } = event
if (connectingTo === "input" && ioNode instanceof SubgraphOutputNode) {
const output = ioNode.getSlotInPosition(canvasX, canvasY)
if (!output) throw new Error("No output slot found for link.")
for (const link of renderLinks) {
link.connectToSubgraphOutput(output, this.events)
}
} else if (connectingTo === "output" && ioNode instanceof SubgraphInputNode) {
const input = ioNode.getSlotInPosition(canvasX, canvasY)
if (!input) throw new Error("No input slot found for link.")
for (const link of renderLinks) {
link.connectToSubgraphInput(input, this.events)
}
} else {
console.error("Invalid connectingTo state &/ ioNode", connectingTo, ioNode)
}
}
dropOnNode(node: LGraphNode, event: CanvasPointerEvent) {
@@ -523,7 +627,8 @@ export class LinkConnector {
if (connectingTo === "output") {
// Dropping new output link
const output = node.findOutputByType(firstLink.fromSlot.type)?.slot
if (!output) {
console.debug("out", node, output, firstLink.fromSlot)
if (output === undefined) {
console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`)
return
}
@@ -532,7 +637,8 @@ export class LinkConnector {
} else if (connectingTo === "input") {
// Dropping new input link
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
if (!input) {
console.debug("in", node, input, firstLink.fromSlot)
if (input === undefined) {
console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`)
return
}
@@ -616,7 +722,7 @@ export class LinkConnector {
const afterRerouteId = link instanceof MovingLinkBase ? link.link?.parentId : link.fromReroute?.id
return {
node: link.node,
node: link.node as LGraphNode,
slot: link.fromSlotIndex,
input,
output,
@@ -690,7 +796,7 @@ export class LinkConnector {
/** Validates that a single {@link RenderLink} can be dropped on the specified reroute. */
function canConnectInputLinkToReroute(
link: ToInputRenderLink | MovingInputLink | FloatingRenderLink,
link: ToInputRenderLink | MovingInputLink | FloatingRenderLink | ToInputFromIoNodeLink,
inputNode: LGraphNode,
input: INodeInputSlot,
reroute: Reroute,

View File

@@ -4,6 +4,9 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/inte
import type { LGraphNode } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { NodeLike } from "@/types/NodeLike"
import type { SubgraphIO } from "@/types/serialisation"
import { LinkDirection } from "@/types/globalEnums"
@@ -28,7 +31,7 @@ export class MovingInputLink extends MovingLinkBase {
this.fromSlotIndex = this.outputIndex
}
canConnectToInput(inputNode: LGraphNode, input: INodeInputSlot): boolean {
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot | SubgraphIO): boolean {
return this.node.canConnectTo(inputNode, input, this.outputSlot)
}
@@ -53,6 +56,15 @@ export class MovingInputLink extends MovingLinkBase {
throw new Error("MovingInputLink cannot connect to an output.")
}
connectToSubgraphInput(): void {
throw new Error("MovingInputLink cannot connect to a subgraph input.")
}
connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
const newLink = output.connect(this.fromSlot, this.node, this.fromReroute?.id)
events?.dispatch("link-created", newLink)
}
connectToRerouteInput(
reroute: Reroute,
{ node: inputNode, input, link: existingLink }: { node: LGraphNode, input: INodeInputSlot, link: LLink },

View File

@@ -5,6 +5,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/inte
import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import { LinkDirection } from "@/types/globalEnums"
@@ -82,6 +84,8 @@ export abstract class MovingLinkBase implements RenderLink {
abstract connectToInput(node: LGraphNode, input: INodeInputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
abstract connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
abstract connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void
abstract connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void
abstract connectToRerouteInput(reroute: Reroute, { node, input, link }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, events: CustomEventTarget<LinkConnectorEventMap>, originalReroutes: Reroute[]): void
abstract connectToRerouteOutput(reroute: Reroute, outputNode: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>): void

View File

@@ -4,6 +4,9 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/inte
import type { LGraphNode } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { NodeLike } from "@/types/NodeLike"
import type { SubgraphIO } from "@/types/serialisation"
import { LinkDirection } from "@/types/globalEnums"
@@ -32,7 +35,7 @@ export class MovingOutputLink extends MovingLinkBase {
return false
}
canConnectToOutput(outputNode: LGraphNode, output: INodeOutputSlot): boolean {
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
return outputNode.canConnectTo(this.node, this.inputSlot, output)
}
@@ -52,6 +55,15 @@ export class MovingOutputLink extends MovingLinkBase {
return link
}
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
const newLink = input.connect(this.fromSlot, this.node, this.fromReroute?.id)
events?.dispatch("link-created", newLink)
}
connectToSubgraphOutput(): void {
throw new Error("MovingOutputLink cannot connect to a subgraph output.")
}
connectToRerouteInput(): never {
throw new Error("MovingOutputLink cannot connect to an input.")
}

View File

@@ -3,6 +3,9 @@ import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventM
import type { LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { INodeInputSlot, INodeOutputSlot, LLink, Reroute } from "@/litegraph"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphIONodeBase } from "@/subgraph/SubgraphIONodeBase"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { LinkDirection } from "@/types/globalEnums"
export interface RenderLink {
@@ -18,9 +21,9 @@ export interface RenderLink {
/** The network that the link belongs to. */
readonly network: LinkNetwork
/** The node that the link is being connected from. */
readonly node: LGraphNode
readonly node: LGraphNode | SubgraphIONodeBase<SubgraphInput | SubgraphOutput>
/** The slot that the link is being connected from. */
readonly fromSlot: INodeOutputSlot | INodeInputSlot
readonly fromSlot: INodeOutputSlot | INodeInputSlot | SubgraphInput | SubgraphOutput
/** The index of the slot that the link is being connected from. */
readonly fromSlotIndex: number
/** The reroute that the link is being connected from. */
@@ -28,6 +31,8 @@ export interface RenderLink {
connectToInput(node: LGraphNode, input: INodeInputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void
connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void
connectToRerouteInput(
reroute: Reroute,

View File

@@ -0,0 +1,114 @@
import type { RenderLink } from "./RenderLink"
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
import type { INodeInputSlot, LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { NodeLike } from "@/types/NodeLike"
import { LinkDirection } from "@/types/globalEnums"
/** Connecting TO an input slot. */
export class ToInputFromIoNodeLink implements RenderLink {
readonly toType = "input"
readonly fromSlotIndex: number
readonly fromPos: Point
fromDirection: LinkDirection = LinkDirection.RIGHT
constructor(
readonly network: LinkNetwork,
readonly node: SubgraphInputNode,
readonly fromSlot: SubgraphInput,
readonly fromReroute?: Reroute,
public dragDirection: LinkDirection = LinkDirection.CENTER,
) {
const outputIndex = node.slots.indexOf(fromSlot)
if (outputIndex === -1 && fromSlot !== node.emptySlot) {
throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
}
this.fromSlotIndex = outputIndex
this.fromPos = fromReroute
? fromReroute.pos
: fromSlot.pos
}
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot): boolean {
return this.node.canConnectTo(inputNode, input, this.fromSlot)
}
canConnectToOutput(): false {
return false
}
connectToInput(node: LGraphNode, input: INodeInputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
const { fromSlot, fromReroute } = this
const newLink = fromSlot.connect(input, node, fromReroute?.id)
events.dispatch("link-created", newLink)
}
connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
throw new Error("Not implemented")
}
connectToRerouteInput(
reroute: Reroute,
{
node: inputNode,
input,
link,
}: { node: LGraphNode, input: INodeInputSlot, link: LLink },
events: CustomEventTarget<LinkConnectorEventMap>,
originalReroutes: Reroute[],
) {
const { fromSlot, fromReroute } = this
// Check before creating new link overwrites the value
const floatingTerminus = fromReroute?.floating?.slotType === "output"
// Set the parentId of the reroute we dropped on, to the reroute we dragged from
reroute.parentId = fromReroute?.id
const newLink = fromSlot.connect(input, inputNode, link.parentId)
// Connecting from the final reroute of a floating reroute chain
if (floatingTerminus) fromReroute.removeAllFloatingLinks()
// Clean up reroutes
for (const reroute of originalReroutes) {
if (reroute.id === fromReroute?.id) break
reroute.removeLink(link)
if (reroute.totalLinks === 0) {
if (link.isFloating) {
// Cannot float from both sides - remove
reroute.remove()
} else {
// Convert to floating
const cl = link.toFloating("output", reroute.id)
this.network.addFloatingLink(cl)
reroute.floating = { slotType: "output" }
}
}
}
events.dispatch("link-created", newLink)
}
connectToOutput() {
throw new Error("ToInputRenderLink cannot connect to an output.")
}
connectToSubgraphInput(): void {
throw new Error("ToInputRenderLink cannot connect to a subgraph input.")
}
connectToRerouteOutput() {
throw new Error("ToInputRenderLink cannot connect to an output.")
}
}

View File

@@ -5,6 +5,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/inte
import type { LGraphNode } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { NodeLike } from "@/types/NodeLike"
import { LinkDirection } from "@/types/globalEnums"
@@ -32,7 +34,7 @@ export class ToInputRenderLink implements RenderLink {
: this.node.getOutputPos(outputIndex)
}
canConnectToInput(inputNode: LGraphNode, input: INodeInputSlot): boolean {
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot): boolean {
return this.node.canConnectTo(inputNode, input, this.fromSlot)
}
@@ -48,6 +50,11 @@ export class ToInputRenderLink implements RenderLink {
events.dispatch("link-created", newLink)
}
connectToSubgraphOutput(output: SubgraphOutput, events: CustomEventTarget<LinkConnectorEventMap>) {
const newLink = output.connect(this.fromSlot, this.node, this.fromReroute?.id)
events.dispatch("link-created", newLink)
}
connectToRerouteInput(
reroute: Reroute,
{
@@ -95,6 +102,10 @@ export class ToInputRenderLink implements RenderLink {
throw new Error("ToInputRenderLink cannot connect to an output.")
}
connectToSubgraphInput(): void {
throw new Error("ToInputRenderLink cannot connect to a subgraph input.")
}
connectToRerouteOutput() {
throw new Error("ToInputRenderLink cannot connect to an output.")
}

View File

@@ -0,0 +1,88 @@
import type { RenderLink } from "./RenderLink"
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
import type { INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
import type { NodeLike } from "@/types/NodeLike"
import type { SubgraphIO } from "@/types/serialisation"
import { LinkDirection } from "@/types/globalEnums"
/** Connecting TO an output slot. */
export class ToOutputFromIoNodeLink implements RenderLink {
readonly toType = "output"
readonly fromPos: Point
readonly fromSlotIndex: number
fromDirection: LinkDirection = LinkDirection.LEFT
constructor(
readonly network: LinkNetwork,
readonly node: SubgraphOutputNode,
readonly fromSlot: SubgraphOutput,
readonly fromReroute?: Reroute,
public dragDirection: LinkDirection = LinkDirection.CENTER,
) {
const inputIndex = node.slots.indexOf(fromSlot)
if (inputIndex === -1 && fromSlot !== node.emptySlot) {
throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
}
this.fromSlotIndex = inputIndex
this.fromPos = fromReroute
? fromReroute.pos
: fromSlot.pos
}
canConnectToInput(): false {
return false
}
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
return this.node.canConnectTo(outputNode, this.fromSlot, output)
}
canConnectToReroute(reroute: Reroute): boolean {
if (reroute.origin_id === this.node.id) return false
return true
}
connectToOutput(node: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
const { fromSlot, fromReroute } = this
const newLink = fromSlot.connect(output, node, fromReroute?.id)
events.dispatch("link-created", newLink)
}
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
throw new Error("Not implemented")
}
connectToRerouteOutput(
reroute: Reroute,
outputNode: LGraphNode,
output: INodeOutputSlot,
events: CustomEventTarget<LinkConnectorEventMap>,
): void {
const { fromSlot } = this
const newLink = fromSlot.connect(output, outputNode, reroute?.id)
events.dispatch("link-created", newLink)
}
connectToInput() {
throw new Error("ToOutputRenderLink cannot connect to an input.")
}
connectToSubgraphOutput(): void {
throw new Error("ToOutputRenderLink cannot connect to a subgraph output.")
}
connectToRerouteInput() {
throw new Error("ToOutputRenderLink cannot connect to an input.")
}
}

View File

@@ -4,6 +4,9 @@ import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventM
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { Reroute } from "@/Reroute"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { NodeLike } from "@/types/NodeLike"
import type { SubgraphIO } from "@/types/serialisation"
import { LinkDirection } from "@/types/globalEnums"
@@ -35,7 +38,7 @@ export class ToOutputRenderLink implements RenderLink {
return false
}
canConnectToOutput(outputNode: LGraphNode, output: INodeOutputSlot): boolean {
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
return this.node.canConnectTo(outputNode, this.fromSlot, output)
}
@@ -52,6 +55,11 @@ export class ToOutputRenderLink implements RenderLink {
events.dispatch("link-created", newLink)
}
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
const newLink = input.connect(this.fromSlot, this.node, this.fromReroute?.id)
events?.dispatch("link-created", newLink)
}
connectToRerouteOutput(
reroute: Reroute,
outputNode: LGraphNode,
@@ -67,6 +75,10 @@ export class ToOutputRenderLink implements RenderLink {
throw new Error("ToOutputRenderLink cannot connect to an input.")
}
connectToSubgraphOutput(): void {
throw new Error("ToOutputRenderLink cannot connect to a subgraph output.")
}
connectToRerouteInput() {
throw new Error("ToOutputRenderLink cannot connect to an input.")
}

11
src/constants.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Subgraph constants
*
* This entire module is exported as `Constants`.
*/
/** ID of the virtual input node of a subgraph. */
export const SUBGRAPH_INPUT_ID = -10
/** ID of the virtual output node of a subgraph. */
export const SUBGRAPH_OUTPUT_ID = -20

View File

@@ -0,0 +1,75 @@
import type { ReadOnlyRect, ReadOnlySize, Size } from "@/interfaces"
import { clamp } from "@/litegraph"
/**
* Basic width and height, with min/max constraints.
*
* - The {@link width} and {@link height} properties are readonly
* - Size is set via {@link desiredWidth} and {@link desiredHeight} properties
* - Width and height are then updated, clamped to min/max values
*/
export class ConstrainedSize {
#width: number = 0
#height: number = 0
#desiredWidth: number = 0
#desiredHeight: number = 0
minWidth: number = 0
minHeight: number = 0
maxWidth: number = Infinity
maxHeight: number = Infinity
get width() {
return this.#width
}
get height() {
return this.#height
}
get desiredWidth() {
return this.#desiredWidth
}
set desiredWidth(value: number) {
this.#desiredWidth = value
this.#width = clamp(value, this.minWidth, this.maxWidth)
}
get desiredHeight() {
return this.#desiredHeight
}
set desiredHeight(value: number) {
this.#desiredHeight = value
this.#height = clamp(value, this.minHeight, this.maxHeight)
}
constructor(width: number, height: number) {
this.desiredWidth = width
this.desiredHeight = height
}
static fromSize(size: ReadOnlySize): ConstrainedSize {
return new ConstrainedSize(size[0], size[1])
}
static fromRect(rect: ReadOnlyRect): ConstrainedSize {
return new ConstrainedSize(rect[2], rect[3])
}
setSize(size: ReadOnlySize): void {
this.desiredWidth = size[0]
this.desiredHeight = size[1]
}
setValues(width: number, height: number): void {
this.desiredWidth = width
this.desiredHeight = height
}
toSize(): Size {
return [this.#width, this.#height]
}
}

View File

@@ -0,0 +1,6 @@
export class InvalidLinkError extends Error {
constructor(message: string = "Attempted to access a link that was invalid.", cause?: Error) {
super(message, { cause })
this.name = "InvalidLinkError"
}
}

View File

@@ -1,9 +1,19 @@
import type { ConnectingLink } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { LGraphGroup } from "@/LGraphGroup"
import type { LGraphNode } from "@/LGraphNode"
import type { Subgraph } from "@/subgraph/Subgraph"
import type { CanvasPointerEvent } from "@/types/events"
export interface LGraphCanvasEventMap {
/** The active graph has changed. */
"litegraph:set-graph": {
/** The new active graph. */
newGraph: LGraph | Subgraph
/** The old active graph, or `null` if there was no active graph. */
oldGraph: LGraph | Subgraph | null | undefined
}
"litegraph:canvas":
| { subType: "before-change" | "after-change" }
| {

View File

@@ -0,0 +1,47 @@
import type { ReadOnlyRect } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { LLink, ResolvedConnection } from "@/LLink"
import type { Subgraph } from "@/subgraph/Subgraph"
import type { ExportedSubgraph, ISerialisedGraph, SerialisableGraph } from "@/types/serialisation"
export interface LGraphEventMap {
"configuring": {
/** The data that was used to configure the graph. */
data: ISerialisedGraph | SerialisableGraph
/** If `true`, the graph will be cleared prior to adding the configuration. */
clearGraph: boolean
}
"configured": never
"subgraph-created": {
/** The subgraph that was created. */
subgraph: Subgraph
/** The raw data that was used to create the subgraph. */
data: ExportedSubgraph
}
/** Dispatched when a group of items are converted to a subgraph. */
"convert-to-subgraph": {
/** The type of subgraph to create. */
subgraph: Subgraph
/** The boundary around every item that was moved into the subgraph. */
bounds: ReadOnlyRect
/** The raw data that was used to create the subgraph. */
exportedSubgraph: ExportedSubgraph
/** The links that were used to create the subgraph. */
boundaryLinks: LLink[]
/** Links that go from outside the subgraph in, via an input on the subgraph node. */
resolvedInputLinks: ResolvedConnection[]
/** Links that go from inside the subgraph out, via an output on the subgraph node. */
resolvedOutputLinks: ResolvedConnection[]
/** The floating links that were used to create the subgraph. */
boundaryFloatingLinks: LLink[]
/** The internal links that were used to create the subgraph. */
internalLinks: LLink[]
}
"open-subgraph": {
subgraph: Subgraph
closingGraph: LGraph | Subgraph
}
}

View File

@@ -6,6 +6,8 @@ import type { ToInputRenderLink } from "@/canvas/ToInputRenderLink"
import type { LGraphNode } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import type { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
import type { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
import type { CanvasPointerEvent } from "@/types/events"
import type { IWidget } from "@/types/widgets"
@@ -37,6 +39,10 @@ export interface LinkConnectorEventMap {
node: LGraphNode
event: CanvasPointerEvent
}
"dropped-on-io-node": {
node: SubgraphInputNode | SubgraphOutputNode
event: CanvasPointerEvent
}
"dropped-on-canvas": CanvasPointerEvent
"dropped-on-widget": {

View File

@@ -6,7 +6,8 @@ import { isInRectangle } from "@/measure"
* A rectangle, represented as a float64 array of 4 numbers: [x, y, width, height].
*
* This class is a subclass of Float64Array, and so has all the methods of that class. Notably,
* {@link Rectangle.from} can be used to convert a {@link ReadOnlyRect}.
* {@link Rectangle.from} can be used to convert a {@link ReadOnlyRect}. Typing of this however,
* is broken due to the base TS lib returning Float64Array rather than `this`.
*
* Sub-array properties ({@link Float64Array.subarray}):
* - {@link pos}: The position of the top-left corner of the rectangle.
@@ -25,6 +26,29 @@ export class Rectangle extends Float64Array {
this[3] = height
}
static override from([x, y, width, height]: ReadOnlyRect): Rectangle {
return new Rectangle(x, y, width, height)
}
/**
* Creates a new rectangle positioned at the given centre, with the given width/height.
* @param centre The centre of the rectangle, as an `[x, y]` point
* @param width The width of the rectangle
* @param height The height of the rectangle. Default: {@link width}
* @returns A new rectangle whose centre is at {@link x}
*/
static fromCentre([x, y]: ReadOnlyPoint, width: number, height = width): Rectangle {
const left = x - width * 0.5
const top = y - height * 0.5
return new Rectangle(left, top, width, height)
}
static ensureRect(rect: ReadOnlyRect): Rectangle {
return rect instanceof Rectangle
? rect
: new Rectangle(rect[0], rect[1], rect[2], rect[3])
}
override subarray(begin: number = 0, end?: number): Float64Array<ArrayBuffer> {
const byteOffset = begin << 3
const length = end === undefined ? end : end - begin
@@ -163,7 +187,7 @@ export class Rectangle extends Float64Array {
* @returns `true` if the point is inside this rectangle, otherwise `false`.
*/
containsXy(x: number, y: number): boolean {
const { x: left, y: top, width, height } = this
const [left, top, width, height] = this
return x >= left &&
x < left + width &&
y >= top &&
@@ -175,23 +199,35 @@ export class Rectangle extends Float64Array {
* @param point The point to check
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
*/
containsPoint(point: ReadOnlyPoint): boolean {
return this.x <= point[0] &&
this.y <= point[1] &&
this.x + this.width >= point[0] &&
this.y + this.height >= point[1]
containsPoint([x, y]: ReadOnlyPoint): boolean {
const [left, top, width, height] = this
return x >= left &&
x < left + width &&
y >= top &&
y < top + height
}
/**
* Checks if {@link rect} is inside this rectangle.
* @param rect The rectangle to check
* @returns `true` if {@link rect} is inside this rectangle, otherwise `false`.
* Checks if {@link other} is a smaller rectangle inside this rectangle.
* One **must** be larger than the other; identical rectangles are not considered to contain each other.
* @param other The rectangle to check
* @returns `true` if {@link other} is inside this rectangle, otherwise `false`.
*/
containsRect(rect: ReadOnlyRect): boolean {
return this.x <= rect[0] &&
this.y <= rect[1] &&
this.x + this.width >= rect[0] + rect[2] &&
this.y + this.height >= rect[1] + rect[3]
containsRect(other: ReadOnlyRect): boolean {
const { right, bottom } = this
const otherRight = other[0] + other[2]
const otherBottom = other[1] + other[3]
const identical = this.x === other[0] &&
this.y === other[1] &&
right === otherRight &&
bottom === otherBottom
return !identical &&
this.x <= other[0] &&
this.y <= other[1] &&
right >= otherRight &&
bottom >= otherBottom
}
/**
@@ -345,6 +381,10 @@ export class Rectangle extends Float64Array {
this[1] += currentHeight - height
}
clone(): Rectangle {
return new Rectangle(this[0], this[1], this[2], this[3])
}
/** Alias of {@link export}. */
toArray() { return this.export() }
@@ -353,7 +393,10 @@ export class Rectangle extends Float64Array {
return [this[0], this[1], this[2], this[3]]
}
/** Draws a debug outline of this rectangle. */
/**
* Draws a debug outline of this rectangle.
* @internal Convenience debug/development interface; not for production use.
*/
_drawDebug(ctx: CanvasRenderingContext2D, colour = "red") {
const { strokeStyle, lineWidth } = ctx
try {

View File

@@ -0,0 +1,9 @@
/**
* Error thrown when infinite recursion is detected.
*/
export class RecursionError extends Error {
constructor(subject: string) {
super(subject)
this.name = "RecursionError"
}
}

View File

@@ -0,0 +1,6 @@
export class SlotIndexError extends Error {
constructor(message: string = "Attempted to access a slot that was out of bounds.", cause?: Error) {
super(message, { cause })
this.name = "SlotIndexError"
}
}

View File

@@ -2,7 +2,12 @@ import type { ContextMenu } from "./ContextMenu"
import type { LGraphNode, NodeId } from "./LGraphNode"
import type { LinkId, LLink } from "./LLink"
import type { Reroute, RerouteId } from "./Reroute"
import type { SubgraphInput } from "./subgraph/SubgraphInput"
import type { SubgraphInputNode } from "./subgraph/SubgraphInputNode"
import type { SubgraphOutputNode } from "./subgraph/SubgraphOutputNode"
import type { LinkDirection, RenderShape } from "./types/globalEnums"
import type { Rectangle } from "@/infrastructure/Rectangle"
import type { CanvasPointerEvent } from "@/types/events"
export type Dictionary<T> = { [key: string]: T }
@@ -73,6 +78,12 @@ export interface Positionable extends Parent<Positionable>, HasBoundingRect {
/** See {@link IPinnable.pinned} */
readonly pinned?: boolean
/**
* When explicitly set to `false`, no options to delete this item will be provided.
* @default undefined (true)
*/
readonly removable?: boolean
/**
* Adds a delta to the current position.
* @param deltaX X value to add to current position
@@ -134,6 +145,9 @@ export interface ReadonlyLinkNetwork {
getLink(id: LinkId | null | undefined): LLink | undefined
getReroute(parentId: null | undefined): undefined
getReroute(parentId: RerouteId | null | undefined): Reroute | undefined
readonly inputNode?: SubgraphInputNode
readonly outputNode?: SubgraphOutputNode
}
/**
@@ -153,6 +167,7 @@ export interface LinkNetwork extends ReadonlyLinkNetwork {
export interface ItemLocator {
getNodeOnPos(x: number, y: number, nodeList?: LGraphNode[]): LGraphNode | null
getRerouteOnPos(x: number, y: number): Reroute | undefined
getIoNodeOnPos?(x: number, y: number): SubgraphInputNode | SubgraphOutputNode | undefined
}
/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */
@@ -420,6 +435,11 @@ export interface DefaultConnectionColors {
getConnectedColor(type: ISlotType): CanvasColour
getDisconnectedColor(type: ISlotType): CanvasColour
}
export interface ISubgraphInput extends INodeInputSlot {
_subgraphSlot: SubgraphInput
}
/**
* Shorthand for {@link Parameters} of optional callbacks.
* @example
@@ -440,3 +460,17 @@ export type CallbackParams<T extends ((...args: any) => any) | undefined> =
* @see {@link CallbackParams}
*/
export type CallbackReturn<T extends ((...args: any) => any) | undefined> = ReturnType<Exclude<T, undefined>>
/**
* An object that can be hovered over.
*/
export interface Hoverable extends HasBoundingRect {
readonly boundingRect: Rectangle
isPointerOver: boolean
containsPoint(point: Point): boolean
onPointerMove(e: CanvasPointerEvent): void
onPointerEnter?(e?: CanvasPointerEvent): void
onPointerLeave?(e?: CanvasPointerEvent): void
}

View File

@@ -91,6 +91,7 @@ export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
export { InputIndicators } from "./canvas/InputIndicators"
export { isOverNodeInput, isOverNodeOutput } from "./canvas/measureSlots"
export { CanvasPointer } from "./CanvasPointer"
export * as Constants from "./constants"
export { ContextMenu } from "./ContextMenu"
export { CurveEditor } from "./CurveEditor"
export { DragAndScale } from "./DragAndScale"
@@ -134,6 +135,8 @@ export { LGraphNode, type NodeId } from "./LGraphNode"
export { type LinkId, LLink } from "./LLink"
export { clamp, createBounds } from "./measure"
export { Reroute, type RerouteId } from "./Reroute"
export { type ExecutableLGraphNode, ExecutableNodeDTO } from "./subgraph/ExecutableNodeDTO"
export { SubgraphNode } from "./subgraph/SubgraphNode"
export type { CanvasPointerEvent } from "./types/events"
export {
CanvasItem,

View File

@@ -6,7 +6,7 @@ import type {
Rect,
} from "./interfaces"
import { LinkDirection } from "./types/globalEnums"
import { Alignment, hasFlag, LinkDirection } from "./types/globalEnums"
/**
* Calculates the distance between two points (2D vector)
@@ -369,6 +369,92 @@ export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
return true
}
/**
* Aligns a {@link Rect} relative to the edges or centre of a {@link container} rectangle.
*
* With no {@link inset}, the element will be placed on the interior of the {@link container},
* with their edges lined up on the {@link anchors}. A positive {@link inset} moves the element towards the centre,
* negative will push it outside the {@link container}.
* @param rect The bounding rect of the element to align.
* If using the element's pos/size backing store, this function will move the element.
* @param anchors The direction(s) to anchor the element to
* @param container The rectangle inside which to align the element
* @param inset Relative offset from each {@link anchors} edge, with positive always leading to the centre, as an `[x, y]` point
* @returns The original {@link rect}, modified in place.
*/
export function alignToContainer(
rect: Rect,
anchors: Alignment,
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
[insetX, insetY]: ReadOnlyPoint = [0, 0],
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left
rect[0] = containerX + insetX
} else if (hasFlag(anchors, Alignment.Right)) {
// Right
rect[0] = containerX + containerWidth - insetX - rect[2]
} else if (hasFlag(anchors, Alignment.Centre)) {
// Horizontal centre
rect[0] = containerX + (containerWidth * 0.5) - (rect[2] * 0.5)
}
if (hasFlag(anchors, Alignment.Top)) {
// Top
rect[1] = containerY + insetY
} else if (hasFlag(anchors, Alignment.Bottom)) {
// Bottom
rect[1] = containerY + containerHeight - insetY - rect[3]
} else if (hasFlag(anchors, Alignment.Middle)) {
// Vertical middle
rect[1] = containerY + (containerHeight * 0.5) - (rect[3] * 0.5)
}
return rect
}
/**
* Aligns a {@link Rect} relative to the edges of {@link other}.
*
* With no {@link outset}, the element will be placed on the exterior of the {@link other},
* with their edges lined up on the {@link anchors}. A positive {@link outset} moves the element away from the {@link other},
* negative will push it inside the {@link other}.
* @param rect The bounding rect of the element to align.
* If using the element's pos/size backing store, this function will move the element.
* @param anchors The direction(s) to anchor the element to
* @param other The rectangle to align {@link rect} to
* @param outset Relative offset from each {@link anchors} edge, with positive always moving away from the centre of the {@link other}, as an `[x, y]` point
* @returns The original {@link rect}, modified in place.
*/
export function alignOutsideContainer(
rect: Rect,
anchors: Alignment,
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
[outsetX, outsetY]: ReadOnlyPoint = [0, 0],
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left
rect[0] = otherX - outsetX - rect[2]
} else if (hasFlag(anchors, Alignment.Right)) {
// Right
rect[0] = otherX + otherWidth + outsetX
} else if (hasFlag(anchors, Alignment.Centre)) {
// Horizontal centre
rect[0] = otherX + (otherWidth * 0.5) - (rect[2] * 0.5)
}
if (hasFlag(anchors, Alignment.Top)) {
// Top
rect[1] = otherY - outsetY - rect[3]
} else if (hasFlag(anchors, Alignment.Bottom)) {
// Bottom
rect[1] = otherY + otherHeight + outsetY
} else if (hasFlag(anchors, Alignment.Middle)) {
// Vertical middle
rect[1] = otherY + (otherHeight * 0.5) - (rect[3] * 0.5)
}
return rect
}
export function clamp(value: number, min: number, max: number): number {
return value < min ? min : (value > max ? max : value)
}

View File

@@ -2,7 +2,7 @@ import type { CanvasColour, DefaultConnectionColors, INodeInputSlot, INodeOutput
import type { LGraphNode } from "@/LGraphNode"
import { LabelPosition, SlotShape, SlotType } from "@/draw"
import { LiteGraph } from "@/litegraph"
import { LiteGraph, Rectangle } from "@/litegraph"
import { getCentre } from "@/measure"
import { LinkDirection, RenderShape } from "@/types/globalEnums"
@@ -52,9 +52,12 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
abstract get isWidgetInputSlot(): boolean
constructor(slot: OptionalProps<INodeSlot, "boundingRect">, node: LGraphNode) {
super(slot.name, slot.type, slot.boundingRect ?? [0, 0, 0, 0])
const { boundingRect, name, type, ...rest } = slot
const rectangle = boundingRect ? Rectangle.ensureRect(boundingRect) : new Rectangle()
Object.assign(this, slot)
super(name, type, rectangle)
Object.assign(this, rest)
this.#node = node
}

View File

@@ -1,8 +1,10 @@
import type { CanvasColour, DefaultConnectionColors, INodeSlot, ISlotType, IWidgetLocator, Point, Rect } from "@/interfaces"
import type { CanvasColour, DefaultConnectionColors, INodeSlot, ISlotType, IWidgetLocator, Point } from "@/interfaces"
import type { LLink } from "@/LLink"
import type { RenderShape } from "@/types/globalEnums"
import type { LinkDirection } from "@/types/globalEnums"
import { Rectangle } from "@/infrastructure/Rectangle"
/** Base class for all input & output slots. */
export abstract class SlotBase implements INodeSlot {
@@ -23,12 +25,12 @@ export abstract class SlotBase implements INodeSlot {
/** The centre point of the slot. */
abstract pos?: Point
readonly boundingRect: Rect
readonly boundingRect: Rectangle
constructor(name: string, type: ISlotType, boundingRect: Rect) {
constructor(name: string, type: ISlotType, boundingRect?: Rectangle) {
this.name = name
this.type = type
this.boundingRect = boundingRect
this.boundingRect = boundingRect ?? new Rectangle()
}
abstract get isConnected(): boolean

View File

@@ -21,3 +21,19 @@ export function stringOrEmpty(value: unknown): string {
export function parseSlotTypes(type: ISlotType): string[] {
return type == "" || type == "0" ? ["*"] : String(type).toLowerCase().split(",")
}
/**
* Creates a unique name by appending an underscore and a number to the end of the name
* if it already exists.
* @param name The name to make unique
* @param existingNames The names that already exist. Default: an empty array
* @returns The name, or a unique name if it already exists.
*/
export function nextUniqueName(name: string, existingNames: string[] = []): string {
let i = 1
const baseName = name
while (existingNames.includes(name)) {
name = `${baseName}_${i++}`
}
return name
}

View File

@@ -0,0 +1,39 @@
import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { INodeInputSlot, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import { LLink } from "@/LLink"
import { nextUniqueName } from "@/strings"
import { zeroUuid } from "@/utils/uuid"
import { SubgraphInput } from "./SubgraphInput"
/**
* A virtual slot that simply creates a new input slot when connected to.
*/
export class EmptySubgraphInput extends SubgraphInput {
declare parent: SubgraphInputNode
constructor(parent: SubgraphInputNode) {
super({
id: zeroUuid,
name: "",
type: "",
}, parent)
}
override connect(slot: INodeInputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
const { subgraph } = this.parent
const existingNames = subgraph.inputs.map(x => x.name)
const name = nextUniqueName(slot.name, existingNames)
const input = subgraph.addInput(name, String(slot.type))
return input.connect(slot, node, afterRerouteId)
}
override get labelPos(): Point {
const [x, y, , height] = this.boundingRect
return [x, y + height * 0.5]
}
}

View File

@@ -0,0 +1,39 @@
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
import type { INodeOutputSlot, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import { LLink } from "@/LLink"
import { nextUniqueName } from "@/strings"
import { zeroUuid } from "@/utils/uuid"
import { SubgraphOutput } from "./SubgraphOutput"
/**
* A virtual slot that simply creates a new output slot when connected to.
*/
export class EmptySubgraphOutput extends SubgraphOutput {
declare parent: SubgraphOutputNode
constructor(parent: SubgraphOutputNode) {
super({
id: zeroUuid,
name: "",
type: "",
}, parent)
}
override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
const { subgraph } = this.parent
const existingNames = subgraph.outputs.map(x => x.name)
const name = nextUniqueName(slot.name, existingNames)
const output = subgraph.addOutput(name, String(slot.type))
return output.connect(slot, node, afterRerouteId)
}
override get labelPos(): Point {
const [x, y, , height] = this.boundingRect
return [x, y + height * 0.5]
}
}

View File

@@ -0,0 +1,232 @@
import type { SubgraphNode } from "./SubgraphNode"
import type { CallbackParams, CallbackReturn, ISlotType } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { LGraphNode, NodeId } from "@/LGraphNode"
import { InvalidLinkError } from "@/infrastructure/InvalidLinkError"
import { NullGraphError } from "@/infrastructure/NullGraphError"
import { RecursionError } from "@/infrastructure/RecursionError"
import { SlotIndexError } from "@/infrastructure/SlotIndexError"
import { LGraphEventMode } from "@/litegraph"
import { Subgraph } from "./Subgraph"
/**
* Interface describing the data transfer objects used when compiling a graph for execution.
*/
export type ExecutableLGraphNode = Omit<ExecutableNodeDTO, "graph" | "node" | "subgraphNodePath" | "subgraphNode">
type NodeAndInput = {
node: ExecutableLGraphNode
origin_id: NodeId
origin_slot: number
}
/**
* Concrete implementation of {@link ExecutableLGraphNode}.
* @remarks This is the class that is used to create the data transfer objects for executable nodes.
*/
export class ExecutableNodeDTO implements ExecutableLGraphNode {
applyToGraph?(...args: CallbackParams<typeof this.node.applyToGraph>): CallbackReturn<typeof this.node.applyToGraph>
/** The graph that this node is a part of. */
readonly graph: LGraph | Subgraph
inputs: { linkId: number | null, name: string, type: ISlotType }[]
/** Backing field for {@link id}. */
#id: NodeId
/**
* The path to the acutal node through subgraph instances, represented as a list of all subgraph node IDs (instances),
* followed by the actual original node ID within the subgraph. Each segment is separated by `:`.
*
* e.g. `1:2:3`:
* - `1` is the node ID of the first subgraph node in the parent workflow
* - `2` is the node ID of the second subgraph node in the first subgraph
* - `3` is the node ID of the actual node in the subgraph definition
*/
get id() {
return this.#id
}
get type() {
return this.node.type
}
get title() {
return this.node.title
}
get mode() {
return this.node.mode
}
get comfyClass() {
return this.node.comfyClass
}
get isVirtualNode() {
return this.node.isVirtualNode
}
get widgets() {
return this.node.widgets
}
constructor(
/** The actual node that this DTO wraps. */
readonly node: LGraphNode | SubgraphNode,
/** A list of subgraph instance node IDs from the root graph to the containing instance. @see {@link id} */
readonly subgraphNodePath: readonly NodeId[],
/** The actual subgraph instance that contains this node, otherise undefined. */
readonly subgraphNode?: SubgraphNode,
) {
if (!node.graph) throw new NullGraphError()
// Set the internal ID of the DTO
this.#id = [...this.subgraphNodePath, this.node.id].join(":")
this.graph = node.graph
this.inputs = this.node.inputs.map(x => ({
linkId: x.link,
name: x.name,
type: x.type,
}))
// Only create a wrapper if the node has an applyToGraph method
if (this.node.applyToGraph) {
this.applyToGraph = (...args) => this.node.applyToGraph?.(...args)
}
}
/** Returns either the DTO itself, or the DTOs of the inner nodes of the subgraph. */
getInnerNodes(): ExecutableLGraphNode[] {
return this.subgraphNode ? this.subgraphNode.getInnerNodes() : [this]
}
/**
* Resolves the executable node & link IDs for a given input slot.
* @param slot The slot index of the input.
* @param visited Leave empty unless overriding this method.
* A set of unique IDs, used to guard against infinite recursion.
* If overriding, ensure that the set is passed on all recursive calls.
* @returns The node and the origin ID / slot index of the output.
*/
resolveInput(slot: number, visited = new Set<string>()): NodeAndInput | undefined {
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[I]${slot}`
if (visited.has(uniqueId)) throw new RecursionError(`While resolving subgraph input [${uniqueId}]`)
visited.add(uniqueId)
const input = this.inputs.at(slot)
if (!input) throw new SlotIndexError(`No input found for flattened id [${this.id}] slot [${slot}]`)
// Nothing connected
if (input.linkId == null) return
const link = this.graph.getLink(input.linkId)
if (!link) throw new InvalidLinkError(`No link found in parent graph for id [${this.id}] slot [${slot}] ${input.name}`)
const { subgraphNode } = this
// Link goes up and out of this subgraph
if (subgraphNode && link.originIsIoNode) {
const subgraphNodeInput = subgraphNode.inputs.at(link.origin_slot)
if (!subgraphNodeInput) throw new SlotIndexError(`No input found for slot [${link.origin_slot}] ${input.name}`)
// Nothing connected
const linkId = subgraphNodeInput.link
if (linkId == null) return
const outerLink = subgraphNode.graph.getLink(linkId)
if (!outerLink) throw new InvalidLinkError(`No outer link found for slot [${link.origin_slot}] ${input.name}`)
// Translate subgraph node IDs to instances (not worth optimising yet)
const subgraphNodes = this.graph.rootGraph.resolveSubgraphIdPath(this.subgraphNodePath)
const subgraphNodeDto = new ExecutableNodeDTO(subgraphNode, this.subgraphNodePath.slice(0, -1), subgraphNodes.at(-2))
return subgraphNodeDto.resolveInput(outerLink.target_slot, visited)
}
// Not part of a subgraph; use the original link
const outputNode = this.graph.getNodeById(link.origin_id)
if (!outputNode) throw new InvalidLinkError(`No input node found for id [${this.id}] slot [${slot}] ${input.name}`)
const outputNodeDto = new ExecutableNodeDTO(outputNode, this.subgraphNodePath, subgraphNode)
return outputNodeDto.resolveOutput(link.origin_slot, input.type, visited)
}
/**
* Determines whether this output is a valid endpoint for a link (non-virtual, non-bypass).
* @param slot The slot index of the output.
* @param type The type of the input
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
* @returns The node and the origin ID / slot index of the output.
*/
resolveOutput(slot: number, type: ISlotType, visited: Set<string>): NodeAndInput | undefined {
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[O]${slot}`
if (visited.has(uniqueId)) throw new RecursionError(`While resolving subgraph output [${uniqueId}]`)
visited.add(uniqueId)
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
const { inputs } = this
// Bypass nodes by finding first input with matching type
const parentInputIndexes = Object.keys(inputs).map(Number)
// Prioritise exact slot index
const indexes = [slot, ...parentInputIndexes]
const matchingIndex = indexes.find(i => inputs[i]?.type === type)
// No input types match
if (matchingIndex === undefined) {
console.debug(`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`, this)
return
}
return this.resolveInput(matchingIndex, visited)
}
const { node } = this
if (node.isSubgraphNode()) return this.#resolveSubgraphOutput(slot, type, visited)
// Upstreamed: Other virtual nodes are bypassed using the same input/output index (slots must match)
if (node.isVirtualNode) {
if (this.inputs.at(slot)) return this.resolveInput(slot, visited)
// Virtual nodes without a matching input should be discarded.
return
}
return {
node: this,
origin_id: this.id,
origin_slot: slot,
}
}
/**
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
* @param slot The slot index of the output on the subgraph node.
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
* @returns A DTO for the node, and the origin ID / slot index of the output.
*/
#resolveSubgraphOutput(slot: number, type: ISlotType, visited: Set<string>): NodeAndInput | undefined {
const { node } = this
const output = node.outputs.at(slot)
if (!output) throw new SlotIndexError(`No output found for flattened id [${this.id}] slot [${slot}]`)
if (!node.isSubgraphNode()) throw new TypeError(`Node is not a subgraph node: ${node.id}`)
// Link inside the subgraph
const innerResolved = node.resolveSubgraphOutputLink(slot)
if (!innerResolved) return
const innerNode = innerResolved.outputNode
if (!innerNode) throw new Error(`No output node found for id [${this.id}] slot [${slot}] ${output.name}`)
// Recurse into the subgraph
const innerNodeDto = new ExecutableNodeDTO(innerNode, [...this.subgraphNodePath, node.id], node)
return innerNodeDto.resolveOutput(innerResolved.link.origin_slot, type, visited)
}
}

View File

@@ -1,6 +1,9 @@
import type { ExportedSubgraph, ExposedWidget, Serialisable, SerialisableGraph } from "@/types/serialisation"
import type { DefaultConnectionColors } from "@/interfaces"
import type { LGraphCanvas } from "@/LGraphCanvas"
import type { ExportedSubgraph, ExposedWidget, ISerialisedGraph, Serialisable, SerialisableGraph } from "@/types/serialisation"
import { type BaseLGraph, LGraph } from "@/LGraph"
import { createUuidv4, type LGraphNode } from "@/litegraph"
import { SubgraphInput } from "./SubgraphInput"
import { SubgraphInputNode } from "./SubgraphInputNode"
@@ -12,44 +15,229 @@ export type GraphOrSubgraph = LGraph | Subgraph
/** A subgraph definition. */
export class Subgraph extends LGraph implements BaseLGraph, Serialisable<ExportedSubgraph> {
/** 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
name: string = "Unnamed Subgraph"
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[]
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[]
readonly outputs: SubgraphOutput[] = []
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
readonly widgets: ExposedWidget[]
readonly widgets: ExposedWidget[] = []
#rootGraph: LGraph
override get rootGraph(): LGraph {
return this.parents[0]
}
/** @inheritdoc */
get pathToRootGraph(): readonly [LGraph, ...Subgraph[]] {
return [...this.parents, this]
return this.#rootGraph
}
constructor(
readonly parents: readonly [LGraph, ...Subgraph[]],
rootGraph: LGraph,
data: ExportedSubgraph,
) {
if (!parents.length) throw new Error("Subgraph must have at least one parent")
if (!rootGraph) throw new Error("Root graph is required")
const cloned = structuredClone(data)
const { name, inputs, outputs, widgets } = cloned
super()
this.name = name
this.inputs = inputs?.map(x => new SubgraphInput(x, this.inputNode)) ?? []
this.outputs = outputs?.map(x => new SubgraphOutput(x, this.outputNode)) ?? []
this.widgets = widgets ?? []
this.#rootGraph = rootGraph
this.configure(cloned)
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
}
#configureSubgraph(data: ISerialisedGraph & ExportedSubgraph | SerialisableGraph & ExportedSubgraph): void {
const { name, inputs, outputs, widgets } = data
this.name = name
if (inputs) {
this.inputs.length = 0
for (const input of inputs) {
this.inputs.push(new SubgraphInput(input, this.inputNode))
}
}
if (outputs) {
this.outputs.length = 0
for (const output of outputs) {
this.outputs.push(new SubgraphOutput(output, this.outputNode))
}
}
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)
}
override configure(data: ISerialisedGraph & ExportedSubgraph | SerialisableGraph & ExportedSubgraph, keep_old?: boolean): boolean | undefined {
const r = super.configure(data, keep_old)
this.#configureSubgraph(data)
return r
}
override attachCanvas(canvas: LGraphCanvas): void {
super.attachCanvas(canvas)
canvas.subgraph = this
}
addInput(name: string, type: string): SubgraphInput {
const input = new SubgraphInput({
id: createUuidv4(),
name,
type,
}, this.inputNode)
this.inputs.push(input)
const subgraphId = this.id
this.#forAllNodes((node) => {
if (node.type === subgraphId) {
node.addInput(name, type)
}
})
return input
}
addOutput(name: string, type: string): SubgraphOutput {
const output = new SubgraphOutput({
id: createUuidv4(),
name,
type,
}, this.outputNode)
this.outputs.push(output)
const subgraphId = this.id
this.#forAllNodes((node) => {
if (node.type === subgraphId) {
node.addOutput(name, type)
}
})
return output
}
#forAllNodes(callback: (node: LGraphNode) => void): void {
forNodes(this.rootGraph.nodes)
for (const subgraph of this.rootGraph.subgraphs.values()) {
forNodes(subgraph.nodes)
}
function forNodes(nodes: LGraphNode[]) {
for (const node of nodes) {
callback(node)
}
}
}
/**
* 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 {
input.label = name
const index = this.inputs.indexOf(input)
if (index === -1) throw new Error("Input not found")
this.#forAllNodes((node) => {
if (node.type === this.id) {
node.inputs[index].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 {
output.label = name
const index = this.outputs.indexOf(output)
if (index === -1) throw new Error("Output not found")
this.#forAllNodes((node) => {
if (node.type === this.id) {
node.outputs[index].label = name
}
})
}
/**
* Removes an input slot from the subgraph.
* @param input The input slot to remove.
*/
removeInput(input: SubgraphInput): void {
input.disconnect()
const index = this.inputs.indexOf(input)
if (index === -1) throw new Error("Input not found")
this.inputs.splice(index, 1)
const { length } = this.inputs
for (let i = index; i < length; i++) {
this.inputs[i].decrementSlots("inputs")
}
this.#forAllNodes((node) => {
if (node.type === this.id) {
node.removeInput(index)
}
})
}
/**
* Removes an output slot from the subgraph.
* @param output The output slot to remove.
*/
removeOutput(output: SubgraphOutput): void {
output.disconnect()
const index = this.outputs.indexOf(output)
if (index === -1) throw new Error("Output not found")
this.outputs.splice(index, 1)
const { length } = this.outputs
for (let i = index; i < length; i++) {
this.outputs[i].decrementSlots("outputs")
}
this.#forAllNodes((node) => {
if (node.type === this.id) {
node.removeOutput(index)
}
})
}
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void {
this.inputNode.draw(ctx, colorContext)
this.outputNode.draw(ctx, colorContext)
}
clone(): Subgraph {
return new Subgraph(this.rootGraph, this.asSerialisable())
}
override asSerialisable(): ExportedSubgraph & Required<Pick<SerialisableGraph, "nodes" | "groups" | "extra">> {

View File

@@ -1,53 +1,65 @@
import type { EmptySubgraphInput } from "./EmptySubgraphInput"
import type { EmptySubgraphOutput } from "./EmptySubgraphOutput"
import type { Subgraph } from "./Subgraph"
import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { Point, Positionable, ReadOnlyRect, Rect } from "@/interfaces"
import type { LinkConnector } from "@/canvas/LinkConnector"
import type { DefaultConnectionColors, Hoverable, Point, Positionable } from "@/interfaces"
import type { NodeId } from "@/LGraphNode"
import type { ExportedSubgraphIONode, Serialisable } from "@/types/serialisation"
import { isPointInRect, snapPoint } from "@/measure"
import { Rectangle } from "@/infrastructure/Rectangle"
import { type CanvasColour, type CanvasPointer, type CanvasPointerEvent, type IContextMenuValue, LiteGraph } from "@/litegraph"
import { snapPoint } from "@/measure"
import { CanvasItem } from "@/types/globalEnums"
export abstract class SubgraphIONodeBase implements Positionable, Serialisable<ExportedSubgraphIONode> {
export abstract class SubgraphIONodeBase<TSlot extends SubgraphInput | SubgraphOutput> implements Positionable, Hoverable, Serialisable<ExportedSubgraphIONode> {
static margin = 10
static defaultWidth = 100
static minWidth = 100
static roundedRadius = 10
readonly #boundingRect: Float32Array = new Float32Array(4)
readonly #pos: Point = this.#boundingRect.subarray(0, 2)
readonly #size: Point = this.#boundingRect.subarray(2, 4)
readonly #boundingRect: Rectangle = new Rectangle()
abstract readonly id: NodeId
get boundingRect(): Rect {
get boundingRect(): Rectangle {
return this.#boundingRect
}
selected: boolean = false
pinned: boolean = false
readonly removable = false
isPointerOver: boolean = false
abstract readonly emptySlot: EmptySubgraphInput | EmptySubgraphOutput
get pos() {
return this.#pos
return this.boundingRect.pos
}
set pos(value) {
if (!value || value.length < 2) return
this.#pos[0] = value[0]
this.#pos[1] = value[1]
this.boundingRect.pos = value
}
get size() {
return this.#size
return this.boundingRect.size
}
set size(value) {
if (!value || value.length < 2) return
this.#size[0] = value[0]
this.#size[1] = value[1]
this.boundingRect.size = value
}
abstract readonly slots: SubgraphInput[] | SubgraphOutput[]
protected get sideLineWidth(): number {
return this.isPointerOver ? 2.5 : 2
}
protected get sideStrokeStyle(): CanvasColour {
return this.isPointerOver ? "white" : "#efefef"
}
abstract readonly slots: TSlot[]
abstract get allSlots(): TSlot[]
constructor(
/** The subgraph that this node belongs to. */
@@ -64,19 +76,210 @@ export abstract class SubgraphIONodeBase implements Positionable, Serialisable<E
return this.pinned ? false : snapPoint(this.pos, snapTo)
}
abstract onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void
// #region Hoverable
containsPoint(point: Point): boolean {
return isPointInRect(point, this.boundingRect)
return this.boundingRect.containsPoint(point)
}
abstract get slotAnchorX(): number
onPointerMove(e: CanvasPointerEvent): CanvasItem {
const containsPoint = this.boundingRect.containsXy(e.canvasX, e.canvasY)
let underPointer = containsPoint ? CanvasItem.SubgraphIoNode : CanvasItem.Nothing
if (containsPoint) {
if (!this.isPointerOver) this.onPointerEnter()
for (const slot of this.allSlots) {
slot.onPointerMove(e)
if (slot.isPointerOver) underPointer |= CanvasItem.SubgraphIoSlot
}
} else if (this.isPointerOver) {
this.onPointerLeave()
}
return underPointer
}
onPointerEnter() {
this.isPointerOver = true
}
onPointerLeave() {
this.isPointerOver = false
for (const slot of this.slots) {
slot.isPointerOver = false
}
}
// #endregion Hoverable
/**
* Renames an IO slot in the subgraph.
* @param slot The slot to rename.
* @param name The new name for the slot.
*/
abstract renameSlot(slot: TSlot, name: string): void
/**
* Removes an IO slot from the subgraph.
* @param slot The slot to remove.
*/
abstract removeSlot(slot: TSlot): void
/**
* Gets the slot at a given position in canvas space.
* @param x The x coordinate of the position.
* @param y The y coordinate of the position.
* @returns The slot at the given position, otherwise `undefined`.
*/
getSlotInPosition(x: number, y: number): TSlot | undefined {
for (const slot of this.allSlots) {
if (slot.boundingRect.containsXy(x, y)) {
return slot
}
}
}
/**
* Shows the context menu for an IO slot.
* @param slot The slot to show the context menu for.
* @param event The event that triggered the context menu.
*/
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
if (!(options.length > 0)) return
new LiteGraph.ContextMenu(
options,
{
event: event as any,
title: slot.name || "Subgraph Output",
callback: (item: IContextMenuValue) => {
this.#onSlotMenuAction(item, slot, event)
},
},
)
}
/**
* Gets the context menu options for an IO slot.
* @param slot The slot to get the context menu options for.
* @returns The context menu options.
*/
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
const options: IContextMenuValue[] = []
// Disconnect option if slot has connections
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
options.push({ content: "Disconnect Links", value: "disconnect" })
}
// Remove / rename slot option (except for the empty slot)
if (slot !== this.emptySlot) {
options.push(
{ content: "Remove Slot", value: "remove" },
{ content: "Rename Slot", value: "rename" },
)
}
return options
}
/**
* Handles the action for an IO slot context menu.
* @param selectedItem The item that was selected from the context menu.
* @param slot The slot
* @param event The event that triggered the context menu.
*/
#onSlotMenuAction(selectedItem: IContextMenuValue, slot: TSlot, event: CanvasPointerEvent): void {
switch (selectedItem.value) {
// Disconnect all links from this output
case "disconnect":
slot.disconnect()
break
// Remove the slot
case "remove":
if (slot !== this.emptySlot) {
this.removeSlot(slot)
}
break
// Rename the slot
case "rename":
if (slot !== this.emptySlot) {
this.subgraph.canvasAction(c => c.prompt(
"Slot name",
slot.name,
(newName: string) => {
if (newName) this.renameSlot(slot, newName)
},
event,
))
}
break
}
this.subgraph.setDirtyCanvas(true)
}
/** Arrange the slots in this node. */
arrange(): void {
const { minWidth, roundedRadius } = SubgraphIONodeBase
const [, y] = this.boundingRect
const x = this.slotAnchorX
const { size } = this
let maxWidth = minWidth
let currentY = y + roundedRadius
for (const slot of this.allSlots) {
const [slotWidth, slotHeight] = slot.measure()
slot.arrange([x, currentY, slotWidth, slotHeight])
currentY += slotHeight
if (slotWidth > maxWidth) maxWidth = slotWidth
}
size[0] = maxWidth + 2 * roundedRadius
size[1] = currentY - y + roundedRadius
}
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void {
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
this.drawProtected(ctx, colorContext)
Object.assign(ctx, { lineWidth, strokeStyle, fillStyle, font, textBaseline })
}
/** @internal Leaves {@link ctx} dirty. */
protected abstract drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void
/** @internal Leaves {@link ctx} dirty. */
protected drawSlots(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void {
ctx.fillStyle = "#AAA"
ctx.font = "12px Arial"
ctx.textBaseline = "middle"
for (const slot of this.allSlots) {
slot.draw({ ctx, colorContext })
slot.drawLabel(ctx)
}
}
configure(data: ExportedSubgraphIONode): void {
this.#boundingRect.set(data.bounding)
this.pinned = data.pinned ?? false
}
asSerialisable(): ExportedSubgraphIONode {
return {
id: this.id,
bounding: serialiseRect(this.boundingRect),
bounding: this.boundingRect.export(),
pinned: this.pinned ? true : undefined,
}
}
}
function serialiseRect(rect: ReadOnlyRect): [number, number, number, number] {
return [rect[0], rect[1], rect[2], rect[3]]
}

View File

@@ -1,8 +1,104 @@
import type { Point, ReadOnlyRect } from "@/interfaces"
import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { INodeInputSlot, Point, ReadOnlyRect } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums"
import { SubgraphSlot } from "./SubgraphSlotBase"
/**
* An input "slot" from a parent graph into a subgraph.
*
* IMPORTANT: A subgraph "input" is both an input AND an output. It creates an extra link connection point between
* a parent graph and a subgraph, so is conceptually similar to a reroute.
*
* This can be a little confusing, but is easier to visualise when imagining editing a subgraph.
* You have "Subgraph Inputs", because they are coming into the subgraph, which then connect to "node inputs".
*
* Functionally, however, when editing a subgraph, that "subgraph input" is the "origin" or "output side" of a link.
*/
export class SubgraphInput extends SubgraphSlot {
declare parent: SubgraphInputNode
override connect(slot: INodeInputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
const { subgraph } = this.parent
// Allow nodes to block connection
const inputIndex = node.inputs.indexOf(slot)
if (node.onConnectInput?.(inputIndex, this.type, this, this.parent, -1) === false) return
// if (slot instanceof SubgraphOutput) {
// // Subgraph IO nodes have no special handling at present.
// return new LLink(
// ++subgraph.state.lastLinkId,
// this.type,
// this.parent.id,
// this.parent.slots.indexOf(this),
// node.id,
// inputIndex,
// afterRerouteId,
// )
// }
// Disconnect target input, if it is already connected.
if (slot.link != null) {
subgraph.beforeChange()
const link = subgraph.getLink(slot.link)
this.parent._disconnectNodeInput(node, slot, link)
}
const link = new LLink(
++subgraph.state.lastLinkId,
slot.type,
this.parent.id,
this.parent.slots.indexOf(this),
node.id,
inputIndex,
afterRerouteId,
)
// Add to graph links list
subgraph._links.set(link.id, link)
// Set link ID in each slot
this.linkIds.push(link.id)
slot.link = link.id
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
subgraph._version++
node.onConnectionsChange?.(
NodeSlotType.INPUT,
inputIndex,
true,
link,
slot,
)
subgraph.afterChange()
return link
}
get labelPos(): Point {
const [x, y, , height] = this.boundingRect
return [x, y + height * 0.5]

View File

@@ -1,12 +1,187 @@
import type { Positionable } from "@/interfaces"
import type { NodeId } from "@/LGraphNode"
import type { SubgraphInput } from "./SubgraphInput"
import type { LinkConnector } from "@/canvas/LinkConnector"
import type { CanvasPointer } from "@/CanvasPointer"
import type { DefaultConnectionColors, INodeInputSlot, ISlotType, Positionable } from "@/interfaces"
import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import type { CanvasPointerEvent } from "@/types/events"
import type { NodeLike } from "@/types/NodeLike"
import { SUBGRAPH_INPUT_ID } from "@/constants"
import { Rectangle } from "@/infrastructure/Rectangle"
import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums"
import { findFreeSlotOfType } from "@/utils/collections"
import { EmptySubgraphInput } from "./EmptySubgraphInput"
import { SubgraphIONodeBase } from "./SubgraphIONodeBase"
export class SubgraphInputNode extends SubgraphIONodeBase implements Positionable {
readonly id: NodeId = -10
export class SubgraphInputNode extends SubgraphIONodeBase<SubgraphInput> implements Positionable {
readonly id: NodeId = SUBGRAPH_INPUT_ID
readonly emptySlot: EmptySubgraphInput = new EmptySubgraphInput(this)
get slots() {
return this.subgraph.inputs
}
override get allSlots(): SubgraphInput[] {
return [...this.slots, this.emptySlot]
}
get slotAnchorX() {
const [x, , width] = this.boundingRect
return x + width - SubgraphIONodeBase.roundedRadius
}
override onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void {
// Left-click handling for dragging connections
if (e.button === 0) {
for (const slot of this.allSlots) {
const slotBounds = Rectangle.fromCentre(slot.pos, slot.boundingRect.height)
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
pointer.onDragStart = () => {
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
}
pointer.onDragEnd = (eUp) => {
linkConnector.dropLinks(this.subgraph, eUp)
}
pointer.finally = () => {
linkConnector.reset(true)
}
}
}
// Check for right-click
} else if (e.button === 2) {
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
if (slot) this.showSlotContextMenu(slot, e)
}
}
/** @inheritdoc */
override renameSlot(slot: SubgraphInput, name: string): void {
this.subgraph.renameInput(slot, name)
}
/** @inheritdoc */
override removeSlot(slot: SubgraphInput): void {
this.subgraph.removeInput(slot)
}
canConnectTo(inputNode: NodeLike, input: INodeInputSlot, fromSlot: SubgraphInput): boolean {
return inputNode.canConnectTo(this, input, fromSlot)
}
connectSlots(fromSlot: SubgraphInput, inputNode: LGraphNode, input: INodeInputSlot, afterRerouteId: RerouteId | undefined): LLink {
const { subgraph } = this
const outputIndex = this.slots.indexOf(fromSlot)
const inputIndex = inputNode.inputs.indexOf(input)
if (outputIndex === -1 || inputIndex === -1) throw new Error("Invalid slot indices.")
return new LLink(
++subgraph.state.lastLinkId,
input.type || fromSlot.type,
this.id,
outputIndex,
inputNode.id,
inputIndex,
afterRerouteId,
)
}
// #region Legacy LGraphNode compatibility
connectByType(
slot: number,
target_node: LGraphNode,
target_slotType: ISlotType,
optsIn?: { afterRerouteId?: RerouteId },
): LLink | undefined {
const inputSlot = target_node.findInputByType(target_slotType)
if (!inputSlot) return
return this.slots[slot].connect(inputSlot.slot, target_node, optsIn?.afterRerouteId)
}
findOutputSlot(name: string): SubgraphInput | undefined {
return this.slots.find(output => output.name === name)
}
findOutputByType(type: ISlotType): SubgraphInput | undefined {
return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot
}
// #endregion Legacy LGraphNode compatibility
_disconnectNodeInput(node: LGraphNode, input: INodeInputSlot, link: LLink | undefined): void {
const { subgraph } = this
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
subgraph.removeFloatingLink(link)
}
}
input.link = null
subgraph.setDirtyCanvas(false, true)
if (!link) return
const subgraphInputIndex = link.origin_slot
link.disconnect(subgraph, "output")
subgraph._version++
const subgraphInput = this.slots.at(subgraphInputIndex)
if (!subgraphInput) {
console.debug("disconnectNodeInput: subgraphInput not found", this, subgraphInputIndex)
return
}
// search in the inputs list for this link
const index = subgraphInput.linkIds.indexOf(link.id)
if (index !== -1) {
subgraphInput.linkIds.splice(index, 1)
} else {
console.debug("disconnectNodeInput: link ID not found in subgraphInput linkIds", link.id)
}
node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
index,
false,
link,
subgraphInput,
)
}
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void {
const { roundedRadius } = SubgraphIONodeBase
const transform = ctx.getTransform()
const [x, y, width, height] = this.boundingRect
ctx.translate(x, y)
// Draw top rounded part
ctx.strokeStyle = this.sideStrokeStyle
ctx.lineWidth = this.sideLineWidth
ctx.beginPath()
ctx.arc(width - roundedRadius, roundedRadius, roundedRadius, Math.PI * 1.5, 0)
// Straight line to bottom
ctx.moveTo(width, roundedRadius)
ctx.lineTo(width, height - roundedRadius)
// Bottom rounded part
ctx.arc(width - roundedRadius, height - roundedRadius, roundedRadius, 0, Math.PI * 0.5)
ctx.stroke()
// Restore context
ctx.setTransform(transform)
this.drawSlots(ctx, colorContext)
}
}

View File

@@ -0,0 +1,148 @@
import type { ISubgraphInput } from "@/interfaces"
import type { BaseLGraph, LGraph } from "@/LGraph"
import type { INodeInputSlot, ISlotType, NodeId } from "@/litegraph"
import type { GraphOrSubgraph, Subgraph } from "@/subgraph/Subgraph"
import type { ExportedSubgraphInstance } from "@/types/serialisation"
import type { UUID } from "@/utils/uuid"
import { RecursionError } from "@/infrastructure/RecursionError"
import { LGraphNode } from "@/LGraphNode"
import { LLink, type ResolvedConnection } from "@/LLink"
import { NodeInputSlot } from "@/node/NodeInputSlot"
import { NodeOutputSlot } from "@/node/NodeOutputSlot"
import { type ExecutableLGraphNode, ExecutableNodeDTO } from "./ExecutableNodeDTO"
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
export class SubgraphNode extends LGraphNode implements BaseLGraph {
override readonly type: UUID
override readonly isVirtualNode = true as const
get rootGraph(): LGraph {
return this.graph.rootGraph
}
override get displayType(): string {
return "Subgraph node"
}
override isSubgraphNode(): this is SubgraphNode {
return true
}
constructor(
/** The (sub)graph that contains this subgraph instance. */
override readonly graph: GraphOrSubgraph,
/** The definition of this subgraph; how its nodes are configured, etc. */
readonly subgraph: Subgraph,
instanceData: ExportedSubgraphInstance,
) {
super(subgraph.name, subgraph.id)
this.type = subgraph.id
this.configure(instanceData)
}
override configure(info: ExportedSubgraphInstance): void {
this.inputs.length = 0
this.inputs.push(
...this.subgraph.inputNode.slots.map(
slot => new NodeInputSlot({ name: slot.name, localized_name: slot.localized_name, label: slot.label, type: slot.type, link: null }, this),
),
)
this.outputs.length = 0
this.outputs.push(
...this.subgraph.outputNode.slots.map(
slot => new NodeOutputSlot({ name: slot.name, localized_name: slot.localized_name, label: slot.label, type: slot.type, links: null }, this),
),
)
super.configure(info)
}
/**
* Ensures the subgraph slot is in the params before adding the input as normal.
* @param name The name of the input slot.
* @param type The type of the input slot.
* @param inputProperties Properties that are directly assigned to the created input. Default: a new, empty object.
* @returns The new input slot.
* @remarks Assertion is required to instantiate empty generic POJO.
*/
override addInput<TInput extends Partial<ISubgraphInput>>(name: string, type: ISlotType, inputProperties: TInput = {} as TInput): INodeInputSlot & TInput {
// Bypasses type narrowing on this.inputs
return super.addInput(name, type, inputProperties)
}
override getInputLink(slot: number): LLink | null {
// Output side: the link from inside the subgraph
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
if (!innerLink) {
console.warn(`SubgraphNode.getInputLink: no inner link found for slot ${slot}`)
return null
}
const newLink = LLink.create(innerLink)
newLink.origin_id = `${this.id}:${innerLink.origin_id}`
newLink.origin_slot = innerLink.origin_slot
return newLink
}
/**
* Finds the internal links connected to the given input slot inside the subgraph, and resolves the nodes / slots.
* @param slot The slot index
* @returns The resolved connections, or undefined if no input node is found.
* @remarks This is used to resolve the input links when dragging a link from a subgraph input slot.
*/
resolveSubgraphInputLinks(slot: number): ResolvedConnection[] {
const inputSlot = this.subgraph.inputNode.slots[slot]
const innerLinks = inputSlot.getLinks()
if (innerLinks.length === 0) {
console.debug(`[SubgraphNode.resolveSubgraphInputLinks] No inner links found for input slot [${slot}] ${inputSlot.name}`, this)
return []
}
return innerLinks.map(link => link.resolve(this.subgraph))
}
/**
* Finds the internal link connected to the given output slot inside the subgraph, and resolves the nodes / slots.
* @param slot The slot index
* @returns The output node if found, otherwise undefined.
*/
resolveSubgraphOutputLink(slot: number): ResolvedConnection | undefined {
const outputSlot = this.subgraph.outputNode.slots[slot]
const innerLink = outputSlot.getLinks().at(0)
if (innerLink) return innerLink.resolve(this.subgraph)
console.debug(`[SubgraphNode.resolveSubgraphOutputLink] No inner link found for output slot [${slot}] ${outputSlot.name}`, this)
}
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
getInnerNodes(
/** The list of nodes to add to. */
nodes: ExecutableLGraphNode[] = [],
/** The set of visited nodes. */
visited = new WeakSet<SubgraphNode>(),
/** The path of subgraph node IDs. */
subgraphNodePath: readonly NodeId[] = [],
): ExecutableLGraphNode[] {
if (visited.has(this)) throw new RecursionError("while flattening subgraph")
visited.add(this)
const subgraphInstanceIdPath = [...subgraphNodePath, this.id]
for (const node of this.subgraph.nodes) {
if ("getInnerNodes" in node) {
node.getInnerNodes(nodes, visited, subgraphInstanceIdPath)
} else {
// Create minimal DTOs rather than cloning the node
const aVeryRealNode = new ExecutableNodeDTO(node, subgraphInstanceIdPath, this)
nodes.push(aVeryRealNode)
}
}
return nodes
}
}

View File

@@ -1,8 +1,99 @@
import type { Point, ReadOnlyRect } from "@/interfaces"
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
import type { INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums"
import { removeFromArray } from "@/utils/collections"
import { SubgraphSlot } from "./SubgraphSlotBase"
/**
* An output "slot" from a subgraph to a parent graph.
*
* IMPORTANT: A subgraph "output" is both an output AND an input. It creates an extra link connection point between
* a parent graph and a subgraph, so is conceptually similar to a reroute.
*
* This can be a little confusing, but is easier to visualise when imagining editing a subgraph.
* You have "Subgraph Outputs", because they go from inside the subgraph and out, but links to them come from "node outputs".
*
* Functionally, however, when editing a subgraph, that "subgraph output" is the "target" or "input side" of a link.
*/
export class SubgraphOutput extends SubgraphSlot {
declare parent: SubgraphOutputNode
override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
const { subgraph } = this.parent
// Allow nodes to block connection
const outputIndex = node.outputs.indexOf(slot)
if (outputIndex === -1) throw new Error("Slot is not an output of the given node")
if (node.onConnectOutput?.(outputIndex, this.type, this, this.parent, -1) === false) return
// Link should not be present, but just in case, disconnect it
const existingLink = this.getLinks().at(0)
if (existingLink != null) {
subgraph.beforeChange()
existingLink.disconnect(subgraph, "input")
const resolved = existingLink.resolve(subgraph)
const links = resolved.output?.links
if (links) removeFromArray(links, existingLink.id)
}
const link = new LLink(
++subgraph.state.lastLinkId,
slot.type,
node.id,
outputIndex,
this.parent.id,
this.parent.slots.indexOf(this),
afterRerouteId,
)
// Add to graph links list
subgraph._links.set(link.id, link)
// Set link ID in each slot
this.linkIds[0] = link.id
slot.links ??= []
slot.links.push(link.id)
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
subgraph._version++
node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
link,
slot,
)
subgraph.afterChange()
return link
}
get labelPos(): Point {
const [x, y, , height] = this.boundingRect
return [x + height, y + height * 0.5]

View File

@@ -1,12 +1,119 @@
import type { Positionable } from "@/interfaces"
import type { NodeId } from "@/LGraphNode"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { LinkConnector } from "@/canvas/LinkConnector"
import type { CanvasPointer } from "@/CanvasPointer"
import type { DefaultConnectionColors, ISlotType, Positionable } from "@/interfaces"
import type { INodeOutputSlot } from "@/interfaces"
import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { RerouteId } from "@/Reroute"
import type { CanvasPointerEvent } from "@/types/events"
import type { NodeLike } from "@/types/NodeLike"
import type { SubgraphIO } from "@/types/serialisation"
import { SUBGRAPH_OUTPUT_ID } from "@/constants"
import { Rectangle } from "@/infrastructure/Rectangle"
import { findFreeSlotOfType } from "@/utils/collections"
import { EmptySubgraphOutput } from "./EmptySubgraphOutput"
import { SubgraphIONodeBase } from "./SubgraphIONodeBase"
export class SubgraphOutputNode extends SubgraphIONodeBase implements Positionable {
readonly id: NodeId = -20
export class SubgraphOutputNode extends SubgraphIONodeBase<SubgraphOutput> implements Positionable {
readonly id: NodeId = SUBGRAPH_OUTPUT_ID
readonly emptySlot: EmptySubgraphOutput = new EmptySubgraphOutput(this)
get slots() {
return this.subgraph.outputs
}
override get allSlots(): SubgraphOutput[] {
return [...this.slots, this.emptySlot]
}
get slotAnchorX() {
const [x] = this.boundingRect
return x + SubgraphIONodeBase.roundedRadius
}
override onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void {
// Left-click handling for dragging connections
if (e.button === 0) {
for (const slot of this.allSlots) {
const slotBounds = Rectangle.fromCentre(slot.pos, slot.boundingRect.height)
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
pointer.onDragStart = () => {
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
}
pointer.onDragEnd = (eUp) => {
linkConnector.dropLinks(this.subgraph, eUp)
}
pointer.finally = () => {
linkConnector.reset(true)
}
}
}
// Check for right-click
} else if (e.button === 2) {
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
if (slot) this.showSlotContextMenu(slot, e)
}
}
/** @inheritdoc */
override renameSlot(slot: SubgraphOutput, name: string): void {
this.subgraph.renameOutput(slot, name)
}
/** @inheritdoc */
override removeSlot(slot: SubgraphOutput): void {
this.subgraph.removeOutput(slot)
}
canConnectTo(outputNode: NodeLike, fromSlot: SubgraphOutput, output: INodeOutputSlot | SubgraphIO): boolean {
return outputNode.canConnectTo(this, fromSlot, output)
}
connectByTypeOutput(
slot: number,
target_node: LGraphNode,
target_slotType: ISlotType,
optsIn?: { afterRerouteId?: RerouteId },
): LLink | undefined {
const outputSlot = target_node.findOutputByType(target_slotType)
if (!outputSlot) return
return this.slots[slot].connect(outputSlot.slot, target_node, optsIn?.afterRerouteId)
}
findInputByType(type: ISlotType): SubgraphOutput | undefined {
return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot
}
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void {
const { roundedRadius } = SubgraphIONodeBase
const transform = ctx.getTransform()
const [x, y, , height] = this.boundingRect
ctx.translate(x, y)
// Draw bottom rounded part
ctx.strokeStyle = this.sideStrokeStyle
ctx.lineWidth = this.sideLineWidth
ctx.beginPath()
ctx.arc(roundedRadius, roundedRadius, roundedRadius, Math.PI, Math.PI * 1.5)
// Straight line to bottom
ctx.moveTo(0, roundedRadius)
ctx.lineTo(0, height - roundedRadius)
// Bottom rounded part
ctx.arc(roundedRadius, height - roundedRadius, roundedRadius, Math.PI, Math.PI * 0.5, true)
ctx.stroke()
// Restore context
ctx.setTransform(transform)
this.drawSlots(ctx, colorContext)
}
}

View File

@@ -1,27 +1,43 @@
import type { SubgraphIONodeBase } from "./SubgraphIONodeBase"
import type { Point, ReadOnlyRect, Rect } from "@/interfaces"
import type { LinkId } from "@/LLink"
import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect, ReadOnlySize } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { LinkId, LLink } from "@/LLink"
import type { RerouteId } from "@/Reroute"
import type { CanvasPointerEvent } from "@/types/events"
import type { Serialisable, SubgraphIO } from "@/types/serialisation"
import { SlotShape } from "@/draw"
import { ConstrainedSize } from "@/infrastructure/ConstrainedSize"
import { Rectangle } from "@/infrastructure/Rectangle"
import { LGraphCanvas } from "@/LGraphCanvas"
import { LiteGraph } from "@/litegraph"
import { SlotBase } from "@/node/SlotBase"
import { createUuidv4, type UUID } from "@/utils/uuid"
export interface SubgraphSlotDrawOptions {
ctx: CanvasRenderingContext2D
colorContext: DefaultConnectionColors
lowQuality?: boolean
}
/** Shared base class for the slots used on Subgraph . */
export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Serialisable<SubgraphIO> {
export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Hoverable, Serialisable<SubgraphIO> {
static get defaultHeight() {
return LiteGraph.NODE_SLOT_HEIGHT
}
readonly #pos: Point = new Float32Array(2)
readonly measurement: ConstrainedSize = new ConstrainedSize(SubgraphSlot.defaultHeight, SubgraphSlot.defaultHeight)
readonly id: UUID
readonly parent: SubgraphIONodeBase
readonly parent: SubgraphInputNode | SubgraphOutputNode
override type: string
readonly linkIds: LinkId[] = []
override readonly boundingRect: Rect = [0, 0, 0, SubgraphSlot.defaultHeight]
override readonly boundingRect: Rectangle = new Rectangle(0, 0, 0, SubgraphSlot.defaultHeight)
override get pos() {
return this.#pos
@@ -46,8 +62,8 @@ export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Seria
abstract get labelPos(): Point
constructor(slot: SubgraphIO, parent: SubgraphIONodeBase) {
super(slot.name, slot.type, slot.boundingRect)
constructor(slot: SubgraphIO, parent: SubgraphInputNode | SubgraphOutputNode) {
super(slot.name, slot.type)
Object.assign(this, slot)
this.id = slot.id ?? createUuidv4()
@@ -55,10 +71,111 @@ export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Seria
this.parent = parent
}
isPointerOver: boolean = false
containsPoint(point: Point): boolean {
return this.boundingRect.containsPoint(point)
}
onPointerMove(e: CanvasPointerEvent): void {
this.isPointerOver = this.boundingRect.containsXy(e.canvasX, e.canvasY)
}
getLinks(): LLink[] {
const links: LLink[] = []
const { subgraph } = this.parent
for (const id of this.linkIds) {
const link = subgraph.getLink(id)
if (link) links.push(link)
}
return links
}
decrementSlots(inputsOrOutputs: "inputs" | "outputs"): void {
const { links } = this.parent.subgraph
const linkProperty = inputsOrOutputs === "inputs" ? "origin_slot" : "target_slot"
for (const linkId of this.linkIds) {
const link = links.get(linkId)
if (link) link[linkProperty]--
else console.warn("decrementSlots: link ID not found", linkId)
}
}
measure(): ReadOnlySize {
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
const { defaultHeight } = SubgraphSlot
this.measurement.setValues(width + defaultHeight, defaultHeight)
return this.measurement.toSize()
}
abstract arrange(rect: ReadOnlyRect): void
abstract connect(
slot: INodeInputSlot | INodeOutputSlot,
node: LGraphNode,
afterRerouteId?: RerouteId,
): LLink | undefined
/**
* Disconnects all links connected to this slot.
*/
disconnect(): void {
const { subgraph } = this.parent
for (const linkId of this.linkIds) {
subgraph.removeLink(linkId)
}
this.linkIds.length = 0
}
/** @remarks Leaves the context dirty. */
drawLabel(ctx: CanvasRenderingContext2D): void {
if (!this.displayName) return
const [x, y] = this.labelPos
ctx.fillStyle = this.isPointerOver ? "white" : "#AAA"
ctx.fillText(this.displayName, x, y)
}
/** @remarks Leaves the context dirty. */
draw({ ctx, colorContext, lowQuality }: SubgraphSlotDrawOptions): void {
// Assertion: SlotShape is a subset of RenderShape
const shape = this.shape as unknown as SlotShape
const { isPointerOver, pos: [x, y] } = this
ctx.beginPath()
// Default rendering for circle, hollow circle.
const color = this.renderingColor(colorContext)
if (lowQuality) {
ctx.fillStyle = color
ctx.rect(x - 4, y - 4, 8, 8)
ctx.fill()
} else if (shape === SlotShape.HollowCircle) {
ctx.lineWidth = 3
ctx.strokeStyle = color
const radius = isPointerOver ? 4 : 3
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.stroke()
} else {
// Normal circle
ctx.fillStyle = color
const radius = isPointerOver ? 5 : 4
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fill()
}
}
asSerialisable(): SubgraphIO {
const { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos, boundingRect } = this
return { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos, boundingRect }
const { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos } = this
return { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos }
}
}

View File

@@ -0,0 +1,338 @@
import type { INodeOutputSlot, Positionable } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { ISerialisedNode, SerialisableLLink, SubgraphIO } from "@/types/serialisation"
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
import { LGraphGroup } from "@/LGraphGroup"
import { LGraphNode } from "@/LGraphNode"
import { createUuidv4, LiteGraph } from "@/litegraph"
import { LLink, type ResolvedConnection } from "@/LLink"
import { Reroute } from "@/Reroute"
import { nextUniqueName } from "@/strings"
import { SubgraphInputNode } from "./SubgraphInputNode"
import { SubgraphOutputNode } from "./SubgraphOutputNode"
export interface FilteredItems {
nodes: Set<LGraphNode>
reroutes: Set<Reroute>
groups: Set<LGraphGroup>
subgraphInputNodes: Set<SubgraphInputNode>
subgraphOutputNodes: Set<SubgraphOutputNode>
unknown: Set<Positionable>
}
export function splitPositionables(items: Iterable<Positionable>): FilteredItems {
const nodes = new Set<LGraphNode>()
const reroutes = new Set<Reroute>()
const groups = new Set<LGraphGroup>()
const subgraphInputNodes = new Set<SubgraphInputNode>()
const subgraphOutputNodes = new Set<SubgraphOutputNode>()
const unknown = new Set<Positionable>()
for (const item of items) {
switch (true) {
case item instanceof LGraphNode:
nodes.add(item)
break
case item instanceof LGraphGroup:
groups.add(item)
break
case item instanceof Reroute:
reroutes.add(item)
break
case item instanceof SubgraphInputNode:
subgraphInputNodes.add(item)
break
case item instanceof SubgraphOutputNode:
subgraphOutputNodes.add(item)
break
default:
unknown.add(item)
break
}
}
return {
nodes,
reroutes,
groups,
subgraphInputNodes,
subgraphOutputNodes,
unknown,
}
}
interface BoundaryLinks {
boundaryLinks: LLink[]
boundaryFloatingLinks: LLink[]
internalLinks: LLink[]
boundaryInputLinks: LLink[]
boundaryOutputLinks: LLink[]
}
export function getBoundaryLinks(graph: LGraph, items: Set<Positionable>): BoundaryLinks {
const internalLinks: LLink[] = []
const boundaryLinks: LLink[] = []
const boundaryInputLinks: LLink[] = []
const boundaryOutputLinks: LLink[] = []
const boundaryFloatingLinks: LLink[] = []
const visited = new WeakSet<Positionable>()
for (const item of items) {
if (visited.has(item)) continue
visited.add(item)
// Nodes
if (item instanceof LGraphNode) {
const node = item
// Inputs
if (node.inputs) {
for (const input of node.inputs) {
addFloatingLinks(input._floatingLinks)
if (input.link == null) continue
const resolved = LLink.resolve(input.link, graph)
if (!resolved) {
console.debug(`Failed to resolve link ID [${input.link}]`)
continue
}
// Output end of this link is outside the items set
const { link, outputNode } = resolved
if (outputNode) {
if (!items.has(outputNode)) {
boundaryInputLinks.push(link)
} else {
internalLinks.push(link)
}
} else if (link.origin_id === SUBGRAPH_INPUT_ID) {
// Subgraph input node - always boundary
boundaryInputLinks.push(link)
}
}
}
// Outputs
if (node.outputs) {
for (const output of node.outputs) {
addFloatingLinks(output._floatingLinks)
if (!output.links) continue
const many = LLink.resolveMany(output.links, graph)
for (const { link, inputNode } of many) {
if (
// Subgraph output node
link.target_id === SUBGRAPH_OUTPUT_ID ||
// Input end of this link is outside the items set
(inputNode && !items.has(inputNode))
) {
boundaryOutputLinks.push(link)
}
// Internal links are discovered on input side.
}
}
}
} else if (item instanceof Reroute) {
// Reroutes
const reroute = item
// TODO: This reroute should be on one side of the boundary. We should mark the reroute that is on each side of the boundary.
// TODO: This could occur any number of times on a link; each time should be marked as a separate boundary.
// TODO: e.g. A link with 3 reroutes, the first and last reroute are in `items`, but the middle reroute is not. This will be two "in" and two "out" boundaries.
const results = LLink.resolveMany(reroute.linkIds, graph)
for (const { link } of results) {
const reroutes = LLink.getReroutes(graph, link)
const reroutesOutside = reroutes.filter(reroute => !items.has(reroute))
// for (const reroute of reroutes) {
// // TODO: Do the checks here.
// }
const { inputNode, outputNode } = link.resolve(graph)
if (
reroutesOutside.length ||
(inputNode && !items.has(inputNode)) ||
(outputNode && !items.has(outputNode))
) {
boundaryLinks.push(link)
}
}
}
}
return { boundaryLinks, boundaryFloatingLinks, internalLinks, boundaryInputLinks, boundaryOutputLinks }
/**
* Adds any floating links that cross the boundary.
* @param floatingLinks The floating links to check
*/
function addFloatingLinks(floatingLinks: Set<LLink> | undefined): void {
if (!floatingLinks) return
for (const link of floatingLinks) {
const crossesBoundary = LLink
.getReroutes(graph, link)
.some(reroute => !items.has(reroute))
if (crossesBoundary) boundaryFloatingLinks.push(link)
}
}
}
export function multiClone(nodes: Iterable<LGraphNode>): ISerialisedNode[] {
const clonedNodes: ISerialisedNode[] = []
// Selectively clone - keep IDs & links
for (const node of nodes) {
const newNode = LiteGraph.createNode(node.type)
if (!newNode) {
console.warn("Failed to create node", node.type)
continue
}
// Must be cloned; litegraph "serialize" is mostly shallow clone
const data = LiteGraph.cloneObject(node.serialize())
newNode.configure(data)
clonedNodes.push(newNode.serialize())
}
return clonedNodes
}
/**
* Groups resolved connections by output object. If the output is nullish, the connection will be in its own group.
* @param resolvedConnections The resolved connections to group
* @returns A map of grouped connections.
*/
export function groupResolvedByOutput(
resolvedConnections: ResolvedConnection[],
): Map<SubgraphIO | INodeOutputSlot | object, ResolvedConnection[]> {
const groupedByOutput: ReturnType<typeof groupResolvedByOutput> = new Map()
for (const resolved of resolvedConnections) {
// Force no group (unique object) if output is undefined; corruption or an error has occurred
const groupBy = resolved.subgraphInput ?? resolved.output ?? {}
const group = groupedByOutput.get(groupBy)
if (group) {
group.push(resolved)
} else {
groupedByOutput.set(groupBy, [resolved])
}
}
return groupedByOutput
}
export function mapSubgraphInputsAndLinks(resolvedInputLinks: ResolvedConnection[], links: SerialisableLLink[]): SubgraphIO[] {
// Group matching links
const groupedByOutput = groupResolvedByOutput(resolvedInputLinks)
// Create one input for each output (outside subgraph)
const inputs: SubgraphIO[] = []
for (const [, connections] of groupedByOutput) {
const inputLinks: SerialisableLLink[] = []
// Create serialised links for all links (will be recreated in subgraph)
for (const resolved of connections) {
const { link, input } = resolved
if (!input) continue
const linkData = link.asSerialisable()
linkData.origin_id = SUBGRAPH_INPUT_ID
linkData.origin_slot = inputs.length
links.push(linkData)
inputLinks.push(linkData)
}
// Use first input link
const { input } = connections[0]
if (!input) continue
// Subgraph input slot
const { color_off, color_on, dir, hasErrors, label, localized_name, name, shape, type } = input
const uniqueName = nextUniqueName(name, inputs.map(input => input.name))
const uniqueLocalizedName = localized_name ? nextUniqueName(localized_name, inputs.map(input => input.localized_name ?? "")) : undefined
const inputData: SubgraphIO = {
id: createUuidv4(),
type: String(type),
linkIds: inputLinks.map(link => link.id),
name: uniqueName,
color_off,
color_on,
dir,
label,
localized_name: uniqueLocalizedName,
hasErrors,
shape,
}
inputs.push(inputData)
}
return inputs
}
/**
* Clones the output slots, and updates existing links, when converting items to a subgraph.
* @param resolvedOutputLinks The resolved output links.
* @param links The links to add to the subgraph.
* @returns The subgraph output slots.
*/
export function mapSubgraphOutputsAndLinks(resolvedOutputLinks: ResolvedConnection[], links: SerialisableLLink[]): SubgraphIO[] {
// Group matching links
const groupedByOutput = groupResolvedByOutput(resolvedOutputLinks)
const outputs: SubgraphIO[] = []
for (const [, connections] of groupedByOutput) {
const outputLinks: SerialisableLLink[] = []
// Create serialised links for all links (will be recreated in subgraph)
for (const resolved of connections) {
const { link, output } = resolved
if (!output) continue
// Link
const linkData = link.asSerialisable()
linkData.target_id = SUBGRAPH_OUTPUT_ID
linkData.target_slot = outputs.length
links.push(linkData)
outputLinks.push(linkData)
}
// Use first output link
const { output } = connections[0]
if (!output) continue
// Subgraph output slot
const { color_off, color_on, dir, hasErrors, label, localized_name, name, shape, type } = output
const uniqueName = nextUniqueName(name, outputs.map(output => output.name))
const uniqueLocalizedName = localized_name ? nextUniqueName(localized_name, outputs.map(output => output.localized_name ?? "")) : undefined
const outputData = {
id: createUuidv4(),
type: String(type),
linkIds: outputLinks.map(link => link.id),
name: uniqueName,
color_off,
color_on,
dir,
label,
localized_name: uniqueLocalizedName,
hasErrors,
shape,
} satisfies SubgraphIO
outputs.push(structuredClone(outputData))
}
return outputs
}

View File

@@ -1,44 +0,0 @@
import type { LGraphNode } from "./LGraphNode"
import type {
ExportedSubgraph,
ExportedSubgraphInstance,
ExposedWidget,
SubgraphIO,
} from "./types/serialisation"
import type { UUID } from "./utils/uuid"
import { LGraph } from "@/LGraph"
/** A subgraph definition. */
export interface Subgraph extends LGraph {
parent: LGraph | Subgraph
/** The display name of the subgraph. */
name: string
/** 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. */
inputs: SubgraphIO[]
/** 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. */
outputs: SubgraphIO[]
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets: ExposedWidget[]
export(): ExportedSubgraph
}
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
* @remarks
*/
export interface SubgraphInstance extends LGraphNode {
/** The definition of this subgraph; how its nodes are configured, etc. */
subgraphType: Subgraph
/** The root-level containing graph */
rootGraph: LGraph
/** The (sub)graph that contains this subgraph instance. */
parent: LGraph | Subgraph
type: UUID
export(): ExportedSubgraphInstance
}

13
src/types/NodeLike.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { INodeInputSlot, INodeOutputSlot } from "@/interfaces"
import type { NodeId } from "@/LGraphNode"
import type { SubgraphIO } from "@/types/serialisation"
export interface NodeLike {
id: NodeId
canConnectTo(
node: NodeLike,
toSlot: INodeInputSlot | SubgraphIO,
fromSlot: INodeOutputSlot | SubgraphIO,
): boolean
}

View File

@@ -36,6 +36,10 @@ export enum CanvasItem {
Link = 1 << 3,
/** A reroute slot */
RerouteSlot = 1 << 5,
/** A subgraph input or output node */
SubgraphIoNode = 1 << 6,
/** A subgraph input or output slot */
SubgraphIoSlot = 1 << 7,
}
/** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */
@@ -90,3 +94,49 @@ export enum EaseFunction {
EASE_OUT_QUAD = "easeOutQuad",
EASE_IN_OUT_QUAD = "easeInOutQuad",
}
/** Bit flags used to indicate what the pointer is currently hovering over. */
export enum Alignment {
/** No items / none */
None = 0,
/** Top */
Top = 1,
/** Bottom */
Bottom = 1 << 1,
/** Vertical middle */
Middle = 1 << 2,
/** Left */
Left = 1 << 3,
/** Right */
Right = 1 << 4,
/** Horizontal centre */
Centre = 1 << 5,
/** Top left */
TopLeft = Top | Left,
/** Top side, horizontally centred */
TopCentre = Top | Centre,
/** Top right */
TopRight = Top | Right,
/** Left side, vertically centred */
MidLeft = Left | Middle,
/** Middle centre */
MidCentre = Middle | Centre,
/** Right side, vertically centred */
MidRight = Right | Middle,
/** Bottom left */
BottomLeft = Bottom | Left,
/** Bottom side, horizontally centred */
BottomCentre = Bottom | Centre,
/** Bottom right */
BottomRight = Bottom | Right,
}
/**
* Checks if the bitwise {@link flag} is set in the {@link flagSet}.
* @param flagSet The unknown set of flags - will be checked for the presence of {@link flag}
* @param flag The flag to check for
* @returns `true` if the flag is set, `false` otherwise.
*/
export function hasFlag(flagSet: number, flag: number): boolean {
return (flagSet & flag) === flag
}

View File

@@ -33,6 +33,7 @@ export interface Serialisable<SerialisableObject> {
export interface BaseExportedGraph {
/** Unique graph ID. Automatically generated if not provided. */
id: UUID
/** The revision number of this graph. Not automatically incremented; intended for use by a downstream save function. */
revision: number
config?: LGraphConfig
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
@@ -135,7 +136,7 @@ export interface ExportedSubgraph extends SerialisableGraph {
}
/** Properties shared by subgraph and node I/O slots. */
type SubgraphIOShared = Omit<INodeSlot, "nameLocked" | "locked" | "removable" | "_floatingLinks">
type SubgraphIOShared = Omit<INodeSlot, "boundingRect" | "nameLocked" | "locked" | "removable" | "_floatingLinks">
/** Subgraph I/O slots */
export interface SubgraphIO extends SubgraphIOShared {

View File

@@ -1,7 +1,7 @@
import type { ConnectingLink, INodeInputSlot, INodeOutputSlot, ISlotType, Positionable } from "../interfaces"
import type { ConnectingLink, ISlotType, Positionable } from "../interfaces"
import type { LinkId } from "@/LLink"
import { type IGenericLinkOrLinks, LGraphNode } from "@/LGraphNode"
import { LGraphNode } from "@/LGraphNode"
import { parseSlotTypes } from "@/strings"
/**
@@ -48,8 +48,7 @@ export function isDraggingLink(linkId: LinkId, connectingLinks: ConnectingLink[]
}
}
type InputOrOutput = (INodeInputSlot | INodeOutputSlot) & IGenericLinkOrLinks
type FreeSlotResult<T extends InputOrOutput> = { index: number, slot: T } | undefined
type FreeSlotResult<T extends { type: ISlotType }> = { index: number, slot: T } | undefined
/**
* Finds the first free in/out slot with any of the comma-delimited types in {@link type}.
@@ -60,12 +59,14 @@ type FreeSlotResult<T extends InputOrOutput> = { index: number, slot: T } | unde
* - The first occupied wildcard slot
* @param slots The iterable of node slots slots to search through
* @param type The {@link ISlotType type} of slot to find
* @param hasNoLinks A predicate that returns `true` if the slot is free.
* @returns The index and slot if found, otherwise `undefined`.
*/
export function findFreeSlotOfType<T extends InputOrOutput>(
export function findFreeSlotOfType<T extends { type: ISlotType }>(
slots: T[],
type: ISlotType,
): FreeSlotResult<T> {
hasNoLinks: (slot: T) => boolean,
) {
if (!slots?.length) return
let occupiedSlot: FreeSlotResult<T>
@@ -80,7 +81,7 @@ export function findFreeSlotOfType<T extends InputOrOutput>(
for (const validType of validTypes) {
for (const slotType of slotTypes) {
if (slotType === validType) {
if (slot.link == null && !slot.links?.length) {
if (hasNoLinks(slot)) {
// Exact match - short circuit
return { index, slot }
}
@@ -88,7 +89,7 @@ export function findFreeSlotOfType<T extends InputOrOutput>(
occupiedSlot ??= { index, slot }
} else if (!wildSlot && (validType === "*" || slotType === "*")) {
// Save the first free wildcard slot as a fallback
if (slot.link == null && !slot.links?.length) {
if (hasNoLinks(slot)) {
wildSlot = { index, slot }
} else {
occupiedWildSlot ??= { index, slot }
@@ -99,3 +100,11 @@ export function findFreeSlotOfType<T extends InputOrOutput>(
}
return wildSlot ?? occupiedSlot ?? occupiedWildSlot
}
export function removeFromArray<T>(array: T[], value: T): boolean {
const index = array.indexOf(value)
const found = index !== -1
if (found) array.splice(index, 1)
return found
}

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect } from "vitest"
import { LGraphNode } from "@/LGraphNode"
import { LiteGraph } from "@/litegraph"
import { LGraphNode, LiteGraph } from "@/litegraph"
import { test } from "./testExtensions"

View File

@@ -28,6 +28,8 @@ describe("LGraphNode", () => {
beforeEach(() => {
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error
delete origLiteGraph.Classes
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,

View File

@@ -240,11 +240,13 @@ LGraph {
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
@@ -285,11 +287,13 @@ LGraph {
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -246,11 +246,13 @@ LGraph {
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -240,11 +240,13 @@ LGraph {
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
@@ -285,11 +287,13 @@ LGraph {
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -12,6 +12,12 @@ LiteGraphGlobal {
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],
"Rectangle": [Function],
"SubgraphIONodeBase": [Function],
"SubgraphSlot": [Function],
},
"ContextMenu": [Function],
"CurveEditor": [Function],
"DEFAULT_FONT": "Arial",
@@ -31,7 +37,6 @@ LiteGraphGlobal {
"Globals": {},
"HIDDEN_LINK": -1,
"INPUT": 1,
"InputIndicators": [Function],
"LEFT": 3,
"LGraph": [Function],
"LGraphCanvas": [Function],

View File

@@ -313,7 +313,7 @@ describe("Rectangle", () => {
test.each([
[[0, 0] as Point, true],
[[10, 10] as Point, true],
[[9, 9] as Point, true],
[[5, 5] as Point, true],
[[-1, 5] as Point, false],
[[11, 5] as Point, false],
@@ -340,7 +340,7 @@ describe("Rectangle", () => {
// Outer rectangle is smaller
[new Rectangle(0, 0, 5, 5), new Rectangle(0, 0, 10, 10), true],
// Same size
[new Rectangle(0, 0, 100, 100), true],
[new Rectangle(0, 0, 99, 99), true],
])("should return %s when checking if %s is inside outer rect", (inner: Rectangle, expectedOrOuter: boolean | Rectangle, expectedIfThreeArgs?: boolean) => {
let testOuter = rect
rect.updateTo([0, 0, 100, 100])