Files
ComfyUI_frontend/src/lib/litegraph/src/LGraphNode.ts
Arthur R Longbottom 657ae6a6c3 fix: subgraph promoted widget input label rename (#10195)
## Summary

Promoted primitive subgraph inputs (String, Int) render their link
anchor at the header position instead of the widget row. Renaming
subgraph input labels breaks the match entirely, causing connections to
detach from their widgets visually.

## Changes

- **What**: Fix widget-input slot positioning for promoted subgraph
inputs in both LiteGraph and Vue (Nodes 2.0) rendering modes
- `_arrangeWidgetInputSlots`: Removed Vue mode branch that skipped
setting `input.pos`. Promoted widget inputs aren't rendered as
`<InputSlot>` Vue components (NodeSlots filters them out), so
`input.pos` is the only position fallback
- `drawConnections`: Added pre-pass to arrange nodes with unpositioned
widget-input slots before link rendering. The background canvas renders
before the foreground canvas calls `arrange()`, so positions weren't set
on the first frame
- `SubgraphNode`: Sync `input.widget.name` with the display name on
label rename and initial setup. The `IWidgetLocator` name diverged from
`PromotedWidgetView.name` after rename, breaking all name-based
slot↔widget matching (`_arrangeWidgetInputSlots`, `getWidgetFromSlot`,
`getSlotFromWidget`)

## Review Focus

- The `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs`
directly instead of building a spread-copy map — simpler and avoids the
stale index issue
- `input.widget.name` is now kept in sync with the display name
(`input.label ?? subgraphInput.name`). This is a semantic shift from
using the raw internal name, but it's required for all name-based
matching to work after renames. The value is overwritten on deserialize
by `_setWidget` anyway
- The `_widget` fallback in `_arrangeWidgetInputSlots` is a safety net
for edge cases where the name still doesn't match (e.g., stale cache)

Fixes #9998

## Screenshots
<img width="847" height="476" alt="Screenshot 2026-03-17 at 3 05 32 PM"
src="https://github.com/user-attachments/assets/38f10563-f0bc-44dd-a1a5-f4a7832575d0"
/>
<img width="804" height="471" alt="Screenshot 2026-03-17 at 3 05 23 PM"
src="https://github.com/user-attachments/assets/3237a7ee-f3e5-4084-b330-371def3415bd"
/>
<img width="974" height="571" alt="Screenshot 2026-03-17 at 3 05 16 PM"
src="https://github.com/user-attachments/assets/cafdca46-8d9b-40e1-8561-02cbb25ee8f2"
/>
<img width="967" height="558" alt="Screenshot 2026-03-17 at 3 05 06 PM"
src="https://github.com/user-attachments/assets/fc03ce43-906c-474d-b3bc-ddf08eb37c75"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10195-fix-subgraph-promoted-widget-input-slot-positions-after-label-rename-3266d73d365081dfa623dd94dd87c718)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
2026-03-23 14:33:03 -07:00

4286 lines
124 KiB
TypeScript

