Add slot compatibility checking for subgraph slots (#1182)

This commit is contained in:
Benjamin Lu
2025-08-01 18:38:57 -04:00
committed by GitHub
parent 04b03e22f8
commit 6fa2e8e3ca
14 changed files with 624 additions and 39 deletions

View File

@@ -4212,7 +4212,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} }
// Draw subgraph IO nodes // Draw subgraph IO nodes
this.subgraph?.draw(ctx, this.colourGetter) this.subgraph?.draw(ctx, this.colourGetter, this.linkConnector.renderLinks[0]?.fromSlot, this.editor_alpha)
// on top (debug) // on top (debug)
if (this.render_execution_order) { if (this.render_execution_order) {

View File

@@ -1,11 +1,14 @@
import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
import type { LinkId } from "@/LLink" import type { LinkId } from "@/LLink"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import type { IBaseWidget } from "@/types/widgets" import type { IBaseWidget } from "@/types/widgets"
import { LabelPosition } from "@/draw" import { LabelPosition } from "@/draw"
import { LiteGraph } from "@/litegraph" import { LiteGraph } from "@/litegraph"
import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot" import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot"
import { isSubgraphInput } from "@/subgraph/subgraphUtils"
export class NodeInputSlot extends NodeSlot implements INodeInputSlot { export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
link: LinkId | null link: LinkId | null
@@ -38,8 +41,16 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
return this.link != null return this.link != null
} }
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean { override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
return "links" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type) if ("links" in fromSlot) {
return LiteGraph.isValidConnection(fromSlot.type, this.type)
}
if (isSubgraphInput(fromSlot)) {
return LiteGraph.isValidConnection(fromSlot.type, this.type)
}
return false
} }
override draw(ctx: CanvasRenderingContext2D, options: Omit<IDrawOptions, "doStroke" | "labelPosition">) { override draw(ctx: CanvasRenderingContext2D, options: Omit<IDrawOptions, "doStroke" | "labelPosition">) {

View File

@@ -1,10 +1,13 @@
import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
import type { LinkId } from "@/LLink" import type { LinkId } from "@/LLink"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import { LabelPosition } from "@/draw" import { LabelPosition } from "@/draw"
import { LiteGraph } from "@/litegraph" import { LiteGraph } from "@/litegraph"
import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot" import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot"
import { isSubgraphOutput } from "@/subgraph/subgraphUtils"
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot { export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
#node: LGraphNode #node: LGraphNode
@@ -32,8 +35,16 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
this.#node = node this.#node = node
} }
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean { override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
return "link" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type) if ("link" in fromSlot) {
return LiteGraph.isValidConnection(this.type, fromSlot.type)
}
if (isSubgraphOutput(fromSlot)) {
return LiteGraph.isValidConnection(this.type, fromSlot.type)
}
return false
} }
override get isConnected(): boolean { override get isConnected(): boolean {

View File

@@ -1,5 +1,7 @@
import type { CanvasColour, DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, INodeSlot, ISubgraphInput, OptionalProps, Point, ReadOnlyPoint } from "@/interfaces" import type { CanvasColour, DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, INodeSlot, ISubgraphInput, OptionalProps, Point, ReadOnlyPoint } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
import { LabelPosition, SlotShape, SlotType } from "@/draw" import { LabelPosition, SlotShape, SlotType } from "@/draw"
import { LiteGraph, Rectangle } from "@/litegraph" import { LiteGraph, Rectangle } from "@/litegraph"
@@ -68,7 +70,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
* Whether this slot is a valid target for a dragging link. * Whether this slot is a valid target for a dragging link.
* @param fromSlot The slot that the link is being connected from. * @param fromSlot The slot that the link is being connected from.
*/ */
abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean
/** /**
* The label to display in the UI. * The label to display in the UI.

View File

@@ -1,5 +1,5 @@
import type { SubgraphEventMap } from "@/infrastructure/SubgraphEventMap" import type { SubgraphEventMap } from "@/infrastructure/SubgraphEventMap"
import type { DefaultConnectionColors } from "@/interfaces" import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot } from "@/interfaces"
import type { LGraphCanvas } from "@/LGraphCanvas" import type { LGraphCanvas } from "@/LGraphCanvas"
import type { ExportedSubgraph, ExposedWidget, ISerialisedGraph, Serialisable, SerialisableGraph } from "@/types/serialisation" import type { ExportedSubgraph, ExposedWidget, ISerialisedGraph, Serialisable, SerialisableGraph } from "@/types/serialisation"
@@ -206,9 +206,9 @@ export class Subgraph extends LGraph implements BaseLGraph, Serialisable<Exporte
} }
} }
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void { draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
this.inputNode.draw(ctx, colorContext) this.inputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
this.outputNode.draw(ctx, colorContext) this.outputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
} }
/** /**

View File

@@ -4,7 +4,7 @@ import type { Subgraph } from "./Subgraph"
import type { SubgraphInput } from "./SubgraphInput" import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutput } from "./SubgraphOutput" import type { SubgraphOutput } from "./SubgraphOutput"
import type { LinkConnector } from "@/canvas/LinkConnector" import type { LinkConnector } from "@/canvas/LinkConnector"
import type { DefaultConnectionColors, Hoverable, Point, Positionable } from "@/interfaces" import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, Positionable } from "@/interfaces"
import type { NodeId } from "@/LGraphNode" import type { NodeId } from "@/LGraphNode"
import type { ExportedSubgraphIONode, Serialisable } from "@/types/serialisation" import type { ExportedSubgraphIONode, Serialisable } from "@/types/serialisation"
@@ -249,24 +249,23 @@ export abstract class SubgraphIONodeBase<TSlot extends SubgraphInput | SubgraphO
size[1] = currentY - y + roundedRadius size[1] = currentY - y + roundedRadius
} }
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void { draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
this.drawProtected(ctx, colorContext) this.drawProtected(ctx, colorContext, fromSlot, editorAlpha)
Object.assign(ctx, { lineWidth, strokeStyle, fillStyle, font, textBaseline }) Object.assign(ctx, { lineWidth, strokeStyle, fillStyle, font, textBaseline })
} }
/** @internal Leaves {@link ctx} dirty. */ /** @internal Leaves {@link ctx} dirty. */
protected abstract drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void protected abstract drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void
/** @internal Leaves {@link ctx} dirty. */ /** @internal Leaves {@link ctx} dirty. */
protected drawSlots(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void { protected drawSlots(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
ctx.fillStyle = "#AAA" ctx.fillStyle = "#AAA"
ctx.font = "12px Arial" ctx.font = "12px Arial"
ctx.textBaseline = "middle" ctx.textBaseline = "middle"
for (const slot of this.allSlots) { for (const slot of this.allSlots) {
slot.draw({ ctx, colorContext }) slot.draw({ ctx, colorContext, fromSlot, editorAlpha })
slot.drawLabel(ctx)
} }
} }

View File

@@ -1,15 +1,18 @@
import type { SubgraphInputNode } from "./SubgraphInputNode" import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { SubgraphInputEventMap } from "@/infrastructure/SubgraphInputEventMap" import type { SubgraphInputEventMap } from "@/infrastructure/SubgraphInputEventMap"
import type { INodeInputSlot, Point, ReadOnlyRect } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute" import type { RerouteId } from "@/Reroute"
import type { IBaseWidget } from "@/types/widgets" import type { IBaseWidget } from "@/types/widgets"
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget" import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
import { LiteGraph } from "@/litegraph"
import { LLink } from "@/LLink" import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums" import { NodeSlotType } from "@/types/globalEnums"
import { SubgraphSlot } from "./SubgraphSlotBase" import { SubgraphSlot } from "./SubgraphSlotBase"
import { isNodeSlot, isSubgraphOutput } from "./subgraphUtils"
/** /**
* An input "slot" from a parent graph into a subgraph. * An input "slot" from a parent graph into a subgraph.
@@ -211,4 +214,21 @@ export class SubgraphInput extends SubgraphSlot {
pos[0] = right - height * 0.5 pos[0] = right - height * 0.5
pos[1] = top + height * 0.5 pos[1] = top + height * 0.5
} }
/**
* Checks if this slot is a valid target for a connection from the given slot.
* For SubgraphInput (which acts as an output inside the subgraph),
* the fromSlot should be an input slot.
*/
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
if (isNodeSlot(fromSlot)) {
return "link" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type)
}
if (isSubgraphOutput(fromSlot)) {
return LiteGraph.isValidConnection(this.type, fromSlot.type)
}
return false
}
} }

