Widget promotion (#1100)

This commit is contained in:
filtered
2025-07-02 17:49:15 -07:00
committed by GitHub
parent 6f9d5a7a5b
commit 8b8f38f4de
9 changed files with 311 additions and 7 deletions

View File

@@ -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

View File

@@ -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<SerialisableLLink> {
}
}
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 })
}
}
/**

View File

@@ -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
}
}

View File

@@ -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
}
/**

View File

@@ -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<IBaseWidget> | 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]
}

View File

@@ -68,7 +68,9 @@ export class Subgraph extends LGraph implements BaseLGraph, Serialisable<Exporte
if (inputs) {
this.inputs.length = 0
for (const input of inputs) {
this.inputs.push(new SubgraphInput(input, this.inputNode))
const subgraphInput = new SubgraphInput(input, this.inputNode)
this.inputs.push(subgraphInput)
this.events.dispatch("input-added", { input: subgraphInput })
}
}

View File

@@ -1,8 +1,11 @@
import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { SubgraphInputEventMap } from "@/infrastructure/SubgraphInputEventMap"
import type { INodeInputSlot, Point, ReadOnlyRect } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute"
import type { IBaseWidget } from "@/types/widgets"
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums"
@@ -22,6 +25,19 @@ import { SubgraphSlot } from "./SubgraphSlotBase"
export class SubgraphInput extends SubgraphSlot {
declare parent: SubgraphInputNode
events = new CustomEventTarget<SubgraphInputEventMap>()
/** The linked widget that this slot is connected to. */
#widgetRef?: WeakRef<IBaseWidget>
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

View File

@@ -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<ISubgraphInput>)[]
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<ISubgraphInput>) {
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<SubgraphInput>, input: INodeInputSlot, widget: Readonly<IBaseWidget>) {
// 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()
}
}
}

View File

@@ -282,4 +282,19 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget> 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
}
}