import type { DragAndScale } from "./DragAndScale" import type { IDrawBoundingOptions } from "./draw" import type { CanvasColour, ColorOption, 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 { Reroute, 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 { getNodeInputOnPos, getNodeOutputOnPos } from "./canvas/measureSlots" 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, isPointInRect, snapPoint } from "./measure" import { ConnectionColorContext, inputAsSerialisable, isINodeInputSlot, isWidgetInputSlot, NodeInputSlot, NodeOutputSlot, outputAsSerialisable, toNodeSlotClass } from "./NodeSlot" import { LGraphEventMode, NodeSlotType, RenderShape, TitleMode, } from "./types/globalEnums" import { findFreeSlotOfType } from "./utils/collections" 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 = "" inputs: INodeInputSlot[] = [] outputs: INodeOutputSlot[] = [] // Not used connections: unknown[] = [] properties: Dictionary = {} 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 @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 { 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 { 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) { 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 } /** * The stroke styles that should be applied to the node. */ strokeStyles: Record 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 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 } /** 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] } 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 /** 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, ): 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 | null)[], ): (IContextMenuValue | 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 | null)[], ): (IContextMenuValue | null)[] onMenuNodeOutputs?( this: LGraphNode, entries: (IContextMenuValue | null)[], ): (IContextMenuValue | 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 #getErrorStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined { if (this.has_errors) { return { padding: 12, lineWidth: 10, color: LiteGraph.NODE_ERROR_COLOUR, } } } #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, } } /** * 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, 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 => inputAsSerialisable(input)) if (this.outputs) 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()) { // @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) 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 { 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 { 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][]): 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) 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 { 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][]): 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, 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 if (!this.graph) throw new NullGraphError() const link = this.graph._links.get(input.link) if (link) 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.filter(input => !isWidgetInputSlot(input)).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, 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(custom_widget: T): T { this.widgets ||= [] 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, ) } /** * 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, ): 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.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(name: string, returnObj?: TReturn): number findInputSlot(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(name: string, returnObj?: TReturn): number findOutputSlot(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( optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, ): number findInputSlotFree( 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( optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, ): number findOutputSlotFree( 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 */ #findFreeSlot( 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( type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean, ): number findInputSlotByType( 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( type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean, ): number findOutputSlotByType( 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( input: TSlot, type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean, ): number findSlotByType( input: TSlot, type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean, ): INodeInputSlot | -1 findSlotByType( 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 */ #findSlotByType( 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) } /** * 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) } /** * 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) 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 !== undefined) 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 } canConnectTo( node: LGraphNode, toSlot: INodeInputSlot, fromSlot: INodeOutputSlot, ) { 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.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 (!outputs || slot >= 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 = 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 } 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() // @ts-expect-error Unused param this.disconnectOutput(slot, false, { doProcessChange: false }) } } 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 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 link = new LLink( ++graph.state.lastLinkId, input.type || output.type, this.id, outputIndex, inputNode.id, inputIndex, afterRerouteId, ) // add to graph links list graph._links.set(link.id, link) // connect in output output.links ??= [] output.links.push(link.id) // connect in input inputNode.inputs[inputIndex].link = link.id // Reroutes const reroutes = LLink.getReroutes(graph, link) for (const reroute of reroutes) { reroute.linkIds.add(link.id) if (reroute.floating) delete reroute.floating 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() graph.connectionChange(this) 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 ouput, 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 delete parentReroute.floating 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.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) 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 // 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 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 link_info.disconnect(graph, "input") this.onConnectionsChange?.( NodeSlotType.OUTPUT, slot, false, link_info, output, ) } 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 { 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 // remove other side const link_info = graph._links.get(link_id) if (link_info) { const target_node = 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(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) graph?.connectionChange(this) 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 ||= new Float32Array(2) 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 out[0] = is_input ? nodeX + offset : nodeX + this.size[0] + 1 - offset out[1] = nodeY + (slot_number + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + (this.constructor.slot_start_y || 0) return out } /** * 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 { const { pos: [nodeX, nodeY], inputs } = this if (this.flags.collapsed) { const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 return [nodeX, nodeY - halfTitle] } const inputPos = inputs?.[slot]?.pos if (inputPos) return [nodeX + inputPos[0], nodeY + inputPos[1]] // default vertical slots const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 const nodeOffsetY = this.constructor.slot_start_y || 0 const slotY = (slot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT return [nodeX + offsetX, nodeY + slotY + nodeOffsetY] } /** * Gets the position of an output slot, in graph co-ordinates. * * This method is preferred over the legacy {@link getConnectionPos} method. * @param slot Output slot index * @returns Position of the output slot */ getOutputPos(slot: number): Point { const { pos: [nodeX, nodeY], outputs, size: [width] } = this if (this.flags.collapsed) { const width = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 return [nodeX + width, nodeY - halfTitle] } const outputPos = outputs?.[slot]?.pos if (outputPos) return outputPos // default vertical slots const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 const nodeOffsetY = this.constructor.slot_start_y || 0 const slotY = (slot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT // TODO: Why +1? return [nodeX + width + 1 - offsetX, nodeY + slotY + nodeOffsetY] } /** @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) continue 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: "", // @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616 type: linkOverWidgetType, link: 0, }).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 const WidgetClass: typeof WIDGET_TYPE_MAP[string] = WIDGET_TYPE_MAP[w.type] if (WidgetClass) { toClass(WidgetClass, w).drawWidget(ctx, { y, width: widget_width, show_text, margin }) } else { w.draw?.(ctx, this, widget_width, y, H, lowQuality) } 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 ?? LiteGraph.NODE_COLLAPSED_WIDTH const y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 toClass(NodeOutputSlot, output_slot).drawCollapsed(ctx, { 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 = isInput ? this.getInputPos(slotIndex) : this.getOutputPos(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[] = [] 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, }) if (slot._layoutElement) { slots.push(slot._layoutElement) } } for (const [i, slot] of this.outputs.entries()) { this.layoutSlot(slot, { slotIndex: i, }) if (slot._layoutElement) { 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: { fromSlot?: INodeInputSlot | INodeOutputSlot colorContext: ConnectionColorContext editorAlpha: number lowQuality: boolean }) { const { fromSlot, 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 = !fromSlot || slotInstance.isValidTarget(fromSlot) 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, { pos: layoutElement?.center ?? [0, 0], colorContext, labelColor, 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, 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 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. 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() 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 actualSlot.pos = [offset, widget.y + offset] this.layoutSlot(actualSlot, { slotIndex: slot.index }) } } /** * 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 } }