View File

@@ -1,7 +1,8 @@
import type { SubgraphInput } from "./SubgraphInput" import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { LinkConnector } from "@/canvas/LinkConnector" import type { LinkConnector } from "@/canvas/LinkConnector"
import type { CanvasPointer } from "@/CanvasPointer" import type { CanvasPointer } from "@/CanvasPointer"
import type { DefaultConnectionColors, INodeInputSlot, ISlotType, Positionable } from "@/interfaces" import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, ISlotType, Positionable } from "@/interfaces"
import type { LGraphNode, NodeId } from "@/LGraphNode" import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute" import type { RerouteId } from "@/Reroute"
import type { CanvasPointerEvent } from "@/types/events" import type { CanvasPointerEvent } from "@/types/events"
@@ -170,7 +171,7 @@ export class SubgraphInputNode extends SubgraphIONodeBase<SubgraphInput> impleme
) )
} }
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void { override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
const { roundedRadius } = SubgraphIONodeBase const { roundedRadius } = SubgraphIONodeBase
const transform = ctx.getTransform() const transform = ctx.getTransform()
@@ -194,6 +195,6 @@ export class SubgraphInputNode extends SubgraphIONodeBase<SubgraphInput> impleme
// Restore context // Restore context
ctx.setTransform(transform) ctx.setTransform(transform)
this.drawSlots(ctx, colorContext) this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
} }
} }

