import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuOptions, INodeSlot, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, Point, Rect, Rect32, Size, IContextMenuValue, ISlotType, ConnectingLink } from "./interfaces" import type { IWidget, TWidgetValue } from "./types/widgets" import type { LGraphNode, NodeId } from "./LGraphNode" import type { CanvasDragEvent, CanvasMouseEvent, CanvasWheelEvent, CanvasEventDetail, CanvasPointerEvent } from "./types/events" import type { IClipboardContents } from "./types/serialisation" import type { LLink } from "./LLink" import type { LGraph } from "./LGraph" import type { ContextMenu } from "./ContextMenu" import { LinkDirection, RenderShape, TitleMode } from "./types/globalEnums" import { LGraphGroup } from "./LGraphGroup" import { isInsideRectangle, distance, overlapBounding, isPointInRectangle } from "./measure" import { drawSlot, LabelPosition } from "./draw" import { DragAndScale } from "./DragAndScale" import { LinkReleaseContextExtended, LiteGraph, clamp } from "./litegraph" import { stringOrEmpty, stringOrNull } from "./strings" import { distributeNodes } from "./utils/arrange" interface IShowSearchOptions { node_to?: LGraphNode node_from?: LGraphNode slot_from: number | INodeOutputSlot | INodeInputSlot type_filter_in?: ISlotType type_filter_out?: ISlotType // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out do_type_filter?: boolean show_general_if_none_on_typefilter?: boolean show_general_after_typefiltered?: boolean hide_on_mouse_leave?: boolean show_all_if_empty?: boolean show_all_on_open?: boolean } interface INodeFromTo { // input nodeFrom?: LGraphNode // input slotFrom?: number | INodeOutputSlot | INodeInputSlot // output nodeTo?: LGraphNode // output slotTo?: number | INodeOutputSlot | INodeInputSlot // pass the event coords } interface ICreateNodeOptions extends INodeFromTo { // FIXME: Should not be optional position?: Point //,e: e // FIXME: Should not be optional // choose a nodetype to add, AUTO to set at first good nodeType?: string //nodeNewType // adjust x,y posAdd?: Point //-alphaPosY*30] // alpha, adjust the position x,y based on the new node size w,h posSizeFix?: Point //-alphaPosY*2*/ e?: CanvasMouseEvent allow_searchbox?: boolean showSearchBox?: LGraphCanvas["showSearchBox"] createDefaultNodeForSlot?: LGraphCanvas["createDefaultNodeForSlot"] } interface ICloseableDiv extends HTMLDivElement { close?(): void } interface IDialog extends ICloseableDiv { modified?(): void close?(): void is_modified?: boolean } interface IDialogOptions { position?: Point event?: MouseEvent checkForInput?: boolean closeOnLeave?: boolean onclose?(): void } interface IDrawSelectionBoundingOptions { shape?: RenderShape title_height?: number title_mode?: TitleMode fgcolor?: CanvasColour padding?: number collapsed?: boolean } /** @inheritdoc {@link LGraphCanvas.state} */ export interface LGraphCanvasState { /** {@link Positionable} items are being dragged on the canvas. */ draggingItems: boolean /** The canvas itself is being dragged. */ draggingCanvas: boolean /** The canvas is read-only, preventing changes to nodes, disconnecting links, moving items, etc. */ readOnly: boolean } /** * This class is in charge of rendering one graph inside a canvas. And provides all the interaction required. * Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked * * @param {HTMLCanvas} canvas the canvas where you want to render (it accepts a selector in string format or the canvas element itself) * @param {LGraph} graph [optional] * @param {Object} options [optional] { skip_rendering, autoresize, viewport } */ export class LGraphCanvas { /* Interaction */ static #temp = new Float32Array(4) static #temp_vec2 = new Float32Array(2) static #tmp_area = new Float32Array(4) static #margin_area = new Float32Array(4) static #link_bounding = new Float32Array(4) static #tempA = new Float32Array(2) static #tempB = new Float32Array(2) static DEFAULT_BACKGROUND_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=" /** Initialised from LiteGraphGlobal static block to avoid circular dependency. */ static link_type_colors: Record static gradients: Record = {} //cache of gradients static search_limit = -1 static node_colors = { red: { color: "#322", bgcolor: "#533", groupcolor: "#A88" }, brown: { color: "#332922", bgcolor: "#593930", groupcolor: "#b06634" }, green: { color: "#232", bgcolor: "#353", groupcolor: "#8A8" }, blue: { color: "#223", bgcolor: "#335", groupcolor: "#88A" }, pale_blue: { color: "#2a363b", bgcolor: "#3f5159", groupcolor: "#3f789e" }, cyan: { color: "#233", bgcolor: "#355", groupcolor: "#8AA" }, purple: { color: "#323", bgcolor: "#535", groupcolor: "#a1309b" }, yellow: { color: "#432", bgcolor: "#653", groupcolor: "#b58b2a" }, black: { color: "#222", bgcolor: "#000", groupcolor: "#444" } } /** * The state of this canvas, e.g. whether it is being dragged, or read-only. * * Implemented as a POCO that can be proxied without side-effects. */ state: LGraphCanvasState = { draggingItems: false, draggingCanvas: false, readOnly: false, } /** @inheritdoc {@link LGraphCanvasState.draggingCanvas} */ get dragging_canvas(): boolean { return this.state.draggingCanvas } set dragging_canvas(value: boolean) { this.state.draggingCanvas = value } // Whether the canvas was previously being dragged prior to pressing space key. // null if space key is not pressed. private _previously_dragging_canvas: boolean | null = null /** @inheritdoc {@link LGraphCanvasState.readOnly} */ get read_only(): boolean { return this.state.readOnly } set read_only(value: boolean) { this.state.readOnly = value } get isDragging(): boolean { return this.state.draggingItems } set isDragging(value: boolean) { this.state.draggingItems = value } options: { skip_events?: any; viewport?: any; skip_render?: any; autoresize?: any } background_image: string ds: DragAndScale zoom_modify_alpha: boolean zoom_speed: number title_text_font: string inner_text_font: string node_title_color: string default_link_color: string default_connection_color: { input_off: string; input_on: string //"#BBD" output_off: string; output_on: string //"#BBD" } default_connection_color_byType: Dictionary default_connection_color_byTypeOff: Dictionary highquality_render: boolean use_gradients: boolean editor_alpha: number pause_rendering: boolean clear_background: boolean clear_background_color: string render_only_selected: boolean live_mode: boolean show_info: boolean allow_dragcanvas: boolean allow_dragnodes: boolean allow_interaction: boolean multi_select: boolean allow_searchbox: boolean allow_reconnect_links: boolean align_to_grid: boolean drag_mode: boolean dragging_rectangle?: Rect filter?: string set_canvas_dirty_on_mouse_event: boolean always_render_background: boolean render_shadows: boolean render_canvas_border: boolean render_connections_shadows: boolean render_connections_border: boolean render_curved_connections: boolean render_connection_arrows: boolean render_collapsed_slots: boolean render_execution_order: boolean render_title_colored: boolean render_link_tooltip: boolean links_render_mode: number mouse: Point graph_mouse: Point canvas_mouse: Point onSearchBox?: (helper: Element, str: string, canvas: LGraphCanvas) => any onSearchBoxSelection?: (name: any, event: any, canvas: LGraphCanvas) => void onMouse?: (e: CanvasMouseEvent) => boolean onDrawBackground?: (ctx: CanvasRenderingContext2D, visible_area: any) => void onDrawForeground?: (arg0: CanvasRenderingContext2D, arg1: any) => void connections_width: number round_radius: number current_node: LGraphNode node_widget?: [LGraphNode, IWidget] over_link_center: LLink last_mouse_position: Point visible_area?: Rect32 visible_links?: LLink[] connecting_links: ConnectingLink[] viewport?: Rect autoresize: boolean static active_canvas: LGraphCanvas static onMenuNodeOutputs?(entries: IOptionalSlotData[]): IOptionalSlotData[] frame: number last_draw_time: number render_time: number fps: number selected_nodes: Dictionary /** @deprecated Temporary implementation only - will be replaced with `selectedItems: Set`. */ selectedGroups: Set selected_group: LGraphGroup visible_nodes: LGraphNode[] node_dragged?: LGraphNode node_over?: LGraphNode node_capturing_input?: LGraphNode highlighted_links: Dictionary link_over_widget?: IWidget link_over_widget_type?: string dirty_canvas: boolean dirty_bgcanvas: boolean /** A map of nodes that require selective-redraw */ dirty_nodes = new Map() dirty_area?: Rect // Unused node_in_panel?: LGraphNode last_mouse: Point last_mouseclick: number pointer_is_down: boolean pointer_is_double: boolean graph: LGraph _graph_stack: LGraph[] canvas: HTMLCanvasElement bgcanvas: HTMLCanvasElement ctx?: CanvasRenderingContext2D _events_binded?: boolean _mousedown_callback?(e: CanvasMouseEvent): boolean _mousewheel_callback?(e: CanvasMouseEvent): boolean _mousemove_callback?(e: CanvasMouseEvent): boolean _mouseup_callback?(e: CanvasMouseEvent): boolean _mouseout_callback?(e: CanvasMouseEvent): boolean _key_callback?(e: KeyboardEvent): boolean _ondrop_callback?(e: CanvasDragEvent): unknown gl?: never bgctx?: CanvasRenderingContext2D is_rendering?: boolean block_click?: boolean last_click_position?: Point resizing_node?: LGraphNode selected_group_resizing?: boolean last_mouse_dragging: boolean onMouseDown: (arg0: CanvasMouseEvent) => void _highlight_pos?: Point _highlight_input?: INodeInputSlot // TODO: Check if panels are used node_panel options_panel onDropItem: (e: Event) => any _bg_img: HTMLImageElement _pattern?: CanvasPattern _pattern_img: HTMLImageElement // TODO: This looks like another panel thing prompt_box: IDialog search_box: HTMLDivElement SELECTED_NODE: LGraphNode NODEPANEL_IS_OPEN: boolean getMenuOptions?(): IContextMenuValue[] getExtraMenuOptions?(canvas: LGraphCanvas, options: IContextMenuValue[]): IContextMenuValue[] static active_node: LGraphNode onBeforeChange?(graph: LGraph): void onAfterChange?(graph: LGraph): void onClear?: () => void onNodeMoved?: (node_dragged: LGraphNode) => void onSelectionChange?: (selected_nodes: Dictionary) => void onDrawLinkTooltip?: (ctx: CanvasRenderingContext2D, link: LLink, canvas?: LGraphCanvas) => boolean onDrawOverlay?: (ctx: CanvasRenderingContext2D) => void onRenderBackground?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => boolean onNodeDblClicked?: (n: LGraphNode) => void onShowNodePanel?: (n: LGraphNode) => void onNodeSelected?: (node: LGraphNode) => void onNodeDeselected?: (node: LGraphNode) => void onRender?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void /** Implement this function to allow conversion of widget types to input types, e.g. number -> INT or FLOAT for widget link validation checks */ getWidgetLinkType?: (widget: IWidget, node: LGraphNode) => string | null | undefined // FIXME: Has never worked - undefined visible_rect?: Rect constructor(canvas: HTMLCanvasElement, graph: LGraph, options?: { viewport?: any; skip_events?: any; skip_render?: any; autoresize?: any }) { this.options = options = options || {} //if(graph === undefined) // throw ("No graph assigned"); this.background_image = LGraphCanvas.DEFAULT_BACKGROUND_IMAGE if (canvas && typeof canvas === "string") { canvas = document.querySelector(canvas) } this.ds = new DragAndScale() this.zoom_modify_alpha = true //otherwise it generates ugly patterns when scaling down too much this.zoom_speed = 1.1 // in range (1.01, 2.5). Less than 1 will invert the zoom direction this.title_text_font = "" + LiteGraph.NODE_TEXT_SIZE + "px Arial" this.inner_text_font = "normal " + LiteGraph.NODE_SUBTEXT_SIZE + "px Arial" this.node_title_color = LiteGraph.NODE_TITLE_COLOR this.default_link_color = LiteGraph.LINK_COLOR this.default_connection_color = { input_off: "#778", input_on: "#7F7", //"#BBD" output_off: "#778", output_on: "#7F7" //"#BBD" } this.default_connection_color_byType = { /*number: "#7F7", string: "#77F", boolean: "#F77",*/ } this.default_connection_color_byTypeOff = { /*number: "#474", string: "#447", boolean: "#744",*/ } this.highquality_render = true this.use_gradients = false //set to true to render titlebar with gradients this.editor_alpha = 1 //used for transition this.pause_rendering = false this.clear_background = true this.clear_background_color = "#222" this.render_only_selected = true this.live_mode = false this.show_info = true this.allow_dragcanvas = true this.allow_dragnodes = true this.allow_interaction = true //allow to control widgets, buttons, collapse, etc this.multi_select = false //allow selecting multi nodes without pressing extra keys this.allow_searchbox = true this.allow_reconnect_links = true //allows to change a connection with having to redo it again this.align_to_grid = false //snap to grid this.drag_mode = false this.dragging_rectangle = null this.filter = null //allows to filter to only accept some type of nodes in a graph this.set_canvas_dirty_on_mouse_event = true //forces to redraw the canvas on mouse events (except move) this.always_render_background = false this.render_shadows = true this.render_canvas_border = true this.render_connections_shadows = false //too much cpu this.render_connections_border = true this.render_curved_connections = false this.render_connection_arrows = false this.render_collapsed_slots = true this.render_execution_order = false this.render_title_colored = true this.render_link_tooltip = true this.links_render_mode = LiteGraph.SPLINE_LINK this.mouse = [0, 0] //mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle this.graph_mouse = [0, 0] //mouse in graph coordinates, where 0,0 is the top-left corner of the blue rectangle this.canvas_mouse = this.graph_mouse //LEGACY: REMOVE THIS, USE GRAPH_MOUSE INSTEAD //to personalize the search box this.onSearchBox = null this.onSearchBoxSelection = null //callbacks this.onMouse = null this.onDrawBackground = null //to render background objects (behind nodes and connections) in the canvas affected by transform this.onDrawForeground = null //to render foreground objects (above nodes and connections) in the canvas affected by transform this.onDrawOverlay = null //to render foreground objects not affected by transform (for GUIs) this.onDrawLinkTooltip = null //called when rendering a tooltip this.onNodeMoved = null //called after moving a node this.onSelectionChange = null //called if the selection changes // FIXME: Typo, does nothing // @ts-expect-error this.onConnectingChange = null //called before any link changes this.onBeforeChange = null //called before modifying the graph this.onAfterChange = null //called after modifying the graph this.connections_width = 3 this.round_radius = 8 this.current_node = null this.node_widget = null //used for widgets this.over_link_center = null this.last_mouse_position = [0, 0] this.visible_area = this.ds.visible_area this.visible_links = [] this.connecting_links = null // Explicitly null-checked this.viewport = options.viewport || null //to constraint render area to a portion of the canvas //link canvas and graph graph?.attachCanvas(this) this.setCanvas(canvas, options.skip_events) this.clear() if (!options.skip_render) { this.startRendering() } this.autoresize = options.autoresize } static getFileExtension(url: string): string { const question = url.indexOf("?") if (question != -1) { url = url.substr(0, question) } const point = url.lastIndexOf(".") if (point == -1) { return "" } return url.substr(point + 1).toLowerCase() } /* this is an implementation for touch not in production and not ready */ /*LGraphCanvas.prototype.touchHandler = function(event) { //alert("foo"); var touches = event.changedTouches, first = touches[0], type = ""; switch (event.type) { case "touchstart": type = "mousedown"; break; case "touchmove": type = "mousemove"; break; case "touchend": type = "mouseup"; break; default: return; } //initMouseEvent(type, canBubble, cancelable, view, clickCount, // screenX, screenY, clientX, clientY, ctrlKey, // altKey, shiftKey, metaKey, button, relatedTarget); // this is eventually a Dom object, get the LGraphCanvas back if(typeof this.getCanvasWindow == "undefined"){ var window = this.lgraphcanvas.getCanvasWindow(); }else{ var window = this.getCanvasWindow(); } var document = window.document; var simulatedEvent = document.createEvent("MouseEvent"); simulatedEvent.initMouseEvent( type, true, true, window, 1, first.screenX, first.screenY, first.clientX, first.clientY, false, false, false, false, 0, //left null ); first.target.dispatchEvent(simulatedEvent); event.preventDefault(); };*/ /* CONTEXT MENU ********************/ static onGroupAdd(info: unknown, entry: unknown, mouse_event: MouseEvent): void { const canvas = LGraphCanvas.active_canvas const group = new LiteGraph.LGraphGroup() group.pos = canvas.convertEventToCanvasOffset(mouse_event) canvas.graph.add(group) } /** * Determines the furthest nodes in each direction * @param {Dictionary} nodes the nodes to from which boundary nodes will be extracted * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} */ static getBoundaryNodes(nodes: LGraphNode[] | Dictionary): IBoundaryNodes { let top = null let right = null let bottom = null let left = null for (const nID in nodes) { const node = nodes[nID] const [x, y] = node.pos const [width, height] = node.size if (top === null || y < top.pos[1]) { top = node } if (right === null || x + width > right.pos[0] + right.size[0]) { right = node } if (bottom === null || y + height > bottom.pos[1] + bottom.size[1]) { bottom = node } if (left === null || x < left.pos[0]) { left = node } } return { "top": top, "right": right, "bottom": bottom, "left": left } } /** * * @param {Dictionary} nodes a list of nodes * @param {"top"|"bottom"|"left"|"right"} direction Direction to align the nodes * @param {LGraphNode?} align_to Node to align to (if null, align to the furthest node in the given direction) */ static alignNodes(nodes: Dictionary, direction: Direction, align_to?: LGraphNode): void { if (!nodes) { return } const canvas = LGraphCanvas.active_canvas let boundaryNodes: IBoundaryNodes if (align_to === undefined) { boundaryNodes = LGraphCanvas.getBoundaryNodes(nodes) } else { boundaryNodes = { "top": align_to, "right": align_to, "bottom": align_to, "left": align_to } } for (const node of Object.values(canvas.selected_nodes)) { switch (direction) { case "right": node.pos[0] = boundaryNodes["right"].pos[0] + boundaryNodes["right"].size[0] - node.size[0] break case "left": node.pos[0] = boundaryNodes["left"].pos[0] break case "top": node.pos[1] = boundaryNodes["top"].pos[1] break case "bottom": node.pos[1] = boundaryNodes["bottom"].pos[1] + boundaryNodes["bottom"].size[1] - node.size[1] break } } canvas.dirty_canvas = true canvas.dirty_bgcanvas = true } static onNodeAlign(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): void { new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { event: event, callback: inner_clicked, parentMenu: prev_menu, }) function inner_clicked(value: string) { LGraphCanvas.alignNodes(LGraphCanvas.active_canvas.selected_nodes, (value.toLowerCase() as Direction), node) } } static onGroupAlign(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu): void { new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { event: event, callback: inner_clicked, parentMenu: prev_menu, }) function inner_clicked(value) { LGraphCanvas.alignNodes(LGraphCanvas.active_canvas.selected_nodes, value.toLowerCase()) } } static createDistributeMenu(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): void { new LiteGraph.ContextMenu(["Vertically", "Horizontally"], { event, callback: inner_clicked, parentMenu: prev_menu, }) function inner_clicked(value: string) { const canvas = LGraphCanvas.active_canvas distributeNodes(Object.values(canvas.selected_nodes), value === "Horizontally") canvas.setDirty(true, true) } } static onMenuAdd(node: LGraphNode, options: IContextMenuOptions, e: MouseEvent, prev_menu: ContextMenu, callback?: (node: LGraphNode) => void): boolean { const canvas = LGraphCanvas.active_canvas const ref_window = canvas.getCanvasWindow() const graph = canvas.graph if (!graph) return function inner_onMenuAdded(base_category: string, prev_menu: ContextMenu): void { const categories = LiteGraph.getNodeTypesCategories(canvas.filter || graph.filter).filter(function (category) { return category.startsWith(base_category) }) const entries = [] categories.map(function (category) { if (!category) return const base_category_regex = new RegExp('^(' + base_category + ')') const category_name = category.replace(base_category_regex, "").split('/')[0] const category_path = base_category === '' ? category_name + '/' : base_category + category_name + '/' let name = category_name if (name.indexOf("::") != -1) //in case it has a namespace like "shader::math/rand" it hides the namespace name = name.split("::")[1] const index = entries.findIndex(function (entry) { return entry.value === category_path }) if (index === -1) { entries.push({ value: category_path, content: name, has_submenu: true, callback: function (value, event, mouseEvent, contextMenu) { inner_onMenuAdded(value.value, contextMenu) } }) } }) const nodes = LiteGraph.getNodeTypesInCategory(base_category.slice(0, -1), canvas.filter || graph.filter) nodes.map(function (node) { if (node.skip_list) return const entry = { value: node.type, content: node.title, has_submenu: false, callback: function (value, event, mouseEvent, contextMenu) { const first_event = contextMenu.getFirstEvent() canvas.graph.beforeChange() const node = LiteGraph.createNode(value.value) if (node) { node.pos = canvas.convertEventToCanvasOffset(first_event) canvas.graph.add(node) } callback?.(node) canvas.graph.afterChange() } } entries.push(entry) }) // @ts-expect-error Remove param ref_window - unused new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu }, ref_window) } inner_onMenuAdded('', prev_menu) return false } static onMenuCollapseAll() { } static onMenuNodeEdit() { } /** @param options Parameter is never used */ static showMenuNodeOptionalInputs(v: unknown, options: INodeInputSlot[], e: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): boolean { if (!node) return // FIXME: Static function this const that = this const canvas = LGraphCanvas.active_canvas const ref_window = canvas.getCanvasWindow() options = node.onGetInputs ? node.onGetInputs() : node.optional_inputs let entries: IOptionalSlotData[] = [] if (options) { for (let i = 0; i < options.length; i++) { const entry = options[i] if (!entry) { entries.push(null) continue } let label = entry[0] entry[2] ||= {} if (entry[2].label) { label = entry[2].label } entry[2].removable = true const data: IOptionalSlotData = { content: label, value: entry } if (entry[1] == LiteGraph.ACTION) { data.className = "event" } entries.push(data) } } const retEntries = node.onMenuNodeInputs?.(entries) if (retEntries) entries = retEntries if (!entries.length) { console.log("no input entries") return } new LiteGraph.ContextMenu( entries, { event: e, callback: inner_clicked, parentMenu: prev_menu, node: node }, // @ts-expect-error Unused param ref_window ) function inner_clicked(v, e, prev) { if (!node) { return } v.callback?.call(that, node, v, e, prev) if (v.value) { node.graph.beforeChange() node.addInput(v.value[0], v.value[1], v.value[2]) // callback to the node when adding a slot node.onNodeInputAdd?.(v.value) node.setDirtyCanvas(true, true) node.graph.afterChange() } } return false } /** @param options Parameter is never used */ static showMenuNodeOptionalOutputs(v: unknown, options: INodeOutputSlot[], e: unknown, prev_menu: ContextMenu, node: LGraphNode): boolean { if (!node) return const that = this const canvas = LGraphCanvas.active_canvas const ref_window = canvas.getCanvasWindow() options = node.onGetOutputs ? node.onGetOutputs() : node.optional_outputs let entries: IOptionalSlotData[] = [] if (options) { for (let i = 0; i < options.length; i++) { const entry = options[i] if (!entry) { //separator? entries.push(null) continue } if (node.flags && node.flags.skip_repeated_outputs && node.findOutputSlot(entry[0]) != -1) { continue } //skip the ones already on let label = entry[0] entry[2] ||= {} if (entry[2].label) { label = entry[2].label } entry[2].removable = true const data: IOptionalSlotData = { content: label, value: entry } if (entry[1] == LiteGraph.EVENT) { data.className = "event" } entries.push(data) } } if (this.onMenuNodeOutputs) entries = this.onMenuNodeOutputs(entries) if (LiteGraph.do_add_triggers_slots) { //canvas.allow_addOutSlot_onExecuted if (node.findOutputSlot("onExecuted") == -1) { // @ts-expect-error Events entries.push({ content: "On Executed", value: ["onExecuted", LiteGraph.EVENT, { nameLocked: true }], className: "event" }) //, opts: {} } } // add callback for modifing the menu elements onMenuNodeOutputs const retEntries = node.onMenuNodeOutputs?.(entries) if (retEntries) entries = retEntries if (!entries.length) { return } new LiteGraph.ContextMenu( entries, { event: e, callback: inner_clicked, parentMenu: prev_menu, node: node }, // @ts-expect-error Unused ref_window ) function inner_clicked(v, e, prev) { if (!node) { return } if (v.callback) { // TODO: This is a static method, so the below "that" appears broken. v.callback.call(that, node, v, e, prev) } if (!v.value) { return } const value = v.value[1] if (value && (typeof value === "object" || Array.isArray(value))) { //submenu why? const entries = [] for (const i in value) { entries.push({ content: i, value: value[i] }) } new LiteGraph.ContextMenu(entries, { event: e, callback: inner_clicked, parentMenu: prev_menu, node: node }) return false } else { node.graph.beforeChange() node.addOutput(v.value[0], v.value[1], v.value[2]) // a callback to the node when adding a slot node.onNodeOutputAdd?.(v.value) node.setDirtyCanvas(true, true) node.graph.afterChange() } } return false } /** @param value Parameter is never used */ static onShowMenuNodeProperties(value: unknown, options: unknown, e: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): boolean { if (!node || !node.properties) return const canvas = LGraphCanvas.active_canvas const ref_window = canvas.getCanvasWindow() const entries = [] for (const i in node.properties) { value = node.properties[i] !== undefined ? node.properties[i] : " " if (typeof value == "object") value = JSON.stringify(value) const info = node.getPropertyInfo(i) if (info.type == "enum" || info.type == "combo") value = LGraphCanvas.getPropertyPrintableValue(value, info.values) //value could contain invalid html characters, clean that value = LGraphCanvas.decodeHTML(stringOrNull(value)) entries.push({ content: "" + (info.label || i) + "" + "" + value + "", value: i }) } if (!entries.length) { return } new LiteGraph.ContextMenu( entries, { event: e, callback: inner_clicked, parentMenu: prev_menu, allow_html: true, node: node }, // @ts-expect-error Unused ref_window ) function inner_clicked(v: { value: any }) { if (!node) return const rect = this.getBoundingClientRect() canvas.showEditPropertyValue(node, v.value, { position: [rect.left, rect.top] }) } return false } static decodeHTML(str: string): string { const e = document.createElement("div") e.innerText = str return e.innerHTML } static onMenuResizeNode(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { if (!node) return const fApplyMultiNode = function (node: LGraphNode) { node.size = node.computeSize() node.onResize?.(node.size) } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } node.setDirtyCanvas(true, true) } // TODO refactor :: this is used fot title but not for properties! static onShowPropertyEditor(item: { property: string; type: string }, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { const property = item.property || "title" const value = node[property] // TODO: Remove "any" kludge // TODO refactor :: use createDialog ? const dialog: any = document.createElement("div") dialog.is_modified = false dialog.className = "graphdialog" dialog.innerHTML = "" dialog.close = function () { dialog.parentNode?.removeChild(dialog) } const title = dialog.querySelector(".name") title.innerText = property const input = dialog.querySelector(".value") if (input) { input.value = value input.addEventListener("blur", function () { this.focus() }) input.addEventListener("keydown", function (e: KeyboardEvent) { dialog.is_modified = true if (e.keyCode == 27) { //ESC dialog.close() } else if (e.keyCode == 13) { inner() // save // @ts-expect-error Intentional - undefined if not present } else if (e.keyCode != 13 && e.target.localName != "textarea") { return } e.preventDefault() e.stopPropagation() }) } const graphcanvas = LGraphCanvas.active_canvas const canvas = graphcanvas.canvas const rect = canvas.getBoundingClientRect() let offsetx = -20 let offsety = -20 if (rect) { offsetx -= rect.left offsety -= rect.top } if (e) { dialog.style.left = e.clientX + offsetx + "px" dialog.style.top = e.clientY + offsety + "px" } else { dialog.style.left = canvas.width * 0.5 + offsetx + "px" dialog.style.top = canvas.height * 0.5 + offsety + "px" } const button = dialog.querySelector("button") button.addEventListener("click", inner) canvas.parentNode.appendChild(dialog) input?.focus() let dialogCloseTimer = null dialog.addEventListener("mouseleave", function () { if (LiteGraph.dialog_close_on_mouse_leave) if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); }) dialog.addEventListener("mouseenter", function () { if (LiteGraph.dialog_close_on_mouse_leave) if (dialogCloseTimer) clearTimeout(dialogCloseTimer) }) function inner() { if (input) setValue(input.value) } function setValue(value) { if (item.type == "Number") { value = Number(value) } else if (item.type == "Boolean") { value = Boolean(value) } node[property] = value dialog.parentNode?.removeChild(dialog) node.setDirtyCanvas(true, true) } } static getPropertyPrintableValue(value: unknown, values: unknown[] | object): string { if (!values) return String(value) if (Array.isArray(values)) { return String(value) } if (typeof values === "object") { let desc_value = "" for (const k in values) { if (values[k] != value) continue desc_value = k break } return String(value) + " (" + desc_value + ")" } } static onMenuNodeCollapse(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { node.graph.beforeChange( /*?*/) const fApplyMultiNode = function (node) { node.collapse() } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } node.graph.afterChange( /*?*/) } // eslint-disable-next-line @typescript-eslint/no-unused-vars static onMenuNodePin(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { } static onMenuNodeMode(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { new LiteGraph.ContextMenu( LiteGraph.NODE_MODES, { event: e, callback: inner_clicked, parentMenu: menu, node: node } ) function inner_clicked(v) { if (!node) return const kV = Object.values(LiteGraph.NODE_MODES).indexOf(v) const fApplyMultiNode = function (node) { if (kV >= 0 && LiteGraph.NODE_MODES[kV]) node.changeMode(kV) else { console.warn("unexpected mode: " + v) node.changeMode(LiteGraph.ALWAYS) } } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } } return false } /** @param value Parameter is never used */ static onMenuNodeColors(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { if (!node) throw "no node for color" const values: IContextMenuValue[] = [] values.push({ value: null, content: "No color" }) for (const i in LGraphCanvas.node_colors) { const color = LGraphCanvas.node_colors[i] value = { value: i, content: "" + i + "" } values.push(value) } new LiteGraph.ContextMenu(values, { event: e, callback: inner_clicked, parentMenu: menu, node: node }) function inner_clicked(v: { value: string | number }) { if (!node) return const color = v.value ? LGraphCanvas.node_colors[v.value] : null const fApplyColor = function (node: LGraphNode) { if (color) { if (node instanceof LGraphGroup) { node.color = color.groupcolor } else { node.color = color.color node.bgcolor = color.bgcolor } } else { delete node.color delete node.bgcolor } } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyColor(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyColor(graphcanvas.selected_nodes[i]) } } node.setDirtyCanvas(true, true) } return false } static onMenuNodeShapes(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { if (!node) throw "no node passed" new LiteGraph.ContextMenu(LiteGraph.VALID_SHAPES, { event: e, callback: inner_clicked, parentMenu: menu, node: node }) function inner_clicked(v) { if (!node) return node.graph.beforeChange( /*?*/) //node const fApplyMultiNode = function (node) { node.shape = v } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } node.graph.afterChange( /*?*/) //node node.setDirtyCanvas(true) } return false } static onMenuNodeRemove(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { if (!node) throw "no node passed" const graph = node.graph graph.beforeChange() const fApplyMultiNode = function (node: LGraphNode) { if (node.removable === false) return graph.remove(node) } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } graph.afterChange() node.setDirtyCanvas(true, true) } static onMenuNodeToSubgraph(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { const graph = node.graph const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas) return let nodes_list = Object.values(graphcanvas.selected_nodes || {}) if (!nodes_list.length) nodes_list = [node] const subgraph_node = LiteGraph.createNode("graph/subgraph") // @ts-expect-error Refactor this to use typed array. subgraph_node.pos = node.pos.concat() graph.add(subgraph_node) // @ts-expect-error Doesn't exist anywhere... subgraph_node.buildFromNodes(nodes_list) graphcanvas.deselectAllNodes() node.setDirtyCanvas(true, true) } static onMenuNodeClone(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { node.graph.beforeChange() const newSelected: Dictionary = {} const fApplyMultiNode = function (node) { if (node.clonable === false) return const newnode = node.clone() if (!newnode) return newnode.pos = [node.pos[0] + 5, node.pos[1] + 5] node.graph.add(newnode) newSelected[newnode.id] = newnode } const graphcanvas = LGraphCanvas.active_canvas if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { fApplyMultiNode(node) } else { for (const i in graphcanvas.selected_nodes) { fApplyMultiNode(graphcanvas.selected_nodes[i]) } } if (Object.keys(newSelected).length) { graphcanvas.selectNodes(newSelected) } node.graph.afterChange() node.setDirtyCanvas(true, true) } /** * clears all the data inside * */ clear(): void { this.frame = 0 this.last_draw_time = 0 this.render_time = 0 this.fps = 0 //this.scale = 1; //this.offset = [0,0]; this.dragging_rectangle = null this.selected_nodes = {} /** All selected groups */ this.selectedGroups = null /** The group currently being resized */ this.selected_group = null this.visible_nodes = [] this.node_dragged = null this.node_over = null this.node_capturing_input = null this.connecting_links = null this.highlighted_links = {} this.dragging_canvas = false this.dirty_canvas = true this.dirty_bgcanvas = true this.dirty_area = null this.node_in_panel = null this.node_widget = null this.last_mouse = [0, 0] this.last_mouseclick = 0 this.pointer_is_down = false this.pointer_is_double = false this.visible_area.set([0, 0, 0, 0]) this.onClear?.() } /** * assigns a graph, you can reassign graphs to the same canvas * * @param {LGraph} graph */ setGraph(graph: LGraph, skip_clear: boolean): void { if (this.graph == graph) return if (!skip_clear) this.clear() if (!graph && this.graph) { this.graph.detachCanvas(this) return } graph.attachCanvas(this) //remove the graph stack in case a subgraph was open this._graph_stack &&= null this.setDirty(true, true) } /** * returns the top level graph (in case there are subgraphs open on the canvas) * * @return {LGraph} graph */ getTopGraph(): LGraph { return this._graph_stack.length ? this._graph_stack[0] : this.graph } /** * opens a graph contained inside a node in the current graph * * @param {LGraph} graph */ openSubgraph(graph: LGraph): void { if (!graph) throw "graph cannot be null" if (this.graph == graph) throw "graph cannot be the same" this.clear() if (this.graph) { this._graph_stack ||= [] this._graph_stack.push(this.graph) } graph.attachCanvas(this) this.checkPanels() this.setDirty(true, true) } /** * closes a subgraph contained inside a node * * @param {LGraph} assigns a graph */ closeSubgraph(): void { if (!this._graph_stack || this._graph_stack.length == 0) return const subgraph_node = this.graph._subgraph_node const graph = this._graph_stack.pop() this.selected_nodes = {} this.highlighted_links = {} graph.attachCanvas(this) this.setDirty(true, true) if (subgraph_node) { this.centerOnNode(subgraph_node) this.selectNodes([subgraph_node]) } // when close sub graph back to offset [0, 0] scale 1 this.ds.offset = [0, 0] this.ds.scale = 1 } /** * returns the visually active graph (in case there are more in the stack) * @return {LGraph} the active graph */ getCurrentGraph(): LGraph { return this.graph } /** * Sets the current HTML canvas element. * Calls bindEvents to add input event listeners, and (re)creates the background canvas. * * @param canvas The canvas element to assign, or its HTML element ID. If null or undefined, the current reference is cleared. * @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation. */ setCanvas(canvas: string | HTMLCanvasElement & { data?: LGraphCanvas }, skip_events?: boolean) { let element: HTMLCanvasElement & { data?: LGraphCanvas } if (typeof canvas === "string") { const el = document.getElementById(canvas) if (!(el instanceof HTMLCanvasElement)) throw "Error creating LiteGraph canvas: Canvas not found" element = el } else { element = canvas } if (element === this.canvas) return //maybe detach events from old_canvas if (!element && this.canvas && !skip_events) this.unbindEvents() this.canvas = element this.ds.element = element if (!element) return // TODO: classList.add element.className += " lgraphcanvas" element.data = this // @ts-expect-error Likely safe to remove. A decent default, but expectation is to be configured by calling app. element.tabindex = "1" //to allow key events // Background canvas: To render objects behind nodes (background, links, groups) this.bgcanvas = null if (!this.bgcanvas) { this.bgcanvas = document.createElement("canvas") this.bgcanvas.width = this.canvas.width this.bgcanvas.height = this.canvas.height } if (element.getContext == null) { if (element.localName != "canvas") { throw "Element supplied for LGraphCanvas must be a element, you passed a " + element.localName } throw "This browser doesn't support Canvas" } const ctx = (this.ctx = element.getContext("2d")) if (ctx == null) { // @ts-expect-error WebGL if (!element.webgl_enabled) { console.warn( "This canvas seems to be WebGL, enabling WebGL renderer" ) } this.enableWebGL() } if (!skip_events) this.bindEvents() } //used in some events to capture them _doNothing(e: Event) { //console.log("pointerevents: _doNothing "+e.type); e.preventDefault() return false } _doReturnTrue(e: Event) { e.preventDefault() return true } /** * binds mouse, keyboard, touch and drag events to the canvas **/ bindEvents(): void { if (this._events_binded) { console.warn("LGraphCanvas: events already binded") return } //console.log("pointerevents: bindEvents"); const canvas = this.canvas const ref_window = this.getCanvasWindow() const document = ref_window.document //hack used when moving canvas between windows this._mousedown_callback = this.processMouseDown.bind(this) this._mousewheel_callback = this.processMouseWheel.bind(this) // why mousemove and mouseup were not binded here? this._mousemove_callback = this.processMouseMove.bind(this) this._mouseup_callback = this.processMouseUp.bind(this) this._mouseout_callback = this.processMouseOut.bind(this) LiteGraph.pointerListenerAdd(canvas, "down", this._mousedown_callback, true) //down do not need to store the binded canvas.addEventListener("mousewheel", this._mousewheel_callback, false) LiteGraph.pointerListenerAdd(canvas, "up", this._mouseup_callback, true) // CHECK: ??? binded or not LiteGraph.pointerListenerAdd(canvas, "move", this._mousemove_callback) canvas.addEventListener("pointerout", this._mouseout_callback) canvas.addEventListener("contextmenu", this._doNothing) canvas.addEventListener( "DOMMouseScroll", this._mousewheel_callback, false ) //Keyboard ****************** this._key_callback = this.processKey.bind(this) canvas.addEventListener("keydown", this._key_callback, true) document.addEventListener("keyup", this._key_callback, true) //in document, otherwise it doesn't fire keyup //Dropping Stuff over nodes ************************************ this._ondrop_callback = this.processDrop.bind(this) canvas.addEventListener("dragover", this._doNothing, false) canvas.addEventListener("dragend", this._doNothing, false) canvas.addEventListener("drop", this._ondrop_callback, false) canvas.addEventListener("dragenter", this._doReturnTrue, false) this._events_binded = true } /** * unbinds mouse events from the canvas **/ unbindEvents(): void { if (!this._events_binded) { console.warn("LGraphCanvas: no events binded") return } //console.log("pointerevents: unbindEvents"); const ref_window = this.getCanvasWindow() const document = ref_window.document this.canvas.removeEventListener("pointerout", this._mouseout_callback) LiteGraph.pointerListenerRemove(this.canvas, "move", this._mousemove_callback) LiteGraph.pointerListenerRemove(this.canvas, "up", this._mouseup_callback) LiteGraph.pointerListenerRemove(this.canvas, "down", this._mousedown_callback) this.canvas.removeEventListener( "mousewheel", this._mousewheel_callback ) this.canvas.removeEventListener( "DOMMouseScroll", this._mousewheel_callback ) this.canvas.removeEventListener("keydown", this._key_callback) document.removeEventListener("keyup", this._key_callback) this.canvas.removeEventListener("contextmenu", this._doNothing) this.canvas.removeEventListener("drop", this._ondrop_callback) this.canvas.removeEventListener("dragenter", this._doReturnTrue) this._mousedown_callback = null this._mousewheel_callback = null this._key_callback = null this._ondrop_callback = null this._events_binded = false } /** * this function allows to render the canvas using WebGL instead of Canvas2D * this is useful if you plant to render 3D objects inside your nodes, it uses litegl.js for webgl and canvas2DtoWebGL to emulate the Canvas2D calls in webGL **/ enableWebGL(): void { // TODO: Delete or move all webgl to a module and never load it. // @ts-expect-error if (typeof GL === "undefined") { throw "litegl.js must be included to use a WebGL canvas" } // @ts-expect-error if (typeof enableWebGLCanvas === "undefined") { throw "webglCanvas.js must be included to use this feature" } // @ts-expect-error this.gl = this.ctx = enableWebGLCanvas(this.canvas) // @ts-expect-error this.ctx.webgl = true this.bgcanvas = this.canvas this.bgctx = this.gl // @ts-expect-error this.canvas.webgl_enabled = true /* GL.create({ canvas: this.bgcanvas }); this.bgctx = enableWebGLCanvas( this.bgcanvas ); window.gl = this.gl; */ } /** * marks as dirty the canvas, this way it will be rendered again * * @class LGraphCanvas * @param {bool} fgcanvas if the foreground canvas is dirty (the one containing the nodes) * @param {bool} bgcanvas if the background canvas is dirty (the one containing the wires) */ setDirty(fgcanvas: boolean, bgcanvas?: boolean): void { if (fgcanvas) this.dirty_canvas = true if (bgcanvas) this.dirty_bgcanvas = true } /** * Used to attach the canvas in a popup * * @return {window} returns the window where the canvas is attached (the DOM root node) */ getCanvasWindow(): Window { if (!this.canvas) return window const doc = this.canvas.ownerDocument // @ts-expect-error Check if required return doc.defaultView || doc.parentWindow } /** * starts rendering the content of the canvas when needed * */ startRendering(): void { //already rendering if (this.is_rendering) return this.is_rendering = true renderFrame.call(this) function renderFrame(this: LGraphCanvas) { if (!this.pause_rendering) { this.draw() } const window = this.getCanvasWindow() if (this.is_rendering) { window.requestAnimationFrame(renderFrame.bind(this)) } } } /** * stops rendering the content of the canvas (to save resources) * */ stopRendering(): void { this.is_rendering = false /* if(this.rendering_timer_id) { clearInterval(this.rendering_timer_id); this.rendering_timer_id = null; } */ } /* LiteGraphCanvas input */ //used to block future mouse events (because of im gui) blockClick(): void { this.block_click = true this.last_mouseclick = 0 } /** * Gets the widget at the current cursor position * @param node Optional node to check for widgets under cursor * @returns The widget located at the current cursor position or null */ getWidgetAtCursor(node?: LGraphNode): IWidget | null { node ??= this.node_over if (!node.widgets) return null const graphPos = this.graph_mouse const x = graphPos[0] - node.pos[0] const y = graphPos[1] - node.pos[1] for (const widget of node.widgets) { let widgetWidth, widgetHeight if (widget.computeSize) { ([widgetWidth, widgetHeight] = widget.computeSize(node.size[0])) } else { widgetWidth = (widget).width || node.size[0] widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT } if ( widget.last_y !== undefined && x >= 6 && x <= widgetWidth - 12 && y >= widget.last_y && y <= widget.last_y + widgetHeight ) { return widget } } return null } /** * Clears highlight and mouse-over information from nodes that should not have it. * * Intended to be called when the pointer moves away from a node. * @param {LGraphNode} node The node that the mouse is now over * @param {MouseEvent} e MouseEvent that is triggering this */ updateMouseOverNodes(node: LGraphNode, e: CanvasMouseEvent): void { const nodes = this.graph._nodes const l = nodes.length for (let i = 0; i < l; ++i) { if (nodes[i].mouseOver && node != nodes[i]) { //mouse leave nodes[i].mouseOver = null this._highlight_input = null this._highlight_pos = null this.link_over_widget = null // Hover transitions // TODO: Implement single lerp ease factor for current progress on hover in/out. In drawNode, multiply by ease factor and differential value (e.g. bg alpha +0.5). nodes[i].lostFocusAt = LiteGraph.getTime() this.node_over?.onMouseLeave?.(e) this.node_over = null this.dirty_canvas = true } } } processMouseDown(e: CanvasPointerEvent): boolean { if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true if (!this.graph) return this.adjustMouseEvent(e) const ref_window = this.getCanvasWindow() LGraphCanvas.active_canvas = this const x = e.clientX const y = e.clientY //console.log(y,this.viewport); //console.log("pointerevents: processMouseDown pointerId:"+e.pointerId+" which:"+e.which+" isPrimary:"+e.isPrimary+" :: x y "+x+" "+y); this.ds.viewport = this.viewport const is_inside = !this.viewport || (this.viewport && x >= this.viewport[0] && x < (this.viewport[0] + this.viewport[2]) && y >= this.viewport[1] && y < (this.viewport[1] + this.viewport[3])) //move mouse move event to the window in case it drags outside of the canvas if (!this.options.skip_events) { LiteGraph.pointerListenerRemove(this.canvas, "move", this._mousemove_callback) LiteGraph.pointerListenerAdd(ref_window.document, "move", this._mousemove_callback, true) //catch for the entire window LiteGraph.pointerListenerAdd(ref_window.document, "up", this._mouseup_callback, true) } if (!is_inside) return let node = this.graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes, 5) let skip_action = false const now = LiteGraph.getTime() const is_primary = (e.isPrimary === undefined || !e.isPrimary) const is_double_click = (now - this.last_mouseclick < 300) this.mouse[0] = e.clientX this.mouse[1] = e.clientY this.graph_mouse[0] = e.canvasX this.graph_mouse[1] = e.canvasY this.last_click_position = [this.mouse[0], this.mouse[1]] this.pointer_is_double = this.pointer_is_down && is_primary this.pointer_is_down = true this.canvas.focus() LiteGraph.closeAllContextMenus(ref_window) if (this.onMouse?.(e) == true) return //left button mouse / single finger if (e.which == 1 && !this.pointer_is_double) { if ((e.metaKey || e.ctrlKey) && !e.altKey) { this.dragging_rectangle = new Float32Array(4) this.dragging_rectangle[0] = e.canvasX this.dragging_rectangle[1] = e.canvasY this.dragging_rectangle[2] = 1 this.dragging_rectangle[3] = 1 skip_action = true } // clone node ALT dragging if (LiteGraph.alt_drag_do_clone_nodes && e.altKey && !e.ctrlKey && node && this.allow_interaction && !skip_action && !this.read_only) { const node_data = node.clone()?.serialize() const cloned = LiteGraph.createNode(node_data.type) if (cloned) { cloned.configure(node_data) cloned.pos[0] += 5 cloned.pos[1] += 5 this.graph.add(cloned, false) node = cloned skip_action = true if (this.allow_dragnodes) { this.graph.beforeChange() this.node_dragged = node this.isDragging = true } if (!this.selected_nodes[node.id]) { this.processNodeSelected(node, e) } } } let clicking_canvas_bg = false //when clicked on top of a node //and it is not interactive if (node && (this.allow_interaction || node.flags.allow_interaction) && !skip_action && !this.read_only) { if (!this.live_mode && !node.flags.pinned) { this.bringToFront(node) } //if it wasn't selected? //not dragging mouse to connect two slots if (this.allow_interaction && !this.connecting_links && !node.flags.collapsed && !this.live_mode) { //Search for corner for resize if (!skip_action && node.resizable !== false && node.inResizeCorner(e.canvasX, e.canvasY)) { this.graph.beforeChange() this.resizing_node = node this.canvas.style.cursor = "se-resize" skip_action = true } else { //search for outputs if (node.outputs) { for (let i = 0, l = node.outputs.length; i < l; ++i) { const output = node.outputs[i] const link_pos = node.getConnectionPos(false, i) if (isInsideRectangle( e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20 )) { // Drag multiple output links if (e.shiftKey) { if (output.links?.length > 0) { this.connecting_links = [] for (const linkId of output.links) { const link = this.graph.links[linkId] const slot = link.target_slot const linked_node = this.graph._nodes_by_id[link.target_id] const input = linked_node.inputs[slot] const pos = linked_node.getConnectionPos(true, slot) this.connecting_links.push({ node: linked_node, slot: slot, input: input, output: null, pos: pos, direction: node.horizontal !== true ? LinkDirection.RIGHT : LinkDirection.CENTER, }) } skip_action = true break } } output.slot_index = i this.connecting_links = [ { node: node, slot: i, input: null, output: output, pos: link_pos, } ] if (LiteGraph.shift_click_do_break_link_from) { if (e.shiftKey) { node.disconnectOutput(i) } } else if (LiteGraph.ctrl_alt_click_do_break_link) { if (e.ctrlKey && e.altKey && !e.shiftKey) { node.disconnectOutput(i) } } if (is_double_click) { node.onOutputDblClick?.(i, e) } else { node.onOutputClick?.(i, e) } skip_action = true break } } } //search for inputs if (node.inputs) { for (let i = 0, l = node.inputs.length; i < l; ++i) { const input = node.inputs[i] const link_pos = node.getConnectionPos(true, i) if (isInsideRectangle( e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20 )) { if (is_double_click) { node.onInputDblClick?.(i, e) } else { node.onInputClick?.(i, e) } if (input.link !== null) { const link_info = this.graph.links[input.link] //before disconnecting const slot = link_info.origin_slot const linked_node = this.graph._nodes_by_id[link_info.origin_id] if (LiteGraph.click_do_break_link_to || (LiteGraph.ctrl_alt_click_do_break_link && e.ctrlKey && e.altKey && !e.shiftKey)) { node.disconnectInput(i) } else if (e.shiftKey) { this.connecting_links = [{ node: linked_node, slot, output: linked_node.outputs[slot], pos: linked_node.getConnectionPos(false, slot), }] this.dirty_bgcanvas = true skip_action = true } else if (this.allow_reconnect_links) { if (!LiteGraph.click_do_break_link_to) { node.disconnectInput(i) } this.connecting_links = [ { node: linked_node, slot: slot, input: null, output: linked_node.outputs[slot], pos: linked_node.getConnectionPos(false, slot), } ] this.dirty_bgcanvas = true skip_action = true } else { // do same action as has not node ? } } else { // has not node } if (!skip_action) { // connect from in to out, from to to from this.connecting_links = [ { node: node, slot: i, input: input, output: null, pos: link_pos, } ] this.dirty_bgcanvas = true skip_action = true } break } } } } //not resizing } //it wasn't clicked on the links boxes if (!skip_action) { let block_drag_node = node?.pinned ? true : false const pos: Point = [e.canvasX - node.pos[0], e.canvasY - node.pos[1]] //widgets const widget = this.processNodeWidgets(node, this.graph_mouse, e) if (widget) { block_drag_node = true this.node_widget = [node, widget] } //double clicking if (this.allow_interaction && is_double_click && this.selected_nodes[node.id]) { // Check if it's a double click on the title bar // Note: pos[1] is the y-coordinate of the node's body // If clicking on node header (title), pos[1] is negative if (pos[1] < 0) { node.onNodeTitleDblClick?.(e, pos, this) } //double click node node.onDblClick?.(e, pos, this) this.processNodeDblClicked(node) block_drag_node = true } //if do not capture mouse if (node.onMouseDown?.(e, pos, this)) { block_drag_node = true } else { //open subgraph button if (node.subgraph && !node.skip_subgraph_button) { if (!node.flags.collapsed && pos[0] > node.size[0] - LiteGraph.NODE_TITLE_HEIGHT && pos[1] < 0) { const that = this setTimeout(function () { that.openSubgraph(node.subgraph) }, 10) } } if (this.live_mode) { clicking_canvas_bg = true block_drag_node = true } } if (!block_drag_node) { if (this.allow_dragnodes) { this.graph.beforeChange() this.node_dragged = node this.isDragging = true } // Account for shift + click + drag if (!(e.shiftKey && !e.ctrlKey && !e.altKey) || !node.is_selected) { this.processNodeSelected(node, e) } } else { // double-click /** * Don't call the function if the block is already selected. * Otherwise, it could cause the block to be unselected while its panel is open. */ if (!node.is_selected) this.processNodeSelected(node, e) } this.dirty_canvas = true } } //clicked outside of nodes else { if (!skip_action) { //search for link connector if (!this.read_only) { // Set the width of the line for isPointInStroke checks const lineWidth = this.ctx.lineWidth this.ctx.lineWidth = this.connections_width + 7 for (let i = 0; i < this.visible_links.length; ++i) { const link = this.visible_links[i] const center = link._pos let overLink = null if (!center || e.canvasX < center[0] - 4 || e.canvasX > center[0] + 4 || e.canvasY < center[1] - 4 || e.canvasY > center[1] + 4) { // If we shift click on a link then start a link from that input if (e.shiftKey && link.path && this.ctx.isPointInStroke(link.path, e.canvasX, e.canvasY)) { overLink = link } else { continue } } if (overLink) { const slot = overLink.origin_slot const originNode = this.graph._nodes_by_id[overLink.origin_id] this.connecting_links ??= [] this.connecting_links.push({ node: originNode, slot, output: originNode.outputs[slot], pos: originNode.getConnectionPos(false, slot), }) skip_action = true } else { //link clicked this.showLinkMenu(link, e) this.over_link_center = null //clear tooltip } break } // Restore line width this.ctx.lineWidth = lineWidth } this.selected_group = this.graph.getGroupOnPos(e.canvasX, e.canvasY) this.selected_group_resizing = false const group = this.selected_group if (this.selected_group && !this.read_only) { if (e.ctrlKey) { this.dragging_rectangle = null } const dist = distance([e.canvasX, e.canvasY], [this.selected_group.pos[0] + this.selected_group.size[0], this.selected_group.pos[1] + this.selected_group.size[1]]) if (dist * this.ds.scale < 10) { this.selected_group_resizing = true } else { const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE const headerHeight = f * 1.4 if (isInsideRectangle(e.canvasX, e.canvasY, group.pos[0], group.pos[1], group.size[0], headerHeight)) { this.selected_group.recomputeInsideNodes() if (!e.shiftKey && !e.ctrlKey && !e.metaKey) this.deselectAllNodes() this.selectedGroups ??= new Set() this.selectedGroups.add(group) group.selected = true this.isDragging = true skip_action = true } } if (is_double_click) { this.emitEvent({ subType: "group-double-click", originalEvent: e, group: this.selected_group, }) } } else if (is_double_click && !this.read_only) { // Double click within group should not trigger the searchbox. if (this.allow_searchbox) { this.showSearchBox(e) e.preventDefault() e.stopPropagation() } this.emitEvent({ subType: "empty-double-click", originalEvent: e, }) } clicking_canvas_bg = true } } if (!skip_action && clicking_canvas_bg && this.allow_dragcanvas) { //console.log("pointerevents: dragging_canvas start"); this.dragging_canvas = true } } else if (e.which == 2) { //middle button if (LiteGraph.middle_click_slot_add_default_node) { if (node && this.allow_interaction && !skip_action && !this.read_only) { //not dragging mouse to connect two slots if (!this.connecting_links && !node.flags.collapsed && !this.live_mode) { let mClikSlot: INodeSlot | false = false let mClikSlot_index: number | false = false let mClikSlot_isOut: boolean = false //search for outputs if (node.outputs) { for (let i = 0, l = node.outputs.length; i < l; ++i) { const output = node.outputs[i] const link_pos = node.getConnectionPos(false, i) if (isInsideRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { mClikSlot = output mClikSlot_index = i mClikSlot_isOut = true break } } } //search for inputs if (node.inputs) { for (let i = 0, l = node.inputs.length; i < l; ++i) { const input = node.inputs[i] const link_pos = node.getConnectionPos(true, i) if (isInsideRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { mClikSlot = input mClikSlot_index = i mClikSlot_isOut = false break } } } //console.log("middleClickSlots? "+mClikSlot+" & "+(mClikSlot_index!==false)); if (mClikSlot && mClikSlot_index !== false) { const alphaPosY = 0.5 - ((mClikSlot_index + 1) / ((mClikSlot_isOut ? node.outputs.length : node.inputs.length))) const node_bounding = node.getBounding() // estimate a position: this is a bad semi-bad-working mess .. REFACTOR with a correct autoplacement that knows about the others slots and nodes const posRef: Point = [(!mClikSlot_isOut ? node_bounding[0] : node_bounding[0] + node_bounding[2]) // + node_bounding[0]/this.canvas.width*150 , e.canvasY - 80 // + node_bounding[0]/this.canvas.width*66 // vertical "derive" ] // eslint-disable-next-line @typescript-eslint/no-unused-vars const nodeCreated = this.createDefaultNodeForSlot({ nodeFrom: !mClikSlot_isOut ? null : node, slotFrom: !mClikSlot_isOut ? null : mClikSlot_index, nodeTo: !mClikSlot_isOut ? node : null, slotTo: !mClikSlot_isOut ? mClikSlot_index : null, //,e: e position: posRef, //nodeNewType nodeType: "AUTO", //-alphaPosY*30] posAdd: [!mClikSlot_isOut ? -30 : 30, -alphaPosY * 130], //-alphaPosY*2*/ posSizeFix: [!mClikSlot_isOut ? -1 : 0, 0] }) skip_action = true } } } } if (!skip_action && this.allow_dragcanvas) { //console.log("pointerevents: dragging_canvas start from middle button"); this.dragging_canvas = true } } else if (e.which == 3 || this.pointer_is_double) { //right button if (this.allow_interaction && !skip_action && !this.read_only) { // is it hover a node ? if (node) { if (Object.keys(this.selected_nodes).length && (this.selected_nodes[node.id] || e.shiftKey || e.ctrlKey || e.metaKey)) { // is multiselected or using shift to include the now node if (!this.selected_nodes[node.id]) this.selectNodes([node], true) // add this if not present } else { // update selection this.selectNodes([node]) } } // Show context menu for the node or group under the pointer this.processContextMenu(node, e) } } //TODO //if(this.node_selected != prev_selected) // this.onNodeSelectionChange(this.node_selected); this.last_mouse[0] = e.clientX this.last_mouse[1] = e.clientY this.last_mouseclick = LiteGraph.getTime() this.last_mouse_dragging = true /* if( (this.dirty_canvas || this.dirty_bgcanvas) && this.rendering_timer_id == null) this.draw(); */ this.graph.change() //this is to ensure to defocus(blur) if a text input element is on focus if (!ref_window.document.activeElement || (ref_window.document.activeElement.nodeName.toLowerCase() != "input" && ref_window.document.activeElement.nodeName.toLowerCase() != "textarea")) { e.preventDefault() } e.stopPropagation() this.onMouseDown?.(e) return false } /** * Called when a mouse move event has to be processed **/ processMouseMove(e: CanvasMouseEvent): boolean { if (this.autoresize) this.resize() if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true if (!this.graph) return LGraphCanvas.active_canvas = this this.adjustMouseEvent(e) const mouse: Point = [e.clientX, e.clientY] this.mouse[0] = mouse[0] this.mouse[1] = mouse[1] const delta = [ mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1] ] this.last_mouse = mouse this.graph_mouse[0] = e.canvasX this.graph_mouse[1] = e.canvasY //console.log("pointerevents: processMouseMove "+e.pointerId+" "+e.isPrimary); if (this.block_click) { //console.log("pointerevents: processMouseMove block_click"); e.preventDefault() return false } e.dragging = this.last_mouse_dragging if (this.node_widget) { this.processNodeWidgets( this.node_widget[0], this.graph_mouse, e, this.node_widget[1] ) this.dirty_canvas = true } //get node over const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) if (this.dragging_rectangle) { this.dragging_rectangle[2] = e.canvasX - this.dragging_rectangle[0] this.dragging_rectangle[3] = e.canvasY - this.dragging_rectangle[1] this.dirty_canvas = true } else if (this.selected_group_resizing && !this.read_only) { //moving/resizing a group this.selected_group.resize( e.canvasX - this.selected_group.pos[0], e.canvasY - this.selected_group.pos[1] ) this.dirty_bgcanvas = true } else if (this.dragging_canvas) { ////console.log("pointerevents: processMouseMove is dragging_canvas"); this.ds.offset[0] += delta[0] / this.ds.scale this.ds.offset[1] += delta[1] / this.ds.scale this.dirty_canvas = true this.dirty_bgcanvas = true } else if ((this.allow_interaction || (node && node.flags.allow_interaction)) && !this.read_only) { if (this.connecting_links) { this.dirty_canvas = true } //remove mouseover flag this.updateMouseOverNodes(node, e) //mouse over a node if (node) { if (node.redraw_on_mouse) this.dirty_canvas = true // For input/output hovering //to store the output of isOverNodeInput const pos: Point = [0, 0] const inputId = this.isOverNodeInput(node, e.canvasX, e.canvasY, pos) const outputId = this.isOverNodeOutput(node, e.canvasX, e.canvasY, pos) const overWidget = this.getWidgetAtCursor(node) if (!node.mouseOver) { //mouse enter node.mouseOver = { inputId: null, outputId: null, overWidget: null, } this.node_over = node this.dirty_canvas = true node.onMouseEnter?.(e) } //in case the node wants to do something node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this) // The input the mouse is over has changed if (node.mouseOver.inputId !== inputId || node.mouseOver.outputId !== outputId || node.mouseOver.overWidget !== overWidget) { node.mouseOver.inputId = inputId node.mouseOver.outputId = outputId node.mouseOver.overWidget = overWidget // Check if link is over anything it could connect to - record position of valid target for snap / highlight if (this.connecting_links) { const firstLink = this.connecting_links[0] // Default: nothing highlighted let highlightPos: Point = null let highlightInput: INodeInputSlot = null let linkOverWidget: IWidget = null if (firstLink.node === node) { // Cannot connect link from a node to itself } else if (firstLink.output) { // Connecting from an output to an input if (inputId === -1 && outputId === -1) { // Allow support for linking to widgets, handled externally to LiteGraph if (this.getWidgetLinkType && overWidget) { const widgetLinkType = this.getWidgetLinkType(overWidget, node) if (widgetLinkType && LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType)) { if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { linkOverWidget = overWidget this.link_over_widget_type = widgetLinkType } } } // Node background / title under the pointer if (!linkOverWidget) { const targetSlotId = firstLink.node.findConnectByTypeSlot(true, node, firstLink.output.type) if (targetSlotId !== null && targetSlotId >= 0) { node.getConnectionPos(true, targetSlotId, pos) highlightPos = pos highlightInput = node.inputs[targetSlotId] } } } else if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { //mouse on top of the corner box, don't know what to do } else { //check if I have a slot below de mouse if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { highlightPos = pos highlightInput = node.inputs[inputId] // XXX CHECK THIS } } } else if (firstLink.input) { // Connecting from an input to an output if (inputId === -1 && outputId === -1) { const targetSlotId = firstLink.node.findConnectByTypeSlot(false, node, firstLink.input.type) if (targetSlotId !== null && targetSlotId >= 0) { node.getConnectionPos(false, targetSlotId, pos) highlightPos = pos } } else if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { //mouse on top of the corner box, don't know what to do } else { //check if I have a slot below de mouse if (outputId != -1 && node.outputs[outputId] && LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type)) { highlightPos = pos } } } this._highlight_pos = highlightPos this._highlight_input = highlightInput this.link_over_widget = linkOverWidget } this.dirty_canvas = true } //Search for corner if (this.canvas) { this.canvas.style.cursor = node.inResizeCorner(e.canvasX, e.canvasY) ? "se-resize" : "crosshair" } } else { //not over a node //search for link connector let over_link: LLink = null for (let i = 0; i < this.visible_links.length; ++i) { const link = this.visible_links[i] const center = link._pos if (!center || e.canvasX < center[0] - 4 || e.canvasX > center[0] + 4 || e.canvasY < center[1] - 4 || e.canvasY > center[1] + 4) { continue } over_link = link break } if (over_link != this.over_link_center) { this.over_link_center = over_link this.dirty_canvas = true } if (this.canvas) { this.canvas.style.cursor = "" } } //end //send event to node if capturing input (used with widgets that allow drag outside of the area of the node) if (this.node_capturing_input && this.node_capturing_input != node) { this.node_capturing_input.onMouseMove?.(e, [e.canvasX - this.node_capturing_input.pos[0], e.canvasY - this.node_capturing_input.pos[1]], this) } //node being dragged if (this.isDragging && !this.live_mode) { //console.log("draggin!",this.selected_nodes); const nodes = new Set() const deltax = delta[0] / this.ds.scale const deltay = delta[1] / this.ds.scale for (const i in this.selected_nodes) { const n = this.selected_nodes[i] nodes.add(n) n.pos[0] += delta[0] / this.ds.scale n.pos[1] += delta[1] / this.ds.scale /* * Don't call the function if the block is already selected. * Otherwise, it could cause the block to be unselected while dragging. */ if (!n.is_selected) this.processNodeSelected(n, e) } if (this.selectedGroups) { for (const group of this.selectedGroups) { group.move(deltax, deltay, true) if (!e.ctrlKey) { for (const node of group._nodes) { if (!nodes.has(node)) { node.pos[0] += deltax node.pos[1] += deltay } } } } } this.dirty_canvas = true this.dirty_bgcanvas = true } if (this.resizing_node && !this.live_mode) { //convert mouse to node space const desired_size: Size = [e.canvasX - this.resizing_node.pos[0], e.canvasY - this.resizing_node.pos[1]] const min_size = this.resizing_node.computeSize() desired_size[0] = Math.max(min_size[0], desired_size[0]) desired_size[1] = Math.max(min_size[1], desired_size[1]) this.resizing_node.setSize(desired_size) this.canvas.style.cursor = "se-resize" this.dirty_canvas = true this.dirty_bgcanvas = true } } e.preventDefault() return false } /** * Called when a mouse up event has to be processed **/ processMouseUp(e: CanvasPointerEvent): boolean { const is_primary = (e.isPrimary === undefined || e.isPrimary) //early exit for extra pointer if (!is_primary) return false //console.log("pointerevents: processMouseUp "+e.pointerId+" "+e.isPrimary+" :: "+e.clientX+" "+e.clientY); if (!this.graph) return const window = this.getCanvasWindow() const document = window.document LGraphCanvas.active_canvas = this //restore the mousemove event back to the canvas if (!this.options.skip_events) { //console.log("pointerevents: processMouseUp adjustEventListener"); LiteGraph.pointerListenerRemove(document, "move", this._mousemove_callback, true) LiteGraph.pointerListenerAdd(this.canvas, "move", this._mousemove_callback, true) LiteGraph.pointerListenerRemove(document, "up", this._mouseup_callback, true) } this.adjustMouseEvent(e) const now = LiteGraph.getTime() e.click_time = now - this.last_mouseclick this.last_mouse_dragging = false this.last_click_position = null //used to avoid sending twice a click in an immediate button this.block_click &&= false //console.log("pointerevents: processMouseUp which: "+e.which); if (e.which == 1) { if (this.node_widget) { this.processNodeWidgets(this.node_widget[0], this.graph_mouse, e) } //left button this.node_widget = null if (this.selected_group) { const diffx = this.selected_group.pos[0] - Math.round(this.selected_group.pos[0]) const diffy = this.selected_group.pos[1] - Math.round(this.selected_group.pos[1]) this.selected_group.move(diffx, diffy, e.ctrlKey) this.selected_group.pos[0] = Math.round( this.selected_group.pos[0] ) this.selected_group.pos[1] = Math.round( this.selected_group.pos[1] ) if (this.selected_group._nodes.length) { this.dirty_canvas = true } this.selected_group = null } this.selected_group_resizing = false this.isDragging = false let node = this.graph.getNodeOnPos( e.canvasX, e.canvasY, this.visible_nodes ) if (this.dragging_rectangle) { if (this.graph) { const nodes = this.graph._nodes const node_bounding = new Float32Array(4) //compute bounding and flip if left to right const w = Math.abs(this.dragging_rectangle[2]) const h = Math.abs(this.dragging_rectangle[3]) const startx = this.dragging_rectangle[2] < 0 ? this.dragging_rectangle[0] - w : this.dragging_rectangle[0] const starty = this.dragging_rectangle[3] < 0 ? this.dragging_rectangle[1] - h : this.dragging_rectangle[1] this.dragging_rectangle[0] = startx this.dragging_rectangle[1] = starty this.dragging_rectangle[2] = w this.dragging_rectangle[3] = h // test dragging rect size, if minimun simulate a click if (!node || (w > 10 && h > 10)) { //test against all nodes (not visible because the rectangle maybe start outside const to_select = [] for (let i = 0; i < nodes.length; ++i) { const nodeX = nodes[i] nodeX.getBounding(node_bounding) if (!overlapBounding( this.dragging_rectangle, node_bounding )) { continue } //out of the visible area to_select.push(nodeX) } if (to_select.length) { this.selectNodes(to_select, e.shiftKey) // add to selection with shift } // Select groups if (!e.shiftKey) this.deselectGroups() this.selectedGroups ??= new Set() const groups = this.graph.groups for (const group of groups) { const r = this.dragging_rectangle const pos = group.pos const size = group.size if (!isInsideRectangle(pos[0], pos[1], r[0], r[1], r[2], r[3]) || !isInsideRectangle(pos[0] + size[0], pos[1] + size[1], r[0], r[1], r[2], r[3])) continue this.selectedGroups.add(group) group.recomputeInsideNodes() group.selected = true } } else { // will select of update selection this.selectNodes([node], e.shiftKey || e.ctrlKey || e.metaKey) // add to selection add to selection with ctrlKey or shiftKey } } this.dragging_rectangle = null } else if (this.connecting_links) { //node below mouse if (node) { for (const link of this.connecting_links) { //dragging a connection this.dirty_canvas = true this.dirty_bgcanvas = true /* no need to condition on event type.. just another type if ( connType == LiteGraph.EVENT && this.isOverNodeBox(node, e.canvasX, e.canvasY) ) { this.connecting_node.connect( this.connecting_slot, node, LiteGraph.EVENT ); } else {*/ //slot below mouse? connect if (link.output) { const slot = this.isOverNodeInput( node, e.canvasX, e.canvasY ) if (slot != -1) { link.node.connect(link.slot, node, slot) } else if (this.link_over_widget) { this.emitEvent({ subType: "connectingWidgetLink", link, node, widget: this.link_over_widget }) this.link_over_widget = null } else { //not on top of an input // look for a good slot link.node.connectByType(link.slot, node, link.output.type) } } else if (link.input) { const slot = this.isOverNodeOutput( node, e.canvasX, e.canvasY ) if (slot != -1) { node.connect(slot, link.node, link.slot) // this is inverted has output-input nature like } else { //not on top of an input // look for a good slot link.node.connectByTypeOutput(link.slot, node, link.input.type) } } } } else { const firstLink = this.connecting_links[0] const linkReleaseContext = firstLink.output ? { node_from: firstLink.node, slot_from: firstLink.output, type_filter_in: firstLink.output.type } : { node_to: firstLink.node, slot_from: firstLink.input, type_filter_out: firstLink.input.type } // For external event only. const linkReleaseContextExtended: LinkReleaseContextExtended = { links: this.connecting_links, } this.emitEvent({ subType: "empty-release", originalEvent: e, linkReleaseContext: linkReleaseContextExtended, }) // add menu when releasing link in empty space if (LiteGraph.release_link_on_empty_shows_menu) { if (e.shiftKey) { if (this.allow_searchbox) { this.showSearchBox(e, linkReleaseContext) } } else { if (firstLink.output) { this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.output, e: e }) } else if (firstLink.input) { this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.input, e: e }) } } } } this.connecting_links = null } //not dragging connection else if (this.resizing_node) { this.dirty_canvas = true this.dirty_bgcanvas = true this.graph.afterChange(this.resizing_node) this.resizing_node = null } else if (this.node_dragged) { //node being dragged? node = this.node_dragged if (node && e.click_time < 300 && isInsideRectangle(e.canvasX, e.canvasY, node.pos[0], node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT, LiteGraph.NODE_TITLE_HEIGHT, LiteGraph.NODE_TITLE_HEIGHT)) { node.collapse() } this.dirty_canvas = true this.dirty_bgcanvas = true this.node_dragged.pos[0] = Math.round(this.node_dragged.pos[0]) this.node_dragged.pos[1] = Math.round(this.node_dragged.pos[1]) if (this.graph.config.align_to_grid || this.align_to_grid) { this.node_dragged.alignToGrid() } this.onNodeMoved?.(this.node_dragged) this.graph.afterChange(this.node_dragged) this.node_dragged = null } //no node being dragged else { //get node over node = this.graph.getNodeOnPos( e.canvasX, e.canvasY, this.visible_nodes ) if (!node && e.click_time < 300 && !this.graph.groups.some(x => x.isPointInTitlebar(e.canvasX, e.canvasY))) { this.deselectAllNodes() } this.dirty_canvas = true this.dragging_canvas = false // @ts-expect-error Unused param this.node_over?.onMouseUp?.(e, [e.canvasX - this.node_over.pos[0], e.canvasY - this.node_over.pos[1]], this) this.node_capturing_input?.onMouseUp?.(e, [ e.canvasX - this.node_capturing_input.pos[0], e.canvasY - this.node_capturing_input.pos[1] ]) } } else if (e.which == 2) { //middle button //trace("middle"); this.dirty_canvas = true this.dragging_canvas = false } else if (e.which == 3) { //right button //trace("right"); this.dirty_canvas = true this.dragging_canvas = false } /* if((this.dirty_canvas || this.dirty_bgcanvas) && this.rendering_timer_id == null) this.draw(); */ if (is_primary) { this.pointer_is_down = false this.pointer_is_double = false } this.graph.change() e.stopPropagation() e.preventDefault() return false } /** * Called when the mouse moves off the canvas. Clears all node hover states. * @param e */ processMouseOut(e: CanvasMouseEvent): void { // TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc. this.updateMouseOverNodes(null, e) } /** * Called when a mouse wheel event has to be processed **/ processMouseWheel(e: CanvasWheelEvent): boolean { if (!this.graph || !this.allow_dragcanvas) return // TODO: Mouse wheel zoom rewrite // @ts-expect-error const delta = e.wheelDeltaY ?? e.detail * -60 this.adjustMouseEvent(e) const pos: Point = [e.clientX, e.clientY] if (this.viewport && !isPointInRectangle(pos, this.viewport)) return let scale = this.ds.scale if (delta > 0) scale *= this.zoom_speed else if (delta < 0) scale *= 1 / this.zoom_speed this.ds.changeScale(scale, [e.clientX, e.clientY]) this.graph.change() e.preventDefault() return false } /** * returns true if a position (in graph space) is on top of a node little corner box **/ isOverNodeBox(node: LGraphNode, canvasx: number, canvasy: number): boolean { const title_height = LiteGraph.NODE_TITLE_HEIGHT return Boolean(isInsideRectangle( canvasx, canvasy, node.pos[0] + 2, node.pos[1] + 2 - title_height, title_height - 4, title_height - 4 )) } /** * returns the INDEX if a position (in graph space) is on top of a node input slot **/ isOverNodeInput(node: LGraphNode, canvasx: number, canvasy: number, slot_pos?: Point): number { if (node.inputs) { for (let i = 0, l = node.inputs.length; i < l; ++i) { const input = node.inputs[i] const link_pos = node.getConnectionPos(true, i) let is_inside = false if (node.horizontal) { is_inside = isInsideRectangle( canvasx, canvasy, link_pos[0] - 5, link_pos[1] - 10, 10, 20 ) } else { // TODO: Find a cheap way to measure text, and do it on node label change instead of here // Input icon width + text approximation const width = 20 + (((input.label?.length ?? input.name?.length) || 3) * 7) is_inside = isInsideRectangle( canvasx, canvasy, link_pos[0] - 10, link_pos[1] - 10, width, 20 ) } if (is_inside) { if (slot_pos) { slot_pos[0] = link_pos[0] slot_pos[1] = link_pos[1] } return i } } } return -1 } /** * returns the INDEX if a position (in graph space) is on top of a node output slot **/ isOverNodeOutput(node: LGraphNode, canvasx: number, canvasy: number, slot_pos?: Point): number { if (node.outputs) { for (let i = 0, l = node.outputs.length; i < l; ++i) { const link_pos = node.getConnectionPos(false, i) let is_inside = false if (node.horizontal) { is_inside = isInsideRectangle( canvasx, canvasy, link_pos[0] - 5, link_pos[1] - 10, 10, 20 ) } else { is_inside = isInsideRectangle( canvasx, canvasy, link_pos[0] - 10, link_pos[1] - 10, 40, 20 ) } if (is_inside) { if (slot_pos) { slot_pos[0] = link_pos[0] slot_pos[1] = link_pos[1] } return i } } } return -1 } /** * process a key event **/ processKey(e: KeyboardEvent): boolean | null { if (!this.graph) return let block_default = false //console.log(e); //debug // @ts-expect-error if (e.target.localName == "input") return if (e.type == "keydown") { // TODO: Switch if (e.keyCode == 32) { // space this.read_only = true if (this._previously_dragging_canvas === null) { this._previously_dragging_canvas = this.dragging_canvas } this.dragging_canvas = this.pointer_is_down block_default = true } else if (e.keyCode == 27) { //esc this.node_panel?.close() this.options_panel?.close() block_default = true } //select all Control A else if (e.keyCode == 65 && e.ctrlKey) { this.selectNodes() block_default = true } else if ((e.keyCode === 67) && (e.metaKey || e.ctrlKey) && !e.shiftKey) { //copy if (this.selected_nodes) { this.copyToClipboard() block_default = true } } else if ((e.keyCode === 86) && (e.metaKey || e.ctrlKey)) { //paste this.pasteFromClipboard(e.shiftKey) } //delete or backspace else if (e.keyCode == 46 || e.keyCode == 8) { // @ts-expect-error if (e.target.localName != "input" && e.target.localName != "textarea") { this.deleteSelectedNodes() block_default = true } } //collapse //... //TODO if (this.selected_nodes) { for (const i in this.selected_nodes) { this.selected_nodes[i].onKeyDown?.(e) } } } else if (e.type == "keyup") { if (e.keyCode == 32) { // space this.read_only = false this.dragging_canvas = this._previously_dragging_canvas ?? false this._previously_dragging_canvas = null } if (this.selected_nodes) { for (const i in this.selected_nodes) { this.selected_nodes[i].onKeyUp?.(e) } } } // TODO: Do we need to remeasure and recalculate everything on every key down/up? this.graph.change() if (block_default) { e.preventDefault() e.stopImmediatePropagation() return false } } copyToClipboard(nodes?: Dictionary): void { const clipboard_info: IClipboardContents = { nodes: [], links: [] } let index = 0 const selected_nodes_array: LGraphNode[] = [] if (!nodes) nodes = this.selected_nodes for (const i in nodes) { const node = nodes[i] if (node.clonable === false) continue node._relative_id = index selected_nodes_array.push(node) index += 1 } for (let i = 0; i < selected_nodes_array.length; ++i) { const node = selected_nodes_array[i] const cloned = node.clone() if (!cloned) { console.warn("node type not found: " + node.type) continue } clipboard_info.nodes.push(cloned.serialize()) if (node.inputs?.length) { for (let j = 0; j < node.inputs.length; ++j) { const input = node.inputs[j] if (!input || input.link == null) continue const link_info = this.graph.links[input.link] if (!link_info) continue const target_node = this.graph.getNodeById(link_info.origin_id) if (!target_node) continue clipboard_info.links.push([ target_node._relative_id, link_info.origin_slot, //j, node._relative_id, link_info.target_slot, target_node.id ]) } } } localStorage.setItem( "litegrapheditor_clipboard", JSON.stringify(clipboard_info) ) } emitEvent(detail: CanvasEventDetail): void { this.canvas.dispatchEvent(new CustomEvent( "litegraph:canvas", { bubbles: true, detail } )) } emitBeforeChange(): void { this.emitEvent({ subType: "before-change", }) } emitAfterChange(): void { this.emitEvent({ subType: "after-change", }) } _pasteFromClipboard(isConnectUnselected = false): void { // if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && isConnectUnselected) return const data = localStorage.getItem("litegrapheditor_clipboard") if (!data) return this.graph.beforeChange() //create nodes const clipboard_info: IClipboardContents = JSON.parse(data) // calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos let posMin: false | [number, number] = false let posMinIndexes: false | [number, number] = false for (let i = 0; i < clipboard_info.nodes.length; ++i) { if (posMin) { if (posMin[0] > clipboard_info.nodes[i].pos[0]) { posMin[0] = clipboard_info.nodes[i].pos[0] posMinIndexes[0] = i } if (posMin[1] > clipboard_info.nodes[i].pos[1]) { posMin[1] = clipboard_info.nodes[i].pos[1] posMinIndexes[1] = i } } else { posMin = [clipboard_info.nodes[i].pos[0], clipboard_info.nodes[i].pos[1]] posMinIndexes = [i, i] } } const nodes: LGraphNode[] = [] for (let i = 0; i < clipboard_info.nodes.length; ++i) { const node_data = clipboard_info.nodes[i] const node = LiteGraph.createNode(node_data.type) if (node) { node.configure(node_data) //paste in last known mouse position node.pos[0] += this.graph_mouse[0] - posMin[0] //+= 5; node.pos[1] += this.graph_mouse[1] - posMin[1] //+= 5; this.graph.add(node, true) nodes.push(node) } } //create links for (let i = 0; i < clipboard_info.links.length; ++i) { const link_info = clipboard_info.links[i] let origin_node: LGraphNode = undefined const origin_node_relative_id = link_info[0] if (origin_node_relative_id != null) { origin_node = nodes[origin_node_relative_id] } else if (LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && isConnectUnselected) { const origin_node_id = link_info[4] if (origin_node_id) { origin_node = this.graph.getNodeById(origin_node_id) } } const target_node = nodes[link_info[2]] if (origin_node && target_node) origin_node.connect(link_info[1], target_node, link_info[3]) else console.warn("Warning, nodes missing on pasting") } this.selectNodes(nodes) this.graph.afterChange() } pasteFromClipboard(isConnectUnselected = false): void { this.emitBeforeChange() try { this._pasteFromClipboard(isConnectUnselected) } finally { this.emitAfterChange() } } /** * process a item drop event on top the canvas **/ processDrop(e: CanvasDragEvent): boolean { e.preventDefault() this.adjustMouseEvent(e) const x = e.clientX const y = e.clientY const is_inside = !this.viewport || (this.viewport && x >= this.viewport[0] && x < (this.viewport[0] + this.viewport[2]) && y >= this.viewport[1] && y < (this.viewport[1] + this.viewport[3])) if (!is_inside) return const pos = [e.canvasX, e.canvasY] const node = this.graph ? this.graph.getNodeOnPos(pos[0], pos[1]) : null if (!node) { const r = this.onDropItem?.(e) if (!r) this.checkDropItem(e) return } if (node.onDropFile || node.onDropData) { const files = e.dataTransfer.files if (files && files.length) { for (let i = 0; i < files.length; i++) { const file = e.dataTransfer.files[0] const filename = file.name node.onDropFile?.(file) if (node.onDropData) { //prepare reader const reader = new FileReader() reader.onload = function (event) { const data = event.target.result node.onDropData(data, filename, file) } //read data const type = file.type.split("/")[0] if (type == "text" || type == "") { reader.readAsText(file) } else if (type == "image") { reader.readAsDataURL(file) } else { reader.readAsArrayBuffer(file) } } } } } if (node.onDropItem?.(e)) return true return this.onDropItem ? this.onDropItem(e) : false } //called if the graph doesn't have a default drop item behaviour checkDropItem(e: CanvasDragEvent): void { if (!e.dataTransfer.files.length) return const file = e.dataTransfer.files[0] const ext = LGraphCanvas.getFileExtension(file.name).toLowerCase() const nodetype = LiteGraph.node_types_by_file_extension[ext] if (!nodetype) return this.graph.beforeChange() const node = LiteGraph.createNode(nodetype.type) node.pos = [e.canvasX, e.canvasY] this.graph.add(node) node.onDropFile?.(file) this.graph.afterChange() } processNodeDblClicked(n: LGraphNode): void { this.onShowNodePanel?.(n) this.onNodeDblClicked?.(n) this.setDirty(true) } processNodeSelected(node: LGraphNode, e: CanvasMouseEvent): void { this.selectNode(node, e && (e.shiftKey || e.metaKey || e.ctrlKey || this.multi_select)) this.onNodeSelected?.(node) } /** * selects a given node (or adds it to the current selection) **/ selectNode(node: LGraphNode, add_to_current_selection?: boolean): void { if (node == null) { this.deselectAllNodes() } else { this.selectNodes([node], add_to_current_selection) } } /** * selects several nodes (or adds them to the current selection) **/ selectNodes(nodes?: LGraphNode[] | Dictionary, add_to_current_selection?: boolean): void { if (!add_to_current_selection) { this.deselectAllNodes() } nodes = nodes || this.graph._nodes if (typeof nodes == "string") nodes = [nodes] for (const i in nodes) { const node: LGraphNode = nodes[i] if (node.is_selected) { this.deselectNode(node) continue } if (!node.is_selected) { node.onSelected?.() } node.is_selected = true this.selected_nodes[node.id] = node if (node.inputs) { for (let j = 0; j < node.inputs.length; ++j) { this.highlighted_links[node.inputs[j].link] = true } } if (node.outputs) { for (let j = 0; j < node.outputs.length; ++j) { const out = node.outputs[j] if (out.links) { for (let k = 0; k < out.links.length; ++k) { this.highlighted_links[out.links[k]] = true } } } } } this.onSelectionChange?.(this.selected_nodes) this.setDirty(true) } /** * removes a node from the current selection **/ deselectNode(node: LGraphNode): void { if (!node.is_selected) return node.onDeselected?.() node.is_selected = false delete this.selected_nodes[node.id] this.onNodeDeselected?.(node) //remove highlighted if (node.inputs) { for (let i = 0; i < node.inputs.length; ++i) { delete this.highlighted_links[node.inputs[i].link] } } if (node.outputs) { for (let i = 0; i < node.outputs.length; ++i) { const out = node.outputs[i] if (out.links) { for (let j = 0; j < out.links.length; ++j) { delete this.highlighted_links[out.links[j]] } } } } } /** * removes all nodes from the current selection **/ deselectAllNodes(): void { if (!this.graph) return const nodes = this.graph._nodes for (let i = 0, l = nodes.length; i < l; ++i) { const node = nodes[i] if (!node.is_selected) { continue } node.onDeselected?.() node.is_selected = false this.onNodeDeselected?.(node) } this.selected_nodes = {} this.current_node = null this.highlighted_links = {} this.deselectGroups() this.onSelectionChange?.(this.selected_nodes) this.setDirty(true) } deselectGroups() { if (!this.selectedGroups) return for (const group of this.selectedGroups) { delete group.selected } this.selectedGroups = null } /** * deletes all nodes in the current selection from the graph **/ deleteSelectedNodes(): void { this.graph.beforeChange() for (const i in this.selected_nodes) { const node = this.selected_nodes[i] if (node.block_delete) continue //autoconnect when possible (very basic, only takes into account first input-output) if (node.inputs?.length && node.outputs && node.outputs.length && LiteGraph.isValidConnection(node.inputs[0].type, node.outputs[0].type) && node.inputs[0].link && node.outputs[0].links && node.outputs[0].links.length) { const input_link = node.graph.links[node.inputs[0].link] const output_link = node.graph.links[node.outputs[0].links[0]] const input_node = node.getInputNode(0) const output_node = node.getOutputNodes(0)[0] if (input_node && output_node) input_node.connect(input_link.origin_slot, output_node, output_link.target_slot) } this.graph.remove(node) this.onNodeDeselected?.(node) } this.selected_nodes = {} this.current_node = null this.highlighted_links = {} this.setDirty(true) this.graph.afterChange() } /** * centers the camera on a given node **/ centerOnNode(node: LGraphNode): void { const dpi = window?.devicePixelRatio || 1 this.ds.offset[0] = -node.pos[0] - node.size[0] * 0.5 + (this.canvas.width * 0.5) / (this.ds.scale * dpi) this.ds.offset[1] = -node.pos[1] - node.size[1] * 0.5 + (this.canvas.height * 0.5) / (this.ds.scale * dpi) this.setDirty(true, true) } /** * adds some useful properties to a mouse event, like the position in graph coordinates **/ adjustMouseEvent(e: CanvasMouseEvent | CanvasDragEvent | CanvasWheelEvent): asserts e is CanvasMouseEvent { let clientX_rel = e.clientX let clientY_rel = e.clientY if (this.canvas) { const b = this.canvas.getBoundingClientRect() clientX_rel -= b.left clientY_rel -= b.top } // TODO: Find a less brittle way to do this // Only set deltaX and deltaY if not already set. // If deltaX and deltaY are already present, they are read-only. // Setting them would result browser error => zoom in/out feature broken. // @ts-expect-error This behaviour is not guaranteed but for now works on all browsers if (e.deltaX === undefined) e.deltaX = clientX_rel - this.last_mouse_position[0] // @ts-expect-error This behaviour is not guaranteed but for now works on all browsers if (e.deltaY === undefined) e.deltaY = clientY_rel - this.last_mouse_position[1] this.last_mouse_position[0] = clientX_rel this.last_mouse_position[1] = clientY_rel e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0] e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1] } /** * changes the zoom level of the graph (default is 1), you can pass also a place used to pivot the zoom **/ setZoom(value: number, zooming_center: Point) { this.ds.changeScale(value, zooming_center) /* if(!zooming_center && this.canvas) zooming_center = [this.canvas.width * 0.5,this.canvas.height * 0.5]; var center = this.convertOffsetToCanvas( zooming_center ); this.ds.scale = value; if(this.scale > this.max_zoom) this.scale = this.max_zoom; else if(this.scale < this.min_zoom) this.scale = this.min_zoom; var new_center = this.convertOffsetToCanvas( zooming_center ); var delta_offset = [new_center[0] - center[0], new_center[1] - center[1]]; this.offset[0] += delta_offset[0]; this.offset[1] += delta_offset[1]; */ this.dirty_canvas = true this.dirty_bgcanvas = true } /** * converts a coordinate from graph coordinates to canvas2D coordinates **/ convertOffsetToCanvas(pos: Point, out: Point): Point { // @ts-expect-error Unused param return this.ds.convertOffsetToCanvas(pos, out) } /** * converts a coordinate from Canvas2D coordinates to graph space **/ convertCanvasToOffset(pos: Point, out?: Point): Point { return this.ds.convertCanvasToOffset(pos, out) } //converts event coordinates from canvas2D to graph coordinates convertEventToCanvasOffset(e: MouseEvent): Point { const rect = this.canvas.getBoundingClientRect() // TODO: -> this.ds.convertCanvasToOffset return this.convertCanvasToOffset([ e.clientX - rect.left, e.clientY - rect.top ]) } /** * brings a node to front (above all other nodes) **/ bringToFront(node: LGraphNode): void { const i = this.graph._nodes.indexOf(node) if (i == -1) return this.graph._nodes.splice(i, 1) this.graph._nodes.push(node) } /** * sends a node to the back (below all other nodes) **/ sendToBack(node: LGraphNode): void { const i = this.graph._nodes.indexOf(node) if (i == -1) return this.graph._nodes.splice(i, 1) this.graph._nodes.unshift(node) } /* LGraphCanvas render */ /** * Determines which nodes are visible and populates {@link out} with the results. * @param nodes The list of nodes to check - if falsy, all nodes in the graph will be checked * @param out Array to write visible nodes into - if falsy, a new array is created instead * @returns {LGraphNode[]} Array passed ({@link out}), or a new array containing all visible nodes */ computeVisibleNodes(nodes?: LGraphNode[], out?: LGraphNode[]): LGraphNode[] { const visible_nodes = out || [] visible_nodes.length = 0 nodes = nodes || this.graph._nodes for (let i = 0, l = nodes.length; i < l; ++i) { const n = nodes[i] //skip rendering nodes in live mode if (this.live_mode && !n.onDrawBackground && !n.onDrawForeground) { continue } if (!overlapBounding(this.visible_area, n.getBounding(LGraphCanvas.#temp, true))) { continue } //out of the visible area visible_nodes.push(n) } return visible_nodes } /** * renders the whole canvas content, by rendering in two separated canvas, one containing the background grid and the connections, and one containing the nodes) **/ draw(force_canvas?: boolean, force_bgcanvas?: boolean): void { if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0) return //fps counting const now = LiteGraph.getTime() this.render_time = (now - this.last_draw_time) * 0.001 this.last_draw_time = now if (this.graph) { this.ds.computeVisibleArea(this.viewport) } if (this.dirty_bgcanvas || force_bgcanvas || this.always_render_background || (this.graph && this.graph._last_trigger_time && now - this.graph._last_trigger_time < 1000)) { this.drawBackCanvas() } if (this.dirty_canvas || force_canvas) { this.drawFrontCanvas() } this.fps = this.render_time ? 1.0 / this.render_time : 0 this.frame += 1 } /** * draws the front canvas (the one containing all the nodes) **/ drawFrontCanvas(): void { this.dirty_canvas = false if (!this.ctx) { this.ctx = this.bgcanvas.getContext("2d") } const ctx = this.ctx //maybe is using webgl... if (!ctx) return const canvas = this.canvas // @ts-expect-error if (ctx.start2D && !this.viewport) { // @ts-expect-error ctx.start2D() ctx.restore() ctx.setTransform(1, 0, 0, 1, 0, 0) } //clip dirty area if there is one, otherwise work in full canvas const area = this.viewport || this.dirty_area if (area) { ctx.save() ctx.beginPath() ctx.rect(area[0], area[1], area[2], area[3]) ctx.clip() } //clear //canvas.width = canvas.width; if (this.clear_background) { if (area) ctx.clearRect(area[0], area[1], area[2], area[3]) else ctx.clearRect(0, 0, canvas.width, canvas.height) } //draw bg canvas if (this.bgcanvas == this.canvas) { this.drawBackCanvas() } else { const scale = window.devicePixelRatio ctx.drawImage(this.bgcanvas, 0, 0, this.bgcanvas.width / scale, this.bgcanvas.height / scale) } //rendering this.onRender?.(canvas, ctx) //info widget if (this.show_info) { this.renderInfo(ctx, area ? area[0] : 0, area ? area[1] : 0) } if (this.graph) { //apply transformations ctx.save() this.ds.toCanvasContext(ctx) //draw nodes const visible_nodes = this.computeVisibleNodes( null, this.visible_nodes ) for (let i = 0; i < visible_nodes.length; ++i) { const node = visible_nodes[i] //transform coords system ctx.save() ctx.translate(node.pos[0], node.pos[1]) //Draw this.drawNode(node, ctx) //Restore ctx.restore() } //on top (debug) if (this.render_execution_order) { this.drawExecutionOrder(ctx) } //connections ontop? if (this.graph.config.links_ontop) { if (!this.live_mode) { this.drawConnections(ctx) } } if (this.connecting_links) { //current connection (the one being dragged by the mouse) for (const link of this.connecting_links) { ctx.lineWidth = this.connections_width let link_color = null const connInOrOut = link.output || link.input const connType = connInOrOut.type let connDir = connInOrOut.dir if (connDir == null) { if (link.output) connDir = link.node.horizontal ? LiteGraph.DOWN : LiteGraph.RIGHT else connDir = link.node.horizontal ? LiteGraph.UP : LiteGraph.LEFT } const connShape = connInOrOut.shape switch (connType) { case LiteGraph.EVENT: link_color = LiteGraph.EVENT_LINK_COLOR break default: link_color = LiteGraph.CONNECTING_LINK_COLOR } const highlightPos: Point = this.#getHighlightPosition() //the connection being dragged by the mouse this.renderLink( ctx, link.pos, highlightPos, null, false, null, link_color, connDir, link.direction ?? LinkDirection.CENTER ) ctx.beginPath() if (connType === LiteGraph.EVENT || connShape === LiteGraph.BOX_SHAPE) { ctx.rect( link.pos[0] - 6 + 0.5, link.pos[1] - 5 + 0.5, 14, 10 ) ctx.fill() ctx.beginPath() ctx.rect( this.graph_mouse[0] - 6 + 0.5, this.graph_mouse[1] - 5 + 0.5, 14, 10 ) } else if (connShape === LiteGraph.ARROW_SHAPE) { ctx.moveTo(link.pos[0] + 8, link.pos[1] + 0.5) ctx.lineTo(link.pos[0] - 4, link.pos[1] + 6 + 0.5) ctx.lineTo(link.pos[0] - 4, link.pos[1] - 6 + 0.5) ctx.closePath() } else { ctx.arc( link.pos[0], link.pos[1], 4, 0, Math.PI * 2 ) ctx.fill() ctx.beginPath() ctx.arc( this.graph_mouse[0], this.graph_mouse[1], 4, 0, Math.PI * 2 ) } ctx.fill() // Gradient half-border over target node this.#renderSnapHighlight(ctx, highlightPos) } } //the selection rectangle if (this.dragging_rectangle) { ctx.strokeStyle = "#FFF" ctx.strokeRect( this.dragging_rectangle[0], this.dragging_rectangle[1], this.dragging_rectangle[2], this.dragging_rectangle[3] ) } //on top of link center if (this.over_link_center && this.render_link_tooltip) this.drawLinkTooltip(ctx, this.over_link_center) //to remove else this.onDrawLinkTooltip?.(ctx, null) //custom info // FIXME: Has never worked - visible_rect is undefined this.onDrawForeground?.(ctx, this.visible_rect) ctx.restore() } //draws panel in the corner if (this._graph_stack?.length) { this.drawSubgraphPanel(ctx) } this.onDrawOverlay?.(ctx) if (area) ctx.restore() // FIXME: Remove this hook //this is a function I use in webgl renderer // @ts-expect-error if (ctx.finish2D) ctx.finish2D() } /** Get the target snap / highlight point in graph space */ #getHighlightPosition(): Point { return LiteGraph.snaps_for_comfy ? this._highlight_pos ?? this.graph_mouse : this.graph_mouse } /** * Renders indicators showing where a link will connect if released. * Partial border over target node and a highlight over the slot itself. * @param ctx Canvas 2D context */ #renderSnapHighlight(ctx: CanvasRenderingContext2D, highlightPos: Point): void { if (!this._highlight_pos) return ctx.fillStyle = "#ffcc00" ctx.beginPath() const shape = this._highlight_input?.shape if (shape === RenderShape.ARROW) { ctx.moveTo(highlightPos[0] + 8, highlightPos[1] + 0.5) ctx.lineTo(highlightPos[0] - 4, highlightPos[1] + 6 + 0.5) ctx.lineTo(highlightPos[0] - 4, highlightPos[1] - 6 + 0.5) ctx.closePath() } else { ctx.arc( highlightPos[0], highlightPos[1], 6, 0, Math.PI * 2 ) } ctx.fill() if (!LiteGraph.snap_highlights_node) return // Ensure we're mousing over a node and connecting a link const node = this.node_over if (!(node && this.connecting_links?.[0])) return const { strokeStyle, lineWidth } = ctx const area = LGraphCanvas.#tmp_area node.measure(area) node.onBounding?.(area) const gap = 3 const radius = this.round_radius + gap const x = area[0] - gap const y = area[1] - gap const width = area[2] + (gap * 2) const height = area[3] + (gap * 2) ctx.beginPath() ctx.roundRect(x, y, width, height, radius) // TODO: Currently works on LTR slots only. Add support for other directions. const start = this.connecting_links[0].output === null ? 0 : 1 const inverter = start ? -1 : 1 // Radial highlight centred on highlight pos const hx = highlightPos[0] const hy = highlightPos[1] const gRadius = width < height ? width : width * Math.max(height / width, 0.5) const gradient = ctx.createRadialGradient(hx, hy, 0, hx, hy, gRadius) gradient.addColorStop(1, "#00000000") gradient.addColorStop(0, "#ffcc00aa") // Linear gradient over half the node. const linearGradient = ctx.createLinearGradient(x, y, x + width, y) linearGradient.addColorStop(0.5, "#00000000") linearGradient.addColorStop(start + (0.67 * inverter), "#ddeeff33") linearGradient.addColorStop(start + inverter, "#ffcc0055") /** * Workaround for a canvas render issue. * In Chromium 129 (2024-10-15), rounded corners can be rendered with the wrong part of a gradient colour. * Occurs only at certain thicknesses / arc sizes. */ ctx.setLineDash([radius, radius * 0.001]) ctx.lineWidth = 1 ctx.strokeStyle = linearGradient ctx.stroke() ctx.strokeStyle = gradient ctx.stroke() ctx.setLineDash([]) ctx.lineWidth = lineWidth ctx.strokeStyle = strokeStyle } /** * draws the panel in the corner that shows subgraph properties **/ drawSubgraphPanel(ctx: CanvasRenderingContext2D): void { const subgraph = this.graph const subnode = subgraph._subgraph_node if (!subnode) { console.warn("subgraph without subnode") return } this.drawSubgraphPanelLeft(subgraph, subnode, ctx) this.drawSubgraphPanelRight(subgraph, subnode, ctx) } drawSubgraphPanelLeft(subgraph: LGraph, subnode: LGraphNode, ctx: CanvasRenderingContext2D): void { const num = subnode.inputs ? subnode.inputs.length : 0 const w = 200 const h = Math.floor(LiteGraph.NODE_SLOT_HEIGHT * 1.6) ctx.fillStyle = "#111" ctx.globalAlpha = 0.8 ctx.beginPath() ctx.roundRect(10, 10, w, (num + 1) * h + 50, [8]) ctx.fill() ctx.globalAlpha = 1 ctx.fillStyle = "#888" ctx.font = "14px Arial" ctx.textAlign = "left" ctx.fillText("Graph Inputs", 20, 34) // var pos = this.mouse; if (this.drawButton(w - 20, 20, 20, 20, "X", "#151515")) { this.closeSubgraph() return } let y = 50 ctx.font = "14px Arial" if (subnode.inputs) for (let i = 0; i < subnode.inputs.length; ++i) { const input = subnode.inputs[i] if (input.not_subgraph_input) continue //input button clicked if (this.drawButton(20, y + 2, w - 20, h - 2)) { // @ts-expect-error ctor props const type = subnode.constructor.input_node_type || "graph/input" this.graph.beforeChange() const newnode = LiteGraph.createNode(type) if (newnode) { subgraph.add(newnode) this.block_click = false this.last_click_position = null this.selectNodes([newnode]) this.node_dragged = newnode this.dragging_canvas = false newnode.setProperty("name", input.name) newnode.setProperty("type", input.type) this.node_dragged.pos[0] = this.graph_mouse[0] - 5 this.node_dragged.pos[1] = this.graph_mouse[1] - 5 this.graph.afterChange() } else console.error("graph input node not found:", type) } ctx.fillStyle = "#9C9" ctx.beginPath() ctx.arc(w - 16, y + h * 0.5, 5, 0, 2 * Math.PI) ctx.fill() ctx.fillStyle = "#AAA" ctx.fillText(input.name, 30, y + h * 0.75) // var tw = ctx.measureText(input.name); ctx.fillStyle = "#777" // @ts-expect-error FIXME: Should be a string? Should be a number? ctx.fillText(input.type, 130, y + h * 0.75) y += h } //add + button if (this.drawButton(20, y + 2, w - 20, h - 2, "+", "#151515", "#222")) { this.showSubgraphPropertiesDialog(subnode) } } drawSubgraphPanelRight(subgraph: LGraph, subnode: LGraphNode, ctx: CanvasRenderingContext2D): void { const num = subnode.outputs ? subnode.outputs.length : 0 const canvas_w = this.bgcanvas.width const w = 200 const h = Math.floor(LiteGraph.NODE_SLOT_HEIGHT * 1.6) ctx.fillStyle = "#111" ctx.globalAlpha = 0.8 ctx.beginPath() ctx.roundRect(canvas_w - w - 10, 10, w, (num + 1) * h + 50, [8]) ctx.fill() ctx.globalAlpha = 1 ctx.fillStyle = "#888" ctx.font = "14px Arial" ctx.textAlign = "left" const title_text = "Graph Outputs" const tw = ctx.measureText(title_text).width ctx.fillText(title_text, (canvas_w - tw) - 20, 34) // var pos = this.mouse; if (this.drawButton(canvas_w - w, 20, 20, 20, "X", "#151515")) { this.closeSubgraph() return } let y = 50 ctx.font = "14px Arial" if (subnode.outputs) for (let i = 0; i < subnode.outputs.length; ++i) { const output = subnode.outputs[i] if (output.not_subgraph_input) continue //output button clicked if (this.drawButton(canvas_w - w, y + 2, w - 20, h - 2)) { // @ts-expect-error ctor props const type = subnode.constructor.output_node_type || "graph/output" this.graph.beforeChange() const newnode = LiteGraph.createNode(type) if (newnode) { subgraph.add(newnode) this.block_click = false this.last_click_position = null this.selectNodes([newnode]) this.node_dragged = newnode this.dragging_canvas = false newnode.setProperty("name", output.name) newnode.setProperty("type", output.type) this.node_dragged.pos[0] = this.graph_mouse[0] - 5 this.node_dragged.pos[1] = this.graph_mouse[1] - 5 this.graph.afterChange() } else console.error("graph input node not found:", type) } ctx.fillStyle = "#9C9" ctx.beginPath() ctx.arc(canvas_w - w + 16, y + h * 0.5, 5, 0, 2 * Math.PI) ctx.fill() ctx.fillStyle = "#AAA" ctx.fillText(output.name, canvas_w - w + 30, y + h * 0.75) // var tw = ctx.measureText(input.name); ctx.fillStyle = "#777" // @ts-expect-error slot type issue ctx.fillText(output.type, canvas_w - w + 130, y + h * 0.75) y += h } //add + button if (this.drawButton(canvas_w - w, y + 2, w - 20, h - 2, "+", "#151515", "#222")) { this.showSubgraphPropertiesDialogRight(subnode) } } //Draws a button into the canvas overlay and computes if it was clicked using the immediate gui paradigm drawButton(x: number, y: number, w: number, h: number, text?: string, bgcolor?: CanvasColour, hovercolor?: CanvasColour, textcolor?: CanvasColour): boolean { const ctx = this.ctx bgcolor = bgcolor || LiteGraph.NODE_DEFAULT_COLOR hovercolor = hovercolor || "#555" textcolor = textcolor || LiteGraph.NODE_TEXT_COLOR let pos = this.ds.convertOffsetToCanvas(this.graph_mouse) const hover = LiteGraph.isInsideRectangle(pos[0], pos[1], x, y, w, h) pos = this.last_click_position ? [this.last_click_position[0], this.last_click_position[1]] : null if (pos) { const rect = this.canvas.getBoundingClientRect() pos[0] -= rect.left pos[1] -= rect.top } const clicked = pos && LiteGraph.isInsideRectangle(pos[0], pos[1], x, y, w, h) ctx.fillStyle = hover ? hovercolor : bgcolor if (clicked) ctx.fillStyle = "#AAA" ctx.beginPath() ctx.roundRect(x, y, w, h, [4]) ctx.fill() if (text != null) { if (text.constructor == String) { ctx.fillStyle = textcolor ctx.textAlign = "center" ctx.font = ((h * 0.65) | 0) + "px Arial" ctx.fillText(text, x + w * 0.5, y + h * 0.75) ctx.textAlign = "left" } } const was_clicked = clicked && !this.block_click if (clicked) this.blockClick() return was_clicked } isAreaClicked(x: number, y: number, w: number, h: number, hold_click: boolean): boolean { const clickPos = this.last_click_position const clicked = clickPos && LiteGraph.isInsideRectangle(clickPos[0], clickPos[1], x, y, w, h) const was_clicked = clicked && !this.block_click if (clicked && hold_click) this.blockClick() return was_clicked } /** * draws some useful stats in the corner of the canvas **/ renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void { x = x || 10 y = y || this.canvas.offsetHeight - 80 ctx.save() ctx.translate(x, y) ctx.font = "10px Arial" ctx.fillStyle = "#888" ctx.textAlign = "left" if (this.graph) { ctx.fillText("T: " + this.graph.globaltime.toFixed(2) + "s", 5, 13 * 1) ctx.fillText("I: " + this.graph.iteration, 5, 13 * 2) ctx.fillText("N: " + this.graph._nodes.length + " [" + this.visible_nodes.length + "]", 5, 13 * 3) ctx.fillText("V: " + this.graph._version, 5, 13 * 4) ctx.fillText("FPS:" + this.fps.toFixed(2), 5, 13 * 5) } else { ctx.fillText("No graph selected", 5, 13 * 1) } ctx.restore() } /** * draws the back canvas (the one containing the background and the connections) **/ drawBackCanvas(): void { const canvas = this.bgcanvas if (canvas.width != this.canvas.width || canvas.height != this.canvas.height) { canvas.width = this.canvas.width canvas.height = this.canvas.height } if (!this.bgctx) { this.bgctx = this.bgcanvas.getContext("2d") } const ctx = this.bgctx // TODO: Remove this // @ts-expect-error if (ctx.start) ctx.start() const viewport = this.viewport || [0, 0, ctx.canvas.width, ctx.canvas.height] //clear if (this.clear_background) { ctx.clearRect(viewport[0], viewport[1], viewport[2], viewport[3]) } //show subgraph stack header if (this._graph_stack?.length) { ctx.save() const subgraph_node = this.graph._subgraph_node ctx.strokeStyle = subgraph_node.bgcolor ctx.lineWidth = 10 ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2) ctx.lineWidth = 1 ctx.font = "40px Arial" ctx.textAlign = "center" ctx.fillStyle = subgraph_node.bgcolor || "#AAA" let title = "" for (let i = 1; i < this._graph_stack.length; ++i) { title += this._graph_stack[i]._subgraph_node.getTitle() + " >> " } ctx.fillText( title + subgraph_node.getTitle(), canvas.width * 0.5, 40 ) ctx.restore() } const bg_already_painted = this.onRenderBackground ? this.onRenderBackground(canvas, ctx) : false //reset in case of error if (!this.viewport) { const scale = window.devicePixelRatio ctx.restore() ctx.setTransform(scale, 0, 0, scale, 0, 0) } this.visible_links.length = 0 if (this.graph) { //apply transformations ctx.save() this.ds.toCanvasContext(ctx) //render BG if (this.ds.scale < 1.5 && !bg_already_painted && this.clear_background_color) { ctx.fillStyle = this.clear_background_color ctx.fillRect( this.visible_area[0], this.visible_area[1], this.visible_area[2], this.visible_area[3] ) } if (this.background_image && this.ds.scale > 0.5 && !bg_already_painted) { if (this.zoom_modify_alpha) { ctx.globalAlpha = (1.0 - 0.5 / this.ds.scale) * this.editor_alpha } else { ctx.globalAlpha = this.editor_alpha } ctx.imageSmoothingEnabled = false if (!this._bg_img || this._bg_img.name != this.background_image) { this._bg_img = new Image() this._bg_img.name = this.background_image this._bg_img.src = this.background_image const that = this this._bg_img.onload = function () { that.draw(true, true) } } let pattern = this._pattern if (pattern == null && this._bg_img.width > 0) { pattern = ctx.createPattern(this._bg_img, "repeat") this._pattern_img = this._bg_img this._pattern = pattern } // NOTE: This ridiculous kludge provides a significant performance increase when rendering many large (> canvas width) paths in HTML canvas. // I could find no documentation or explanation. Requires that the BG image is set. if (pattern) { ctx.fillStyle = pattern ctx.fillRect( this.visible_area[0], this.visible_area[1], this.visible_area[2], this.visible_area[3] ) ctx.fillStyle = "transparent" } ctx.globalAlpha = 1.0 ctx.imageSmoothingEnabled = true } //groups if (this.graph._groups.length && !this.live_mode) { this.drawGroups(canvas, ctx) } this.onDrawBackground?.(ctx, this.visible_area) //DEBUG: show clipping area //ctx.fillStyle = "red"; //ctx.fillRect( this.visible_area[0] + 10, this.visible_area[1] + 10, this.visible_area[2] - 20, this.visible_area[3] - 20); //bg if (this.render_canvas_border) { ctx.strokeStyle = "#235" ctx.strokeRect(0, 0, canvas.width, canvas.height) } if (this.render_connections_shadows) { ctx.shadowColor = "#000" ctx.shadowOffsetX = 0 ctx.shadowOffsetY = 0 ctx.shadowBlur = 6 } else { ctx.shadowColor = "rgba(0,0,0,0)" } //draw connections if (!this.live_mode) { this.drawConnections(ctx) } ctx.shadowColor = "rgba(0,0,0,0)" //restore state ctx.restore() } // TODO: Remove this // @ts-expect-error ctx.finish?.() this.dirty_bgcanvas = false //to force to repaint the front canvas with the bgcanvas // But why would you actually want to do this? this.dirty_canvas = true } /** * draws the given node inside the canvas **/ drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void { this.current_node = node const color = node.color || node.constructor.color || LiteGraph.NODE_DEFAULT_COLOR let bgcolor = node.bgcolor || node.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR const low_quality = this.ds.scale < 0.6 //zoomed out //only render if it forces it to do it if (this.live_mode) { if (!node.flags.collapsed) { ctx.shadowColor = "transparent" node.onDrawForeground?.(ctx, this, this.canvas) } return } const editor_alpha = this.editor_alpha ctx.globalAlpha = editor_alpha if (this.render_shadows && !low_quality) { ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR ctx.shadowOffsetX = 2 * this.ds.scale ctx.shadowOffsetY = 2 * this.ds.scale ctx.shadowBlur = 3 * this.ds.scale } else { ctx.shadowColor = "transparent" } //custom draw collapsed method (draw after shadows because they are affected) if (node.flags.collapsed && node.onDrawCollapsed?.(ctx, this) == true) return //clip if required (mask) const shape = node._shape || LiteGraph.BOX_SHAPE const size = LGraphCanvas.#temp_vec2 LGraphCanvas.#temp_vec2.set(node.size) const horizontal = node.horizontal // || node.flags.horizontal; if (node.flags.collapsed) { ctx.font = this.inner_text_font const title = node.getTitle ? node.getTitle() : node.title if (title != null) { node._collapsed_width = Math.min( node.size[0], ctx.measureText(title).width + LiteGraph.NODE_TITLE_HEIGHT * 2 ) //LiteGraph.NODE_COLLAPSED_WIDTH; size[0] = node._collapsed_width size[1] = 0 } } if (node.clip_area) { //Start clipping ctx.save() ctx.beginPath() if (shape == LiteGraph.BOX_SHAPE) { ctx.rect(0, 0, size[0], size[1]) } else if (shape == LiteGraph.ROUND_SHAPE) { ctx.roundRect(0, 0, size[0], size[1], [10]) } else if (shape == LiteGraph.CIRCLE_SHAPE) { ctx.arc( size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2 ) } ctx.clip() } //draw shape if (node.has_errors) { bgcolor = "red" } this.drawNodeShape( node, ctx, size, color, bgcolor, node.is_selected ) if (!low_quality) { node.drawBadges(ctx) } ctx.shadowColor = "transparent" //draw foreground node.onDrawForeground?.(ctx, this, this.canvas) //connection slots ctx.textAlign = horizontal ? "center" : "left" ctx.font = this.inner_text_font const render_text = !low_quality const highlightColour = LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR const out_slot = this.connecting_links ? this.connecting_links[0].output : null const in_slot = this.connecting_links ? this.connecting_links[0].input : null ctx.lineWidth = 1 let max_y = 0 const slot_pos = new Float32Array(2) //to reuse //render inputs and outputs if (!node.flags.collapsed) { //input connection slots if (node.inputs) { for (let i = 0; i < node.inputs.length; i++) { const slot = node.inputs[i] const slot_type = slot.type //change opacity of incompatible slots when dragging a connection const isValid = !this.connecting_links || (out_slot && LiteGraph.isValidConnection(slot.type, out_slot.type)) const highlight = isValid && node.mouseOver?.inputId === i const label_color = highlight ? highlightColour : LiteGraph.NODE_TEXT_COLOR ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha ctx.fillStyle = slot.link != null ? slot.color_on || this.default_connection_color_byType[slot_type] || this.default_connection_color.input_on : slot.color_off || this.default_connection_color_byTypeOff[slot_type] || this.default_connection_color_byType[slot_type] || this.default_connection_color.input_off const pos = node.getConnectionPos(true, i, slot_pos) pos[0] -= node.pos[0] pos[1] -= node.pos[1] if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 } drawSlot(ctx, slot, pos, { horizontal, low_quality, render_text, label_color, label_position: LabelPosition.Right, // Input slot is not stroked. do_stroke: false, highlight, }) } } //output connection slots ctx.textAlign = horizontal ? "center" : "right" ctx.strokeStyle = "black" if (node.outputs) { for (let i = 0; i < node.outputs.length; i++) { const slot = node.outputs[i] const slot_type = slot.type //change opacity of incompatible slots when dragging a connection const isValid = !this.connecting_links || (in_slot && LiteGraph.isValidConnection(slot_type, in_slot.type)) const highlight = isValid && node.mouseOver?.outputId === i const label_color = highlight ? highlightColour : LiteGraph.NODE_TEXT_COLOR ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha const pos = node.getConnectionPos(false, i, slot_pos) pos[0] -= node.pos[0] pos[1] -= node.pos[1] if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 } ctx.fillStyle = slot.links && slot.links.length ? slot.color_on || this.default_connection_color_byType[slot_type] || this.default_connection_color.output_on : slot.color_off || this.default_connection_color_byTypeOff[slot_type] || this.default_connection_color_byType[slot_type] || this.default_connection_color.output_off drawSlot(ctx, slot, pos, { horizontal, low_quality, render_text, label_color, label_position: LabelPosition.Left, do_stroke: true, highlight, }) } } ctx.textAlign = "left" ctx.globalAlpha = 1 if (node.widgets) { let widgets_y = max_y if (horizontal || node.widgets_up) { widgets_y = 2 } if (node.widgets_start_y != null) widgets_y = node.widgets_start_y this.drawNodeWidgets( node, widgets_y, ctx, this.node_widget && this.node_widget[0] == node ? this.node_widget[1] : null ) } } else if (this.render_collapsed_slots) { //if collapsed let input_slot = null let output_slot = null let slot //get first connected slot to render if (node.inputs) { for (let i = 0; i < node.inputs.length; i++) { slot = node.inputs[i] if (slot.link == null) { continue } input_slot = slot break } } if (node.outputs) { for (let i = 0; i < node.outputs.length; i++) { slot = node.outputs[i] if (!slot.links || !slot.links.length) { continue } output_slot = slot } } if (input_slot) { let x = 0 let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 //center if (horizontal) { x = node._collapsed_width * 0.5 y = -LiteGraph.NODE_TITLE_HEIGHT } ctx.fillStyle = "#686" ctx.beginPath() if (slot.type === LiteGraph.EVENT || slot.shape === LiteGraph.BOX_SHAPE) { ctx.rect(x - 7 + 0.5, y - 4, 14, 8) } else if (slot.shape === LiteGraph.ARROW_SHAPE) { ctx.moveTo(x + 8, y) ctx.lineTo(x + -4, y - 4) ctx.lineTo(x + -4, y + 4) ctx.closePath() } else { ctx.arc(x, y, 4, 0, Math.PI * 2) } ctx.fill() } if (output_slot) { let x = node._collapsed_width let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 //center if (horizontal) { x = node._collapsed_width * 0.5 y = 0 } ctx.fillStyle = "#686" ctx.strokeStyle = "black" ctx.beginPath() if (slot.type === LiteGraph.EVENT || slot.shape === LiteGraph.BOX_SHAPE) { ctx.rect(x - 7 + 0.5, y - 4, 14, 8) } else if (slot.shape === LiteGraph.ARROW_SHAPE) { ctx.moveTo(x + 6, y) ctx.lineTo(x - 6, y - 4) ctx.lineTo(x - 6, y + 4) ctx.closePath() } else { ctx.arc(x, y, 4, 0, Math.PI * 2) } ctx.fill() //ctx.stroke(); } } if (node.clip_area) { ctx.restore() } ctx.globalAlpha = 1.0 } //used by this.over_link_center drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LLink): void { const pos = link._pos ctx.fillStyle = "black" ctx.beginPath() ctx.arc(pos[0], pos[1], 3, 0, Math.PI * 2) ctx.fill() if (link.data == null) return if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return // TODO: Better value typing const data = link.data let text: string = null if (typeof data === "number") text = data.toFixed(2) else if (typeof data === "string") text = "\"" + data + "\"" else if (typeof data === "boolean") text = String(data) else if (data.toToolTip) text = data.toToolTip() else text = "[" + data.constructor.name + "]" if (text == null) return // Hard-coded tooltip limit text = text.substring(0, 30) ctx.font = "14px Courier New" const info = ctx.measureText(text) const w = info.width + 20 const h = 24 ctx.shadowColor = "black" ctx.shadowOffsetX = 2 ctx.shadowOffsetY = 2 ctx.shadowBlur = 3 ctx.fillStyle = "#454" ctx.beginPath() ctx.roundRect(pos[0] - w * 0.5, pos[1] - 15 - h, w, h, [3]) ctx.moveTo(pos[0] - 10, pos[1] - 15) ctx.lineTo(pos[0] + 10, pos[1] - 15) ctx.lineTo(pos[0], pos[1] - 5) ctx.fill() ctx.shadowColor = "transparent" ctx.textAlign = "center" ctx.fillStyle = "#CEC" ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3) } /** * Draws the shape of the given node on the canvas * @param node The node to draw * @param ctx 2D canvas rendering context used to draw * @param size Size of the background to draw, in graph units. Differs from node size if collapsed, etc. * @param fgcolor Foreground colour - used for text * @param bgcolor Background colour of the node * @param selected Whether to render the node as selected. Likely to be removed in future, as current usage is simply the is_selected property of the node. * @param mouse_over Deprecated */ drawNodeShape( node: LGraphNode, ctx: CanvasRenderingContext2D, size: Size, fgcolor: CanvasColour, bgcolor: CanvasColour, selected: boolean ): void { //bg rect ctx.strokeStyle = fgcolor ctx.fillStyle = bgcolor const title_height = LiteGraph.NODE_TITLE_HEIGHT const low_quality = this.ds.scale < 0.5 //render node area depending on shape const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE const title_mode = node.constructor.title_mode const render_title = title_mode == LiteGraph.TRANSPARENT_TITLE || title_mode == LiteGraph.NO_TITLE ? false : true // Normalised node dimensions const area = LGraphCanvas.#tmp_area node.measure(area) area[0] -= node.pos[0] area[1] -= node.pos[1] area[2]++ const old_alpha = ctx.globalAlpha //full node shape //if(node.flags.collapsed) { ctx.beginPath() if (shape == LiteGraph.BOX_SHAPE || low_quality) { ctx.fillRect(area[0], area[1], area[2], area[3]) } else if (shape == LiteGraph.ROUND_SHAPE || shape == LiteGraph.CARD_SHAPE) { ctx.roundRect( area[0], area[1], area[2], area[3], shape == LiteGraph.CARD_SHAPE ? [this.round_radius, this.round_radius, 0, 0] : [this.round_radius] ) } else if (shape == LiteGraph.CIRCLE_SHAPE) { ctx.arc( size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2 ) } ctx.fill() //separator if (!node.flags.collapsed && render_title) { ctx.shadowColor = "transparent" ctx.fillStyle = "rgba(0,0,0,0.2)" ctx.fillRect(0, -1, area[2], 2) } } ctx.shadowColor = "transparent" node.onDrawBackground?.(ctx, this, this.canvas, this.graph_mouse) //title bg (remember, it is rendered ABOVE the node) if (render_title || title_mode == LiteGraph.TRANSPARENT_TITLE) { //title bar if (node.onDrawTitleBar) { node.onDrawTitleBar(ctx, title_height, size, this.ds.scale, fgcolor) } else if ( title_mode != LiteGraph.TRANSPARENT_TITLE && (node.constructor.title_color || this.render_title_colored) ) { const title_color = node.constructor.title_color || fgcolor if (node.flags.collapsed) { ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR } //* gradient test if (this.use_gradients) { // TODO: This feature may not have been completed. Could finish or remove. // Original impl. may cause CanvasColour to be used as index key. Also, colour requires validation before blindly passing on. // @ts-expect-error Fix or remove gradient feature let grad = LGraphCanvas.gradients[title_color] if (!grad) { // @ts-expect-error Fix or remove gradient feature grad = LGraphCanvas.gradients[title_color] = ctx.createLinearGradient(0, 0, 400, 0) grad.addColorStop(0, title_color) grad.addColorStop(1, "#000") } ctx.fillStyle = grad } else { ctx.fillStyle = title_color } //ctx.globalAlpha = 0.5 * old_alpha; ctx.beginPath() if (shape == LiteGraph.BOX_SHAPE || low_quality) { ctx.rect(0, -title_height, size[0] + 1, title_height) } else if (shape == LiteGraph.ROUND_SHAPE || shape == LiteGraph.CARD_SHAPE) { ctx.roundRect( 0, -title_height, size[0] + 1, title_height, node.flags.collapsed ? [this.round_radius] : [this.round_radius, this.round_radius, 0, 0] ) } ctx.fill() ctx.shadowColor = "transparent" } let colState: string | boolean = false if (LiteGraph.node_box_coloured_by_mode) { if (LiteGraph.NODE_MODES_COLORS[node.mode]) { colState = LiteGraph.NODE_MODES_COLORS[node.mode] } } if (LiteGraph.node_box_coloured_when_on) { colState = node.action_triggered ? "#FFF" : (node.execute_triggered ? "#AAA" : colState) } //title box const box_size = 10 if (node.onDrawTitleBox) { node.onDrawTitleBox(ctx, title_height, size, this.ds.scale) } else if (shape == LiteGraph.ROUND_SHAPE || shape == LiteGraph.CIRCLE_SHAPE || shape == LiteGraph.CARD_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 = node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR 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 = node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR ctx.fillRect( (title_height - box_size) * 0.5, (title_height + box_size) * -0.5, box_size, box_size ) } ctx.globalAlpha = old_alpha //title text if (node.onDrawTitleText) { node.onDrawTitleText( ctx, title_height, size, this.ds.scale, this.title_text_font, selected ) } if (!low_quality) { ctx.font = this.title_text_font const title = String(node.getTitle()) + (node.pinned ? "📌" : "") if (title) { if (selected) { ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR } else { ctx.fillStyle = node.constructor.title_text_color || this.node_title_color } if (node.flags.collapsed) { ctx.textAlign = "left" // const measure = ctx.measureText(title) ctx.fillText( title.substr(0, 20), //avoid urls too long title_height, // + measure.width * 0.5, 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 ) } } } //subgraph box if (!node.flags.collapsed && node.subgraph && !node.skip_subgraph_button) { const w = LiteGraph.NODE_TITLE_HEIGHT const x = node.size[0] - w const over = LiteGraph.isInsideRectangle(this.graph_mouse[0] - node.pos[0], this.graph_mouse[1] - node.pos[1], x + 2, -w + 2, w - 4, w - 4) ctx.fillStyle = over ? "#888" : "#555" if (shape == LiteGraph.BOX_SHAPE || low_quality) { ctx.fillRect(x + 2, -w + 2, w - 4, w - 4) } else { ctx.beginPath() ctx.roundRect(x + 2, -w + 2, w - 4, w - 4, [4]) ctx.fill() } ctx.fillStyle = "#333" ctx.beginPath() ctx.moveTo(x + w * 0.2, -w * 0.6) ctx.lineTo(x + w * 0.8, -w * 0.6) ctx.lineTo(x + w * 0.5, -w * 0.3) ctx.fill() } //custom title render node.onDrawTitle?.(ctx) } //render selection marker if (selected) { node.onBounding?.(area) this.drawSelectionBounding( ctx, area, { shape, title_height, title_mode, fgcolor, collapsed: node.flags?.collapsed } ) } // these counter helps in conditioning drawing based on if the node has been executed or an action occurred if (node.execute_triggered > 0) node.execute_triggered-- if (node.action_triggered > 0) node.action_triggered-- } /** * Draws the selection bounding of an area. * @param {CanvasRenderingContext2D} ctx * @param {Vector4} area * @param {{ * shape: LiteGraph.Shape, * title_height: number, * title_mode: LiteGraph.TitleMode, * fgcolor: string, * padding: number, * }} options */ drawSelectionBounding( ctx: CanvasRenderingContext2D, area: Rect, { shape = LiteGraph.BOX_SHAPE, title_height = LiteGraph.NODE_TITLE_HEIGHT, title_mode = LiteGraph.NORMAL_TITLE, fgcolor = LiteGraph.NODE_BOX_OUTLINE_COLOR, padding = 6, collapsed = false, }: IDrawSelectionBoundingOptions = {} ) { // Adjust area if title is transparent if (title_mode === LiteGraph.TRANSPARENT_TITLE) { area[1] -= title_height area[3] += title_height } // Set up context ctx.lineWidth = 1 ctx.globalAlpha = 0.8 ctx.beginPath() // Draw shape based on type const [x, y, width, height] = area switch (shape) { case LiteGraph.BOX_SHAPE: { ctx.rect(x - padding, y - padding, width + 2 * padding, height + 2 * padding) break } case LiteGraph.ROUND_SHAPE: case LiteGraph.CARD_SHAPE: { const radius = this.round_radius * 2 const isCollapsed = shape === LiteGraph.CARD_SHAPE && collapsed const cornerRadii = isCollapsed || shape === LiteGraph.ROUND_SHAPE ? [radius] : [radius, 2, radius, 2] ctx.roundRect(x - padding, y - padding, width + 2 * padding, height + 2 * padding, cornerRadii) break } case LiteGraph.CIRCLE_SHAPE: { const centerX = x + width / 2 const centerY = y + height / 2 const radius = Math.max(width, height) / 2 + padding ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) break } } // Stroke the shape ctx.strokeStyle = LiteGraph.NODE_BOX_OUTLINE_COLOR ctx.stroke() // Reset context ctx.strokeStyle = fgcolor ctx.globalAlpha = 1 } drawConnections(ctx: CanvasRenderingContext2D): void { const now = LiteGraph.getTime() const visible_area = this.visible_area LGraphCanvas.#margin_area[0] = visible_area[0] - 20 LGraphCanvas.#margin_area[1] = visible_area[1] - 20 LGraphCanvas.#margin_area[2] = visible_area[2] + 40 LGraphCanvas.#margin_area[3] = visible_area[3] + 40 //draw connections ctx.lineWidth = this.connections_width ctx.fillStyle = "#AAA" ctx.strokeStyle = "#AAA" ctx.globalAlpha = this.editor_alpha //for every node const nodes = this.graph._nodes for (let n = 0, l = nodes.length; n < l; ++n) { const node = nodes[n] //for every input (we render just inputs because it is easier as every slot can only have one input) if (!node.inputs || !node.inputs.length) continue for (let i = 0; i < node.inputs.length; ++i) { const input = node.inputs[i] if (!input || input.link == null) continue const link_id = input.link const link = this.graph.links[link_id] if (!link) continue //find link info const start_node = this.graph.getNodeById(link.origin_id) if (start_node == null) continue const start_node_slot = link.origin_slot let start_node_slotpos: Point = null if (start_node_slot == -1) { start_node_slotpos = [ start_node.pos[0] + 10, start_node.pos[1] + 10 ] } else { start_node_slotpos = start_node.getConnectionPos( false, start_node_slot, LGraphCanvas.#tempA ) } const end_node_slotpos = node.getConnectionPos(true, i, LGraphCanvas.#tempB) //compute link bounding LGraphCanvas.#link_bounding[0] = start_node_slotpos[0] LGraphCanvas.#link_bounding[1] = start_node_slotpos[1] LGraphCanvas.#link_bounding[2] = end_node_slotpos[0] - start_node_slotpos[0] LGraphCanvas.#link_bounding[3] = end_node_slotpos[1] - start_node_slotpos[1] if (LGraphCanvas.#link_bounding[2] < 0) { LGraphCanvas.#link_bounding[0] += LGraphCanvas.#link_bounding[2] LGraphCanvas.#link_bounding[2] = Math.abs(LGraphCanvas.#link_bounding[2]) } if (LGraphCanvas.#link_bounding[3] < 0) { LGraphCanvas.#link_bounding[1] += LGraphCanvas.#link_bounding[3] LGraphCanvas.#link_bounding[3] = Math.abs(LGraphCanvas.#link_bounding[3]) } //skip links outside of the visible area of the canvas if (!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)) continue const start_slot = start_node.outputs[start_node_slot] const end_slot = node.inputs[i] if (!start_slot || !end_slot) continue const start_dir = start_slot.dir || (start_node.horizontal ? LiteGraph.DOWN : LiteGraph.RIGHT) const end_dir = end_slot.dir || (node.horizontal ? LiteGraph.UP : LiteGraph.LEFT) this.renderLink( ctx, start_node_slotpos, end_node_slotpos, link, false, 0, null, start_dir, end_dir ) //event triggered rendered on top if (link && link._last_time && now - link._last_time < 1000) { const f = 2.0 - (now - link._last_time) * 0.002 const tmp = ctx.globalAlpha ctx.globalAlpha = tmp * f this.renderLink( ctx, start_node_slotpos, end_node_slotpos, link, true, f, "white", start_dir, end_dir ) ctx.globalAlpha = tmp } } } ctx.globalAlpha = 1 } /** * draws a link between two points * @param {vec2} a start pos * @param {vec2} b end pos * @param {Object} link the link object with all the link info * @param {boolean} skip_border ignore the shadow of the link * @param {boolean} flow show flow animation (for events) * @param {string} color the color for the link * @param {LinkDirection} start_dir the direction enum * @param {LinkDirection} end_dir the direction enum * @param {number} num_sublines number of sublines (useful to represent vec3 or rgb) **/ renderLink(ctx: CanvasRenderingContext2D, a: Point, b: Point, link: LLink, skip_border: boolean, flow: number, color: CanvasColour, start_dir: LinkDirection, end_dir: LinkDirection, num_sublines?: number): void { if (link) { this.visible_links.push(link) } //choose color if (!color && link) { color = link.color || LGraphCanvas.link_type_colors[link.type] } color ||= this.default_link_color if (link != null && this.highlighted_links[link.id]) { color = "#FFF" } start_dir = start_dir || LiteGraph.RIGHT end_dir = end_dir || LiteGraph.LEFT const dist = distance(a, b) // TODO: Subline code below was inserted in the wrong place - should be before this statement if (this.render_connections_border && this.ds.scale > 0.6) { ctx.lineWidth = this.connections_width + 4 } ctx.lineJoin = "round" num_sublines ||= 1 if (num_sublines > 1) { ctx.lineWidth = 0.5 } //begin line shape const path = new Path2D() if (link) { // Store the path on the link for hittests link.path = path } for (let i = 0; i < num_sublines; i += 1) { const offsety = (i - (num_sublines - 1) * 0.5) * 5 if (this.links_render_mode == LiteGraph.SPLINE_LINK) { path.moveTo(a[0], a[1] + offsety) let start_offset_x = 0 let start_offset_y = 0 let end_offset_x = 0 let end_offset_y = 0 switch (start_dir) { case LiteGraph.LEFT: start_offset_x = dist * -0.25 break case LiteGraph.RIGHT: start_offset_x = dist * 0.25 break case LiteGraph.UP: start_offset_y = dist * -0.25 break case LiteGraph.DOWN: start_offset_y = dist * 0.25 break } switch (end_dir) { case LiteGraph.LEFT: end_offset_x = dist * -0.25 break case LiteGraph.RIGHT: end_offset_x = dist * 0.25 break case LiteGraph.UP: end_offset_y = dist * -0.25 break case LiteGraph.DOWN: end_offset_y = dist * 0.25 break } path.bezierCurveTo( a[0] + start_offset_x, a[1] + start_offset_y + offsety, b[0] + end_offset_x, b[1] + end_offset_y + offsety, b[0], b[1] + offsety ) } else if (this.links_render_mode == LiteGraph.LINEAR_LINK) { path.moveTo(a[0], a[1] + offsety) let start_offset_x = 0 let start_offset_y = 0 let end_offset_x = 0 let end_offset_y = 0 switch (start_dir) { case LiteGraph.LEFT: start_offset_x = -1 break case LiteGraph.RIGHT: start_offset_x = 1 break case LiteGraph.UP: start_offset_y = -1 break case LiteGraph.DOWN: start_offset_y = 1 break } switch (end_dir) { case LiteGraph.LEFT: end_offset_x = -1 break case LiteGraph.RIGHT: end_offset_x = 1 break case LiteGraph.UP: end_offset_y = -1 break case LiteGraph.DOWN: end_offset_y = 1 break } const l = 15 path.lineTo( a[0] + start_offset_x * l, a[1] + start_offset_y * l + offsety ) path.lineTo( b[0] + end_offset_x * l, b[1] + end_offset_y * l + offsety ) path.lineTo(b[0], b[1] + offsety) } else if (this.links_render_mode == LiteGraph.STRAIGHT_LINK) { path.moveTo(a[0], a[1]) let start_x = a[0] let start_y = a[1] let end_x = b[0] let end_y = b[1] if (start_dir == LiteGraph.RIGHT) { start_x += 10 } else { start_y += 10 } if (end_dir == LiteGraph.LEFT) { end_x -= 10 } else { end_y -= 10 } path.lineTo(start_x, start_y) path.lineTo((start_x + end_x) * 0.5, start_y) path.lineTo((start_x + end_x) * 0.5, end_y) path.lineTo(end_x, end_y) path.lineTo(b[0], b[1]) } else { return } //unknown } //rendering the outline of the connection can be a little bit slow if (this.render_connections_border && this.ds.scale > 0.6 && !skip_border) { ctx.strokeStyle = "rgba(0,0,0,0.5)" ctx.stroke(path) } ctx.lineWidth = this.connections_width ctx.fillStyle = ctx.strokeStyle = color ctx.stroke(path) //end line shape const pos = this.computeConnectionPoint(a, b, 0.5, start_dir, end_dir) if (link?._pos) { link._pos[0] = pos[0] link._pos[1] = pos[1] } //render arrow in the middle if (this.ds.scale >= 0.6 && this.highquality_render && end_dir != LiteGraph.CENTER) { //render arrow if (this.render_connection_arrows) { //compute two points in the connection const posA = this.computeConnectionPoint( a, b, 0.25, start_dir, end_dir ) const posB = this.computeConnectionPoint( a, b, 0.26, start_dir, end_dir ) const posC = this.computeConnectionPoint( a, b, 0.75, start_dir, end_dir ) const posD = this.computeConnectionPoint( a, b, 0.76, start_dir, end_dir ) //compute the angle between them so the arrow points in the right direction let angleA = 0 let angleB = 0 if (this.render_curved_connections) { angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1]) angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1]) } else { angleB = angleA = b[1] > a[1] ? 0 : Math.PI } //render arrow ctx.save() ctx.translate(posA[0], posA[1]) ctx.rotate(angleA) ctx.beginPath() ctx.moveTo(-5, -3) ctx.lineTo(0, +7) ctx.lineTo(+5, -3) ctx.fill() ctx.restore() ctx.save() ctx.translate(posC[0], posC[1]) ctx.rotate(angleB) ctx.beginPath() ctx.moveTo(-5, -3) ctx.lineTo(0, +7) ctx.lineTo(+5, -3) ctx.fill() ctx.restore() } //circle ctx.beginPath() ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) ctx.fill() } //render flowing points if (flow) { ctx.fillStyle = color for (let i = 0; i < 5; ++i) { const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 const flowPos = this.computeConnectionPoint( a, b, f, start_dir, end_dir ) ctx.beginPath() ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) ctx.fill() } } } //returns the link center point based on curvature computeConnectionPoint(a: Point, b: Point, t: number, start_dir: number, end_dir: number): number[] { start_dir ||= LiteGraph.RIGHT end_dir ||= LiteGraph.LEFT const dist = distance(a, b) const p0 = a const p1 = [a[0], a[1]] const p2 = [b[0], b[1]] const p3 = b switch (start_dir) { case LiteGraph.LEFT: p1[0] += dist * -0.25 break case LiteGraph.RIGHT: p1[0] += dist * 0.25 break case LiteGraph.UP: p1[1] += dist * -0.25 break case LiteGraph.DOWN: p1[1] += dist * 0.25 break } switch (end_dir) { case LiteGraph.LEFT: p2[0] += dist * -0.25 break case LiteGraph.RIGHT: p2[0] += dist * 0.25 break case LiteGraph.UP: p2[1] += dist * -0.25 break case LiteGraph.DOWN: p2[1] += dist * 0.25 break } const c1 = (1 - t) * (1 - t) * (1 - t) const c2 = 3 * ((1 - t) * (1 - t)) * t const c3 = 3 * (1 - t) * (t * t) const c4 = t * t * t const x = c1 * p0[0] + c2 * p1[0] + c3 * p2[0] + c4 * p3[0] const y = c1 * p0[1] + c2 * p1[1] + c3 * p2[1] + c4 * p3[1] return [x, y] } drawExecutionOrder(ctx: CanvasRenderingContext2D): void { ctx.shadowColor = "transparent" ctx.globalAlpha = 0.25 ctx.textAlign = "center" ctx.strokeStyle = "white" ctx.globalAlpha = 0.75 const visible_nodes = this.visible_nodes for (let i = 0; i < visible_nodes.length; ++i) { const node = visible_nodes[i] ctx.fillStyle = "black" ctx.fillRect( node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT, node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT, LiteGraph.NODE_TITLE_HEIGHT, LiteGraph.NODE_TITLE_HEIGHT ) if (node.order == 0) { ctx.strokeRect( node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, LiteGraph.NODE_TITLE_HEIGHT, LiteGraph.NODE_TITLE_HEIGHT ) } ctx.fillStyle = "#FFF" ctx.fillText( stringOrEmpty(node.order), node.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * -0.5, node.pos[1] - 6 ) } ctx.globalAlpha = 1 } /** * draws the widgets stored inside a node **/ drawNodeWidgets(node: LGraphNode, posY: number, ctx: CanvasRenderingContext2D, active_widget: IWidget) { if (!node.widgets || !node.widgets.length) return 0 const width = node.size[0] const widgets = node.widgets posY += 2 const H = LiteGraph.NODE_WIDGET_HEIGHT const show_text = this.ds.scale > 0.5 ctx.save() ctx.globalAlpha = this.editor_alpha const outline_color = LiteGraph.WIDGET_OUTLINE_COLOR const background_color = LiteGraph.WIDGET_BGCOLOR const text_color = LiteGraph.WIDGET_TEXT_COLOR const secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR const margin = 15 for (let i = 0; i < widgets.length; ++i) { const w = widgets[i] const y = w.y || posY if (w === this.link_over_widget) { ctx.fillStyle = this.default_connection_color_byType[this.link_over_widget_type] || this.default_connection_color.input_on // Manually draw a slot next to the widget simulating an input drawSlot(ctx, {}, [10, y + 10], {}) } w.last_y = y ctx.strokeStyle = outline_color ctx.fillStyle = "#222" ctx.textAlign = "left" //ctx.lineWidth = 2; if (w.disabled) ctx.globalAlpha *= 0.5 const widget_width = w.width || width switch (w.type) { case "button": ctx.fillStyle = background_color if (w.clicked) { ctx.fillStyle = "#AAA" w.clicked = false this.dirty_canvas = true } ctx.fillRect(margin, y, widget_width - margin * 2, H) if (show_text && !w.disabled) ctx.strokeRect(margin, y, widget_width - margin * 2, H) if (show_text) { ctx.textAlign = "center" ctx.fillStyle = text_color ctx.fillText(w.label || w.name, widget_width * 0.5, y + H * 0.7) } break case "toggle": ctx.textAlign = "left" ctx.strokeStyle = outline_color ctx.fillStyle = background_color ctx.beginPath() if (show_text) ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) else ctx.rect(margin, y, widget_width - margin * 2, H) ctx.fill() if (show_text && !w.disabled) ctx.stroke() ctx.fillStyle = w.value ? "#89A" : "#333" ctx.beginPath() ctx.arc(widget_width - margin * 2, y + H * 0.5, H * 0.36, 0, Math.PI * 2) ctx.fill() if (show_text) { ctx.fillStyle = secondary_text_color const label = w.label || w.name if (label != null) { ctx.fillText(label, margin * 2, y + H * 0.7) } ctx.fillStyle = w.value ? text_color : secondary_text_color ctx.textAlign = "right" ctx.fillText( w.value ? w.options.on || "true" : w.options.off || "false", widget_width - 40, y + H * 0.7 ) } break case "slider": { ctx.fillStyle = background_color ctx.fillRect(margin, y, widget_width - margin * 2, H) const range = w.options.max - w.options.min let nvalue = (w.value - w.options.min) / range if (nvalue < 0.0) nvalue = 0.0 if (nvalue > 1.0) nvalue = 1.0 ctx.fillStyle = w.options.hasOwnProperty("slider_color") ? w.options.slider_color : (active_widget == w ? "#89A" : "#678") ctx.fillRect(margin, y, nvalue * (widget_width - margin * 2), H) if (show_text && !w.disabled) ctx.strokeRect(margin, y, widget_width - margin * 2, H) if (w.marker) { let marker_nvalue = (w.marker - w.options.min) / range if (marker_nvalue < 0.0) marker_nvalue = 0.0 if (marker_nvalue > 1.0) marker_nvalue = 1.0 ctx.fillStyle = w.options.hasOwnProperty("marker_color") ? w.options.marker_color : "#AA9" ctx.fillRect(margin + marker_nvalue * (widget_width - margin * 2), y, 2, H) } if (show_text) { ctx.textAlign = "center" ctx.fillStyle = text_color ctx.fillText( w.label || w.name + " " + Number(w.value).toFixed( w.options.precision != null ? w.options.precision : 3 ), widget_width * 0.5, y + H * 0.7 ) } break } case "number": case "combo": ctx.textAlign = "left" ctx.strokeStyle = outline_color ctx.fillStyle = background_color ctx.beginPath() if (show_text) ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) else ctx.rect(margin, y, widget_width - margin * 2, H) ctx.fill() if (show_text) { if (!w.disabled) ctx.stroke() ctx.fillStyle = text_color if (!w.disabled) { ctx.beginPath() ctx.moveTo(margin + 16, y + 5) ctx.lineTo(margin + 6, y + H * 0.5) ctx.lineTo(margin + 16, y + H - 5) ctx.fill() ctx.beginPath() ctx.moveTo(widget_width - margin - 16, y + 5) ctx.lineTo(widget_width - margin - 6, y + H * 0.5) ctx.lineTo(widget_width - margin - 16, y + H - 5) ctx.fill() } ctx.fillStyle = secondary_text_color ctx.fillText(w.label || w.name, margin * 2 + 5, y + H * 0.7) ctx.fillStyle = text_color ctx.textAlign = "right" if (w.type == "number") { ctx.fillText( Number(w.value).toFixed( w.options.precision !== undefined ? w.options.precision : 3 ), widget_width - margin * 2 - 20, y + H * 0.7 ) } else { let v = typeof w.value === "number" ? String(w.value) : w.value if (w.options.values) { let values = w.options.values if (typeof values === "function") // @ts-expect-error values = values() if (values && !Array.isArray(values)) v = values[w.value] } const labelWidth = ctx.measureText(w.label || w.name).width + margin * 2 const inputWidth = widget_width - margin * 4 const availableWidth = inputWidth - labelWidth const textWidth = ctx.measureText(v).width if (textWidth > availableWidth) { const ELLIPSIS = "\u2026" const ellipsisWidth = ctx.measureText(ELLIPSIS).width const charWidthAvg = ctx.measureText("a").width if (availableWidth <= ellipsisWidth) { v = "\u2024" // One dot leader } else { v = `${v}` const overflowWidth = (textWidth + ellipsisWidth) - availableWidth // Only first 3 characters need to be measured precisely if (overflowWidth + charWidthAvg * 3 > availableWidth) { const preciseRange = availableWidth + charWidthAvg * 3 const preTruncateCt = Math.floor((preciseRange - ellipsisWidth) / charWidthAvg) v = v.substr(0, preTruncateCt) } while (ctx.measureText(v).width + ellipsisWidth > availableWidth) { v = v.substr(0, v.length - 1) } v += ELLIPSIS } } ctx.fillText( v, widget_width - margin * 2 - 20, y + H * 0.7 ) } } break case "string": case "text": ctx.textAlign = "left" ctx.strokeStyle = outline_color ctx.fillStyle = background_color ctx.beginPath() if (show_text) ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) else ctx.rect(margin, y, widget_width - margin * 2, H) ctx.fill() if (show_text) { if (!w.disabled) ctx.stroke() ctx.save() ctx.beginPath() ctx.rect(margin, y, widget_width - margin * 2, H) ctx.clip() //ctx.stroke(); ctx.fillStyle = secondary_text_color const label = w.label || w.name if (label != null) ctx.fillText(label, margin * 2, y + H * 0.7) ctx.fillStyle = text_color ctx.textAlign = "right" ctx.fillText(String(w.value).substr(0, 30), widget_width - margin * 2, y + H * 0.7) //30 chars max ctx.restore() } break // Custom widgets default: w.draw?.(ctx, node, widget_width, y, H) break } posY += (w.computeSize ? w.computeSize(widget_width)[1] : H) + 4 ctx.globalAlpha = this.editor_alpha } ctx.restore() ctx.textAlign = "left" } /** * process an event on widgets **/ processNodeWidgets(node: LGraphNode, // TODO: Hitting enter does not trigger onWidgetChanged - may require a separate value processor for processKey pos: Point, event: CanvasMouseEvent, active_widget?: IWidget): IWidget { if (!node.widgets || !node.widgets.length || (!this.allow_interaction && !node.flags.allow_interaction)) { return null } const x = pos[0] - node.pos[0] const y = pos[1] - node.pos[1] const width = node.size[0] const that = this const ref_window = this.getCanvasWindow() let values let values_list for (let i = 0; i < node.widgets.length; ++i) { const w = node.widgets[i] if (!w || w.disabled) continue const widget_height = w.computeSize ? w.computeSize(width)[1] : LiteGraph.NODE_WIDGET_HEIGHT const widget_width = w.width || width //outside if (w != active_widget && (x < 6 || x > widget_width - 12 || y < w.last_y || y > w.last_y + widget_height || w.last_y === undefined)) continue const old_value = w.value //if ( w == active_widget || (x > 6 && x < widget_width - 12 && y > w.last_y && y < w.last_y + widget_height) ) { //inside widget switch (w.type) { case "button": { // FIXME: This one-function-to-rule-them-all pattern is nuts. Split events into manageable chunks. if (event.type === LiteGraph.pointerevents_method + "down") { if (w.callback) { setTimeout(function () { w.callback(w, that, node, pos, event) }, 20) } w.clicked = true this.dirty_canvas = true } break } case "slider": { const nvalue = clamp((x - 15) / (widget_width - 30), 0, 1) if (w.options.read_only) break w.value = w.options.min + (w.options.max - w.options.min) * nvalue if (old_value != w.value) { setTimeout(function () { inner_value_change(w, w.value) }, 20) } this.dirty_canvas = true break } case "number": case "combo": { let delta = x < 40 ? -1 : x > widget_width - 40 ? 1 : 0 const allow_scroll = delta && (x > -3 && x < widget_width + 3) ? false : true // TODO: Type checks on widget values if (allow_scroll && event.type == LiteGraph.pointerevents_method + "move" && w.type == "number") { if (event.deltaX) w.value += event.deltaX * 0.1 * (w.options.step || 1) if (w.options.min != null && w.value < w.options.min) { w.value = w.options.min } if (w.options.max != null && w.value > w.options.max) { w.value = w.options.max } } else if (event.type == LiteGraph.pointerevents_method + "down") { values = w.options.values if (typeof values === "function") { // @ts-expect-error values = w.options.values(w, node) } values_list = null if (w.type != "number") values_list = Array.isArray(values) ? values : Object.keys(values) delta = x < 40 ? -1 : x > widget_width - 40 ? 1 : 0 if (w.type == "number") { w.value += delta * 0.1 * (w.options.step || 1) if (w.options.min != null && w.value < w.options.min) { w.value = w.options.min } if (w.options.max != null && w.value > w.options.max) { w.value = w.options.max } } else if (delta) { //clicked in arrow, used for combos let index = -1 this.last_mouseclick = 0 //avoids dobl click event index = typeof values === "object" ? values_list.indexOf(String(w.value)) + delta : values_list.indexOf(w.value) + delta if (index >= values_list.length) index = values_list.length - 1 if (index < 0) index = 0 w.value = Array.isArray(values) ? values[index] : index } else { //combo clicked const text_values = values != values_list ? Object.values(values) : values new LiteGraph.ContextMenu(text_values, { scale: Math.max(1, this.ds.scale), event: event, className: "dark", callback: inner_clicked.bind(w) }, // @ts-expect-error Not impl - harmless ref_window) function inner_clicked(v) { if (values != values_list) v = text_values.indexOf(v) this.value = v inner_value_change(this, v) that.dirty_canvas = true return false } } } //end mousedown else if (event.type == LiteGraph.pointerevents_method + "up" && w.type == "number") { delta = x < 40 ? -1 : x > widget_width - 40 ? 1 : 0 if (event.click_time < 200 && delta == 0) { this.prompt("Value", w.value, function (v) { // check if v is a valid equation or a number if (/^[0-9+\-*/()\s]+|\d+\.\d+$/.test(v)) { try { //solve the equation if possible v = eval(v) } catch { } } this.value = Number(v) inner_value_change(this, this.value) }.bind(w), event) } } if (old_value != w.value) setTimeout( function () { inner_value_change(this, this.value) }.bind(w), 20 ) this.dirty_canvas = true break } case "toggle": if (event.type == LiteGraph.pointerevents_method + "down") { w.value = !w.value setTimeout(function () { inner_value_change(w, w.value) }, 20) } break case "string": case "text": if (event.type == LiteGraph.pointerevents_method + "down") { this.prompt("Value", w.value, function (v: any) { inner_value_change(this, v) }.bind(w), event, w.options ? w.options.multiline : false) } break default: if (w.mouse) this.dirty_canvas = w.mouse(event, [x, y], node) break } //end switch //value changed if (old_value != w.value) { node.onWidgetChanged?.(w.name, w.value, old_value, w) node.graph._version++ } return w } //end for function inner_value_change(widget: IWidget, value: TWidgetValue) { const v = widget.type === "number" ? Number(value) : value widget.value = v if (widget.options?.property && node.properties[widget.options.property] !== undefined) { node.setProperty(widget.options.property, v) } widget.callback?.(widget.value, that, node, pos, event) } return null } /** * draws every group area in the background **/ drawGroups(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void { if (!this.graph) return const groups = this.graph._groups ctx.save() ctx.globalAlpha = 0.5 * this.editor_alpha for (let i = 0; i < groups.length; ++i) { const group = groups[i] if (!overlapBounding(this.visible_area, group._bounding)) { continue } //out of the visible area group.draw(this, ctx) } ctx.restore() } adjustNodesSize(): void { const nodes = this.graph._nodes for (let i = 0; i < nodes.length; ++i) { nodes[i].size = nodes[i].computeSize() } this.setDirty(true, true) } /** * resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode **/ resize(width?: number, height?: number): void { if (!width && !height) { // TODO: Type-check parentNode const parent = this.canvas.parentNode // @ts-expect-error width = parent.offsetWidth // @ts-expect-error height = parent.offsetHeight } if (this.canvas.width == width && this.canvas.height == height) return this.canvas.width = width this.canvas.height = height this.bgcanvas.width = this.canvas.width this.bgcanvas.height = this.canvas.height this.setDirty(true, true) } /** * switches to live mode (node shapes are not rendered, only the content) * this feature was designed when graphs where meant to create user interfaces **/ switchLiveMode(transition: boolean): void { if (!transition) { this.live_mode = !this.live_mode this.dirty_canvas = true this.dirty_bgcanvas = true return } const self = this const delta = this.live_mode ? 1.1 : 0.9 if (this.live_mode) { this.live_mode = false this.editor_alpha = 0.1 } const t = setInterval(function () { self.editor_alpha *= delta self.dirty_canvas = true self.dirty_bgcanvas = true if (delta < 1 && self.editor_alpha < 0.01) { clearInterval(t) if (delta < 1) self.live_mode = true } if (delta > 1 && self.editor_alpha > 0.99) { clearInterval(t) self.editor_alpha = 1 } }, 1) } onNodeSelectionChange(): void { } /** * Determines the furthest nodes in each direction for the currently selected nodes * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} */ boundaryNodesForSelection(): IBoundaryNodes { return LGraphCanvas.getBoundaryNodes(Object.values(this.selected_nodes)) } showLinkMenu(link: LLink, e: CanvasMouseEvent): boolean { const graph = this.graph const node_left = graph.getNodeById(link.origin_id) const node_right = graph.getNodeById(link.target_id) // TODO: Replace ternary with ?? "" const fromType = node_left?.outputs?.[link.origin_slot] ? node_left.outputs[link.origin_slot].type : false const destType = node_right?.outputs?.[link.target_slot] ? node_right.inputs[link.target_slot].type : false const options = ["Add Node", null, "Delete", null] const menu = new LiteGraph.ContextMenu(options, { event: e, title: link.data != null ? link.data.constructor.name : null, callback: inner_clicked }) function inner_clicked(v: string, options: unknown, e: MouseEvent) { switch (v) { case "Add Node": LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { if (!node.inputs?.length || !node.outputs?.length) return // leave the connection type checking inside connectByType // @ts-expect-error Assigning from check to false results in the type being treated as "*". This should fail. if (node_left.connectByType(link.origin_slot, node, fromType)) { // @ts-expect-error Assigning from check to false results in the type being treated as "*". This should fail. node.connectByType(link.target_slot, node_right, destType) node.pos[0] -= node.size[0] * 0.5 } }) break case "Delete": graph.removeLink(link.id) break default: } } return false } createDefaultNodeForSlot(optPass: ICreateNodeOptions): boolean { optPass = optPass || {} const opts = Object.assign({ nodeFrom: null // input , slotFrom: null // input , nodeTo: null // output , slotTo: null // output , position: [] // pass the event coords , nodeType: null // choose a nodetype to add, AUTO to set at first good , posAdd: [0, 0] // adjust x,y , posSizeFix: [0, 0] // alpha, adjust the position x,y based on the new node size w,h }, optPass ) const that = this const isFrom = opts.nodeFrom && opts.slotFrom !== null const isTo = !isFrom && opts.nodeTo && opts.slotTo !== null if (!isFrom && !isTo) { console.warn("No data passed to createDefaultNodeForSlot " + opts.nodeFrom + " " + opts.slotFrom + " " + opts.nodeTo + " " + opts.slotTo) return false } if (!opts.nodeType) { console.warn("No type to createDefaultNodeForSlot") return false } const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo let slotX = isFrom ? opts.slotFrom : opts.slotTo let iSlotConn: number | false = false switch (typeof slotX) { case "string": iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false) slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] break case "object": // ok slotX iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name) break case "number": iSlotConn = slotX slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] break case "undefined": default: // bad ? //iSlotConn = 0; console.warn("Cant get slot information " + slotX) return false } if (slotX === false || iSlotConn === false) { console.warn("createDefaultNodeForSlot bad slotX " + slotX + " " + iSlotConn) } // check for defaults nodes for this slottype const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type const slotTypesDefault = isFrom ? LiteGraph.slot_types_default_out : LiteGraph.slot_types_default_in if (slotTypesDefault?.[fromSlotType]) { // TODO: Remove "any" kludge let nodeNewType: any = false if (typeof slotTypesDefault[fromSlotType] == "object") { for (const typeX in slotTypesDefault[fromSlotType]) { if (opts.nodeType == slotTypesDefault[fromSlotType][typeX] || opts.nodeType == "AUTO") { nodeNewType = slotTypesDefault[fromSlotType][typeX] // console.log("opts.nodeType == slotTypesDefault[fromSlotType][typeX] :: "+opts.nodeType); break // -------- } } } else { if (opts.nodeType == slotTypesDefault[fromSlotType] || opts.nodeType == "AUTO") nodeNewType = slotTypesDefault[fromSlotType] } if (nodeNewType) { // TODO: Remove "any" kludge let nodeNewOpts: any = false if (typeof nodeNewType == "object" && nodeNewType.node) { nodeNewOpts = nodeNewType nodeNewType = nodeNewType.node } //that.graph.beforeChange(); const newNode = LiteGraph.createNode(nodeNewType) if (newNode) { // if is object pass options if (nodeNewOpts) { if (nodeNewOpts.properties) { for (const i in nodeNewOpts.properties) { newNode.addProperty(i, nodeNewOpts.properties[i]) } } if (nodeNewOpts.inputs) { newNode.inputs = [] for (const i in nodeNewOpts.inputs) { newNode.addOutput( nodeNewOpts.inputs[i][0], nodeNewOpts.inputs[i][1] ) } } if (nodeNewOpts.outputs) { newNode.outputs = [] for (const i in nodeNewOpts.outputs) { newNode.addOutput( nodeNewOpts.outputs[i][0], nodeNewOpts.outputs[i][1] ) } } if (nodeNewOpts.title) { newNode.title = nodeNewOpts.title } if (nodeNewOpts.json) { newNode.configure(nodeNewOpts.json) } } // add the node that.graph.add(newNode) newNode.pos = [opts.position[0] + opts.posAdd[0] + (opts.posSizeFix[0] ? opts.posSizeFix[0] * newNode.size[0] : 0), opts.position[1] + opts.posAdd[1] + (opts.posSizeFix[1] ? opts.posSizeFix[1] * newNode.size[1] : 0)] //that.last_click_position; //[e.canvasX+30, e.canvasX+5];*/ //that.graph.afterChange(); // connect the two! if (isFrom) { opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType) } else { opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType) } // if connecting in between if (isFrom && isTo) { // TODO } return true } console.log("failed creating " + nodeNewType) } } return false } showConnectionMenu(optPass: Partial): void { optPass ||= {} const opts = Object.assign({ nodeFrom: null // input , slotFrom: null // input , nodeTo: null // output , slotTo: null // output , e: null, allow_searchbox: this.allow_searchbox, showSearchBox: this.showSearchBox, }, optPass ) const that = this const isFrom = opts.nodeFrom && opts.slotFrom const isTo = !isFrom && opts.nodeTo && opts.slotTo if (!isFrom && !isTo) { console.warn("No data passed to showConnectionMenu") return } const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo let slotX = isFrom ? opts.slotFrom : opts.slotTo // TODO: Remove "any" kludge let iSlotConn: any = false switch (typeof slotX) { case "string": iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false) slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] break case "object": // ok slotX iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name) break case "number": iSlotConn = slotX slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] break default: // bad ? //iSlotConn = 0; console.warn("Cant get slot information " + slotX) return } const options = ["Add Node", null] if (opts.allow_searchbox) { options.push("Search") options.push(null) } // get defaults nodes for this slottype const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type const slotTypesDefault = isFrom ? LiteGraph.slot_types_default_out : LiteGraph.slot_types_default_in if (slotTypesDefault?.[fromSlotType]) { if (typeof slotTypesDefault[fromSlotType] == "object") { for (const typeX in slotTypesDefault[fromSlotType]) { options.push(slotTypesDefault[fromSlotType][typeX]) } } else { options.push(slotTypesDefault[fromSlotType]) } } // build menu const menu = new LiteGraph.ContextMenu(options, { event: opts.e, title: (slotX && slotX.name != "" ? (slotX.name + (fromSlotType ? " | " : "")) : "") + (slotX && fromSlotType ? fromSlotType : ""), callback: inner_clicked }) // callback function inner_clicked(v, options, e) { //console.log("Process showConnectionMenu selection"); switch (v) { case "Add Node": LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { if (isFrom) { opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType) } else { opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType) } }) break case "Search": if (isFrom) { opts.showSearchBox(e, { node_from: opts.nodeFrom, slot_from: slotX, type_filter_in: fromSlotType }) } else { opts.showSearchBox(e, { node_to: opts.nodeTo, slot_from: slotX, type_filter_out: fromSlotType }) } break default: { // check for defaults nodes for this slottype // eslint-disable-next-line @typescript-eslint/no-unused-vars const nodeCreated = that.createDefaultNodeForSlot(Object.assign(opts, { position: [opts.e.canvasX, opts.e.canvasY], nodeType: v })) break } } } } // refactor: there are different dialogs, some uses createDialog some dont prompt(title: string, value: any, callback: (arg0: any) => void, event: CanvasMouseEvent, multiline?: boolean): HTMLDivElement { const that = this title = title || "" const dialog: IDialog = document.createElement("div") dialog.is_modified = false dialog.className = "graphdialog rounded" dialog.innerHTML = multiline ? " " : " " dialog.close = function () { that.prompt_box = null if (dialog.parentNode) { dialog.parentNode.removeChild(dialog) } } const graphcanvas = LGraphCanvas.active_canvas const canvas = graphcanvas.canvas canvas.parentNode.appendChild(dialog) if (this.ds.scale > 1) dialog.style.transform = "scale(" + this.ds.scale + ")" let dialogCloseTimer = null let prevent_timeout = 0 LiteGraph.pointerListenerAdd(dialog, "leave", function () { if (prevent_timeout) return if (LiteGraph.dialog_close_on_mouse_leave) if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); }) LiteGraph.pointerListenerAdd(dialog, "enter", function () { if (LiteGraph.dialog_close_on_mouse_leave && dialogCloseTimer) clearTimeout(dialogCloseTimer) }) const selInDia = dialog.querySelectorAll("select") if (selInDia) { // if filtering, check focus changed to comboboxes and prevent closing for (const selIn of selInDia) { selIn.addEventListener("click", function () { prevent_timeout++ }) selIn.addEventListener("blur", function () { prevent_timeout = 0 }) selIn.addEventListener("change", function () { prevent_timeout = -1 }) } } this.prompt_box?.close() this.prompt_box = dialog const name_element: HTMLSpanElement = dialog.querySelector(".name") name_element.innerText = title const value_element: HTMLTextAreaElement | HTMLInputElement = dialog.querySelector(".value") value_element.value = value value_element.select() const input = value_element input.addEventListener("keydown", function (e: KeyboardEvent) { dialog.is_modified = true if (e.keyCode == 27) { //ESC dialog.close() } else if (e.keyCode == 13 && (e.target as Element).localName != "textarea") { if (callback) { callback(this.value) } dialog.close() } else { return } e.preventDefault() e.stopPropagation() }) const button = dialog.querySelector("button") button.addEventListener("click", function () { callback?.(input.value) that.setDirty(true) dialog.close() }) const rect = canvas.getBoundingClientRect() let offsetx = -20 let offsety = -20 if (rect) { offsetx -= rect.left offsety -= rect.top } if (event) { dialog.style.left = event.clientX + offsetx + "px" dialog.style.top = event.clientY + offsety + "px" } else { dialog.style.left = canvas.width * 0.5 + offsetx + "px" dialog.style.top = canvas.height * 0.5 + offsety + "px" } setTimeout(function () { input.focus() const clickTime = Date.now() function handleOutsideClick(e) { if (e.target === canvas && Date.now() - clickTime > 256) { dialog.close() canvas.parentNode.removeEventListener("click", handleOutsideClick) canvas.parentNode.removeEventListener("touchend", handleOutsideClick) } } canvas.parentNode.addEventListener("click", handleOutsideClick) canvas.parentNode.addEventListener("touchend", handleOutsideClick) }, 10) return dialog } showSearchBox(event: CanvasMouseEvent, options?: IShowSearchOptions): HTMLDivElement { // proposed defaults const def_options: IShowSearchOptions = { slot_from: null, node_from: null, node_to: null, do_type_filter: LiteGraph.search_filter_enabled // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out , // @ts-expect-error type_filter_in: false // these are default: pass to set initially set values , // @ts-expect-error type_filter_out: false, show_general_if_none_on_typefilter: true, show_general_after_typefiltered: true, hide_on_mouse_leave: LiteGraph.search_hide_on_mouse_leave, show_all_if_empty: true, show_all_on_open: LiteGraph.search_show_all_on_open } options = Object.assign(def_options, options || {}) //console.log(options); const that = this const graphcanvas = LGraphCanvas.active_canvas const canvas = graphcanvas.canvas const root_document = canvas.ownerDocument || document const dialog = document.createElement("div") dialog.className = "litegraph litesearchbox graphdialog rounded" dialog.innerHTML = "Search " if (options.do_type_filter) { dialog.innerHTML += "" dialog.innerHTML += "" } dialog.innerHTML += "
" if (root_document.fullscreenElement) root_document.fullscreenElement.appendChild(dialog) else { root_document.body.appendChild(dialog) root_document.body.style.overflow = "hidden" } // dialog element has been appended let selIn let selOut if (options.do_type_filter) { selIn = dialog.querySelector(".slot_in_type_filter") selOut = dialog.querySelector(".slot_out_type_filter") } // @ts-expect-error Panel? dialog.close = function () { that.search_box = null this.blur() canvas.focus() root_document.body.style.overflow = "" setTimeout(function () { that.canvas.focus() }, 20) //important, if canvas loses focus keys wont be captured dialog.parentNode?.removeChild(dialog) } if (this.ds.scale > 1) { dialog.style.transform = "scale(" + this.ds.scale + ")" } // hide on mouse leave if (options.hide_on_mouse_leave) { // FIXME: Remove "any" kludge let prevent_timeout: any = false let timeout_close = null LiteGraph.pointerListenerAdd(dialog, "enter", function () { if (timeout_close) { clearTimeout(timeout_close) timeout_close = null } }) LiteGraph.pointerListenerAdd(dialog, "leave", function () { if (prevent_timeout) return timeout_close = setTimeout(function () { // @ts-expect-error Panel? dialog.close() }, typeof options.hide_on_mouse_leave === "number" ? options.hide_on_mouse_leave : 500) }) // if filtering, check focus changed to comboboxes and prevent closing if (options.do_type_filter) { selIn.addEventListener("click", function () { prevent_timeout++ }) selIn.addEventListener("blur", function () { prevent_timeout = 0 }) selIn.addEventListener("change", function () { prevent_timeout = -1 }) selOut.addEventListener("click", function () { prevent_timeout++ }) selOut.addEventListener("blur", function () { prevent_timeout = 0 }) selOut.addEventListener("change", function () { prevent_timeout = -1 }) } } // @ts-expect-error Panel? that.search_box?.close() that.search_box = dialog const helper = dialog.querySelector(".helper") let first = null let timeout = null let selected = null const input = dialog.querySelector("input") if (input) { input.addEventListener("blur", function () { this.focus() }) input.addEventListener("keydown", function (e) { if (e.keyCode == 38) { //UP changeSelection(false) } else if (e.keyCode == 40) { //DOWN changeSelection(true) } else if (e.keyCode == 27) { //ESC // @ts-expect-error Panel? dialog.close() } else if (e.keyCode == 13) { if (selected) { select(unescape(selected.dataset["type"])) } else if (first) { select(first) } else { // @ts-expect-error Panel? dialog.close() } } else { if (timeout) { clearInterval(timeout) } timeout = setTimeout(refreshHelper, 10) return } e.preventDefault() e.stopPropagation() e.stopImmediatePropagation() return true }) } // if should filter on type, load and fill selected and choose elements if passed if (options.do_type_filter) { if (selIn) { const aSlots = LiteGraph.slot_types_in const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; if (options.type_filter_in == LiteGraph.EVENT || options.type_filter_in == LiteGraph.ACTION) options.type_filter_in = "_event_" /* this will filter on * .. but better do it manually in case else if(options.type_filter_in === "" || options.type_filter_in === 0) options.type_filter_in = "*";*/ for (let iK = 0; iK < nSlots; iK++) { const opt = document.createElement('option') opt.value = aSlots[iK] opt.innerHTML = aSlots[iK] selIn.appendChild(opt) // @ts-expect-error if (options.type_filter_in !== false && (options.type_filter_in + "").toLowerCase() == (aSlots[iK] + "").toLowerCase()) { //selIn.selectedIndex .. opt.selected = true //console.log("comparing IN "+options.type_filter_in+" :: "+aSlots[iK]); } else { //console.log("comparing OUT "+options.type_filter_in+" :: "+aSlots[iK]); } } selIn.addEventListener("change", function () { refreshHelper() }) } if (selOut) { const aSlots = LiteGraph.slot_types_out const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; if (options.type_filter_out == LiteGraph.EVENT || options.type_filter_out == LiteGraph.ACTION) options.type_filter_out = "_event_" /* this will filter on * .. but better do it manually in case else if(options.type_filter_out === "" || options.type_filter_out === 0) options.type_filter_out = "*";*/ for (let iK = 0; iK < nSlots; iK++) { const opt = document.createElement('option') opt.value = aSlots[iK] opt.innerHTML = aSlots[iK] selOut.appendChild(opt) // @ts-expect-error if (options.type_filter_out !== false && (options.type_filter_out + "").toLowerCase() == (aSlots[iK] + "").toLowerCase()) opt.selected = true } selOut.addEventListener("change", function () { refreshHelper() }) } } //compute best position const rect = canvas.getBoundingClientRect() const left = (event ? event.clientX : (rect.left + rect.width * 0.5)) - 80 const top = (event ? event.clientY : (rect.top + rect.height * 0.5)) - 20 dialog.style.left = left + "px" dialog.style.top = top + "px" //To avoid out of screen problems if (event.layerY > (rect.height - 200)) // @ts-expect-error helper.style.maxHeight = (rect.height - event.layerY - 20) + "px" /* var offsetx = -20; var offsety = -20; if (rect) { offsetx -= rect.left; offsety -= rect.top; } if (event) { dialog.style.left = event.clientX + offsetx + "px"; dialog.style.top = event.clientY + offsety + "px"; } else { dialog.style.left = canvas.width * 0.5 + offsetx + "px"; dialog.style.top = canvas.height * 0.5 + offsety + "px"; } canvas.parentNode.appendChild(dialog); */ requestAnimationFrame(function () { input.focus() }) if (options.show_all_on_open) refreshHelper() function select(name) { if (name) { if (that.onSearchBoxSelection) { that.onSearchBoxSelection(name, event, graphcanvas) } else { const extra = LiteGraph.searchbox_extras[name.toLowerCase()] if (extra) name = extra.type graphcanvas.graph.beforeChange() const node = LiteGraph.createNode(name) if (node) { node.pos = graphcanvas.convertEventToCanvasOffset( event ) graphcanvas.graph.add(node, false) } if (extra?.data) { if (extra.data.properties) { for (const i in extra.data.properties) { node.addProperty(i, extra.data.properties[i]) } } if (extra.data.inputs) { node.inputs = [] for (const i in extra.data.inputs) { node.addOutput( extra.data.inputs[i][0], extra.data.inputs[i][1] ) } } if (extra.data.outputs) { node.outputs = [] for (const i in extra.data.outputs) { node.addOutput( extra.data.outputs[i][0], extra.data.outputs[i][1] ) } } if (extra.data.title) { node.title = extra.data.title } if (extra.data.json) { node.configure(extra.data.json) } } // join node after inserting if (options.node_from) { // FIXME: any let iS: any = false switch (typeof options.slot_from) { case "string": iS = options.node_from.findOutputSlot(options.slot_from) break case "object": iS = options.slot_from.name ? options.node_from.findOutputSlot(options.slot_from.name) : -1 // @ts-expect-error change interface check if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index break case "number": iS = options.slot_from break default: iS = 0 // try with first if no name set } if (typeof options.node_from.outputs[iS] !== "undefined") { if (iS !== false && iS > -1) { options.node_from.connectByType(iS, node, options.node_from.outputs[iS].type) } } else { // console.warn("cant find slot " + options.slot_from); } } if (options.node_to) { // FIXME: any let iS: any = false switch (typeof options.slot_from) { case "string": iS = options.node_to.findInputSlot(options.slot_from) break case "object": iS = options.slot_from.name ? options.node_to.findInputSlot(options.slot_from.name) : -1 // @ts-expect-error change interface check if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index break case "number": iS = options.slot_from break default: iS = 0 // try with first if no name set } if (typeof options.node_to.inputs[iS] !== "undefined") { if (iS !== false && iS > -1) { // try connection options.node_to.connectByTypeOutput(iS, node, options.node_to.inputs[iS].type) } } else { // console.warn("cant find slot_nodeTO " + options.slot_from); } } graphcanvas.graph.afterChange() } } // @ts-expect-error Panel? dialog.close() } function changeSelection(forward) { const prev = selected if (!selected) { selected = forward ? helper.childNodes[0] : helper.childNodes[helper.childNodes.length] } else { selected.classList.remove("selected") selected = forward ? selected.nextSibling : selected.previousSibling selected ||= prev } if (!selected) return selected.classList.add("selected") selected.scrollIntoView({ block: "end", behavior: "smooth" }) } function refreshHelper() { timeout = null let str = input.value first = null helper.innerHTML = "" if (!str && !options.show_all_if_empty) return if (that.onSearchBox) { const list = that.onSearchBox(helper, str, graphcanvas) if (list) { for (let i = 0; i < list.length; ++i) { addResult(list[i]) } } } else { let c = 0 str = str.toLowerCase() const filter = graphcanvas.filter || graphcanvas.graph.filter // FIXME: any // filter by type preprocess let sIn: any = false let sOut: any = false if (options.do_type_filter && that.search_box) { sIn = that.search_box.querySelector(".slot_in_type_filter") sOut = that.search_box.querySelector(".slot_out_type_filter") } //extras for (const i in LiteGraph.searchbox_extras) { const extra = LiteGraph.searchbox_extras[i] if ((!options.show_all_if_empty || str) && extra.desc.toLowerCase().indexOf(str) === -1) continue const ctor = LiteGraph.registered_node_types[extra.type] if (ctor && ctor.filter != filter) continue if (!inner_test_filter(extra.type)) continue addResult(extra.desc, "searchbox_extra") if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) { break } } let filtered = null if (Array.prototype.filter) { //filter supported const keys = Object.keys(LiteGraph.registered_node_types) //types filtered = keys.filter(inner_test_filter) } else { filtered = [] for (const i in LiteGraph.registered_node_types) { if (inner_test_filter(i)) filtered.push(i) } } for (let i = 0; i < filtered.length; i++) { addResult(filtered[i]) if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) break } // add general type if filtering if (options.show_general_after_typefiltered && (sIn.value || sOut.value)) { // FIXME: Undeclared variable again // @ts-expect-error filtered_extra = [] for (const i in LiteGraph.registered_node_types) { if (inner_test_filter(i, { inTypeOverride: sIn && sIn.value ? "*" : false, outTypeOverride: sOut && sOut.value ? "*" : false })) // @ts-expect-error filtered_extra.push(i) } // @ts-expect-error for (let i = 0; i < filtered_extra.length; i++) { // @ts-expect-error addResult(filtered_extra[i], "generic_type") if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) break } } // check il filtering gave no results if ((sIn.value || sOut.value) && ((helper.childNodes.length == 0 && options.show_general_if_none_on_typefilter))) { // @ts-expect-error filtered_extra = [] for (const i in LiteGraph.registered_node_types) { if (inner_test_filter(i, { skipFilter: true })) // @ts-expect-error filtered_extra.push(i) } // @ts-expect-error for (let i = 0; i < filtered_extra.length; i++) { // @ts-expect-error addResult(filtered_extra[i], "not_in_filter") if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) break } } function inner_test_filter(type: string, optsIn?: number | { inTypeOverride?: string | boolean; outTypeOverride?: string | boolean; skipFilter?: boolean }): boolean { optsIn = optsIn || {} const optsDef = { skipFilter: false, inTypeOverride: false, outTypeOverride: false } const opts = Object.assign(optsDef, optsIn) const ctor = LiteGraph.registered_node_types[type] if (filter && ctor.filter != filter) return false if ((!options.show_all_if_empty || str) && type.toLowerCase().indexOf(str) === -1 && (!ctor.title || ctor.title.toLowerCase().indexOf(str) === -1)) return false // filter by slot IN, OUT types if (options.do_type_filter && !opts.skipFilter) { const sType = type let sV = opts.inTypeOverride !== false ? opts.inTypeOverride : sIn.value //if (sV.toLowerCase() == "_event_") sV = LiteGraph.EVENT; // -1 if (sIn && sV) { //console.log("will check filter against "+sV); if (LiteGraph.registered_slot_in_types[sV]?.nodes) { // type is stored //console.debug("check "+sType+" in "+LiteGraph.registered_slot_in_types[sV].nodes); const doesInc = LiteGraph.registered_slot_in_types[sV].nodes.includes(sType) if (doesInc !== false) { //console.log(sType+" HAS "+sV); } else { /*console.debug(LiteGraph.registered_slot_in_types[sV]); console.log(+" DONT includes "+type);*/ return false } } } sV = sOut.value if (opts.outTypeOverride !== false) sV = opts.outTypeOverride //if (sV.toLowerCase() == "_event_") sV = LiteGraph.EVENT; // -1 if (sOut && sV) { //console.log("search will check filter against "+sV); if (LiteGraph.registered_slot_out_types[sV]?.nodes) { // type is stored //console.debug("check "+sType+" in "+LiteGraph.registered_slot_out_types[sV].nodes); const doesInc = LiteGraph.registered_slot_out_types[sV].nodes.includes(sType) if (doesInc !== false) { //console.log(sType+" HAS "+sV); } else { /*console.debug(LiteGraph.registered_slot_out_types[sV]); console.log(+" DONT includes "+type);*/ return false } } } } return true } } function addResult(type: string, className?: string): void { const help = document.createElement("div") first ||= type const nodeType = LiteGraph.registered_node_types[type] if (nodeType?.title) { help.innerText = nodeType?.title const typeEl = document.createElement("span") typeEl.className = "litegraph lite-search-item-type" typeEl.textContent = type help.append(typeEl) } else { help.innerText = type } help.dataset["type"] = escape(type) help.className = "litegraph lite-search-item" if (className) { help.className += " " + className } help.addEventListener("click", function () { select(unescape(this.dataset["type"])) }) helper.appendChild(help) } } return dialog } showEditPropertyValue(node: LGraphNode, property: string, options: IDialogOptions): IDialog { if (!node || node.properties[property] === undefined) return options = options || {} const info = node.getPropertyInfo(property) const type = info.type let input_html = "" if (type == "string" || type == "number" || type == "array" || type == "object") { input_html = "" } else if ((type == "enum" || type == "combo") && info.values) { input_html = "" } else if (type == "boolean" || type == "toggle") { input_html = "" } else { console.warn("unknown type: " + type) return } const dialog = this.createDialog( "" + (info.label || property) + "" + input_html + "", options ) let input: HTMLInputElement | HTMLSelectElement if ((type == "enum" || type == "combo") && info.values) { input = dialog.querySelector("select") input.addEventListener("change", function (e) { dialog.modified() setValue((e.target as HTMLSelectElement)?.value) }) } else if (type == "boolean" || type == "toggle") { input = dialog.querySelector("input") input?.addEventListener("click", function () { dialog.modified() // @ts-expect-error setValue(!!input.checked) }) } else { input = dialog.querySelector("input") if (input) { input.addEventListener("blur", function () { this.focus() }) let v = node.properties[property] !== undefined ? node.properties[property] : "" if (type !== 'string') { v = JSON.stringify(v) } // @ts-expect-error input.value = v input.addEventListener("keydown", function (e) { if (e.keyCode == 27) { //ESC dialog.close() } else if (e.keyCode == 13) { // ENTER inner() // save } else if (e.keyCode != 13) { dialog.modified() return } e.preventDefault() e.stopPropagation() }) } } input?.focus() const button = dialog.querySelector("button") button.addEventListener("click", inner) function inner() { setValue(input.value) } function setValue(value: string | number) { if (info?.values && typeof info.values === "object" && info.values[value] != undefined) value = info.values[value] if (typeof node.properties[property] == "number") { value = Number(value) } if (type == "array" || type == "object") { // @ts-expect-error JSON.parse doesn't care. value = JSON.parse(value) } node.properties[property] = value if (node.graph) { node.graph._version++ } node.onPropertyChanged?.(property, value) options.onclose?.() dialog.close() node.setDirtyCanvas(true, true) } return dialog } // TODO refactor, theer are different dialog, some uses createDialog, some dont createDialog(html: string, options: IDialogOptions): IDialog { const def_options = { checkForInput: false, closeOnLeave: true, closeOnLeave_checkModified: true } options = Object.assign(def_options, options || {}) const dialog: IDialog = document.createElement("div") dialog.className = "graphdialog" dialog.innerHTML = html dialog.is_modified = false const rect = this.canvas.getBoundingClientRect() let offsetx = -20 let offsety = -20 if (rect) { offsetx -= rect.left offsety -= rect.top } if (options.position) { offsetx += options.position[0] offsety += options.position[1] } else if (options.event) { offsetx += options.event.clientX offsety += options.event.clientY } //centered else { offsetx += this.canvas.width * 0.5 offsety += this.canvas.height * 0.5 } dialog.style.left = offsetx + "px" dialog.style.top = offsety + "px" this.canvas.parentNode.appendChild(dialog) // acheck for input and use default behaviour: save on enter, close on esc if (options.checkForInput) { const aI = dialog.querySelectorAll("input") const focused = false aI?.forEach(function (iX) { iX.addEventListener("keydown", function (e) { dialog.modified() if (e.keyCode == 27) { dialog.close() } else if (e.keyCode != 13) { return } // set value ? e.preventDefault() e.stopPropagation() }) if (!focused) iX.focus() }) } dialog.modified = function () { dialog.is_modified = true } dialog.close = function () { dialog.parentNode?.removeChild(dialog) } let dialogCloseTimer = null let prevent_timeout = 0 dialog.addEventListener("mouseleave", function () { if (prevent_timeout) return if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); }) dialog.addEventListener("mouseenter", function () { if (options.closeOnLeave || LiteGraph.dialog_close_on_mouse_leave) if (dialogCloseTimer) clearTimeout(dialogCloseTimer) }) const selInDia = dialog.querySelectorAll("select") // if filtering, check focus changed to comboboxes and prevent closing selInDia?.forEach(function (selIn) { selIn.addEventListener("click", function () { prevent_timeout++ }) selIn.addEventListener("blur", function () { prevent_timeout = 0 }) selIn.addEventListener("change", function () { prevent_timeout = -1 }) }) return dialog } createPanel(title, options) { options = options || {} const ref_window = options.window || window // TODO: any kludge const root: any = document.createElement("div") root.className = "litegraph dialog" root.innerHTML = "
" root.header = root.querySelector(".dialog-header") if (options.width) root.style.width = options.width + (typeof options.width === "number" ? "px" : "") if (options.height) root.style.height = options.height + (typeof options.height === "number" ? "px" : "") if (options.closable) { const close = document.createElement("span") close.innerHTML = "✕" close.classList.add("close") close.addEventListener("click", function () { root.close() }) root.header.appendChild(close) } root.title_element = root.querySelector(".dialog-title") root.title_element.innerText = title root.content = root.querySelector(".dialog-content") root.alt_content = root.querySelector(".dialog-alt-content") root.footer = root.querySelector(".dialog-footer") root.close = function () { if (typeof root.onClose == "function") root.onClose() root.parentNode?.removeChild(root) /* XXX CHECK THIS */ this.parentNode?.removeChild(this) /* XXX this was not working, was fixed with an IF, check this */ } // function to swap panel content root.toggleAltContent = function (force: unknown) { let vTo: string let vAlt: string if (typeof force != "undefined") { vTo = force ? "block" : "none" vAlt = force ? "none" : "block" } else { vTo = root.alt_content.style.display != "block" ? "block" : "none" vAlt = root.alt_content.style.display != "block" ? "none" : "block" } root.alt_content.style.display = vTo root.content.style.display = vAlt } root.toggleFooterVisibility = function (force: unknown) { let vTo: string if (typeof force != "undefined") { vTo = force ? "block" : "none" } else { vTo = root.footer.style.display != "block" ? "block" : "none" } root.footer.style.display = vTo } root.clear = function () { this.content.innerHTML = "" } root.addHTML = function (code, classname, on_footer) { const elem = document.createElement("div") if (classname) elem.className = classname elem.innerHTML = code if (on_footer) root.footer.appendChild(elem) else root.content.appendChild(elem) return elem } root.addButton = function (name, callback, options) { // TODO: any kludge const elem: any = document.createElement("button") elem.innerText = name elem.options = options elem.classList.add("btn") elem.addEventListener("click", callback) root.footer.appendChild(elem) return elem } root.addSeparator = function () { const elem = document.createElement("div") elem.className = "separator" root.content.appendChild(elem) } root.addWidget = function (type, name, value, options, callback) { options = options || {} let str_value = String(value) type = type.toLowerCase() if (type == "number") str_value = value.toFixed(3) // FIXME: any kludge const elem: any = document.createElement("div") elem.className = "property" elem.innerHTML = "" elem.querySelector(".property_name").innerText = options.label || name // TODO: any kludge const value_element: any = elem.querySelector(".property_value") value_element.innerText = str_value elem.dataset["property"] = name elem.dataset["type"] = options.type || type elem.options = options elem.value = value if (type == "code") elem.addEventListener("click", function () { root.inner_showCodePad(this.dataset["property"]) }) else if (type == "boolean") { elem.classList.add("boolean") if (value) elem.classList.add("bool-on") elem.addEventListener("click", function () { const propname = this.dataset["property"] this.value = !this.value this.classList.toggle("bool-on") this.querySelector(".property_value").innerText = this.value ? "true" : "false" innerChange(propname, this.value) }) } else if (type == "string" || type == "number") { value_element.setAttribute("contenteditable", true) value_element.addEventListener("keydown", function (e) { // allow for multiline if (e.code == "Enter" && (type != "string" || !e.shiftKey)) { e.preventDefault() this.blur() } }) value_element.addEventListener("blur", function () { let v = this.innerText const propname = this.parentNode.dataset["property"] const proptype = this.parentNode.dataset["type"] if (proptype == "number") v = Number(v) innerChange(propname, v) }) } else if (type == "enum" || type == "combo") { const str_value = LGraphCanvas.getPropertyPrintableValue(value, options.values) value_element.innerText = str_value value_element.addEventListener("click", function (event) { const values = options.values || [] const propname = this.parentNode.dataset["property"] const elem_that = this new LiteGraph.ContextMenu(values, { event: event, className: "dark", callback: inner_clicked }, // @ts-expect-error ref_window) function inner_clicked(v) { //node.setProperty(propname,v); //graphcanvas.dirty_canvas = true; elem_that.innerText = v innerChange(propname, v) return false } }) } root.content.appendChild(elem) function innerChange(name, value) { options.callback?.(name, value, options) callback?.(name, value, options) } return elem } if (root.onOpen && typeof root.onOpen == "function") root.onOpen() return root } closePanels(): void { document.querySelector("#node-panel")?.close() document.querySelector("#option-panel")?.close() } showShowNodePanel(node: LGraphNode): void { this.SELECTED_NODE = node this.closePanels() const ref_window = this.getCanvasWindow() const graphcanvas = this const panel = this.createPanel(node.title || "", { closable: true, window: ref_window, onOpen: function () { graphcanvas.NODEPANEL_IS_OPEN = true }, onClose: function () { graphcanvas.NODEPANEL_IS_OPEN = false graphcanvas.node_panel = null } }) graphcanvas.node_panel = panel panel.id = "node-panel" panel.node = node panel.classList.add("settings") function inner_refresh() { //clear panel.content.innerHTML = "" // @ts-expect-error ctor props panel.addHTML(`${node.type}${node.constructor.desc || ""}`) panel.addHTML("

Properties

") const fUpdate = function (name, value) { graphcanvas.graph.beforeChange(node) switch (name) { case "Title": node.title = value break case "Mode": { const kV = Object.values(LiteGraph.NODE_MODES).indexOf(value) if (kV >= 0 && LiteGraph.NODE_MODES[kV]) { node.changeMode(kV) } else { console.warn("unexpected mode: " + value) } break } case "Color": if (LGraphCanvas.node_colors[value]) { node.color = LGraphCanvas.node_colors[value].color node.bgcolor = LGraphCanvas.node_colors[value].bgcolor } else { console.warn("unexpected color: " + value) } break default: node.setProperty(name, value) break } graphcanvas.graph.afterChange() graphcanvas.dirty_canvas = true } panel.addWidget("string", "Title", node.title, {}, fUpdate) panel.addWidget("combo", "Mode", LiteGraph.NODE_MODES[node.mode], { values: LiteGraph.NODE_MODES }, fUpdate) const nodeCol = node.color !== undefined ? Object.keys(LGraphCanvas.node_colors).filter(function (nK) { return LGraphCanvas.node_colors[nK].color == node.color }) : "" panel.addWidget("combo", "Color", nodeCol, { values: Object.keys(LGraphCanvas.node_colors) }, fUpdate) for (const pName in node.properties) { const value = node.properties[pName] const info = node.getPropertyInfo(pName) //in case the user wants control over the side panel widget if (node.onAddPropertyToPanel?.(pName, panel)) continue panel.addWidget(info.widget || info.type, pName, value, info, fUpdate) } panel.addSeparator() node.onShowCustomPanelInfo?.(panel) panel.footer.innerHTML = "" // clear panel.addButton("Delete", function () { if (node.block_delete) return node.graph.remove(node) panel.close() }).classList.add("delete") } panel.inner_showCodePad = function (propname) { panel.classList.remove("settings") panel.classList.add("centered") panel.alt_content.innerHTML = "" const textarea = panel.alt_content.querySelector("textarea") const fDoneWith = function () { panel.toggleAltContent(false) panel.toggleFooterVisibility(true) textarea.parentNode.removeChild(textarea) panel.classList.add("settings") panel.classList.remove("centered") inner_refresh() } textarea.value = node.properties[propname] textarea.addEventListener("keydown", function (e) { if (e.code == "Enter" && e.ctrlKey) { node.setProperty(propname, textarea.value) fDoneWith() } }) panel.toggleAltContent(true) panel.toggleFooterVisibility(false) textarea.style.height = "calc(100% - 40px)" const assign = panel.addButton("Assign", function () { node.setProperty(propname, textarea.value) fDoneWith() }) panel.alt_content.appendChild(assign) const button = panel.addButton("Close", fDoneWith) button.style.float = "right" panel.alt_content.appendChild(button) } inner_refresh() this.canvas.parentNode.appendChild(panel) } showSubgraphPropertiesDialog(node: LGraphNode) { console.log("showing subgraph properties dialog") const old_panel = this.canvas.parentNode.querySelector(".subgraph_dialog") old_panel?.close() const panel = this.createPanel("Subgraph Inputs", { closable: true, width: 500 }) panel.node = node panel.classList.add("subgraph_dialog") function inner_refresh() { panel.clear() //show currents if (node.inputs) for (let i = 0; i < node.inputs.length; ++i) { const input = node.inputs[i] if (input.not_subgraph_input) continue const html = " " const elem = panel.addHTML(html, "subgraph_property") elem.dataset["name"] = input.name elem.dataset["slot"] = i elem.querySelector(".name").innerText = input.name elem.querySelector(".type").innerText = input.type elem.querySelector("button").addEventListener("click", function () { node.removeInput(Number(this.parentNode.dataset["slot"])) inner_refresh() }) } } //add extra const html = " + NameType" const elem = panel.addHTML(html, "subgraph_property extra", true) elem.querySelector("button").addEventListener("click", function () { const elem = this.parentNode const name = elem.querySelector(".name").value const type = elem.querySelector(".type").value if (!name || node.findInputSlot(name) != -1) return node.addInput(name, type) elem.querySelector(".name").value = "" elem.querySelector(".type").value = "" inner_refresh() }) inner_refresh() this.canvas.parentNode.appendChild(panel) return panel } showSubgraphPropertiesDialogRight(node: LGraphNode): any { // console.log("showing subgraph properties dialog"); // old_panel if old_panel is exist close it const old_panel = this.canvas.parentNode.querySelector(".subgraph_dialog") old_panel?.close() // new panel const panel = this.createPanel("Subgraph Outputs", { closable: true, width: 500 }) panel.node = node panel.classList.add("subgraph_dialog") function inner_refresh() { panel.clear() //show currents if (node.outputs) for (let i = 0; i < node.outputs.length; ++i) { // FIXME: Rename - it's an output const input = node.outputs[i] if (input.not_subgraph_output) continue const html = " " const elem = panel.addHTML(html, "subgraph_property") elem.dataset["name"] = input.name elem.dataset["slot"] = i elem.querySelector(".name").innerText = input.name elem.querySelector(".type").innerText = input.type elem.querySelector("button").addEventListener("click", function () { node.removeOutput(Number(this.parentNode.dataset["slot"])) inner_refresh() }) } } //add extra const html = " + NameType" const elem = panel.addHTML(html, "subgraph_property extra", true) elem.querySelector(".name").addEventListener("keydown", function (e) { if (e.keyCode == 13) addOutput.apply(this) }) elem.querySelector("button").addEventListener("click", function () { addOutput.apply(this) }) function addOutput() { const elem = this.parentNode const name = elem.querySelector(".name").value const type = elem.querySelector(".type").value if (!name || node.findOutputSlot(name) != -1) return node.addOutput(name, type) elem.querySelector(".name").value = "" elem.querySelector(".type").value = "" inner_refresh() } inner_refresh() this.canvas.parentNode.appendChild(panel) return panel } checkPanels(): void { if (!this.canvas) return const panels = this.canvas.parentNode.querySelectorAll(".litegraph.dialog") for (let i = 0; i < panels.length; ++i) { const panel = panels[i] // @ts-expect-error Panel if (!panel.node) continue // @ts-expect-error Panel if (!panel.node.graph || panel.graph != this.graph) panel.close() } } getCanvasMenuOptions(): IContextMenuValue[] { let options: IContextMenuValue[] = null if (this.getMenuOptions) { options = this.getMenuOptions() } else { options = [ { content: "Add Node", has_submenu: true, // @ts-expect-error Might be broken? Or just param overlap callback: LGraphCanvas.onMenuAdd }, { content: "Add Group", callback: LGraphCanvas.onGroupAdd }, //{ content: "Arrange", callback: that.graph.arrange }, //{content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll } ] if (Object.keys(this.selected_nodes).length > 1) { options.push({ content: "Align", has_submenu: true, callback: LGraphCanvas.onGroupAlign, }) } if (this._graph_stack && this._graph_stack.length > 0) { options.push(null, { content: "Close subgraph", callback: this.closeSubgraph.bind(this) }) } } const extra = this.getExtraMenuOptions?.(this, options) return extra ? options.concat(extra) : options } //called by processContextMenu to extract the menu list getNodeMenuOptions(node: LGraphNode): IContextMenuValue[] { let options: IContextMenuValue[] = null if (node.getMenuOptions) { options = node.getMenuOptions(this) } else { options = [ { content: "Inputs", has_submenu: true, disabled: true, callback: LGraphCanvas.showMenuNodeOptionalInputs }, { content: "Outputs", has_submenu: true, disabled: true, callback: LGraphCanvas.showMenuNodeOptionalOutputs }, null, { content: "Properties", has_submenu: true, callback: LGraphCanvas.onShowMenuNodeProperties }, { content: "Properties Panel", callback: function (item, options, e, menu, node) { LGraphCanvas.active_canvas.showShowNodePanel(node) } }, null, { content: "Title", callback: LGraphCanvas.onShowPropertyEditor }, { content: "Mode", has_submenu: true, callback: LGraphCanvas.onMenuNodeMode } ] if (node.resizable !== false) { options.push({ content: "Resize", callback: LGraphCanvas.onMenuResizeNode }) } if (node.collapsible) { options.push({ content: node.collapsed ? "Expand" : "Collapse", callback: LGraphCanvas.onMenuNodeCollapse }) } options.push( { content: node.pinned ? "Unpin" : "Pin", callback: (...args) => { // @ts-expect-error Not impl. LGraphCanvas.onMenuNodePin(...args) for (const i in this.selected_nodes) { const node = this.selected_nodes[i] node.pin() } this.setDirty(true, true) } }, { content: "Colors", has_submenu: true, callback: LGraphCanvas.onMenuNodeColors }, { content: "Shapes", has_submenu: true, callback: LGraphCanvas.onMenuNodeShapes }, null ) } const inputs = node.onGetInputs?.() if (inputs?.length) options[0].disabled = false const outputs = node.onGetOutputs?.() if (outputs?.length) options[1].disabled = false const extra = node.getExtraMenuOptions?.(this, options) if (extra) { extra.push(null) options = extra.concat(options) } if (node.clonable !== false) { options.push({ content: "Clone", callback: LGraphCanvas.onMenuNodeClone }) } // TODO: Subgraph code never implemented. // options.push({ // content: "To Subgraph", // callback: LGraphCanvas.onMenuNodeToSubgraph // }) if (Object.keys(this.selected_nodes).length > 1) { options.push({ content: "Align Selected To", has_submenu: true, callback: LGraphCanvas.onNodeAlign, }) options.push({ content: "Distribute Nodes", has_submenu: true, callback: LGraphCanvas.createDistributeMenu, }) } options.push(null, { content: "Remove", disabled: !(node.removable !== false && !node.block_delete), callback: LGraphCanvas.onMenuNodeRemove }) node.graph?.onGetNodeMenuOptions?.(options, node) return options } getGroupMenuOptions(group: LGraphGroup): IContextMenuValue[] { console.warn("LGraphCanvas.getGroupMenuOptions is deprecated, use LGraphGroup.getMenuOptions instead") return group.getMenuOptions() } processContextMenu(node: LGraphNode, event: CanvasMouseEvent): void { const that = this const canvas = LGraphCanvas.active_canvas const ref_window = canvas.getCanvasWindow() // TODO: Remove type kludge let menu_info: (IContextMenuValue | string)[] = null const options: IContextMenuOptions = { event: event, callback: inner_option_clicked, extra: node } if (node) options.title = node.type //check if mouse is in input let slot: ReturnType = null if (node) { slot = node.getSlotInPosition(event.canvasX, event.canvasY) LGraphCanvas.active_node = node } if (slot) { //on slot menu_info = [] if (node.getSlotMenuOptions) { menu_info = node.getSlotMenuOptions(slot) } else { if (slot?.output?.links?.length) menu_info.push({ content: "Disconnect Links", slot: slot }) const _slot = slot.input || slot.output if (_slot.removable) { menu_info.push( _slot.locked ? "Cannot remove" : { content: "Remove Slot", slot: slot } ) } if (!_slot.nameLocked) menu_info.push({ content: "Rename Slot", slot: slot }) } // @ts-expect-error Slot type can be number and has number checks options.title = (slot.input ? slot.input.type : slot.output.type) || "*" if (slot.input && slot.input.type == LiteGraph.ACTION) options.title = "Action" if (slot.output && slot.output.type == LiteGraph.EVENT) options.title = "Event" } else if (node) { //on node menu_info = this.getNodeMenuOptions(node) } else { menu_info = this.getCanvasMenuOptions() const group = this.graph.getGroupOnPos( event.canvasX, event.canvasY ) if (group) { //on group menu_info.push(null, { content: "Edit Group", has_submenu: true, submenu: { title: "Group", extra: group, options: group.getMenuOptions() } }) } } //show menu if (!menu_info) return // @ts-expect-error Remove param ref_window - unused new LiteGraph.ContextMenu(menu_info, options, ref_window) function inner_option_clicked(v, options) { if (!v) return if (v.content == "Remove Slot") { const info = v.slot node.graph.beforeChange() if (info.input) { node.removeInput(info.slot) } else if (info.output) { node.removeOutput(info.slot) } node.graph.afterChange() return } else if (v.content == "Disconnect Links") { const info = v.slot node.graph.beforeChange() if (info.output) { node.disconnectOutput(info.slot) } else if (info.input) { node.disconnectInput(info.slot) } node.graph.afterChange() return } else if (v.content == "Rename Slot") { const info = v.slot const slot_info = info.input ? node.getInputInfo(info.slot) : node.getOutputInfo(info.slot) const dialog = that.createDialog( "Name", options ) const input = dialog.querySelector("input") if (input && slot_info) { input.value = slot_info.label || "" } const inner = function () { node.graph.beforeChange() if (input.value) { if (slot_info) { slot_info.label = input.value } that.setDirty(true) } dialog.close() node.graph.afterChange() } dialog.querySelector("button").addEventListener("click", inner) input.addEventListener("keydown", function (e) { dialog.is_modified = true if (e.keyCode == 27) { //ESC dialog.close() } else if (e.keyCode == 13) { inner() // save } else if (e.keyCode != 13 && (e.target as Element).localName != "textarea") { return } e.preventDefault() e.stopPropagation() }) input.focus() } //if(v.callback) // return v.callback.call(that, node, options, e, menu, that, event ); } } }