From 63407abf3c2c51c98c5e8769c6ed546d75241133 Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Sat, 26 Apr 2025 00:12:09 +1000 Subject: [PATCH] Reduce input socket hitbox for widgets (#966) Restores the full left-arrow button click area for widgets. Previously lost ~5 canvas pixels to clicks intercepted by input sockets. Supporting refactors: - Maps concrete node slot impls. to private array, once per frame - Converts slot boundingRect to use absolute canvas pos (same as other elements) - Stores parent node ref in concrete slot classes --- src/LGraphCanvas.ts | 8 +++- src/LGraphNode.ts | 76 ++++++++++++++++++++++---------------- src/node/NodeInputSlot.ts | 5 ++- src/node/NodeOutputSlot.ts | 5 ++- src/node/NodeSlot.ts | 31 ++++++++++++++-- src/node/slotUtils.ts | 11 ------ src/utils/type.ts | 11 ++++-- 7 files changed, 93 insertions(+), 54 deletions(-) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index c5dfd19722..5defb4aabd 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -58,6 +58,7 @@ import { overlapBounding, snapPoint, } from "./measure" +import { NodeInputSlot } from "./node/NodeInputSlot" import { Reroute, type RerouteId } from "./Reroute" import { stringOrEmpty } from "./strings" import { @@ -2277,7 +2278,11 @@ export class LGraphCanvas implements ConnectionColorContext { if (inputs) { for (const [i, input] of inputs.entries()) { const link_pos = node.getInputPos(i) - if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + const isInSlot = input instanceof NodeInputSlot + ? isInRect(x, y, input.boundingRect) + : isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20) + + if (isInSlot) { pointer.onDoubleClick = () => node.onInputDblClick?.(i, e) pointer.onClick = () => node.onInputClick?.(i, e) @@ -4312,6 +4317,7 @@ export class LGraphCanvas implements ConnectionColorContext { ctx.font = this.inner_text_font // render inputs and outputs + node._setConcreteSlots() if (!node.collapsed) { node.arrange() node.drawSlots(ctx, { diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index e97cee6066..9d247a95ca 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -35,7 +35,7 @@ import { LLink } from "./LLink" import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure" import { NodeInputSlot } from "./node/NodeInputSlot" import { NodeOutputSlot } from "./node/NodeOutputSlot" -import { inputAsSerialisable, isINodeInputSlot, isWidgetInputSlot, outputAsSerialisable, toNodeSlotClass } from "./node/slotUtils" +import { inputAsSerialisable, isINodeInputSlot, isWidgetInputSlot, outputAsSerialisable } from "./node/slotUtils" import { LGraphEventMode, NodeSlotType, @@ -210,6 +210,10 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { type: string = "" inputs: INodeInputSlot[] = [] outputs: INodeOutputSlot[] = [] + + #concreteInputs: NodeInputSlot[] = [] + #concreteOutputs: NodeOutputSlot[] = [] + // Not used connections: unknown[] = [] properties: Dictionary = {} @@ -697,7 +701,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { } this.inputs ??= [] - this.inputs = this.inputs.map(input => toClass(NodeInputSlot, input)) + this.inputs = this.inputs.map(input => toClass(NodeInputSlot, input, this)) for (const [i, input] of this.inputs.entries()) { const link = this.graph && input.link != null ? this.graph._links.get(input.link) @@ -707,7 +711,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { } this.outputs ??= [] - this.outputs = this.outputs.map(output => toClass(NodeOutputSlot, output)) + this.outputs = this.outputs.map(output => toClass(NodeOutputSlot, output, this)) for (const [i, output] of this.outputs.entries()) { if (!output.links) continue @@ -1424,7 +1428,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { type: ISlotType, extra_info?: Partial, ): INodeOutputSlot { - const output = new NodeOutputSlot({ name, type, links: null }) + const output = new NodeOutputSlot({ name, type, links: null }, this) if (extra_info) Object.assign(output, extra_info) this.outputs ||= [] @@ -1470,7 +1474,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { */ addInput(name: string, type: ISlotType, extra_info?: Partial): INodeInputSlot { type = type || 0 - const input = new NodeInputSlot({ name: name, type: type, link: null }) + const input = new NodeInputSlot({ name: name, type: type, link: null }, this) if (extra_info) Object.assign(input, extra_info) this.inputs ||= [] @@ -3430,18 +3434,18 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { */ drawCollapsedSlots(ctx: CanvasRenderingContext2D): void { // if collapsed - let input_slot: INodeInputSlot | null = null - let output_slot: INodeOutputSlot | null = null + let input_slot: NodeInputSlot | undefined + let output_slot: NodeOutputSlot | undefined // get first connected slot to render - for (const slot of this.inputs ?? []) { + for (const slot of this.#concreteInputs) { if (slot.link == null) { continue } input_slot = slot break } - for (const slot of this.outputs ?? []) { + for (const slot of this.#concreteOutputs) { if (!slot.links || !slot.links.length) { continue } @@ -3452,7 +3456,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { if (input_slot) { const x = 0 const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 - toClass(NodeInputSlot, input_slot).drawCollapsed(ctx, { + input_slot.drawCollapsed(ctx, { pos: [x, y], }) } @@ -3460,7 +3464,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { if (output_slot) { const x = this._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 - toClass(NodeOutputSlot, output_slot).drawCollapsed(ctx, { + output_slot.drawCollapsed(ctx, { pos: [x, y], }) } @@ -3470,30 +3474,29 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { return [...this.inputs, ...this.outputs] } - #measureSlot(slot: INodeSlot, slotIndex: number): void { - const isInput = isINodeInputSlot(slot) + #measureSlot(slot: NodeInputSlot | NodeOutputSlot, slotIndex: number, isInput: boolean): void { const pos = isInput ? this.getInputPos(slotIndex) : this.getOutputPos(slotIndex) - slot.boundingRect[0] = pos[0] - this.pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5 - slot.boundingRect[1] = pos[1] - this.pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5 - slot.boundingRect[2] = LiteGraph.NODE_SLOT_HEIGHT + slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5 + slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5 + slot.boundingRect[2] = slot.isWidgetInputSlot ? BaseWidget.margin : LiteGraph.NODE_SLOT_HEIGHT slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT } #measureSlots(): ReadOnlyRect | null { - const slots: INodeSlot[] = [] + const slots: (NodeInputSlot | NodeOutputSlot)[] = [] - for (const [slotIndex, slot] of this.inputs.entries()) { + for (const [slotIndex, slot] of this.#concreteInputs.entries()) { // Unrecognized nodes (Nodes with error) has inputs but no widgets. Treat // converted inputs as normal inputs. /** Widget input slots are handled in {@link layoutWidgetInputSlots} */ if (this.widgets?.length && isWidgetInputSlot(slot)) continue - this.#measureSlot(slot, slotIndex) + this.#measureSlot(slot, slotIndex, true) slots.push(slot) } - for (const [slotIndex, slot] of this.outputs.entries()) { - this.#measureSlot(slot, slotIndex) + for (const [slotIndex, slot] of this.#concreteOutputs.entries()) { + this.#measureSlot(slot, slotIndex, false) slots.push(slot) } @@ -3542,9 +3545,8 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { editorAlpha, lowQuality, }: DrawSlotsOptions) { - for (const slot of this.slots) { - const slotInstance = toNodeSlotClass(slot) - const isValidTarget = fromSlot && slotInstance.isValidTarget(fromSlot) + for (const slot of [...this.#concreteInputs, ...this.#concreteOutputs]) { + const isValidTarget = fromSlot && slot.isValidTarget(fromSlot) const isMouseOverSlot = this.#isMouseOverSlot(slot) // change opacity of incompatible slots when dragging a connection @@ -3559,12 +3561,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { if ( isMouseOverSlot || isValidTarget || - !slotInstance.isWidgetInputSlot || - this.#isMouseOverWidget(this.getWidgetFromSlot(slotInstance)) || - slotInstance.isConnected() + !slot.isWidgetInputSlot || + this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) || + slot.isConnected() ) { ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha - slotInstance.draw(ctx, { + slot.draw(ctx, { colorContext, lowQuality, highlight, @@ -3673,19 +3675,31 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { const slot = slotByWidgetName.get(widget.name) if (!slot) continue - const actualSlot = this.inputs[slot.index] + const actualSlot = this.#concreteInputs[slot.index] const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 actualSlot.pos = [offset, widget.y + offset] - this.#measureSlot(actualSlot, slot.index) + this.#measureSlot(actualSlot, slot.index, true) } } + /** + * @internal Sets the internal concrete slot arrays, ensuring they are instances of + * {@link NodeInputSlot} or {@link NodeOutputSlot}. + * + * A temporary workaround until duck-typed inputs and outputs + * have been removed from the ecosystem. + */ + _setConcreteSlots(): void { + this.#concreteInputs = this.inputs.map(slot => toClass(NodeInputSlot, slot, this)) + this.#concreteOutputs = this.outputs.map(slot => toClass(NodeOutputSlot, slot, this)) + } + /** * Arranges node elements in preparation for rendering (slots & widgets). */ arrange(): void { const slotsBounds = this.#measureSlots() - const widgetStartY = slotsBounds ? slotsBounds[1] + slotsBounds[3] : 0 + const widgetStartY = slotsBounds ? slotsBounds[1] + slotsBounds[3] - this.pos[1] : 0 this.#arrangeWidgets(widgetStartY) this.#arrangeWidgetInputSlots() } diff --git a/src/node/NodeInputSlot.ts b/src/node/NodeInputSlot.ts index c4ecf5b8cc..87ed25dfea 100644 --- a/src/node/NodeInputSlot.ts +++ b/src/node/NodeInputSlot.ts @@ -1,4 +1,5 @@ import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" import type { LinkId } from "@/LLink" import { LabelPosition } from "@/draw" @@ -12,8 +13,8 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot { return !!this.widget } - constructor(slot: OptionalProps) { - super(slot) + constructor(slot: OptionalProps, node: LGraphNode) { + super(slot, node) this.link = slot.link } diff --git a/src/node/NodeOutputSlot.ts b/src/node/NodeOutputSlot.ts index bb5beb06a4..f94b45e0b7 100644 --- a/src/node/NodeOutputSlot.ts +++ b/src/node/NodeOutputSlot.ts @@ -1,4 +1,5 @@ import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" import type { LinkId } from "@/LLink" import { LabelPosition } from "@/draw" @@ -14,8 +15,8 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot { return false } - constructor(slot: OptionalProps) { - super(slot) + constructor(slot: OptionalProps, node: LGraphNode) { + super(slot, node) this.links = slot.links this._data = slot._data this.slot_index = slot.slot_index diff --git a/src/node/NodeSlot.ts b/src/node/NodeSlot.ts index dc551c3097..6a65f1a4a9 100644 --- a/src/node/NodeSlot.ts +++ b/src/node/NodeSlot.ts @@ -1,4 +1,5 @@ -import type { CanvasColour, Dictionary, INodeInputSlot, INodeOutputSlot, INodeSlot, ISlotType, IWidgetLocator, OptionalProps, Point, Rect } from "@/interfaces" +import type { CanvasColour, Dictionary, INodeInputSlot, INodeOutputSlot, INodeSlot, ISlotType, IWidgetLocator, OptionalProps, Point, ReadOnlyPoint, Rect } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" import { LabelPosition, SlotShape, SlotType } from "@/draw" import { LiteGraph } from "@/litegraph" @@ -41,7 +42,28 @@ export abstract class NodeSlot implements INodeSlot { pos?: Point widget?: IWidgetLocator hasErrors?: boolean - boundingRect: Rect + readonly boundingRect: Rect + + /** The offset from the parent node to the centre point of this slot. */ + get #centreOffset(): ReadOnlyPoint { + const nodePos = this.node.pos + const { boundingRect } = this + + // Use height; widget input slots may be thinner. + const diameter = boundingRect[3] + + return getCentre([ + boundingRect[0] - nodePos[0], + boundingRect[1] - nodePos[1], + diameter, + diameter, + ]) + } + + #node: LGraphNode + get node(): LGraphNode { + return this.#node + } get highlightColor(): CanvasColour { return LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR @@ -49,11 +71,12 @@ export abstract class NodeSlot implements INodeSlot { abstract get isWidgetInputSlot(): boolean - constructor(slot: OptionalProps) { + constructor(slot: OptionalProps, node: LGraphNode) { Object.assign(this, slot) this.name = slot.name this.type = slot.type this.boundingRect = slot.boundingRect ?? [0, 0, 0, 0] + this.#node = node } /** @@ -109,7 +132,7 @@ export abstract class NodeSlot implements INodeSlot { ? this.highlightColor : LiteGraph.NODE_TEXT_COLOR - const pos = getCentre(this.boundingRect) + const pos = this.#centreOffset const slot_type = this.type const slot_shape = ( slot_type === SlotType.Array ? SlotShape.Grid : this.shape diff --git a/src/node/slotUtils.ts b/src/node/slotUtils.ts index f151175daa..b439d5467b 100644 --- a/src/node/slotUtils.ts +++ b/src/node/slotUtils.ts @@ -2,9 +2,6 @@ import type { IWidgetInputSlot, SharedIntersection } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, INodeSlot, IWidget } from "@/litegraph" import type { ISerialisableNodeInput, ISerialisableNodeOutput } from "@/types/serialisation" -import { NodeInputSlot } from "./NodeInputSlot" -import { NodeOutputSlot } from "./NodeOutputSlot" - type CommonIoSlotProps = SharedIntersection export function shallowCloneCommonProps(slot: CommonIoSlotProps): CommonIoSlotProps { @@ -57,11 +54,3 @@ export function isINodeOutputSlot(slot: INodeSlot): slot is INodeOutputSlot { export function isWidgetInputSlot(slot: INodeInputSlot): slot is IWidgetInputSlot { return !!slot.widget } - -export function toNodeSlotClass(slot: INodeInputSlot | INodeOutputSlot): NodeInputSlot | NodeOutputSlot { - if (slot instanceof NodeInputSlot || slot instanceof NodeOutputSlot) return slot - - return "link" in slot - ? new NodeInputSlot(slot) - : new NodeOutputSlot(slot) -} diff --git a/src/utils/type.ts b/src/utils/type.ts index 03758e7cba..edd0eb7e22 100644 --- a/src/utils/type.ts +++ b/src/utils/type.ts @@ -2,12 +2,17 @@ import type { IColorable } from "@/interfaces" /** * Converts a plain object to a class instance if it is not already an instance of the class. + * + * Requires specific constructor signature; first parameter must be the object to convert. * @param cls The class to convert to - * @param obj The object to convert + * @param args The object to convert, followed by any other constructor arguments * @returns The class instance */ -export function toClass(cls: new (plain: P) => C, obj: P | C): C { - return obj instanceof cls ? obj : new cls(obj as P) +export function toClass( + cls: new (instance: P, ...args: Args) => C, + ...args: [P, ...Args] +): C { + return args[0] instanceof cls ? args[0] : new cls(...args) } /**