View File

@@ -1,13 +1,16 @@
import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutputNode } from "./SubgraphOutputNode" import type { SubgraphOutputNode } from "./SubgraphOutputNode"
import type { INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
import type { RerouteId } from "@/Reroute" import type { RerouteId } from "@/Reroute"
import { LiteGraph } from "@/litegraph"
import { LLink } from "@/LLink" import { LLink } from "@/LLink"
import { NodeSlotType } from "@/types/globalEnums" import { NodeSlotType } from "@/types/globalEnums"
import { removeFromArray } from "@/utils/collections" import { removeFromArray } from "@/utils/collections"
import { SubgraphSlot } from "./SubgraphSlotBase" import { SubgraphSlot } from "./SubgraphSlotBase"
import { isNodeSlot, isSubgraphInput } from "./subgraphUtils"
/** /**
* An output "slot" from a subgraph to a parent graph. * An output "slot" from a subgraph to a parent graph.
@@ -26,6 +29,9 @@ export class SubgraphOutput extends SubgraphSlot {
override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined { override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
const { subgraph } = this.parent const { subgraph } = this.parent
// Validate type compatibility
if (!LiteGraph.isValidConnection(slot.type, this.type)) return
// Allow nodes to block connection // Allow nodes to block connection
const outputIndex = node.outputs.indexOf(slot) const outputIndex = node.outputs.indexOf(slot)
if (outputIndex === -1) throw new Error("Slot is not an output of the given node") if (outputIndex === -1) throw new Error("Slot is not an output of the given node")
@@ -111,4 +117,21 @@ export class SubgraphOutput extends SubgraphSlot {
pos[0] = left + height * 0.5 pos[0] = left + height * 0.5
pos[1] = top + height * 0.5 pos[1] = top + height * 0.5
} }
/**
* Checks if this slot is a valid target for a connection from the given slot.
* For SubgraphOutput (which acts as an input inside the subgraph),
* the fromSlot should be an output slot.
*/
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
if (isNodeSlot(fromSlot)) {
return "links" in fromSlot && LiteGraph.isValidConnection(fromSlot.type, this.type)
}
if (isSubgraphInput(fromSlot)) {
return LiteGraph.isValidConnection(fromSlot.type, this.type)
}
return false
}
} }

View File

