diff --git a/src/LGraph.ts b/src/LGraph.ts index 74e857f31..37f59d887 100644 --- a/src/LGraph.ts +++ b/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 { } export interface BaseLGraph { + /** The root graph. */ readonly rootGraph: LGraph } @@ -71,6 +82,25 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable() + readonly _subgraphs: Map = new Map() + + _nodes: (LGraphNode | SubgraphNode)[] = [] _nodes_by_id: Record = {} _nodes_in_order: LGraphNode[] = [] _nodes_executable: LGraphNode[] | null = null @@ -241,6 +274,7 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable c.clear()) } + get subgraphs(): Map { + return this.rootGraph._subgraphs + } + get nodes() { return this._nodes } @@ -312,6 +350,8 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable 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): { 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 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() - // 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) { diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index e9f22377c..3c9b0f83e 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -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 { // 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>(type: T, detail: LGraphCanvasEventMap[T]): boolean + dispatch>(type: T): boolean + dispatch(type: T, detail?: LGraphCanvasEventMap[T]) { + const event = new CustomEvent(type as string, { detail, bubbles: true }) + return this.canvas.dispatchEvent(event) + } + + dispatchEvent(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 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() 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 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 diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 9fe8fdf06..13fd8682a 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -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 } diff --git a/src/LLink.ts b/src/LLink.ts index 7f3f0bc2f..e748747c5 100644 --- a/src/LLink.ts +++ b/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 +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 // this is the class in charge of storing link information export class LLink implements LinkSegment, Serialisable { + static _drawDebug = false + /** Link ID */ id: LinkId parentId?: RerouteId @@ -83,6 +128,16 @@ export class LLink implements LinkSegment, Serialisable { 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 { */ 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) { diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index ab13f1728..72b2be01b 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -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 diff --git a/src/canvas/FloatingRenderLink.ts b/src/canvas/FloatingRenderLink.ts index 1e41984c1..11efefeac 100644 --- a/src/canvas/FloatingRenderLink.ts +++ b/src/canvas/FloatingRenderLink.ts @@ -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): 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): 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 }, diff --git a/src/canvas/LinkConnector.ts b/src/canvas/LinkConnector.ts index 1f11cb165..a5a9de503 100644 --- a/src/canvas/LinkConnector.ts +++ b/src/canvas/LinkConnector.ts @@ -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, diff --git a/src/canvas/MovingInputLink.ts b/src/canvas/MovingInputLink.ts index e26ce7d60..15619e1b6 100644 --- a/src/canvas/MovingInputLink.ts +++ b/src/canvas/MovingInputLink.ts @@ -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): 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 }, diff --git a/src/canvas/MovingLinkBase.ts b/src/canvas/MovingLinkBase.ts index 062d968c0..aaba42e8a 100644 --- a/src/canvas/MovingLinkBase.ts +++ b/src/canvas/MovingLinkBase.ts @@ -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): void abstract connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget): void + abstract connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget): void + abstract connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget): void abstract connectToRerouteInput(reroute: Reroute, { node, input, link }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, events: CustomEventTarget, originalReroutes: Reroute[]): void abstract connectToRerouteOutput(reroute: Reroute, outputNode: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget): void diff --git a/src/canvas/MovingOutputLink.ts b/src/canvas/MovingOutputLink.ts index fa1b675a8..8d6838d65 100644 --- a/src/canvas/MovingOutputLink.ts +++ b/src/canvas/MovingOutputLink.ts @@ -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): 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.") } diff --git a/src/canvas/RenderLink.ts b/src/canvas/RenderLink.ts index 34dd4c1c5..44fd51fd1 100644 --- a/src/canvas/RenderLink.ts +++ b/src/canvas/RenderLink.ts @@ -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 /** 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): void connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget): void + connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget): void + connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget): void connectToRerouteInput( reroute: Reroute, diff --git a/src/canvas/ToInputFromIoNodeLink.ts b/src/canvas/ToInputFromIoNodeLink.ts new file mode 100644 index 000000000..df64ef991 --- /dev/null +++ b/src/canvas/ToInputFromIoNodeLink.ts @@ -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) { + const { fromSlot, fromReroute } = this + + const newLink = fromSlot.connect(input, node, fromReroute?.id) + events.dispatch("link-created", newLink) + } + + connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget): void { + throw new Error("Not implemented") + } + + connectToRerouteInput( + reroute: Reroute, + { + node: inputNode, + input, + link, + }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, + events: CustomEventTarget, + 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.") + } +} diff --git a/src/canvas/ToInputRenderLink.ts b/src/canvas/ToInputRenderLink.ts index 7fcab80c0..bdb8a08c8 100644 --- a/src/canvas/ToInputRenderLink.ts +++ b/src/canvas/ToInputRenderLink.ts @@ -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) { + 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.") } diff --git a/src/canvas/ToOutputFromIoNodeLink.ts b/src/canvas/ToOutputFromIoNodeLink.ts new file mode 100644 index 000000000..35b12459e --- /dev/null +++ b/src/canvas/ToOutputFromIoNodeLink.ts @@ -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) { + const { fromSlot, fromReroute } = this + + const newLink = fromSlot.connect(output, node, fromReroute?.id) + events.dispatch("link-created", newLink) + } + + connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget): void { + throw new Error("Not implemented") + } + + connectToRerouteOutput( + reroute: Reroute, + outputNode: LGraphNode, + output: INodeOutputSlot, + events: CustomEventTarget, + ): 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.") + } +} diff --git a/src/canvas/ToOutputRenderLink.ts b/src/canvas/ToOutputRenderLink.ts index a00a60172..1f2762770 100644 --- a/src/canvas/ToOutputRenderLink.ts +++ b/src/canvas/ToOutputRenderLink.ts @@ -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): 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.") } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..8f0b7a909 --- /dev/null +++ b/src/constants.ts @@ -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 diff --git a/src/infrastructure/ConstrainedSize.ts b/src/infrastructure/ConstrainedSize.ts new file mode 100644 index 000000000..11d86955d --- /dev/null +++ b/src/infrastructure/ConstrainedSize.ts @@ -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] + } +} diff --git a/src/infrastructure/InvalidLinkError.ts b/src/infrastructure/InvalidLinkError.ts new file mode 100644 index 000000000..d5ffa0f85 --- /dev/null +++ b/src/infrastructure/InvalidLinkError.ts @@ -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" + } +} diff --git a/src/infrastructure/LGraphCanvasEventMap.ts b/src/infrastructure/LGraphCanvasEventMap.ts index aa12d00f2..79d360e25 100644 --- a/src/infrastructure/LGraphCanvasEventMap.ts +++ b/src/infrastructure/LGraphCanvasEventMap.ts @@ -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" } | { diff --git a/src/infrastructure/LGraphEventMap.ts b/src/infrastructure/LGraphEventMap.ts new file mode 100644 index 000000000..6a08a8b8d --- /dev/null +++ b/src/infrastructure/LGraphEventMap.ts @@ -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 + } +} diff --git a/src/infrastructure/LinkConnectorEventMap.ts b/src/infrastructure/LinkConnectorEventMap.ts index 8264c227c..985d24dd4 100644 --- a/src/infrastructure/LinkConnectorEventMap.ts +++ b/src/infrastructure/LinkConnectorEventMap.ts @@ -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": { diff --git a/src/infrastructure/Rectangle.ts b/src/infrastructure/Rectangle.ts index 991597178..4e47dabd3 100644 --- a/src/infrastructure/Rectangle.ts +++ b/src/infrastructure/Rectangle.ts @@ -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 { 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 { diff --git a/src/infrastructure/RecursionError.ts b/src/infrastructure/RecursionError.ts new file mode 100644 index 000000000..bba29e9c8 --- /dev/null +++ b/src/infrastructure/RecursionError.ts @@ -0,0 +1,9 @@ +/** + * Error thrown when infinite recursion is detected. + */ +export class RecursionError extends Error { + constructor(subject: string) { + super(subject) + this.name = "RecursionError" + } +} diff --git a/src/infrastructure/SlotIndexError.ts b/src/infrastructure/SlotIndexError.ts new file mode 100644 index 000000000..4ab963679 --- /dev/null +++ b/src/infrastructure/SlotIndexError.ts @@ -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" + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts index 00e1bd26b..4c7ef07ab 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -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 = { [key: string]: T } @@ -73,6 +78,12 @@ export interface Positionable extends Parent, 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 any) | undefined> = * @see {@link CallbackParams} */ export type CallbackReturn any) | undefined> = ReturnType> + +/** + * 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 +} diff --git a/src/litegraph.ts b/src/litegraph.ts index 6e5e7c2f8..91608a32a 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -91,6 +91,7 @@ export interface LGraphNodeConstructor { 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, diff --git a/src/measure.ts b/src/measure.ts index 762895c81..4c2417d3f 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -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) } diff --git a/src/node/NodeSlot.ts b/src/node/NodeSlot.ts index b4279aaac..4115bef10 100644 --- a/src/node/NodeSlot.ts +++ b/src/node/NodeSlot.ts @@ -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, 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 } diff --git a/src/node/SlotBase.ts b/src/node/SlotBase.ts index bee4b1916..9d87b4aec 100644 --- a/src/node/SlotBase.ts +++ b/src/node/SlotBase.ts @@ -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 diff --git a/src/strings.ts b/src/strings.ts index 06ed3fb9f..e5481e604 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -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 +} diff --git a/src/subgraph/EmptySubgraphInput.ts b/src/subgraph/EmptySubgraphInput.ts new file mode 100644 index 000000000..f005bd239 --- /dev/null +++ b/src/subgraph/EmptySubgraphInput.ts @@ -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] + } +} diff --git a/src/subgraph/EmptySubgraphOutput.ts b/src/subgraph/EmptySubgraphOutput.ts new file mode 100644 index 000000000..c8ccdf29a --- /dev/null +++ b/src/subgraph/EmptySubgraphOutput.ts @@ -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] + } +} diff --git a/src/subgraph/ExecutableNodeDTO.ts b/src/subgraph/ExecutableNodeDTO.ts new file mode 100644 index 000000000..e802e2d7f --- /dev/null +++ b/src/subgraph/ExecutableNodeDTO.ts @@ -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 + +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): CallbackReturn + + /** 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()): 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): 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): 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) + } +} diff --git a/src/subgraph/Subgraph.ts b/src/subgraph/Subgraph.ts index 784decf30..5f4119acf 100644 --- a/src/subgraph/Subgraph.ts +++ b/src/subgraph/Subgraph.ts @@ -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 { + /** 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> { diff --git a/src/subgraph/SubgraphIONodeBase.ts b/src/subgraph/SubgraphIONodeBase.ts index 4798db385..0b2aabcd4 100644 --- a/src/subgraph/SubgraphIONodeBase.ts +++ b/src/subgraph/SubgraphIONodeBase.ts @@ -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 { +export abstract class SubgraphIONodeBase implements Positionable, Hoverable, Serialisable { 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 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]] -} diff --git a/src/subgraph/SubgraphInput.ts b/src/subgraph/SubgraphInput.ts index b41e76907..37c853a1e 100644 --- a/src/subgraph/SubgraphInput.ts +++ b/src/subgraph/SubgraphInput.ts @@ -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] diff --git a/src/subgraph/SubgraphInputNode.ts b/src/subgraph/SubgraphInputNode.ts index f1dcbf9d0..f2cf2dd10 100644 --- a/src/subgraph/SubgraphInputNode.ts +++ b/src/subgraph/SubgraphInputNode.ts @@ -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 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) + } } diff --git a/src/subgraph/SubgraphNode.ts b/src/subgraph/SubgraphNode.ts new file mode 100644 index 000000000..643ed823f --- /dev/null +++ b/src/subgraph/SubgraphNode.ts @@ -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>(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(), + /** 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 + } +} diff --git a/src/subgraph/SubgraphOutput.ts b/src/subgraph/SubgraphOutput.ts index 7f9390da4..1d4224512 100644 --- a/src/subgraph/SubgraphOutput.ts +++ b/src/subgraph/SubgraphOutput.ts @@ -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] diff --git a/src/subgraph/SubgraphOutputNode.ts b/src/subgraph/SubgraphOutputNode.ts index 395b74eeb..1328e8e40 100644 --- a/src/subgraph/SubgraphOutputNode.ts +++ b/src/subgraph/SubgraphOutputNode.ts @@ -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 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) + } } diff --git a/src/subgraph/SubgraphSlotBase.ts b/src/subgraph/SubgraphSlotBase.ts index 2a876b230..bf5b148e8 100644 --- a/src/subgraph/SubgraphSlotBase.ts +++ b/src/subgraph/SubgraphSlotBase.ts @@ -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 { +export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Hoverable, Serialisable { 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 } } } diff --git a/src/subgraph/subgraphUtils.ts b/src/subgraph/subgraphUtils.ts new file mode 100644 index 000000000..eaa6bb7e8 --- /dev/null +++ b/src/subgraph/subgraphUtils.ts @@ -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 + reroutes: Set + groups: Set + subgraphInputNodes: Set + subgraphOutputNodes: Set + unknown: Set +} + +export function splitPositionables(items: Iterable): FilteredItems { + const nodes = new Set() + const reroutes = new Set() + const groups = new Set() + const subgraphInputNodes = new Set() + const subgraphOutputNodes = new Set() + + const unknown = new Set() + + 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): BoundaryLinks { + const internalLinks: LLink[] = [] + const boundaryLinks: LLink[] = [] + const boundaryInputLinks: LLink[] = [] + const boundaryOutputLinks: LLink[] = [] + const boundaryFloatingLinks: LLink[] = [] + const visited = new WeakSet() + + 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 | 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): 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 { + const groupedByOutput: ReturnType = 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 +} diff --git a/src/subgraphInterfaces.ts b/src/subgraphInterfaces.ts deleted file mode 100644 index 4ec3422fb..000000000 --- a/src/subgraphInterfaces.ts +++ /dev/null @@ -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 -} diff --git a/src/types/NodeLike.ts b/src/types/NodeLike.ts new file mode 100644 index 000000000..38a9e72c0 --- /dev/null +++ b/src/types/NodeLike.ts @@ -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 +} diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index 911c41dd9..7beb43b93 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -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 +} diff --git a/src/types/serialisation.ts b/src/types/serialisation.ts index 40115726b..1590ebc9a 100644 --- a/src/types/serialisation.ts +++ b/src/types/serialisation.ts @@ -33,6 +33,7 @@ export interface Serialisable { 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 +type SubgraphIOShared = Omit /** Subgraph I/O slots */ export interface SubgraphIO extends SubgraphIOShared { diff --git a/src/utils/collections.ts b/src/utils/collections.ts index f97ea4ff9..d70e6275b 100644 --- a/src/utils/collections.ts +++ b/src/utils/collections.ts @@ -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 = { index: number, slot: T } | undefined +type FreeSlotResult = { 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 = { 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( +export function findFreeSlotOfType( slots: T[], type: ISlotType, -): FreeSlotResult { + hasNoLinks: (slot: T) => boolean, +) { if (!slots?.length) return let occupiedSlot: FreeSlotResult @@ -80,7 +81,7 @@ export function findFreeSlotOfType( 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( 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( } return wildSlot ?? occupiedSlot ?? occupiedWildSlot } + +export function removeFromArray(array: T[], value: T): boolean { + const index = array.indexOf(value) + const found = index !== -1 + + if (found) array.splice(index, 1) + return found +} diff --git a/test/LGraphNode.resize.test.ts b/test/LGraphNode.resize.test.ts index 5951a902f..8f0b06acd 100644 --- a/test/LGraphNode.resize.test.ts +++ b/test/LGraphNode.resize.test.ts @@ -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" diff --git a/test/LGraphNode.test.ts b/test/LGraphNode.test.ts index 9d8265aa0..6bb5dc08c 100644 --- a/test/LGraphNode.test.ts +++ b/test/LGraphNode.test.ts @@ -28,6 +28,8 @@ describe("LGraphNode", () => { beforeEach(() => { origLiteGraph = Object.assign({}, LiteGraph) + // @ts-expect-error + delete origLiteGraph.Classes Object.assign(LiteGraph, { NODE_TITLE_HEIGHT: 20, diff --git a/test/__snapshots__/ConfigureGraph.test.ts.snap b/test/__snapshots__/ConfigureGraph.test.ts.snap index dbc29135b..37d51774f 100644 --- a/test/__snapshots__/ConfigureGraph.test.ts.snap +++ b/test/__snapshots__/ConfigureGraph.test.ts.snap @@ -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": {}, diff --git a/test/__snapshots__/LGraph.test.ts.snap b/test/__snapshots__/LGraph.test.ts.snap index 1879b70f9..d1f520005 100644 --- a/test/__snapshots__/LGraph.test.ts.snap +++ b/test/__snapshots__/LGraph.test.ts.snap @@ -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": {}, diff --git a/test/__snapshots__/LGraph_constructor.test.ts.snap b/test/__snapshots__/LGraph_constructor.test.ts.snap index 10022c368..a0fea6132 100644 --- a/test/__snapshots__/LGraph_constructor.test.ts.snap +++ b/test/__snapshots__/LGraph_constructor.test.ts.snap @@ -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": {}, diff --git a/test/__snapshots__/litegraph.test.ts.snap b/test/__snapshots__/litegraph.test.ts.snap index f168f791d..dbbfa556a 100644 --- a/test/__snapshots__/litegraph.test.ts.snap +++ b/test/__snapshots__/litegraph.test.ts.snap @@ -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], diff --git a/test/infrastructure/Rectangle.test.ts b/test/infrastructure/Rectangle.test.ts index 839b301ec..2dd00f964 100644 --- a/test/infrastructure/Rectangle.test.ts +++ b/test/infrastructure/Rectangle.test.ts @@ -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])