diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 13fd8682a..6078b7a2a 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -689,6 +689,9 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable } } + /** Internal callback for subgraph nodes. Do not implement externally. */ + _internalConfigureAfterSlots?(): void + /** * configure a node from an object containing the serialized info */ @@ -754,6 +757,9 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable this.onOutputAdded?.(output) } + // SubgraphNode callback. + this._internalConfigureAfterSlots?.() + if (this.widgets) { for (const w of this.widgets) { if (!w) continue @@ -1788,6 +1794,28 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable return widget } + removeWidgetByName(name: string): void { + const widget = this.widgets?.find(x => x.name === name) + if (widget) this.removeWidget(widget) + } + + removeWidget(widget: IBaseWidget): void { + if (!this.widgets) throw new Error("removeWidget called on node without widgets") + + const widgetIndex = this.widgets.indexOf(widget) + if (widgetIndex === -1) throw new Error("Widget not found on this node") + + this.widgets.splice(widgetIndex, 1) + } + + ensureWidgetRemoved(widget: IBaseWidget): void { + try { + this.removeWidget(widget) + } catch (error) { + console.debug("Failed to remove widget", error) + } + } + move(deltaX: number, deltaY: number): void { if (this.pinned) return diff --git a/src/LLink.ts b/src/LLink.ts index e748747c5..c78cd1858 100644 --- a/src/LLink.ts +++ b/src/LLink.ts @@ -13,6 +13,8 @@ import type { Serialisable, SerialisableLLink, SubgraphIO } from "./types/serial import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants" +import { Subgraph } from "./litegraph" + export type LinkId = number export type SerialisedLLinkArray = [ @@ -405,6 +407,13 @@ export class LLink implements LinkSegment, Serialisable { } } network.links.delete(this.id) + + if (this.originIsIoNode && network instanceof Subgraph) { + const subgraphInput = network.inputs.at(this.origin_slot) + if (!subgraphInput) throw new Error("Invalid link - subgraph input not found") + + subgraphInput.events.dispatch("input-disconnected", { input: subgraphInput }) + } } /** diff --git a/src/infrastructure/SubgraphInputEventMap.ts b/src/infrastructure/SubgraphInputEventMap.ts new file mode 100644 index 000000000..0bc116589 --- /dev/null +++ b/src/infrastructure/SubgraphInputEventMap.ts @@ -0,0 +1,15 @@ +import type { LGraphEventMap } from "./LGraphEventMap" +import type { INodeInputSlot } from "@/litegraph" +import type { SubgraphInput } from "@/subgraph/SubgraphInput" +import type { IBaseWidget } from "@/types/widgets" + +export interface SubgraphInputEventMap extends LGraphEventMap { + "input-connected": { + input: INodeInputSlot + widget: IBaseWidget + } + + "input-disconnected": { + input: SubgraphInput + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts index 4c7ef07ab..33290a24e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -2,10 +2,10 @@ 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 { IBaseWidget } from "./types/widgets" import type { Rectangle } from "@/infrastructure/Rectangle" import type { CanvasPointerEvent } from "@/types/events" @@ -342,12 +342,16 @@ export interface INodeFlags { */ export interface IWidgetLocator { name: string - [key: string | symbol]: unknown } export interface INodeInputSlot extends INodeSlot { link: LinkId | null widget?: IWidgetLocator + + /** + * Internal use only; API is not finalised and may change at any time. + */ + _widget?: IBaseWidget } export interface IWidgetInputSlot extends INodeInputSlot { @@ -437,7 +441,7 @@ export interface DefaultConnectionColors { } export interface ISubgraphInput extends INodeInputSlot { - _subgraphSlot: SubgraphInput + _listenerController?: AbortController } /** diff --git a/src/node/NodeInputSlot.ts b/src/node/NodeInputSlot.ts index 2f7fd9f31..bc83d6651 100644 --- a/src/node/NodeInputSlot.ts +++ b/src/node/NodeInputSlot.ts @@ -1,6 +1,7 @@ import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces" import type { LGraphNode } from "@/LGraphNode" import type { LinkId } from "@/LLink" +import type { IBaseWidget } from "@/types/widgets" import { LabelPosition } from "@/draw" import { LiteGraph } from "@/litegraph" @@ -13,6 +14,17 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot { return !!this.widget } + #widget: WeakRef | undefined + + /** Internal use only; API is not finalised and may change at any time. */ + get _widget(): IBaseWidget | undefined { + return this.#widget?.deref() + } + + set _widget(widget: IBaseWidget | undefined) { + this.#widget = widget ? new WeakRef(widget) : undefined + } + get collapsedPos(): ReadOnlyPoint { return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5] } diff --git a/src/subgraph/Subgraph.ts b/src/subgraph/Subgraph.ts index 025271bbd..9abe36668 100644 --- a/src/subgraph/Subgraph.ts +++ b/src/subgraph/Subgraph.ts @@ -68,7 +68,9 @@ export class Subgraph extends LGraph implements BaseLGraph, Serialisable() + + /** The linked widget that this slot is connected to. */ + #widgetRef?: WeakRef + + get _widget() { + return this.#widgetRef?.deref() + } + + set _widget(widget) { + this.#widgetRef = widget ? new WeakRef(widget) : undefined + } + override connect(slot: INodeInputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined { const { subgraph } = this.parent @@ -49,6 +65,17 @@ export class SubgraphInput extends SubgraphSlot { this.parent._disconnectNodeInput(node, slot, link) } + const inputWidget = node.getWidgetFromSlot(slot) + if (inputWidget) { + if (!this.matchesWidget(inputWidget)) { + console.warn("Target input has invalid widget.", slot, node) + return + } + + this._widget ??= inputWidget + this.events.dispatch("input-connected", { input: slot, widget: inputWidget }) + } + const link = new LLink( ++subgraph.state.lastLinkId, slot.type, @@ -104,6 +131,73 @@ export class SubgraphInput extends SubgraphSlot { return [x, y + height * 0.5] } + getConnectedWidgets(): IBaseWidget[] { + const { subgraph } = this.parent + const widgets: IBaseWidget[] = [] + + for (const linkId of this.linkIds) { + const link = subgraph.getLink(linkId) + if (!link) { + console.error("Link not found", linkId) + continue + } + + const resolved = link.resolve(subgraph) + if (resolved.input && resolved.inputNode?.widgets) { + // Has no widget + const widgetNamePojo = resolved.input.widget + if (!widgetNamePojo) continue + + // Invalid widget name + if (!widgetNamePojo.name) { + console.warn("Invalid widget name", widgetNamePojo) + continue + } + + const widget = resolved.inputNode.widgets.find(w => w.name === widgetNamePojo.name) + if (!widget) { + console.warn("Widget not found", widgetNamePojo) + continue + } + + widgets.push(widget) + } else { + console.debug("No input found on link id", linkId, link) + } + } + return widgets + } + + /** + * Validates that the connection between the new slot and the existing widget is valid. + * Used to prevent connections between widgets that are not of the same type. + * @param otherWidget The widget to compare to. + * @returns `true` if the connection is valid, otherwise `false`. + */ + matchesWidget(otherWidget: IBaseWidget): boolean { + const widget = this.#widgetRef?.deref() + if (!widget) return true + + if ( + otherWidget.type !== widget.type || + otherWidget.options.min !== widget.options.min || + otherWidget.options.max !== widget.options.max || + otherWidget.options.step !== widget.options.step || + otherWidget.options.step2 !== widget.options.step2 || + otherWidget.options.precision !== widget.options.precision + ) { + return false + } + + return true + } + + override disconnect(): void { + super.disconnect() + + this.events.dispatch("input-disconnected", { input: this }) + } + /** For inputs, x is the right edge of the input node. */ override arrange(rect: ReadOnlyRect): void { const [right, top, width, height] = rect diff --git a/src/subgraph/SubgraphNode.ts b/src/subgraph/SubgraphNode.ts index eba4bc33e..25e2a8e16 100644 --- a/src/subgraph/SubgraphNode.ts +++ b/src/subgraph/SubgraphNode.ts @@ -1,15 +1,18 @@ +import type { SubgraphInput } from "./SubgraphInput" 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 { IBaseWidget } from "@/types/widgets" import type { UUID } from "@/utils/uuid" import { RecursionError } from "@/infrastructure/RecursionError" import { LGraphNode } from "@/LGraphNode" +import { type INodeInputSlot, type ISlotType, type NodeId } from "@/litegraph" import { LLink, type ResolvedConnection } from "@/LLink" import { NodeInputSlot } from "@/node/NodeInputSlot" import { NodeOutputSlot } from "@/node/NodeOutputSlot" +import { toConcreteWidget } from "@/widgets/widgetMap" import { type ExecutableLGraphNode, ExecutableNodeDTO } from "./ExecutableNodeDTO" @@ -17,6 +20,8 @@ import { type ExecutableLGraphNode, ExecutableNodeDTO } from "./ExecutableNodeDT * An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph. */ export class SubgraphNode extends LGraphNode implements BaseLGraph { + declare inputs: (INodeInputSlot & Partial)[] + override readonly type: UUID override readonly isVirtualNode = true as const @@ -32,6 +37,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { return true } + override widgets: IBaseWidget[] = [] + constructor( /** The (sub)graph that contains this subgraph instance. */ override readonly graph: GraphOrSubgraph, @@ -44,10 +51,17 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { // Update this node when the subgraph input / output slots are changed const subgraphEvents = this.subgraph.events subgraphEvents.addEventListener("input-added", (e) => { - const { name, type } = e.detail.input - this.addInput(name, type) + const subgraphInput = e.detail.input + const { name, type } = subgraphInput + const input = this.addInput(name, type) + + this.#addSubgraphInputListeners(subgraphInput, input) }) + subgraphEvents.addEventListener("removing-input", (e) => { + const widget = e.detail.input._widget + if (widget) this.ensureWidgetRemoved(widget) + this.removeInput(e.detail.index) }) @@ -55,6 +69,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const { name, type } = e.detail.output this.addOutput(name, type) }) + subgraphEvents.addEventListener("removing-output", (e) => { this.removeOutput(e.detail.index) }) @@ -79,7 +94,46 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { this.configure(instanceData) } + #addSubgraphInputListeners(subgraphInput: SubgraphInput, input: INodeInputSlot & Partial) { + input._listenerController?.abort() + input._listenerController = new AbortController() + const { signal } = input._listenerController + + subgraphInput.events.addEventListener( + "input-connected", + () => { + if (input._widget) return + + const widget = subgraphInput._widget + if (!widget) return + + this.#setWidget(subgraphInput, input, widget) + }, + { signal }, + ) + + subgraphInput.events.addEventListener( + "input-disconnected", + () => { + // If the input is connected to more than one widget, don't remove the widget + const connectedWidgets = subgraphInput.getConnectedWidgets() + if (connectedWidgets.length > 0) return + + this.removeWidgetByName(input.name) + + delete input.pos + delete input.widget + input._widget = undefined + }, + { signal }, + ) + } + override configure(info: ExportedSubgraphInstance): void { + for (const input of this.inputs) { + input._listenerController?.abort() + } + this.inputs.length = 0 this.inputs.push( ...this.subgraph.inputNode.slots.map( @@ -97,6 +151,71 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { super.configure(info) } + override _internalConfigureAfterSlots() { + // Reset widgets + this.widgets.length = 0 + + // Check all inputs for connected widgets + for (const input of this.inputs) { + const subgraphInput = this.subgraph.inputNode.slots.find(slot => slot.name === input.name) + if (!subgraphInput) throw new Error(`[SubgraphNode.configure] No subgraph input found for input ${input.name}`) + + this.#addSubgraphInputListeners(subgraphInput, input) + + // Find the first widget that this slot is connected to + for (const linkId of subgraphInput.linkIds) { + const link = this.subgraph.getLink(linkId) + if (!link) { + console.warn(`[SubgraphNode.configure] No link found for link ID ${linkId}`, this) + continue + } + + const resolved = link.resolve(this.subgraph) + if (!resolved.input || !resolved.inputNode) { + console.warn("Invalid resolved link", resolved, this) + continue + } + + // No widget - ignore this link + const widget = resolved.inputNode.getWidgetFromSlot(resolved.input) + if (!widget) continue + + this.#setWidget(subgraphInput, input, widget) + break + } + } + } + + #setWidget(subgraphInput: Readonly, input: INodeInputSlot, widget: Readonly) { + // Use the first matching widget + const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(this) + Object.assign(promotedWidget, { + get name() { + return subgraphInput.name + }, + set name(value) { + console.warn("Promoted widget: setting name is not allowed", this, value) + }, + get localized_name() { + return subgraphInput.localized_name + }, + set localized_name(value) { + console.warn("Promoted widget: setting localized_name is not allowed", this, value) + }, + get label() { + return subgraphInput.label + }, + set label(value) { + console.warn("Promoted widget: setting label is not allowed", this, value) + }, + }) + + this.widgets.push(promotedWidget) + + input.widget = { name: subgraphInput.name } + input._widget = promotedWidget + } + /** * Ensures the subgraph slot is in the params before adding the input as normal. * @param name The name of the input slot. @@ -179,4 +298,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } return nodes } + + override onRemoved(): void { + for (const input of this.inputs) { + input._listenerController?.abort() + } + } } diff --git a/src/widgets/BaseWidget.ts b/src/widgets/BaseWidget.ts index f423cc642..73576e9ca 100644 --- a/src/widgets/BaseWidget.ts +++ b/src/widgets/BaseWidget.ts @@ -282,4 +282,19 @@ export abstract class BaseWidget impl node.onWidgetChanged?.(this.name ?? "", v, oldValue, this) if (node.graph) node.graph._version++ } + + /** + * Clones the widget. + * @param node The node that will own the cloned widget. + * @returns A new widget with the same properties as the original + * @remarks Subclasses with custom constructors must override this method. + * + * Correctly and safely typing this is currently not possible (practical?) in TypeScript 5.8. + */ + createCopyForNode(node: LGraphNode): this { + // @ts-expect-error + const cloned: this = new (this.constructor as typeof this)(this, node) + cloned.value = this.value + return cloned + } }