@@ -1,8 +1,8 @@
import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutput } from "./SubgraphOutput" import type { SubgraphOutput } from "./SubgraphOutput"
import type { LinkConnector } from "@/canvas/LinkConnector" import type { LinkConnector } from "@/canvas/LinkConnector"
import type { CanvasPointer } from "@/CanvasPointer" import type { CanvasPointer } from "@/CanvasPointer"
import type { DefaultConnectionColors, ISlotType, Positionable } from "@/interfaces" import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, ISlotType, Positionable } from "@/interfaces"
import type { INodeOutputSlot } from "@/interfaces"
import type { LGraphNode, NodeId } from "@/LGraphNode" import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { LLink } from "@/LLink" import type { LLink } from "@/LLink"
import type { RerouteId } from "@/Reroute" import type { RerouteId } from "@/Reroute"
@@ -90,7 +90,7 @@ export class SubgraphOutputNode extends SubgraphIONodeBase<SubgraphOutput> imple
return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot
} }
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors): void { override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
const { roundedRadius } = SubgraphIONodeBase const { roundedRadius } = SubgraphIONodeBase
const transform = ctx.getTransform() const transform = ctx.getTransform()
@@ -114,6 +114,6 @@ export class SubgraphOutputNode extends SubgraphIONodeBase<SubgraphOutput> imple
// Restore context // Restore context
ctx.setTransform(transform) ctx.setTransform(transform)
this.drawSlots(ctx, colorContext) this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
} }
} }

View File

@@ -1,4 +1,6 @@
import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphInputNode } from "./SubgraphInputNode" import type { SubgraphInputNode } from "./SubgraphInputNode"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { SubgraphOutputNode } from "./SubgraphOutputNode" import type { SubgraphOutputNode } from "./SubgraphOutputNode"
import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect, ReadOnlySize } from "@/interfaces" import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect, ReadOnlySize } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode" import type { LGraphNode } from "@/LGraphNode"
@@ -19,6 +21,8 @@ export interface SubgraphSlotDrawOptions {
ctx: CanvasRenderingContext2D ctx: CanvasRenderingContext2D
colorContext: DefaultConnectionColors colorContext: DefaultConnectionColors
lowQuality?: boolean lowQuality?: boolean
fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
editorAlpha?: number
} }
/** Shared base class for the slots used on Subgraph . */ /** Shared base class for the slots used on Subgraph . */
@@ -132,22 +136,32 @@ export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Hover
this.linkIds.length = 0 this.linkIds.length = 0
} }
/** @remarks Leaves the context dirty. */ /**
drawLabel(ctx: CanvasRenderingContext2D): void { * Checks if this slot is a valid target for a connection from the given slot.
if (!this.displayName) return * @param fromSlot The slot that is being dragged to connect to this slot.
* @returns true if the connection is valid, false otherwise.
const [x, y] = this.labelPos */
ctx.fillStyle = this.isPointerOver ? "white" : (LiteGraph.NODE_TEXT_COLOR || "#AAA") abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean
ctx.fillText(this.displayName, x, y)
}
/** @remarks Leaves the context dirty. */ /** @remarks Leaves the context dirty. */
draw({ ctx, colorContext, lowQuality }: SubgraphSlotDrawOptions): void { draw({ ctx, colorContext, lowQuality, fromSlot, editorAlpha = 1 }: SubgraphSlotDrawOptions): void {
// Assertion: SlotShape is a subset of RenderShape // Assertion: SlotShape is a subset of RenderShape
const shape = this.shape as unknown as SlotShape const shape = this.shape as unknown as SlotShape
const { isPointerOver, pos: [x, y] } = this const { isPointerOver, pos: [x, y] } = this
// Check if this slot is a valid target for the current dragging connection
const isValidTarget = fromSlot ? this.isValidTarget(fromSlot) : true
const isValid = !fromSlot || isValidTarget
// Only highlight if the slot is valid AND mouse is over it
const highlight = isValid && isPointerOver
// Save current alpha
const previousAlpha = ctx.globalAlpha
// Set opacity based on validity when dragging a connection
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
ctx.beginPath() ctx.beginPath()
// Default rendering for circle, hollow circle. // Default rendering for circle, hollow circle.
@@ -161,17 +175,28 @@ export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Hover
ctx.lineWidth = 3 ctx.lineWidth = 3
ctx.strokeStyle = color ctx.strokeStyle = color
const radius = isPointerOver ? 4 : 3 const radius = highlight ? 4 : 3
ctx.arc(x, y, radius, 0, Math.PI * 2) ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.stroke() ctx.stroke()
} else { } else {
// Normal circle // Normal circle
ctx.fillStyle = color ctx.fillStyle = color
const radius = isPointerOver ? 5 : 4 const radius = highlight ? 5 : 4
ctx.arc(x, y, radius, 0, Math.PI * 2) ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fill() ctx.fill()
} }
// Draw label with current opacity
if (this.displayName) {
const [labelX, labelY] = this.labelPos
// Also apply highlight logic to text color
ctx.fillStyle = highlight ? "white" : (LiteGraph.NODE_TEXT_COLOR || "#AAA")
ctx.fillText(this.displayName, labelX, labelY)
}
// Restore alpha
ctx.globalAlpha = previousAlpha
} }
asSerialisable(): SubgraphIO { asSerialisable(): SubgraphIO {

View File

@@ -1,4 +1,6 @@
import type { INodeOutputSlot, Positionable } from "@/interfaces" import type { SubgraphInput } from "./SubgraphInput"
import type { SubgraphOutput } from "./SubgraphOutput"
import type { INodeInputSlot, INodeOutputSlot, Positionable } from "@/interfaces"
import type { LGraph } from "@/LGraph" import type { LGraph } from "@/LGraph"
import type { ISerialisedNode, SerialisableLLink, SubgraphIO } from "@/types/serialisation" import type { ISerialisedNode, SerialisableLLink, SubgraphIO } from "@/types/serialisation"
@@ -336,3 +338,33 @@ export function mapSubgraphOutputsAndLinks(resolvedOutputLinks: ResolvedConnecti
} }
return outputs return outputs
} }
/**
* Type guard to check if a slot is a SubgraphInput.
* @param slot The slot to check
* @returns true if the slot is a SubgraphInput
*/
export function isSubgraphInput(slot: unknown): slot is SubgraphInput {
return slot != null && typeof slot === "object" && "parent" in slot &&
slot.parent instanceof SubgraphInputNode
}
/**
* Type guard to check if a slot is a SubgraphOutput.
* @param slot The slot to check
* @returns true if the slot is a SubgraphOutput
*/
export function isSubgraphOutput(slot: unknown): slot is SubgraphOutput {
return slot != null && typeof slot === "object" && "parent" in slot &&
slot.parent instanceof SubgraphOutputNode
}
/**
* Type guard to check if a slot is a regular node slot (INodeInputSlot or INodeOutputSlot).
* @param slot The slot to check
* @returns true if the slot is a regular node slot
*/
export function isNodeSlot(slot: unknown): slot is INodeInputSlot | INodeOutputSlot {
return slot != null && typeof slot === "object" &&
("link" in slot || "links" in slot)
}

