diff --git a/README.md b/README.md index ad54984e..d7abf37f 100755 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Try it in the [demo site](https://tamats.com/projects/litegraph/editor). - Customizable theme (colors, shapes, background) - Callbacks to personalize every action/drawing/event of nodes - Subgraphs (nodes that contain graphs themselves) -- Live mode system (hides the graph but calls nodes to render whatever they want, useful to create UIs) - Graphs can be executed in NodeJS - Highly customizable nodes (color, shape, slots vertical or horizontal, widgets, custom rendering) - Easy to integrate in any JS application (one single file, no dependencies) diff --git a/src/CanvasPointer.ts b/src/CanvasPointer.ts new file mode 100644 index 00000000..c433e4f3 --- /dev/null +++ b/src/CanvasPointer.ts @@ -0,0 +1,268 @@ +import type { CanvasPointerEvent } from "./types/events" +import type { LGraphCanvas } from "./LGraphCanvas" +import { dist2 } from "./measure" + +/** + * Allows click and drag actions to be declared ahead of time during a pointerdown event. + * + * Depending on whether the user clicks or drags the pointer, only the appropriate callbacks are called: + * - {@link onClick} + * - {@link onDoubleClick} + * - {@link onDragStart} + * - {@link onDrag} + * - {@link onDragEnd} + * - {@link finally} + * + * @seealso + * - {@link LGraphCanvas.processMouseDown} + * - {@link LGraphCanvas.processMouseMove} + * - {@link LGraphCanvas.processMouseUp} + */ +export class CanvasPointer { + /** Maximum time in milliseconds to ignore click drift */ + static bufferTime = 150 + + /** Maximum gap between pointerup and pointerdown events to be considered as a double click */ + static doubleClickTime = 300 + + /** Maximum offset from click location */ + static get maxClickDrift() { + return this.#maxClickDrift + } + static set maxClickDrift(value) { + this.#maxClickDrift = value + this.#maxClickDrift2 = value * value + } + static #maxClickDrift = 6 + /** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */ + static #maxClickDrift2 = this.#maxClickDrift ** 2 + + /** The element this PointerState should capture input against when dragging. */ + element: Element + /** Pointer ID used by drag capture. */ + pointerId: number + + /** Set to true when if the pointer moves far enough after a down event, before the corresponding up event is fired. */ + dragStarted: boolean = false + + /** The {@link eUp} from the last successful click */ + eLastDown?: CanvasPointerEvent + + /** Used downstream for touch event support. */ + isDouble: boolean = false + /** Used downstream for touch event support. */ + isDown: boolean = false + + /** + * If `true`, {@link eDown}, {@link eMove}, and {@link eUp} will be set to + * `null` when {@link reset} is called. + * + * Default: `true` + */ + clearEventsOnReset: boolean = true + + /** The last pointerdown event for the primary button */ + eDown: CanvasPointerEvent | null = null + /** The last pointermove event for the primary button */ + eMove: CanvasPointerEvent | null = null + /** The last pointerup event for the primary button */ + eUp: CanvasPointerEvent | null = null + + /** If set, as soon as the mouse moves outside the click drift threshold, this action is run once. */ + onDragStart?(): unknown + + /** + * Called on pointermove whilst dragging. + * @param eMove The pointermove event of this ongoing drag action + */ + onDrag?(eMove: CanvasPointerEvent): unknown + + /** + * Called on pointerup after dragging (i.e. not called if clicked). + * @param upEvent The pointerup or pointermove event that triggered this callback + */ + onDragEnd?(upEvent: CanvasPointerEvent): unknown + + /** + * Callback that will be run once, the next time a pointerup event appears to be a normal click. + * @param upEvent The pointerup or pointermove event that triggered this callback + */ + onClick?(upEvent: CanvasPointerEvent): unknown + + /** + * Callback that will be run once, the next time a pointerup event appears to be a normal click. + * @param upEvent The pointerup or pointermove event that triggered this callback + */ + onDoubleClick?(upEvent: CanvasPointerEvent): unknown + + /** + * Run-once callback, called at the end of any click or drag, whether or not it was successful in any way. + * + * The setter of this callback will call the existing value before replacing it. + * Therefore, simply setting this value twice will execute the first callback. + */ + get finally() { + return this.#finally + } + set finally(value) { + try { + this.#finally?.() + } finally { + this.#finally = value + } + } + #finally?: () => unknown + + constructor(element: Element) { + this.element = element + } + + /** + * Callback for `pointerdown` events. To be used as the event handler (or called by it). + * @param e The `pointerdown` event + */ + down(e: CanvasPointerEvent): void { + this.reset() + this.eDown = e + this.pointerId = e.pointerId + this.element.setPointerCapture(e.pointerId) + } + + /** + * Callback for `pointermove` events. To be used as the event handler (or called by it). + * @param e The `pointermove` event + */ + move(e: CanvasPointerEvent): void { + const { eDown } = this + if (!eDown) return + + // No buttons down, but eDown exists - clean up & leave + if (!e.buttons) { + this.reset() + return + } + + // Primary button released - treat as pointerup. + if (!(e.buttons & eDown.buttons)) { + this.#completeClick(e) + this.reset() + return + } + this.eMove = e + this.onDrag?.(e) + + // Dragging, but no callback to run + if (this.dragStarted) return + + const longerThanBufferTime = e.timeStamp - eDown.timeStamp > CanvasPointer.bufferTime + if (longerThanBufferTime || !this.#hasSamePosition(e, eDown)) { + this.#setDragStarted() + } + } + + /** + * Callback for `pointerup` events. To be used as the event handler (or called by it). + * @param e The `pointerup` event + */ + up(e: CanvasPointerEvent): boolean { + if (e.button !== this.eDown?.button) return false + + this.#completeClick(e) + const { dragStarted } = this + this.reset() + return !dragStarted + } + + #completeClick(e: CanvasPointerEvent): void { + const { eDown } = this + if (!eDown) return + + this.eUp = e + + if (this.dragStarted) { + // A move event already started drag + this.onDragEnd?.(e) + } else if (!this.#hasSamePosition(e, eDown)) { + // Teleport without a move event (e.g. tab out, move, tab back) + this.#setDragStarted() + this.onDragEnd?.(e) + } else if (this.onDoubleClick && this.#isDoubleClick()) { + // Double-click event + this.onDoubleClick(e) + this.eLastDown = undefined + } else { + // Normal click event + this.onClick?.(e) + this.eLastDown = eDown + } + } + + /** + * Checks if two events occurred near each other - not further apart than the maximum click drift. + * @param a The first event to compare + * @param b The second event to compare + * @param tolerance2 The maximum distance (squared) before the positions are considered different + * @returns `true` if the two events were no more than {@link maxClickDrift} apart, otherwise `false` + */ + #hasSamePosition( + a: PointerEvent, + b: PointerEvent, + tolerance2 = CanvasPointer.#maxClickDrift2, + ): boolean { + const drift = dist2(a.clientX, a.clientY, b.clientX, b.clientY) + return drift <= tolerance2 + } + + /** + * Checks whether the pointer is currently past the max click drift threshold. + * @param e The latest pointer event + * @returns `true` if the latest pointer event is past the the click drift threshold + */ + #isDoubleClick(): boolean { + const { eDown, eLastDown } = this + if (!eDown || !eLastDown) return false + + // Use thrice the drift distance for double-click gap + const tolerance2 = (3 * CanvasPointer.#maxClickDrift) ** 2 + const diff = eDown.timeStamp - eLastDown.timeStamp + return diff > 0 && + diff < CanvasPointer.doubleClickTime && + this.#hasSamePosition(eDown, eLastDown, tolerance2) + } + + #setDragStarted(): void { + this.dragStarted = true + this.onDragStart?.() + delete this.onDragStart + } + + /** + * Resets the state of this {@link CanvasPointer} instance. + * + * The {@link finally} callback is first executed, then all callbacks and intra-click + * state is cleared. + */ + reset(): void { + // The setter executes the callback before clearing it + this.finally = undefined + delete this.onClick + delete this.onDoubleClick + delete this.onDragStart + delete this.onDrag + delete this.onDragEnd + + this.isDown = false + this.isDouble = false + this.dragStarted = false + + if (this.clearEventsOnReset) { + this.eDown = null + this.eMove = null + this.eUp = null + } + + const { element, pointerId } = this + if (element.hasPointerCapture(pointerId)) + element.releasePointerCapture(pointerId) + } +} diff --git a/src/DragAndScale.ts b/src/DragAndScale.ts index ff8f8c97..5a05e3c3 100644 --- a/src/DragAndScale.ts +++ b/src/DragAndScale.ts @@ -1,6 +1,7 @@ import type { Point, Rect, Rect32 } from "./interfaces" import type { CanvasMouseEvent } from "./types/events" import { LiteGraph } from "./litegraph" +import { isInRect } from "./measure" export class DragAndScale { /** Maximum scale (zoom in) */ @@ -98,7 +99,7 @@ export class DragAndScale { e.canvasy = y e.dragging = this.dragging - 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])) + const is_inside = !this.viewport || isInRect(x, y, this.viewport) let ignore = false if (this.onmouse) { diff --git a/src/LGraph.ts b/src/LGraph.ts index d74bf2e0..c3429d93 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -826,9 +826,6 @@ export class LGraph implements LinkNetwork, Serialisable { const canvas = this.list_of_graphcanvas[i] if (canvas.selected_nodes[node.id]) delete canvas.selected_nodes[node.id] - - if (canvas.node_dragged == node) - canvas.node_dragged = null } } @@ -1222,18 +1219,6 @@ export class LGraph implements LinkNetwork, Serialisable { // @ts-expect-error this.canvasAction(c => c.onConnectionChange?.()) } - /** - * returns if the graph is in live mode - */ - isLive(): boolean { - if (!this.list_of_graphcanvas) return false - - for (let i = 0; i < this.list_of_graphcanvas.length; ++i) { - const c = this.list_of_graphcanvas[i] - if (c.live_mode) return true - } - return false - } /** * clears the triggered slot animation in all links (stop visual animation) */ @@ -1334,7 +1319,7 @@ export class LGraph implements LinkNetwork, Serialisable { const node = this.getNodeById(link.target_id) node?.disconnectInput(link.target_slot) - + link.disconnect(this) } diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 52711265..54263fbf 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -1,14 +1,14 @@ import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuOptions, INodeSlot, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, Point, Rect, Rect32, Size, IContextMenuValue, ISlotType, ConnectingLink, NullableProperties, Positionable, LinkSegment, ReadOnlyPoint, ReadOnlyRect } from "./interfaces" import type { IWidget, TWidgetValue } from "./types/widgets" import { LGraphNode, type NodeId } from "./LGraphNode" -import type { CanvasDragEvent, CanvasMouseEvent, CanvasWheelEvent, CanvasEventDetail, CanvasPointerEvent } from "./types/events" +import type { CanvasDragEvent, CanvasMouseEvent, CanvasEventDetail, CanvasPointerEvent, ICanvasPosition, IDeltaPosition } from "./types/events" import type { ClipboardItems } from "./types/serialisation" import { LLink, type LinkId } from "./LLink" import type { LGraph } from "./LGraph" import type { ContextMenu } from "./ContextMenu" -import { EaseFunction, LGraphEventMode, LinkDirection, LinkMarkerShape, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" +import { CanvasItem, EaseFunction, LGraphEventMode, LinkDirection, LinkMarkerShape, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" import { LGraphGroup } from "./LGraphGroup" -import { isInsideRectangle, distance, overlapBounding, isPointInRectangle, findPointOnCurve, containsRect, createBounds } from "./measure" +import { distance, overlapBounding, isPointInRect, findPointOnCurve, containsRect, isInRectangle, createBounds, isInRect } from "./measure" import { drawSlot, LabelPosition } from "./draw" import { DragAndScale } from "./DragAndScale" import { LinkReleaseContextExtended, LiteGraph, clamp } from "./litegraph" @@ -16,6 +16,7 @@ import { stringOrEmpty, stringOrNull } from "./strings" import { alignNodes, distributeNodes, getBoundaryNodes } from "./utils/arrange" import { Reroute, type RerouteId } from "./Reroute" import { getAllNestedItems } from "./utils/collections" +import { CanvasPointer } from "./CanvasPointer" interface IShowSearchOptions { node_to?: LGraphNode @@ -60,7 +61,7 @@ interface ICreateNodeOptions { e?: CanvasMouseEvent allow_searchbox?: boolean /** See {@link LGraphCanvas.showSearchBox} */ - showSearchBox?: ((event: CanvasMouseEvent, options?: IShowSearchOptions) => HTMLDivElement | void) + showSearchBox?: ((event: MouseEvent, options?: IShowSearchOptions) => HTMLDivElement | void) } interface ICloseableDiv extends HTMLDivElement { @@ -98,6 +99,11 @@ export interface LGraphCanvasState { draggingCanvas: boolean /** The canvas is read-only, preventing changes to nodes, disconnecting links, moving items, etc. */ readOnly: boolean + + /** Bit flags indicating what is currently below the pointer. */ + hoveringOver: CanvasItem + /** If `true`, pointer move events will set the canvas cursor style. */ + shouldSetCursor: boolean } /** @@ -169,21 +175,16 @@ export class LGraphCanvas { 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 + hoveringOver: CanvasItem.Nothing, + shouldSetCursor: true, } // 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} */ + //#region Legacy accessors + /** @deprecated @inheritdoc {@link LGraphCanvasState.readOnly} */ get read_only(): boolean { return this.state.readOnly } @@ -198,6 +199,22 @@ export class LGraphCanvas { this.state.draggingItems = value } + /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDown isDown}. */ + get pointer_is_down() { return this.pointer.isDown } + /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDouble isDouble}. */ + get pointer_is_double() { return this.pointer.isDouble } + + + /** @deprecated @inheritdoc {@link LGraphCanvasState.draggingCanvas} */ + get dragging_canvas(): boolean { + return this.state.draggingCanvas + } + set dragging_canvas(value: boolean) { + this.state.draggingCanvas = value + } + //#endregion Legacy accessors + + get title_text_font(): string { return `${LiteGraph.NODE_TEXT_SIZE}px Arial` } @@ -208,7 +225,8 @@ export class LGraphCanvas { options: { skip_events?: any; viewport?: any; skip_render?: any; autoresize?: any } background_image: string - ds: DragAndScale + readonly ds: DragAndScale + readonly pointer: CanvasPointer zoom_modify_alpha: boolean zoom_speed: number node_title_color: string @@ -226,7 +244,6 @@ export class LGraphCanvas { clear_background: boolean clear_background_color: string render_only_selected: boolean - live_mode: boolean show_info: boolean allow_dragcanvas: boolean allow_dragnodes: boolean @@ -258,9 +275,9 @@ export class LGraphCanvas { linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle links_render_mode: number /** mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle */ - mouse: Point + readonly mouse: Point /** mouse in graph coordinates, where 0,0 is the top-left corner of the blue rectangle */ - graph_mouse: Point + readonly graph_mouse: Point /** @deprecated LEGACY: REMOVE THIS, USE {@link graph_mouse} INSTEAD */ canvas_mouse: Point /** to personalize the search box */ @@ -277,14 +294,17 @@ export class LGraphCanvas { current_node: LGraphNode | null /** used for widgets */ node_widget?: [LGraphNode, IWidget] | null + /** The link to draw a tooltip for. */ over_link_center: LinkSegment | null last_mouse_position: Point + /** The visible area of this canvas. Tightly coupled with {@link ds}. */ visible_area?: Rect32 /** Contains all links and reroutes that were rendered. Repopulated every render cycle. */ renderedPaths: Set = new Set() visible_links?: LLink[] connecting_links: ConnectingLink[] | null - viewport?: Rect + /** The viewport of this canvas. Tightly coupled with {@link ds}. */ + readonly viewport?: Rect autoresize: boolean static active_canvas: LGraphCanvas static onMenuNodeOutputs?(entries: IOptionalSlotData[]): IOptionalSlotData[] @@ -301,7 +321,6 @@ export class LGraphCanvas { /** @deprecated See {@link LGraphCanvas.selectedItems} */ selected_group: LGraphGroup | null = null visible_nodes: LGraphNode[] = [] - node_dragged?: LGraphNode node_over?: LGraphNode node_capturing_input?: LGraphNode highlighted_links: Dictionary = {} @@ -315,23 +334,22 @@ export class LGraphCanvas { dirty_area?: Rect /** @deprecated Unused */ node_in_panel?: LGraphNode - last_mouse: Point = [0, 0] + last_mouse: ReadOnlyPoint = [0, 0] last_mouseclick: number = 0 - pointer_is_down: boolean = false - pointer_is_double: boolean = false graph!: LGraph _graph_stack: LGraph[] | null = null 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 + _mousedown_callback?(e: PointerEvent): boolean + _mousewheel_callback?(e: WheelEvent): boolean + _mousemove_callback?(e: PointerEvent): boolean + _mouseup_callback?(e: PointerEvent): boolean + _mouseout_callback?(e: PointerEvent): boolean + _mousecancel_callback?(e: PointerEvent): boolean _key_callback?(e: KeyboardEvent): boolean - _ondrop_callback?(e: CanvasDragEvent): unknown + _ondrop_callback?(e: DragEvent): unknown /** @deprecated WebGL */ gl?: never bgctx?: CanvasRenderingContext2D @@ -343,6 +361,7 @@ export class LGraphCanvas { resizing_node?: LGraphNode /** @deprecated See {@link LGraphCanvas.resizingGroup} */ selected_group_resizing?: boolean + /** @deprecated See {@link pointer}.{@link CanvasPointer.dragStarted dragStarted} */ last_mouse_dragging: boolean onMouseDown: (arg0: CanvasMouseEvent) => void _highlight_pos?: Point @@ -371,7 +390,7 @@ export class LGraphCanvas { /** called after modifying the graph */ onAfterChange?(graph: LGraph): void onClear?: () => void - /** called after moving a node */ + /** called after moving a node @deprecated Does not handle multi-node move, and can return the wrong node. */ onNodeMoved?: (node_dragged: LGraphNode) => void /** called if the selection changes */ onSelectionChange?: (selected_nodes: Dictionary) => void @@ -403,6 +422,7 @@ export class LGraphCanvas { this.background_image = LGraphCanvas.DEFAULT_BACKGROUND_IMAGE this.ds = new DragAndScale() + this.pointer = new CanvasPointer(this.canvas) 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 @@ -433,7 +453,6 @@ export class LGraphCanvas { 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 @@ -1300,7 +1319,6 @@ export class LGraphCanvas { this.selected_group = null this.visible_nodes = [] - this.node_dragged = null this.node_over = null this.node_capturing_input = null this.connecting_links = null @@ -1316,8 +1334,7 @@ export class LGraphCanvas { this.last_mouse = [0, 0] this.last_mouseclick = 0 - this.pointer_is_down = false - this.pointer_is_double = false + this.pointer.reset() this.visible_area.set([0, 0, 0, 0]) this.onClear?.() @@ -1354,49 +1371,6 @@ export class LGraphCanvas { ? 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 @@ -1433,6 +1407,7 @@ export class LGraphCanvas { this.canvas = element this.ds.element = element + this.pointer.element = element if (!element) return @@ -1502,6 +1477,7 @@ export class LGraphCanvas { this._mousemove_callback = this.processMouseMove.bind(this) this._mouseup_callback = this.processMouseUp.bind(this) this._mouseout_callback = this.processMouseOut.bind(this) + this._mousecancel_callback = this.processMouseCancel.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) @@ -1509,6 +1485,7 @@ export class LGraphCanvas { 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("pointercancel", this._mousecancel_callback, true) canvas.addEventListener("contextmenu", this._doNothing) canvas.addEventListener( @@ -1546,6 +1523,7 @@ export class LGraphCanvas { const ref_window = this.getCanvasWindow() const document = ref_window.document + this.canvas.removeEventListener("pointercancel", this._mousecancel_callback) this.canvas.removeEventListener("pointerout", this._mouseout_callback) LiteGraph.pointerListenerRemove(this.canvas, "move", this._mousemove_callback) LiteGraph.pointerListenerRemove(this.canvas, "up", this._mouseup_callback) @@ -1602,11 +1580,12 @@ export class LGraphCanvas { */ } /** - * marks as dirty the canvas, this way it will be rendered again + * Ensures the canvas will be redrawn on the next frame by setting the dirty flag(s). + * Without parameters, this function does nothing. + * @todo Impl. `setDirty()` or similar as shorthand to redraw everything. * - * @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) + * @param fgcanvas If true, marks the foreground canvas as dirty (nodes and anything drawn on top of them). Default: false + * @param bgcanvas If true, mark the background canvas as dirty (background, groups, links). Default: false */ setDirty(fgcanvas: boolean, bgcanvas?: boolean): void { if (fgcanvas) this.dirty_canvas = true @@ -1742,47 +1721,36 @@ export class LGraphCanvas { } } - processMouseDown(e: CanvasPointerEvent): boolean { + processMouseDown(e: PointerEvent): void { + const { graph, pointer } = this + this.adjustMouseEvent(e) + if (e.isPrimary) pointer.down(e) if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true - const { graph } = this if (!graph) return - this.adjustMouseEvent(e) - const ref_window = this.getCanvasWindow() LGraphCanvas.active_canvas = this const x = e.clientX const y = e.clientY 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) - //catch for the entire window - LiteGraph.pointerListenerAdd(ref_window.document, "move", this._mousemove_callback, true) - LiteGraph.pointerListenerAdd(ref_window.document, "up", this._mouseup_callback, true) - } + const is_inside = !this.viewport || isInRect(x, y, this.viewport) if (!is_inside) return - let node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) + const node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) - let skip_action = false - const now = LiteGraph.getTime() - const is_double_click = (now - this.last_mouseclick < 300) this.mouse[0] = x this.mouse[1] = y 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 && e.isPrimary - this.pointer_is_down = true + pointer.isDouble = pointer.isDown && e.isPrimary + pointer.isDown = true this.canvas.focus() @@ -1791,515 +1759,705 @@ export class LGraphCanvas { 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) { - const dragRect = new Float32Array(4) - dragRect[0] = e.canvasX - dragRect[1] = e.canvasY - dragRect[2] = 1 - dragRect[3] = 1 - this.dragging_rectangle = dragRect - 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 - - graph.add(cloned, false) - node = cloned - skip_action = true - if (this.allow_dragnodes) { - graph.beforeChange() - this.node_dragged = node - this.isDragging = true - } - this.processSelect(node, e) - } - } + if (e.button === 0 && !pointer.isDouble) { + this.#processPrimaryButton(e, node) + } else if (e.button === 1) { + this.#processMiddleButton(e, node) + } else if ((e.button === 2 || pointer.isDouble) && this.allow_interaction && !this.read_only) { + // Right / aux button - let clicking_canvas_bg = false + // Sticky select - won't remove single nodes + if (node) this.processSelect(node, e, true) - //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 it wasn't selected? - if (!this.live_mode && !node.flags.pinned) { - this.bringToFront(node) - } + // Show context menu for the node or group under the pointer + this.processContextMenu(node, e) + } - //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)) { - 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 = graph._links.get(linkId) - const slot = link.target_slot - const linked_node = 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 - } - } + this.last_mouse = [x, y] + this.last_mouseclick = LiteGraph.getTime() + this.last_mouse_dragging = true - output.slot_index = i - this.connecting_links = [ - { - node: node, - slot: i, - input: null, - output: output, - pos: link_pos, - } - ] + graph.change() - 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) - } - } + //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() - if (is_double_click) { - node.onOutputDblClick?.(i, e) - } else { - node.onOutputClick?.(i, e) - } + this.onMouseDown?.(e) + } - skip_action = true - break - } - } - } + #processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode) { + const { pointer, graph } = this + const x = e.canvasX + const y = e.canvasY + + // Modifiers + const ctrlOrMeta = e.ctrlKey || e.metaKey + + // Multi-select drag rectangle + if (ctrlOrMeta && !e.altKey) { + const dragRect = new Float32Array(4) + dragRect[0] = x + dragRect[1] = y + dragRect[2] = 1 + dragRect[3] = 1 + + pointer.onClick = eUp => { + // Click, not drag + const clickedItem = node + ?? (this.reroutesEnabled ? graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) : null) + ?? graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY) + this.processSelect(clickedItem, eUp) + } + pointer.onDragStart = () => this.dragging_rectangle = dragRect + pointer.onDragEnd = upEvent => this.#handleMultiSelect(upEvent, dragRect) + pointer.finally = () => this.dragging_rectangle = null + return + } - //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 (this.read_only) { + pointer.finally = () => this.dragging_canvas = false + this.dragging_canvas = true + return + } - if (input.link !== null) { - //before disconnecting - const link_info = graph._links.get(input.link) - const slot = link_info.origin_slot - const linked_node = 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 ? - } + // clone node ALT dragging + if (LiteGraph.alt_drag_do_clone_nodes && e.altKey && !e.ctrlKey && node && this.allow_interaction) { + 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 - } else { - // has not node - } + graph.add(cloned, false) + if (this.allow_dragnodes) { + graph.beforeChange() + this.isDragging = true + } + this.processSelect(cloned, e) + return + } + } - 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 - } + // Node clicked + if (node && (this.allow_interaction || node.flags.allow_interaction)) { + this.#processNodeClick(e, ctrlOrMeta, node) + } else { + // Reroutes + if (this.reroutesEnabled) { + const reroute = graph.getRerouteOnPos(x, y) + if (reroute) { + if (e.shiftKey) { + // Connect new link from reroute + const link = graph._links.get(reroute.linkIds.values().next().value) + + const outputNode = graph.getNodeById(link.origin_id) + const slot = link.origin_slot + const connecting: ConnectingLink = { + node: outputNode, + slot, + input: null, + pos: outputNode.getConnectionPos(false, slot), + afterRerouteId: reroute.id, + } + this.connecting_links = [connecting] + pointer.onDragStart = () => connecting.output = outputNode.outputs[slot] + // pointer.finally = () => this.connecting_links = null - break - } - } + this.dirty_bgcanvas = true + } + + pointer.onClick = () => this.processSelect(reroute, e) + if (!pointer.onDragStart) { + pointer.onDragStart = () => { + this.processSelect(reroute, e, true) + this.isDragging = true } + pointer.finally = () => this.isDragging = false } + return } + } - //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]] + // Links - paths of links & reroutes + // Set the width of the line for isPointInStroke checks + const { lineWidth } = this.ctx + this.ctx.lineWidth = this.connections_width + 7 - //widgets - const widget = this.processNodeWidgets(node, this.graph_mouse, e) - if (widget) { - block_drag_node = true - this.node_widget = [node, widget] - } + for (const linkSegment of this.renderedPaths) { + const centre = linkSegment._pos + if (!centre) continue - //double clicking - if (this.allow_interaction && is_double_click && this.selectedItems.has(node)) { - // 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 we shift click on a link then start a link from that input + if ((e.shiftKey || e.altKey) && linkSegment.path && this.ctx.isPointInStroke(linkSegment.path, x, y)) { + if (e.shiftKey && !e.altKey) { + const slot = linkSegment.origin_slot + const originNode = graph._nodes_by_id[linkSegment.origin_id] - //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) - } + const connecting: ConnectingLink = { + node: originNode, + slot, + pos: originNode.getConnectionPos(false, slot), } + this.connecting_links = [connecting] + if (linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId + + pointer.onDragStart = () => connecting.output = originNode.outputs[slot] + // pointer.finally = () => this.connecting_links = null - if (this.live_mode) { - clicking_canvas_bg = true - block_drag_node = true + return + } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { + pointer.finally = () => { + this.emitAfterChange() + this.isDragging = false } - } - if (!block_drag_node) { - if (this.allow_dragnodes) { - graph.beforeChange() - this.node_dragged = node + this.emitBeforeChange() + const newReroute = graph.createReroute([x, y], linkSegment) + pointer.onDragStart = () => { + this.processSelect(newReroute, e) this.isDragging = true } - // Account for shift + click + drag - if (!(e.shiftKey && !e.ctrlKey && !e.altKey) || !node.selected) { - this.processSelect(node, e) - } - } else if (!node.selected) { - this.processSelect(node, e) + return } + } else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) { + pointer.onClick = () => this.showLinkMenu(linkSegment, e) + pointer.onDragStart = () => this.dragging_canvas = true + pointer.finally = () => this.dragging_canvas = false - this.dirty_canvas = true + //clear tooltip + this.over_link_center = null + return } - } //clicked outside of nodes - else { - if (this.reroutesEnabled && !skip_action && !this.read_only) { - // Check for reroutes - const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY) - if (reroute) { - this.processSelect(reroute, e) + } - if (e.shiftKey) { - // Connect new link from reroute - const link = graph._links.get(reroute.linkIds.values().next().value) - - const outputNode = graph.getNodeById(link.origin_id) - const slot = link.origin_slot - this.connecting_links = [{ - node: outputNode, - slot, - input: null, - output: outputNode.outputs[slot], - pos: outputNode.getConnectionPos(false, slot), - afterRerouteId: reroute.id, - }] - } else { + // Restore line width + this.ctx.lineWidth = lineWidth + + // Groups + const group = graph.getGroupOnPos(x, y) + this.selected_group = group + if (group) { + if (group.isInResize(x, y)) { + pointer.onDragStart = () => this.resizingGroup = group + pointer.finally = () => this.resizingGroup = null + } else { + const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE + const headerHeight = f * 1.4 + if (isInRectangle(x, y, group.pos[0], group.pos[1], group.size[0], headerHeight)) { + // In title bar + pointer.onDragStart = () => { + group.recomputeInsideNodes() + this.processSelect(group, e, true) this.isDragging = true } + pointer.finally = () => this.isDragging = false + } + } - skip_action = true + pointer.onDoubleClick = () => { + this.emitEvent({ + subType: "group-double-click", + originalEvent: e, + group, + }) + } + } else { + pointer.onDoubleClick = () => { + // Double click within group should not trigger the searchbox. + if (this.allow_searchbox) { + this.showSearchBox(e) + e.preventDefault() } + this.emitEvent({ + subType: "empty-double-click", + originalEvent: e, + }) } + } + } - 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 (const linkSegment of this.renderedPaths) { - const centre = linkSegment._pos - if (!centre) continue - - // FIXME: Clean up - if (isInsideRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { - this.showLinkMenu(linkSegment, e) - //clear tooltip - this.over_link_center = null - break + if (!pointer.onDragStart && !pointer.onClick && !pointer.onDrag && this.allow_dragcanvas) { + pointer.onClick = () => this.processSelect(null, e) + pointer.finally = () => this.dragging_canvas = false + this.dragging_canvas = true + } + } + + /** + * Processes a pointerdown event inside the bounds of a node. Part of {@link processMouseDown}. + * @param ctrlOrMeta Ctrl or meta key is pressed + * @param e The pointerdown event + * @param node The node to process a click event for + */ + #processNodeClick(e: CanvasPointerEvent, ctrlOrMeta: boolean, node: LGraphNode): void { + const { pointer, graph } = this + const x = e.canvasX + const y = e.canvasY + + pointer.onClick = () => this.processSelect(node, e) + + // Immediately bring to front + if (!node.flags.pinned) { + this.bringToFront(node) + } + + // Collapse toggle + const inCollapse = node.isPointInCollapse(x, y) + if (inCollapse) { + pointer.onClick = () => { + node.collapse() + this.setDirty(true, true) + } + } else if (!node.flags.collapsed) { + // Resize node + if (node.resizable !== false && node.inResizeCorner(x, y)) { + pointer.onDragStart = () => { + graph.beforeChange() + this.resizing_node = node + } + pointer.onDragEnd = upEvent => { + this.#dirty() + graph.afterChange(this.resizing_node) + } + pointer.finally = () => this.resizing_node = null + this.canvas.style.cursor = "se-resize" + return + } + + // 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 (isInRectangle( + x, + y, + link_pos[0] - 15, + link_pos[1] - 10, + 30, + 20 + )) { + // Drag multiple output links + if (e.shiftKey && output.links?.length > 0) { + + this.connecting_links = [] + for (const linkId of output.links) { + const link = graph._links.get(linkId) + const slot = link.target_slot + const linked_node = 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, + }) } - // If we shift click on a link then start a link from that input - if ((e.shiftKey || e.altKey) && linkSegment.path && this.ctx.isPointInStroke(linkSegment.path, e.canvasX, e.canvasY)) { - if (e.shiftKey && !e.altKey) { - const slot = linkSegment.origin_slot - const originNode = graph._nodes_by_id[linkSegment.origin_id] - - const connecting: ConnectingLink = { - node: originNode, - slot, - output: originNode.outputs[slot], - pos: originNode.getConnectionPos(false, slot), - } - this.connecting_links = [connecting] - if (this.reroutesEnabled && linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId - - skip_action = true - break - } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { - const newReroute = graph.createReroute([e.canvasX, e.canvasY], linkSegment) - this.processSelect(newReroute, e) - this.isDragging = true - - skip_action = true - break - } + return + } + + 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 (ctrlOrMeta && e.altKey && !e.shiftKey) { + node.disconnectOutput(i) } } - // Restore line width - this.ctx.lineWidth = lineWidth + // TODO: Move callbacks to the start of this closure (onInputClick is already correct). + pointer.onDoubleClick = () => node.onOutputDblClick?.(i, e) + pointer.onClick = () => node.onOutputClick?.(i, e) + + return } + } + } - const group = graph.getGroupOnPos(e.canvasX, e.canvasY) - this.selected_group = group - if (group && !this.read_only) { - if (e.ctrlKey) { - this.dragging_rectangle = null - } + // 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 (isInRectangle( + x, + y, + link_pos[0] - 15, + link_pos[1] - 10, + 30, + 20 + )) { + pointer.onDoubleClick = () => node.onInputDblClick?.(i, e) + pointer.onClick = () => node.onInputClick?.(i, e) + + if (input.link !== null) { + //before disconnecting + const link_info = graph._links.get(input.link) + const slot = link_info.origin_slot + const linked_node = graph._nodes_by_id[link_info.origin_id] + if (LiteGraph.click_do_break_link_to || (LiteGraph.ctrl_alt_click_do_break_link && ctrlOrMeta && e.altKey && !e.shiftKey)) { + node.disconnectInput(i) + } else if (e.shiftKey || this.allow_reconnect_links) { + const connecting: ConnectingLink = { + node: linked_node, + slot, + output: linked_node.outputs[slot], + pos: linked_node.getConnectionPos(false, slot), + } + this.connecting_links = [connecting] - if (group.isInResize(e.canvasX, e.canvasY)) { - this.resizingGroup = group - } 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)) { - group.recomputeInsideNodes() - this.processSelect(group, e, true) - - this.isDragging = true - skip_action = true + pointer.onDragStart = () => { + if (this.allow_reconnect_links && !LiteGraph.click_do_break_link_to) + node.disconnectInput(i) + connecting.output = linked_node.outputs[slot] + } + + this.dirty_bgcanvas = true } } + if (!pointer.onDragStart) { + // Connect from input to output + const connecting: ConnectingLink = { + node, + slot: i, + output: null, + pos: link_pos, + } + this.connecting_links = [connecting] + pointer.onDragStart = () => connecting.input = input - if (is_double_click) { - this.emitEvent({ - subType: "group-double-click", - originalEvent: e, - 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.dirty_bgcanvas = true } - this.emitEvent({ - subType: "empty-double-click", - originalEvent: e, - }) + + // pointer.finally = () => this.connecting_links = null + return } + } + } + } + + // Click was inside the node, but not on input/output, or the resize corner + const pos: Point = [x - node.pos[0], y - node.pos[1]] - clicking_canvas_bg = true + // Widget + const widget = node.getWidgetOnPos(x, y) + if (widget) { + this.#processWidgetClick(e, node, widget) + this.node_widget = [node, widget] + } else { + pointer.onDoubleClick = () => { + // Double-click + // 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 && !inCollapse) { + node.onNodeTitleDblClick?.(e, pos, this) } + node.onDblClick?.(e, pos, this) + this.processNodeDblClicked(node) } - if (!skip_action && clicking_canvas_bg && this.allow_dragcanvas) { - this.dragging_canvas = true + // Mousedown callback - can block drag + if (node.onMouseDown?.(e, pos, this) || !this.allow_dragnodes) + return + + // Drag node + pointer.onDragStart = () => { + graph.beforeChange() + this.processSelect(node, e, true) + this.isDragging = true } + pointer.finally = () => this.isDragging = false + } - } 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 - } - } - } + this.dirty_canvas = true + } - //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 - } - } - } - // Middle clicked a slot - 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]), - e.canvasY - 80 - ] - // 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, - position: posRef, - nodeType: "AUTO", - posAdd: [!mClikSlot_isOut ? -30 : 30, -alphaPosY * 130], - posSizeFix: [!mClikSlot_isOut ? -1 : 0, 0] - }) - skip_action = true - } + #processWidgetClick(e: CanvasPointerEvent, node: LGraphNode, widget: IWidget) { + const { pointer } = this + const width = widget.width || node.width + + const oldValue = widget.value + + const pos = this.graph_mouse + const x = pos[0] - node.pos[0] + const y = pos[1] - node.pos[1] + + switch (widget.type) { + case "button": + pointer.onClick = () => { + widget.callback?.(widget, this, node, pos, e) + widget.clicked = true + this.dirty_canvas = true + } + break + case "slider": { + if (widget.options.read_only) break + + pointer.onDrag = eMove => { + const slideFactor = clamp((x - 15) / (width - 30), 0, 1) + widget.value = widget.options.min + (widget.options.max - widget.options.min) * slideFactor + if (oldValue != widget.value) { + setWidgetValue(this, node, widget, widget.value) } + this.dirty_canvas = true } + break } + case "number": { + const delta = x < 40 + ? -1 + : x > width - 40 + ? 1 + : 0 + pointer.onClick = (upEvent) => { + // Left/right arrows + widget.value += delta * 0.1 * (widget.options.step || 1) + if (widget.options.min != null && widget.value < widget.options.min) { + widget.value = widget.options.min + } + if (widget.options.max != null && widget.value > widget.options.max) { + widget.value = widget.options.max + } - // Drag canvas using middle mouse button - if (!skip_action && this.allow_dragcanvas) { - this.dragging_canvas = true + if (delta !== 0) return + + // Click in widget centre area - prompt user for input + this.prompt("Value", widget.value, (v: string) => { + // check if v is a valid equation or a number + if (/^[0-9+\-*/()\s]+|\d+\.\d+$/.test(v)) { + //solve the equation if possible + try { + v = eval(v) + } catch { } + } + widget.value = Number(v) + setWidgetValue(this, node, widget, widget.value) + }, e) + this.dirty_canvas = true + } + + // Click & drag from widget centre area + pointer.onDrag = eMove => { + const x = eMove.canvasX - node.pos[0] + if (delta && (x > -3 && x < width + 3)) return + + if (eMove.deltaX) widget.value += eMove.deltaX * 0.1 * (widget.options.step || 1) + + if (widget.options.min != null && widget.value < widget.options.min) { + widget.value = widget.options.min + } + if (widget.options.max != null && widget.value > widget.options.max) { + widget.value = widget.options.max + } + } + break } + case "combo": { + // TODO: Type checks on widget values + let values: string[] + let values_list: string[] + + pointer.onClick = (upEvent) => { + const delta = x < 40 + ? -1 + : x > width - 40 + ? 1 + : 0 + + // Combo buttons + values = widget.options.values + if (typeof values === "function") { + // @ts-expect-error + values = values(widget, node) + } + values_list = null - } else if (e.which == 3 || this.pointer_is_double) { + values_list = Array.isArray(values) ? values : Object.keys(values) - //right button - if (this.allow_interaction && !skip_action && !this.read_only) { + // Left/right arrows + if (delta) { + console.warn("DELTA", delta) + let index = -1 + this.last_mouseclick = 0 //avoids dobl click event + index = typeof values === "object" + ? values_list.indexOf(String(widget.value)) + delta + // @ts-expect-error + : values_list.indexOf(widget.value) + delta - // is it hover a node ? - if (node) { - // add this if not present - this.processSelect(node, e, true) + if (index >= values_list.length) index = values_list.length - 1 + if (index < 0) index = 0 + + widget.value = Array.isArray(values) + ? values[index] + : index + return + } + console.warn("MENU", delta) + const text_values = values != values_list ? Object.values(values) : values + new LiteGraph.ContextMenu(text_values, { + scale: Math.max(1, this.ds.scale), + event: e, + className: "dark", + callback: (value: string) => { + widget.value = values != values_list + ? text_values.indexOf(value) + : value + + setWidgetValue(this, node, widget, widget.value) + this.dirty_canvas = true + return false + } + }) } - // Show context menu for the node or group under the pointer - this.processContextMenu(node, e) + // TODO: setTimeout why + if (oldValue != widget.value) + setTimeout(() => setWidgetValue(this, node, widget, widget.value), 20) + this.dirty_canvas = true + break } - + case "toggle": + pointer.onClick = () => { + widget.value = !widget.value + setWidgetValue(this, node, widget, widget.value) + } + break + case "string": + case "text": + pointer.onClick = () => this.prompt( + "Value", + widget.value, + (v: any) => setWidgetValue(this, node, widget, v), + e, + widget.options ? widget.options.multiline : false + ) + break + default: + if (widget.mouse) this.dirty_canvas = widget.mouse(e, [x, y], node) + break } - this.last_mouse = [x, y] - this.last_mouseclick = LiteGraph.getTime() - this.last_mouse_dragging = true + //value changed + if (oldValue != widget.value) { + node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget) + node.graph._version++ + } - graph.change() + pointer.finally = () => this.node_widget = null - //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() + function setWidgetValue(canvas: LGraphCanvas, node: LGraphNode, 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, canvas, node, pos, e) } - e.stopPropagation() + } - this.onMouseDown?.(e) + /** + * Pointer middle button click processing. Part of {@link processMouseDown}. + * @param e The pointerdown event + * @param node The node to process a click event for + */ + #processMiddleButton(e: CanvasPointerEvent, node: LGraphNode) { + const { pointer } = this + + if (LiteGraph.middle_click_slot_add_default_node && + node && + this.allow_interaction && + !this.read_only && + !this.connecting_links && + !node.flags.collapsed + ) { + //not dragging mouse to connect two slots + 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 (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + mClikSlot = output + mClikSlot_index = i + mClikSlot_isOut = true + break + } + } + } - return false + //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 (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + mClikSlot = input + mClikSlot_index = i + mClikSlot_isOut = false + break + } + } + } + // Middle clicked a slot + 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]), + e.canvasY - 80 + ] + // eslint-disable-next-line @typescript-eslint/no-unused-vars + pointer.onClick = () => this.createDefaultNodeForSlot({ + nodeFrom: !mClikSlot_isOut ? null : node, + slotFrom: !mClikSlot_isOut ? null : mClikSlot_index, + nodeTo: !mClikSlot_isOut ? node : null, + slotTo: !mClikSlot_isOut ? mClikSlot_index : null, + position: posRef, + nodeType: "AUTO", + posAdd: [!mClikSlot_isOut ? -30 : 30, -alphaPosY * 130], + posSizeFix: [!mClikSlot_isOut ? -1 : 0, 0] + }) + } + } + + // Drag canvas using middle mouse button + if (this.allow_dragcanvas) { + pointer.onDragStart = () => this.dragging_canvas = true + pointer.finally = () => this.dragging_canvas = false + } } + /** * Called when a mouse move event has to be processed **/ - processMouseMove(e: CanvasMouseEvent): boolean { + processMouseMove(e: PointerEvent): void { if (this.autoresize) this.resize() if (this.set_canvas_dirty_on_mouse_event) @@ -2309,7 +2467,7 @@ export class LGraphCanvas { LGraphCanvas.active_canvas = this this.adjustMouseEvent(e) - const mouse: Point = [e.clientX, e.clientY] + const mouse: ReadOnlyPoint = [e.clientX, e.clientY] this.mouse[0] = mouse[0] this.mouse[1] = mouse[1] const delta = [ @@ -2320,30 +2478,26 @@ export class LGraphCanvas { this.graph_mouse[0] = e.canvasX this.graph_mouse[1] = e.canvasY + if (e.isPrimary) this.pointer.move(e) + this.link_over_widget = null + if (this.block_click) { e.preventDefault() - return false + return } 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 - } - + /** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */ + let underPointer = CanvasItem.Nothing //get node over const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) const { resizingGroup } = this - if (this.dragging_rectangle) { - this.dragging_rectangle[2] = e.canvasX - this.dragging_rectangle[0] - this.dragging_rectangle[3] = e.canvasY - this.dragging_rectangle[1] + const dragRect = this.dragging_rectangle + if (dragRect) { + dragRect[2] = e.canvasX - dragRect[0] + dragRect[3] = e.canvasY - dragRect[1] this.dirty_canvas = true } else if (resizingGroup && !this.read_only) { @@ -2352,6 +2506,7 @@ export class LGraphCanvas { e.canvasX - resizingGroup.pos[0], e.canvasY - resizingGroup.pos[1] ) + underPointer |= CanvasItem.ResizeSe | CanvasItem.Group if (resized) this.dirty_bgcanvas = true } else if (this.dragging_canvas) { this.ds.offset[0] += delta[0] / this.ds.scale @@ -2365,6 +2520,7 @@ export class LGraphCanvas { //mouse over a node if (node) { + underPointer |= CanvasItem.Node if (node.redraw_on_mouse) this.dirty_canvas = true @@ -2399,7 +2555,7 @@ export class LGraphCanvas { 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) { + if (this.connecting_links?.length) { const firstLink = this.connecting_links[0] // Default: nothing highlighted @@ -2410,9 +2566,7 @@ export class LGraphCanvas { 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) { @@ -2433,7 +2587,7 @@ export class LGraphCanvas { highlightInput = node.inputs[targetSlotId] } } - } else { + } else if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { //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 @@ -2442,10 +2596,10 @@ export class LGraphCanvas { } } 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 @@ -2465,22 +2619,24 @@ export class LGraphCanvas { this.dirty_canvas = true } - //Search for corner - if (this.canvas) { - this.canvas.style.cursor = node.inResizeCorner(e.canvasX, e.canvasY) - ? "se-resize" - : "crosshair" + // Resize corner + if (this.canvas && !e.ctrlKey) { + if (node.inResizeCorner(e.canvasX, e.canvasY)) underPointer |= CanvasItem.ResizeSe } } else { // Not over a node const segment = this.#getLinkCentreOnPos(e) if (this.over_link_center !== segment) { + underPointer |= CanvasItem.Link this.over_link_center = segment this.dirty_bgcanvas = true } if (this.canvas) { - this.canvas.style.cursor = "" + const group = this.graph.getGroupOnPos(e.canvasX, e.canvasY) + if (group && !e.ctrlKey && !this.read_only && group.isInResize(e.canvasX, e.canvasY)) { + underPointer |= CanvasItem.ResizeSe + } } } //end @@ -2490,7 +2646,7 @@ export class LGraphCanvas { } // Items being dragged - if (this.isDragging && !this.live_mode) { + if (this.isDragging) { const selected = this.selectedItems const allItems = e.ctrlKey ? selected : getAllNestedItems(selected) @@ -2503,7 +2659,7 @@ export class LGraphCanvas { this.#dirty() } - if (this.resizing_node && !this.live_mode) { + if (this.resizing_node) { //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() @@ -2511,34 +2667,57 @@ export class LGraphCanvas { desired_size[1] = Math.max(min_size[1], desired_size[1]) this.resizing_node.setSize(desired_size) - this.canvas.style.cursor = "se-resize" + underPointer |= CanvasItem.ResizeSe this.#dirty() } } + this.state.hoveringOver = underPointer + + if (this.state.shouldSetCursor) { + if (!underPointer) { + this.canvas.style.cursor = "" + } else if (underPointer & CanvasItem.ResizeSe) { + this.canvas.style.cursor = "se-resize" + } else if (underPointer & CanvasItem.Node) { + this.canvas.style.cursor = "crosshair" + } + } + e.preventDefault() - return false + return } /** * Called when a mouse up event has to be processed **/ - processMouseUp(e: CanvasPointerEvent): boolean { + processMouseUp(e: PointerEvent): void { //early exit for extra pointer - if (e.isPrimary === false) return false - if (!this.graph) return + if (e.isPrimary === false) return + + const { graph, pointer } = this + if (!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) { - 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) + + /** The mouseup event occurred near the mousedown event. */ + /** Normal-looking click event - mouseUp occurred near mouseDown, without dragging. */ + const isClick = pointer.up(e) + if (isClick === true) { + pointer.isDown = false + pointer.isDouble = false + // Required until all link behaviour is added to Pointer API + this.connecting_links = null + this.dragging_canvas = false + + graph.change() + + e.stopPropagation() + e.preventDefault() + return } - this.adjustMouseEvent(e) const now = LiteGraph.getTime() e.click_time = now - this.last_mouseclick this.last_mouse_dragging = false @@ -2547,83 +2726,38 @@ export class LGraphCanvas { //used to avoid sending twice a click in an immediate button this.block_click &&= false - if (e.which == 1) { - this.resizingGroup = null - - if (this.node_widget) { - this.processNodeWidgets(this.node_widget[0], this.graph_mouse, e) - } - + if (e.button === 0) { //left button - this.node_widget = null this.selected_group = null - this.isDragging = false - - const node = this.graph.getNodeOnPos( - e.canvasX, - e.canvasY, - this.visible_nodes - ) - const dragRect = this.dragging_rectangle - if (dragRect) { - 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(dragRect[2]) - const h = Math.abs(dragRect[3]) - const startx = dragRect[2] < 0 - ? dragRect[0] - w - : dragRect[0] - const starty = dragRect[3] < 0 - ? dragRect[1] - h - : dragRect[1] - dragRect[0] = startx - dragRect[1] = starty - dragRect[2] = w - dragRect[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 (const nodeX of nodes) { - nodeX.getBounding(node_bounding) - if (!overlapBounding(dragRect, node_bounding)) continue - - to_select.push(nodeX) - } - // add to selection with shift - if (to_select.length) this.selectNodes(to_select, e.shiftKey) - - // Select groups - const groups = this.graph.groups - for (const group of groups) { - if (!containsRect(dragRect, group._bounding)) continue - this.selectedItems.add(group) - group.recomputeInsideNodes() - group.selected = true - } + // Deprecated - old API for backwards compat + if (this.isDragging && this.selectedItems.size === 1) { + const val = this.selectedItems.values().next().value + if (val instanceof LGraphNode) { + this.onNodeMoved?.(val) + graph.afterChange(val) + } + } + if (this.isDragging && LiteGraph.always_round_positions) { + const selected = this.selectedItems + const allItems = getAllNestedItems(selected) - if (this.reroutesEnabled) { - for (const reroute of this.graph.reroutes.values()) { - if (!isPointInRectangle(reroute.pos, dragRect)) continue - this.selectedItems.add(reroute) - reroute.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 - } + allItems.forEach(x => { + x.pos[0] = Math.round(x.pos[0]) + x.pos[1] = Math.round(x.pos[1]) + }) + this.dirty_canvas = true + this.dirty_bgcanvas = true + } + this.isDragging = false - } - this.dragging_rectangle = null - } else if (this.connecting_links) { + const x = e.canvasX + const y = e.canvasY + const node = graph.getNodeOnPos(x, y, this.visible_nodes) + if (this.connecting_links?.length) { //node below mouse + const firstLink = this.connecting_links[0] if (node) { for (const link of this.connecting_links) { @@ -2633,11 +2767,7 @@ export class LGraphCanvas { //slot below mouse? connect if (link.output) { - const slot = this.isOverNodeInput( - node, - e.canvasX, - e.canvasY - ) + const slot = this.isOverNodeInput(node, x, y) if (slot != -1) { link.node.connect(link.slot, node, slot, link.afterRerouteId) } else if (this.link_over_widget) { @@ -2654,11 +2784,7 @@ export class LGraphCanvas { link.node.connectByType(link.slot, node, link.output.type, { afterRerouteId: link.afterRerouteId }) } } else if (link.input) { - const slot = this.isOverNodeOutput( - node, - e.canvasX, - e.canvasY - ) + const slot = this.isOverNodeOutput(node, x, y) if (slot != -1) { node.connect(slot, link.node, link.slot, link.afterRerouteId) // this is inverted has output-input nature like @@ -2669,8 +2795,7 @@ export class LGraphCanvas { } } } - } else { - const firstLink = this.connecting_links[0] + } else if (firstLink.input || firstLink.output) { const linkReleaseContext = firstLink.output ? { node_from: firstLink.node, slot_from: firstLink.output, @@ -2705,83 +2830,56 @@ export class LGraphCanvas { } } } - } //not dragging connection - else if (this.resizing_node) { - this.#dirty() - this.graph.afterChange(this.resizing_node) - this.resizing_node = null - } else if (this.node_dragged) { - //node being dragged? - const { node_dragged } = this - if (e.click_time < 300 && node_dragged?.isPointInCollapse(e.canvasX, e.canvasY)) { - node_dragged.collapse() - } - - this.#dirty() - node_dragged.pos[0] = Math.round(node_dragged.pos[0]) - node_dragged.pos[1] = Math.round(node_dragged.pos[1]) - if (this.graph.config.align_to_grid || this.align_to_grid) { - node_dragged.alignToGrid() - } - this.onNodeMoved?.(node_dragged) - this.graph.afterChange(node_dragged) - this.node_dragged = null - } //no node being dragged - else { - if ( - !node && - e.click_time < 300 && - !this.graph.getGroupTitlebarOnPos(e.canvasX, e.canvasY) && - (!this.reroutesEnabled || !this.graph.getRerouteOnPos(e.canvasX, e.canvasY)) - ) { - this.processSelect(null, e) - } - + } else { 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_over?.onMouseUp?.(e, [x - this.node_over.pos[0], y - 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] + x - this.node_capturing_input.pos[0], + y - this.node_capturing_input.pos[1] ]) } this.connecting_links = null - } else if (e.which == 2) { + } else if (e.button === 1) { //middle button this.dirty_canvas = true this.dragging_canvas = false - } else if (e.which == 3) { + } else if (e.button === 2) { //right button this.dirty_canvas = true - this.dragging_canvas = false } - this.pointer_is_down = false - this.pointer_is_double = false + pointer.isDown = false + pointer.isDouble = false - this.graph.change() + graph.change() e.stopPropagation() e.preventDefault() - return false + return } /** * Called when the mouse moves off the canvas. Clears all node hover states. * @param e */ - processMouseOut(e: CanvasMouseEvent): void { + processMouseOut(e: MouseEvent): void { // TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc. + this.adjustMouseEvent(e) this.updateMouseOverNodes(null, e) } + processMouseCancel(e: PointerEvent): void { + console.warn("Pointer cancel!") + this.pointer.reset() + } + /** * Called when a mouse wheel event has to be processed **/ - processMouseWheel(e: CanvasWheelEvent): boolean { + processMouseWheel(e: WheelEvent): void { if (!this.graph || !this.allow_dragcanvas) return // TODO: Mouse wheel zoom rewrite @@ -2791,7 +2889,7 @@ export class LGraphCanvas { this.adjustMouseEvent(e) const pos: Point = [e.clientX, e.clientY] - if (this.viewport && !isPointInRectangle(pos, this.viewport)) return + if (this.viewport && !isPointInRect(pos, this.viewport)) return let scale = this.ds.scale @@ -2803,7 +2901,7 @@ export class LGraphCanvas { this.graph.change() e.preventDefault() - return false + return } /** @@ -2816,7 +2914,7 @@ export class LGraphCanvas { const link_pos = node.getConnectionPos(true, i) let is_inside = false if (node.horizontal) { - is_inside = isInsideRectangle( + is_inside = isInRectangle( canvasx, canvasy, link_pos[0] - 5, @@ -2828,7 +2926,7 @@ export class LGraphCanvas { // 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( + is_inside = isInRectangle( canvasx, canvasy, link_pos[0] - 10, @@ -2857,7 +2955,7 @@ export class LGraphCanvas { const link_pos = node.getConnectionPos(false, i) let is_inside = false if (node.horizontal) { - is_inside = isInsideRectangle( + is_inside = isInRectangle( canvasx, canvasy, link_pos[0] - 5, @@ -2866,7 +2964,7 @@ export class LGraphCanvas { 20 ) } else { - is_inside = isInsideRectangle( + is_inside = isInRectangle( canvasx, canvasy, link_pos[0] - 10, @@ -2905,7 +3003,7 @@ export class LGraphCanvas { if (this._previously_dragging_canvas === null) { this._previously_dragging_canvas = this.dragging_canvas } - this.dragging_canvas = this.pointer_is_down + this.dragging_canvas = this.pointer.isDown block_default = true } @@ -3192,12 +3290,12 @@ export class LGraphCanvas { /** * process a item drop event on top the canvas **/ - processDrop(e: CanvasDragEvent): boolean { + processDrop(e: DragEvent): 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])) + const is_inside = !this.viewport || isInRect(x, y, this.viewport) if (!is_inside) return const pos = [e.canvasX, e.canvasY] @@ -3268,6 +3366,66 @@ export class LGraphCanvas { this.setDirty(true) } + #handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) { + // Process drag + // Convert Point pair (pos, offset) to Rect + const { graph, selectedItems } = this + + const w = Math.abs(dragRect[2]) + const h = Math.abs(dragRect[3]) + if (dragRect[2] < 0) dragRect[0] -= w + if (dragRect[3] < 0) dragRect[1] -= h + dragRect[2] = w + dragRect[3] = h + + // Select nodes - any part of the node is in the select area + const isSelected: Positionable[] = [] + const notSelected: Positionable[] = [] + for (const nodeX of graph._nodes) { + if (!overlapBounding(dragRect, nodeX.boundingRect)) continue + + if (!nodeX.selected || !selectedItems.has(nodeX)) + notSelected.push(nodeX) + else isSelected.push(nodeX) + } + + // Select groups - the group is wholly inside the select area + for (const group of graph.groups) { + if (!containsRect(dragRect, group._bounding)) continue + group.recomputeInsideNodes() + + if (!group.selected || !selectedItems.has(group)) + notSelected.push(group) + else isSelected.push(group) + } + + // Select reroutes - the centre point is inside the select area + for (const reroute of graph.reroutes.values()) { + if (!isPointInRect(reroute.pos, dragRect)) continue + + selectedItems.add(reroute) + reroute.selected = true + + if (!reroute.selected || !selectedItems.has(reroute)) + notSelected.push(reroute) + else isSelected.push(reroute) + } + + if (e.shiftKey) { + // Add to selection + for (const item of notSelected) this.select(item) + } else if (e.altKey) { + // Remove from selection + for (const item of isSelected) this.deselect(item) + } else { + // Replace selection + for (const item of selectedItems.values()) { + if (!isSelected.includes(item)) this.deselect(item) + } + for (const item of notSelected) this.select(item) + } + } + /** * Determines whether to select or deselect an item that has received a pointer event. Will deselect other nodes if * @param item Canvas item to select/deselect @@ -3498,7 +3656,7 @@ export class LGraphCanvas { /** * adds some useful properties to a mouse event, like the position in graph coordinates **/ - adjustMouseEvent(e: CanvasMouseEvent | CanvasDragEvent | CanvasWheelEvent): asserts e is CanvasMouseEvent { + adjustMouseEvent(e: T & Partial): asserts e is T & CanvasMouseEvent { let clientX_rel = e.clientX let clientY_rel = e.clientY @@ -3513,9 +3671,7 @@ export class LGraphCanvas { // 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 @@ -3586,9 +3742,6 @@ export class LGraphCanvas { const _nodes = nodes || this.graph._nodes for (const node of _nodes) { - //skip rendering nodes in live mode - if (this.live_mode && !node.onDrawBackground && !node.onDrawForeground) continue - node.updateArea() // Not in visible area if (!overlapBounding(this.visible_area, node.renderArea)) continue @@ -3713,9 +3866,7 @@ export class LGraphCanvas { //connections ontop? if (this.graph.config.links_ontop) { - if (!this.live_mode) { - this.drawConnections(ctx) - } + this.drawConnections(ctx) } if (this.connecting_links?.length) { @@ -3834,11 +3985,6 @@ export class LGraphCanvas { ctx.restore() } - //draws panel in the corner - if (this._graph_stack?.length) { - this.drawSubgraphPanel(ctx) - } - this.onDrawOverlay?.(ctx) if (area) ctx.restore() @@ -3855,14 +4001,14 @@ export class LGraphCanvas { const centre = linkSegment._pos if (!centre) continue - if (isInsideRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { + if (isInRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { return linkSegment } } } /** Get the target snap / highlight point in graph space */ - #getHighlightPosition(): Point { + #getHighlightPosition(): ReadOnlyPoint { return LiteGraph.snaps_for_comfy ? this._highlight_pos ?? this.graph_mouse : this.graph_mouse @@ -3873,7 +4019,7 @@ export class LGraphCanvas { * Partial border over target node and a highlight over the slot itself. * @param ctx Canvas 2D context */ - #renderSnapHighlight(ctx: CanvasRenderingContext2D, highlightPos: Point): void { + #renderSnapHighlight(ctx: CanvasRenderingContext2D, highlightPos: ReadOnlyPoint): void { if (!this._highlight_pos) return ctx.fillStyle = "#ffcc00" @@ -3956,203 +4102,6 @@ export class LGraphCanvas { 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 **/ @@ -4301,7 +4250,7 @@ export class LGraphCanvas { } //groups - if (this.graph._groups.length && !this.live_mode) { + if (this.graph._groups.length) { this.drawGroups(canvas, ctx) } @@ -4326,9 +4275,7 @@ export class LGraphCanvas { } //draw connections - if (!this.live_mode) { - this.drawConnections(ctx) - } + this.drawConnections(ctx) ctx.shadowColor = "rgba(0,0,0,0)" @@ -4354,16 +4301,6 @@ export class LGraphCanvas { 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 @@ -5886,205 +5823,6 @@ export class LGraphCanvas { 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 || w.hidden || (w.advanced && !node.showAdvanced)) - 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 @@ -6136,39 +5874,6 @@ export class LGraphCanvas { 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() - 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 { } @@ -6580,7 +6285,7 @@ export class LGraphCanvas { return dialog } - showSearchBox(event: CanvasMouseEvent, options?: IShowSearchOptions): HTMLDivElement { + showSearchBox(event: MouseEvent, options?: IShowSearchOptions): HTMLDivElement { // proposed defaults const def_options: IShowSearchOptions = { slot_from: null, @@ -7648,115 +7353,6 @@ export class LGraphCanvas { 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 @@ -7792,13 +7388,6 @@ export class LGraphCanvas { 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) @@ -7912,12 +7501,6 @@ export class LGraphCanvas { }) } - // 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", @@ -8100,9 +7683,6 @@ export class LGraphCanvas { }) input.focus() } - - //if(v.callback) - // return v.callback.call(that, node, options, e, menu, that, event ); } } diff --git a/src/LGraphGroup.ts b/src/LGraphGroup.ts index 11d709f4..ae598fb0 100644 --- a/src/LGraphGroup.ts +++ b/src/LGraphGroup.ts @@ -1,9 +1,9 @@ -import type { IContextMenuValue, IPinnable, Point, Positionable, ReadOnlyRect, Size } from "./interfaces" +import type { IContextMenuValue, IPinnable, Point, Positionable, Size } from "./interfaces" import type { LGraph } from "./LGraph" import type { ISerialisedGroup } from "./types/serialisation" import { LiteGraph } from "./litegraph" import { LGraphCanvas } from "./LGraphCanvas" -import { containsCentre, containsRect, isInsideRectangle, isPointInRectangle, createBounds } from "./measure" +import { containsCentre, containsRect, isInRectangle, isPointInRect, createBounds } from "./measure" import { LGraphNode } from "./LGraphNode" import { RenderShape, TitleMode } from "./types/globalEnums" @@ -64,7 +64,6 @@ export class LGraphGroup implements Positionable, IPinnable { this._size[1] = Math.max(LGraphGroup.minHeight, v[1]) } - get boundingRect() { return this._bounding } @@ -209,7 +208,7 @@ export class LGraphGroup implements Positionable, IPinnable { // Move reroutes we overlap the centre point of for (const reroute of reroutes.values()) { - if (isPointInRectangle(reroute.pos, this._bounding)) + if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute) } @@ -283,8 +282,8 @@ export class LGraphGroup implements Positionable, IPinnable { } isPointInTitlebar(x: number, y: number): boolean { - const b = this._bounding - return isInsideRectangle(x, y, b[0], b[1], b[2], this.titleHeight) + const b = this.boundingRect + return isInRectangle(x, y, b[0], b[1], b[2], this.titleHeight) } isInResize(x: number, y: number): boolean { diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 69084ab0..50e13b18 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -9,7 +9,7 @@ import type { Reroute, RerouteId } from "./Reroute" import { LGraphEventMode, NodeSlotType, TitleMode, RenderShape } from "./types/globalEnums" import { BadgePosition, LGraphBadge } from "./LGraphBadge" import { type LGraphNodeConstructor, LiteGraph } from "./litegraph" -import { isInsideRectangle, isXyInRectangle } from "./measure" +import { isInRectangle, isInRect } from "./measure" import { LLink } from "./LLink" export type NodeId = number | string @@ -322,8 +322,11 @@ export class LGraphNode implements Positionable, IPinnable { onGetOutputs?(this: LGraphNode): INodeOutputSlot[] onMouseUp?(this: LGraphNode, e: CanvasMouseEvent, pos: Point): void onMouseEnter?(this: LGraphNode, e: CanvasMouseEvent): void + /** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */ onMouseDown?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): boolean + /** @param pos Offset from {@link LGraphNode.pos}. */ onDblClick?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): void + /** @param pos Offset from {@link LGraphNode.pos}. */ onNodeTitleDblClick?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): void onDrawTitle?(this: LGraphNode, ctx: CanvasRenderingContext2D): void onDrawTitleText?(this: LGraphNode, ctx: CanvasRenderingContext2D, title_height: number, size: Size, scale: number, title_text_font: string, selected: boolean): void @@ -1330,7 +1333,7 @@ export class LGraphNode implements Positionable, IPinnable { inResizeCorner(canvasX: number, canvasY: number): boolean { const rows = this.outputs ? this.outputs.length : 1 const outputs_offset = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT - return isInsideRectangle(canvasX, + return isInRectangle(canvasX, canvasY, this.pos[0] + this.size[0] - 15, this.pos[1] + Math.max(this.size[1] - 15, outputs_offset), @@ -1518,7 +1521,7 @@ export class LGraphNode implements Positionable, IPinnable { * @return {boolean} */ isPointInside(x: number, y: number): boolean { - return isXyInRectangle(x, y, this.boundingRect) + return isInRect(x, y, this.boundingRect) } /** @@ -1529,7 +1532,7 @@ export class LGraphNode implements Positionable, IPinnable { */ isPointInCollapse(x: number, y: number): boolean { const squareLength = LiteGraph.NODE_TITLE_HEIGHT - return isInsideRectangle(x, y, this.pos[0], this.pos[1] - squareLength, squareLength, squareLength) + return isInRectangle(x, y, this.pos[0], this.pos[1] - squareLength, squareLength, squareLength) } /** @@ -1545,7 +1548,7 @@ export class LGraphNode implements Positionable, IPinnable { for (let i = 0, l = this.inputs.length; i < l; ++i) { const input = this.inputs[i] this.getConnectionPos(true, i, link_pos) - if (isInsideRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { + if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { return { input, slot: i, link_pos } } } @@ -1555,7 +1558,7 @@ export class LGraphNode implements Positionable, IPinnable { for (let i = 0, l = this.outputs.length; i < l; ++i) { const output = this.outputs[i] this.getConnectionPos(false, i, link_pos) - if (isInsideRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { + if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { return { output, slot: i, link_pos } } } @@ -1564,6 +1567,36 @@ export class LGraphNode implements Positionable, IPinnable { return null } + /** + * Gets the widget on this node at the given co-ordinates. + * @param canvasX X co-ordinate in graph space + * @param canvasY Y co-ordinate in graph space + * @returns The widget found, otherwise `null` + */ + getWidgetOnPos(canvasX: number, canvasY: number, includeDisabled = false): IWidget | null { + const { widgets, pos, size } = this + if (!widgets?.length) return null + + const x = canvasX - pos[0] + const y = canvasY - pos[1] + const nodeWidth = size[0] + + for (const widget of widgets) { + if (!widget || (widget.disabled && !includeDisabled) || widget.hidden || (widget.advanced && !this.showAdvanced)) continue + + const h = widget.computeSize + ? widget.computeSize(nodeWidth)[1] + : LiteGraph.NODE_WIDGET_HEIGHT + const w = widget.width || nodeWidth + if ( + widget.last_y !== undefined && + isInRectangle(x, y, 6, widget.last_y, w - 12, h) + ) + return widget + } + return null + } + /** * Returns the input slot with a given name (used for dynamic slots), -1 if not found * @param name the name of the slot diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index ac523aef..2d341830 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -132,6 +132,8 @@ export class LiteGraphGlobal { ctrl_alt_click_do_break_link = true // [true!] who accidentally ctrl-alt-clicks on an in/output? nobody! that's who! snaps_for_comfy = true // [true!] snaps links when dragging connections over valid targets snap_highlights_node = true // [true!] renders a partial border to highlight when a dragged link is snapped to a node + /** After moving items on the canvas, their positions will be rounded. Effectively "snap to grid" with a grid size of 1. */ + always_round_positions = false search_hide_on_mouse_leave = true // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false search_filter_enabled = false // [true!] enable filtering slots type in the search widget, !requires auto_load_slot_types or manual set registered_slot_[in/out]_types and slot_types_[in/out] diff --git a/src/interfaces.ts b/src/interfaces.ts index afe01e04..28ffb38a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -219,7 +219,7 @@ export interface IContextMenuOptions extends IContextMenuBase { scroll_speed?: number left?: number top?: number - scale?: string + scale?: number node?: LGraphNode autoopen?: boolean } diff --git a/src/litegraph.ts b/src/litegraph.ts index 148069a4..624fbb46 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -25,6 +25,7 @@ export { LGraphBadge, BadgePosition } export { SlotShape, LabelPosition, SlotDirection, SlotType } export { EaseFunction, LinkMarkerShape } from "./types/globalEnums" export type { SerialisableGraph, SerialisableLLink } from "./types/serialisation" +export { CanvasPointer } from "./CanvasPointer" export { createBounds } from "./measure" export function clamp(v: number, a: number, b: number): number { diff --git a/src/measure.ts b/src/measure.ts index bdefaf88..94fbad1f 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -1,4 +1,4 @@ -import type { Point, Positionable, ReadOnlyPoint, ReadOnlyRect } from "./interfaces" +import type { Point, Positionable, ReadOnlyPoint, ReadOnlyRect, Rect } from "./interfaces" import { LinkDirection } from "./types/globalEnums" /** @@ -16,43 +16,70 @@ export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number { /** * Calculates the distance2 (squared) between two points (2D vector). * Much faster when only comparing distances (closest/furthest point). - * @param a Point a as `x, y` - * @param b Point b as `x, y` - * @returns Distance2 (squared) between point {@link a} & {@link b} + * @param x1 Origin point X + * @param y1 Origin point Y + * @param x2 Destination point X + * @param y2 Destination point Y + * @returns Distance2 (squared) between point [{@link x1}, {@link y1}] & [{@link x2}, {@link y2}] */ -export function dist2(a: ReadOnlyPoint, b: ReadOnlyPoint): number { - return ((b[0] - a[0]) * (b[0] - a[0])) + ((b[1] - a[1]) * (b[1] - a[1])) +export function dist2(x1: number, y1: number, x2: number, y2: number): number { + return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1)) } /** * Determines whether a point is inside a rectangle. + * + * Otherwise identical to {@link isInsideRectangle}, it also returns `true` if `x` equals `left` or `y` equals `top`. + * @param x Point x + * @param y Point y + * @param left Rect x + * @param top Rect y + * @param width Rect width + * @param height Rect height + * @returns `true` if the point is inside the rect, otherwise `false` + */ +export function isInRectangle(x: number, y: number, left: number, top: number, width: number, height: number): boolean { + return x >= left + && x < left + width + && y >= top + && y < top + height +} + +/** + * Determines whether a {@link Point} is inside a {@link Rect}. * @param point The point to check, as `x, y` * @param rect The rectangle, as `x, y, width, height` * @returns `true` if the point is inside the rect, otherwise `false` */ -export function isPointInRectangle(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean { - return rect[0] <= point[0] - && rect[0] + rect[2] > point[0] - && rect[1] <= point[1] - && rect[1] + rect[3] > point[1] +export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean { + return point[0] >= rect[0] + && point[0] < rect[0] + rect[2] + && point[1] >= rect[1] + && point[1] < rect[1] + rect[3] } /** - * Determines whether a point is inside a rectangle. + * Determines whether the point represented by {@link x}, {@link y} is inside a {@link Rect}. * @param x X co-ordinate of the point to check * @param y Y co-ordinate of the point to check * @param rect The rectangle, as `x, y, width, height` * @returns `true` if the point is inside the rect, otherwise `false` */ -export function isXyInRectangle(x: number, y: number, rect: ReadOnlyRect): boolean { - return rect[0] <= x - && rect[0] + rect[2] > x - && rect[1] <= y - && rect[1] + rect[3] > y +export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean { + return x >= rect[0] + && x < rect[0] + rect[2] + && y >= rect[1] + && y < rect[1] + rect[3] } /** - * Determines whether a point is inside a rectangle. + * Determines whether a point (`x, y`) is inside a rectangle. + * + * This is the original litegraph implementation. It returns `false` if `x` is equal to `left`, or `y` is equal to `top`. + * @deprecated + * Use {@link isInRectangle} to match inclusive of top left. + * This function returns a false negative when an integer point (e.g. pixel) is on the leftmost or uppermost edge of a rectangle. + * * @param x Point x * @param y Point y * @param left Rect x @@ -109,7 +136,7 @@ export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean { export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean { const centreX = b[0] + (b[2] * 0.5) const centreY = b[1] + (b[3] * 0.5) - return isXyInRectangle(centreX, centreY, a) + return isInRect(centreX, centreY, a) } /** diff --git a/src/types/events.ts b/src/types/events.ts index 8faf1b6c..f35eab38 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -10,41 +10,32 @@ import type { LGraphGroup } from "../LGraphGroup" /** For Canvas*Event - adds graph space co-ordinates (property names are shipped) */ export interface ICanvasPosition { /** X co-ordinate of the event, in graph space (NOT canvas space) */ - canvasX?: number + canvasX: number /** Y co-ordinate of the event, in graph space (NOT canvas space) */ - canvasY?: number + canvasY: number } /** For Canvas*Event */ export interface IDeltaPosition { - deltaX?: number - deltaY?: number + deltaX: number + deltaY: number } -/** PointerEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent { } - -/** MouseEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasMouseEvent extends MouseEvent, ICanvasPosition, IDeltaPosition { +interface LegacyMouseEvent { /** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */ dragging?: boolean click_time?: number - dataTransfer?: unknown } -/** WheelEvent with canvasX/Y properties */ -export interface CanvasWheelEvent extends WheelEvent, ICanvasPosition { - dragging?: boolean - click_time?: number - dataTransfer?: unknown -} +/** PointerEvent with canvasX/Y and deltaX/Y properties */ +export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent { } + +/** MouseEvent with canvasX/Y and deltaX/Y properties */ +export interface CanvasMouseEvent extends MouseEvent, Readonly, Readonly, LegacyMouseEvent { } /** DragEvent with canvasX/Y and deltaX/Y properties */ export interface CanvasDragEvent extends DragEvent, ICanvasPosition, IDeltaPosition { } -/** TouchEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasTouchEvent extends TouchEvent, ICanvasPosition, IDeltaPosition { } - export type CanvasEventDetail = GenericEventDetail | DragggingCanvasEventDetail diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index 3af4e50e..c75fa68a 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -16,6 +16,22 @@ export enum RenderShape { HollowCircle = 7, } +/** Bit flags used to indicate what the pointer is currently hovering over. */ +export enum CanvasItem { + /** No items / none */ + Nothing = 0, + /** At least one node */ + Node = 1 << 0, + /** At least one group */ + Group = 1 << 1, + /** A reroute (not its path) */ + Reroute = 1 << 2, + /** The path of a link */ + Link = 1 << 3, + /** A resize in the bottom-right corner */ + ResizeSe = 1 << 4, +} + /** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */ export enum LinkDirection { NONE = 0, diff --git a/test/__snapshots__/litegraph.test.ts.snap b/test/__snapshots__/litegraph.test.ts.snap index e9f35124..052e4a28 100644 --- a/test/__snapshots__/litegraph.test.ts.snap +++ b/test/__snapshots__/litegraph.test.ts.snap @@ -134,6 +134,7 @@ LiteGraphGlobal { "allow_multi_output_for_events": true, "allow_scripts": false, "alt_drag_do_clone_nodes": false, + "always_round_positions": false, "auto_load_slot_types": false, "auto_sort_node_types": false, "catch_exceptions": true,