mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 13:59:54 +00:00
Add Subgraphs (#1000)
This commit is contained in:
567
src/LGraph.ts
567
src/LGraph.ts
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
83
src/LLink.ts
83
src/LLink.ts
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
114
src/canvas/ToInputFromIoNodeLink.ts
Normal file
114
src/canvas/ToInputFromIoNodeLink.ts
Normal 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.")
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
88
src/canvas/ToOutputFromIoNodeLink.ts
Normal file
88
src/canvas/ToOutputFromIoNodeLink.ts
Normal 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.")
|
||||
}
|
||||
}
|
||||
@@ -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
11
src/constants.ts
Normal 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
|
||||
75
src/infrastructure/ConstrainedSize.ts
Normal file
75
src/infrastructure/ConstrainedSize.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
6
src/infrastructure/InvalidLinkError.ts
Normal file
6
src/infrastructure/InvalidLinkError.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
| {
|
||||
|
||||
47
src/infrastructure/LGraphEventMap.ts
Normal file
47
src/infrastructure/LGraphEventMap.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
9
src/infrastructure/RecursionError.ts
Normal file
9
src/infrastructure/RecursionError.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
6
src/infrastructure/SlotIndexError.ts
Normal file
6
src/infrastructure/SlotIndexError.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
39
src/subgraph/EmptySubgraphInput.ts
Normal file
39
src/subgraph/EmptySubgraphInput.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
39
src/subgraph/EmptySubgraphOutput.ts
Normal file
39
src/subgraph/EmptySubgraphOutput.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
232
src/subgraph/ExecutableNodeDTO.ts
Normal file
232
src/subgraph/ExecutableNodeDTO.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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">> {
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
148
src/subgraph/SubgraphNode.ts
Normal file
148
src/subgraph/SubgraphNode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
338
src/subgraph/subgraphUtils.ts
Normal file
338
src/subgraph/subgraphUtils.ts
Normal 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
|
||||
}
|
||||
@@ -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
13
src/types/NodeLike.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ describe("LGraphNode", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
NODE_TITLE_HEIGHT: 20,
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user