View File

@@ -0,0 +1,280 @@
import { describe, expect, it } from "vitest"
import { LGraphNode } from "@/litegraph"
import { NodeInputSlot } from "@/node/NodeInputSlot"
import { NodeOutputSlot } from "@/node/NodeOutputSlot"
import { isSubgraphInput, isSubgraphOutput } from "@/subgraph/subgraphUtils"
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
describe("Subgraph slot connections", () => {
describe("SubgraphInput connections", () => {
it("should connect to compatible regular input slots", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "test_input", type: "number" }],
})
const subgraphInput = subgraph.inputs[0]
const node = new LGraphNode("TestNode")
node.addInput("compatible_input", "number")
node.addInput("incompatible_input", "string")
subgraph.add(node)
const compatibleSlot = node.inputs[0] as NodeInputSlot
const incompatibleSlot = node.inputs[1] as NodeInputSlot
expect(compatibleSlot.isValidTarget(subgraphInput)).toBe(true)
expect(incompatibleSlot.isValidTarget(subgraphInput)).toBe(false)
})
// "not implemented" yet, but the test passes in terms of type checking
// it("should connect to compatible SubgraphOutput", () => {
// const subgraph = createTestSubgraph({
// inputs: [{ name: "test_input", type: "number" }],
// outputs: [{ name: "test_output", type: "number" }],
// })
// const subgraphInput = subgraph.inputs[0]
// const subgraphOutput = subgraph.outputs[0]
// expect(subgraphOutput.isValidTarget(subgraphInput)).toBe(true)
// })
it("should not connect to another SubgraphInput", () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: "input1", type: "number" },
{ name: "input2", type: "number" },
],
})
const subgraphInput1 = subgraph.inputs[0]
const subgraphInput2 = subgraph.inputs[1]
expect(subgraphInput2.isValidTarget(subgraphInput1)).toBe(false)
})
it("should not connect to output slots", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "test_input", type: "number" }],
})
const subgraphInput = subgraph.inputs[0]
const node = new LGraphNode("TestNode")
node.addOutput("test_output", "number")
subgraph.add(node)
const outputSlot = node.outputs[0] as NodeOutputSlot
expect(outputSlot.isValidTarget(subgraphInput)).toBe(false)
})
})
describe("SubgraphOutput connections", () => {
it("should connect from compatible regular output slots", () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode("TestNode")
node.addOutput("out", "number")
subgraph.add(node)
const subgraphOutput = subgraph.addOutput("result", "number")
const nodeOutput = node.outputs[0]
expect(subgraphOutput.isValidTarget(nodeOutput)).toBe(true)
})
it("should connect from SubgraphInput", () => {
const subgraph = createTestSubgraph()
const subgraphInput = subgraph.addInput("value", "number")
const subgraphOutput = subgraph.addOutput("result", "number")
expect(subgraphOutput.isValidTarget(subgraphInput)).toBe(true)
})
it("should not connect to another SubgraphOutput", () => {
const subgraph = createTestSubgraph()
const subgraphOutput1 = subgraph.addOutput("result1", "number")
const subgraphOutput2 = subgraph.addOutput("result2", "number")
expect(subgraphOutput1.isValidTarget(subgraphOutput2)).toBe(false)
})
})
describe("Type compatibility", () => {
it("should respect type compatibility for SubgraphInput connections", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "number_input", type: "number" }],
})
const subgraphInput = subgraph.inputs[0]
const node = new LGraphNode("TestNode")
node.addInput("number_slot", "number")
node.addInput("string_slot", "string")
node.addInput("any_slot", "*")
node.addInput("boolean_slot", "boolean")
subgraph.add(node)
const numberSlot = node.inputs[0] as NodeInputSlot
const stringSlot = node.inputs[1] as NodeInputSlot
const anySlot = node.inputs[2] as NodeInputSlot
const booleanSlot = node.inputs[3] as NodeInputSlot
expect(numberSlot.isValidTarget(subgraphInput)).toBe(true)
expect(stringSlot.isValidTarget(subgraphInput)).toBe(false)
expect(anySlot.isValidTarget(subgraphInput)).toBe(true)
expect(booleanSlot.isValidTarget(subgraphInput)).toBe(false)
})
it("should respect type compatibility for SubgraphOutput connections", () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode("TestNode")
node.addOutput("out", "string")
subgraph.add(node)
const subgraphOutput = subgraph.addOutput("result", "number")
const nodeOutput = node.outputs[0]
expect(subgraphOutput.isValidTarget(nodeOutput)).toBe(false)
})
it("should handle wildcard SubgraphInput", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "any_input", type: "*" }],
})
const subgraphInput = subgraph.inputs[0]
const node = new LGraphNode("TestNode")
node.addInput("number_slot", "number")
subgraph.add(node)
const numberSlot = node.inputs[0] as NodeInputSlot
expect(numberSlot.isValidTarget(subgraphInput)).toBe(true)
})
})
describe("Type guards", () => {
it("should correctly identify SubgraphInput", () => {
const subgraph = createTestSubgraph()
const subgraphInput = subgraph.addInput("value", "number")
const node = new LGraphNode("TestNode")
node.addInput("in", "number")
expect(isSubgraphInput(subgraphInput)).toBe(true)
expect(isSubgraphInput(node.inputs[0])).toBe(false)
expect(isSubgraphInput(null)).toBe(false)
// eslint-disable-next-line unicorn/no-useless-undefined
expect(isSubgraphInput(undefined)).toBe(false)
expect(isSubgraphInput({})).toBe(false)
})
it("should correctly identify SubgraphOutput", () => {
const subgraph = createTestSubgraph()
const subgraphOutput = subgraph.addOutput("result", "number")
const node = new LGraphNode("TestNode")
node.addOutput("out", "number")
expect(isSubgraphOutput(subgraphOutput)).toBe(true)
expect(isSubgraphOutput(node.outputs[0])).toBe(false)
expect(isSubgraphOutput(null)).toBe(false)
// eslint-disable-next-line unicorn/no-useless-undefined
expect(isSubgraphOutput(undefined)).toBe(false)
expect(isSubgraphOutput({})).toBe(false)
})
})
describe("Nested subgraphs", () => {
it("should handle dragging from SubgraphInput in nested subgraphs", () => {
const parentSubgraph = createTestSubgraph({
inputs: [{ name: "parent_input", type: "number" }],
outputs: [{ name: "parent_output", type: "number" }],
})
const nestedSubgraph = createTestSubgraph({
inputs: [{ name: "nested_input", type: "number" }],
outputs: [{ name: "nested_output", type: "number" }],
})
const nestedSubgraphNode = createTestSubgraphNode(nestedSubgraph)
parentSubgraph.add(nestedSubgraphNode)
const regularNode = new LGraphNode("TestNode")
regularNode.addInput("test_input", "number")
nestedSubgraph.add(regularNode)
const nestedSubgraphInput = nestedSubgraph.inputs[0]
const regularNodeSlot = regularNode.inputs[0] as NodeInputSlot
expect(regularNodeSlot.isValidTarget(nestedSubgraphInput)).toBe(true)
})
it("should handle multiple levels of nesting", () => {
const level1 = createTestSubgraph({
inputs: [{ name: "level1_input", type: "string" }],
})
const level2 = createTestSubgraph({
inputs: [{ name: "level2_input", type: "string" }],
})
const level3 = createTestSubgraph({
inputs: [{ name: "level3_input", type: "string" }],
outputs: [{ name: "level3_output", type: "string" }],
})
const level2Node = createTestSubgraphNode(level2)
level1.add(level2Node)
const level3Node = createTestSubgraphNode(level3)
level2.add(level3Node)
const deepNode = new LGraphNode("DeepNode")
deepNode.addInput("deep_input", "string")
level3.add(deepNode)
const level3Input = level3.inputs[0]
const deepNodeSlot = deepNode.inputs[0] as NodeInputSlot
expect(deepNodeSlot.isValidTarget(level3Input)).toBe(true)
const level3Output = level3.outputs[0]
expect(level3Output.isValidTarget(level3Input)).toBe(true)
})
it("should maintain type checking across nesting levels", () => {
const outer = createTestSubgraph({
inputs: [{ name: "outer_number", type: "number" }],
})
const inner = createTestSubgraph({
inputs: [
{ name: "inner_number", type: "number" },
{ name: "inner_string", type: "string" },
],
})
const innerNode = createTestSubgraphNode(inner)
outer.add(innerNode)
const node = new LGraphNode("TestNode")
node.addInput("number_slot", "number")
node.addInput("string_slot", "string")
inner.add(node)
const innerNumberInput = inner.inputs[0]
const innerStringInput = inner.inputs[1]
const numberSlot = node.inputs[0] as NodeInputSlot
const stringSlot = node.inputs[1] as NodeInputSlot
expect(numberSlot.isValidTarget(innerNumberInput)).toBe(true)
expect(numberSlot.isValidTarget(innerStringInput)).toBe(false)
expect(stringSlot.isValidTarget(innerNumberInput)).toBe(false)
expect(stringSlot.isValidTarget(innerStringInput)).toBe(true)
})
})
})

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { LGraphNode } from "@/litegraph"
import { createTestSubgraph } from "./fixtures/subgraphHelpers"
describe("SubgraphSlot visual feedback", () => {
let mockCtx: CanvasRenderingContext2D
let mockColorContext: any
let globalAlphaValues: number[]
beforeEach(() => {
// Clear the array before each test
globalAlphaValues = []
// Create a mock canvas context that tracks all globalAlpha values
const mockContext = {
_globalAlpha: 1,
get globalAlpha() {
return this._globalAlpha
},
set globalAlpha(value: number) {
this._globalAlpha = value
globalAlphaValues.push(value)
},
fillStyle: "",
strokeStyle: "",
lineWidth: 1,
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
rect: vi.fn(),
fillText: vi.fn(),
}
mockCtx = mockContext as unknown as CanvasRenderingContext2D
// Create a mock color context
mockColorContext = {
defaultInputColor: "#FF0000",
defaultOutputColor: "#00FF00",
getConnectedColor: vi.fn().mockReturnValue("#0000FF"),
getDisconnectedColor: vi.fn().mockReturnValue("#AAAAAA"),
}
})
it("should render SubgraphInput slots with full opacity when dragging from compatible slot", () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode("TestNode")
node.addInput("in", "number")
subgraph.add(node)
// Add a subgraph input
const subgraphInput = subgraph.addInput("value", "number")
// Simulate dragging from the subgraph input (which acts as output inside subgraph)
const nodeInput = node.inputs[0]
// Draw the slot with a compatible fromSlot
subgraphInput.draw({
ctx: mockCtx,
colorContext: mockColorContext,
fromSlot: nodeInput,
editorAlpha: 1,
})
// Should render with full opacity (not 0.4)
// Check that 0.4 was NOT set during drawing
expect(globalAlphaValues).not.toContain(0.4)
})
it("should render SubgraphInput slots with 40% opacity when dragging from another SubgraphInput", () => {
const subgraph = createTestSubgraph()
// Add two subgraph inputs
const subgraphInput1 = subgraph.addInput("value1", "number")
const subgraphInput2 = subgraph.addInput("value2", "number")
// Draw subgraphInput2 while dragging from subgraphInput1 (incompatible - both are outputs inside subgraph)
subgraphInput2.draw({
ctx: mockCtx,
colorContext: mockColorContext,
fromSlot: subgraphInput1,
editorAlpha: 1,
})
// Should render with 40% opacity
// Check that 0.4 was set during drawing
expect(globalAlphaValues).toContain(0.4)
})
it("should render SubgraphOutput slots with full opacity when dragging from compatible slot", () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode("TestNode")
node.addOutput("out", "number")
subgraph.add(node)
// Add a subgraph output
const subgraphOutput = subgraph.addOutput("result", "number")
// Simulate dragging from a node output
const nodeOutput = node.outputs[0]
// Draw the slot with a compatible fromSlot
subgraphOutput.draw({
ctx: mockCtx,
colorContext: mockColorContext,
fromSlot: nodeOutput,
editorAlpha: 1,
})
// Should render with full opacity (not 0.4)
// Check that 0.4 was NOT set during drawing
expect(globalAlphaValues).not.toContain(0.4)
})
it("should render SubgraphOutput slots with 40% opacity when dragging from another SubgraphOutput", () => {
const subgraph = createTestSubgraph()
// Add two subgraph outputs
const subgraphOutput1 = subgraph.addOutput("result1", "number")
const subgraphOutput2 = subgraph.addOutput("result2", "number")
// Draw subgraphOutput2 while dragging from subgraphOutput1 (incompatible - both are inputs inside subgraph)
subgraphOutput2.draw({
ctx: mockCtx,
colorContext: mockColorContext,
fromSlot: subgraphOutput1,
editorAlpha: 1,
})
// Should render with 40% opacity
// Check that 0.4 was set during drawing
expect(globalAlphaValues).toContain(0.4)
})
// "not implmeneted yet"
// it("should render slots with full opacity when dragging between compatible SubgraphInput and SubgraphOutput", () => {
// const subgraph = createTestSubgraph()
// // Add subgraph input and output with matching types
// const subgraphInput = subgraph.addInput("value", "number")
// const subgraphOutput = subgraph.addOutput("result", "number")
// // Draw SubgraphOutput slot while dragging from SubgraphInput
// subgraphOutput.draw({
// ctx: mockCtx,
// colorContext: mockColorContext,
// fromSlot: subgraphInput,
// editorAlpha: 1,
// })
// // Should render with full opacity
// expect(mockCtx.globalAlpha).toBe(1)
// })
it("should render slots with 40% opacity when dragging between incompatible types", () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode("TestNode")
node.addOutput("string_output", "string")
subgraph.add(node)
// Add subgraph output with incompatible type
const subgraphOutput = subgraph.addOutput("result", "number")
// Get the string output slot from the node
const nodeStringOutput = node.outputs[0]
// Draw the SubgraphOutput slot while dragging from a node output with incompatible type
subgraphOutput.draw({
ctx: mockCtx,
colorContext: mockColorContext,
fromSlot: nodeStringOutput,
editorAlpha: 1,
})
// Should render with 40% opacity due to type mismatch
// Check that 0.4 was set during drawing
expect(globalAlphaValues).toContain(0.4)
})
})