import { toValue } from 'vue'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPosFromSlot,
getSlotPosition
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { adjustColor } from '@/utils/colorUtil'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
commonType,
isNodeBindable,
toClass
} from '@/lib/litegraph/src/utils/type'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
import { LGraphButton } from './LGraphButton'
import type { LGraphButtonOptions } from './LGraphButton'
import { LGraphCanvas } from './LGraphCanvas'
import { LLink } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
import { NullGraphError } from './infrastructure/NullGraphError'
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
import { Rectangle } from './infrastructure/Rectangle'
import type {
ColorOption,
CompassCorners,
DefaultConnectionColors,
Dictionary,
IColorable,
IContextMenuValue,
IFoundSlot,
INodeFlags,
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
INodeSlotContextItem,
IPinnable,
ISlotType,
Panel,
Point,
Positionable,
ReadOnlyRect,
Rect,
Size
} from './interfaces'
import { LiteGraph, Subgraph } from './litegraph'
import type { LGraphNodeConstructor, SubgraphNode } from './litegraph'
import {
createBounds,
isInRect,
isInRectangle,
isPointInRect,
snapPoint
} from './measure'
import { NodeInputSlot } from './node/NodeInputSlot'
import { NodeOutputSlot } from './node/NodeOutputSlot'
import {
inputAsSerialisable,
isINodeInputSlot,
isWidgetInputSlot,
outputAsSerialisable
} from './node/slotUtils'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type { NodeLike } from './types/NodeLike'
import type { CanvasPointerEvent } from './types/events'
import {
LGraphEventMode,
NodeSlotType,
RenderShape,
TitleMode
} from './types/globalEnums'
import type { ISerialisedNode, SubgraphIO } from './types/serialisation'
import type {
IBaseWidget,
IWidgetOptions,
TWidgetType,
TWidgetValue
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
// #region Types
export type NodeId = number | string
export type NodeProperty = string | number | boolean | object
interface INodePropertyInfo {
name?: string
type?: string
default_value?: NodeProperty
widget?: string
label?: string
values?: TWidgetValue[]
}
interface IMouseOverData {
inputId?: number
outputId?: number
overWidget?: IBaseWidget
}
interface ConnectByTypeOptions {
/** @deprecated Events */
createEventInCase?: boolean
/** Allow our wildcard slot to connect to typed slots on remote node. Default: true */
wildcardToTyped?: boolean
/** Allow our typed slot to connect to wildcard slots on remote node. Default: true */
typedToWildcard?: boolean
/** The {@link Reroute.id} that the connection is being dragged from. */
afterRerouteId?: RerouteId
}
/** Internal type used for type safety when implementing generic checks for inputs & outputs */
interface IGenericLinkOrLinks {
links?: INodeOutputSlot['links']
link?: INodeInputSlot['link']
}
interface FindFreeSlotOptions {
/** Slots matching these types will be ignored. Default: [] */
typesNotAccepted?: ISlotType[]
/** If true, the slot itself is returned instead of the index. Default: false */
returnObj?: boolean
}
interface DrawSlotsOptions {
fromSlot?: INodeInputSlot | INodeOutputSlot
colorContext: DefaultConnectionColors
editorAlpha: number
lowQuality: boolean
}
interface DrawWidgetsOptions {
lowQuality?: boolean
editorAlpha?: number
}
interface DrawTitleOptions {
scale: number
title_height?: number
low_quality?: boolean
}
interface DrawTitleTextOptions extends DrawTitleOptions {
default_title_color: string
}
export interface DrawTitleBoxOptions extends DrawTitleOptions {
box_size?: number
}
/*
title: string
pos: [x,y]
size: [x,y]
input|output: every connection
+ { name:string, type:string, pos: [x,y]=Optional, direction: "input"|"output", links: Array });
general properties:
+ clip_area: if you render outside the node, it will be clipped
+ unsafe_execution: not allowed for safe execution
+ skip_repeated_outputs: when adding new outputs, it won't show if there is one already connected
+ resizable: if set to false it won't be resizable with the mouse
+ widgets_start_y: widgets start at y distance from the top of the node
flags object:
+ collapsed: if it is collapsed
supported callbacks:
+ onAdded: when added to graph (warning: this is called BEFORE the node is configured when loading)
+ onRemoved: when removed from graph
+ onStart: when the graph starts playing
+ onStop: when the graph stops playing
+ onDrawForeground: render the inside widgets inside the node
+ onDrawBackground: render the background area inside the node (only in edit mode)
+ onMouseDown
+ onMouseMove
+ onMouseUp
+ onMouseEnter
+ onMouseLeave
+ onExecute: execute the node
+ onPropertyChanged: when a property is changed in the panel (return true to skip default behaviour)
+ onGetInputs: returns an array of possible inputs
+ onGetOutputs: returns an array of possible outputs
+ onBounding: in case this node has a bigger bounding than the node itself (the callback receives the bounding as [x,y,w,h])
+ onDblClick: double clicked in the node
+ onNodeTitleDblClick: double clicked in the node title
+ onInputDblClick: input slot double clicked (can be used to automatically create a node connected)
+ onOutputDblClick: output slot double clicked (can be used to automatically create a node connected)
+ onConfigure: called after the node has been configured
+ onSerialize: to add extra info when serializing (the callback receives the object that should be filled with the data)
+ onSelected
+ onDeselected
+ onDropItem : DOM item dropped over the node
+ onDropFile : file dropped over the node
+ onConnectInput : if returns false the incoming connection will be canceled
+ onConnectionsChange : a connection changed (new one or removed) (NodeSlotType.INPUT or NodeSlotType.OUTPUT, slot, true if connected, link_info, input_info )
+ onAction: action slot triggered
+ getExtraMenuOptions: to add option to context menu
*/
export interface LGraphNode {
constructor: LGraphNodeConstructor
}
// #endregion Types
/**
* Base class for all nodes
* @param title a name for the node
* @param type a type for the node
*/
export class LGraphNode
implements NodeLike, Positionable, IPinnable, IColorable
{
// Static properties used by dynamic child classes
static title?: string
static MAX_CONSOLE?: number
static type?: string
static category?: string
static description?: string
static filter?: string
static skip_list?: boolean
static nodeData?: {
dev_only?: boolean
deprecated?: boolean
experimental?: boolean
output_node?: boolean
api_node?: boolean
name?: string
}
static resizeHandleSize = 15
static resizeEdgeSize = 5
/** Default setting for {@link LGraphNode.connectInputToOutput}. @see {@link INodeFlags.keepAllLinksOnBypass} */
static keepAllLinksOnBypass: boolean = false
/** The title text of the node. */
title: string
/**
* The font style used to render the node's title text.
*/
get titleFontStyle(): string {
return `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
}
get innerFontStyle(): string {
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px ${LiteGraph.NODE_FONT}`
}
get displayType(): string {
return this.type
}
graph: LGraph | Subgraph | null = null
id: NodeId
type: string = ''
inputs: INodeInputSlot[] = []
outputs: INodeOutputSlot[] = []
private _concreteInputs: NodeInputSlot[] = []
private _concreteOutputs: NodeOutputSlot[] = []
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IBaseWidget[]
/** Property manager for this node */
changeTracker: LGraphNodeProperties
/**
* The amount of space available for widgets to grow into.
* @see {@link layoutWidgets}
*/
freeWidgetSpace?: number
/**
* Set to true when widget-backed input slot positions need recalculation.
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
*/
_widgetSlotsDirty = false
locked?: boolean
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
order: number = 0
mode: LGraphEventMode = LGraphEventMode.ALWAYS
last_serialization?: ISerialisedNode
serialize_widgets?: boolean
/**
* The overridden fg color used to render the node.
* @see {@link renderingColor}
*/
color?: string
/**
* The overridden bg color used to render the node.
* @see {@link renderingBgColor}
*/
bgcolor?: string
/**
* The overridden box color used to render the node.
* @see {@link renderingBoxColor}
*/
boxcolor?: string
/** The fg color used to render the node. */
get renderingColor(): string {
const baseColor =
this.color || this.constructor.color || LiteGraph.NODE_DEFAULT_COLOR
return adjustColor(baseColor, { lightness: LiteGraph.nodeLightness })
}
/** The bg color used to render the node. */
get renderingBgColor(): string {
const baseBgColor =
this.bgcolor || this.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR
const adjustments: ColorAdjustOptions = {
opacity: LiteGraph.nodeOpacity,
lightness: LiteGraph.nodeLightness
}
return adjustColor(
this.mode === LGraphEventMode.BYPASS
? LiteGraph.NODE_DEFAULT_BYPASS_COLOR
: baseBgColor,
adjustments
)
}
/** The box color used to render the node. */
get renderingBoxColor(): string {
if (this.boxcolor) return this.boxcolor
if (LiteGraph.node_box_coloured_when_on) {
if (this.action_triggered) return '#FFF'
if (this.execute_triggered) return '#AAA'
}
if (LiteGraph.node_box_coloured_by_mode) {
const modeColour =
LiteGraph.NODE_MODES_COLORS[this.mode ?? LGraphEventMode.ALWAYS]
if (modeColour) return modeColour
}
return LiteGraph.NODE_DEFAULT_BOXCOLOR
}
/** @inheritdoc {@link IColorable.setColorOption} */
setColorOption(colorOption: ColorOption | null): void {
if (colorOption == null) {
this.color = undefined
this.bgcolor = undefined
} else {
this.color = colorOption.color
this.bgcolor = colorOption.bgcolor
}
}
/** @inheritdoc {@link IColorable.getColorOption} */
getColorOption(): ColorOption | null {
return (
Object.values(LGraphCanvas.node_colors).find(
(colorOption) =>
colorOption.color === this.color &&
colorOption.bgcolor === this.bgcolor
) ?? null
)
}
/**
* The stroke styles that should be applied to the node.
*/
strokeStyles: Record<
string,
(this: LGraphNode) => IDrawBoundingOptions | undefined
>
/**
* The progress of node execution. Used to render a progress bar. Value between 0 and 1.
*/
progress?: number
exec_version?: number
action_call?: string
execute_triggered?: number
action_triggered?: number
/**
* @deprecated This property is unsupported and will be removed in a future release.
* Use `widgets_start_y` or a custom `arrange()` override instead.
*/
widgets_up?: boolean
widgets_start_y?: number
lostFocusAt?: number
gotFocusAt?: number
badges: (LGraphBadge | (() => LGraphBadge))[] = []
title_buttons: LGraphButton[] = []
badgePosition: BadgePosition = BadgePosition.TopLeft
onOutputRemoved?(this: LGraphNode, slot: number): void
onInputRemoved?(this: LGraphNode, slot: number, input: INodeInputSlot): void
/**
* The width of the node when collapsed.
* Updated by {@link LGraphCanvas.drawNode}
*/
_collapsed_width?: number
/**
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
*/
onBounding?(this: LGraphNode, out: Rect): void
console?: string[]
_level?: number
_shape?: RenderShape
mouseOver?: IMouseOverData
redraw_on_mouse?: boolean
resizable?: boolean
clonable?: boolean
_relative_id?: number
clip_area?: boolean
ignore_remove?: boolean
has_errors?: boolean
removable?: boolean
block_delete?: boolean
selected?: boolean
showAdvanced?: boolean
declare comfyDynamic?: Record<string, object>
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void
isSubgraphNode(): this is SubgraphNode {
return false
}
/** @inheritdoc {@link renderArea} */
private _renderArea = new Rectangle()
/**
* Rect describing the node area, including shadows and any protrusions.
* Determines if the node is visible. Calculated once at the start of every frame.
*/
get renderArea(): ReadOnlyRect {
return this._renderArea
}
/** @inheritdoc {@link boundingRect} */
private _boundingRect: Rectangle = new Rectangle()
/**
* Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present.
*
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
*/
get boundingRect(): ReadOnlyRectangle {
return this._boundingRect
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
get boundingOffset(): Readonly<Point> {
const {
pos: [posX, posY],
boundingRect: [bX, bY]
} = this
return [posX - bX, posY - bY]
}
/** {@link pos} and {@link size} values are backed by this {@link Rectangle}. */
_posSize = new Rectangle()
_pos: Point = this._posSize.pos
_size: Size = this._posSize.size
public get pos() {
return this._pos
}
/** Node position does not necessarily correlate to the top-left corner. */
public set pos(value) {
if (!value || value.length < 2) return
this._pos[0] = value[0]
this._pos[1] = value[1]
const mutations = useLayoutMutations()
mutations.setSource(LayoutSource.Canvas)
mutations.moveNode(String(this.id), { x: value[0], y: value[1] })
}
/**
* Set the node position to an absolute location.
*/
setPos(x: number, y: number): void {
this.pos = [x, y]
}
public get size() {
return this._size
}
public set size(value) {
if (!value || value.length < 2) return
this._size[0] = value[0]
this._size[1] = value[1]
const mutations = useLayoutMutations()
mutations.setSource(LayoutSource.Canvas)
mutations.resizeNode(String(this.id), {
width: value[0],
height: value[1]
})
}
/**
* The size of the node used for rendering.
*/
get renderingSize(): Size {
return this.flags.collapsed ? [this._collapsed_width ?? 0, 0] : this._size
}
get shape(): RenderShape | undefined {
return this._shape
}
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
const oldValue = this._shape
switch (v) {
case 'default':
this._shape = undefined
break
case 'box':
this._shape = RenderShape.BOX
break
case 'round':
this._shape = RenderShape.ROUND
break
case 'circle':
this._shape = RenderShape.CIRCLE
break
case 'card':
this._shape = RenderShape.CARD
break
default:
this._shape = v
}
if (oldValue !== this._shape) {
this.graph?.trigger('node:property:changed', {
nodeId: this.id,
property: 'shape',
oldValue,
newValue: this._shape
})
}
}
/**
* The shape of the node used for rendering. @see {@link RenderShape}
*/
get renderingShape(): RenderShape {
return this._shape || this.constructor.shape || LiteGraph.NODE_DEFAULT_SHAPE
}
public get is_selected(): boolean | undefined {
return this.selected
}
public set is_selected(value: boolean) {
this.selected = value
}
public get title_mode(): TitleMode {
return this.constructor.title_mode ?? TitleMode.NORMAL_TITLE
}
onConnectInput?(
this: LGraphNode,
target_slot: number,
type: unknown,
output: INodeOutputSlot | SubgraphIO,
node: LGraphNode | SubgraphInputNode,
slot: number
): boolean
onConnectOutput?(
this: LGraphNode,
slot: number,
type: unknown,
input: INodeInputSlot | SubgraphIO,
target_node: number | LGraphNode | SubgraphOutputNode,
target_slot: number
): boolean
onResize?(this: LGraphNode, size: Size): void
onPropertyChanged?(
this: LGraphNode,
name: string,
value: unknown,
prev_value?: unknown
): boolean
/** Called for each connection that is created, updated, or removed. This includes "restored" connections when deserialising. */
onConnectionsChange?(
this: LGraphNode,
type: ISlotType,
index: number,
isConnected: boolean,
link_info: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot | SubgraphIO
): void
onInputAdded?(this: LGraphNode, input: INodeInputSlot): void
onOutputAdded?(this: LGraphNode, output: INodeOutputSlot): void
onConfigure?(this: LGraphNode, serialisedNode: ISerialisedNode): void
onSerialize?(this: LGraphNode, serialised: ISerialisedNode): void
onExecute?(
this: LGraphNode,
param?: unknown,
options?: { action_call?: string }
): void
onAction?(
this: LGraphNode,
action: string,
param: unknown,
options: { action_call?: string }
): void
onDrawBackground?(this: LGraphNode, ctx: CanvasRenderingContext2D): void
onNodeCreated?(this: LGraphNode): void
/**
* Callback invoked by {@link connect} to override the target slot index.
* Its return value overrides the target index selection.
* @param target_slot The current input slot index
* @param requested_slot The originally requested slot index - could be negative, or if using (deprecated) name search, a string
* @returns {number | null} If a number is returned, the connection will be made to that input index.
* If an invalid index or non-number (false, null, NaN etc) is returned, the connection will be cancelled.
*/
onBeforeConnectInput?(
this: LGraphNode,
target_slot: number,
requested_slot?: number | string
): number | false | null
onShowCustomPanelInfo?(this: LGraphNode, panel: Panel): void
onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: Panel): boolean
onWidgetChanged?(
this: LGraphNode,
name: string,
value: unknown,
old_value: unknown,
w: IBaseWidget
): void
onDeselected?(this: LGraphNode): void
onKeyUp?(this: LGraphNode, e: KeyboardEvent): void
onKeyDown?(this: LGraphNode, e: KeyboardEvent): void
onSelected?(this: LGraphNode): void
getExtraMenuOptions?(
this: LGraphNode,
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[]
): (IContextMenuValue<unknown> | null)[]
getMenuOptions?(this: LGraphNode, canvas: LGraphCanvas): IContextMenuValue[]
onAdded?(this: LGraphNode, graph: LGraph): void
onDrawCollapsed?(
this: LGraphNode,
ctx: CanvasRenderingContext2D,
cavnas: LGraphCanvas
): boolean
onDrawForeground?(
this: LGraphNode,
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas,
canvasElement: HTMLCanvasElement
): void
onMouseLeave?(this: LGraphNode, e: CanvasPointerEvent): void
/**
* Override the default slot menu options.
*/
getSlotMenuOptions?(this: LGraphNode, slot: IFoundSlot): IContextMenuValue[]
/**
* Add extra menu options to the slot context menu.
*/
getExtraSlotMenuOptions?(
this: LGraphNode,
slot: IFoundSlot
): IContextMenuValue[]
// FIXME: Re-typing
onDropItem?(this: LGraphNode, event: Event): boolean
onDropData?(
this: LGraphNode,
data: string | ArrayBuffer,
filename: string,
file: File
): void
onDropFile?(this: LGraphNode, file: File): void
onInputClick?(this: LGraphNode, index: number, e: CanvasPointerEvent): void
onInputDblClick?(this: LGraphNode, index: number, e: CanvasPointerEvent): void
onOutputClick?(this: LGraphNode, index: number, e: CanvasPointerEvent): void
onOutputDblClick?(
this: LGraphNode,
index: number,
e: CanvasPointerEvent
): void
onGetPropertyInfo?(this: LGraphNode, property: string): INodePropertyInfo
onNodeOutputAdd?(this: LGraphNode, value: unknown): void
onNodeInputAdd?(this: LGraphNode, value: unknown): void
onMenuNodeInputs?(
this: LGraphNode,
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
): (IContextMenuValue<INodeSlotContextItem> | null)[]
onMenuNodeOutputs?(
this: LGraphNode,
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
): (IContextMenuValue<INodeSlotContextItem> | null)[]
onMouseUp?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): void
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
onMouseDown?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): boolean
/** @param pos Offset from {@link LGraphNode.pos}. */
onDblClick?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): void
/** @param pos Offset from {@link LGraphNode.pos}. */
onNodeTitleDblClick?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): void
onDrawTitle?(this: LGraphNode, ctx: CanvasRenderingContext2D): void
onDrawTitleText?(
this: LGraphNode,
ctx: CanvasRenderingContext2D,
title_height: number,
size: Size,
scale: number,
title_text_font: string,
selected?: boolean
): void
onDrawTitleBox?(
this: LGraphNode,
ctx: CanvasRenderingContext2D,
title_height: number,
size: Size,
scale: number
): void
onDrawTitleBar?(
this: LGraphNode,
ctx: CanvasRenderingContext2D,
title_height: number,
size: Size,
scale: number,
fgcolor: string
): void
onRemoved?(this: LGraphNode): void
onMouseMove?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
arg2: LGraphCanvas
): void
onPropertyChange?(this: LGraphNode): void
updateOutputData?(this: LGraphNode, origin_slot: number): void
private _getErrorStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.has_errors) {
return {
padding: 12,
lineWidth: 10,
color: LiteGraph.NODE_ERROR_COLOUR
}
}
}
private _getSelectedStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.selected) {
return {
padding: this.has_errors ? 20 : undefined
}
}
}
constructor(title: string, type?: string) {
this.id = LiteGraph.use_uuids ? LiteGraph.uuidv4() : -1
this.title = title || 'Unnamed'
this.type = type ?? ''
this.size = [LiteGraph.NODE_WIDTH, 60]
this.pos = [10, 10]
this.strokeStyles = {
error: this._getErrorStrokeStyle,
selected: this._getSelectedStrokeStyle
}
// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
}
/** Internal callback for subgraph nodes. Do not implement externally. */
_internalConfigureAfterSlots?(): void
/**
* configure a node from an object containing the serialized info
*/
configure(info: ISerialisedNode): void {
if (this.graph) {
this.graph._version++
}
if (info.id === -1) info.id = this.id
for (const j in info) {
if (j == 'properties') {
// i don't want to clone properties, I want to reuse the old container
for (const k in info.properties) {
this.properties[k] = info.properties[k]
this.onPropertyChanged?.(k, info.properties[k])
}
continue
}
// @ts-expect-error #594
if (info[j] == null) {
continue
// @ts-expect-error #594
} else if (typeof info[j] == 'object') {
// @ts-expect-error #594
if (this[j]?.configure) {
// @ts-expect-error #594
this[j]?.configure(info[j])
} else {
// @ts-expect-error #594
this[j] = LiteGraph.cloneObject(info[j], this[j])
}
} else {
// value
// @ts-expect-error #594
this[j] = info[j]
}
}
if (!info.title) {
this.title = this.constructor.title
}
this.inputs ??= []
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)
: null
this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input)
this.onInputAdded?.(input)
}
this.outputs ??= []
this.outputs = this.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
for (const [i, output] of this.outputs.entries()) {
if (!output.links) continue
for (const linkId of output.links) {
const link = this.graph ? this.graph._links.get(linkId) : null
this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output)
}
this.onOutputAdded?.(output)
}
// SubgraphNode callback.
this._internalConfigureAfterSlots?.()
if (this.widgets) {
for (const w of this.widgets) {
if (!w) continue
const input = this.inputs.find((i) => i.widget?.name === w.name)
if (input?.label) w.label = input.label
if (
w.options?.property &&
this.properties[w.options.property] != undefined
)
w.value = JSON.parse(
JSON.stringify(this.properties[w.options.property])
)
}
if (info.widgets_values) {
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}
// Sync the state of this.resizable.
if (this.pinned) this.resizable = false
if (this.widgets_up) {
console.warn(
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
'This property is unsupported and will be removed. ' +
'Use "widgets_start_y" or a custom arrange() override instead.'
)
}
this.onConfigure?.(info)
}
/**
* serialize the content
*/
serialize(): ISerialisedNode {
// create serialization object
const o: ISerialisedNode = {
id: this.id,
type: this.type,
pos: [this.pos[0], this.pos[1]],
size: [this.size[0], this.size[1]],
flags: LiteGraph.cloneObject(this.flags),
order: this.order,
mode: this.mode,
showAdvanced: this.showAdvanced
}
// special case for when there were errors
if (this.constructor === LGraphNode && this.last_serialization)
return { ...this.last_serialization, mode: o.mode, pos: o.pos }
if (this.inputs)
o.inputs = this.inputs.map((input) => inputAsSerialisable(input))
if (this.outputs)
// @ts-expect-error - Output serialization type mismatch
o.outputs = this.outputs.map((output) => outputAsSerialisable(output))
if (this.title && this.title != this.constructor.title) o.title = this.title
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
const { widgets } = this
if (widgets && this.serialize_widgets) {
o.widgets_values = []
for (const [i, widget] of widgets.entries()) {
if (widget.serialize === false) continue
const val = widget?.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
o.widgets_values[i] =
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
}
}
if (!o.type && this.constructor.type) o.type = this.constructor.type
if (this.color) o.color = this.color
if (this.bgcolor) o.bgcolor = this.bgcolor
if (this.boxcolor) o.boxcolor = this.boxcolor
if (this.shape) o.shape = this.shape
if (this.onSerialize?.(o))
console.warn(
"node onSerialize shouldn't return anything, data should be stored in the object pass in the first parameter"
)
return o
}
/* Creates a clone of this node */
clone(): LGraphNode | null {
if (this.type == null) return null
const node = LiteGraph.createNode(this.type)
if (!node) return null
// we clone it because serialize returns shared containers
const data = LiteGraph.cloneObject(this.serialize())
const { inputs, outputs } = data
// remove links
if (inputs) {
for (const input of inputs) {
input.link = null
}
}
if (outputs) {
for (const { links } of outputs) {
if (links) links.length = 0
}
}
// @ts-expect-error Exceptional case: id is removed so that the graph can assign a new one on add.
data.id = undefined
if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4()
node.configure(data)
return node
}
/**
* serialize and stringify
*/
toString(): string {
return JSON.stringify(this.serialize())
}
/**
* get the title string
*/
getTitle(): string {
return this.title || this.constructor.title
}
/**
* sets the value of a property
* @param name
* @param value
*/
setProperty(name: string, value: TWidgetValue): void {
this.properties ||= {}
if (value === this.properties[name]) return
const prev_value = this.properties[name]
this.properties[name] = value
// abort change
if (this.onPropertyChanged?.(name, value, prev_value) === false)
this.properties[name] = prev_value
if (this.widgets) {
for (const w of this.widgets) {
if (!w) continue
if (w.options.property == name) {
w.value = value
break
}
}
}
}
/**
* sets the output data
* @param slot
* @param data
*/
setOutputData(
slot: number,
data: number | string | boolean | { toToolTip?(): string }
): void {
const { outputs } = this
if (!outputs) return
// this maybe slow and a niche case
if (slot == -1 || slot >= outputs.length) return
const output_info = outputs[slot]
if (!output_info) return
// store data in the output itself in case we want to debug
output_info._data = data
if (!this.graph) throw new NullGraphError()
// if there are connections, pass the data to the connections
const { links } = outputs[slot]
if (links) {
for (const id of links) {
const link = this.graph._links.get(id)
if (link) link.data = data
}
}
}
/**
* sets the output data type, useful when you want to be able to overwrite the data type
*/
setOutputDataType(slot: number, type: ISlotType): void {
const { outputs } = this
if (!outputs || slot == -1 || slot >= outputs.length) return
const output_info = outputs[slot]
if (!output_info) return
// store data in the output itself in case we want to debug
output_info.type = type
if (!this.graph) throw new NullGraphError()
// if there are connections, pass the data to the connections
const { links } = outputs[slot]
if (links) {
for (const id of links) {
const link = this.graph._links.get(id)
if (link) link.type = type
}
}
}
/**
* Retrieves the input data (data traveling through the connection) from one slot
* @param slot
* @param force_update if set to true it will force the connected node of this slot to output data into this link
* @returns data or if it is not connected returns undefined
*/
getInputData(slot: number, force_update?: boolean): unknown {
if (!this.inputs) return
if (slot >= this.inputs.length || this.inputs[slot].link == null) return
if (!this.graph) throw new NullGraphError()
const link_id = this.inputs[slot].link
const link = this.graph._links.get(link_id)
// bug: weird case but it happens sometimes
if (!link) return null
if (!force_update) return link.data
// special case: used to extract data from the incoming connection before the graph has been executed
const node = this.graph.getNodeById(link.origin_id)
if (!node) return link.data
if (node.updateOutputData) {
node.updateOutputData(link.origin_slot)
} else {
node.onExecute?.()
}
return link.data
}
/**
* Retrieves the input data type (in case this supports multiple input types)
* @param slot
* @returns datatype in string format
*/
getInputDataType(slot: number): ISlotType | null {
if (!this.inputs) return null
if (slot >= this.inputs.length || this.inputs[slot].link == null)
return null
if (!this.graph) throw new NullGraphError()
const link_id = this.inputs[slot].link
const link = this.graph._links.get(link_id)
// bug: weird case but it happens sometimes
if (!link) return null
const node = this.graph.getNodeById(link.origin_id)
if (!node) return link.type
const output_info = node.outputs[link.origin_slot]
return output_info ? output_info.type : null
}
/**
* Retrieves the input data from one slot using its name instead of slot number
* @param slot_name
* @param force_update if set to true it will force the connected node of this slot to output data into this link
* @returns data or if it is not connected returns null
*/
getInputDataByName(slot_name: string, force_update: boolean): unknown {
const slot = this.findInputSlot(slot_name)
return slot == -1 ? null : this.getInputData(slot, force_update)
}
/**
* tells you if there is a connection in one input slot
* @param slot The 0-based index of the input to check
* @returns `true` if the input slot has a link ID (does not perform validation)
*/
isInputConnected(slot: number): boolean {
if (!this.inputs) return false
return slot < this.inputs.length && this.inputs[slot].link != null
}
/**
* tells you info about an input connection (which node, type, etc)
* @returns object or null { link: id, name: string, type: string or 0 }
*/
getInputInfo(slot: number): INodeInputSlot | null {
return !this.inputs || !(slot < this.inputs.length)
? null
: this.inputs[slot]
}
/**
* Resolves the output source for cross-graph virtual nodes (e.g. Set/Get),
* bypassing {@link getInputLink} when the source lives in a different graph.
*/
resolveVirtualOutput?(
slot: number
): { node: LGraphNode; slot: number } | undefined
/**
* Returns the link info in the connection of an input slot
* @returns object or null
*/
getInputLink(slot: number): LLink | null {
if (!this.inputs) return null
if (slot < this.inputs.length) {
if (!this.graph) throw new NullGraphError()
const input = this.inputs[slot]
if (input.link != null) {
return this.graph._links.get(input.link) ?? null
}
}
return null
}
/**
* returns the node connected in the input slot
* @returns node or null
*/
getInputNode(slot: number): LGraphNode | null {
if (!this.inputs) return null
if (slot >= this.inputs.length) return null
const input = this.inputs[slot]
if (!input || input.link === null) return null
if (!this.graph) throw new NullGraphError()
const link_info = this.graph._links.get(input.link)
if (!link_info) return null
return this.graph.getNodeById(link_info.origin_id)
}
/**
* returns the value of an input with this name, otherwise checks if there is a property with that name
* @returns value
*/
getInputOrProperty(name: string): unknown {
const { inputs } = this
if (!inputs?.length) {
return this.properties ? this.properties[name] : null
}
if (!this.graph) throw new NullGraphError()
for (const input of inputs) {
if (name == input.name && input.link != null) {
const link = this.graph._links.get(input.link)
if (link) return link.data
}
}
return this.properties[name]
}
/**
* tells you the last output data that went in that slot
* @returns object or null
*/
getOutputData(slot: number): unknown {
if (!this.outputs) return null
if (slot >= this.outputs.length) return null
const info = this.outputs[slot]
return info._data
}
/**
* tells you info about an output connection (which node, type, etc)
* @returns object or null { name: string, type: string, links: [ ids of links in number ] }
*/
getOutputInfo(slot: number): INodeOutputSlot | null {
return !this.outputs || !(slot < this.outputs.length)
? null
: this.outputs[slot]
}
/**
* tells you if there is a connection in one output slot
*/
isOutputConnected(slot: number): boolean {
if (!this.outputs) return false
return (
slot < this.outputs.length && Number(this.outputs[slot].links?.length) > 0
)
}
/**
* tells you if there is any connection in the output slots
*/
isAnyOutputConnected(): boolean {
const { outputs } = this
if (!outputs) return false
for (const output of outputs) {
if (output.links?.length) return true
}
return false
}
/**
* retrieves all the nodes connected to this output slot
*/
getOutputNodes(slot: number): LGraphNode[] | null {
const { outputs } = this
if (!outputs || outputs.length == 0) return null
if (slot >= outputs.length) return null
const { links } = outputs[slot]
if (!links || links.length == 0) return null
if (!this.graph) throw new NullGraphError()
const r: LGraphNode[] = []
for (const id of links) {
const link = this.graph._links.get(id)
if (link) {
const target_node = this.graph.getNodeById(link.target_id)
if (target_node) {
r.push(target_node)
}
}
}
return r
}
addOnTriggerInput(): number {
const trigS = this.findInputSlot('onTrigger')
if (trigS == -1) {
this.addInput('onTrigger', LiteGraph.EVENT, {
nameLocked: true
})
return this.findInputSlot('onTrigger')
}
return trigS
}
addOnExecutedOutput(): number {
const trigS = this.findOutputSlot('onExecuted')
if (trigS == -1) {
this.addOutput('onExecuted', LiteGraph.ACTION, {
nameLocked: true
})
return this.findOutputSlot('onExecuted')
}
return trigS
}
onAfterExecuteNode(param: unknown, options?: { action_call?: string }) {
const trigS = this.findOutputSlot('onExecuted')
if (trigS != -1) {
this.triggerSlot(trigS, param, null, options)
}
}
changeMode(modeTo: number): boolean {
switch (modeTo) {
case LGraphEventMode.ON_EVENT:
break
case LGraphEventMode.ON_TRIGGER:
this.addOnTriggerInput()
this.addOnExecutedOutput()
break
case LGraphEventMode.NEVER:
break
case LGraphEventMode.ALWAYS:
break
// @ts-expect-error Not impl.
case LiteGraph.ON_REQUEST:
break
default:
return false
break
}
this.mode = modeTo
return true
}
/**
* Triggers the node code execution, place a boolean/counter to mark the node as being executed
*/
doExecute(param?: unknown, options?: { action_call?: string }): void {
options = options || {}
if (this.onExecute) {
// enable this to give the event an ID
options.action_call ||= `${this.id}_exec_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error Technically it works when id is a string. Array gets props.
this.graph.nodes_executing[this.id] = true
this.onExecute(param, options)
// @ts-expect-error deprecated
this.graph.nodes_executing[this.id] = false
// save execution/action ref
this.exec_version = this.graph.iteration
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
// the nFrames it will be used (-- each step), means "how old" is the event
this.execute_triggered = 2
this.onAfterExecuteNode?.(param, options)
}
/**
* Triggers an action, wrapped by logics to control execution flow
* @param action name
*/
actionDo(
action: string,
param: unknown,
options: { action_call?: string }
): void {
options = options || {}
if (this.onAction) {
// enable this to give the event an ID
options.action_call ||= `${this.id}_${action || 'action'}_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = action || 'actioning'
this.onAction(action, param, options)
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = false
// save execution/action ref
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
// the nFrames it will be used (-- each step), means "how old" is the event
this.action_triggered = 2
this.onAfterExecuteNode?.(param, options)
}
/**
* Triggers an event in this node, this will trigger any output with the same name
* @param action name ( "on_play", ... ) if action is equivalent to false then the event is send to all
*/
trigger(
action: string,
param: unknown,
options: { action_call?: string }
): void {
const { outputs } = this
if (!outputs || !outputs.length) {
return
}
if (this.graph) this.graph._last_trigger_time = LiteGraph.getTime()
for (const [i, output] of outputs.entries()) {
if (
!output ||
output.type !== LiteGraph.EVENT ||
(action && output.name != action)
) {
continue
}
this.triggerSlot(i, param, null, options)
}
}
/**
* Triggers a slot event in this node: cycle output slots and launch execute/action on connected nodes
* @param slot the index of the output slot
* @param link_id [optional] in case you want to trigger and specific output link in a slot
*/
triggerSlot(
slot: number,
param: unknown,
link_id: number | null,
options?: { action_call?: string }
): void {
options = options || {}
if (!this.outputs) return
if (slot == null) {
console.error('slot must be a number')
return
}
if (typeof slot !== 'number')
console.warn(
"slot must be a number, use node.trigger('name') if you want to use a string"
)
const output = this.outputs[slot]
if (!output) return
const links = output.links
if (!links || !links.length) return
if (!this.graph) throw new NullGraphError()
this.graph._last_trigger_time = LiteGraph.getTime()
// for every link attached here
for (const id of links) {
// to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph._links.get(id)
// not connected
if (!link_info) continue
link_info._last_time = LiteGraph.getTime()
const node = this.graph.getNodeById(link_info.target_id)
// node not found?
if (!node) continue
if (node.mode === LGraphEventMode.ON_TRIGGER) {
// generate unique trigger ID if not present
if (!options.action_call)
options.action_call = `${this.id}_trigg_${Math.floor(Math.random() * 9999)}`
// -- wrapping node.onExecute(param); --
node.doExecute?.(param, options)
} else if (node.onAction) {
// generate unique action ID if not present
if (!options.action_call)
options.action_call = `${this.id}_act_${Math.floor(Math.random() * 9999)}`
// pass the action name
const target_connection = node.inputs[link_info.target_slot]
node.actionDo(target_connection.name, param, options)
}
}
}
/**
* clears the trigger slot animation
* @param slot the index of the output slot
* @param link_id [optional] in case you want to trigger and specific output link in a slot
*/
clearTriggeredSlot(slot: number, link_id: number): void {
if (!this.outputs) return
const output = this.outputs[slot]
if (!output) return
const links = output.links
if (!links || !links.length) return
if (!this.graph) throw new NullGraphError()
// for every link attached here
for (const id of links) {
// to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph._links.get(id)
// not connected
if (!link_info) continue
link_info._last_time = 0
}
}
/**
* changes node size and triggers callback
*/
setSize(size: Size): void {
this.size = size
this.onResize?.(this.size)
}
/**
* Expands the node size to fit its content.
*/
expandToFitContent(): void {
const newSize = this.computeSize()
this.setSize([
Math.max(this.size[0], newSize[0]),
Math.max(this.size[1], newSize[1])
])
}
/**
* add a new property to this node
* @param type string defining the output type ("vec3","number",...)
* @param extra_info this can be used to have special properties of the property (like values, etc)
*/
addProperty(
name: string,
default_value: NodeProperty | undefined,
type?: string,
extra_info?: Partial<INodePropertyInfo>
): INodePropertyInfo {
const o: INodePropertyInfo = { name, type, default_value }
if (extra_info) Object.assign(o, extra_info)
this.properties_info ||= []
this.properties_info.push(o)
this.properties ||= {}
this.properties[name] = default_value
return o
}
/**
* add a new output slot to use in this node
* @param type string defining the output type ("vec3","number",...)
* @param extra_info this can be used to have special properties of an output (label, special color, position, etc)
*/
addOutput<TProperties extends Partial<INodeOutputSlot>>(
name: string,
type: ISlotType,
extra_info?: TProperties
): INodeOutputSlot & TProperties {
const output = Object.assign(
new NodeOutputSlot({ name, type, links: null }, this),
extra_info
)
this.outputs ||= []
this.outputs.push(output)
this.onOutputAdded?.(output)
if (LiteGraph.auto_load_slot_types)
LiteGraph.registerNodeAndSlotType(this, type, true)
this.expandToFitContent()
this.setDirtyCanvas(true, true)
return output
}
/**
* remove an existing output slot
*/
removeOutput(slot: number): void {
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectOutput(slot)
}
const { outputs } = this
outputs.splice(slot, 1)
for (let i = slot; i < outputs.length; ++i) {
const output = outputs[i]
if (!output || !output.links) continue
// Only update link indices if node is part of a graph
if (this.graph) {
for (const linkId of output.links) {
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
}
}
}
this.onOutputRemoved?.(slot)
this.setDirtyCanvas(true, true)
}
/**
* add a new input slot to use in this node
* @param type string defining the input type ("vec3","number",...), it its a generic one use 0
* @param extra_info this can be used to have special properties of an input (label, color, position, etc)
*/
addInput<TProperties extends Partial<INodeInputSlot>>(
name: string,
type: ISlotType,
extra_info?: TProperties
): INodeInputSlot & TProperties {
type ||= 0
const input = Object.assign(
new NodeInputSlot({ name, type, link: null }, this),
extra_info
)
this.inputs ||= []
this.inputs.push(input)
this.expandToFitContent()
this.onInputAdded?.(input)
LiteGraph.registerNodeAndSlotType(this, type)
this.setDirtyCanvas(true, true)
return input
}
/**
* remove an existing input slot
*/
removeInput(slot: number): void {
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectInput(slot, true)
}
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
for (let i = slot; i < inputs.length; ++i) {
const input = inputs[i]
if (!input?.link) continue
// Only update link indices if node is part of a graph
if (this.graph) {
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
/**
* computes the minimum size of a node according to its inputs and output slots
* @returns the total size
*/
computeSize(out?: Size): Size {
const ctorSize = this.constructor.size
if (ctorSize) return [ctorSize[0], ctorSize[1]]
const { inputs, outputs, widgets } = this
let rows = Math.max(
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
outputs ? outputs.length : 1
)
const size = out ?? [0, 0]
rows = Math.max(rows, 1)
// although it should be graphcanvas.inner_text_font size
const font_size = LiteGraph.NODE_TEXT_SIZE
const padLeft = LiteGraph.NODE_TITLE_HEIGHT
const padRight = padLeft * 0.33
const title_width =
padLeft + compute_text_size(this.title, this.titleFontStyle) + padRight
let input_width = 0
let widgetWidth = 0
let output_width = 0
if (inputs) {
for (const input of inputs) {
const text = input.label || input.localized_name || input.name || ''
const text_width = compute_text_size(text, this.innerFontStyle)
if (isWidgetInputSlot(input)) {
const widget = this.getWidgetFromSlot(input)
if (widget && !this.isWidgetVisible(widget)) continue
if (text_width > widgetWidth) widgetWidth = text_width
} else {
if (text_width > input_width) input_width = text_width
}
}
}
if (outputs) {
for (const output of outputs) {
const text = output.label || output.localized_name || output.name || ''
const text_width = compute_text_size(text, this.innerFontStyle)
if (output_width < text_width) output_width = text_width
}
}
const minWidth = LiteGraph.NODE_WIDTH * (widgets?.length ? 1.5 : 1)
// Text + slot width + centre padding
const centrePadding = input_width && output_width ? 5 : 0
const slotsWidth =
input_width +
output_width +
2 * LiteGraph.NODE_SLOT_HEIGHT +
centrePadding
// Total distance from edge of node to the inner edge of the widget 'previous' arrow button
const widgetMargin =
BaseWidget.margin + BaseWidget.arrowMargin + BaseWidget.arrowWidth
const widgetPadding = BaseWidget.minValueWidth + 2 * widgetMargin
if (widgetWidth) widgetWidth += widgetPadding
size[0] = Math.max(slotsWidth, widgetWidth, title_width, minWidth)
size[1] =
(this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT
// Get widget height & expand size if necessary
let widgets_height = 0
if (widgets?.length) {
for (const widget of widgets) {
if (!this.isWidgetVisible(widget)) continue
let widget_height = 0
if (widget.computeSize) {
widget_height += widget.computeSize(size[0])[1]
} else if (widget.computeLayoutSize) {
// Expand widget width if necessary
const { minHeight, minWidth } = widget.computeLayoutSize(this)
const widgetWidth = minWidth + widgetPadding
if (widgetWidth > size[0]) size[0] = widgetWidth
widget_height += minHeight
} else {
widget_height += LiteGraph.NODE_WIDGET_HEIGHT
}
widgets_height += widget_height + 4
}
widgets_height += 8
}
// compute height using widgets height
if (this.widgets_up) size[1] = Math.max(size[1], widgets_height)
else if (this.widgets_start_y != null)
size[1] = Math.max(size[1], widgets_height + this.widgets_start_y)
else size[1] += widgets_height
function compute_text_size(text: string, fontStyle: string) {
return (
LGraphCanvas._measureText?.(text, fontStyle) ??
font_size * (text?.length ?? 0) * 0.6
)
}
if (this.constructor.min_height && size[1] < this.constructor.min_height) {
size[1] = this.constructor.min_height
}
// margin
size[1] += 6
return size
}
inResizeCorner(canvasX: number, canvasY: number): boolean {
const rows = this.outputs ? this.outputs.length : 1
const outputs_offset =
(this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT
return isInRectangle(
canvasX,
canvasY,
this.pos[0] + this.size[0] - 15,
this.pos[1] + Math.max(this.size[1] - 15, outputs_offset),
20,
20
)
}
/**
* Returns which resize corner the point is over, if any.
* @param canvasX X position in canvas coordinates
* @param canvasY Y position in canvas coordinates
* @returns The compass corner the point is in, otherwise `undefined`.
*/
findResizeDirection(
canvasX: number,
canvasY: number
): CompassCorners | undefined {
if (this.resizable === false) return
const { boundingRect } = this
if (!boundingRect.containsXy(canvasX, canvasY)) return
// Check corners first (they take priority over edges)
return boundingRect.findContainingCorner(
canvasX,
canvasY,
LGraphNode.resizeHandleSize
)
}
/**
* returns all the info available about a property of this node.
* @param property name of the property
* @returns the object with all the available info
*/
getPropertyInfo(property: string) {
let info = null
// there are several ways to define info about a property
// legacy mode
const { properties_info } = this
if (properties_info) {
for (const propInfo of properties_info) {
if (propInfo.name == property) {
info = propInfo
break
}
}
}
// litescene mode using the constructor
// @ts-expect-error deprecated https://github.com/Comfy-Org/litegraph.js/issues/639
if (this.constructor[`@${property}`])
// @ts-expect-error deprecated https://github.com/Comfy-Org/litegraph.js/issues/639
info = this.constructor[`@${property}`]
if (this.constructor.widgets_info?.[property])
info = this.constructor.widgets_info[property]
// litescene mode using the constructor
if (!info && this.onGetPropertyInfo) {
info = this.onGetPropertyInfo(property)
}
info ||= {}
info.type ||= typeof this.properties[property]
if (info.widget == 'combo') info.type = 'enum'
return info
}
/**
* Defines a widget inside the node, it will be rendered on top of the node, you can control lots of properties
* @param type the widget type
* @param name the text to show on the widget
* @param value the default value
* @param callback function to call when it changes (optionally, it can be the name of the property to modify)
* @param options the object that contains special properties of this widget
* @returns the created widget object
*/
addWidget<
Type extends TWidgetType,
TValue extends WidgetTypeMap[Type]['value']
>(
type: Type,
name: string,
value: TValue,
callback: IBaseWidget['callback'] | string | null,
options?: IWidgetOptions | string
): WidgetTypeMap[Type] | IBaseWidget {
this.widgets ||= []
if (!options && callback && typeof callback === 'object') {
options = callback
callback = null
}
// options can be the property name
options ||= {}
if (typeof options === 'string') options = { property: options }
// callback can be the property name
if (callback && typeof callback === 'string') {
options.property = callback
callback = null
}
const w: IBaseWidget & { type: Type } = {
// @ts-expect-error - Type casting for widget type property
type: type.toLowerCase(),
name: name,
value: value,
callback: typeof callback !== 'function' ? undefined : callback,
options,
y: 0
}
if (w.options.y !== undefined) {
w.y = w.options.y
}
if (!callback && !w.options.callback && !w.options.property) {
console.warn(
'LiteGraph addWidget(...) without a callback or property assigned'
)
}
if (type == 'combo' && !w.options.values) {
throw "LiteGraph addWidget('combo',...) requires to pass values in options: { values:['red','blue'] }"
}
const widget = this.addCustomWidget(w)
this.expandToFitContent()
return widget
}
addCustomWidget<TPlainWidget extends IBaseWidget>(
custom_widget: TPlainWidget
): TPlainWidget | WidgetTypeMap[TPlainWidget['type']] {
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
// when the node is added via LGraph.add() -> node.onAdded.
if (this.id !== -1 && isNodeBindable(widget)) {
widget.setNodeId(this.id)
}
return widget
}
addTitleButton(options: LGraphButtonOptions): LGraphButton {
this.title_buttons ||= []
const button = new LGraphButton(options)
this.title_buttons.push(button)
return button
}
onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
// Dispatch event for button click
canvas.dispatch('litegraph:node-title-button-clicked', {
node: this,
button: button
})
}
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')
// Clean up slot references to prevent memory leaks
if (this.inputs) {
for (const input of this.inputs) {
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
input.pos = undefined
}
}
}
this._widgetSlotsDirty = true
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
}
ensureWidgetRemoved(widget: IBaseWidget): void {
try {
this.removeWidget(widget)
} catch (error) {
console.error('Failed to remove widget', error)
}
}
move(deltaX: number, deltaY: number): void {
if (this.pinned) return
// If Vue nodes mode is enabled, skip LiteGraph's direct position update
// The layout store will handle the movement and sync back to LiteGraph
if (LiteGraph.vueNodesMode) {
// Vue nodes handle their own dragging through the layout store
// This prevents the snap-back issue from conflicting position updates
return
}
this.pos = [this._pos[0] + deltaX, this._pos[1] + deltaY]
}
/**
* Internal method to measure the node for rendering. Prefer {@link boundingRect} where possible.
*
* Populates {@link out} with the results in graph space.
* Populates {@link _collapsed_width} with the collapsed width if the node is collapsed.
* Adjusts for title and collapsed status, but does not call {@link onBounding}.
* @param out `x, y, width, height` are written to this array.
* @param ctx The canvas context to use for measuring text.
*/
measure(out: Rect, ctx?: CanvasRenderingContext2D): void {
const titleMode = this.title_mode
const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE &&
titleMode != TitleMode.NO_TITLE
const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0
out[0] = this.pos[0]
out[1] = this.pos[1] + -titleHeight
if (!this.flags?.collapsed) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
}
}
/**
* returns the bounding of the object, used for rendering purposes
* @param out {Rect?} [optional] a place to store the output, to free garbage
* @param includeExternal {boolean?} [optional] set to true to
* include the shadow and connection points in the bounding calculation
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
*/
getBounding(out?: Rect, includeExternal?: boolean): Rect {
out ||= [0, 0, 0, 0]
const rect = includeExternal ? this.renderArea : this.boundingRect
out[0] = rect[0]
out[1] = rect[1]
out[2] = rect[2]
out[3] = rect[3]
return out
}
/**
* Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}.
* Called automatically at the start of every frame.
*/
updateArea(ctx?: CanvasRenderingContext2D): void {
const bounds = this._boundingRect
this.measure(bounds, ctx)
this.onBounding?.(bounds)
const renderArea = this._renderArea
renderArea.set(bounds)
// 4 offset for collapsed node connection points
renderArea[0] -= 4
renderArea[1] -= 4
// Add shadow & left offset
renderArea[2] += 6 + 4
// Add shadow & top offsets
renderArea[3] += 5 + 4
}
/**
* checks if a point is inside the shape of a node
*/
isPointInside(x: number, y: number): boolean {
if (isInRect(x, y, this.boundingRect)) return true
for (const badge of this.badges.map(toValue).filter((b) => b.onClick)) {
if (isInRect(x - this.pos[0], y - this.pos[1], badge.boundingRect))
return true
}
return false
}
/**
* Checks if the provided point is inside this node's collapse button area.
* @param x X co-ordinate to check
* @param y Y co-ordinate to check
* @returns true if the x,y point is in the collapse button area, otherwise false
*/
isPointInCollapse(x: number, y: number): boolean {
const squareLength = LiteGraph.NODE_TITLE_HEIGHT
return isInRectangle(
x,
y,
this.pos[0],
this.pos[1] - squareLength,
squareLength,
squareLength
)
}
/**
* Returns the input slot at the given position. Uses full 20 height, and approximates the label length.
* @param pos The graph co-ordinates to check
* @returns The input slot at the given position if found, otherwise `undefined`.
*/
getInputOnPos(pos: Point): INodeInputSlot | undefined {
return getNodeInputOnPos(this, pos[0], pos[1])?.input
}
/**
* Returns the output slot at the given position. Uses full 20x20 box for the slot.
* @param pos The graph co-ordinates to check
* @returns The output slot at the given position if found, otherwise `undefined`.
*/
getOutputOnPos(pos: Point): INodeOutputSlot | undefined {
return getNodeOutputOnPos(this, pos[0], pos[1])?.output
}
/**
* Returns the input or output slot at the given position.
*
* Tries {@link getNodeInputOnPos} first, then {@link getNodeOutputOnPos}.
* @param pos The graph co-ordinates to check
* @returns The input or output slot at the given position if found, otherwise `undefined`.
*/
getSlotOnPos(pos: Point): INodeInputSlot | INodeOutputSlot | undefined {
if (!isPointInRect(pos, this.boundingRect)) return
return this.getInputOnPos(pos) ?? this.getOutputOnPos(pos)
}
/**
* @deprecated Use {@link getSlotOnPos} instead.
* checks if a point is inside a node slot, and returns info about which slot
* @param x
* @param y
* @returns if found the object contains { input|output: slot object, slot: number, link_pos: [x,y] }
*/
getSlotInPosition(x: number, y: number): IFoundSlot | null {
// search for inputs
const { inputs, outputs } = this
if (inputs) {
for (const [i, input] of inputs.entries()) {
const pos = this.getInputPos(i)
if (isInRectangle(x, y, pos[0] - 10, pos[1] - 10, 20, 20)) {
return { input, slot: i, link_pos: pos }
}
}
}
if (outputs) {
for (const [i, output] of outputs.entries()) {
const pos = this.getOutputPos(i)
if (isInRectangle(x, y, pos[0] - 10, pos[1] - 10, 20, 20)) {
return { output, slot: i, link_pos: pos }
}
}
}
return null
}
/**
* Gets the widget on this node at the given co-ordinates.
* @param canvasX X co-ordinate in graph space
* @param canvasY Y co-ordinate in graph space
* @returns The widget found, otherwise `null`
*/
getWidgetOnPos(
canvasX: number,
canvasY: number,
includeDisabled = false
): IBaseWidget | undefined {
const { widgets, pos, size } = this
if (!widgets?.length) return
const x = canvasX - pos[0]
const y = canvasY - pos[1]
const nodeWidth = size[0]
for (const widget of widgets) {
if (
(widget.computedDisabled && !includeDisabled) ||
!this.isWidgetVisible(widget)
) {
continue
}
const h =
widget.computedHeight ??
widget.computeSize?.(nodeWidth)[1] ??
LiteGraph.NODE_WIDGET_HEIGHT
const maybeDOMWidget = widget as { margin?: number }
const mtop = maybeDOMWidget.margin ?? -2
const mbot = maybeDOMWidget.margin ?? 2
const mx = maybeDOMWidget.margin ?? 6
const w = widget.width || nodeWidth
if (
widget.last_y !== undefined &&
isInRectangle(
x,
y,
mx,
widget.last_y + mtop,
w - 2 * mx,
h - mtop - mbot
)
) {
return widget
}
}
}
/**
* Returns the input slot with a given name (used for dynamic slots), -1 if not found
* @param name the name of the slot to find
* @param returnObj if the obj itself wanted
* @returns the slot (-1 if not found)
*/
findInputSlot<TReturn extends false>(
name: string,
returnObj?: TReturn
): number
findInputSlot<TReturn extends true>(
name: string,
returnObj?: TReturn
): INodeInputSlot
findInputSlot(name: string, returnObj: boolean = false) {
const { inputs } = this
if (!inputs) return -1
for (const [i, input] of inputs.entries()) {
if (name == input.name) {
return !returnObj ? i : input
}
}
return -1
}
/**
* returns the output slot with a given name (used for dynamic slots), -1 if not found
* @param name the name of the slot to find
* @param returnObj if the obj itself wanted
* @returns the slot (-1 if not found)
*/
findOutputSlot<TReturn extends false>(
name: string,
returnObj?: TReturn
): number
findOutputSlot<TReturn extends true>(
name: string,
returnObj?: TReturn
): INodeOutputSlot
findOutputSlot(name: string, returnObj: boolean = false) {
const { outputs } = this
if (!outputs) return -1
for (const [i, output] of outputs.entries()) {
if (name == output.name) {
return !returnObj ? i : output
}
}
return -1
}
/**
* Finds the first free input slot.
* @param optsIn
* @returns The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found.
*/
findInputSlotFree<TReturn extends false>(
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): number
findInputSlotFree<TReturn extends true>(
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeInputSlot | -1
findInputSlotFree(optsIn?: FindFreeSlotOptions) {
return this._findFreeSlot(this.inputs, optsIn)
}
/**
* Finds the first free output slot.
* @param optsIn
* @returns The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found.
*/
findOutputSlotFree<TReturn extends false>(
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): number
findOutputSlotFree<TReturn extends true>(
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeOutputSlot | -1
findOutputSlotFree(optsIn?: FindFreeSlotOptions) {
return this._findFreeSlot(this.outputs, optsIn)
}
/**
* Finds the next free slot
* @param slots The slots to search, i.e. this.inputs or this.outputs
*/
private _findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
options?: FindFreeSlotOptions
): TSlot | number {
const defaults = {
returnObj: false,
typesNotAccepted: []
}
const opts = Object.assign(defaults, options || {})
const length = slots?.length
if (!(length > 0)) return -1
for (let i = 0; i < length; ++i) {
const slot: TSlot & IGenericLinkOrLinks = slots[i]
if (!slot || slot.link || slot.links?.length) continue
if (opts.typesNotAccepted?.includes?.(slot.type)) continue
return !opts.returnObj ? i : slot
}
return -1
}
/**
* findSlotByType for INPUTS
*/
findInputSlotByType<TReturn extends false>(
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): number
findInputSlotByType<TReturn extends true>(
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): INodeInputSlot
findInputSlotByType(
type: ISlotType,
returnObj?: boolean,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this._findSlotByType(
this.inputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
}
/**
* findSlotByType for OUTPUTS
*/
findOutputSlotByType<TReturn extends false>(
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): number
findOutputSlotByType<TReturn extends true>(
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): INodeOutputSlot
findOutputSlotByType(
type: ISlotType,
returnObj?: boolean,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this._findSlotByType(
this.outputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
}
/**
* returns the output (or input) slot with a given type, -1 if not found
* @param input use inputs instead of outputs
* @param type the type of the slot to find
* @param returnObj if the obj itself wanted
* @param preferFreeSlot if we want a free slot (if not found, will return the first of the type anyway)
* @returns the slot (-1 if not found)
*/
findSlotByType<TSlot extends true | false, TReturn extends false>(
input: TSlot,
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): number
findSlotByType<TSlot extends true, TReturn extends true>(
input: TSlot,
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): INodeInputSlot | -1
findSlotByType<TSlot extends false, TReturn extends true>(
input: TSlot,
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): INodeOutputSlot | -1
findSlotByType(
input: boolean,
type: ISlotType,
returnObj?: boolean,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): number | INodeOutputSlot | INodeInputSlot {
return input
? this._findSlotByType(
this.inputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
: this._findSlotByType(
this.outputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
}
/**
* Finds a matching slot from those provided, returning the slot itself or its index in {@link slots}.
* @param slots Slots to search (this.inputs or this.outputs)
* @param type Type of slot to look for
* @param returnObj If true, returns the slot itself. Otherwise, the index.
* @param preferFreeSlot Prefer a free slot, but if none are found, fall back to an occupied slot.
* @param doNotUseOccupied Do not fall back to occupied slots.
* @see {findSlotByType}
* @see {findOutputSlotByType}
* @see {findInputSlotByType}
* @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1
*/
private _findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
type: ISlotType,
returnObj?: boolean,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
): TSlot | number {
const length = slots?.length
if (!length) return -1
// Empty string and * match anything (type: 0)
if (type == '' || type == '*') type = 0
const sourceTypes = String(type).toLowerCase().split(',')
// Run the search
let occupiedSlot: number | TSlot | null = null
for (let i = 0; i < length; ++i) {
const slot: TSlot & IGenericLinkOrLinks = slots[i]
const destTypes =
slot.type == '0' || slot.type == '*'
? ['0']
: String(slot.type).toLowerCase().split(',')
for (const sourceType of sourceTypes) {
// TODO: Remove _event_ entirely.
const source = sourceType == '_event_' ? LiteGraph.EVENT : sourceType
for (const destType of destTypes) {
const dest = destType == '_event_' ? LiteGraph.EVENT : destType
if (source == dest || source === '*' || dest === '*') {
if (preferFreeSlot && (slot.links?.length || slot.link != null)) {
// In case we can't find a free slot.
occupiedSlot ??= returnObj ? slot : i
continue
}
return returnObj ? slot : i
}
}
}
}
return doNotUseOccupied ? -1 : (occupiedSlot ?? -1)
}
/**
* Determines the slot index to connect to when attempting to connect by type.
* @param findInputs If true, searches for an input. Otherwise, an output.
* @param node The node at the other end of the connection.
* @param slotType The type of slot at the other end of the connection.
* @param options Search restrictions to adhere to.
* @see {connectByType}
* @see {connectByTypeOutput}
*/
findConnectByTypeSlot(
findInputs: boolean,
node: LGraphNode,
slotType: ISlotType,
options?: ConnectByTypeOptions
): number | undefined {
// LEGACY: Old options names
if (options && typeof options === 'object') {
if ('firstFreeIfInputGeneralInCase' in options)
options.wildcardToTyped = !!options.firstFreeIfInputGeneralInCase
if ('firstFreeIfOutputGeneralInCase' in options)
options.wildcardToTyped = !!options.firstFreeIfOutputGeneralInCase
if ('generalTypeInCase' in options)
options.typedToWildcard = !!options.generalTypeInCase
}
const optsDef: ConnectByTypeOptions = {
createEventInCase: true,
wildcardToTyped: true,
typedToWildcard: true
}
const opts = Object.assign(optsDef, options)
if (!this.graph) throw new NullGraphError()
if (node && typeof node === 'number') {
const nodeById = this.graph.getNodeById(node)
if (!nodeById) return
node = nodeById
}
const slot = node.findSlotByType(findInputs, slotType, false, true)
if (slot >= 0 && slot !== null) return slot
// TODO: Remove or reimpl. events. WILL CREATE THE onTrigger IN SLOT
if (opts.createEventInCase && slotType == LiteGraph.EVENT) {
if (findInputs) return -1
if (LiteGraph.do_add_triggers_slots) return node.addOnExecutedOutput()
}
// connect to the first general output slot if not found a specific type and
if (opts.typedToWildcard) {
const generalSlot = node.findSlotByType(findInputs, 0, false, true, true)
if (generalSlot >= 0) return generalSlot
}
// connect to the first free input slot if not found a specific type and this output is general
if (
opts.wildcardToTyped &&
(slotType == 0 || slotType == '*' || slotType == '')
) {
const opt = { typesNotAccepted: [LiteGraph.EVENT] }
const nonEventSlot = findInputs
? node.findInputSlotFree(opt)
: node.findOutputSlotFree(opt)
if (nonEventSlot >= 0) return nonEventSlot
}
}
/**
* Finds the first free output slot with any of the comma-delimited types in {@link type}.
*
* If no slots are free, falls back in order to:
* - The first free wildcard slot
* - The first occupied slot
* - The first occupied wildcard slot
* @param type The {@link ISlotType type} of slot to find
* @returns The index and slot if found, otherwise `undefined`.
*/
findOutputByType(
type: ISlotType
): { index: number; slot: INodeOutputSlot } | undefined {
return findFreeSlotOfType(
this.outputs,
type,
(output) => !output.links?.length
)
}
/**
* Finds the first free input slot with any of the comma-delimited types in {@link type}.
*
* If no slots are free, falls back in order to:
* - The first free wildcard slot
* - The first occupied slot
* - The first occupied wildcard slot
* @param type The {@link ISlotType type} of slot to find
* @returns The index and slot if found, otherwise `undefined`.
*/
findInputByType(
type: ISlotType
): { index: number; slot: INodeInputSlot } | undefined {
return findFreeSlotOfType(
this.inputs,
type,
(input) =>
input.link == null || !!this.graph?.getLink(input.link)?._dragging
)
}
/**
* connect this node output to the input of another node BY TYPE
* @param slot (could be the number of the slot or the string with the name of the slot)
* @param target_node the target node
* @param target_slotType the input slot type of the target node
* @returns the link_info is created, otherwise null
*/
connectByType(
slot: number | string,
target_node: LGraphNode,
target_slotType: ISlotType,
optsIn?: ConnectByTypeOptions
): LLink | null {
const slotIndex = this.findConnectByTypeSlot(
true,
target_node,
target_slotType,
optsIn
)
if (slotIndex !== undefined)
return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId)
// No compatible slot found - connection not possible
return null
}
/**
* connect this node input to the output of another node BY TYPE
* @param slot (could be the number of the slot or the string with the name of the slot)
* @param source_node the target node
* @param source_slotType the output slot type of the target node
* @returns the link_info is created, otherwise null
*/
connectByTypeOutput(
slot: number | string,
source_node: LGraphNode,
source_slotType: ISlotType,
optsIn?: ConnectByTypeOptions
): LLink | null {
// LEGACY: Old options names
if (typeof optsIn === 'object') {
if ('firstFreeIfInputGeneralInCase' in optsIn)
optsIn.wildcardToTyped = !!optsIn.firstFreeIfInputGeneralInCase
if ('generalTypeInCase' in optsIn)
optsIn.typedToWildcard = !!optsIn.generalTypeInCase
}
const slotIndex = this.findConnectByTypeSlot(
false,
source_node,
source_slotType,
optsIn
)
if (slotIndex !== undefined)
return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId)
console.error(
'[connectByType]: no way to connect type:',
source_slotType,
'to node:',
source_node
)
return null
}
canConnectTo(
node: NodeLike,
toSlot: INodeInputSlot | SubgraphIO,
fromSlot: INodeOutputSlot | SubgraphIO
) {
return (
this.id !== node.id &&
LiteGraph.isValidConnection(fromSlot.type, toSlot.type)
)
}
/**
* Connect an output of this node to an input of another node
* @param slot (could be the number of the slot or the string with the name of the slot)
* @param target_node the target node
* @param target_slot the input slot of the target node (could be the number of the slot or the string with the name of the slot, or -1 to connect a trigger)
* @returns the link_info is created, otherwise null
*/
connect(
slot: number | string,
target_node: LGraphNode,
target_slot: ISlotType,
afterRerouteId?: RerouteId
): LLink | null {
// Allow legacy API support for searching target_slot by string, without mutating the input variables
let targetIndex: number | null
const { graph, outputs } = this
if (!graph) {
// could be connected before adding it to a graph
// due to link ids being associated with graphs
console.error(
"Connect: Error, node doesn't belong to any graph. Nodes must be added first to a graph before connecting them."
)
return null
}
// seek for the output slot
if (typeof slot === 'string') {
slot = this.findOutputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.error(`Connect: Error, no slot of name ${slot}`)
return null
}
} else if (!outputs || slot >= outputs.length) {
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return null
}
if (target_node && typeof target_node === 'number') {
const nodeById = graph.getNodeById(target_node)
if (!nodeById) throw 'target node is null'
target_node = nodeById
}
if (!target_node) throw 'target node is null'
// avoid loopback
if (target_node == this) return null
// you can specify the slot by name
if (typeof target_slot === 'string') {
targetIndex = target_node.findInputSlot(target_slot)
if (targetIndex == -1) {
if (LiteGraph.debug)
console.error(`Connect: Error, no slot of name ${targetIndex}`)
return null
}
} else if (target_slot === LiteGraph.EVENT) {
// TODO: Events
if (LiteGraph.do_add_triggers_slots) {
target_node.changeMode(LGraphEventMode.ON_TRIGGER)
targetIndex = target_node.findInputSlot('onTrigger')
} else {
return null
}
} else if (typeof target_slot === 'number') {
targetIndex = target_slot
} else {
targetIndex = 0
}
// Allow target node to change slot
if (target_node.onBeforeConnectInput) {
// This way node can choose another slot (or make a new one?)
const requestedIndex = target_node.onBeforeConnectInput(
targetIndex,
target_slot
)
targetIndex = typeof requestedIndex === 'number' ? requestedIndex : null
}
if (
targetIndex === null ||
!target_node.inputs ||
targetIndex >= target_node.inputs.length
) {
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return null
}
const input = target_node.inputs[targetIndex]
const output = outputs[slot]
if (!output) return null
if (output.links?.length) {
if (
output.type === LiteGraph.EVENT &&
!LiteGraph.allow_multi_output_for_events
) {
graph.beforeChange()
this.disconnectOutput(slot)
}
}
const link = this.connectSlots(output, target_node, input, afterRerouteId)
return link ?? null
}
/**
* Connect two slots between two nodes
* @param output The output slot to connect
* @param inputNode The node that the input slot is on
* @param input The input slot to connect
* @param afterRerouteId The reroute ID to use for the link
* @returns The link that was created, or null if the connection was blocked
*/
connectSlots(
output: INodeOutputSlot,
inputNode: LGraphNode,
input: INodeInputSlot,
afterRerouteId: RerouteId | undefined
): LLink | null | undefined {
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
return
}
const inputIndex = inputNode.inputs.indexOf(input)
if (inputIndex === -1) {
console.warn('connectSlots: input not found')
return
}
// check targetSlot and check connection types
if (!LiteGraph.isValidConnection(output.type, input.type)) {
this.setDirtyCanvas(false, true)
return null
}
// Allow nodes to block connection
if (
inputNode.onConnectInput?.(
inputIndex,
output.type,
output,
this,
outputIndex
) === false
)
return null
if (
this.onConnectOutput?.(
outputIndex,
input.type,
input,
inputNode,
inputIndex
) === false
)
return null
// if there is something already plugged there, disconnect
if (inputNode.inputs[inputIndex]?.link != null) {
graph.beforeChange()
inputNode.disconnectInput(inputIndex, true)
}
const maybeCommonType =
input.type && output.type && commonType(input.type, output.type)
const link = new LLink(
++graph.state.lastLinkId,
maybeCommonType || input.type || output.type,
this.id,
outputIndex,
inputNode.id,
inputIndex,
afterRerouteId
)
// add to graph links list
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
// connect in output
output.links ??= []
output.links.push(link.id)
// connect in input
const targetInput = inputNode.inputs[inputIndex]
targetInput.link = link.id
if (targetInput.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: inputNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) reroute.floating = undefined
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = graph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
graph.removeFloatingLink(link)
}
}
}
graph._version++
// link has been created now, so its updated
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
link,
output
)
inputNode.onConnectionsChange?.(
NodeSlotType.INPUT,
inputIndex,
true,
link,
input
)
this.setDirtyCanvas(false, true)
graph.afterChange()
return link
}
connectFloatingReroute(
pos: Point,
slot: INodeInputSlot | INodeOutputSlot,
afterRerouteId?: RerouteId
): Reroute {
const { graph, id } = this
if (!graph) throw new NullGraphError()
// Assertion: It's either there or it isn't.
const inputIndex = this.inputs.indexOf(slot as INodeInputSlot)
const outputIndex = this.outputs.indexOf(slot as INodeOutputSlot)
if (inputIndex === -1 && outputIndex === -1) throw new Error('Invalid slot')
const slotType = outputIndex === -1 ? 'input' : 'output'
const reroute = graph.setReroute({
pos,
parentId: afterRerouteId,
linkIds: [],
floating: { slotType }
})
const parentReroute = graph.getReroute(afterRerouteId)
const fromLastFloatingReroute =
parentReroute?.floating?.slotType === 'output'
// Adding from an output, or a floating reroute that is NOT the tip of an existing floating chain
if (afterRerouteId == null || !fromLastFloatingReroute) {
const link = new LLink(
-1,
slot.type,
outputIndex === -1 ? -1 : id,
outputIndex,
inputIndex === -1 ? -1 : id,
inputIndex
)
link.parentId = reroute.id
graph.addFloatingLink(link)
return reroute
}
// Adding a new floating reroute from the tip of a floating chain.
if (!parentReroute)
throw new Error('[connectFloatingReroute] Parent reroute not found')
const link = parentReroute.getFloatingLinks('output')?.[0]
if (!link)
throw new Error('[connectFloatingReroute] Floating link not found')
reroute.floatingLinkIds.add(link.id)
link.parentId = reroute.id
parentReroute.floating = undefined
return reroute
}
/**
* disconnect one output to an specific node
* @param slot (could be the number of the slot or the string with the name of the slot)
* @param target_node the target node to which this slot is connected [Optional,
* if not target_node is specified all nodes will be disconnected]
* @returns if it was disconnected successfully
*/
disconnectOutput(slot: string | number, target_node?: LGraphNode): boolean {
if (typeof slot === 'string') {
slot = this.findOutputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.error(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.outputs || slot >= this.outputs.length) {
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return false
}
// get output slot
const output = this.outputs[slot]
if (!output) return false
if (output._floatingLinks) {
for (const link of output._floatingLinks) {
if (link.hasOrigin(this.id, slot)) {
this.graph?.removeFloatingLink(link)
}
}
}
if (!output.links || output.links.length == 0) return false
const { links } = output
// one of the output links in this slot
const graph = this.graph
if (!graph) throw new NullGraphError()
if (target_node) {
const target =
typeof target_node === 'number'
? graph.getNodeById(target_node)
: target_node
if (!target) throw 'Target Node not found'
for (const [i, link_id] of links.entries()) {
const link_info = graph._links.get(link_id)
if (link_info?.target_id != target.id) continue
// is the link we are searching for...
// remove here
links.splice(i, 1)
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
graph._version++
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
break
}
} else {
// all the links in this output slot
for (const link_id of links) {
const link_info = graph._links.get(link_id)
if (!link_info) continue
if (
link_info.target_id === SUBGRAPH_OUTPUT_ID &&
graph instanceof Subgraph
) {
const targetSlot = graph.outputNode.slots[link_info.target_slot]
if (targetSlot) {
targetSlot.linkIds.length = 0
} else {
console.error('Missing subgraphOutput slot when disconnecting link')
}
}
const target = graph.getNodeById(link_info.target_id)
graph._version++
if (target) {
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
}
output.links = null
}
this.setDirtyCanvas(false, true)
return true
}
/**
* Disconnect one input
* @param slot Input slot index, or the name of the slot
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @returns true if disconnected successfully or already disconnected, otherwise false
*/
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
// Allow search by string
if (typeof slot === 'string') {
slot = this.findInputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.error(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.inputs || slot >= this.inputs.length) {
if (LiteGraph.debug) {
console.error('Connect: Error, slot number not found')
}
return false
}
const input = this.inputs[slot]
if (!input) {
console.error('disconnectInput: input not found', slot, this.inputs)
return false
}
const { graph } = this
if (!graph) throw new NullGraphError()
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
graph.removeFloatingLink(link)
}
}
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
// remove other side
const link_info = graph._links.get(link_id)
if (link_info) {
// Let SubgraphInput do the disconnect.
if (link_info.origin_id === -10 && 'inputNode' in graph) {
graph.inputNode._disconnectNodeInput(this, input, link_info)
return true
}
const target_node = graph.getNodeById(link_info.origin_id)
if (!target_node) {
console.error(
'disconnectInput: output not found',
link_info.origin_slot
)
return false
}
const output = target_node.outputs[link_info.origin_slot]
if (!output?.links?.length) {
// Output not found - may have been removed
return false
}
// search in the inputs list for this link
let i = 0
for (const l = output.links.length; i < l; i++) {
if (output.links[i] == link_id) {
output.links.splice(i, 1)
break
}
}
link_info.disconnect(graph, keepReroutes ? 'output' : undefined)
if (graph) graph._version++
this.onConnectionsChange?.(
NodeSlotType.INPUT,
slot,
false,
link_info,
input
)
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
false,
link_info,
output
)
}
}
this.setDirtyCanvas(false, true)
return true
}
/**
* @deprecated Use {@link getInputPos} or {@link getOutputPos} instead.
* returns the center of a connection point in canvas coords
* @param is_input true if if a input slot, false if it is an output
* @param slot_number (could be the number of the slot or the string with the name of the slot)
* @param out [optional] a place to store the output, to free garbage
* @returns the position
*/
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
out ||= [0, 0]
const {
pos: [nodeX, nodeY],
inputs,
outputs
} = this
if (this.flags.collapsed) {
const w = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[0] = is_input ? nodeX : nodeX + w
out[1] = nodeY - LiteGraph.NODE_TITLE_HEIGHT * 0.5
return out
}
// weird feature that never got finished
if (is_input && slot_number == -1) {
out[0] = nodeX + LiteGraph.NODE_TITLE_HEIGHT * 0.5
out[1] = nodeY + LiteGraph.NODE_TITLE_HEIGHT * 0.5
return out
}
// hard-coded pos
const inputPos = inputs?.[slot_number]?.pos
const outputPos = outputs?.[slot_number]?.pos
if (is_input && inputPos) {
out[0] = nodeX + inputPos[0]
out[1] = nodeY + inputPos[1]
return out
} else if (!is_input && outputPos) {
out[0] = nodeX + outputPos[0]
out[1] = nodeY + outputPos[1]
return out
}
// default vertical slots
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotIndex = is_input
? this._defaultVerticalInputs.indexOf(this.inputs[slot_number])
: this._defaultVerticalOutputs.indexOf(this.outputs[slot_number])
out[0] = is_input ? nodeX + offset : nodeX + this.size[0] + 1 - offset
out[1] =
nodeY +
(slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
(this.constructor.slot_start_y || 0)
return out
}
/**
* @internal The inputs that are not positioned with absolute coordinates.
*/
private get _defaultVerticalInputs() {
return this.inputs.filter(
(slot) => !slot.pos && !(this.widgets?.length && isWidgetInputSlot(slot))
)
}
/**
* @internal The outputs that are not positioned with absolute coordinates.
*/
private get _defaultVerticalOutputs() {
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
/**
* Get the context needed for slot position calculations
* @internal
*/
private _getSlotPositionContext(): SlotPositionContext {
return {
nodeX: this.pos[0],
nodeY: this.pos[1],
nodeWidth: this.size[0],
nodeHeight: this.size[1],
collapsed: this.flags.collapsed ?? false,
collapsedWidth: this._collapsed_width,
slotStartY: this.constructor.slot_start_y,
inputs: this.inputs,
outputs: this.outputs,
widgets: this.widgets
}
}
/**
* Gets the position of an input slot, in graph co-ordinates.
*
* This method is preferred over the legacy {@link getConnectionPos} method.
* @param slot Input slot index
* @returns Position of the input slot
*/
getInputPos(slot: number): Point {
return getSlotPosition(this, slot, true)
}
/**
* Gets the position of an input slot, in graph co-ordinates.
* @param input The actual node input object
* @returns Position of the centre of the input slot in graph co-ordinates.
*/
getInputSlotPos(input: INodeInputSlot): Point {
return calculateInputSlotPosFromSlot(this._getSlotPositionContext(), input)
}
/**
* Gets the position of an output slot, in graph co-ordinates.
*
* This method is preferred over the legacy {@link getConnectionPos} method.
* @param outputSlotIndex Output slot index
* @returns Position of the output slot
*/
getOutputPos(outputSlotIndex: number): Point {
return getSlotPosition(this, outputSlotIndex, false)
}
/**
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
getSlotPosition(slotIndex: number, isInput: boolean): Point {
return getSlotPosition(this, slotIndex, isInput)
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)
}
/** @see {@link snapToGrid} */
alignToGrid(): void {
this.snapToGrid(LiteGraph.CANVAS_GRID_SIZE)
}
/* Console output */
trace(msg: string): void {
this.console ||= []
this.console.push(msg)
// @ts-expect-error deprecated
if (this.console.length > LGraphNode.MAX_CONSOLE) this.console.shift()
}
/* Forces to redraw or the main canvas (LGraphNode) or the bg canvas (links) */
setDirtyCanvas(dirty_foreground: boolean, dirty_background?: boolean): void {
this.graph?.canvasAction((c) =>
c.setDirty(dirty_foreground, dirty_background)
)
}
loadImage(url: string): HTMLImageElement {
interface AsyncImageElement extends HTMLImageElement {
ready?: boolean
}
const img: AsyncImageElement = new Image()
img.src = LiteGraph.node_images_path + url
img.ready = false
const dirty = () => this.setDirtyCanvas(true)
img.addEventListener('load', function (this: AsyncImageElement) {
this.ready = true
dirty()
})
return img
}
/**
* Allows to get onMouseMove and onMouseUp events even if the mouse is out of focus
* @deprecated Use {@link LGraphCanvas.pointer} instead.
*/
captureInput(v: boolean): void {
warnDeprecated(
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
)
if (!this.graph || !this.graph.list_of_graphcanvas) return
const list = this.graph.list_of_graphcanvas
for (const c of list) {
// releasing somebody elses capture?!
if (!v && c.node_capturing_input != this) continue
// change
c.node_capturing_input = v ? this : null
}
}
get collapsed() {
return !!this.flags.collapsed
}
get collapsible() {
return !this.pinned && this.constructor.collapsable !== false
}
/**
* Toggle node collapse (makes it smaller on the canvas)
*/
collapse(force?: boolean): void {
if (!this.collapsible && !force) return
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.flags.collapsed = !this.flags.collapsed
this.setDirtyCanvas(true, true)
}
/**
* Toggles advanced mode of the node, showing advanced widgets
*/
toggleAdvanced() {
if (!this.hasAdvancedWidgets()) return
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.showAdvanced = !this.showAdvanced
this.expandToFitContent()
this.setDirtyCanvas(true, true)
}
get pinned() {
return !!this.flags.pinned
}
/**
* Prevents the node being accidentally moved or resized by mouse interaction.
* Toggles pinned state if no value is provided.
*/
pin(v?: boolean): void {
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.flags.pinned = v ?? !this.flags.pinned
this.resizable = !this.pinned
if (!this.pinned) this.flags.pinned = undefined
}
unpin(): void {
this.pin(false)
}
localToScreen(x: number, y: number, dragAndScale: DragAndScale): Point {
return [
(x + this.pos[0]) * dragAndScale.scale + dragAndScale.offset[0],
(y + this.pos[1]) * dragAndScale.scale + dragAndScale.offset[1]
]
}
get width() {
return this.collapsed
? this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
: this.size[0]
}
/**
* Returns the height of the node, including the title bar.
*/
get height() {
return LiteGraph.NODE_TITLE_HEIGHT + this.bodyHeight
}
/**
* Returns the height of the node, excluding the title bar.
*/
get bodyHeight() {
return this.collapsed ? 0 : this.size[1]
}
drawBadges(ctx: CanvasRenderingContext2D, { gap = 2 } = {}): void {
const badgeInstances = this.badges.map((badge) =>
badge instanceof LGraphBadge ? badge : badge()
)
const isLeftAligned = this.badgePosition === BadgePosition.TopLeft
let currentX = isLeftAligned
? 0
: this.width -
badgeInstances.reduce(
(acc, badge) => acc + badge.getWidth(ctx) + gap,
0
)
const y = -(LiteGraph.NODE_TITLE_HEIGHT + gap)
for (const badge of badgeInstances) {
badge.draw(ctx, currentX, y - badge.height)
currentX += badge.getWidth(ctx) + gap
}
}
/**
* Renders the node's title bar background
*/
drawTitleBarBackground(
ctx: CanvasRenderingContext2D,
{
scale,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
low_quality = false
}: DrawTitleOptions
): void {
const fgcolor = this.renderingColor
const shape = this.renderingShape
const size = this.renderingSize
if (this.onDrawTitleBar) {
this.onDrawTitleBar(ctx, title_height, size, scale, fgcolor)
return
}
if (this.title_mode === TitleMode.TRANSPARENT_TITLE) {
return
}
if (this.collapsed) {
ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR
}
ctx.fillStyle = this.constructor.title_color || fgcolor
ctx.beginPath()
if (shape == RenderShape.BOX || low_quality) {
ctx.rect(0, -title_height, size[0], title_height)
} else if (shape == RenderShape.ROUND || shape == RenderShape.CARD) {
ctx.roundRect(
0,
-title_height,
size[0],
title_height,
this.collapsed
? [LiteGraph.ROUND_RADIUS]
: [LiteGraph.ROUND_RADIUS, LiteGraph.ROUND_RADIUS, 0, 0]
)
}
ctx.fill()
ctx.shadowColor = 'transparent'
}
/**
* Renders the node's title box, i.e. the dot in front of the title text that
* when clicked toggles the node's collapsed state. The term `title box` comes
* from the original LiteGraph implementation.
*/
drawTitleBox(
ctx: CanvasRenderingContext2D,
{
scale,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
box_size = 10
}: DrawTitleBoxOptions
): void {
const size = this.renderingSize
const shape = this.renderingShape
if (this.onDrawTitleBox) {
this.onDrawTitleBox(ctx, title_height, size, scale)
return
}
if (
[RenderShape.ROUND, RenderShape.CIRCLE, RenderShape.CARD].includes(shape)
) {
if (low_quality) {
ctx.fillStyle = 'black'
ctx.beginPath()
ctx.arc(
title_height * 0.5,
title_height * -0.5,
box_size * 0.5 + 1,
0,
Math.PI * 2
)
ctx.fill()
}
ctx.fillStyle = this.renderingBoxColor
if (low_quality) {
ctx.fillRect(
title_height * 0.5 - box_size * 0.5,
title_height * -0.5 - box_size * 0.5,
box_size,
box_size
)
} else {
ctx.beginPath()
ctx.arc(
title_height * 0.5,
title_height * -0.5,
box_size * 0.5,
0,
Math.PI * 2
)
ctx.fill()
}
} else {
if (low_quality) {
ctx.fillStyle = 'black'
ctx.fillRect(
(title_height - box_size) * 0.5 - 1,
(title_height + box_size) * -0.5 - 1,
box_size + 2,
box_size + 2
)
}
ctx.fillStyle = this.renderingBoxColor
ctx.fillRect(
(title_height - box_size) * 0.5,
(title_height + box_size) * -0.5,
box_size,
box_size
)
}
}
/**
* Renders the node's title text.
*/
drawTitleText(
ctx: CanvasRenderingContext2D,
{
scale,
default_title_color,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT
}: DrawTitleTextOptions
): void {
const size = this.renderingSize
const selected = this.selected
if (this.onDrawTitleText) {
this.onDrawTitleText(
ctx,
title_height,
size,
scale,
this.titleFontStyle,
selected
)
return
}
// Don't render title text if low quality
if (low_quality) {
return
}
ctx.font = this.titleFontStyle
const rawTitle = this.getTitle() ?? `${this.type}`
const title = String(rawTitle) + (this.pinned ? '📌' : '')
if (title) {
if (selected) {
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
} else {
ctx.fillStyle = this.constructor.title_text_color || default_title_color
}
// Calculate available width for title
let availableWidth = size[0] - title_height * 2 // Basic margins
// Subtract space for title buttons
if (this.title_buttons?.length > 0) {
let buttonsWidth = 0
const savedFont = ctx.font // Save current font
for (const button of this.title_buttons) {
if (button.visible) {
buttonsWidth += button.getWidth(ctx) + 2 // button width + gap
}
}
ctx.font = savedFont // Restore font after button measurements
if (buttonsWidth > 0) {
buttonsWidth -= 20 // Reduce by empty padding
availableWidth -= buttonsWidth
}
}
// Truncate title if needed
let displayTitle = title
if (this.collapsed) {
// For collapsed nodes, limit to 20 chars as before
displayTitle = title.substr(0, 20)
} else if (availableWidth > 0) {
// For regular nodes, truncate based on available width
displayTitle = truncateText(ctx, title, availableWidth)
}
ctx.textAlign = 'left'
ctx.fillText(
displayTitle,
title_height,
LiteGraph.NODE_TITLE_TEXT_Y - title_height
)
}
}
/**
* Attempts to gracefully bypass this node in all of its connections by reconnecting all links.
*
* Each input is checked against each output. This is done on a matching index basis, i.e. input 3 -> output 3.
* If there are any input links remaining,
* and {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `true`,
* each input will check for outputs that match, and take the first one that matches
* `true`: Try the index matching first, then every input to every output.
* `false`: Only matches indexes, e.g. input 3 to output 3.
*
* If {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `undefined`, it will fall back to
* the static {@link keepAllLinksOnBypass}.
* @returns `true` if any new links were established, otherwise `false`.
* @todo Decision: Change API to return array of new links instead?
*/
connectInputToOutput(): boolean | undefined {
const { inputs, outputs, graph } = this
if (!inputs || !outputs) return
if (!graph) throw new NullGraphError()
const { _links } = graph
let madeAnyConnections = false
// First pass: only match exactly index-to-index
for (const [index, input] of inputs.entries()) {
if (input.link == null) continue
const output = outputs[index]
if (!output || !LiteGraph.isValidConnection(input.type, output.type))
continue
const inLink = _links.get(input.link)
if (!inLink) continue
const inNode = graph.getNodeById(inLink?.origin_id)
if (!inNode) continue
bypassAllLinks(output, inNode, inLink, graph)
}
// Configured to only use index-to-index matching
if (!(this.flags.keepAllLinksOnBypass ?? LGraphNode.keepAllLinksOnBypass))
return madeAnyConnections
// Second pass: match any remaining links
for (const input of inputs) {
if (input.link == null) continue
const inLink = _links.get(input.link)
if (!inLink) continue
const inNode = graph.getNodeById(inLink?.origin_id)
if (!inNode) continue
for (const output of outputs) {
if (!LiteGraph.isValidConnection(input.type, output.type)) continue
bypassAllLinks(output, inNode, inLink, graph)
break
}
}
return madeAnyConnections
function bypassAllLinks(
output: INodeOutputSlot,
inNode: LGraphNode,
inLink: LLink,
graph: LGraph
) {
const outLinks = output.links
?.map((x) => _links.get(x))
.filter((x) => !!x)
if (!outLinks?.length) return
for (const outLink of outLinks) {
const outNode = graph.getNodeById(outLink.target_id)
if (!outNode) continue
const result = inNode.connect(
inLink.origin_slot,
outNode,
outLink.target_slot,
inLink.parentId
)
madeAnyConnections ||= !!result
}
}
}
/**
* Returns `true` if the widget is visible, otherwise `false`.
*/
isWidgetVisible(widget: IBaseWidget): boolean {
const isHidden =
this.collapsed || widget.hidden || (widget.advanced && !this.showAdvanced)
return !isHidden
}
/**
* Returns all widgets that should participate in layout calculations.
* Filters out hidden widgets only (not collapsed/advanced).
*/
getLayoutWidgets(): IBaseWidget[] {
return this.widgets?.filter((w) => !w.hidden) ?? []
}
/**
* Returns `true` if the node has any advanced widgets.
*/
hasAdvancedWidgets(): boolean {
return this.widgets?.some((w) => w.advanced) ?? false
}
updateComputedDisabled() {
if (!this.widgets) return
for (const widget of this.widgets)
widget.computedDisabled =
widget.disabled || this.getSlotFromWidget(widget)?.link != null
}
drawWidgets(
ctx: CanvasRenderingContext2D,
{ lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions
): void {
if (!this.widgets) return
const nodeWidth = this.size[0]
const { widgets } = this
const H = LiteGraph.NODE_WIDGET_HEIGHT
const showText = !lowQuality
ctx.save()
ctx.globalAlpha = editorAlpha
this.updateComputedDisabled()
for (const widget of widgets) {
if (!this.isWidgetVisible(widget)) continue
const { y } = widget
const outlineColour = widget.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
widget.last_y = y
ctx.strokeStyle = outlineColour
ctx.fillStyle = '#222'
ctx.textAlign = 'left'
if (widget.computedDisabled) ctx.globalAlpha *= 0.5
const width = widget.width || nodeWidth
if (typeof widget.draw === 'function') {
widget.draw(ctx, this, width, y, H, lowQuality)
} else {
toConcreteWidget(widget, this, false)?.drawWidget(ctx, {
width,
showText
})
}
ctx.globalAlpha = editorAlpha
}
ctx.restore()
}
/**
* When {@link LGraphNode.collapsed} is `true`, this method draws the node's collapsed slots.
*/
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
// Render the first connected slot only.
for (const slot of this._concreteInputs) {
if (slot.link != null) {
slot.drawCollapsed(ctx)
break
}
}
for (const slot of this._concreteOutputs) {
if (slot.links?.length) {
slot.drawCollapsed(ctx)
break
}
}
}
get slots(): (INodeInputSlot | INodeOutputSlot)[] {
return [...this.inputs, ...this.outputs]
}
private _measureSlot(
slot: NodeInputSlot | NodeOutputSlot,
slotIndex: number,
isInput: boolean
): void {
const pos = isInput
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
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
}
private _measureSlots(): ReadOnlyRect | null {
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
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, true)
slots.push(slot)
}
for (const [slotIndex, slot] of this._concreteOutputs.entries()) {
this._measureSlot(slot, slotIndex, false)
slots.push(slot)
}
return slots.length ? createBounds(slots, 0) : null
}
private _getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
const isInput = isINodeInputSlot(slot)
const mouseOverId = this.mouseOver?.[isInput ? 'inputId' : 'outputId'] ?? -1
if (mouseOverId === -1) {
return null
}
return isInput ? this.inputs[mouseOverId] : this.outputs[mouseOverId]
}
private _isMouseOverSlot(slot: INodeSlot): boolean {
return this._getMouseOverSlot(slot) === slot
}
private _isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
if (!widget) return false
return this.mouseOver?.overWidget === widget
}
/**
* Returns the input slot that is associated with the given widget.
*/
getSlotFromWidget(
widget: IBaseWidget | undefined
): INodeInputSlot | undefined {
if (widget)
return this.inputs.find(
(slot) => isWidgetInputSlot(slot) && slot.widget.name === widget.name
)
}
/**
* Returns the widget that is associated with the given input slot.
*/
getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
if (!isWidgetInputSlot(slot)) return
return this.widgets?.find((w) => w.name === slot.widget.name)
}
/**
* Draws the node's input and output slots.
*/
drawSlots(
ctx: CanvasRenderingContext2D,
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
) {
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
const isValid = !fromSlot || isValidTarget
const highlight = isValid && isMouseOverSlot
// Show slot if it's not a widget input slot
// or if it's a widget input slot and satisfies one of the following:
// - the mouse is over the widget
// - the slot is valid during link drop
// - the slot is connected
if (
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slot.draw(ctx, {
colorContext,
lowQuality,
highlight
})
}
}
}
/**
* Arranges the node's widgets vertically.
* Sets following properties on each widget:
* - {@link IBaseWidget.computedHeight}
* - {@link IBaseWidget.y}
* @param widgetStartY The y-coordinate of the first widget
*/
private _arrangeWidgets(widgetStartY: number): void {
if (!this.widgets || !this.widgets.length) return
const bodyHeight = this.bodyHeight
const startY =
this.widgets_start_y ?? (this.widgets_up ? 0 : widgetStartY) + 2
let freeSpace = bodyHeight - startY
// Collect fixed height widgets first
let fixedWidgetHeight = 0
const growableWidgets: {
minHeight: number
prefHeight?: number
w: IBaseWidget
}[] = []
const visibleWidgets = this.getLayoutWidgets()
for (const w of visibleWidgets) {
if (w.computeSize) {
const height = w.computeSize()[1] + 4
w.computedHeight = height
fixedWidgetHeight += height
} else if (w.computeLayoutSize) {
const { minHeight, maxHeight } = w.computeLayoutSize(this)
growableWidgets.push({
minHeight,
prefHeight: maxHeight,
w
})
} else {
const height = LiteGraph.NODE_WIDGET_HEIGHT + 4
w.computedHeight = height
fixedWidgetHeight += height
}
}
// Calculate remaining space for DOM widgets
freeSpace -= fixedWidgetHeight
this.freeWidgetSpace = freeSpace
// Prepare space requests for distribution
const spaceRequests = growableWidgets.map((d) => ({
minSize: d.minHeight,
maxSize: d.prefHeight
}))
// Distribute space among DOM widgets
const allocations = distributeSpace(Math.max(0, freeSpace), spaceRequests)
// Apply computed heights
for (const [i, d] of growableWidgets.entries()) {
d.w.computedHeight = allocations[i]
}
// Position widgets
let y = startY
for (const w of visibleWidgets) {
w.y = y
y += w.computedHeight ?? 0
}
if (!this.graph) throw new NullGraphError()
// Grow the node if necessary.
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/2652
// TODO: Move the layout logic before drawing of the node shape, so we don't
// need to trigger extra round of rendering.
// In Vue mode, the DOM is the source of truth for node sizing — the
// ResizeObserver feeds measurements back to the layout store. Allowing
// LiteGraph to also call setSize() here creates an infinite feedback loop
// (LG grows node → CSS min-height increases → textarea fills extra space →
// ResizeObserver reports larger size → LG grows node again).
if (!LiteGraph.vueNodesMode && y > bodyHeight) {
this.setSize([this.size[0], y])
this.graph.setDirtyCanvas(false, true)
}
}
/**
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
if (!this.widgets?.length) return
// Build a name→widget map for fast lookup.
const widgetByName = new Map<string, IBaseWidget>()
for (const w of this.widgets) widgetByName.set(w.name, w)
// Set widget-backed slot positions from widget Y coordinates.
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
// components (NodeSlots filters them out), so they have no DOM-registered
// position. input.pos serves as the fallback for getSlotPosition().
for (const [i, slot] of this._concreteInputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
// Fall back to name-map lookup for regular nodes without _widget set.
// Note: the name-map is ambiguous if two promoted inputs share a label;
// _widget avoids this since it is a direct reference.
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
if (!widget) continue
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, 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] - this.pos[1]
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this._widgetSlotsDirty = false
}
/**
* Draws a progress bar on the node.
* @param ctx The canvas context to draw on
*/
drawProgressBar(ctx: CanvasRenderingContext2D): void {
if (!this.progress) return
const originalFillStyle = ctx.fillStyle
ctx.fillStyle = 'green'
ctx.fillRect(0, 0, this.width * this.progress, 6)
ctx.fillStyle = originalFillStyle
}
}