Files
ComfyUI_frontend/src/LGraphNode.ts
2025-03-02 14:56:15 +00:00

3455 lines
102 KiB
TypeScript

import type { DragAndScale } from "./DragAndScale"
import type {
CanvasColour,
ColorOption,
ConnectingLink,
Dictionary,
IColorable,
IContextMenuValue,
IFoundSlot,
INodeFlags,
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
INodeSlotContextItem,
IPinnable,
ISlotType,
Point,
Positionable,
ReadOnlyRect,
Rect,
Size,
} from "./interfaces"
import type { LGraph } from "./LGraph"
import type { RerouteId } from "./Reroute"
import type { CanvasMouseEvent } from "./types/events"
import type { ISerialisedNode } from "./types/serialisation"
import type { IBaseWidget, IWidget, IWidgetOptions, TWidgetType, TWidgetValue } from "./types/widgets"
import { NullGraphError } from "./infrastructure/NullGraphError"
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
import { LGraphCanvas } from "./LGraphCanvas"
import { type LGraphNodeConstructor, LiteGraph } from "./litegraph"
import { LLink } from "./LLink"
import { createBounds, isInRect, isInRectangle, snapPoint } from "./measure"
import { ConnectionColorContext, isINodeInputSlot, isWidgetInputSlot, NodeInputSlot, NodeOutputSlot, serializeSlot, toNodeSlotClass } from "./NodeSlot"
import {
LGraphEventMode,
NodeSlotType,
RenderShape,
TitleMode,
} from "./types/globalEnums"
import { LayoutElement } from "./utils/layout"
import { distributeSpace } from "./utils/spaceDistribution"
import { toClass } from "./utils/type"
import { WIDGET_TYPE_MAP } from "./widgets/widgetMap"
export type NodeId = number | string
export type NodeProperty = string | number | boolean | object
export interface INodePropertyInfo {
name: string
type?: string
default_value: NodeProperty | undefined
}
export interface IMouseOverData {
inputId: number | null
outputId: number | null
overWidget: IWidget | null
}
export 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 */
export interface IGenericLinkOrLinks {
links?: INodeOutputSlot["links"]
link?: INodeInputSlot["link"]
}
export 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
}
/*
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 wont show if there is one already connected
+ resizable: if set to false it wont 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
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface LGraphNode {
constructor: LGraphNodeConstructor
}
/**
* Base Class for all the node type classes
* @param {string} name a name for the node
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class LGraphNode implements Positionable, IPinnable, IColorable {
// Static properties used by dynamic child classes
static title?: string
static MAX_CONSOLE?: number
static type?: string
static category?: string
static filter?: string
static skip_list?: boolean
/** 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 Arial`
}
get innerFontStyle(): string {
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px Arial`
}
graph: LGraph | null = null
id: NodeId
type: string | null = null
inputs: INodeInputSlot[] = []
outputs: INodeOutputSlot[] = []
// Not used
connections: unknown[] = []
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IWidget[]
/**
* The amount of space available for widgets to grow into.
* @see {@link layoutWidgets}
*/
freeWidgetSpace?: number
locked?: boolean
// Execution order, automatically computed during run
order?: number
mode?: LGraphEventMode
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 {
return this.color || this.constructor.color || LiteGraph.NODE_DEFAULT_COLOR
}
/** The bg color used to render the node. */
get renderingBgColor(): string {
return this.bgcolor || this.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR
}
/** The box color used to render the node. */
get renderingBoxColor(): string {
const mode = this.mode ?? LGraphEventMode.ALWAYS
let colState = LiteGraph.node_box_coloured_by_mode && LiteGraph.NODE_MODES_COLORS[mode]
? LiteGraph.NODE_MODES_COLORS[mode]
: undefined
if (LiteGraph.node_box_coloured_when_on) {
colState = this.action_triggered
? "#FFF"
: (this.execute_triggered
? "#AAA"
: colState)
}
return this.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR
}
/** @inheritdoc {@link IColorable.setColorOption} */
setColorOption(colorOption: ColorOption | null): void {
if (colorOption == null) {
delete this.color
delete this.bgcolor
} 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
}
exec_version?: number
action_call?: string
execute_triggered?: number
action_triggered?: number
widgets_up?: boolean
widgets_start_y?: number
lostFocusAt?: number
gotFocusAt?: number
badges: (LGraphBadge | (() => LGraphBadge))[] = []
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}. */
onBounding?(this: LGraphNode, out: Rect): void
console?: string[]
_level?: number
_shape?: RenderShape
mouseOver?: IMouseOverData | null
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
/** @inheritdoc {@link renderArea} */
#renderArea: Float32Array = new Float32Array(4)
/**
* 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} */
#boundingRect: Float32Array = new Float32Array(4)
/**
* 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(): ReadOnlyRect {
return this.#boundingRect
}
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
_posSize: Float32Array = new Float32Array(4)
_pos: Point = this._posSize.subarray(0, 2)
_size: Size = this._posSize.subarray(2, 4)
public get pos() {
return this._pos
}
public set pos(value) {
if (!value || value.length < 2) return
this._pos[0] = value[0]
this._pos[1] = value[1]
}
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]
}
/**
* 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") {
switch (v) {
case "default":
delete this._shape
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
}
}
/**
* 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,
node: LGraphNode,
slot: number,
): boolean
onConnectOutput?(
this: LGraphNode,
slot: number,
type: unknown,
input: INodeInputSlot,
target_node: number | LGraphNode,
target_slot: number,
): boolean
onResize?(this: LGraphNode, size: Size): void
onPropertyChanged?(
this: LGraphNode,
name: string,
value: unknown,
prev_value?: unknown,
): boolean
onConnectionsChange?(
this: LGraphNode,
type: ISlotType,
index: number,
isConnected: boolean,
link_info: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot,
): void
onInputAdded?(this: LGraphNode, input: INodeInputSlot): void
onOutputAdded?(this: LGraphNode, output: INodeOutputSlot): void
onConfigure?(this: LGraphNode, serialisedNode: ISerialisedNode): void
onSerialize?(this: LGraphNode, serialised: ISerialisedNode): any
onExecute?(
this: LGraphNode,
param?: unknown,
options?: { action_call?: any },
): 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: any): void
onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: any): boolean
onWidgetChanged?(
this: LGraphNode,
name: string,
value: unknown,
old_value: unknown,
w: IWidget,
): 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: CanvasMouseEvent): 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: any,
file: any,
): void
onDropFile?(this: LGraphNode, file: any): void
onInputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void
onInputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void
onOutputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void
onOutputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void
// TODO: Return type
onGetPropertyInfo?(this: LGraphNode, property: string): any
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: CanvasMouseEvent, pos: Point): void
onMouseEnter?(this: LGraphNode, e: CanvasMouseEvent): void
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
onMouseDown?(
this: LGraphNode,
e: CanvasMouseEvent,
pos: Point,
canvas: LGraphCanvas,
): boolean
/** @param pos Offset from {@link LGraphNode.pos}. */
onDblClick?(
this: LGraphNode,
e: CanvasMouseEvent,
pos: Point,
canvas: LGraphCanvas,
): void
/** @param pos Offset from {@link LGraphNode.pos}. */
onNodeTitleDblClick?(
this: LGraphNode,
e: CanvasMouseEvent,
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: any,
): void
onRemoved?(this: LGraphNode): void
onMouseMove?(
this: LGraphNode,
e: MouseEvent,
pos: Point,
arg2: LGraphCanvas,
): void
onPropertyChange?(this: LGraphNode): void
updateOutputData?(this: LGraphNode, origin_slot: number): void
isValidWidgetLink?(
slot_index: number,
node: LGraphNode,
overWidget: IWidget,
): boolean | undefined
constructor(title: string) {
this.id = LiteGraph.use_uuids ? LiteGraph.uuidv4() : -1
this.title = title || "Unnamed"
this.size = [LiteGraph.NODE_WIDTH, 60]
this.pos = [10, 10]
}
/**
* configure a node from an object containing the serialized info
*/
configure(info: ISerialisedNode): void {
if (this.graph) {
this.graph._version++
}
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))
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))
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)
}
if (this.widgets) {
for (const w of this.widgets) {
if (!w) continue
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) {
for (let i = 0; i < info.widgets_values.length; ++i) {
const widget = this.widgets[i]
if (widget) {
widget.value = info.widgets_values[i]
}
}
}
}
// Sync the state of this.resizable.
if (this.pinned) this.resizable = false
this.onConfigure?.(info)
}
/**
* serialize the content
*/
serialize(): ISerialisedNode {
// create serialization object
const o: ISerialisedNode = {
id: this.id,
type: this.type ?? undefined,
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
if (this.inputs) o.inputs = this.inputs.map(input => serializeSlot(input))
if (this.outputs) o.outputs = this.outputs.map(output => serializeSlot(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()) {
// @ts-expect-error #595 No-null
o.widgets_values[i] = widget ? widget.value : null
}
}
if (!o.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 shouldnt 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.
delete data.id
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) continue
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]
}
/**
* 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?: any }) {
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?: any }): 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?: any },
): 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?: any },
): 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(
name: string,
type: ISlotType,
extra_info?: Partial<INodeOutputSlot>,
): INodeOutputSlot {
const output = new NodeOutputSlot({ name, type, links: null })
if (extra_info) Object.assign(output, 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
}
/**
* add a new output slot to use in this node
* @param array of triplets like [[name,type,extra_info],[...]]
*/
addOutputs(array: [string, ISlotType, Partial<INodeOutputSlot>][]): void {
for (const info of array) {
const o = new NodeOutputSlot({ name: info[0], type: info[1], links: null })
// TODO: Checking the wrong variable here - confirm no downstream consumers, then remove.
if (array[2]) Object.assign(o, info[2])
this.outputs ||= []
this.outputs.push(o)
this.onOutputAdded?.(o)
if (LiteGraph.auto_load_slot_types)
LiteGraph.registerNodeAndSlotType(this, info[1], true)
}
this.expandToFitContent()
this.setDirtyCanvas(true, true)
}
/**
* remove an existing output slot
*/
removeOutput(slot: number): void {
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
for (const linkId of output.links) {
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(linkId)
if (!link) continue
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(name: string, type: ISlotType, extra_info?: Partial<INodeInputSlot>): INodeInputSlot {
type = type || 0
const input: INodeInputSlot = new NodeInputSlot({ name: name, type: type, link: null })
if (extra_info) Object.assign(input, extra_info)
this.inputs ||= []
this.inputs.push(input)
this.expandToFitContent()
this.onInputAdded?.(input)
LiteGraph.registerNodeAndSlotType(this, type)
this.setDirtyCanvas(true, true)
return input
}
/**
* add several new input slots in this node
* @param array of triplets like [[name,type,extra_info],[...]]
*/
addInputs(array: [string, ISlotType, Partial<INodeInputSlot>][]): void {
for (const info of array) {
const o: INodeInputSlot = new NodeInputSlot({ name: info[0], type: info[1], link: null })
// TODO: Checking the wrong variable here - confirm no downstream consumers, then remove.
if (array[2]) Object.assign(o, info[2])
this.inputs ||= []
this.inputs.push(o)
this.onInputAdded?.(o)
LiteGraph.registerNodeAndSlotType(this, info[1])
}
this.expandToFitContent()
this.setDirtyCanvas(true, true)
}
/**
* remove an existing input slot
*/
removeInput(slot: number): void {
this.disconnectInput(slot)
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
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(input.link)
if (!link) continue
link.target_slot--
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
/**
* add an special connection to this node (used for special kinds of graphs)
* @param type string defining the input type ("vec3","number",...)
* @param pos position of the connection inside the node
* @param direction if is input or output
*/
addConnection(name: string, type: string, pos: Point, direction: string) {
const o = {
name: name,
type: type,
pos: pos,
direction: direction,
links: null,
}
this.connections.push(o)
return o
}
/**
* 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 } = this
let rows = Math.max(
inputs ? inputs.length : 1,
outputs ? outputs.length : 1,
)
const size = out || new Float32Array([0, 0])
rows = Math.max(rows, 1)
// although it should be graphcanvas.inner_text_font size
const font_size = LiteGraph.NODE_TEXT_SIZE
const title_width = compute_text_size(this.title)
let input_width = 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)
if (input_width < text_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)
if (output_width < text_width)
output_width = text_width
}
}
size[0] = Math.max(input_width + output_width + 10, title_width)
size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH)
if (this.widgets?.length)
size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH * 1.5)
size[1] = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT
let widgets_height = 0
if (this.widgets?.length) {
for (const widget of this.widgets) {
if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue
let widget_height = 0
if (widget.computeSize) {
widget_height += widget.computeSize(size[0])[1]
} else if (widget.computeLayoutSize) {
widget_height += widget.computeLayoutSize(this).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) {
return text
? font_size * text.length * 0.6
: 0
}
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 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}`]) 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: TWidgetType,
name: string,
value: string | number | boolean | object,
callback: IWidget["callback"] | string | null,
options?: IWidgetOptions | string,
): IWidget {
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: IWidget = {
// @ts-expect-error Type check or just assert?
type: type.toLowerCase(),
name: name,
value: value,
callback: typeof callback !== "function" ? undefined : callback,
options,
}
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<T extends IWidget>(custom_widget: T): T {
this.widgets ||= []
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
const WidgetClass = WIDGET_TYPE_MAP[custom_widget.type]
const widget = WidgetClass ? new WidgetClass(custom_widget) as IWidget : custom_widget
this.widgets.push(widget)
return widget as T
}
move(deltaX: number, deltaY: number): void {
if (this.pinned) return
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 {
ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx.measureText(this.getTitle() ?? "").width + LiteGraph.NODE_TITLE_HEIGHT * 2,
)
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 {Float32Array[4]?} [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 ||= new Float32Array(4)
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 {
return isInRect(x, y, this.boundingRect)
}
/**
* 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,
)
}
/**
* 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 link_pos = new Float32Array(2)
const { inputs, outputs } = this
if (inputs) {
for (const [i, input] of inputs.entries()) {
this.getConnectionPos(true, i, link_pos)
if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) {
return { input, slot: i, link_pos }
}
}
}
if (outputs) {
for (const [i, output] of outputs.entries()) {
this.getConnectionPos(false, i, link_pos)
if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) {
return { output, slot: i, link_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,
): IWidget | null {
const { widgets, pos, size } = this
if (!widgets?.length) return null
const x = canvasX - pos[0]
const y = canvasY - pos[1]
const nodeWidth = size[0]
for (const widget of widgets) {
if (
!widget ||
(widget.disabled && !includeDisabled) ||
widget.hidden ||
(widget.advanced && !this.showAdvanced)
) {
continue
}
const h = widget.computedHeight ??
widget.computeSize?.(nodeWidth)[1] ??
LiteGraph.NODE_WIDGET_HEIGHT
const w = widget.width || nodeWidth
if (
widget.last_y !== undefined &&
isInRectangle(x, y, 6, widget.last_y, w - 12, h)
) {
return widget
}
}
return null
}
/**
* 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
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
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
*/
#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 uise 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
findSlotByType<TSlot extends false, TReturn extends true>(
input: TSlot,
type: ISlotType,
returnObj?: TReturn,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean,
): INodeOutputSlot
findSlotByType(
input: boolean,
type: ISlotType,
returnObj?: boolean,
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean,
) {
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
*/
#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 | null {
// 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 null
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
}
return null
}
/**
* 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 !== null)
return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId)
console.debug("[connectByType]: no way to connect type:", target_slotType, "to node:", target_node)
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 !== null)
return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId)
console.debug("[connectByType]: no way to connect type:", source_slotType, "to node:", source_node)
return null
}
/**
* 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 = this.graph
if (!graph) {
// could be connected before adding it to a graph
// due to link ids being associated with graphs
console.log("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.log(`Connect: Error, no slot of name ${slot}`)
return null
}
} else if (!this.outputs || slot >= this.outputs.length) {
if (LiteGraph.debug) console.log("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.log(`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: false | number | null =
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.log("Connect: Error, slot number not found")
return null
}
let changed = false
const input = target_node.inputs[targetIndex]
let link_info: LLink | null = null
const output = this.outputs[slot]
if (!this.outputs[slot]) return null
// check targetSlot and check connection types
if (!LiteGraph.isValidConnection(output.type, input.type)) {
this.setDirtyCanvas(false, true)
// @ts-expect-error Unused param
if (changed) graph.connectionChange(this, link_info)
return null
}
// Allow nodes to block connection
if (target_node.onConnectInput?.(targetIndex, output.type, output, this, slot) === false)
return null
if (this.onConnectOutput?.(slot, input.type, input, target_node, targetIndex) === false)
return null
// if there is something already plugged there, disconnect
if (target_node.inputs[targetIndex]?.link != null) {
graph.beforeChange()
target_node.disconnectInput(targetIndex, true)
changed = true
}
if (output.links?.length) {
if (output.type === LiteGraph.EVENT && !LiteGraph.allow_multi_output_for_events) {
graph.beforeChange()
// @ts-expect-error Unused param
this.disconnectOutput(slot, false, { doProcessChange: false })
changed = true
}
}
// UUID: LinkIds
// const nextId = LiteGraph.use_uuids ? LiteGraph.uuidv4() : ++graph.state.lastLinkId
const nextId = ++graph.state.lastLinkId
// create link class
link_info = new LLink(
nextId,
input.type || output.type,
this.id,
slot,
target_node.id,
targetIndex,
afterRerouteId,
)
// add to graph links list
graph._links.set(link_info.id, link_info)
// connect in output
output.links ??= []
output.links.push(link_info.id)
// connect in input
target_node.inputs[targetIndex].link = link_info.id
// Reroutes
for (const reroute of LLink.getReroutes(graph, link_info)) {
reroute?.linkIds.add(nextId)
}
graph._version++
// link_info has been created now, so its updated
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
true,
link_info,
output,
)
target_node.onConnectionsChange?.(
NodeSlotType.INPUT,
targetIndex,
true,
link_info,
input,
)
graph.onNodeConnectionChange?.(
NodeSlotType.INPUT,
target_node,
targetIndex,
this,
slot,
)
graph.onNodeConnectionChange?.(
NodeSlotType.OUTPUT,
this,
slot,
target_node,
targetIndex,
)
this.setDirtyCanvas(false, true)
graph.afterChange()
graph.connectionChange(this)
return link_info
}
/**
* 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.log(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.outputs || slot >= this.outputs.length) {
if (LiteGraph.debug) console.log("Connect: Error, slot number not found")
return false
}
// get output slot
const output = this.outputs[slot]
if (!output || !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)
// is the link we are searching for...
if (link_info?.target_id == target.id) {
// remove here
links.splice(i, 1)
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
// remove the link from the links pool
graph._links.delete(link_id)
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,
)
graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target, link_info.target_slot)
break
}
}
} else {
// all the links in this output slot
for (const link_id of links) {
const link_info = graph._links.get(link_id)
// bug: it happens sometimes
if (!link_info) continue
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
// 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
graph._links.delete(link_id)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output,
)
graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target, link_info.target_slot)
}
output.links = null
}
this.setDirtyCanvas(false, true)
graph.connectionChange(this)
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.log(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.inputs || slot >= this.inputs.length) {
if (LiteGraph.debug) {
console.log("Connect: Error, slot number not found")
}
return false
}
const input = this.inputs[slot]
if (!input) return false
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (!this.graph) throw new NullGraphError()
// remove other side
const link_info = this.graph._links.get(link_id)
if (link_info) {
const target_node = this.graph.getNodeById(link_info.origin_id)
if (!target_node) return false
const output = target_node.outputs[link_info.origin_slot]
if (!(output?.links?.length)) 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(this.graph, keepReroutes)
if (this.graph) this.graph._version++
this.onConnectionsChange?.(
NodeSlotType.INPUT,
slot,
false,
link_info,
input,
)
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
false,
link_info,
output,
)
this.graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, target_node, i)
this.graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, this, slot)
}
}
this.setDirtyCanvas(false, true)
this.graph?.connectionChange(this)
return true
}
/**
* 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 ||= new Float32Array(2)
const num_slots = is_input
? this.inputs?.length ?? 0
: this.outputs?.length ?? 0
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
if (this.flags.collapsed) {
const w = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[0] = is_input
? this.pos[0]
: this.pos[0] + w
out[1] = this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5
return out
}
// weird feature that never got finished
if (is_input && slot_number == -1) {
out[0] = this.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * 0.5
out[1] = this.pos[1] + LiteGraph.NODE_TITLE_HEIGHT * 0.5
return out
}
// hard-coded pos
if (
is_input &&
num_slots > slot_number &&
this.inputs[slot_number].pos
) {
out[0] = this.pos[0] + this.inputs[slot_number].pos[0]
out[1] = this.pos[1] + this.inputs[slot_number].pos[1]
return out
} else if (
!is_input &&
num_slots > slot_number &&
this.outputs[slot_number].pos
) {
out[0] = this.pos[0] + this.outputs[slot_number].pos[0]
out[1] = this.pos[1] + this.outputs[slot_number].pos[1]
return out
}
// default vertical slots
out[0] = is_input
? this.pos[0] + offset
: this.pos[0] + this.size[0] + 1 - offset
out[1] =
this.pos[1] +
(slot_number + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
(this.constructor.slot_start_y || 0)
return out
}
/** @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 */
captureInput(v: boolean): void {
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.widgets?.some(w => w.advanced)) 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
// Delete the flag if unpinned, so that we don't get unnecessary
// flags.pinned = false in serialized object.
if (!this.pinned) delete this.flags.pinned
}
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, options: {
scale: number
title_height?: number
low_quality?: boolean
}): void {
const {
scale,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
low_quality = false,
} = options
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, options: {
scale: number
low_quality?: boolean
title_height?: number
box_size?: number
}): void {
const {
scale,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
box_size = 10,
} = options
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, options: {
scale: number
default_title_color: string
low_quality?: boolean
title_height?: number
}): void {
const {
scale,
default_title_color,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
} = options
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
}
if (this.collapsed) {
ctx.textAlign = "left"
ctx.fillText(
// avoid urls too long
title.substr(0, 20),
title_height,
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
)
ctx.textAlign = "left"
} else {
ctx.textAlign = "left"
ctx.fillText(
title,
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) return
const result = inNode.connect(
inLink.origin_slot,
outNode,
outLink.target_slot,
inLink.parentId,
)
madeAnyConnections ||= !!result
}
}
}
drawWidgets(ctx: CanvasRenderingContext2D, options: {
colorContext: ConnectionColorContext
linkOverWidget: IWidget | null | undefined
linkOverWidgetType: ISlotType
lowQuality?: boolean
editorAlpha?: number
}): void {
if (!this.widgets) return
const { colorContext, linkOverWidget, linkOverWidgetType, lowQuality = false, editorAlpha = 1 } = options
const width = this.size[0]
const widgets = this.widgets
const H = LiteGraph.NODE_WIDGET_HEIGHT
const show_text = !lowQuality
ctx.save()
ctx.globalAlpha = editorAlpha
const margin = 15
for (const w of widgets) {
if (w.hidden || (w.advanced && !this.showAdvanced)) continue
const y = w.y
const outline_color = w.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR
if (w === linkOverWidget) {
// Manually draw a slot next to the widget simulating an input
new NodeInputSlot({
name: "",
type: linkOverWidgetType,
link: 0,
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
}).draw(ctx, { pos: [10, y + 10], colorContext })
}
w.last_y = y
ctx.strokeStyle = outline_color
ctx.fillStyle = "#222"
ctx.textAlign = "left"
if (w.disabled) ctx.globalAlpha *= 0.5
const widget_width = w.width || width
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
const WidgetClass: typeof WIDGET_TYPE_MAP[string] = WIDGET_TYPE_MAP[w.type]
if (WidgetClass) {
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
toClass(WidgetClass, w).drawWidget(ctx, { y, width: widget_width, show_text, margin })
} else {
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
w.draw?.(ctx, this, widget_width, y, H)
}
ctx.globalAlpha = editorAlpha
}
ctx.restore()
}
/**
* When {@link LGraphNode.collapsed} is `true`, this method draws the node's collapsed slots.
*/
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
// if collapsed
let input_slot: INodeInputSlot | null = null
let output_slot: INodeOutputSlot | null = null
// get first connected slot to render
for (const slot of this.inputs ?? []) {
if (slot.link == null) {
continue
}
input_slot = slot
break
}
for (const slot of this.outputs ?? []) {
if (!slot.links || !slot.links.length) {
continue
}
output_slot = slot
break
}
if (input_slot) {
const x = 0
const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5
toClass(NodeInputSlot, input_slot).drawCollapsed(ctx, {
pos: [x, y],
})
}
if (output_slot) {
const x = this._collapsed_width
const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5
toClass(NodeOutputSlot, output_slot).drawCollapsed(ctx, {
// @ts-expect-error8 https://github.com/Comfy-Org/litegraph.js/issues/616
pos: [x, y],
})
}
}
get highlightColor(): CanvasColour {
return LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR
}
get slots(): INodeSlot[] {
return [...this.inputs, ...this.outputs]
}
layoutSlot(slot: INodeSlot, options: {
slotIndex: number
}): void {
const { slotIndex } = options
const isInput = isINodeInputSlot(slot)
const pos = this.getConnectionPos(isInput, slotIndex)
slot._layoutElement = new LayoutElement({
value: slot,
boundingRect: [
pos[0] - this.pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5,
pos[1] - this.pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5,
LiteGraph.NODE_SLOT_HEIGHT,
LiteGraph.NODE_SLOT_HEIGHT,
],
})
}
layoutSlots(): ReadOnlyRect | null {
const slots: LayoutElement<INodeSlot>[] = []
for (const [i, slot] of this.inputs.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.layoutSlot(slot, {
slotIndex: i,
})
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
slots.push(slot._layoutElement)
}
for (const [i, slot] of this.outputs.entries()) {
this.layoutSlot(slot, {
slotIndex: i,
})
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
slots.push(slot._layoutElement)
}
return slots.length ? createBounds(slots, /** padding= */ 0) : null
}
#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]
}
#isMouseOverSlot(slot: INodeSlot): boolean {
return this.#getMouseOverSlot(slot) === slot
}
/**
* Draws the node's input and output slots.
*/
drawSlots(ctx: CanvasRenderingContext2D, options: {
connectingLink: ConnectingLink | undefined
colorContext: ConnectionColorContext
editorAlpha: number
lowQuality: boolean
}) {
const { connectingLink, colorContext, editorAlpha, lowQuality } = options
for (const slot of this.slots) {
// change opacity of incompatible slots when dragging a connection
const layoutElement = slot._layoutElement
const slotInstance = toNodeSlotClass(slot)
const isValid = slotInstance.isValidTarget(connectingLink)
const highlight = isValid && this.#isMouseOverSlot(slot)
const labelColor = highlight
? this.highlightColor
: LiteGraph.NODE_TEXT_COLOR
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slotInstance.draw(ctx, {
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
pos: layoutElement.center,
colorContext,
labelColor,
lowQuality,
renderText: !lowQuality,
highlight,
})
}
}
/**
* Lays out the node's widgets vertically.
* Sets following properties on each widget:
* - {@link IBaseWidget.computedHeight}
* - {@link IBaseWidget.y}
*/
layoutWidgets(options: { widgetStartY: number }): void {
if (!this.widgets || !this.widgets.length) return
const bodyHeight = this.bodyHeight
const widgetStartY = this.widgets_start_y ?? (
(this.widgets_up ? 0 : options.widgetStartY) + 2
)
let freeSpace = bodyHeight - widgetStartY
// Collect fixed height widgets first
let fixedWidgetHeight = 0
const growableWidgets: {
minHeight: number
prefHeight: number
w: IBaseWidget
}[] = []
for (const w of this.widgets) {
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,
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
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 = widgetStartY
for (const w of this.widgets) {
w.y = y
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
y += w.computedHeight
}
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.
if (y > bodyHeight) {
this.setSize([this.size[0], y])
this.graph.setDirtyCanvas(false, true)
}
}
/**
* Lays out the node's widget input slots.
*/
layoutWidgetInputSlots(): void {
if (!this.widgets) return
const slotByWidgetName = new Map<string, INodeInputSlot & { index: number }>()
for (const [i, slot] of this.inputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
slotByWidgetName.set(slot.widget?.name, { ...slot, index: i })
}
if (!slotByWidgetName.size) return
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this.inputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
// @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616
actualSlot.pos = [offset, widget.y + offset]
this.layoutSlot(actualSlot, { slotIndex: slot.index })
}
}
}