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
This commit is contained in:
filtered
2025-04-26 00:12:09 +10:00
committed by GitHub
parent 0fd47a767d
commit 63407abf3c
7 changed files with 93 additions and 54 deletions

View File

@@ -58,6 +58,7 @@ import {
overlapBounding, overlapBounding,
snapPoint, snapPoint,
} from "./measure" } from "./measure"
import { NodeInputSlot } from "./node/NodeInputSlot"
import { Reroute, type RerouteId } from "./Reroute" import { Reroute, type RerouteId } from "./Reroute"
import { stringOrEmpty } from "./strings" import { stringOrEmpty } from "./strings"
import { import {
@@ -2277,7 +2278,11 @@ export class LGraphCanvas implements ConnectionColorContext {
if (inputs) { if (inputs) {
for (const [i, input] of inputs.entries()) { for (const [i, input] of inputs.entries()) {
const link_pos = node.getInputPos(i) 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.onDoubleClick = () => node.onInputDblClick?.(i, e)
pointer.onClick = () => node.onInputClick?.(i, e) pointer.onClick = () => node.onInputClick?.(i, e)
@@ -4312,6 +4317,7 @@ export class LGraphCanvas implements ConnectionColorContext {
ctx.font = this.inner_text_font ctx.font = this.inner_text_font
// render inputs and outputs // render inputs and outputs
node._setConcreteSlots()
if (!node.collapsed) { if (!node.collapsed) {
node.arrange() node.arrange()
node.drawSlots(ctx, { node.drawSlots(ctx, {

View File

@@ -35,7 +35,7 @@ import { LLink } from "./LLink"
import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure" import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure"
import { NodeInputSlot } from "./node/NodeInputSlot" import { NodeInputSlot } from "./node/NodeInputSlot"
import { NodeOutputSlot } from "./node/NodeOutputSlot" import { NodeOutputSlot } from "./node/NodeOutputSlot"
import { inputAsSerialisable, isINodeInputSlot, isWidgetInputSlot, outputAsSerialisable, toNodeSlotClass } from "./node/slotUtils" import { inputAsSerialisable, isINodeInputSlot, isWidgetInputSlot, outputAsSerialisable } from "./node/slotUtils"
import { import {
LGraphEventMode, LGraphEventMode,
NodeSlotType, NodeSlotType,
@@ -210,6 +210,10 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
type: string = "" type: string = ""
inputs: INodeInputSlot[] = [] inputs: INodeInputSlot[] = []
outputs: INodeOutputSlot[] = [] outputs: INodeOutputSlot[] = []
#concreteInputs: NodeInputSlot[] = []
#concreteOutputs: NodeOutputSlot[] = []
// Not used // Not used
connections: unknown[] = [] connections: unknown[] = []
properties: Dictionary<NodeProperty | undefined> = {} properties: Dictionary<NodeProperty | undefined> = {}
@@ -697,7 +701,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
} }
this.inputs ??= [] 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()) { for (const [i, input] of this.inputs.entries()) {
const link = this.graph && input.link != null const link = this.graph && input.link != null
? this.graph._links.get(input.link) ? this.graph._links.get(input.link)
@@ -707,7 +711,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
} }
this.outputs ??= [] 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()) { for (const [i, output] of this.outputs.entries()) {
if (!output.links) continue if (!output.links) continue
@@ -1424,7 +1428,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
type: ISlotType, type: ISlotType,
extra_info?: Partial<INodeOutputSlot>, extra_info?: Partial<INodeOutputSlot>,
): INodeOutputSlot { ): 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) if (extra_info) Object.assign(output, extra_info)
this.outputs ||= [] this.outputs ||= []
@@ -1470,7 +1474,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
*/ */
addInput(name: string, type: ISlotType, extra_info?: Partial<INodeInputSlot>): INodeInputSlot { addInput(name: string, type: ISlotType, extra_info?: Partial<INodeInputSlot>): INodeInputSlot {
type = type || 0 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) if (extra_info) Object.assign(input, extra_info)
this.inputs ||= [] this.inputs ||= []
@@ -3430,18 +3434,18 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
*/ */
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void { drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
// if collapsed // if collapsed
let input_slot: INodeInputSlot | null = null let input_slot: NodeInputSlot | undefined
let output_slot: INodeOutputSlot | null = null let output_slot: NodeOutputSlot | undefined
// get first connected slot to render // get first connected slot to render
for (const slot of this.inputs ?? []) { for (const slot of this.#concreteInputs) {
if (slot.link == null) { if (slot.link == null) {
continue continue
} }
input_slot = slot input_slot = slot
break break
} }
for (const slot of this.outputs ?? []) { for (const slot of this.#concreteOutputs) {
if (!slot.links || !slot.links.length) { if (!slot.links || !slot.links.length) {
continue continue
} }
@@ -3452,7 +3456,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
if (input_slot) { if (input_slot) {
const x = 0 const x = 0
const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5
toClass(NodeInputSlot, input_slot).drawCollapsed(ctx, { input_slot.drawCollapsed(ctx, {
pos: [x, y], pos: [x, y],
}) })
} }
@@ -3460,7 +3464,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
if (output_slot) { if (output_slot) {
const x = this._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH const x = this._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH
const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5
toClass(NodeOutputSlot, output_slot).drawCollapsed(ctx, { output_slot.drawCollapsed(ctx, {
pos: [x, y], pos: [x, y],
}) })
} }
@@ -3470,30 +3474,29 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
return [...this.inputs, ...this.outputs] return [...this.inputs, ...this.outputs]
} }
#measureSlot(slot: INodeSlot, slotIndex: number): void { #measureSlot(slot: NodeInputSlot | NodeOutputSlot, slotIndex: number, isInput: boolean): void {
const isInput = isINodeInputSlot(slot)
const pos = isInput ? this.getInputPos(slotIndex) : this.getOutputPos(slotIndex) 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[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - this.pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5 slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = LiteGraph.NODE_SLOT_HEIGHT slot.boundingRect[2] = slot.isWidgetInputSlot ? BaseWidget.margin : LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
} }
#measureSlots(): ReadOnlyRect | null { #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 // Unrecognized nodes (Nodes with error) has inputs but no widgets. Treat
// converted inputs as normal inputs. // converted inputs as normal inputs.
/** Widget input slots are handled in {@link layoutWidgetInputSlots} */ /** Widget input slots are handled in {@link layoutWidgetInputSlots} */
if (this.widgets?.length && isWidgetInputSlot(slot)) continue if (this.widgets?.length && isWidgetInputSlot(slot)) continue
this.#measureSlot(slot, slotIndex) this.#measureSlot(slot, slotIndex, true)
slots.push(slot) slots.push(slot)
} }
for (const [slotIndex, slot] of this.outputs.entries()) { for (const [slotIndex, slot] of this.#concreteOutputs.entries()) {
this.#measureSlot(slot, slotIndex) this.#measureSlot(slot, slotIndex, false)
slots.push(slot) slots.push(slot)
} }
@@ -3542,9 +3545,8 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
editorAlpha, editorAlpha,
lowQuality, lowQuality,
}: DrawSlotsOptions) { }: DrawSlotsOptions) {
for (const slot of this.slots) { for (const slot of [...this.#concreteInputs, ...this.#concreteOutputs]) {
const slotInstance = toNodeSlotClass(slot) const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
const isValidTarget = fromSlot && slotInstance.isValidTarget(fromSlot)
const isMouseOverSlot = this.#isMouseOverSlot(slot) const isMouseOverSlot = this.#isMouseOverSlot(slot)
// change opacity of incompatible slots when dragging a connection // change opacity of incompatible slots when dragging a connection
@@ -3559,12 +3561,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
if ( if (
isMouseOverSlot || isMouseOverSlot ||
isValidTarget || isValidTarget ||
!slotInstance.isWidgetInputSlot || !slot.isWidgetInputSlot ||
this.#isMouseOverWidget(this.getWidgetFromSlot(slotInstance)) || this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slotInstance.isConnected() slot.isConnected()
) { ) {
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slotInstance.draw(ctx, { slot.draw(ctx, {
colorContext, colorContext,
lowQuality, lowQuality,
highlight, highlight,
@@ -3673,19 +3675,31 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
const slot = slotByWidgetName.get(widget.name) const slot = slotByWidgetName.get(widget.name)
if (!slot) continue if (!slot) continue
const actualSlot = this.inputs[slot.index] const actualSlot = this.#concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset] 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). * Arranges node elements in preparation for rendering (slots & widgets).
*/ */
arrange(): void { arrange(): void {
const slotsBounds = this.#measureSlots() 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.#arrangeWidgets(widgetStartY)
this.#arrangeWidgetInputSlots() this.#arrangeWidgetInputSlots()
} }

View File

@@ -1,4 +1,5 @@
import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { LinkId } from "@/LLink" import type { LinkId } from "@/LLink"
import { LabelPosition } from "@/draw" import { LabelPosition } from "@/draw"
@@ -12,8 +13,8 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
return !!this.widget return !!this.widget
} }
constructor(slot: OptionalProps<INodeInputSlot, "boundingRect">) { constructor(slot: OptionalProps<INodeInputSlot, "boundingRect">, node: LGraphNode) {
super(slot) super(slot, node)
this.link = slot.link this.link = slot.link
} }

View File

@@ -1,4 +1,5 @@
import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces" import type { INodeInputSlot, INodeOutputSlot, OptionalProps } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { LinkId } from "@/LLink" import type { LinkId } from "@/LLink"
import { LabelPosition } from "@/draw" import { LabelPosition } from "@/draw"
@@ -14,8 +15,8 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
return false return false
} }
constructor(slot: OptionalProps<INodeOutputSlot, "boundingRect">) { constructor(slot: OptionalProps<INodeOutputSlot, "boundingRect">, node: LGraphNode) {
super(slot) super(slot, node)
this.links = slot.links this.links = slot.links
this._data = slot._data this._data = slot._data
this.slot_index = slot.slot_index this.slot_index = slot.slot_index

View File

@@ -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 { LabelPosition, SlotShape, SlotType } from "@/draw"
import { LiteGraph } from "@/litegraph" import { LiteGraph } from "@/litegraph"
@@ -41,7 +42,28 @@ export abstract class NodeSlot implements INodeSlot {
pos?: Point pos?: Point
widget?: IWidgetLocator widget?: IWidgetLocator
hasErrors?: boolean 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 { get highlightColor(): CanvasColour {
return LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR 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 abstract get isWidgetInputSlot(): boolean
constructor(slot: OptionalProps<INodeSlot, "boundingRect">) { constructor(slot: OptionalProps<INodeSlot, "boundingRect">, node: LGraphNode) {
Object.assign(this, slot) Object.assign(this, slot)
this.name = slot.name this.name = slot.name
this.type = slot.type this.type = slot.type
this.boundingRect = slot.boundingRect ?? [0, 0, 0, 0] this.boundingRect = slot.boundingRect ?? [0, 0, 0, 0]
this.#node = node
} }
/** /**
@@ -109,7 +132,7 @@ export abstract class NodeSlot implements INodeSlot {
? this.highlightColor ? this.highlightColor
: LiteGraph.NODE_TEXT_COLOR : LiteGraph.NODE_TEXT_COLOR
const pos = getCentre(this.boundingRect) const pos = this.#centreOffset
const slot_type = this.type const slot_type = this.type
const slot_shape = ( const slot_shape = (
slot_type === SlotType.Array ? SlotShape.Grid : this.shape slot_type === SlotType.Array ? SlotShape.Grid : this.shape

View File

@@ -2,9 +2,6 @@ import type { IWidgetInputSlot, SharedIntersection } from "@/interfaces"
import type { INodeInputSlot, INodeOutputSlot, INodeSlot, IWidget } from "@/litegraph" import type { INodeInputSlot, INodeOutputSlot, INodeSlot, IWidget } from "@/litegraph"
import type { ISerialisableNodeInput, ISerialisableNodeOutput } from "@/types/serialisation" import type { ISerialisableNodeInput, ISerialisableNodeOutput } from "@/types/serialisation"
import { NodeInputSlot } from "./NodeInputSlot"
import { NodeOutputSlot } from "./NodeOutputSlot"
type CommonIoSlotProps = SharedIntersection<ISerialisableNodeInput, ISerialisableNodeOutput> type CommonIoSlotProps = SharedIntersection<ISerialisableNodeInput, ISerialisableNodeOutput>
export function shallowCloneCommonProps(slot: CommonIoSlotProps): CommonIoSlotProps { 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 { export function isWidgetInputSlot(slot: INodeInputSlot): slot is IWidgetInputSlot {
return !!slot.widget 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)
}

View File

@@ -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. * 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 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 * @returns The class instance
*/ */
export function toClass<P, C>(cls: new (plain: P) => C, obj: P | C): C { export function toClass<P, C extends P, Args extends unknown[]>(
return obj instanceof cls ? obj : new cls(obj as P) cls: new (instance: P, ...args: Args) => C,
...args: [P, ...Args]
): C {
return args[0] instanceof cls ? args[0] : new cls(...args)
} }
/** /**