mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 22:37:32 +00:00
### Reroute snap highlight When connecting links, a simple border now helps to indicate that a connecting link can be dropped on a reroute below the pointer. ### Reroute ID badges Optionally, intended for debugging purposes, drawing of ID badges can also be manually enabled via console.
7245 lines
220 KiB
TypeScript
7245 lines
220 KiB
TypeScript
import type { ContextMenu } from "./ContextMenu"
|
|
import type {
|
|
CanvasColour,
|
|
ColorOption,
|
|
ConnectingLink,
|
|
ContextMenuDivElement,
|
|
Dictionary,
|
|
Direction,
|
|
IBoundaryNodes,
|
|
IColorable,
|
|
IContextMenuOptions,
|
|
IContextMenuValue,
|
|
INodeInputSlot,
|
|
INodeOutputSlot,
|
|
INodeSlot,
|
|
INodeSlotContextItem,
|
|
ISlotType,
|
|
LinkSegment,
|
|
NullableProperties,
|
|
Point,
|
|
Positionable,
|
|
ReadOnlyPoint,
|
|
ReadOnlyRect,
|
|
Rect,
|
|
Rect32,
|
|
Size,
|
|
} from "./interfaces"
|
|
import type { LGraph } from "./LGraph"
|
|
import type {
|
|
CanvasEventDetail,
|
|
CanvasMouseEvent,
|
|
CanvasPointerEvent,
|
|
CanvasPointerExtensions,
|
|
} from "./types/events"
|
|
import type { ClipboardItems } from "./types/serialisation"
|
|
import type { IWidget } from "./types/widgets"
|
|
|
|
import { LinkConnector } from "@/canvas/LinkConnector"
|
|
|
|
import { isOverNodeInput, isOverNodeOutput } from "./canvas/measureSlots"
|
|
import { CanvasPointer } from "./CanvasPointer"
|
|
import { type AnimationOptions, DragAndScale } from "./DragAndScale"
|
|
import { strokeShape } from "./draw"
|
|
import { NullGraphError } from "./infrastructure/NullGraphError"
|
|
import { LGraphGroup } from "./LGraphGroup"
|
|
import { LGraphNode, type NodeId, type NodeProperty } from "./LGraphNode"
|
|
import { LiteGraph } from "./litegraph"
|
|
import { type LinkId, LLink } from "./LLink"
|
|
import {
|
|
containsRect,
|
|
createBounds,
|
|
distance,
|
|
findPointOnCurve,
|
|
isInRect,
|
|
isInRectangle,
|
|
isPointInRect,
|
|
overlapBounding,
|
|
snapPoint,
|
|
} from "./measure"
|
|
import { type ConnectionColorContext } from "./NodeSlot"
|
|
import { Reroute, type RerouteId } from "./Reroute"
|
|
import { stringOrEmpty } from "./strings"
|
|
import {
|
|
CanvasItem,
|
|
LGraphEventMode,
|
|
LinkDirection,
|
|
LinkMarkerShape,
|
|
LinkRenderType,
|
|
RenderShape,
|
|
TitleMode,
|
|
} from "./types/globalEnums"
|
|
import { alignNodes, distributeNodes, getBoundaryNodes } from "./utils/arrange"
|
|
import { findFirstNode, getAllNestedItems } from "./utils/collections"
|
|
import { toClass } from "./utils/type"
|
|
import { WIDGET_TYPE_MAP } from "./widgets/widgetMap"
|
|
|
|
interface IShowSearchOptions {
|
|
node_to?: LGraphNode | null
|
|
node_from?: LGraphNode | null
|
|
slot_from: number | INodeOutputSlot | INodeInputSlot | null | undefined
|
|
type_filter_in?: ISlotType
|
|
type_filter_out?: ISlotType | false
|
|
|
|
// TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out
|
|
do_type_filter?: boolean
|
|
show_general_if_none_on_typefilter?: boolean
|
|
show_general_after_typefiltered?: boolean
|
|
hide_on_mouse_leave?: boolean
|
|
show_all_if_empty?: boolean
|
|
show_all_on_open?: boolean
|
|
}
|
|
|
|
interface ICreateNodeOptions {
|
|
/** input */
|
|
nodeFrom?: LGraphNode | null
|
|
/** input */
|
|
slotFrom?: number | INodeOutputSlot | INodeInputSlot | null
|
|
/** output */
|
|
nodeTo?: LGraphNode | null
|
|
/** output */
|
|
slotTo?: number | INodeOutputSlot | INodeInputSlot | null
|
|
/** pass the event coords */
|
|
|
|
/** Create the connection from a reroute */
|
|
afterRerouteId?: RerouteId
|
|
|
|
// FIXME: Should not be optional
|
|
/** choose a nodetype to add, AUTO to set at first good */
|
|
nodeType?: string
|
|
e?: CanvasMouseEvent
|
|
allow_searchbox?: boolean
|
|
}
|
|
|
|
interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
|
|
/** Position of new node */
|
|
position: Point
|
|
/** adjust x,y */
|
|
posAdd?: Point
|
|
/** alpha, adjust the position x,y based on the new node size w,h */
|
|
posSizeFix?: Point
|
|
}
|
|
|
|
interface HasShowSearchCallback {
|
|
/** See {@link LGraphCanvas.showSearchBox} */
|
|
showSearchBox: (
|
|
event: MouseEvent,
|
|
options?: IShowSearchOptions,
|
|
) => HTMLDivElement | void
|
|
}
|
|
|
|
interface ICloseable {
|
|
close(): void
|
|
}
|
|
|
|
interface IDialogExtensions extends ICloseable {
|
|
modified(): void
|
|
is_modified: boolean
|
|
}
|
|
|
|
interface IDialog extends HTMLDivElement, IDialogExtensions {}
|
|
type PromptDialog = Omit<IDialog, "modified">
|
|
|
|
interface IDialogOptions {
|
|
position?: Point
|
|
event?: MouseEvent
|
|
checkForInput?: boolean
|
|
closeOnLeave?: boolean
|
|
onclose?(): void
|
|
}
|
|
|
|
/** @inheritdoc {@link LGraphCanvas.state} */
|
|
export interface LGraphCanvasState {
|
|
/** {@link Positionable} items are being dragged on the canvas. */
|
|
draggingItems: boolean
|
|
/** The canvas itself is being dragged. */
|
|
draggingCanvas: boolean
|
|
/** The canvas is read-only, preventing changes to nodes, disconnecting links, moving items, etc. */
|
|
readOnly: boolean
|
|
|
|
/** Bit flags indicating what is currently below the pointer. */
|
|
hoveringOver: CanvasItem
|
|
/** If `true`, pointer move events will set the canvas cursor style. */
|
|
shouldSetCursor: boolean
|
|
}
|
|
|
|
/**
|
|
* The items created by a clipboard paste operation.
|
|
* Includes maps of original copied IDs to newly created items.
|
|
*/
|
|
interface ClipboardPasteResult {
|
|
/** All successfully created items */
|
|
created: Positionable[]
|
|
/** Map: original node IDs to newly created nodes */
|
|
nodes: Map<NodeId, LGraphNode>
|
|
/** Map: original link IDs to new link IDs */
|
|
links: Map<LinkId, LLink>
|
|
/** Map: original reroute IDs to newly created reroutes */
|
|
reroutes: Map<RerouteId, Reroute>
|
|
}
|
|
|
|
/** Options for {@link LGraphCanvas.pasteFromClipboard}. */
|
|
interface IPasteFromClipboardOptions {
|
|
/** If `true`, always attempt to connect inputs of pasted nodes - including to nodes that were not pasted. */
|
|
connectInputs?: boolean
|
|
/** The position to paste the items at. */
|
|
position?: Point
|
|
}
|
|
|
|
interface ICreatePanelOptions {
|
|
closable?: any
|
|
window?: any
|
|
onOpen?: () => void
|
|
onClose?: () => void
|
|
width?: any
|
|
height?: any
|
|
}
|
|
|
|
/**
|
|
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
|
|
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
|
|
*/
|
|
export class LGraphCanvas implements ConnectionColorContext {
|
|
// Optimised buffers used during rendering
|
|
static #temp = new Float32Array(4)
|
|
static #temp_vec2 = new Float32Array(2)
|
|
static #tmp_area = new Float32Array(4)
|
|
static #margin_area = new Float32Array(4)
|
|
static #link_bounding = new Float32Array(4)
|
|
static #lTempA: Point = new Float32Array(2)
|
|
static #lTempB: Point = new Float32Array(2)
|
|
static #lTempC: Point = new Float32Array(2)
|
|
|
|
static DEFAULT_BACKGROUND_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII="
|
|
|
|
static DEFAULT_EVENT_LINK_COLOR = "#A86"
|
|
|
|
/** Link type to colour dictionary. */
|
|
static link_type_colors: Dictionary<string> = {
|
|
"-1": LGraphCanvas.DEFAULT_EVENT_LINK_COLOR,
|
|
"number": "#AAA",
|
|
"node": "#DCA",
|
|
}
|
|
|
|
static gradients: Record<string, CanvasGradient> = {}
|
|
|
|
static search_limit = -1
|
|
static node_colors: Record<string, ColorOption> = {
|
|
red: { color: "#322", bgcolor: "#533", groupcolor: "#A88" },
|
|
brown: { color: "#332922", bgcolor: "#593930", groupcolor: "#b06634" },
|
|
green: { color: "#232", bgcolor: "#353", groupcolor: "#8A8" },
|
|
blue: { color: "#223", bgcolor: "#335", groupcolor: "#88A" },
|
|
pale_blue: {
|
|
color: "#2a363b",
|
|
bgcolor: "#3f5159",
|
|
groupcolor: "#3f789e",
|
|
},
|
|
cyan: { color: "#233", bgcolor: "#355", groupcolor: "#8AA" },
|
|
purple: { color: "#323", bgcolor: "#535", groupcolor: "#a1309b" },
|
|
yellow: { color: "#432", bgcolor: "#653", groupcolor: "#b58b2a" },
|
|
black: { color: "#222", bgcolor: "#000", groupcolor: "#444" },
|
|
}
|
|
|
|
/**
|
|
* The state of this canvas, e.g. whether it is being dragged, or read-only.
|
|
*
|
|
* Implemented as a POCO that can be proxied without side-effects.
|
|
*/
|
|
state: LGraphCanvasState = {
|
|
draggingItems: false,
|
|
draggingCanvas: false,
|
|
readOnly: false,
|
|
hoveringOver: CanvasItem.Nothing,
|
|
shouldSetCursor: true,
|
|
}
|
|
|
|
#updateCursorStyle() {
|
|
if (!this.state.shouldSetCursor) return
|
|
|
|
let cursor = "default"
|
|
if (this.state.draggingCanvas) {
|
|
cursor = "grabbing"
|
|
} else if (this.state.readOnly) {
|
|
cursor = "grab"
|
|
} else if (this.state.hoveringOver & CanvasItem.ResizeSe) {
|
|
cursor = "se-resize"
|
|
} else if (this.state.hoveringOver & CanvasItem.Node) {
|
|
cursor = "crosshair"
|
|
}
|
|
|
|
this.canvas.style.cursor = cursor
|
|
}
|
|
|
|
// 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
|
|
|
|
// #region Legacy accessors
|
|
/** @deprecated @inheritdoc {@link LGraphCanvasState.readOnly} */
|
|
get read_only(): boolean {
|
|
return this.state.readOnly
|
|
}
|
|
|
|
set read_only(value: boolean) {
|
|
this.state.readOnly = value
|
|
this.#updateCursorStyle()
|
|
}
|
|
|
|
get isDragging(): boolean {
|
|
return this.state.draggingItems
|
|
}
|
|
|
|
set isDragging(value: boolean) {
|
|
this.state.draggingItems = value
|
|
}
|
|
|
|
get hoveringOver(): CanvasItem {
|
|
return this.state.hoveringOver
|
|
}
|
|
|
|
set hoveringOver(value: CanvasItem) {
|
|
this.state.hoveringOver = value
|
|
this.#updateCursorStyle()
|
|
}
|
|
|
|
/** @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
|
|
this.#updateCursorStyle()
|
|
}
|
|
// #endregion Legacy accessors
|
|
|
|
/**
|
|
* @deprecated Use {@link LGraphNode.titleFontStyle} instead.
|
|
*/
|
|
get title_text_font(): string {
|
|
return `${LiteGraph.NODE_TEXT_SIZE}px Arial`
|
|
}
|
|
|
|
get inner_text_font(): string {
|
|
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px Arial`
|
|
}
|
|
|
|
#maximumFrameGap = 0
|
|
/** Maximum frames per second to render. 0: unlimited. Default: 0 */
|
|
public get maximumFps() {
|
|
return this.#maximumFrameGap > Number.EPSILON ? this.#maximumFrameGap / 1000 : 0
|
|
}
|
|
|
|
public set maximumFps(value) {
|
|
this.#maximumFrameGap = value > Number.EPSILON ? 1000 / value : 0
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use {@link LiteGraphGlobal.ROUND_RADIUS} instead.
|
|
*/
|
|
get round_radius() {
|
|
return LiteGraph.ROUND_RADIUS
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use {@link LiteGraphGlobal.ROUND_RADIUS} instead.
|
|
*/
|
|
set round_radius(value: number) {
|
|
LiteGraph.ROUND_RADIUS = value
|
|
}
|
|
|
|
/**
|
|
* Render low quality when zoomed out.
|
|
*/
|
|
get low_quality(): boolean {
|
|
return this.ds.scale < this.low_quality_zoom_threshold
|
|
}
|
|
|
|
options: {
|
|
skip_events?: any
|
|
viewport?: any
|
|
skip_render?: any
|
|
autoresize?: any
|
|
}
|
|
|
|
background_image: string
|
|
readonly ds: DragAndScale
|
|
readonly pointer: CanvasPointer
|
|
zoom_modify_alpha: boolean
|
|
zoom_speed: number
|
|
node_title_color: string
|
|
default_link_color: string
|
|
default_connection_color: {
|
|
input_off: string
|
|
input_on: string
|
|
output_off: string
|
|
output_on: string
|
|
}
|
|
|
|
default_connection_color_byType: Dictionary<CanvasColour>
|
|
default_connection_color_byTypeOff: Dictionary<CanvasColour>
|
|
highquality_render: boolean
|
|
use_gradients: boolean
|
|
editor_alpha: number
|
|
pause_rendering: boolean
|
|
clear_background: boolean
|
|
clear_background_color: string
|
|
render_only_selected: boolean
|
|
show_info: boolean
|
|
allow_dragcanvas: boolean
|
|
allow_dragnodes: boolean
|
|
allow_interaction: boolean
|
|
multi_select: boolean
|
|
allow_searchbox: boolean
|
|
allow_reconnect_links: boolean
|
|
align_to_grid: boolean
|
|
drag_mode: boolean
|
|
dragging_rectangle: Rect | null
|
|
filter?: string | null
|
|
set_canvas_dirty_on_mouse_event: boolean
|
|
always_render_background: boolean
|
|
render_shadows: boolean
|
|
render_canvas_border: boolean
|
|
render_connections_shadows: boolean
|
|
render_connections_border: boolean
|
|
render_curved_connections: boolean
|
|
render_connection_arrows: boolean
|
|
render_collapsed_slots: boolean
|
|
render_execution_order: boolean
|
|
render_link_tooltip: boolean
|
|
|
|
/** Shape of the markers shown at the midpoint of links. Default: Circle */
|
|
linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle
|
|
links_render_mode: number
|
|
/** Zoom threshold for low quality rendering. Zoom below this threshold will render low quality. */
|
|
low_quality_zoom_threshold: number = 0.6
|
|
/** mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle */
|
|
readonly mouse: Point
|
|
/** mouse in graph coordinates, where 0,0 is the top-left corner of the blue rectangle */
|
|
readonly graph_mouse: Point
|
|
/** @deprecated LEGACY: REMOVE THIS, USE {@link graph_mouse} INSTEAD */
|
|
canvas_mouse: Point
|
|
/** to personalize the search box */
|
|
onSearchBox?: (helper: Element, str: string, canvas: LGraphCanvas) => any
|
|
onSearchBoxSelection?: (name: any, event: any, canvas: LGraphCanvas) => void
|
|
onMouse?: (e: CanvasMouseEvent) => boolean
|
|
/** to render background objects (behind nodes and connections) in the canvas affected by transform */
|
|
onDrawBackground?: (ctx: CanvasRenderingContext2D, visible_area: any) => void
|
|
/** to render foreground objects (above nodes and connections) in the canvas affected by transform */
|
|
onDrawForeground?: (arg0: CanvasRenderingContext2D, arg1: any) => void
|
|
connections_width: number
|
|
/** The current node being drawn by {@link drawNode}. This should NOT be used to determine the currently selected node. See {@link selectedItems} */
|
|
current_node: LGraphNode | null
|
|
/** used for widgets */
|
|
node_widget?: [LGraphNode, IWidget] | null
|
|
/** The link to draw a tooltip for. */
|
|
over_link_center?: LinkSegment
|
|
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<LinkSegment> = new Set()
|
|
/** @deprecated Replaced by {@link renderedPaths}, but length is set to 0 by some extensions. */
|
|
visible_links: LLink[] = []
|
|
/** @deprecated This array is populated and cleared to support legacy extensions. The contents are ignored by Litegraph. */
|
|
connecting_links: ConnectingLink[] | null
|
|
linkConnector = new LinkConnector(links => this.connecting_links = links)
|
|
/** The viewport of this canvas. Tightly coupled with {@link ds}. */
|
|
readonly viewport?: Rect
|
|
autoresize: boolean
|
|
static active_canvas: LGraphCanvas
|
|
frame = 0
|
|
last_draw_time = 0
|
|
render_time = 0
|
|
fps = 0
|
|
/** @deprecated See {@link LGraphCanvas.selectedItems} */
|
|
selected_nodes: Dictionary<LGraphNode> = {}
|
|
/** All selected nodes, groups, and reroutes */
|
|
selectedItems: Set<Positionable> = new Set()
|
|
/** The group currently being resized. */
|
|
resizingGroup: LGraphGroup | null = null
|
|
/** @deprecated See {@link LGraphCanvas.selectedItems} */
|
|
selected_group: LGraphGroup | null = null
|
|
/** The nodes that are currently visible on the canvas. */
|
|
visible_nodes: LGraphNode[] = []
|
|
/**
|
|
* The IDs of the nodes that are currently visible on the canvas. More
|
|
* performant than {@link visible_nodes} for visibility checks.
|
|
*/
|
|
#visible_node_ids: Set<NodeId> = new Set()
|
|
node_over?: LGraphNode
|
|
node_capturing_input?: LGraphNode | null
|
|
highlighted_links: Dictionary<boolean> = {}
|
|
|
|
dirty_canvas: boolean = true
|
|
dirty_bgcanvas: boolean = true
|
|
/** A map of nodes that require selective-redraw */
|
|
dirty_nodes = new Map<NodeId, LGraphNode>()
|
|
dirty_area?: Rect | null
|
|
/** @deprecated Unused */
|
|
node_in_panel?: LGraphNode | null
|
|
last_mouse: ReadOnlyPoint = [0, 0]
|
|
last_mouseclick: number = 0
|
|
graph: LGraph | null
|
|
canvas: HTMLCanvasElement
|
|
bgcanvas: HTMLCanvasElement
|
|
ctx: CanvasRenderingContext2D
|
|
_events_binded?: boolean
|
|
_mousedown_callback?(e: PointerEvent): void
|
|
_mousewheel_callback?(e: WheelEvent): void
|
|
_mousemove_callback?(e: PointerEvent): void
|
|
_mouseup_callback?(e: PointerEvent): void
|
|
_mouseout_callback?(e: PointerEvent): void
|
|
_mousecancel_callback?(e: PointerEvent): void
|
|
_key_callback?(e: KeyboardEvent): void
|
|
bgctx?: CanvasRenderingContext2D | null
|
|
is_rendering?: boolean
|
|
/** @deprecated Panels */
|
|
block_click?: boolean
|
|
/** @deprecated Panels */
|
|
last_click_position?: Point | null
|
|
resizing_node?: LGraphNode | null
|
|
/** @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
|
|
_highlight_input?: INodeInputSlot
|
|
// TODO: Check if panels are used
|
|
/** @deprecated Panels */
|
|
node_panel?: any
|
|
/** @deprecated Panels */
|
|
options_panel?: any
|
|
_bg_img?: HTMLImageElement
|
|
_pattern?: CanvasPattern | null
|
|
_pattern_img?: HTMLImageElement
|
|
// TODO: This looks like another panel thing
|
|
prompt_box?: PromptDialog | null
|
|
search_box?: HTMLDivElement
|
|
/** @deprecated Panels */
|
|
SELECTED_NODE?: LGraphNode
|
|
/** @deprecated Panels */
|
|
NODEPANEL_IS_OPEN?: boolean
|
|
|
|
/** Once per frame check of snap to grid value. @todo Update on change. */
|
|
#snapToGrid?: number
|
|
/** Set on keydown, keyup. @todo */
|
|
#shiftDown: boolean = false
|
|
|
|
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
|
|
dragZoomEnabled: boolean = false
|
|
/** The start position of the drag zoom. */
|
|
#dragZoomStart: { pos: Point, scale: number } | null = null
|
|
|
|
getMenuOptions?(): IContextMenuValue<string>[]
|
|
getExtraMenuOptions?(
|
|
canvas: LGraphCanvas,
|
|
options: IContextMenuValue<string>[],
|
|
): IContextMenuValue<string>[]
|
|
static active_node: LGraphNode
|
|
/** called before modifying the graph */
|
|
onBeforeChange?(graph: LGraph): void
|
|
/** called after modifying the graph */
|
|
onAfterChange?(graph: LGraph): void
|
|
onClear?: () => void
|
|
/** called after moving a node @deprecated Does not handle multi-node move, and can return the wrong node. */
|
|
onNodeMoved?: (node_dragged: LGraphNode | undefined) => void
|
|
/** called if the selection changes */
|
|
onSelectionChange?: (selected: Dictionary<Positionable>) => void
|
|
/** called when rendering a tooltip */
|
|
onDrawLinkTooltip?: (
|
|
ctx: CanvasRenderingContext2D,
|
|
link: LLink | null,
|
|
canvas?: LGraphCanvas,
|
|
) => boolean
|
|
|
|
/** to render foreground objects not affected by transform (for GUIs) */
|
|
onDrawOverlay?: (ctx: CanvasRenderingContext2D) => void
|
|
onRenderBackground?: (
|
|
canvas: HTMLCanvasElement,
|
|
ctx: CanvasRenderingContext2D,
|
|
) => boolean
|
|
|
|
onNodeDblClicked?: (n: LGraphNode) => void
|
|
onShowNodePanel?: (n: LGraphNode) => void
|
|
onNodeSelected?: (node: LGraphNode) => void
|
|
onNodeDeselected?: (node: LGraphNode) => void
|
|
onRender?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void
|
|
/** Implement this function to allow conversion of widget types to input types, e.g. number -> INT or FLOAT for widget link validation checks */
|
|
getWidgetLinkType?: (
|
|
widget: IWidget,
|
|
node: LGraphNode,
|
|
) => string | null | undefined
|
|
|
|
/**
|
|
* Creates a new instance of LGraphCanvas.
|
|
* @param canvas The canvas HTML element (or its id) to use, or null / undefined to leave blank.
|
|
* @param graph The graph that owns this canvas.
|
|
* @param options
|
|
*/
|
|
constructor(
|
|
canvas: HTMLCanvasElement,
|
|
graph: LGraph,
|
|
options?: LGraphCanvas["options"],
|
|
) {
|
|
options ||= {}
|
|
this.options = options
|
|
|
|
// if(graph === undefined)
|
|
// throw ("No graph assigned");
|
|
this.background_image = LGraphCanvas.DEFAULT_BACKGROUND_IMAGE
|
|
|
|
this.ds = new DragAndScale(canvas)
|
|
this.pointer = new CanvasPointer(canvas)
|
|
|
|
// @deprecated Workaround: Keep until connecting_links is removed.
|
|
this.linkConnector.events.addEventListener("reset", () => {
|
|
this.connecting_links = null
|
|
})
|
|
|
|
// Dropped a link on the canvas
|
|
this.linkConnector.events.addEventListener("dropped-on-canvas", (customEvent) => {
|
|
if (!this.connecting_links) return
|
|
|
|
const e = customEvent.detail
|
|
this.emitEvent({
|
|
subType: "empty-release",
|
|
originalEvent: e,
|
|
linkReleaseContext: { links: this.connecting_links },
|
|
})
|
|
|
|
const firstLink = this.linkConnector.renderLinks[0]
|
|
|
|
// No longer in use
|
|
// add menu when releasing link in empty space
|
|
if (LiteGraph.release_link_on_empty_shows_menu) {
|
|
const linkReleaseContext = this.linkConnector.state.connectingTo === "input"
|
|
? {
|
|
node_from: firstLink.node,
|
|
slot_from: firstLink.fromSlot,
|
|
type_filter_in: firstLink.fromSlot.type,
|
|
}
|
|
: {
|
|
node_to: firstLink.node,
|
|
slot_from: firstLink.fromSlot,
|
|
type_filter_out: firstLink.fromSlot.type,
|
|
}
|
|
|
|
if ("shiftKey" in e && e.shiftKey) {
|
|
if (this.allow_searchbox) {
|
|
this.showSearchBox(e as unknown as MouseEvent, linkReleaseContext)
|
|
}
|
|
} else if (this.linkConnector.state.connectingTo === "input") {
|
|
this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.fromSlot, e })
|
|
} else {
|
|
this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.fromSlot, e })
|
|
}
|
|
}
|
|
})
|
|
|
|
// otherwise it generates ugly patterns when scaling down too much
|
|
this.zoom_modify_alpha = true
|
|
// in range (1.01, 2.5). Less than 1 will invert the zoom direction
|
|
this.zoom_speed = 1.1
|
|
|
|
this.node_title_color = LiteGraph.NODE_TITLE_COLOR
|
|
this.default_link_color = LiteGraph.LINK_COLOR
|
|
this.default_connection_color = {
|
|
input_off: "#778",
|
|
input_on: "#7F7",
|
|
output_off: "#778",
|
|
output_on: "#7F7",
|
|
}
|
|
this.default_connection_color_byType = {
|
|
/* number: "#7F7",
|
|
string: "#77F",
|
|
boolean: "#F77", */
|
|
}
|
|
this.default_connection_color_byTypeOff = {
|
|
/* number: "#474",
|
|
string: "#447",
|
|
boolean: "#744", */
|
|
}
|
|
|
|
this.highquality_render = true
|
|
// set to true to render titlebar with gradients
|
|
this.use_gradients = false
|
|
// used for transition
|
|
this.editor_alpha = 1
|
|
this.pause_rendering = false
|
|
this.clear_background = true
|
|
this.clear_background_color = "#222"
|
|
|
|
this.render_only_selected = true
|
|
this.show_info = true
|
|
this.allow_dragcanvas = true
|
|
this.allow_dragnodes = true
|
|
// allow to control widgets, buttons, collapse, etc
|
|
this.allow_interaction = true
|
|
// allow selecting multi nodes without pressing extra keys
|
|
this.multi_select = false
|
|
this.allow_searchbox = true
|
|
// allows to change a connection with having to redo it again
|
|
this.allow_reconnect_links = true
|
|
// snap to grid
|
|
this.align_to_grid = false
|
|
|
|
this.drag_mode = false
|
|
this.dragging_rectangle = null
|
|
|
|
// allows to filter to only accept some type of nodes in a graph
|
|
this.filter = null
|
|
|
|
// forces to redraw the canvas on mouse events (except move)
|
|
this.set_canvas_dirty_on_mouse_event = true
|
|
this.always_render_background = false
|
|
this.render_shadows = true
|
|
this.render_canvas_border = true
|
|
// too much cpu
|
|
this.render_connections_shadows = false
|
|
this.render_connections_border = true
|
|
this.render_curved_connections = false
|
|
this.render_connection_arrows = false
|
|
this.render_collapsed_slots = true
|
|
this.render_execution_order = false
|
|
this.render_link_tooltip = true
|
|
|
|
this.links_render_mode = LinkRenderType.SPLINE_LINK
|
|
|
|
this.mouse = [0, 0]
|
|
this.graph_mouse = [0, 0]
|
|
this.canvas_mouse = this.graph_mouse
|
|
|
|
this.connections_width = 3
|
|
|
|
this.current_node = null
|
|
this.node_widget = null
|
|
this.last_mouse_position = [0, 0]
|
|
this.visible_area = this.ds.visible_area
|
|
// Explicitly null-checked
|
|
this.connecting_links = null
|
|
|
|
// to constraint render area to a portion of the canvas
|
|
this.viewport = options.viewport || null
|
|
|
|
// link canvas and graph
|
|
this.graph = graph
|
|
graph?.attachCanvas(this)
|
|
|
|
// TypeScript strict workaround: cannot use method to initialize properties.
|
|
this.canvas = undefined!
|
|
this.bgcanvas = undefined!
|
|
this.ctx = undefined!
|
|
|
|
this.setCanvas(canvas, options.skip_events)
|
|
this.clear()
|
|
|
|
if (!options.skip_render) {
|
|
this.startRendering()
|
|
}
|
|
|
|
this.autoresize = options.autoresize
|
|
}
|
|
|
|
static onGroupAdd(info: unknown, entry: unknown, mouse_event: MouseEvent): void {
|
|
const canvas = LGraphCanvas.active_canvas
|
|
|
|
const group = new LiteGraph.LGraphGroup()
|
|
group.pos = canvas.convertEventToCanvasOffset(mouse_event)
|
|
if (!canvas.graph) throw new NullGraphError()
|
|
canvas.graph.add(group)
|
|
}
|
|
|
|
/**
|
|
* @deprecated Functionality moved to {@link getBoundaryNodes}. The new function returns null on failure, instead of an object with all null properties.
|
|
* Determines the furthest nodes in each direction
|
|
* @param nodes the nodes to from which boundary nodes will be extracted
|
|
* @returns
|
|
*/
|
|
static getBoundaryNodes(
|
|
nodes: LGraphNode[] | Dictionary<LGraphNode>,
|
|
): NullableProperties<IBoundaryNodes> {
|
|
const _nodes = Array.isArray(nodes) ? nodes : Object.values(nodes)
|
|
return (
|
|
getBoundaryNodes(_nodes) ?? {
|
|
top: null,
|
|
right: null,
|
|
bottom: null,
|
|
left: null,
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @deprecated Functionality moved to {@link alignNodes}. The new function does not set dirty canvas.
|
|
* @param nodes a list of nodes
|
|
* @param direction Direction to align the nodes
|
|
* @param align_to Node to align to (if null, align to the furthest node in the given direction)
|
|
*/
|
|
static alignNodes(
|
|
nodes: Dictionary<LGraphNode>,
|
|
direction: Direction,
|
|
align_to?: LGraphNode,
|
|
): void {
|
|
alignNodes(Object.values(nodes), direction, align_to)
|
|
LGraphCanvas.active_canvas.setDirty(true, true)
|
|
}
|
|
|
|
static onNodeAlign(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
event: MouseEvent,
|
|
prev_menu: ContextMenu<string>,
|
|
node: LGraphNode,
|
|
): void {
|
|
new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], {
|
|
event,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
})
|
|
|
|
function inner_clicked(value: string) {
|
|
alignNodes(
|
|
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
|
value.toLowerCase() as Direction,
|
|
node,
|
|
)
|
|
LGraphCanvas.active_canvas.setDirty(true, true)
|
|
}
|
|
}
|
|
|
|
static onGroupAlign(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
event: MouseEvent,
|
|
prev_menu: ContextMenu<string>,
|
|
): void {
|
|
new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], {
|
|
event,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
})
|
|
|
|
function inner_clicked(value: string) {
|
|
alignNodes(
|
|
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
|
value.toLowerCase() as Direction,
|
|
)
|
|
LGraphCanvas.active_canvas.setDirty(true, true)
|
|
}
|
|
}
|
|
|
|
static createDistributeMenu(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
event: MouseEvent,
|
|
prev_menu: ContextMenu<string>,
|
|
): void {
|
|
new LiteGraph.ContextMenu(["Vertically", "Horizontally"], {
|
|
event,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
})
|
|
|
|
function inner_clicked(value: string) {
|
|
const canvas = LGraphCanvas.active_canvas
|
|
distributeNodes(Object.values(canvas.selected_nodes), value === "Horizontally")
|
|
canvas.setDirty(true, true)
|
|
}
|
|
}
|
|
|
|
static onMenuAdd(
|
|
value: unknown,
|
|
options: unknown,
|
|
e: MouseEvent,
|
|
prev_menu?: ContextMenu<string>,
|
|
callback?: (node: LGraphNode | null) => void,
|
|
): boolean | undefined {
|
|
const canvas = LGraphCanvas.active_canvas
|
|
const ref_window = canvas.getCanvasWindow()
|
|
const { graph } = canvas
|
|
if (!graph) return
|
|
|
|
inner_onMenuAdded("", prev_menu)
|
|
return false
|
|
|
|
type AddNodeMenu = Omit<IContextMenuValue<string>, "callback"> & {
|
|
callback: (
|
|
value: { value: string },
|
|
event: Event,
|
|
mouseEvent: MouseEvent,
|
|
contextMenu: ContextMenu<string>
|
|
) => void
|
|
}
|
|
|
|
function inner_onMenuAdded(base_category: string, prev_menu?: ContextMenu<string>): void {
|
|
if (!graph) return
|
|
|
|
const categories = LiteGraph
|
|
.getNodeTypesCategories(canvas.filter || graph.filter)
|
|
.filter(category => category.startsWith(base_category))
|
|
const entries: AddNodeMenu[] = []
|
|
|
|
for (const category of categories) {
|
|
if (!category) continue
|
|
|
|
const base_category_regex = new RegExp(`^(${base_category})`)
|
|
const category_name = category
|
|
.replace(base_category_regex, "")
|
|
.split("/", 1)[0]
|
|
const category_path =
|
|
base_category === ""
|
|
? `${category_name}/`
|
|
: `${base_category}${category_name}/`
|
|
|
|
let name = category_name
|
|
// in case it has a namespace like "shader::math/rand" it hides the namespace
|
|
if (name.includes("::")) name = name.split("::", 2)[1]
|
|
|
|
const index = entries.findIndex(entry => entry.value === category_path)
|
|
if (index === -1) {
|
|
entries.push({
|
|
value: category_path,
|
|
content: name,
|
|
has_submenu: true,
|
|
callback: function (value, event, mouseEvent, contextMenu) {
|
|
inner_onMenuAdded(value.value, contextMenu)
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const nodes = LiteGraph.getNodeTypesInCategory(
|
|
base_category.slice(0, -1),
|
|
canvas.filter || graph.filter,
|
|
)
|
|
|
|
for (const node of nodes) {
|
|
if (node.skip_list) continue
|
|
|
|
const entry: AddNodeMenu = {
|
|
value: node.type,
|
|
content: node.title,
|
|
has_submenu: false,
|
|
callback: function (value, event, mouseEvent, contextMenu) {
|
|
if (!canvas.graph) throw new NullGraphError()
|
|
|
|
const first_event = contextMenu.getFirstEvent()
|
|
canvas.graph.beforeChange()
|
|
const node = LiteGraph.createNode(value.value)
|
|
if (node) {
|
|
if (!first_event) throw new TypeError("Context menu event was null. This should not occure in normal usage.")
|
|
node.pos = canvas.convertEventToCanvasOffset(first_event)
|
|
canvas.graph.add(node)
|
|
}
|
|
|
|
callback?.(node)
|
|
canvas.graph.afterChange()
|
|
},
|
|
}
|
|
|
|
entries.push(entry)
|
|
}
|
|
|
|
// @ts-expect-error Remove param ref_window - unused
|
|
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu }, ref_window)
|
|
}
|
|
}
|
|
|
|
static onMenuCollapseAll() {}
|
|
static onMenuNodeEdit() {}
|
|
|
|
/** @param _options Parameter is never used */
|
|
static showMenuNodeOptionalOutputs(
|
|
v: unknown,
|
|
/** Unused - immediately overwritten */
|
|
_options: INodeOutputSlot[],
|
|
e: MouseEvent,
|
|
prev_menu: ContextMenu<INodeSlotContextItem>,
|
|
node: LGraphNode,
|
|
): boolean | undefined {
|
|
if (!node) return
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
|
|
let entries: (IContextMenuValue<INodeSlotContextItem> | null)[] = []
|
|
|
|
if (LiteGraph.do_add_triggers_slots && node.findOutputSlot("onExecuted") == -1) {
|
|
entries.push({ content: "On Executed", value: ["onExecuted", LiteGraph.EVENT, { nameLocked: true }], className: "event" })
|
|
}
|
|
// add callback for modifing the menu elements onMenuNodeOutputs
|
|
const retEntries = node.onMenuNodeOutputs?.(entries)
|
|
if (retEntries) entries = retEntries
|
|
|
|
if (!entries.length) return
|
|
|
|
new LiteGraph.ContextMenu<INodeSlotContextItem>(
|
|
entries,
|
|
{
|
|
event: e,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
node,
|
|
},
|
|
)
|
|
|
|
function inner_clicked(this: ContextMenuDivElement<INodeSlotContextItem>, v: IContextMenuValue<INodeSlotContextItem>, e: any, prev: any) {
|
|
if (!node) return
|
|
|
|
// TODO: This is a static method, so the below "that" appears broken.
|
|
if (v.callback) v.callback.call(this, node, v, e, prev)
|
|
|
|
if (!v.value) return
|
|
|
|
const value = v.value[1]
|
|
|
|
if (value &&
|
|
(typeof value === "object" || Array.isArray(value))) {
|
|
// submenu why?
|
|
const entries = []
|
|
for (const i in value) {
|
|
entries.push({ content: i, value: value[i] })
|
|
}
|
|
new LiteGraph.ContextMenu(entries, {
|
|
event: e,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
node,
|
|
})
|
|
return false
|
|
}
|
|
|
|
const { graph } = node
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
graph.beforeChange()
|
|
node.addOutput(v.value[0], v.value[1], v.value[2])
|
|
|
|
// a callback to the node when adding a slot
|
|
node.onNodeOutputAdd?.(v.value)
|
|
canvas.setDirty(true, true)
|
|
graph.afterChange()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/** @param value Parameter is never used */
|
|
static onShowMenuNodeProperties(
|
|
value: NodeProperty | undefined,
|
|
options: unknown,
|
|
e: MouseEvent,
|
|
prev_menu: ContextMenu<string>,
|
|
node: LGraphNode,
|
|
): boolean | undefined {
|
|
if (!node || !node.properties) return
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
const ref_window = canvas.getCanvasWindow()
|
|
|
|
const entries: IContextMenuValue<string>[] = []
|
|
for (const i in node.properties) {
|
|
value = node.properties[i] !== undefined ? node.properties[i] : " "
|
|
if (typeof value == "object")
|
|
value = JSON.stringify(value)
|
|
const info = node.getPropertyInfo(i)
|
|
if (info.type == "enum" || info.type == "combo")
|
|
value = LGraphCanvas.getPropertyPrintableValue(value, info.values)
|
|
|
|
// value could contain invalid html characters, clean that
|
|
value = LGraphCanvas.decodeHTML(stringOrEmpty(value))
|
|
entries.push({
|
|
content:
|
|
`<span class='property_name'>${info.label || i}</span>` +
|
|
`<span class='property_value'>${value}</span>`,
|
|
value: i,
|
|
})
|
|
}
|
|
if (!entries.length) {
|
|
return
|
|
}
|
|
|
|
new LiteGraph.ContextMenu<string>(
|
|
entries,
|
|
{
|
|
event: e,
|
|
callback: inner_clicked,
|
|
parentMenu: prev_menu,
|
|
allow_html: true,
|
|
node,
|
|
},
|
|
// @ts-expect-error Unused
|
|
ref_window,
|
|
)
|
|
|
|
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
|
|
if (!node) return
|
|
|
|
const rect = this.getBoundingClientRect()
|
|
canvas.showEditPropertyValue(node, v.value, {
|
|
position: [rect.left, rect.top],
|
|
})
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/** @deprecated */
|
|
static decodeHTML(str: string): string {
|
|
const e = document.createElement("div")
|
|
e.textContent = str
|
|
return e.innerHTML
|
|
}
|
|
|
|
static onMenuResizeNode(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu,
|
|
node: LGraphNode,
|
|
): void {
|
|
if (!node) return
|
|
|
|
const fApplyMultiNode = function (node: LGraphNode) {
|
|
node.setSize(node.computeSize())
|
|
}
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node)
|
|
} else {
|
|
for (const i in canvas.selected_nodes) {
|
|
fApplyMultiNode(canvas.selected_nodes[i])
|
|
}
|
|
}
|
|
|
|
canvas.setDirty(true, true)
|
|
}
|
|
|
|
// TODO refactor :: this is used fot title but not for properties!
|
|
static onShowPropertyEditor(
|
|
item: { property: keyof LGraphNode, type: string },
|
|
options: IContextMenuOptions<string>,
|
|
e: MouseEvent,
|
|
menu: ContextMenu<string>,
|
|
node: LGraphNode,
|
|
): void {
|
|
const property = item.property || "title"
|
|
const value = node[property]
|
|
|
|
const title = document.createElement("span")
|
|
title.className = "name"
|
|
title.textContent = property
|
|
|
|
const input = document.createElement("input")
|
|
Object.assign(input, { type: "text", className: "value", autofocus: true })
|
|
|
|
const button = document.createElement("button")
|
|
button.textContent = "OK"
|
|
|
|
// TODO refactor :: use createDialog ?
|
|
const dialog = Object.assign(document.createElement("div"), {
|
|
is_modified: false,
|
|
className: "graphdialog",
|
|
close: () => dialog.remove(),
|
|
})
|
|
dialog.append(title, input, button)
|
|
|
|
input.value = String(value)
|
|
input.addEventListener("blur", function () {
|
|
this.focus()
|
|
})
|
|
input.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
dialog.is_modified = true
|
|
if (e.key == "Escape") {
|
|
// ESC
|
|
dialog.close()
|
|
} else if (e.key == "Enter") {
|
|
// save
|
|
inner()
|
|
} else if (!e.target || !("localName" in e.target) || e.target.localName != "textarea") {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
const canvasEl = canvas.canvas
|
|
|
|
const rect = canvasEl.getBoundingClientRect()
|
|
const offsetx = rect ? -20 - rect.left : -20
|
|
const offsety = rect ? -20 - rect.top : -20
|
|
|
|
if (e) {
|
|
dialog.style.left = `${e.clientX + offsetx}px`
|
|
dialog.style.top = `${e.clientY + offsety}px`
|
|
} else {
|
|
dialog.style.left = `${canvasEl.width * 0.5 + offsetx}px`
|
|
dialog.style.top = `${canvasEl.height * 0.5 + offsety}px`
|
|
}
|
|
|
|
button.addEventListener("click", inner)
|
|
|
|
if (canvasEl.parentNode == null) throw new TypeError("canvasEl.parentNode was null")
|
|
canvasEl.parentNode.append(dialog)
|
|
|
|
input.focus()
|
|
|
|
let dialogCloseTimer: number
|
|
dialog.addEventListener("mouseleave", function () {
|
|
if (LiteGraph.dialog_close_on_mouse_leave) {
|
|
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
|
dialogCloseTimer = setTimeout(
|
|
dialog.close,
|
|
LiteGraph.dialog_close_on_mouse_leave_delay,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
dialog.addEventListener("mouseenter", function () {
|
|
if (LiteGraph.dialog_close_on_mouse_leave) {
|
|
if (dialogCloseTimer) clearTimeout(dialogCloseTimer)
|
|
}
|
|
})
|
|
|
|
function inner() {
|
|
if (input) setValue(input.value)
|
|
}
|
|
|
|
function setValue(value: NodeProperty) {
|
|
if (item.type == "Number") {
|
|
value = Number(value)
|
|
} else if (item.type == "Boolean") {
|
|
value = Boolean(value)
|
|
}
|
|
// @ts-expect-error Requires refactor.
|
|
node[property] = value
|
|
dialog.remove()
|
|
canvas.setDirty(true, true)
|
|
}
|
|
}
|
|
|
|
static getPropertyPrintableValue(value: unknown, values: unknown[] | object | undefined): string | undefined {
|
|
if (!values) return String(value)
|
|
|
|
if (Array.isArray(values)) {
|
|
return String(value)
|
|
}
|
|
|
|
if (typeof values === "object") {
|
|
let desc_value = ""
|
|
for (const k in values) {
|
|
// @ts-expect-error deprecated #578
|
|
if (values[k] != value) continue
|
|
|
|
desc_value = k
|
|
break
|
|
}
|
|
return `${String(value)} (${desc_value})`
|
|
}
|
|
}
|
|
|
|
static onMenuNodeCollapse(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu,
|
|
node: LGraphNode,
|
|
): void {
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.beforeChange()
|
|
|
|
const fApplyMultiNode = function (node: LGraphNode) {
|
|
node.collapse()
|
|
}
|
|
|
|
const graphcanvas = LGraphCanvas.active_canvas
|
|
if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node)
|
|
} else {
|
|
for (const i in graphcanvas.selected_nodes) {
|
|
fApplyMultiNode(graphcanvas.selected_nodes[i])
|
|
}
|
|
}
|
|
|
|
node.graph.afterChange()
|
|
}
|
|
|
|
static onMenuToggleAdvanced(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu,
|
|
node: LGraphNode,
|
|
): void {
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.beforeChange()
|
|
const fApplyMultiNode = function (node: LGraphNode) {
|
|
node.toggleAdvanced()
|
|
}
|
|
|
|
const graphcanvas = LGraphCanvas.active_canvas
|
|
if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node)
|
|
} else {
|
|
for (const i in graphcanvas.selected_nodes) {
|
|
fApplyMultiNode(graphcanvas.selected_nodes[i])
|
|
}
|
|
}
|
|
node.graph.afterChange()
|
|
}
|
|
|
|
static onMenuNodeMode(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu,
|
|
node: LGraphNode,
|
|
): boolean {
|
|
new LiteGraph.ContextMenu(
|
|
LiteGraph.NODE_MODES,
|
|
{ event: e, callback: inner_clicked, parentMenu: menu, node },
|
|
)
|
|
|
|
function inner_clicked(v: string) {
|
|
if (!node) return
|
|
|
|
const kV = Object.values(LiteGraph.NODE_MODES).indexOf(v)
|
|
const fApplyMultiNode = function (node: LGraphNode) {
|
|
if (kV !== -1 && LiteGraph.NODE_MODES[kV]) {
|
|
node.changeMode(kV)
|
|
} else {
|
|
console.warn(`unexpected mode: ${v}`)
|
|
node.changeMode(LGraphEventMode.ALWAYS)
|
|
}
|
|
}
|
|
|
|
const graphcanvas = LGraphCanvas.active_canvas
|
|
if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node)
|
|
} else {
|
|
for (const i in graphcanvas.selected_nodes) {
|
|
fApplyMultiNode(graphcanvas.selected_nodes[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/** @param value Parameter is never used */
|
|
static onMenuNodeColors(
|
|
value: IContextMenuValue<string | null>,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu<string | null>,
|
|
node: LGraphNode,
|
|
): boolean {
|
|
if (!node) throw "no node for color"
|
|
|
|
const values: IContextMenuValue<string | null, unknown, { value: string | null }>[] = []
|
|
values.push({
|
|
value: null,
|
|
content: "<span style='display: block; padding-left: 4px;'>No color</span>",
|
|
})
|
|
|
|
for (const i in LGraphCanvas.node_colors) {
|
|
const color = LGraphCanvas.node_colors[i]
|
|
value = {
|
|
value: i,
|
|
content: `<span style='display: block; color: #999; padding-left: 4px;` +
|
|
` border-left: 8px solid ${color.color}; background-color:${color.bgcolor}'>${i}</span>`,
|
|
}
|
|
values.push(value)
|
|
}
|
|
new LiteGraph.ContextMenu<string | null>(values, {
|
|
event: e,
|
|
callback: inner_clicked,
|
|
parentMenu: menu,
|
|
node,
|
|
})
|
|
|
|
function inner_clicked(v: IContextMenuValue<string>) {
|
|
if (!node) return
|
|
|
|
const fApplyColor = function (item: IColorable) {
|
|
const colorOption = v.value ? LGraphCanvas.node_colors[v.value] : null
|
|
item.setColorOption(colorOption)
|
|
}
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) {
|
|
fApplyColor(node)
|
|
} else {
|
|
for (const i in canvas.selected_nodes) {
|
|
fApplyColor(canvas.selected_nodes[i])
|
|
}
|
|
}
|
|
canvas.setDirty(true, true)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static onMenuNodeShapes(
|
|
value: IContextMenuValue<typeof LiteGraph.VALID_SHAPES[number]>,
|
|
options: IContextMenuOptions<typeof LiteGraph.VALID_SHAPES[number]>,
|
|
e: MouseEvent,
|
|
menu?: ContextMenu<typeof LiteGraph.VALID_SHAPES[number]>,
|
|
node?: LGraphNode,
|
|
): boolean {
|
|
if (!node) throw "no node passed"
|
|
|
|
new LiteGraph.ContextMenu<typeof LiteGraph.VALID_SHAPES[number]>(LiteGraph.VALID_SHAPES, {
|
|
event: e,
|
|
callback: inner_clicked,
|
|
parentMenu: menu,
|
|
node,
|
|
})
|
|
|
|
function inner_clicked(v: typeof LiteGraph.VALID_SHAPES[number]) {
|
|
if (!node) return
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.beforeChange()
|
|
|
|
const fApplyMultiNode = function (node: LGraphNode) {
|
|
node.shape = v
|
|
}
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node)
|
|
} else {
|
|
for (const i in canvas.selected_nodes) {
|
|
fApplyMultiNode(canvas.selected_nodes[i])
|
|
}
|
|
}
|
|
|
|
node.graph.afterChange()
|
|
canvas.setDirty(true)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static onMenuNodeRemove(): void {
|
|
LGraphCanvas.active_canvas.deleteSelected()
|
|
}
|
|
|
|
static onMenuNodeClone(
|
|
value: IContextMenuValue,
|
|
options: IContextMenuOptions,
|
|
e: MouseEvent,
|
|
menu: ContextMenu,
|
|
node: LGraphNode,
|
|
): void {
|
|
const { graph } = node
|
|
if (!graph) throw new NullGraphError()
|
|
graph.beforeChange()
|
|
|
|
const newSelected = new Set<LGraphNode>()
|
|
|
|
const fApplyMultiNode = function (node: LGraphNode, newNodes: Set<LGraphNode>): void {
|
|
if (node.clonable === false) return
|
|
|
|
const newnode = node.clone()
|
|
if (!newnode) return
|
|
|
|
newnode.pos = [node.pos[0] + 5, node.pos[1] + 5]
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.add(newnode)
|
|
newNodes.add(newnode)
|
|
}
|
|
|
|
const canvas = LGraphCanvas.active_canvas
|
|
if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) {
|
|
fApplyMultiNode(node, newSelected)
|
|
} else {
|
|
for (const i in canvas.selected_nodes) {
|
|
fApplyMultiNode(canvas.selected_nodes[i], newSelected)
|
|
}
|
|
}
|
|
|
|
if (newSelected.size) {
|
|
canvas.selectNodes([...newSelected])
|
|
}
|
|
|
|
graph.afterChange()
|
|
|
|
canvas.setDirty(true, true)
|
|
}
|
|
|
|
/**
|
|
* clears all the data inside
|
|
*
|
|
*/
|
|
clear(): void {
|
|
this.frame = 0
|
|
this.last_draw_time = 0
|
|
this.render_time = 0
|
|
this.fps = 0
|
|
|
|
// this.scale = 1;
|
|
// this.offset = [0,0];
|
|
this.dragging_rectangle = null
|
|
|
|
this.selected_nodes = {}
|
|
this.selected_group = null
|
|
this.selectedItems.clear()
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
|
|
this.visible_nodes = []
|
|
this.node_over = undefined
|
|
this.node_capturing_input = null
|
|
this.connecting_links = null
|
|
this.highlighted_links = {}
|
|
|
|
this.dragging_canvas = false
|
|
|
|
this.#dirty()
|
|
this.dirty_area = null
|
|
|
|
this.node_in_panel = null
|
|
this.node_widget = null
|
|
|
|
this.last_mouse = [0, 0]
|
|
this.last_mouseclick = 0
|
|
this.pointer.reset()
|
|
this.visible_area.set([0, 0, 0, 0])
|
|
|
|
this.onClear?.()
|
|
}
|
|
|
|
/**
|
|
* assigns a graph, you can reassign graphs to the same canvas
|
|
* @param graph
|
|
*/
|
|
setGraph(graph: LGraph, skip_clear: boolean): void {
|
|
if (this.graph == graph) return
|
|
|
|
if (!skip_clear) this.clear()
|
|
|
|
if (!graph && this.graph) {
|
|
this.graph.detachCanvas(this)
|
|
return
|
|
}
|
|
|
|
graph.attachCanvas(this)
|
|
|
|
this.setDirty(true, true)
|
|
}
|
|
|
|
/**
|
|
* @returns the visually active graph (in case there are more in the stack)
|
|
*/
|
|
getCurrentGraph(): LGraph | null {
|
|
return this.graph
|
|
}
|
|
|
|
/**
|
|
* Finds the canvas if required, throwing on failure.
|
|
* @param canvas Canvas element, or its element ID
|
|
* @returns The canvas element
|
|
* @throws If {@link canvas} is an element ID that does not belong to a valid HTML canvas element
|
|
*/
|
|
#validateCanvas(
|
|
canvas: string | HTMLCanvasElement,
|
|
): HTMLCanvasElement & { data?: LGraphCanvas } {
|
|
if (typeof canvas === "string") {
|
|
const el = document.getElementById(canvas)
|
|
if (!(el instanceof HTMLCanvasElement)) throw "Error validating LiteGraph canvas: Canvas element not found"
|
|
return el
|
|
}
|
|
return canvas
|
|
}
|
|
|
|
/**
|
|
* Sets the current HTML canvas element.
|
|
* Calls bindEvents to add input event listeners, and (re)creates the background canvas.
|
|
* @param canvas The canvas element to assign, or its HTML element ID. If null or undefined, the current reference is cleared.
|
|
* @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation.
|
|
*/
|
|
setCanvas(canvas: string | HTMLCanvasElement, skip_events?: boolean) {
|
|
const element = this.#validateCanvas(canvas)
|
|
if (element === this.canvas) return
|
|
// maybe detach events from old_canvas
|
|
if (!element && this.canvas && !skip_events) this.unbindEvents()
|
|
|
|
this.canvas = element
|
|
this.ds.element = element
|
|
this.pointer.element = element
|
|
|
|
if (!element) return
|
|
|
|
// TODO: classList.add
|
|
element.className += " lgraphcanvas"
|
|
element.data = this
|
|
|
|
// Background canvas: To render objects behind nodes (background, links, groups)
|
|
this.bgcanvas = document.createElement("canvas")
|
|
this.bgcanvas.width = this.canvas.width
|
|
this.bgcanvas.height = this.canvas.height
|
|
|
|
const ctx = element.getContext?.("2d")
|
|
if (ctx == null) {
|
|
if (element.localName != "canvas") {
|
|
throw `Element supplied for LGraphCanvas must be a <canvas> element, you passed a ${element.localName}`
|
|
}
|
|
throw "This browser doesn't support Canvas"
|
|
}
|
|
this.ctx = ctx
|
|
|
|
if (!skip_events) this.bindEvents()
|
|
}
|
|
|
|
/** Captures an event and prevents default - returns false. */
|
|
_doNothing(e: Event): boolean {
|
|
// console.log("pointerevents: _doNothing "+e.type);
|
|
e.preventDefault()
|
|
return false
|
|
}
|
|
|
|
/** Captures an event and prevents default - returns true. */
|
|
_doReturnTrue(e: Event): boolean {
|
|
e.preventDefault()
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* binds mouse, keyboard, touch and drag events to the canvas
|
|
*/
|
|
bindEvents(): void {
|
|
if (this._events_binded) {
|
|
console.warn("LGraphCanvas: events already binded")
|
|
return
|
|
}
|
|
|
|
const { canvas } = this
|
|
// hack used when moving canvas between windows
|
|
const { document } = this.getCanvasWindow()
|
|
|
|
this._mousedown_callback = this.processMouseDown.bind(this)
|
|
this._mousewheel_callback = this.processMouseWheel.bind(this)
|
|
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)
|
|
|
|
canvas.addEventListener("pointerdown", this._mousedown_callback, true)
|
|
canvas.addEventListener("wheel", this._mousewheel_callback, false)
|
|
|
|
canvas.addEventListener("pointerup", this._mouseup_callback, true)
|
|
canvas.addEventListener("pointermove", this._mousemove_callback)
|
|
canvas.addEventListener("pointerout", this._mouseout_callback)
|
|
canvas.addEventListener("pointercancel", this._mousecancel_callback, true)
|
|
|
|
canvas.addEventListener("contextmenu", this._doNothing)
|
|
|
|
// Keyboard
|
|
this._key_callback = this.processKey.bind(this)
|
|
|
|
canvas.addEventListener("keydown", this._key_callback, true)
|
|
// keyup event must be bound on the document
|
|
document.addEventListener("keyup", this._key_callback, true)
|
|
|
|
canvas.addEventListener("dragover", this._doNothing, false)
|
|
canvas.addEventListener("dragend", this._doNothing, false)
|
|
canvas.addEventListener("dragenter", this._doReturnTrue, false)
|
|
|
|
this._events_binded = true
|
|
}
|
|
|
|
/**
|
|
* unbinds mouse events from the canvas
|
|
*/
|
|
unbindEvents(): void {
|
|
if (!this._events_binded) {
|
|
console.warn("LGraphCanvas: no events binded")
|
|
return
|
|
}
|
|
|
|
// console.log("pointerevents: unbindEvents");
|
|
const { document } = this.getCanvasWindow()
|
|
const { canvas } = this
|
|
|
|
// Assertions: removing nullish is fine.
|
|
canvas.removeEventListener("pointercancel", this._mousecancel_callback!)
|
|
canvas.removeEventListener("pointerout", this._mouseout_callback!)
|
|
canvas.removeEventListener("pointermove", this._mousemove_callback!)
|
|
canvas.removeEventListener("pointerup", this._mouseup_callback!)
|
|
canvas.removeEventListener("pointerdown", this._mousedown_callback!)
|
|
canvas.removeEventListener("wheel", this._mousewheel_callback!)
|
|
canvas.removeEventListener("keydown", this._key_callback!)
|
|
document.removeEventListener("keyup", this._key_callback!)
|
|
canvas.removeEventListener("contextmenu", this._doNothing)
|
|
canvas.removeEventListener("dragenter", this._doReturnTrue)
|
|
|
|
this._mousedown_callback = undefined
|
|
this._mousewheel_callback = undefined
|
|
this._key_callback = undefined
|
|
|
|
this._events_binded = false
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @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
|
|
if (bgcanvas) this.dirty_bgcanvas = true
|
|
}
|
|
|
|
/** Marks the entire canvas as dirty. */
|
|
#dirty(): void {
|
|
this.dirty_canvas = true
|
|
this.dirty_bgcanvas = true
|
|
}
|
|
|
|
/**
|
|
* Used to attach the canvas in a popup
|
|
* @returns returns the window where the canvas is attached (the DOM root node)
|
|
*/
|
|
getCanvasWindow(): Window {
|
|
if (!this.canvas) return window
|
|
|
|
const doc = this.canvas.ownerDocument
|
|
// @ts-expect-error Check if required
|
|
return doc.defaultView || doc.parentWindow
|
|
}
|
|
|
|
/**
|
|
* starts rendering the content of the canvas when needed
|
|
*
|
|
*/
|
|
startRendering(): void {
|
|
// already rendering
|
|
if (this.is_rendering) return
|
|
|
|
this.is_rendering = true
|
|
renderFrame.call(this)
|
|
|
|
/** Render loop */
|
|
function renderFrame(this: LGraphCanvas) {
|
|
if (!this.pause_rendering) {
|
|
this.draw()
|
|
}
|
|
|
|
const window = this.getCanvasWindow()
|
|
if (this.is_rendering) {
|
|
if (this.#maximumFrameGap > 0) {
|
|
// Manual FPS limit
|
|
const gap = this.#maximumFrameGap - (LiteGraph.getTime() - this.last_draw_time)
|
|
setTimeout(renderFrame.bind(this), Math.max(1, gap))
|
|
} else {
|
|
// FPS limited by refresh rate
|
|
window.requestAnimationFrame(renderFrame.bind(this))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* stops rendering the content of the canvas (to save resources)
|
|
*
|
|
*/
|
|
stopRendering(): void {
|
|
this.is_rendering = false
|
|
/*
|
|
if(this.rendering_timer_id)
|
|
{
|
|
clearInterval(this.rendering_timer_id);
|
|
this.rendering_timer_id = null;
|
|
}
|
|
*/
|
|
}
|
|
|
|
/* LiteGraphCanvas input */
|
|
// used to block future mouse events (because of im gui)
|
|
blockClick(): void {
|
|
this.block_click = true
|
|
this.last_mouseclick = 0
|
|
}
|
|
|
|
/**
|
|
* Gets the widget at the current cursor position
|
|
* @param node Optional node to check for widgets under cursor
|
|
* @returns The widget located at the current cursor position or null
|
|
*/
|
|
getWidgetAtCursor(node?: LGraphNode): IWidget | null {
|
|
node ??= this.node_over
|
|
return node?.getWidgetOnPos(this.graph_mouse[0], this.graph_mouse[1], true) ?? null
|
|
}
|
|
|
|
/**
|
|
* Clears highlight and mouse-over information from nodes that should not have it.
|
|
*
|
|
* Intended to be called when the pointer moves away from a node.
|
|
* @param node The node that the mouse is now over
|
|
* @param e MouseEvent that is triggering this
|
|
*/
|
|
updateMouseOverNodes(node: LGraphNode | null, e: CanvasMouseEvent): void {
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
const nodes = this.graph._nodes
|
|
for (const otherNode of nodes) {
|
|
if (otherNode.mouseOver && node != otherNode) {
|
|
// mouse leave
|
|
otherNode.mouseOver = null
|
|
this._highlight_input = undefined
|
|
this._highlight_pos = undefined
|
|
this.linkConnector.overWidget = undefined
|
|
|
|
// Hover transitions
|
|
// TODO: Implement single lerp ease factor for current progress on hover in/out.
|
|
// In drawNode, multiply by ease factor and differential value (e.g. bg alpha +0.5).
|
|
otherNode.lostFocusAt = LiteGraph.getTime()
|
|
|
|
this.node_over?.onMouseLeave?.(e)
|
|
this.node_over = undefined
|
|
this.dirty_canvas = true
|
|
}
|
|
}
|
|
}
|
|
|
|
processMouseDown(e: PointerEvent): void {
|
|
if (this.dragZoomEnabled && e.ctrlKey && e.shiftKey && !e.altKey && e.buttons) {
|
|
this.#dragZoomStart = { pos: [e.x, e.y], scale: this.ds.scale }
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
if (!graph) return
|
|
|
|
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 || isInRect(x, y, this.viewport)
|
|
|
|
if (!is_inside) return
|
|
|
|
const node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
|
|
|
|
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]]
|
|
|
|
pointer.isDouble = pointer.isDown && e.isPrimary
|
|
pointer.isDown = true
|
|
|
|
this.canvas.focus()
|
|
|
|
LiteGraph.closeAllContextMenus(ref_window)
|
|
|
|
if (this.onMouse?.(e) == true) return
|
|
|
|
// left button mouse / single finger
|
|
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
|
|
|
|
// Sticky select - won't remove single nodes
|
|
if (node) this.processSelect(node, e, true)
|
|
|
|
// Show context menu for the node or group under the pointer
|
|
this.processContextMenu(node, e)
|
|
}
|
|
|
|
this.last_mouse = [x, y]
|
|
this.last_mouseclick = LiteGraph.getTime()
|
|
this.last_mouse_dragging = true
|
|
|
|
graph.change()
|
|
|
|
// this is to ensure to defocus(blur) if a text input element is on focus
|
|
if (
|
|
!ref_window.document.activeElement ||
|
|
(ref_window.document.activeElement.nodeName.toLowerCase() != "input" &&
|
|
ref_window.document.activeElement.nodeName.toLowerCase() != "textarea")
|
|
) {
|
|
e.preventDefault()
|
|
}
|
|
e.stopPropagation()
|
|
|
|
this.onMouseDown?.(e)
|
|
}
|
|
|
|
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
|
|
const { pointer, graph, linkConnector } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
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 ??
|
|
graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) ??
|
|
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
|
|
}
|
|
|
|
if (this.read_only) {
|
|
pointer.finally = () => this.dragging_canvas = false
|
|
this.dragging_canvas = true
|
|
return
|
|
}
|
|
|
|
// 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()
|
|
if (node_data?.type != null) {
|
|
const cloned = LiteGraph.createNode(node_data.type)
|
|
if (cloned) {
|
|
cloned.configure(node_data)
|
|
cloned.pos[0] += 5
|
|
cloned.pos[1] += 5
|
|
|
|
if (this.allow_dragnodes) {
|
|
pointer.onDragStart = (pointer) => {
|
|
graph.add(cloned, false)
|
|
this.#startDraggingItems(cloned, pointer)
|
|
}
|
|
pointer.onDragEnd = e => this.#processDraggedItems(e)
|
|
} else {
|
|
// TODO: Check if before/after change are necessary here.
|
|
graph.beforeChange()
|
|
graph.add(cloned, false)
|
|
graph.afterChange()
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Node clicked
|
|
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
|
|
this.#processNodeClick(e, ctrlOrMeta, node)
|
|
} else {
|
|
// Reroutes
|
|
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
|
const reroute = graph.getRerouteOnPos(x, y)
|
|
if (reroute) {
|
|
if (e.shiftKey) {
|
|
linkConnector.dragFromReroute(graph, reroute)
|
|
|
|
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => linkConnector.reset(true)
|
|
|
|
this.dirty_bgcanvas = true
|
|
}
|
|
|
|
pointer.onClick = () => this.processSelect(reroute, e)
|
|
if (!pointer.onDragEnd) {
|
|
pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true)
|
|
pointer.onDragEnd = e => this.#processDraggedItems(e)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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
|
|
const dpi = window?.devicePixelRatio || 1
|
|
|
|
for (const linkSegment of this.renderedPaths) {
|
|
const centre = linkSegment._pos
|
|
if (!centre) continue
|
|
|
|
// 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 * dpi, y * dpi)
|
|
) {
|
|
this.ctx.lineWidth = lineWidth
|
|
|
|
if (e.shiftKey && !e.altKey) {
|
|
linkConnector.dragFromLinkSegment(graph, linkSegment)
|
|
|
|
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => linkConnector.reset(true)
|
|
|
|
return
|
|
} else if (e.altKey && !e.shiftKey) {
|
|
const newReroute = graph.createReroute([x, y], linkSegment)
|
|
pointer.onDragStart = pointer => this.#startDraggingItems(newReroute, pointer)
|
|
pointer.onDragEnd = e => this.#processDraggedItems(e)
|
|
return
|
|
}
|
|
} else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) {
|
|
this.ctx.lineWidth = lineWidth
|
|
|
|
pointer.onClick = () => this.showLinkMenu(linkSegment, e)
|
|
pointer.onDragStart = () => this.dragging_canvas = true
|
|
pointer.finally = () => this.dragging_canvas = false
|
|
|
|
// clear tooltip
|
|
this.over_link_center = undefined
|
|
return
|
|
}
|
|
}
|
|
|
|
// Restore line width
|
|
this.ctx.lineWidth = lineWidth
|
|
|
|
// Groups
|
|
const group = graph.getGroupOnPos(x, y)
|
|
this.selected_group = group ?? null
|
|
if (group) {
|
|
if (group.isInResize(x, y)) {
|
|
// Resize group
|
|
const b = group.boundingRect
|
|
const offsetX = x - (b[0] + b[2])
|
|
const offsetY = y - (b[1] + b[3])
|
|
|
|
pointer.onDragStart = () => this.resizingGroup = group
|
|
pointer.onDrag = (eMove) => {
|
|
if (this.read_only) return
|
|
|
|
// Resize only by the exact pointer movement
|
|
const pos: Point = [
|
|
eMove.canvasX - group.pos[0] - offsetX,
|
|
eMove.canvasY - group.pos[1] - offsetY,
|
|
]
|
|
// Unless snapping.
|
|
if (this.#snapToGrid) snapPoint(pos, this.#snapToGrid)
|
|
|
|
const resized = group.resize(pos[0], pos[1])
|
|
if (resized) this.dirty_bgcanvas = true
|
|
}
|
|
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.onClick = () => this.processSelect(group, e)
|
|
pointer.onDragStart = (pointer) => {
|
|
group.recomputeInsideNodes()
|
|
this.#startDraggingItems(group, pointer, true)
|
|
}
|
|
pointer.onDragEnd = e => this.#processDraggedItems(e)
|
|
}
|
|
}
|
|
|
|
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 (
|
|
!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 e The pointerdown event
|
|
* @param ctrlOrMeta Ctrl or meta key is pressed
|
|
* @param node The node to process a click event for
|
|
*/
|
|
#processNodeClick(
|
|
e: CanvasPointerEvent,
|
|
ctrlOrMeta: boolean,
|
|
node: LGraphNode,
|
|
): void {
|
|
const { pointer, graph, linkConnector } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
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)) {
|
|
const b = node.boundingRect
|
|
const offsetX = x - (b[0] + b[2])
|
|
const offsetY = y - (b[1] + b[3])
|
|
|
|
pointer.onDragStart = () => {
|
|
graph.beforeChange()
|
|
this.resizing_node = node
|
|
}
|
|
|
|
pointer.onDrag = (eMove) => {
|
|
if (this.read_only) return
|
|
|
|
// Resize only by the exact pointer movement
|
|
const pos: Point = [
|
|
eMove.canvasX - node.pos[0] - offsetX,
|
|
eMove.canvasY - node.pos[1] - offsetY,
|
|
]
|
|
// Unless snapping.
|
|
if (this.#snapToGrid) snapPoint(pos, this.#snapToGrid)
|
|
|
|
const min = node.computeSize()
|
|
pos[0] = Math.max(min[0], pos[0])
|
|
pos[1] = Math.max(min[1], pos[1])
|
|
node.setSize(pos)
|
|
|
|
this.#dirty()
|
|
}
|
|
|
|
pointer.onDragEnd = () => {
|
|
this.#dirty()
|
|
graph.afterChange(this.resizing_node)
|
|
}
|
|
pointer.finally = () => this.resizing_node = null
|
|
this.canvas.style.cursor = "se-resize"
|
|
return
|
|
}
|
|
|
|
const { inputs, outputs } = node
|
|
|
|
// Outputs
|
|
if (outputs) {
|
|
for (const [i, output] of outputs.entries()) {
|
|
const link_pos = node.getOutputPos(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) {
|
|
linkConnector.moveOutputLink(graph, output)
|
|
|
|
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => linkConnector.reset(true)
|
|
|
|
return
|
|
}
|
|
|
|
// New output link
|
|
linkConnector.dragNewFromOutput(graph, node, output)
|
|
|
|
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => linkConnector.reset(true)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inputs
|
|
if (inputs) {
|
|
for (const [i, input] of inputs.entries()) {
|
|
const link_pos = node.getInputPos(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) {
|
|
if (
|
|
LiteGraph.click_do_break_link_to ||
|
|
(LiteGraph.ctrl_alt_click_do_break_link &&
|
|
ctrlOrMeta &&
|
|
e.altKey &&
|
|
!e.shiftKey)
|
|
) {
|
|
node.disconnectInput(i, true)
|
|
} else if (e.shiftKey || this.allow_reconnect_links) {
|
|
linkConnector.moveInputLink(graph, input)
|
|
}
|
|
} else {
|
|
for (const link of graph.floatingLinks.values()) {
|
|
if (link.target_id === node.id && link.target_slot === i) {
|
|
graph.removeFloatingLink(link)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dragging a new link from input to output
|
|
if (!linkConnector.isConnecting) {
|
|
linkConnector.dragNewFromInput(graph, node, input)
|
|
}
|
|
pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => linkConnector.reset(true)
|
|
this.dirty_bgcanvas = true
|
|
|
|
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]]
|
|
|
|
// 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.emitEvent({
|
|
subType: "node-double-click",
|
|
originalEvent: e,
|
|
node,
|
|
})
|
|
this.processNodeDblClicked(node)
|
|
}
|
|
|
|
// Mousedown callback - can block drag
|
|
if (node.onMouseDown?.(e, pos, this) || !this.allow_dragnodes)
|
|
return
|
|
|
|
// Drag node
|
|
pointer.onDragStart = pointer => this.#startDraggingItems(node, pointer, true)
|
|
pointer.onDragEnd = e => this.#processDraggedItems(e)
|
|
}
|
|
|
|
this.dirty_canvas = true
|
|
}
|
|
|
|
#processWidgetClick(e: CanvasPointerEvent, node: LGraphNode, widget: IWidget) {
|
|
const { pointer } = this
|
|
|
|
// Custom widget - CanvasPointer
|
|
if (typeof widget.onPointerDown === "function") {
|
|
const handled = widget.onPointerDown(pointer, node, this)
|
|
if (handled) return
|
|
}
|
|
|
|
const oldValue = widget.value
|
|
|
|
const pos = this.graph_mouse
|
|
const x = pos[0] - node.pos[0]
|
|
const y = pos[1] - node.pos[1]
|
|
|
|
const WidgetClass = WIDGET_TYPE_MAP[widget.type]
|
|
if (WidgetClass) {
|
|
const widgetInstance = toClass(WidgetClass, widget)
|
|
pointer.onClick = () => widgetInstance.onClick({
|
|
e,
|
|
node,
|
|
canvas: this,
|
|
})
|
|
pointer.onDrag = eMove => widgetInstance.onDrag?.({
|
|
e: eMove,
|
|
node,
|
|
canvas: this,
|
|
})
|
|
} else if (widget.mouse) {
|
|
const result = widget.mouse(e, [x, y], node)
|
|
if (result != null) this.dirty_canvas = result
|
|
}
|
|
|
|
// value changed
|
|
if (oldValue != widget.value) {
|
|
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
|
|
if (!node.graph) throw new NullGraphError()
|
|
node.graph._version++
|
|
}
|
|
|
|
// Clean up state var
|
|
pointer.finally = () => {
|
|
// Legacy custom widget callback
|
|
if (widget.mouse) {
|
|
const { eUp } = pointer
|
|
if (!eUp) return
|
|
const { canvasX, canvasY } = eUp
|
|
widget.mouse(eUp, [canvasX - node.pos[0], canvasY - node.pos[1]], node)
|
|
}
|
|
|
|
this.node_widget = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 | undefined) {
|
|
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
|
|
const { inputs, outputs } = node
|
|
|
|
// search for outputs
|
|
if (outputs) {
|
|
for (const [i, output] of outputs.entries()) {
|
|
const link_pos = node.getOutputPos(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
|
|
}
|
|
}
|
|
}
|
|
|
|
// search for inputs
|
|
if (inputs) {
|
|
for (const [i, input] of inputs.entries()) {
|
|
const link_pos = node.getInputPos(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 ? outputs.length : 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,
|
|
]
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
#processDragZoom(e: PointerEvent): void {
|
|
// stop canvas zoom action
|
|
if (!e.buttons) {
|
|
this.#dragZoomStart = null
|
|
return
|
|
}
|
|
|
|
const start = this.#dragZoomStart
|
|
if (!start) throw new TypeError("Drag-zoom state object was null")
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
// calculate delta
|
|
const deltaY = e.y - start.pos[1]
|
|
const startScale = start.scale
|
|
|
|
const scale = startScale - deltaY / 100
|
|
|
|
this.ds.changeScale(scale, start.pos)
|
|
this.graph.change()
|
|
}
|
|
|
|
/**
|
|
* Called when a mouse move event has to be processed
|
|
*/
|
|
processMouseMove(e: PointerEvent): void {
|
|
if (this.dragZoomEnabled && e.ctrlKey && e.shiftKey && this.#dragZoomStart) {
|
|
this.#processDragZoom(e)
|
|
return
|
|
}
|
|
|
|
if (this.autoresize) this.resize()
|
|
|
|
if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true
|
|
|
|
const { graph, resizingGroup, linkConnector } = this
|
|
if (!graph) return
|
|
|
|
LGraphCanvas.active_canvas = this
|
|
this.adjustMouseEvent(e)
|
|
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
|
|
this.mouse[0] = mouse[0]
|
|
this.mouse[1] = mouse[1]
|
|
const delta = [
|
|
mouse[0] - this.last_mouse[0],
|
|
mouse[1] - this.last_mouse[1],
|
|
]
|
|
this.last_mouse = mouse
|
|
this.graph_mouse[0] = e.canvasX
|
|
this.graph_mouse[1] = e.canvasY
|
|
|
|
if (e.isPrimary) this.pointer.move(e)
|
|
|
|
if (this.block_click) {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
e.dragging = this.last_mouse_dragging
|
|
|
|
if (this.node_widget) {
|
|
// Legacy widget mouse callbacks for pointermove events
|
|
const [node, widget] = this.node_widget
|
|
|
|
if (widget?.mouse) {
|
|
const x = e.canvasX - node.pos[0]
|
|
const y = e.canvasY - node.pos[1]
|
|
const result = widget.mouse(e, [x, y], node)
|
|
if (result != null) this.dirty_canvas = result
|
|
}
|
|
}
|
|
|
|
/** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */
|
|
let underPointer = CanvasItem.Nothing
|
|
// get node over
|
|
const node = graph.getNodeOnPos(
|
|
e.canvasX,
|
|
e.canvasY,
|
|
this.visible_nodes,
|
|
)
|
|
|
|
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) {
|
|
// Resizing a group
|
|
underPointer |= CanvasItem.ResizeSe | CanvasItem.Group
|
|
} else if (this.dragging_canvas) {
|
|
this.ds.offset[0] += delta[0] / this.ds.scale
|
|
this.ds.offset[1] += delta[1] / this.ds.scale
|
|
this.#dirty()
|
|
} else if (
|
|
(this.allow_interaction || node?.flags.allow_interaction) &&
|
|
!this.read_only
|
|
) {
|
|
if (linkConnector.isConnecting) this.dirty_canvas = true
|
|
|
|
// remove mouseover flag
|
|
this.updateMouseOverNodes(node, e)
|
|
|
|
// mouse over a node
|
|
if (node) {
|
|
underPointer |= CanvasItem.Node
|
|
|
|
if (node.redraw_on_mouse) this.dirty_canvas = true
|
|
|
|
// For input/output hovering
|
|
// to store the output of isOverNodeInput
|
|
const pos: Point = [0, 0]
|
|
const inputId = isOverNodeInput(node, e.canvasX, e.canvasY, pos)
|
|
const outputId = isOverNodeOutput(node, e.canvasX, e.canvasY, pos)
|
|
const overWidget = node.getWidgetOnPos(e.canvasX, e.canvasY, true)
|
|
|
|
if (!node.mouseOver) {
|
|
// mouse enter
|
|
node.mouseOver = {
|
|
inputId: null,
|
|
outputId: null,
|
|
overWidget: null,
|
|
}
|
|
this.node_over = node
|
|
this.dirty_canvas = true
|
|
|
|
node.onMouseEnter?.(e)
|
|
}
|
|
|
|
// in case the node wants to do something
|
|
node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this)
|
|
|
|
// The input the mouse is over has changed
|
|
if (
|
|
node.mouseOver.inputId !== inputId ||
|
|
node.mouseOver.outputId !== outputId ||
|
|
node.mouseOver.overWidget !== overWidget
|
|
) {
|
|
node.mouseOver.inputId = inputId
|
|
node.mouseOver.outputId = outputId
|
|
node.mouseOver.overWidget = overWidget
|
|
|
|
// State reset
|
|
linkConnector.overWidget = undefined
|
|
|
|
// Check if link is over anything it could connect to - record position of valid target for snap / highlight
|
|
if (linkConnector.isConnecting) {
|
|
const firstLink = linkConnector.renderLinks.at(0)
|
|
|
|
// Default: nothing highlighted
|
|
let highlightPos: Point | undefined
|
|
let highlightInput: INodeInputSlot | undefined
|
|
|
|
if (!firstLink || firstLink.node === node) {
|
|
// No link / node loopback
|
|
} else if (linkConnector.state.connectingTo === "input") {
|
|
if (inputId === -1 && outputId === -1) {
|
|
// Allow support for linking to widgets, handled externally to LiteGraph
|
|
if (this.getWidgetLinkType && overWidget) {
|
|
const widgetLinkType = this.getWidgetLinkType(overWidget, node)
|
|
if (
|
|
widgetLinkType &&
|
|
LiteGraph.isValidConnection(linkConnector.renderLinks[0]?.fromSlot.type, widgetLinkType) &&
|
|
firstLink.node.isValidWidgetLink?.(firstLink.fromSlotIndex, node, overWidget) !== false
|
|
) {
|
|
const { pos: [nodeX, nodeY] } = node
|
|
highlightPos = [nodeX + 10, nodeY + 10 + overWidget.y]
|
|
linkConnector.overWidget = overWidget
|
|
linkConnector.overWidgetType = widgetLinkType
|
|
}
|
|
}
|
|
// Node background / title under the pointer
|
|
if (!linkConnector.overWidget) {
|
|
const result = node.findInputByType(firstLink.fromSlot.type)
|
|
if (result) {
|
|
highlightInput = result.slot
|
|
highlightPos = node.getInputPos(result.index)
|
|
}
|
|
}
|
|
} else if (
|
|
inputId != -1 &&
|
|
node.inputs[inputId] &&
|
|
LiteGraph.isValidConnection(firstLink.fromSlot.type, node.inputs[inputId].type)
|
|
) {
|
|
highlightPos = pos
|
|
// XXX CHECK THIS
|
|
highlightInput = node.inputs[inputId]
|
|
}
|
|
} else if (linkConnector.state.connectingTo === "output") {
|
|
// Connecting from an input to an output
|
|
if (inputId === -1 && outputId === -1) {
|
|
const result = node.findOutputByType(firstLink.fromSlot.type)
|
|
if (result) {
|
|
highlightPos = node.getOutputPos(result.index)
|
|
}
|
|
} else {
|
|
// check if I have a slot below de mouse
|
|
if (
|
|
outputId != -1 &&
|
|
node.outputs[outputId] &&
|
|
LiteGraph.isValidConnection(firstLink.fromSlot.type, node.outputs[outputId].type)
|
|
) {
|
|
highlightPos = pos
|
|
}
|
|
}
|
|
}
|
|
this._highlight_pos = highlightPos
|
|
this._highlight_input = highlightInput
|
|
}
|
|
|
|
this.dirty_canvas = true
|
|
}
|
|
|
|
// Resize corner
|
|
if (node.inResizeCorner(e.canvasX, e.canvasY)) {
|
|
underPointer |= CanvasItem.ResizeSe
|
|
}
|
|
} else {
|
|
// Reroute
|
|
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY)
|
|
if (reroute) {
|
|
underPointer |= CanvasItem.Reroute
|
|
linkConnector.overReroute = reroute
|
|
|
|
if (linkConnector.isConnecting && linkConnector.isRerouteValidDrop(reroute)) {
|
|
this._highlight_pos = reroute.pos
|
|
}
|
|
} else {
|
|
this._highlight_pos &&= undefined
|
|
linkConnector.overReroute &&= undefined
|
|
}
|
|
|
|
// 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) {
|
|
const group = graph.getGroupOnPos(e.canvasX, e.canvasY)
|
|
if (
|
|
group &&
|
|
!e.ctrlKey &&
|
|
!this.read_only &&
|
|
group.isInResize(e.canvasX, e.canvasY)
|
|
) {
|
|
underPointer |= CanvasItem.ResizeSe
|
|
}
|
|
}
|
|
}
|
|
|
|
// send event to node if capturing input (used with widgets that allow drag outside of the area of the node)
|
|
if (this.node_capturing_input && this.node_capturing_input != node) {
|
|
this.node_capturing_input.onMouseMove?.(
|
|
e,
|
|
[
|
|
e.canvasX - this.node_capturing_input.pos[0],
|
|
e.canvasY - this.node_capturing_input.pos[1],
|
|
],
|
|
this,
|
|
)
|
|
}
|
|
|
|
// Items being dragged
|
|
if (this.isDragging) {
|
|
const selected = this.selectedItems
|
|
const allItems = e.ctrlKey ? selected : getAllNestedItems(selected)
|
|
|
|
const deltaX = delta[0] / this.ds.scale
|
|
const deltaY = delta[1] / this.ds.scale
|
|
for (const item of allItems) {
|
|
item.move(deltaX, deltaY, true)
|
|
}
|
|
|
|
this.#dirty()
|
|
}
|
|
|
|
if (this.resizing_node) underPointer |= CanvasItem.ResizeSe
|
|
}
|
|
|
|
this.hoveringOver = underPointer
|
|
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
/**
|
|
* Start dragging an item, optionally including all other selected items.
|
|
*
|
|
* ** This function sets the {@link CanvasPointer.finally}() callback. **
|
|
* @param item The item that the drag event started on
|
|
* @param pointer The pointer event that initiated the drag, e.g. pointerdown
|
|
* @param sticky If `true`, the item is added to the selection - see {@link processSelect}
|
|
*/
|
|
#startDraggingItems(item: Positionable, pointer: CanvasPointer, sticky = false): void {
|
|
this.emitBeforeChange()
|
|
this.graph?.beforeChange()
|
|
// Ensure that dragging is properly cleaned up, on success or failure.
|
|
pointer.finally = () => {
|
|
this.isDragging = false
|
|
this.graph?.afterChange()
|
|
this.emitAfterChange()
|
|
}
|
|
|
|
this.processSelect(item, pointer.eDown, sticky)
|
|
this.isDragging = true
|
|
}
|
|
|
|
/**
|
|
* Handles shared clean up and placement after items have been dragged.
|
|
* @param e The event that completed the drag, e.g. pointerup, pointermove
|
|
*/
|
|
#processDraggedItems(e: CanvasPointerEvent): void {
|
|
const { graph } = this
|
|
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
|
graph?.snapToGrid(this.selectedItems)
|
|
|
|
this.dirty_canvas = true
|
|
this.dirty_bgcanvas = true
|
|
|
|
// TODO: Replace legacy behaviour: callbacks were never extended for multiple items
|
|
this.onNodeMoved?.(findFirstNode(this.selectedItems))
|
|
}
|
|
|
|
/**
|
|
* Called when a mouse up event has to be processed
|
|
*/
|
|
processMouseUp(e: PointerEvent): void {
|
|
// early exit for extra pointer
|
|
if (e.isPrimary === false) return
|
|
|
|
const { graph, pointer } = this
|
|
if (!graph) return
|
|
|
|
LGraphCanvas.active_canvas = this
|
|
|
|
this.adjustMouseEvent(e)
|
|
|
|
const now = LiteGraph.getTime()
|
|
e.click_time = now - this.last_mouseclick
|
|
|
|
/** 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.last_mouse_dragging = false
|
|
this.last_click_position = null
|
|
|
|
// used to avoid sending twice a click in an immediate button
|
|
this.block_click &&= false
|
|
|
|
if (e.button === 0) {
|
|
// left button
|
|
this.selected_group = null
|
|
|
|
this.isDragging = false
|
|
|
|
const x = e.canvasX
|
|
const y = e.canvasY
|
|
|
|
if (!this.linkConnector.isConnecting) {
|
|
this.dirty_canvas = true
|
|
|
|
// @ts-expect-error Unused param
|
|
this.node_over?.onMouseUp?.(e, [x - this.node_over.pos[0], y - this.node_over.pos[1]], this)
|
|
this.node_capturing_input?.onMouseUp?.(e, [
|
|
x - this.node_capturing_input.pos[0],
|
|
y - this.node_capturing_input.pos[1],
|
|
])
|
|
}
|
|
} else if (e.button === 1) {
|
|
// middle button
|
|
this.dirty_canvas = true
|
|
this.dragging_canvas = false
|
|
} else if (e.button === 2) {
|
|
// right button
|
|
this.dirty_canvas = true
|
|
}
|
|
|
|
pointer.isDown = false
|
|
pointer.isDouble = false
|
|
|
|
graph.change()
|
|
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
/**
|
|
* Called when the mouse moves off the canvas. Clears all node hover states.
|
|
* @param e
|
|
*/
|
|
processMouseOut(e: MouseEvent): void {
|
|
// TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc.
|
|
this.adjustMouseEvent(e)
|
|
this.updateMouseOverNodes(null, e)
|
|
}
|
|
|
|
processMouseCancel(): void {
|
|
console.warn("Pointer cancel!")
|
|
this.pointer.reset()
|
|
}
|
|
|
|
/**
|
|
* Called when a mouse wheel event has to be processed
|
|
*/
|
|
processMouseWheel(e: WheelEvent): void {
|
|
if (!this.graph || !this.allow_dragcanvas) return
|
|
|
|
// TODO: Mouse wheel zoom rewrite
|
|
// @ts-expect-error
|
|
const delta = e.wheelDeltaY ?? e.detail * -60
|
|
|
|
this.adjustMouseEvent(e)
|
|
|
|
const pos: Point = [e.clientX, e.clientY]
|
|
if (this.viewport && !isPointInRect(pos, this.viewport)) return
|
|
|
|
let { scale } = this.ds
|
|
|
|
if (delta > 0) scale *= this.zoom_speed
|
|
else if (delta < 0) scale *= 1 / this.zoom_speed
|
|
|
|
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
|
|
|
this.graph.change()
|
|
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
/**
|
|
* process a key event
|
|
*/
|
|
processKey(e: KeyboardEvent): boolean | null | undefined {
|
|
this.#shiftDown = e.shiftKey
|
|
if (!this.graph) return
|
|
|
|
let block_default = false
|
|
// @ts-expect-error
|
|
if (e.target.localName == "input") return
|
|
|
|
if (e.type == "keydown") {
|
|
// TODO: Switch
|
|
if (e.key === " ") {
|
|
// space
|
|
this.read_only = true
|
|
if (this._previously_dragging_canvas === null) {
|
|
this._previously_dragging_canvas = this.dragging_canvas
|
|
}
|
|
this.dragging_canvas = this.pointer.isDown
|
|
block_default = true
|
|
} else if (e.key === "Escape") {
|
|
// esc
|
|
if (this.linkConnector.isConnecting) {
|
|
this.linkConnector.reset()
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
this.node_panel?.close()
|
|
this.options_panel?.close()
|
|
block_default = true
|
|
} else if (e.keyCode === 65 && e.ctrlKey) {
|
|
// select all Control A
|
|
this.selectItems()
|
|
block_default = true
|
|
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
// copy
|
|
if (this.selected_nodes) {
|
|
this.copyToClipboard()
|
|
block_default = true
|
|
}
|
|
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
|
|
// paste
|
|
this.pasteFromClipboard({ connectInputs: e.shiftKey })
|
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
// delete or backspace
|
|
// @ts-expect-error
|
|
if (e.target.localName != "input" && e.target.localName != "textarea") {
|
|
this.deleteSelected()
|
|
block_default = true
|
|
}
|
|
}
|
|
|
|
// collapse
|
|
// ...
|
|
// TODO
|
|
if (this.selected_nodes) {
|
|
for (const i in this.selected_nodes) {
|
|
this.selected_nodes[i].onKeyDown?.(e)
|
|
}
|
|
}
|
|
} else if (e.type == "keyup") {
|
|
if (e.key === " ") {
|
|
// space
|
|
this.read_only = false
|
|
this.dragging_canvas = (this._previously_dragging_canvas ?? false) && this.pointer.isDown
|
|
this._previously_dragging_canvas = null
|
|
}
|
|
|
|
if (this.selected_nodes) {
|
|
for (const i in this.selected_nodes) {
|
|
this.selected_nodes[i].onKeyUp?.(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: Do we need to remeasure and recalculate everything on every key down/up?
|
|
this.graph.change()
|
|
|
|
if (block_default) {
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copies canvas items to an internal, app-specific clipboard backed by local storage.
|
|
* When called without parameters, it copies {@link selectedItems}.
|
|
* @param items The items to copy. If nullish, all selected items are copied.
|
|
*/
|
|
copyToClipboard(items?: Iterable<Positionable>): void {
|
|
const serialisable: Required<ClipboardItems> = {
|
|
nodes: [],
|
|
groups: [],
|
|
reroutes: [],
|
|
links: [],
|
|
}
|
|
|
|
// Create serialisable objects
|
|
for (const item of items ?? this.selectedItems) {
|
|
if (item instanceof LGraphNode) {
|
|
// Nodes
|
|
if (item.clonable === false) continue
|
|
|
|
const cloned = item.clone()?.serialize()
|
|
if (!cloned) continue
|
|
|
|
cloned.id = item.id
|
|
serialisable.nodes.push(cloned)
|
|
|
|
// Links
|
|
if (item.inputs) {
|
|
for (const { link: linkId } of item.inputs) {
|
|
if (linkId == null) continue
|
|
|
|
const link = this.graph?._links.get(linkId)?.asSerialisable()
|
|
if (link) serialisable.links.push(link)
|
|
}
|
|
}
|
|
} else if (item instanceof LGraphGroup) {
|
|
// Groups
|
|
serialisable.groups.push(item.serialize())
|
|
} else if (item instanceof Reroute) {
|
|
// Reroutes
|
|
serialisable.reroutes.push(item.asSerialisable())
|
|
}
|
|
}
|
|
|
|
localStorage.setItem(
|
|
"litegrapheditor_clipboard",
|
|
JSON.stringify(serialisable),
|
|
)
|
|
}
|
|
|
|
emitEvent(detail: CanvasEventDetail): void {
|
|
this.canvas.dispatchEvent(
|
|
new CustomEvent("litegraph:canvas", {
|
|
bubbles: true,
|
|
detail,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */
|
|
emitBeforeChange(): void {
|
|
this.emitEvent({
|
|
subType: "before-change",
|
|
})
|
|
}
|
|
|
|
/** @todo See {@link emitBeforeChange} */
|
|
emitAfterChange(): void {
|
|
this.emitEvent({
|
|
subType: "after-change",
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Pastes the items from the canvas "clipbaord" - a local storage variable.
|
|
*/
|
|
_pasteFromClipboard(options: IPasteFromClipboardOptions = {}): ClipboardPasteResult | undefined {
|
|
const {
|
|
connectInputs = false,
|
|
position = this.graph_mouse,
|
|
} = options
|
|
|
|
// if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior
|
|
if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && connectInputs) return
|
|
|
|
const data = localStorage.getItem("litegrapheditor_clipboard")
|
|
if (!data) return
|
|
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
graph.beforeChange()
|
|
|
|
// Parse & initialise
|
|
const parsed: ClipboardItems = JSON.parse(data)
|
|
parsed.nodes ??= []
|
|
parsed.groups ??= []
|
|
parsed.reroutes ??= []
|
|
parsed.links ??= []
|
|
|
|
// Find top-left-most boundary
|
|
let offsetX = Infinity
|
|
let offsetY = Infinity
|
|
for (const item of [...parsed.nodes, ...parsed.reroutes]) {
|
|
if (item.pos == null) throw new TypeError("Invalid node encounterd on paste. `pos` was null.")
|
|
|
|
if (item.pos[0] < offsetX) offsetX = item.pos[0]
|
|
if (item.pos[1] < offsetY) offsetY = item.pos[1]
|
|
}
|
|
|
|
// TODO: Remove when implementing `asSerialisable`
|
|
if (parsed.groups) {
|
|
for (const group of parsed.groups) {
|
|
if (group.bounding[0] < offsetX) offsetX = group.bounding[0]
|
|
if (group.bounding[1] < offsetY) offsetY = group.bounding[1]
|
|
}
|
|
}
|
|
|
|
const results: ClipboardPasteResult = {
|
|
created: [],
|
|
nodes: new Map<NodeId, LGraphNode>(),
|
|
links: new Map<LinkId, LLink>(),
|
|
reroutes: new Map<RerouteId, Reroute>(),
|
|
}
|
|
const { created, nodes, links, reroutes } = results
|
|
|
|
// const failedNodes: ISerialisedNode[] = []
|
|
|
|
// Groups
|
|
for (const info of parsed.groups) {
|
|
info.id = -1
|
|
|
|
const group = new LGraphGroup()
|
|
group.configure(info)
|
|
graph.add(group)
|
|
created.push(group)
|
|
}
|
|
|
|
// Nodes
|
|
for (const info of parsed.nodes) {
|
|
const node = info.type == null ? null : LiteGraph.createNode(info.type)
|
|
if (!node) {
|
|
// failedNodes.push(info)
|
|
continue
|
|
}
|
|
|
|
nodes.set(info.id, node)
|
|
info.id = -1
|
|
|
|
node.configure(info)
|
|
graph.add(node)
|
|
|
|
created.push(node)
|
|
}
|
|
|
|
// Reroutes
|
|
for (const info of parsed.reroutes) {
|
|
const { id, ...rerouteInfo } = info
|
|
|
|
const reroute = graph.setReroute(rerouteInfo)
|
|
created.push(reroute)
|
|
reroutes.set(id, reroute)
|
|
}
|
|
|
|
// Remap reroute parentIds for pasted reroutes
|
|
for (const reroute of reroutes.values()) {
|
|
if (reroute.parentId == null) continue
|
|
|
|
const mapped = reroutes.get(reroute.parentId)
|
|
if (mapped) reroute.parentId = mapped.id
|
|
}
|
|
|
|
// Links
|
|
for (const info of parsed.links) {
|
|
// Find the copied node / reroute ID
|
|
let outNode: LGraphNode | null | undefined = nodes.get(info.origin_id)
|
|
let afterRerouteId: number | undefined
|
|
if (info.parentId != null) afterRerouteId = reroutes.get(info.parentId)?.id
|
|
|
|
// If it wasn't copied, use the original graph value
|
|
if (connectInputs && LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs) {
|
|
outNode ??= graph.getNodeById(info.origin_id)
|
|
afterRerouteId ??= info.parentId
|
|
}
|
|
|
|
const inNode = nodes.get(info.target_id)
|
|
if (inNode) {
|
|
const link = outNode?.connect(
|
|
info.origin_slot,
|
|
inNode,
|
|
info.target_slot,
|
|
afterRerouteId,
|
|
)
|
|
if (link) links.set(info.id, link)
|
|
}
|
|
}
|
|
|
|
// Remap linkIds
|
|
for (const reroute of reroutes.values()) {
|
|
const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x)
|
|
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
|
|
|
|
// Remove any invalid items
|
|
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
|
|
graph.removeReroute(reroute.id)
|
|
}
|
|
}
|
|
|
|
// Adjust positions
|
|
for (const item of created) {
|
|
item.pos[0] += position[0] - offsetX
|
|
item.pos[1] += position[1] - offsetY
|
|
}
|
|
|
|
// TODO: Report failures, i.e. `failedNodes`
|
|
|
|
this.selectItems(created)
|
|
|
|
graph.afterChange()
|
|
|
|
return results
|
|
}
|
|
|
|
pasteFromClipboard(options: IPasteFromClipboardOptions = {}): void {
|
|
this.emitBeforeChange()
|
|
try {
|
|
this._pasteFromClipboard(options)
|
|
} finally {
|
|
this.emitAfterChange()
|
|
}
|
|
}
|
|
|
|
processNodeDblClicked(n: LGraphNode): void {
|
|
this.onShowNodePanel?.(n)
|
|
this.onNodeDblClicked?.(n)
|
|
|
|
this.setDirty(true)
|
|
}
|
|
|
|
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
|
|
// Process drag
|
|
// Convert Point pair (pos, offset) to Rect
|
|
const { graph, selectedItems } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
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)
|
|
}
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param e The MouseEvent to handle
|
|
* @param sticky Prevents deselecting individual nodes (as used by aux/right-click)
|
|
* @remarks
|
|
* Accessibility: anyone using {@link mutli_select} always deselects when clicking empty space.
|
|
*/
|
|
processSelect<TPositionable extends Positionable = LGraphNode>(
|
|
item: TPositionable | null | undefined,
|
|
e: CanvasMouseEvent | undefined,
|
|
sticky: boolean = false,
|
|
): void {
|
|
const addModifier = e?.shiftKey
|
|
const subtractModifier = e != null && (e.metaKey || e.ctrlKey)
|
|
const eitherModifier = addModifier || subtractModifier
|
|
const modifySelection = eitherModifier || this.multi_select
|
|
|
|
if (!item) {
|
|
if (!eitherModifier || this.multi_select) this.deselectAll()
|
|
} else if (!item.selected || !this.selectedItems.has(item)) {
|
|
if (!modifySelection) this.deselectAll(item)
|
|
this.select(item)
|
|
} else if (modifySelection && !sticky) {
|
|
this.deselect(item)
|
|
} else if (!sticky) {
|
|
this.deselectAll(item)
|
|
} else {
|
|
return
|
|
}
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
this.setDirty(true)
|
|
}
|
|
|
|
/**
|
|
* Selects a {@link Positionable} item.
|
|
* @param item The canvas item to add to the selection.
|
|
*/
|
|
select<TPositionable extends Positionable = LGraphNode>(item: TPositionable): void {
|
|
if (item.selected && this.selectedItems.has(item)) return
|
|
|
|
item.selected = true
|
|
this.selectedItems.add(item)
|
|
if (!(item instanceof LGraphNode)) return
|
|
|
|
// Node-specific handling
|
|
item.onSelected?.()
|
|
this.selected_nodes[item.id] = item
|
|
|
|
this.onNodeSelected?.(item)
|
|
|
|
// Highlight links
|
|
if (item.inputs) {
|
|
for (const input of item.inputs) {
|
|
if (input.link == null) continue
|
|
this.highlighted_links[input.link] = true
|
|
}
|
|
}
|
|
if (item.outputs) {
|
|
for (const id of item.outputs.flatMap(x => x.links)) {
|
|
if (id == null) continue
|
|
this.highlighted_links[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deselects a {@link Positionable} item.
|
|
* @param item The canvas item to remove from the selection.
|
|
*/
|
|
deselect<TPositionable extends Positionable = LGraphNode>(item: TPositionable): void {
|
|
if (!item.selected && !this.selectedItems.has(item)) return
|
|
|
|
item.selected = false
|
|
this.selectedItems.delete(item)
|
|
if (!(item instanceof LGraphNode)) return
|
|
|
|
// Node-specific handling
|
|
item.onDeselected?.()
|
|
delete this.selected_nodes[item.id]
|
|
|
|
this.onNodeDeselected?.(item)
|
|
|
|
// Clear link highlight
|
|
if (item.inputs) {
|
|
for (const input of item.inputs) {
|
|
if (input.link == null) continue
|
|
delete this.highlighted_links[input.link]
|
|
}
|
|
}
|
|
if (item.outputs) {
|
|
for (const id of item.outputs.flatMap(x => x.links)) {
|
|
if (id == null) continue
|
|
delete this.highlighted_links[id]
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @deprecated See {@link LGraphCanvas.processSelect} */
|
|
processNodeSelected(item: LGraphNode, e: CanvasMouseEvent): void {
|
|
this.processSelect(
|
|
item,
|
|
e,
|
|
e && (e.shiftKey || e.metaKey || e.ctrlKey || this.multi_select),
|
|
)
|
|
}
|
|
|
|
/** @deprecated See {@link LGraphCanvas.select} */
|
|
selectNode(node: LGraphNode, add_to_current_selection?: boolean): void {
|
|
if (node == null) {
|
|
this.deselectAll()
|
|
} else {
|
|
this.selectNodes([node], add_to_current_selection)
|
|
}
|
|
}
|
|
|
|
get empty(): boolean {
|
|
if (!this.graph) throw new NullGraphError()
|
|
return this.graph.empty
|
|
}
|
|
|
|
get positionableItems() {
|
|
if (!this.graph) throw new NullGraphError()
|
|
return this.graph.positionableItems()
|
|
}
|
|
|
|
/**
|
|
* Selects several items.
|
|
* @param items Items to select - if falsy, all items on the canvas will be selected
|
|
* @param add_to_current_selection If set, the items will be added to the current selection instead of replacing it
|
|
*/
|
|
selectItems(items?: Positionable[], add_to_current_selection?: boolean): void {
|
|
const itemsToSelect = items ?? this.positionableItems
|
|
if (!add_to_current_selection) this.deselectAll()
|
|
for (const item of itemsToSelect) this.select(item)
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
this.setDirty(true)
|
|
}
|
|
|
|
/**
|
|
* selects several nodes (or adds them to the current selection)
|
|
* @deprecated See {@link LGraphCanvas.selectItems}
|
|
*/
|
|
selectNodes(nodes?: LGraphNode[], add_to_current_selection?: boolean): void {
|
|
this.selectItems(nodes, add_to_current_selection)
|
|
}
|
|
|
|
/** @deprecated See {@link LGraphCanvas.deselect} */
|
|
deselectNode(node: LGraphNode): void {
|
|
this.deselect(node)
|
|
}
|
|
|
|
/**
|
|
* Deselects all items on the canvas.
|
|
* @param keepSelected If set, this item will not be removed from the selection.
|
|
*/
|
|
deselectAll(keepSelected?: Positionable): void {
|
|
if (!this.graph) return
|
|
|
|
const selected = this.selectedItems
|
|
let wasSelected: Positionable | undefined
|
|
for (const sel of selected) {
|
|
if (sel === keepSelected) {
|
|
wasSelected = sel
|
|
continue
|
|
}
|
|
sel.onDeselected?.()
|
|
sel.selected = false
|
|
}
|
|
selected.clear()
|
|
if (wasSelected) selected.add(wasSelected)
|
|
|
|
this.setDirty(true)
|
|
|
|
// Legacy code
|
|
const oldNode = keepSelected?.id == null ? null : this.selected_nodes[keepSelected.id]
|
|
this.selected_nodes = {}
|
|
this.current_node = null
|
|
this.highlighted_links = {}
|
|
|
|
if (keepSelected instanceof LGraphNode) {
|
|
// Handle old object lookup
|
|
if (oldNode) this.selected_nodes[oldNode.id] = oldNode
|
|
|
|
// Highlight links
|
|
if (keepSelected.inputs) {
|
|
for (const input of keepSelected.inputs) {
|
|
if (input.link == null) continue
|
|
this.highlighted_links[input.link] = true
|
|
}
|
|
}
|
|
if (keepSelected.outputs) {
|
|
for (const id of keepSelected.outputs.flatMap(x => x.links)) {
|
|
if (id == null) continue
|
|
this.highlighted_links[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
}
|
|
|
|
/** @deprecated See {@link LGraphCanvas.deselectAll} */
|
|
deselectAllNodes(): void {
|
|
this.deselectAll()
|
|
}
|
|
|
|
/**
|
|
* Deletes all selected items from the graph.
|
|
* @todo Refactor deletion task to LGraph. Selection is a canvas property, delete is a graph action.
|
|
*/
|
|
deleteSelected(): void {
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
this.emitBeforeChange()
|
|
graph.beforeChange()
|
|
|
|
for (const item of this.selectedItems) {
|
|
if (item instanceof LGraphNode) {
|
|
const node = item
|
|
if (node.block_delete) continue
|
|
node.connectInputToOutput()
|
|
graph.remove(node)
|
|
this.onNodeDeselected?.(node)
|
|
} else if (item instanceof LGraphGroup) {
|
|
graph.remove(item)
|
|
} else if (item instanceof Reroute) {
|
|
graph.removeReroute(item.id)
|
|
}
|
|
}
|
|
|
|
this.selected_nodes = {}
|
|
this.selectedItems.clear()
|
|
this.current_node = null
|
|
this.highlighted_links = {}
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
this.setDirty(true)
|
|
graph.afterChange()
|
|
this.emitAfterChange()
|
|
}
|
|
|
|
/**
|
|
* deletes all nodes in the current selection from the graph
|
|
* @deprecated See {@link LGraphCanvas.deleteSelected}
|
|
*/
|
|
deleteSelectedNodes(): void {
|
|
this.deleteSelected()
|
|
}
|
|
|
|
/**
|
|
* centers the camera on a given node
|
|
*/
|
|
centerOnNode(node: LGraphNode): void {
|
|
const dpi = window?.devicePixelRatio || 1
|
|
this.ds.offset[0] =
|
|
-node.pos[0] -
|
|
node.size[0] * 0.5 +
|
|
(this.canvas.width * 0.5) / (this.ds.scale * dpi)
|
|
this.ds.offset[1] =
|
|
-node.pos[1] -
|
|
node.size[1] * 0.5 +
|
|
(this.canvas.height * 0.5) / (this.ds.scale * dpi)
|
|
this.setDirty(true, true)
|
|
}
|
|
|
|
/**
|
|
* adds some useful properties to a mouse event, like the position in graph coordinates
|
|
*/
|
|
adjustMouseEvent<T extends MouseEvent>(
|
|
e: T & Partial<CanvasPointerExtensions>,
|
|
): asserts e is T & CanvasMouseEvent {
|
|
let clientX_rel = e.clientX
|
|
let clientY_rel = e.clientY
|
|
|
|
if (this.canvas) {
|
|
const b = this.canvas.getBoundingClientRect()
|
|
clientX_rel -= b.left
|
|
clientY_rel -= b.top
|
|
}
|
|
|
|
e.safeOffsetX = clientX_rel
|
|
e.safeOffsetY = clientY_rel
|
|
|
|
// TODO: Find a less brittle way to do this
|
|
|
|
// Only set deltaX and deltaY if not already set.
|
|
// If deltaX and deltaY are already present, they are read-only.
|
|
// Setting them would result browser error => zoom in/out feature broken.
|
|
if (e.deltaX === undefined)
|
|
e.deltaX = clientX_rel - this.last_mouse_position[0]
|
|
if (e.deltaY === undefined)
|
|
e.deltaY = clientY_rel - this.last_mouse_position[1]
|
|
|
|
this.last_mouse_position[0] = clientX_rel
|
|
this.last_mouse_position[1] = clientY_rel
|
|
|
|
e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0]
|
|
e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1]
|
|
}
|
|
|
|
/**
|
|
* changes the zoom level of the graph (default is 1), you can pass also a place used to pivot the zoom
|
|
*/
|
|
setZoom(value: number, zooming_center: Point) {
|
|
this.ds.changeScale(value, zooming_center)
|
|
this.#dirty()
|
|
}
|
|
|
|
/**
|
|
* converts a coordinate from graph coordinates to canvas2D coordinates
|
|
*/
|
|
convertOffsetToCanvas(pos: Point, out: Point): Point {
|
|
// @ts-expect-error Unused param
|
|
return this.ds.convertOffsetToCanvas(pos, out)
|
|
}
|
|
|
|
/**
|
|
* converts a coordinate from Canvas2D coordinates to graph space
|
|
*/
|
|
convertCanvasToOffset(pos: Point, out?: Point): Point {
|
|
return this.ds.convertCanvasToOffset(pos, out)
|
|
}
|
|
|
|
// converts event coordinates from canvas2D to graph coordinates
|
|
convertEventToCanvasOffset(e: MouseEvent): Point {
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
// TODO: -> this.ds.convertCanvasToOffset
|
|
return this.convertCanvasToOffset([
|
|
e.clientX - rect.left,
|
|
e.clientY - rect.top,
|
|
])
|
|
}
|
|
|
|
/**
|
|
* brings a node to front (above all other nodes)
|
|
*/
|
|
bringToFront(node: LGraphNode): void {
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const i = graph._nodes.indexOf(node)
|
|
if (i == -1) return
|
|
|
|
graph._nodes.splice(i, 1)
|
|
graph._nodes.push(node)
|
|
}
|
|
|
|
/**
|
|
* sends a node to the back (below all other nodes)
|
|
*/
|
|
sendToBack(node: LGraphNode): void {
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const i = graph._nodes.indexOf(node)
|
|
if (i == -1) return
|
|
|
|
graph._nodes.splice(i, 1)
|
|
graph._nodes.unshift(node)
|
|
}
|
|
|
|
/**
|
|
* Determines which nodes are visible and populates {@link out} with the results.
|
|
* @param nodes The list of nodes to check - if falsy, all nodes in the graph will be checked
|
|
* @param out Array to write visible nodes into - if falsy, a new array is created instead
|
|
* @returns Array passed ({@link out}), or a new array containing all visible nodes
|
|
*/
|
|
computeVisibleNodes(nodes?: LGraphNode[], out?: LGraphNode[]): LGraphNode[] {
|
|
const visible_nodes = out || []
|
|
visible_nodes.length = 0
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
const _nodes = nodes || this.graph._nodes
|
|
for (const node of _nodes) {
|
|
node.updateArea(this.ctx)
|
|
// Not in visible area
|
|
if (!overlapBounding(this.visible_area, node.renderArea)) continue
|
|
|
|
visible_nodes.push(node)
|
|
}
|
|
return visible_nodes
|
|
}
|
|
|
|
/**
|
|
* Checks if a node is visible on the canvas.
|
|
* @param node The node to check
|
|
* @returns `true` if the node is visible, otherwise `false`
|
|
*/
|
|
isNodeVisible(node: LGraphNode): boolean {
|
|
return this.#visible_node_ids.has(node.id)
|
|
}
|
|
|
|
/**
|
|
* renders the whole canvas content, by rendering in two separated canvas, one containing the background grid and the connections, and one containing the nodes)
|
|
*/
|
|
draw(force_canvas?: boolean, force_bgcanvas?: boolean): void {
|
|
if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0) return
|
|
|
|
// fps counting
|
|
const now = LiteGraph.getTime()
|
|
this.render_time = (now - this.last_draw_time) * 0.001
|
|
this.last_draw_time = now
|
|
|
|
if (this.graph) this.ds.computeVisibleArea(this.viewport)
|
|
|
|
// Compute node size before drawing links.
|
|
if (this.dirty_canvas || force_canvas) {
|
|
this.computeVisibleNodes(undefined, this.visible_nodes)
|
|
// Update visible node IDs
|
|
this.#visible_node_ids = new Set(this.visible_nodes.map(node => node.id))
|
|
}
|
|
|
|
if (
|
|
this.dirty_bgcanvas ||
|
|
force_bgcanvas ||
|
|
this.always_render_background ||
|
|
(this.graph?._last_trigger_time &&
|
|
now - this.graph._last_trigger_time < 1000)
|
|
) {
|
|
this.drawBackCanvas()
|
|
}
|
|
|
|
if (this.dirty_canvas || force_canvas) this.drawFrontCanvas()
|
|
|
|
this.fps = this.render_time ? 1.0 / this.render_time : 0
|
|
this.frame++
|
|
}
|
|
|
|
/**
|
|
* draws the front canvas (the one containing all the nodes)
|
|
*/
|
|
drawFrontCanvas(): void {
|
|
this.dirty_canvas = false
|
|
|
|
const { ctx, canvas, linkConnector } = this
|
|
|
|
// @ts-expect-error
|
|
if (ctx.start2D && !this.viewport) {
|
|
// @ts-expect-error
|
|
ctx.start2D()
|
|
ctx.restore()
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
}
|
|
|
|
// clip dirty area if there is one, otherwise work in full canvas
|
|
const area = this.viewport || this.dirty_area
|
|
if (area) {
|
|
ctx.save()
|
|
ctx.beginPath()
|
|
ctx.rect(area[0], area[1], area[2], area[3])
|
|
ctx.clip()
|
|
}
|
|
|
|
// TODO: Set snapping value when changed instead of once per frame
|
|
this.#snapToGrid = this.#shiftDown || LiteGraph.alwaysSnapToGrid
|
|
? this.graph?.getSnapToGridSize()
|
|
: undefined
|
|
|
|
// clear
|
|
// canvas.width = canvas.width;
|
|
if (this.clear_background) {
|
|
if (area) ctx.clearRect(area[0], area[1], area[2], area[3])
|
|
else ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
}
|
|
|
|
// draw bg canvas
|
|
if (this.bgcanvas == this.canvas) {
|
|
this.drawBackCanvas()
|
|
} else {
|
|
const scale = window.devicePixelRatio
|
|
ctx.drawImage(
|
|
this.bgcanvas,
|
|
0,
|
|
0,
|
|
this.bgcanvas.width / scale,
|
|
this.bgcanvas.height / scale,
|
|
)
|
|
}
|
|
|
|
// rendering
|
|
this.onRender?.(canvas, ctx)
|
|
|
|
// info widget
|
|
if (this.show_info) {
|
|
this.renderInfo(ctx, area ? area[0] : 0, area ? area[1] : 0)
|
|
}
|
|
|
|
if (this.graph) {
|
|
// apply transformations
|
|
ctx.save()
|
|
this.ds.toCanvasContext(ctx)
|
|
|
|
// draw nodes
|
|
const { visible_nodes } = this
|
|
const drawSnapGuides = this.#snapToGrid && this.isDragging
|
|
|
|
for (const node of visible_nodes) {
|
|
ctx.save()
|
|
|
|
// Draw snap shadow
|
|
if (drawSnapGuides && this.selectedItems.has(node))
|
|
this.drawSnapGuide(ctx, node)
|
|
|
|
// Localise co-ordinates to node position
|
|
ctx.translate(node.pos[0], node.pos[1])
|
|
|
|
// Draw
|
|
this.drawNode(node, ctx)
|
|
|
|
ctx.restore()
|
|
}
|
|
|
|
// on top (debug)
|
|
if (this.render_execution_order) {
|
|
this.drawExecutionOrder(ctx)
|
|
}
|
|
|
|
// connections ontop?
|
|
if (this.graph.config.links_ontop) {
|
|
this.drawConnections(ctx)
|
|
}
|
|
|
|
if (linkConnector.isConnecting) {
|
|
// current connection (the one being dragged by the mouse)
|
|
const { renderLinks } = linkConnector
|
|
const highlightPos = this.#getHighlightPosition()
|
|
ctx.lineWidth = this.connections_width
|
|
|
|
for (const renderLink of renderLinks) {
|
|
const { fromSlot, fromPos: pos, fromDirection, dragDirection } = renderLink
|
|
const connShape = fromSlot.shape
|
|
const connType = fromSlot.type
|
|
|
|
const colour = connType === LiteGraph.EVENT
|
|
? LiteGraph.EVENT_LINK_COLOR
|
|
: LiteGraph.CONNECTING_LINK_COLOR
|
|
|
|
// the connection being dragged by the mouse
|
|
this.renderLink(
|
|
ctx,
|
|
pos,
|
|
highlightPos,
|
|
null,
|
|
false,
|
|
null,
|
|
colour,
|
|
fromDirection,
|
|
dragDirection,
|
|
)
|
|
|
|
ctx.beginPath()
|
|
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
|
|
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
|
|
ctx.fill()
|
|
ctx.beginPath()
|
|
ctx.rect(
|
|
this.graph_mouse[0] - 6 + 0.5,
|
|
this.graph_mouse[1] - 5 + 0.5,
|
|
14,
|
|
10,
|
|
)
|
|
} else if (connShape === RenderShape.ARROW) {
|
|
ctx.moveTo(pos[0] + 8, pos[1] + 0.5)
|
|
ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5)
|
|
ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5)
|
|
ctx.closePath()
|
|
} else {
|
|
ctx.arc(pos[0], pos[1], 4, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
ctx.beginPath()
|
|
ctx.arc(this.graph_mouse[0], this.graph_mouse[1], 4, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
}
|
|
|
|
// Gradient half-border over target node
|
|
this.#renderSnapHighlight(ctx, highlightPos)
|
|
}
|
|
|
|
// Area-selection rectangle
|
|
if (this.dragging_rectangle) {
|
|
const { eDown, eMove } = this.pointer
|
|
ctx.strokeStyle = "#FFF"
|
|
|
|
if (eDown && eMove) {
|
|
// Do not scale the selection box
|
|
const transform = ctx.getTransform()
|
|
const ratio = Math.max(1, window.devicePixelRatio)
|
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
|
|
const x = eDown.safeOffsetX
|
|
const y = eDown.safeOffsetY
|
|
ctx.strokeRect(x, y, eMove.safeOffsetX - x, eMove.safeOffsetY - y)
|
|
|
|
ctx.setTransform(transform)
|
|
} else {
|
|
// Fallback to legacy behaviour
|
|
const [x, y, w, h] = this.dragging_rectangle
|
|
ctx.strokeRect(x, y, w, h)
|
|
}
|
|
}
|
|
|
|
// on top of link center
|
|
if (this.over_link_center && this.render_link_tooltip)
|
|
this.drawLinkTooltip(ctx, this.over_link_center)
|
|
// to remove
|
|
else
|
|
this.onDrawLinkTooltip?.(ctx, null)
|
|
|
|
// custom info
|
|
this.onDrawForeground?.(ctx, this.visible_area)
|
|
|
|
ctx.restore()
|
|
}
|
|
|
|
this.onDrawOverlay?.(ctx)
|
|
|
|
if (area) ctx.restore()
|
|
}
|
|
|
|
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
|
|
#getLinkCentreOnPos(e: CanvasMouseEvent): LinkSegment | undefined {
|
|
for (const linkSegment of this.renderedPaths) {
|
|
const centre = linkSegment._pos
|
|
if (!centre) continue
|
|
|
|
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(): ReadOnlyPoint {
|
|
return LiteGraph.snaps_for_comfy
|
|
? this._highlight_pos ?? this.graph_mouse
|
|
: this.graph_mouse
|
|
}
|
|
|
|
/**
|
|
* Renders indicators showing where a link will connect if released.
|
|
* Partial border over target node and a highlight over the slot itself.
|
|
* @param ctx Canvas 2D context
|
|
*/
|
|
#renderSnapHighlight(
|
|
ctx: CanvasRenderingContext2D,
|
|
highlightPos: ReadOnlyPoint,
|
|
): void {
|
|
if (!this._highlight_pos) return
|
|
|
|
ctx.fillStyle = "#ffcc00"
|
|
ctx.beginPath()
|
|
const shape = this._highlight_input?.shape
|
|
|
|
if (shape === RenderShape.ARROW) {
|
|
ctx.moveTo(highlightPos[0] + 8, highlightPos[1] + 0.5)
|
|
ctx.lineTo(highlightPos[0] - 4, highlightPos[1] + 6 + 0.5)
|
|
ctx.lineTo(highlightPos[0] - 4, highlightPos[1] - 6 + 0.5)
|
|
ctx.closePath()
|
|
} else {
|
|
ctx.arc(highlightPos[0], highlightPos[1], 6, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
|
|
if (!LiteGraph.snap_highlights_node) return
|
|
|
|
const { linkConnector } = this
|
|
const { overReroute, overWidget } = linkConnector
|
|
// Reroute highlight
|
|
if (overReroute) {
|
|
const { globalAlpha } = ctx
|
|
ctx.globalAlpha = 1
|
|
overReroute.drawHighlight(ctx, "#ffcc00aa")
|
|
ctx.globalAlpha = globalAlpha
|
|
}
|
|
|
|
// Ensure we're mousing over a node and connecting a link
|
|
const node = this.node_over
|
|
if (!(node && linkConnector.isConnecting)) return
|
|
|
|
const { strokeStyle, lineWidth } = ctx
|
|
|
|
const area = node.boundingRect
|
|
const gap = 3
|
|
const radius = LiteGraph.ROUND_RADIUS + gap
|
|
|
|
const x = area[0] - gap
|
|
const y = area[1] - gap
|
|
const width = area[2] + gap * 2
|
|
const height = area[3] + gap * 2
|
|
|
|
ctx.beginPath()
|
|
ctx.roundRect(x, y, width, height, radius)
|
|
|
|
// TODO: Currently works on LTR slots only. Add support for other directions.
|
|
const start = linkConnector.state.connectingTo === "output" ? 0 : 1
|
|
const inverter = start ? -1 : 1
|
|
|
|
// Radial highlight centred on highlight pos
|
|
const hx = highlightPos[0]
|
|
const hy = highlightPos[1]
|
|
const gRadius = width < height
|
|
? width
|
|
: width * Math.max(height / width, 0.5)
|
|
|
|
const gradient = ctx.createRadialGradient(hx, hy, 0, hx, hy, gRadius)
|
|
gradient.addColorStop(1, "#00000000")
|
|
gradient.addColorStop(0, "#ffcc00aa")
|
|
|
|
// Linear gradient over half the node.
|
|
const linearGradient = ctx.createLinearGradient(x, y, x + width, y)
|
|
linearGradient.addColorStop(0.5, "#00000000")
|
|
linearGradient.addColorStop(start + 0.67 * inverter, "#ddeeff33")
|
|
linearGradient.addColorStop(start + inverter, "#ffcc0055")
|
|
|
|
/**
|
|
* Workaround for a canvas render issue.
|
|
* In Chromium 129 (2024-10-15), rounded corners can be rendered with the wrong part of a gradient colour.
|
|
* Occurs only at certain thicknesses / arc sizes.
|
|
*/
|
|
ctx.setLineDash([radius, radius * 0.001])
|
|
|
|
ctx.lineWidth = 1
|
|
ctx.strokeStyle = linearGradient
|
|
ctx.stroke()
|
|
|
|
if (overWidget) {
|
|
const { computedHeight } = overWidget
|
|
|
|
ctx.beginPath()
|
|
const { pos: [nodeX, nodeY] } = node
|
|
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
|
if (
|
|
overWidget.type.startsWith("custom") &&
|
|
computedHeight != null &&
|
|
computedHeight > height * 2
|
|
) {
|
|
// Most likely DOM widget text box
|
|
ctx.rect(
|
|
nodeX + 9,
|
|
nodeY + overWidget.y + 9,
|
|
(overWidget.width ?? area[2]) - 18,
|
|
computedHeight - 18,
|
|
)
|
|
} else {
|
|
// Regular widget, probably
|
|
ctx.roundRect(
|
|
nodeX + 15,
|
|
nodeY + overWidget.y,
|
|
overWidget.width ?? area[2],
|
|
height,
|
|
height * 0.5,
|
|
)
|
|
}
|
|
ctx.stroke()
|
|
}
|
|
|
|
ctx.strokeStyle = gradient
|
|
ctx.stroke()
|
|
|
|
ctx.setLineDash([])
|
|
ctx.lineWidth = lineWidth
|
|
ctx.strokeStyle = strokeStyle
|
|
}
|
|
|
|
/**
|
|
* draws some useful stats in the corner of the canvas
|
|
*/
|
|
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
|
x = x || 10
|
|
y = y || this.canvas.offsetHeight - 80
|
|
|
|
ctx.save()
|
|
ctx.translate(x, y)
|
|
|
|
ctx.font = "10px Arial"
|
|
ctx.fillStyle = "#888"
|
|
ctx.textAlign = "left"
|
|
if (this.graph) {
|
|
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
|
|
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
|
|
ctx.fillText(`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`, 5, 13 * 3)
|
|
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
|
|
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
|
|
} else {
|
|
ctx.fillText("No graph selected", 5, 13 * 1)
|
|
}
|
|
ctx.restore()
|
|
}
|
|
|
|
/**
|
|
* draws the back canvas (the one containing the background and the connections)
|
|
*/
|
|
drawBackCanvas(): void {
|
|
const canvas = this.bgcanvas
|
|
if (
|
|
canvas.width != this.canvas.width ||
|
|
canvas.height != this.canvas.height
|
|
) {
|
|
canvas.width = this.canvas.width
|
|
canvas.height = this.canvas.height
|
|
}
|
|
|
|
if (!this.bgctx) {
|
|
this.bgctx = this.bgcanvas.getContext("2d")
|
|
}
|
|
const ctx = this.bgctx
|
|
if (!ctx) throw new TypeError("Background canvas context was null.")
|
|
|
|
const viewport = this.viewport || [0, 0, ctx.canvas.width, ctx.canvas.height]
|
|
|
|
// clear
|
|
if (this.clear_background) {
|
|
ctx.clearRect(viewport[0], viewport[1], viewport[2], viewport[3])
|
|
}
|
|
|
|
const bg_already_painted = this.onRenderBackground
|
|
? this.onRenderBackground(canvas, ctx)
|
|
: false
|
|
|
|
// reset in case of error
|
|
if (!this.viewport) {
|
|
const scale = window.devicePixelRatio
|
|
ctx.restore()
|
|
ctx.setTransform(scale, 0, 0, scale, 0, 0)
|
|
}
|
|
|
|
if (this.graph) {
|
|
// apply transformations
|
|
ctx.save()
|
|
this.ds.toCanvasContext(ctx)
|
|
|
|
// render BG
|
|
if (
|
|
this.ds.scale < 1.5 &&
|
|
!bg_already_painted &&
|
|
this.clear_background_color
|
|
) {
|
|
ctx.fillStyle = this.clear_background_color
|
|
ctx.fillRect(
|
|
this.visible_area[0],
|
|
this.visible_area[1],
|
|
this.visible_area[2],
|
|
this.visible_area[3],
|
|
)
|
|
}
|
|
|
|
if (this.background_image && this.ds.scale > 0.5 && !bg_already_painted) {
|
|
if (this.zoom_modify_alpha) {
|
|
ctx.globalAlpha = (1.0 - 0.5 / this.ds.scale) * this.editor_alpha
|
|
} else {
|
|
ctx.globalAlpha = this.editor_alpha
|
|
}
|
|
ctx.imageSmoothingEnabled = false
|
|
if (!this._bg_img || this._bg_img.name != this.background_image) {
|
|
this._bg_img = new Image()
|
|
this._bg_img.name = this.background_image
|
|
this._bg_img.src = this.background_image
|
|
const that = this
|
|
this._bg_img.addEventListener("load", function () {
|
|
that.draw(true, true)
|
|
})
|
|
}
|
|
|
|
let pattern = this._pattern
|
|
if (pattern == null && this._bg_img.width > 0) {
|
|
pattern = ctx.createPattern(this._bg_img, "repeat")
|
|
this._pattern_img = this._bg_img
|
|
this._pattern = pattern
|
|
}
|
|
|
|
// NOTE: This ridiculous kludge provides a significant performance increase when rendering many large (> canvas width) paths in HTML canvas.
|
|
// I could find no documentation or explanation. Requires that the BG image is set.
|
|
if (pattern) {
|
|
ctx.fillStyle = pattern
|
|
ctx.fillRect(
|
|
this.visible_area[0],
|
|
this.visible_area[1],
|
|
this.visible_area[2],
|
|
this.visible_area[3],
|
|
)
|
|
ctx.fillStyle = "transparent"
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0
|
|
ctx.imageSmoothingEnabled = true
|
|
}
|
|
|
|
// groups
|
|
if (this.graph._groups.length) {
|
|
this.drawGroups(canvas, ctx)
|
|
}
|
|
|
|
this.onDrawBackground?.(ctx, this.visible_area)
|
|
|
|
// DEBUG: show clipping area
|
|
// ctx.fillStyle = "red";
|
|
// ctx.fillRect( this.visible_area[0] + 10, this.visible_area[1] + 10, this.visible_area[2] - 20, this.visible_area[3] - 20);
|
|
// bg
|
|
if (this.render_canvas_border) {
|
|
ctx.strokeStyle = "#235"
|
|
ctx.strokeRect(0, 0, canvas.width, canvas.height)
|
|
}
|
|
|
|
if (this.render_connections_shadows) {
|
|
ctx.shadowColor = "#000"
|
|
ctx.shadowOffsetX = 0
|
|
ctx.shadowOffsetY = 0
|
|
ctx.shadowBlur = 6
|
|
} else {
|
|
ctx.shadowColor = "rgba(0,0,0,0)"
|
|
}
|
|
|
|
// draw connections
|
|
this.drawConnections(ctx)
|
|
|
|
ctx.shadowColor = "rgba(0,0,0,0)"
|
|
|
|
// restore state
|
|
ctx.restore()
|
|
}
|
|
|
|
this.dirty_bgcanvas = false
|
|
// Forces repaint of the front canvas.
|
|
this.dirty_canvas = true
|
|
}
|
|
|
|
/**
|
|
* draws the given node inside the canvas
|
|
*/
|
|
drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void {
|
|
this.current_node = node
|
|
|
|
const color = node.renderingColor
|
|
const bgcolor = node.renderingBgColor
|
|
|
|
const { low_quality, editor_alpha } = this
|
|
ctx.globalAlpha = editor_alpha
|
|
|
|
if (this.render_shadows && !low_quality) {
|
|
ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR
|
|
ctx.shadowOffsetX = 2 * this.ds.scale
|
|
ctx.shadowOffsetY = 2 * this.ds.scale
|
|
ctx.shadowBlur = 3 * this.ds.scale
|
|
} else {
|
|
ctx.shadowColor = "transparent"
|
|
}
|
|
|
|
// custom draw collapsed method (draw after shadows because they are affected)
|
|
if (node.flags.collapsed && node.onDrawCollapsed?.(ctx, this) == true)
|
|
return
|
|
|
|
// clip if required (mask)
|
|
const shape = node._shape || RenderShape.BOX
|
|
const size = LGraphCanvas.#temp_vec2
|
|
size.set(node.renderingSize)
|
|
|
|
if (node.collapsed) {
|
|
ctx.font = this.inner_text_font
|
|
}
|
|
|
|
if (node.clip_area) {
|
|
// Start clipping
|
|
ctx.save()
|
|
ctx.beginPath()
|
|
if (shape == RenderShape.BOX) {
|
|
ctx.rect(0, 0, size[0], size[1])
|
|
} else if (shape == RenderShape.ROUND) {
|
|
ctx.roundRect(0, 0, size[0], size[1], [10])
|
|
} else if (shape == RenderShape.CIRCLE) {
|
|
ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2)
|
|
}
|
|
ctx.clip()
|
|
}
|
|
|
|
// draw shape
|
|
this.drawNodeShape(
|
|
node,
|
|
ctx,
|
|
size,
|
|
color,
|
|
bgcolor,
|
|
!!node.selected,
|
|
)
|
|
|
|
if (!low_quality) {
|
|
node.drawBadges(ctx)
|
|
}
|
|
|
|
ctx.shadowColor = "transparent"
|
|
|
|
// TODO: Legacy behaviour: onDrawForeground received ctx in this state
|
|
ctx.strokeStyle = LiteGraph.NODE_BOX_OUTLINE_COLOR
|
|
|
|
// Draw Foreground
|
|
node.onDrawForeground?.(ctx, this, this.canvas)
|
|
|
|
// connection slots
|
|
ctx.font = this.inner_text_font
|
|
|
|
// render inputs and outputs
|
|
if (!node.collapsed) {
|
|
const slotsBounds = node.layoutSlots()
|
|
const widgetStartY = slotsBounds ? slotsBounds[1] + slotsBounds[3] : 0
|
|
node.layoutWidgets({ widgetStartY })
|
|
node.layoutWidgetInputSlots()
|
|
|
|
node.drawSlots(ctx, {
|
|
fromSlot: this.linkConnector.renderLinks[0]?.fromSlot,
|
|
colorContext: this,
|
|
editorAlpha: this.editor_alpha,
|
|
lowQuality: this.low_quality,
|
|
})
|
|
|
|
ctx.textAlign = "left"
|
|
ctx.globalAlpha = 1
|
|
|
|
this.drawNodeWidgets(node, widgetStartY, ctx)
|
|
} else if (this.render_collapsed_slots) {
|
|
node.drawCollapsedSlots(ctx)
|
|
}
|
|
|
|
if (node.clip_area) {
|
|
ctx.restore()
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0
|
|
}
|
|
|
|
/**
|
|
* Draws the link mouseover effect and tooltip.
|
|
* @param ctx Canvas 2D context to draw on
|
|
* @param link The link to render the mouseover effect for
|
|
* @remarks
|
|
* Called against {@link LGraphCanvas.over_link_center}.
|
|
* @todo Split tooltip from hover, so it can be drawn / eased separately
|
|
*/
|
|
drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LinkSegment): void {
|
|
const pos = link._pos
|
|
ctx.fillStyle = "black"
|
|
ctx.beginPath()
|
|
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
|
const transform = ctx.getTransform()
|
|
ctx.translate(pos[0], pos[1])
|
|
// Assertion: Number.isFinite guarantees this is a number.
|
|
if (Number.isFinite(link._centreAngle)) ctx.rotate(link._centreAngle as number)
|
|
ctx.moveTo(-2, -3)
|
|
ctx.lineTo(+4, 0)
|
|
ctx.lineTo(-2, +3)
|
|
ctx.setTransform(transform)
|
|
} else if (
|
|
this.linkMarkerShape == null ||
|
|
this.linkMarkerShape === LinkMarkerShape.Circle
|
|
) {
|
|
ctx.arc(pos[0], pos[1], 3, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
|
|
// @ts-expect-error TODO: Better value typing
|
|
const { data } = link
|
|
if (data == null) return
|
|
|
|
// @ts-expect-error TODO: Better value typing
|
|
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return
|
|
|
|
let text: string | null = null
|
|
|
|
if (typeof data === "number")
|
|
text = data.toFixed(2)
|
|
else if (typeof data === "string")
|
|
text = `"${data}"`
|
|
else if (typeof data === "boolean")
|
|
text = String(data)
|
|
else if (data.toToolTip)
|
|
text = data.toToolTip()
|
|
else
|
|
text = `[${data.constructor.name}]`
|
|
|
|
if (text == null) return
|
|
|
|
// Hard-coded tooltip limit
|
|
text = text.substring(0, 30)
|
|
|
|
ctx.font = "14px Courier New"
|
|
const info = ctx.measureText(text)
|
|
const w = info.width + 20
|
|
const h = 24
|
|
ctx.shadowColor = "black"
|
|
ctx.shadowOffsetX = 2
|
|
ctx.shadowOffsetY = 2
|
|
ctx.shadowBlur = 3
|
|
ctx.fillStyle = "#454"
|
|
ctx.beginPath()
|
|
ctx.roundRect(pos[0] - w * 0.5, pos[1] - 15 - h, w, h, [3])
|
|
ctx.moveTo(pos[0] - 10, pos[1] - 15)
|
|
ctx.lineTo(pos[0] + 10, pos[1] - 15)
|
|
ctx.lineTo(pos[0], pos[1] - 5)
|
|
ctx.fill()
|
|
ctx.shadowColor = "transparent"
|
|
ctx.textAlign = "center"
|
|
ctx.fillStyle = "#CEC"
|
|
ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3)
|
|
}
|
|
|
|
/**
|
|
* Draws the shape of the given node on the canvas
|
|
* @param node The node to draw
|
|
* @param ctx 2D canvas rendering context used to draw
|
|
* @param size Size of the background to draw, in graph units. Differs from node size if collapsed, etc.
|
|
* @param fgcolor Foreground colour - used for text
|
|
* @param bgcolor Background colour of the node
|
|
* @param selected Whether to render the node as selected. Likely to be removed in future, as current usage is simply the selected property of the node.
|
|
*/
|
|
drawNodeShape(
|
|
node: LGraphNode,
|
|
ctx: CanvasRenderingContext2D,
|
|
size: Size,
|
|
fgcolor: CanvasColour,
|
|
bgcolor: CanvasColour,
|
|
selected: boolean,
|
|
): void {
|
|
// Rendering options
|
|
ctx.strokeStyle = fgcolor
|
|
ctx.fillStyle = LiteGraph.use_legacy_node_error_indicator ? "#F00" : bgcolor
|
|
|
|
const title_height = LiteGraph.NODE_TITLE_HEIGHT
|
|
const { low_quality } = this
|
|
|
|
const { collapsed } = node.flags
|
|
const shape = node.renderingShape
|
|
const { title_mode } = node
|
|
|
|
const render_title = title_mode == TitleMode.TRANSPARENT_TITLE || title_mode == TitleMode.NO_TITLE
|
|
? false
|
|
: true
|
|
|
|
// Normalised node dimensions
|
|
const area = LGraphCanvas.#tmp_area
|
|
area.set(node.boundingRect)
|
|
area[0] -= node.pos[0]
|
|
area[1] -= node.pos[1]
|
|
|
|
const old_alpha = ctx.globalAlpha
|
|
|
|
// Draw node background (shape)
|
|
ctx.beginPath()
|
|
if (shape == RenderShape.BOX || low_quality) {
|
|
ctx.fillRect(area[0], area[1], area[2], area[3])
|
|
} else if (shape == RenderShape.ROUND || shape == RenderShape.CARD) {
|
|
ctx.roundRect(
|
|
area[0],
|
|
area[1],
|
|
area[2],
|
|
area[3],
|
|
shape == RenderShape.CARD
|
|
? [LiteGraph.ROUND_RADIUS, LiteGraph.ROUND_RADIUS, 0, 0]
|
|
: [LiteGraph.ROUND_RADIUS],
|
|
)
|
|
} else if (shape == RenderShape.CIRCLE) {
|
|
ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
|
|
if (node.has_errors && !LiteGraph.use_legacy_node_error_indicator) {
|
|
strokeShape(ctx, area, {
|
|
shape,
|
|
title_mode,
|
|
title_height,
|
|
padding: 12,
|
|
colour: LiteGraph.NODE_ERROR_COLOUR,
|
|
collapsed,
|
|
thickness: 10,
|
|
})
|
|
}
|
|
|
|
// Separator - title bar <-> body
|
|
if (!collapsed && render_title) {
|
|
ctx.shadowColor = "transparent"
|
|
ctx.fillStyle = "rgba(0,0,0,0.2)"
|
|
ctx.fillRect(0, -1, area[2], 2)
|
|
}
|
|
ctx.shadowColor = "transparent"
|
|
|
|
node.onDrawBackground?.(ctx)
|
|
|
|
// Title bar background (remember, it is rendered ABOVE the node)
|
|
if (render_title || title_mode == TitleMode.TRANSPARENT_TITLE) {
|
|
node.drawTitleBarBackground(ctx, {
|
|
scale: this.ds.scale,
|
|
low_quality,
|
|
})
|
|
|
|
// title box
|
|
node.drawTitleBox(ctx, {
|
|
scale: this.ds.scale,
|
|
low_quality,
|
|
box_size: 10,
|
|
})
|
|
|
|
ctx.globalAlpha = old_alpha
|
|
|
|
// title text
|
|
node.drawTitleText(ctx, {
|
|
scale: this.ds.scale,
|
|
default_title_color: this.node_title_color,
|
|
low_quality,
|
|
})
|
|
|
|
// custom title render
|
|
node.onDrawTitle?.(ctx)
|
|
}
|
|
|
|
// render selection marker
|
|
if (selected) {
|
|
node.onBounding?.(area)
|
|
|
|
const padding = node.has_errors && !LiteGraph.use_legacy_node_error_indicator ? 20 : undefined
|
|
|
|
strokeShape(ctx, area, {
|
|
shape,
|
|
title_height,
|
|
title_mode,
|
|
padding,
|
|
collapsed: node.flags?.collapsed,
|
|
})
|
|
}
|
|
|
|
// these counter helps in conditioning drawing based on if the node has been executed or an action occurred
|
|
if (node.execute_triggered != null && node.execute_triggered > 0) node.execute_triggered--
|
|
if (node.action_triggered != null && node.action_triggered > 0) node.action_triggered--
|
|
}
|
|
|
|
/**
|
|
* Draws a snap guide for a {@link Positionable} item.
|
|
*
|
|
* Initial design was a simple white rectangle representing the location the
|
|
* item would land if dropped.
|
|
* @param ctx The 2D canvas context to draw on
|
|
* @param item The item to draw a snap guide for
|
|
* @param shape The shape of the snap guide to draw
|
|
* @todo Update to align snapping with boundingRect
|
|
* @todo Shapes
|
|
*/
|
|
drawSnapGuide(
|
|
ctx: CanvasRenderingContext2D,
|
|
item: Positionable,
|
|
shape = RenderShape.ROUND,
|
|
) {
|
|
const snapGuide = LGraphCanvas.#temp
|
|
snapGuide.set(item.boundingRect)
|
|
|
|
// Not all items have pos equal to top-left of bounds
|
|
const { pos } = item
|
|
const offsetX = pos[0] - snapGuide[0]
|
|
const offsetY = pos[1] - snapGuide[1]
|
|
|
|
// Normalise boundingRect to pos to snap
|
|
snapGuide[0] += offsetX
|
|
snapGuide[1] += offsetY
|
|
if (this.#snapToGrid) snapPoint(snapGuide, this.#snapToGrid)
|
|
snapGuide[0] -= offsetX
|
|
snapGuide[1] -= offsetY
|
|
|
|
const { globalAlpha } = ctx
|
|
ctx.globalAlpha = 1
|
|
ctx.beginPath()
|
|
const [x, y, w, h] = snapGuide
|
|
if (shape === RenderShape.CIRCLE) {
|
|
const midX = x + (w * 0.5)
|
|
const midY = y + (h * 0.5)
|
|
const radius = Math.min(w * 0.5, h * 0.5)
|
|
ctx.arc(midX, midY, radius, 0, Math.PI * 2)
|
|
} else {
|
|
ctx.rect(x, y, w, h)
|
|
}
|
|
|
|
ctx.lineWidth = 0.5
|
|
ctx.strokeStyle = "#FFFFFF66"
|
|
ctx.fillStyle = "#FFFFFF22"
|
|
ctx.fill()
|
|
ctx.stroke()
|
|
ctx.globalAlpha = globalAlpha
|
|
}
|
|
|
|
drawConnections(ctx: CanvasRenderingContext2D): void {
|
|
this.renderedPaths.clear()
|
|
if (this.links_render_mode === LinkRenderType.HIDDEN_LINK) return
|
|
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const visibleReroutes: Reroute[] = []
|
|
|
|
const now = LiteGraph.getTime()
|
|
const { visible_area } = this
|
|
LGraphCanvas.#margin_area[0] = visible_area[0] - 20
|
|
LGraphCanvas.#margin_area[1] = visible_area[1] - 20
|
|
LGraphCanvas.#margin_area[2] = visible_area[2] + 40
|
|
LGraphCanvas.#margin_area[3] = visible_area[3] + 40
|
|
|
|
// draw connections
|
|
ctx.lineWidth = this.connections_width
|
|
|
|
ctx.fillStyle = "#AAA"
|
|
ctx.strokeStyle = "#AAA"
|
|
ctx.globalAlpha = this.editor_alpha
|
|
// for every node
|
|
const nodes = graph._nodes
|
|
for (const node of nodes) {
|
|
// for every input (we render just inputs because it is easier as every slot can only have one input)
|
|
const { inputs } = node
|
|
if (!inputs?.length) continue
|
|
|
|
for (const [i, input] of inputs.entries()) {
|
|
if (!input || input.link == null) continue
|
|
|
|
const link_id = input.link
|
|
const link = graph._links.get(link_id)
|
|
if (!link) continue
|
|
|
|
const endPos = node.getInputPos(i)
|
|
|
|
// find link info
|
|
const start_node = graph.getNodeById(link.origin_id)
|
|
if (start_node == null) continue
|
|
|
|
const outputId = link.origin_slot
|
|
const startPos: Point = outputId === -1
|
|
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
|
|
: start_node.getOutputPos(outputId)
|
|
|
|
const output = start_node.outputs[outputId]
|
|
if (!output) continue
|
|
|
|
this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, output.dir, input.dir)
|
|
}
|
|
}
|
|
|
|
if (graph.floatingLinks.size > 0) {
|
|
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
|
|
}
|
|
|
|
// Render the reroute circles
|
|
for (const reroute of visibleReroutes) {
|
|
if (
|
|
this.#snapToGrid &&
|
|
this.isDragging &&
|
|
this.selectedItems.has(reroute)
|
|
) {
|
|
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
|
|
}
|
|
reroute.draw(ctx)
|
|
}
|
|
ctx.globalAlpha = 1
|
|
}
|
|
|
|
#renderFloatingLinks(ctx: CanvasRenderingContext2D, graph: LGraph, visibleReroutes: Reroute[], now: number) {
|
|
// Floating reroutes
|
|
for (const link of graph.floatingLinks.values()) {
|
|
const reroutes = LLink.getReroutes(graph, link)
|
|
const firstReroute = reroutes[0]
|
|
const reroute = reroutes.at(-1)
|
|
if (!firstReroute || !reroute?.floating) continue
|
|
|
|
// Input not connected
|
|
if (reroute.floating.slotType === "input") {
|
|
const node = graph.getNodeById(link.target_id)
|
|
if (!node) continue
|
|
|
|
const startPos = firstReroute.pos
|
|
const endPos = node.getInputPos(link.target_slot)
|
|
const endDirection = node.inputs[link.target_slot]?.dir
|
|
|
|
firstReroute._dragging = true
|
|
this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, LinkDirection.CENTER, endDirection)
|
|
} else {
|
|
const node = graph.getNodeById(link.origin_id)
|
|
if (!node) continue
|
|
|
|
const startPos = node.getOutputPos(link.origin_slot)
|
|
const endPos = reroute.pos
|
|
const startDirection = node.outputs[link.origin_slot]?.dir
|
|
|
|
link._dragging = true
|
|
this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, startDirection, LinkDirection.CENTER)
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderAllLinkSegments(
|
|
ctx: CanvasRenderingContext2D,
|
|
link: LLink,
|
|
startPos: Point,
|
|
endPos: Point,
|
|
visibleReroutes: Reroute[],
|
|
now: number,
|
|
startDirection?: LinkDirection,
|
|
endDirection?: LinkDirection,
|
|
) {
|
|
const { graph, renderedPaths } = this
|
|
if (!graph) return
|
|
|
|
// Get all points this link passes through
|
|
const reroutes = LLink.getReroutes(graph, link)
|
|
const points: [Point, ...Point[], Point] = [
|
|
startPos,
|
|
...reroutes.map(x => x.pos),
|
|
endPos,
|
|
]
|
|
|
|
// Bounding box of all points (bezier overshoot on long links will be cut)
|
|
const pointsX = points.map(x => x[0])
|
|
const pointsY = points.map(x => x[1])
|
|
LGraphCanvas.#link_bounding[0] = Math.min(...pointsX)
|
|
LGraphCanvas.#link_bounding[1] = Math.min(...pointsY)
|
|
LGraphCanvas.#link_bounding[2] = Math.max(...pointsX) - LGraphCanvas.#link_bounding[0]
|
|
LGraphCanvas.#link_bounding[3] = Math.max(...pointsY) - LGraphCanvas.#link_bounding[1]
|
|
|
|
// skip links outside of the visible area of the canvas
|
|
if (!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area))
|
|
return
|
|
|
|
const start_dir = startDirection || LinkDirection.RIGHT
|
|
const end_dir = endDirection || LinkDirection.LEFT
|
|
|
|
// Has reroutes
|
|
if (reroutes.length) {
|
|
let startControl: Point | undefined
|
|
|
|
const l = reroutes.length
|
|
for (let j = 0; j < l; j++) {
|
|
const reroute = reroutes[j]
|
|
|
|
// Only render once
|
|
if (!renderedPaths.has(reroute)) {
|
|
renderedPaths.add(reroute)
|
|
visibleReroutes.push(reroute)
|
|
reroute._colour = link.color ||
|
|
LGraphCanvas.link_type_colors[link.type] ||
|
|
this.default_link_color
|
|
|
|
const prevReroute = reroute.parentId == null ? undefined : graph.reroutes.get(reroute.parentId)
|
|
const rerouteStartPos = prevReroute?.pos ?? startPos
|
|
reroute.calculateAngle(this.last_draw_time, graph, rerouteStartPos)
|
|
|
|
// Skip the first segment if it is being dragged
|
|
if (!reroute._dragging) {
|
|
this.renderLink(
|
|
ctx,
|
|
rerouteStartPos,
|
|
reroute.pos,
|
|
link,
|
|
false,
|
|
0,
|
|
null,
|
|
start_dir,
|
|
end_dir,
|
|
{
|
|
startControl,
|
|
endControl: reroute.controlPoint,
|
|
reroute,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
if (!startControl && reroutes.at(-1)?.floating?.slotType === "input") {
|
|
// Floating link connected to an input
|
|
startControl = [0, 0] satisfies Point
|
|
} else {
|
|
// Calculate start control for the next iter control point
|
|
const nextPos = reroutes[j + 1]?.pos ?? endPos
|
|
const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25)
|
|
startControl = [dist * reroute.cos, dist * reroute.sin]
|
|
}
|
|
}
|
|
|
|
// Skip the last segment if it is being dragged
|
|
if (link._dragging) return
|
|
|
|
// Use runtime fallback; TypeScript cannot evaluate this correctly.
|
|
const segmentStartPos = points.at(-2) ?? startPos
|
|
|
|
// Render final link segment
|
|
this.renderLink(
|
|
ctx,
|
|
segmentStartPos,
|
|
endPos,
|
|
link,
|
|
false,
|
|
0,
|
|
null,
|
|
start_dir,
|
|
end_dir,
|
|
{ startControl },
|
|
)
|
|
// Skip normal render when link is being dragged
|
|
} else if (!link._dragging) {
|
|
this.renderLink(
|
|
ctx,
|
|
startPos,
|
|
endPos,
|
|
link,
|
|
false,
|
|
0,
|
|
null,
|
|
start_dir,
|
|
end_dir,
|
|
)
|
|
}
|
|
renderedPaths.add(link)
|
|
|
|
// event triggered rendered on top
|
|
if (link?._last_time && now - link._last_time < 1000) {
|
|
const f = 2.0 - (now - link._last_time) * 0.002
|
|
const tmp = ctx.globalAlpha
|
|
ctx.globalAlpha = tmp * f
|
|
this.renderLink(
|
|
ctx,
|
|
startPos,
|
|
endPos,
|
|
link,
|
|
true,
|
|
f,
|
|
"white",
|
|
start_dir,
|
|
end_dir,
|
|
)
|
|
ctx.globalAlpha = tmp
|
|
}
|
|
}
|
|
|
|
/**
|
|
* draws a link between two points
|
|
* @param ctx Canvas 2D rendering context
|
|
* @param a start pos
|
|
* @param b end pos
|
|
* @param link the link object with all the link info
|
|
* @param skip_border ignore the shadow of the link
|
|
* @param flow show flow animation (for events)
|
|
* @param color the color for the link
|
|
* @param start_dir the direction enum
|
|
* @param end_dir the direction enum
|
|
*/
|
|
renderLink(
|
|
ctx: CanvasRenderingContext2D,
|
|
a: ReadOnlyPoint,
|
|
b: ReadOnlyPoint,
|
|
link: LLink | null,
|
|
skip_border: boolean,
|
|
flow: number | null,
|
|
color: CanvasColour | null,
|
|
start_dir: LinkDirection,
|
|
end_dir: LinkDirection,
|
|
{
|
|
startControl,
|
|
endControl,
|
|
reroute,
|
|
num_sublines = 1,
|
|
}: {
|
|
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
|
|
reroute?: Reroute
|
|
/** Offset of the bezier curve control point from {@link a point a} (output side) */
|
|
startControl?: ReadOnlyPoint
|
|
/** Offset of the bezier curve control point from {@link b point b} (input side) */
|
|
endControl?: ReadOnlyPoint
|
|
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
|
|
num_sublines?: number
|
|
} = {},
|
|
): void {
|
|
const linkColour =
|
|
link != null && this.highlighted_links[link.id]
|
|
? "#FFF"
|
|
: color ||
|
|
link?.color ||
|
|
(link?.type != null && LGraphCanvas.link_type_colors[link.type]) ||
|
|
this.default_link_color
|
|
const startDir = start_dir || LinkDirection.RIGHT
|
|
const endDir = end_dir || LinkDirection.LEFT
|
|
|
|
const dist = this.links_render_mode == LinkRenderType.SPLINE_LINK && (!endControl || !startControl)
|
|
? distance(a, b)
|
|
: 0
|
|
|
|
// TODO: Subline code below was inserted in the wrong place - should be before this statement
|
|
if (this.render_connections_border && !this.low_quality) {
|
|
ctx.lineWidth = this.connections_width + 4
|
|
}
|
|
ctx.lineJoin = "round"
|
|
num_sublines ||= 1
|
|
if (num_sublines > 1) ctx.lineWidth = 0.5
|
|
|
|
// begin line shape
|
|
const path = new Path2D()
|
|
|
|
/** The link or reroute we're currently rendering */
|
|
const linkSegment = reroute ?? link
|
|
if (linkSegment) linkSegment.path = path
|
|
|
|
const innerA = LGraphCanvas.#lTempA
|
|
const innerB = LGraphCanvas.#lTempB
|
|
|
|
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
|
|
const pos: Point = linkSegment?._pos ?? [0, 0]
|
|
|
|
for (let i = 0; i < num_sublines; i++) {
|
|
const offsety = (i - (num_sublines - 1) * 0.5) * 5
|
|
innerA[0] = a[0]
|
|
innerA[1] = a[1]
|
|
innerB[0] = b[0]
|
|
innerB[1] = b[1]
|
|
|
|
if (this.links_render_mode == LinkRenderType.SPLINE_LINK) {
|
|
if (endControl) {
|
|
innerB[0] = b[0] + endControl[0]
|
|
innerB[1] = b[1] + endControl[1]
|
|
} else {
|
|
this.#addSplineOffset(innerB, endDir, dist)
|
|
}
|
|
if (startControl) {
|
|
innerA[0] = a[0] + startControl[0]
|
|
innerA[1] = a[1] + startControl[1]
|
|
} else {
|
|
this.#addSplineOffset(innerA, startDir, dist)
|
|
}
|
|
path.moveTo(a[0], a[1] + offsety)
|
|
path.bezierCurveTo(
|
|
innerA[0],
|
|
innerA[1] + offsety,
|
|
innerB[0],
|
|
innerB[1] + offsety,
|
|
b[0],
|
|
b[1] + offsety,
|
|
)
|
|
|
|
// Calculate centre point
|
|
findPointOnCurve(pos, a, b, innerA, innerB, 0.5)
|
|
|
|
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
|
const justPastCentre = LGraphCanvas.#lTempC
|
|
findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51)
|
|
|
|
linkSegment._centreAngle = Math.atan2(
|
|
justPastCentre[1] - pos[1],
|
|
justPastCentre[0] - pos[0],
|
|
)
|
|
}
|
|
} else if (this.links_render_mode == LinkRenderType.LINEAR_LINK) {
|
|
const l = 15
|
|
switch (startDir) {
|
|
case LinkDirection.LEFT:
|
|
innerA[0] += -l
|
|
break
|
|
case LinkDirection.RIGHT:
|
|
innerA[0] += l
|
|
break
|
|
case LinkDirection.UP:
|
|
innerA[1] += -l
|
|
break
|
|
case LinkDirection.DOWN:
|
|
innerA[1] += l
|
|
break
|
|
}
|
|
switch (endDir) {
|
|
case LinkDirection.LEFT:
|
|
innerB[0] += -l
|
|
break
|
|
case LinkDirection.RIGHT:
|
|
innerB[0] += l
|
|
break
|
|
case LinkDirection.UP:
|
|
innerB[1] += -l
|
|
break
|
|
case LinkDirection.DOWN:
|
|
innerB[1] += l
|
|
break
|
|
}
|
|
path.moveTo(a[0], a[1] + offsety)
|
|
path.lineTo(innerA[0], innerA[1] + offsety)
|
|
path.lineTo(innerB[0], innerB[1] + offsety)
|
|
path.lineTo(b[0], b[1] + offsety)
|
|
|
|
// Calculate centre point
|
|
pos[0] = (innerA[0] + innerB[0]) * 0.5
|
|
pos[1] = (innerA[1] + innerB[1]) * 0.5
|
|
|
|
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
|
linkSegment._centreAngle = Math.atan2(
|
|
innerB[1] - innerA[1],
|
|
innerB[0] - innerA[0],
|
|
)
|
|
}
|
|
} else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) {
|
|
if (startDir == LinkDirection.RIGHT) {
|
|
innerA[0] += 10
|
|
} else {
|
|
innerA[1] += 10
|
|
}
|
|
if (endDir == LinkDirection.LEFT) {
|
|
innerB[0] -= 10
|
|
} else {
|
|
innerB[1] -= 10
|
|
}
|
|
const midX = (innerA[0] + innerB[0]) * 0.5
|
|
|
|
path.moveTo(a[0], a[1])
|
|
path.lineTo(innerA[0], innerA[1])
|
|
path.lineTo(midX, innerA[1])
|
|
path.lineTo(midX, innerB[1])
|
|
path.lineTo(innerB[0], innerB[1])
|
|
path.lineTo(b[0], b[1])
|
|
|
|
// Calculate centre point
|
|
pos[0] = midX
|
|
pos[1] = (innerA[1] + innerB[1]) * 0.5
|
|
|
|
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
|
const diff = innerB[1] - innerA[1]
|
|
if (Math.abs(diff) < 4) linkSegment._centreAngle = 0
|
|
else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5
|
|
else linkSegment._centreAngle = -(Math.PI * 0.5)
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
// rendering the outline of the connection can be a little bit slow
|
|
if (this.render_connections_border && !this.low_quality && !skip_border) {
|
|
ctx.strokeStyle = "rgba(0,0,0,0.5)"
|
|
ctx.stroke(path)
|
|
}
|
|
|
|
ctx.lineWidth = this.connections_width
|
|
ctx.fillStyle = ctx.strokeStyle = linkColour
|
|
ctx.stroke(path)
|
|
|
|
// render arrow in the middle
|
|
if (
|
|
this.ds.scale >= 0.6 &&
|
|
this.highquality_render &&
|
|
linkSegment &&
|
|
// TODO: Re-assess this usage - likely a workaround that linkSegment truthy check resolves
|
|
endDir != LinkDirection.CENTER
|
|
) {
|
|
// render arrow
|
|
if (this.render_connection_arrows) {
|
|
// compute two points in the connection
|
|
const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir)
|
|
const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir)
|
|
const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir)
|
|
const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir)
|
|
|
|
// compute the angle between them so the arrow points in the right direction
|
|
let angleA = 0
|
|
let angleB = 0
|
|
if (this.render_curved_connections) {
|
|
angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1])
|
|
angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1])
|
|
} else {
|
|
angleB = angleA = b[1] > a[1] ? 0 : Math.PI
|
|
}
|
|
|
|
// render arrow
|
|
const transform = ctx.getTransform()
|
|
ctx.translate(posA[0], posA[1])
|
|
ctx.rotate(angleA)
|
|
ctx.beginPath()
|
|
ctx.moveTo(-5, -3)
|
|
ctx.lineTo(0, +7)
|
|
ctx.lineTo(+5, -3)
|
|
ctx.fill()
|
|
ctx.setTransform(transform)
|
|
|
|
ctx.translate(posC[0], posC[1])
|
|
ctx.rotate(angleB)
|
|
ctx.beginPath()
|
|
ctx.moveTo(-5, -3)
|
|
ctx.lineTo(0, +7)
|
|
ctx.lineTo(+5, -3)
|
|
ctx.fill()
|
|
ctx.setTransform(transform)
|
|
}
|
|
|
|
// Draw link centre marker
|
|
ctx.beginPath()
|
|
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
|
const transform = ctx.getTransform()
|
|
ctx.translate(pos[0], pos[1])
|
|
if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle)
|
|
// The math is off, but it currently looks better in chromium
|
|
ctx.moveTo(-3.2, -5)
|
|
ctx.lineTo(+7, 0)
|
|
ctx.lineTo(-3.2, +5)
|
|
ctx.fill()
|
|
ctx.setTransform(transform)
|
|
} else if (
|
|
this.linkMarkerShape == null ||
|
|
this.linkMarkerShape === LinkMarkerShape.Circle
|
|
) {
|
|
ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
}
|
|
|
|
// render flowing points
|
|
if (flow) {
|
|
ctx.fillStyle = linkColour
|
|
for (let i = 0; i < 5; ++i) {
|
|
const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1
|
|
const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir)
|
|
ctx.beginPath()
|
|
ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir.
|
|
* @param a Start point
|
|
* @param b End point
|
|
* @param t Time: distance between points (e.g 0.25 is 25% along the line)
|
|
* @param start_dir Spline start direction
|
|
* @param end_dir Spline end direction
|
|
* @returns The point at {@link t} distance along the spline a-b.
|
|
*/
|
|
computeConnectionPoint(
|
|
a: ReadOnlyPoint,
|
|
b: ReadOnlyPoint,
|
|
t: number,
|
|
start_dir: LinkDirection,
|
|
end_dir: LinkDirection,
|
|
): Point {
|
|
start_dir ||= LinkDirection.RIGHT
|
|
end_dir ||= LinkDirection.LEFT
|
|
|
|
const dist = distance(a, b)
|
|
const pa: Point = [a[0], a[1]]
|
|
const pb: Point = [b[0], b[1]]
|
|
|
|
this.#addSplineOffset(pa, start_dir, dist)
|
|
this.#addSplineOffset(pb, end_dir, dist)
|
|
|
|
const c1 = (1 - t) * (1 - t) * (1 - t)
|
|
const c2 = 3 * ((1 - t) * (1 - t)) * t
|
|
const c3 = 3 * (1 - t) * (t * t)
|
|
const c4 = t * t * t
|
|
|
|
const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0]
|
|
const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1]
|
|
return [x, y]
|
|
}
|
|
|
|
/**
|
|
* Modifies an existing point, adding a single-axis offset.
|
|
* @param point The point to add the offset to
|
|
* @param direction The direction to add the offset in
|
|
* @param dist Distance to offset
|
|
* @param factor Distance is mulitplied by this value. Default: 0.25
|
|
*/
|
|
#addSplineOffset(
|
|
point: Point,
|
|
direction: LinkDirection,
|
|
dist: number,
|
|
factor = 0.25,
|
|
): void {
|
|
switch (direction) {
|
|
case LinkDirection.LEFT:
|
|
point[0] += dist * -factor
|
|
break
|
|
case LinkDirection.RIGHT:
|
|
point[0] += dist * factor
|
|
break
|
|
case LinkDirection.UP:
|
|
point[1] += dist * -factor
|
|
break
|
|
case LinkDirection.DOWN:
|
|
point[1] += dist * factor
|
|
break
|
|
}
|
|
}
|
|
|
|
drawExecutionOrder(ctx: CanvasRenderingContext2D): void {
|
|
ctx.shadowColor = "transparent"
|
|
ctx.globalAlpha = 0.25
|
|
|
|
ctx.textAlign = "center"
|
|
ctx.strokeStyle = "white"
|
|
ctx.globalAlpha = 0.75
|
|
|
|
const { visible_nodes } = this
|
|
for (const node of visible_nodes) {
|
|
ctx.fillStyle = "black"
|
|
ctx.fillRect(
|
|
node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT,
|
|
node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
|
|
LiteGraph.NODE_TITLE_HEIGHT,
|
|
LiteGraph.NODE_TITLE_HEIGHT,
|
|
)
|
|
if (node.order == 0) {
|
|
ctx.strokeRect(
|
|
node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT + 0.5,
|
|
node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 0.5,
|
|
LiteGraph.NODE_TITLE_HEIGHT,
|
|
LiteGraph.NODE_TITLE_HEIGHT,
|
|
)
|
|
}
|
|
ctx.fillStyle = "#FFF"
|
|
ctx.fillText(
|
|
stringOrEmpty(node.order),
|
|
node.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * -0.5,
|
|
node.pos[1] - 6,
|
|
)
|
|
}
|
|
ctx.globalAlpha = 1
|
|
}
|
|
|
|
/**
|
|
* draws the widgets stored inside a node
|
|
* @deprecated Use {@link LGraphNode.drawWidgets} instead.
|
|
* @remarks Currently there are extensions hijacking this function, so we cannot remove it.
|
|
*/
|
|
drawNodeWidgets(
|
|
node: LGraphNode,
|
|
posY: number,
|
|
ctx: CanvasRenderingContext2D,
|
|
): void {
|
|
const { linkConnector } = this
|
|
|
|
node.drawWidgets(ctx, {
|
|
colorContext: this,
|
|
linkOverWidget: linkConnector.overWidget,
|
|
linkOverWidgetType: linkConnector.overWidgetType,
|
|
lowQuality: this.low_quality,
|
|
editorAlpha: this.editor_alpha,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* draws every group area in the background
|
|
*/
|
|
drawGroups(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
|
|
if (!this.graph) return
|
|
|
|
const groups = this.graph._groups
|
|
|
|
ctx.save()
|
|
ctx.globalAlpha = 0.5 * this.editor_alpha
|
|
const drawSnapGuides = this.#snapToGrid && this.isDragging
|
|
|
|
for (const group of groups) {
|
|
// out of the visible area
|
|
if (!overlapBounding(this.visible_area, group._bounding)) {
|
|
continue
|
|
}
|
|
|
|
// Draw snap shadow
|
|
if (drawSnapGuides && this.selectedItems.has(group))
|
|
this.drawSnapGuide(ctx, group)
|
|
|
|
group.draw(this, ctx)
|
|
}
|
|
|
|
ctx.restore()
|
|
}
|
|
|
|
/**
|
|
* resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode
|
|
* @todo Remove or rewrite
|
|
*/
|
|
resize(width?: number, height?: number): void {
|
|
if (!width && !height) {
|
|
const parent = this.canvas.parentElement
|
|
if (!parent) throw new TypeError("Attempted to resize canvas, but parent element was null.")
|
|
width = parent.offsetWidth
|
|
height = parent.offsetHeight
|
|
}
|
|
|
|
if (this.canvas.width == width && this.canvas.height == height) return
|
|
|
|
this.canvas.width = width ?? 0
|
|
this.canvas.height = height ?? 0
|
|
this.bgcanvas.width = this.canvas.width
|
|
this.bgcanvas.height = this.canvas.height
|
|
this.setDirty(true, true)
|
|
}
|
|
|
|
onNodeSelectionChange(): void {}
|
|
|
|
/**
|
|
* Determines the furthest nodes in each direction for the currently selected nodes
|
|
*/
|
|
boundaryNodesForSelection(): NullableProperties<IBoundaryNodes> {
|
|
return LGraphCanvas.getBoundaryNodes(this.selected_nodes)
|
|
}
|
|
|
|
showLinkMenu(segment: LinkSegment, e: CanvasMouseEvent): boolean {
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const title = "data" in segment && segment.data != null
|
|
? segment.data.constructor.name
|
|
: undefined
|
|
|
|
const { origin_id, origin_slot } = segment
|
|
if (origin_id == null || origin_slot == null) {
|
|
new LiteGraph.ContextMenu<string>(["Link has no origin"], {
|
|
event: e,
|
|
title,
|
|
})
|
|
return false
|
|
}
|
|
|
|
const node_left = graph.getNodeById(origin_id)
|
|
const fromType = node_left?.outputs?.[origin_slot]?.type
|
|
|
|
const options = ["Add Node", null, "Delete", null]
|
|
options.splice(1, 0, "Add Reroute")
|
|
|
|
const menu = new LiteGraph.ContextMenu<string>(options, {
|
|
event: e,
|
|
title,
|
|
callback: inner_clicked.bind(this),
|
|
})
|
|
|
|
return false
|
|
|
|
function inner_clicked(this: LGraphCanvas, v: string, options: unknown, e: MouseEvent) {
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
switch (v) {
|
|
case "Add Node":
|
|
LGraphCanvas.onMenuAdd(null, null, e, menu, (node) => {
|
|
if (!node?.inputs?.length || !node?.outputs?.length || origin_slot == null) return
|
|
|
|
// leave the connection type checking inside connectByType
|
|
const options = { afterRerouteId: segment.parentId }
|
|
if (node_left?.connectByType(origin_slot, node, fromType ?? "*", options)) {
|
|
node.pos[0] -= node.size[0] * 0.5
|
|
}
|
|
})
|
|
break
|
|
|
|
case "Add Reroute": {
|
|
try {
|
|
this.emitBeforeChange()
|
|
this.adjustMouseEvent(e)
|
|
graph.createReroute(segment._pos, segment)
|
|
this.setDirty(false, true)
|
|
} catch (error) {
|
|
console.error(error)
|
|
} finally {
|
|
this.emitAfterChange()
|
|
}
|
|
break
|
|
}
|
|
|
|
case "Delete":
|
|
graph.removeLink(segment.id)
|
|
break
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
createDefaultNodeForSlot(optPass: ICreateDefaultNodeOptions): boolean {
|
|
type DefaultOptions = ICreateDefaultNodeOptions & {
|
|
posAdd: Point
|
|
posSizeFix: Point
|
|
}
|
|
|
|
const opts = Object.assign<DefaultOptions, ICreateDefaultNodeOptions>({
|
|
nodeFrom: null,
|
|
slotFrom: null,
|
|
nodeTo: null,
|
|
slotTo: null,
|
|
position: [0, 0],
|
|
nodeType: undefined,
|
|
posAdd: [0, 0],
|
|
posSizeFix: [0, 0],
|
|
}, optPass)
|
|
const { afterRerouteId } = opts
|
|
|
|
const isFrom = opts.nodeFrom && opts.slotFrom !== null
|
|
const isTo = !isFrom && opts.nodeTo && opts.slotTo !== null
|
|
|
|
if (!isFrom && !isTo) {
|
|
console.warn(`No data passed to createDefaultNodeForSlot`, opts.nodeFrom, opts.slotFrom, opts.nodeTo, opts.slotTo)
|
|
return false
|
|
}
|
|
if (!opts.nodeType) {
|
|
console.warn("No type to createDefaultNodeForSlot")
|
|
return false
|
|
}
|
|
|
|
const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo
|
|
if (!nodeX) throw new TypeError("nodeX was null when creating default node for slot.")
|
|
|
|
let slotX = isFrom ? opts.slotFrom : opts.slotTo
|
|
|
|
let iSlotConn: number | false = false
|
|
switch (typeof slotX) {
|
|
case "string":
|
|
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false)
|
|
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
|
|
break
|
|
case "object":
|
|
if (slotX === null) {
|
|
console.warn("Cant get slot information", slotX)
|
|
return false
|
|
}
|
|
|
|
// ok slotX
|
|
iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name)
|
|
break
|
|
case "number":
|
|
iSlotConn = slotX
|
|
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
|
|
break
|
|
case "undefined":
|
|
default:
|
|
console.warn("Cant get slot information", slotX)
|
|
return false
|
|
}
|
|
|
|
// check for defaults nodes for this slottype
|
|
const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type
|
|
const slotTypesDefault = isFrom
|
|
? LiteGraph.slot_types_default_out
|
|
: LiteGraph.slot_types_default_in
|
|
if (slotTypesDefault?.[fromSlotType]) {
|
|
// TODO: Remove "any" kludge
|
|
let nodeNewType: any = false
|
|
if (typeof slotTypesDefault[fromSlotType] == "object") {
|
|
for (const typeX in slotTypesDefault[fromSlotType]) {
|
|
if (
|
|
opts.nodeType == slotTypesDefault[fromSlotType][typeX] ||
|
|
opts.nodeType == "AUTO"
|
|
) {
|
|
nodeNewType = slotTypesDefault[fromSlotType][typeX]
|
|
break
|
|
}
|
|
}
|
|
} else if (
|
|
opts.nodeType == slotTypesDefault[fromSlotType] ||
|
|
opts.nodeType == "AUTO"
|
|
) {
|
|
nodeNewType = slotTypesDefault[fromSlotType]
|
|
}
|
|
if (nodeNewType) {
|
|
// TODO: Remove "any" kludge
|
|
let nodeNewOpts: any = false
|
|
if (typeof nodeNewType == "object" && nodeNewType.node) {
|
|
nodeNewOpts = nodeNewType
|
|
nodeNewType = nodeNewType.node
|
|
}
|
|
|
|
// that.graph.beforeChange();
|
|
const newNode = LiteGraph.createNode(nodeNewType)
|
|
if (newNode) {
|
|
// if is object pass options
|
|
if (nodeNewOpts) {
|
|
if (nodeNewOpts.properties) {
|
|
for (const i in nodeNewOpts.properties) {
|
|
newNode.addProperty(i, nodeNewOpts.properties[i])
|
|
}
|
|
}
|
|
if (nodeNewOpts.inputs) {
|
|
newNode.inputs = []
|
|
for (const i in nodeNewOpts.inputs) {
|
|
newNode.addOutput(
|
|
nodeNewOpts.inputs[i][0],
|
|
nodeNewOpts.inputs[i][1],
|
|
)
|
|
}
|
|
}
|
|
if (nodeNewOpts.outputs) {
|
|
newNode.outputs = []
|
|
for (const i in nodeNewOpts.outputs) {
|
|
newNode.addOutput(
|
|
nodeNewOpts.outputs[i][0],
|
|
nodeNewOpts.outputs[i][1],
|
|
)
|
|
}
|
|
}
|
|
if (nodeNewOpts.title) {
|
|
newNode.title = nodeNewOpts.title
|
|
}
|
|
if (nodeNewOpts.json) {
|
|
newNode.configure(nodeNewOpts.json)
|
|
}
|
|
}
|
|
|
|
// add the node
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
this.graph.add(newNode)
|
|
newNode.pos = [
|
|
opts.position[0] + opts.posAdd[0] + (opts.posSizeFix[0] ? opts.posSizeFix[0] * newNode.size[0] : 0),
|
|
opts.position[1] + opts.posAdd[1] + (opts.posSizeFix[1] ? opts.posSizeFix[1] * newNode.size[1] : 0),
|
|
]
|
|
|
|
// connect the two!
|
|
if (isFrom) {
|
|
if (!opts.nodeFrom) throw new TypeError("createDefaultNodeForSlot - nodeFrom was null")
|
|
opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType, { afterRerouteId })
|
|
} else {
|
|
if (!opts.nodeTo) throw new TypeError("createDefaultNodeForSlot - nodeTo was null")
|
|
opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType, { afterRerouteId })
|
|
}
|
|
|
|
// if connecting in between
|
|
if (isFrom && isTo) {
|
|
// TODO
|
|
}
|
|
|
|
return true
|
|
}
|
|
console.log(`failed creating ${nodeNewType}`)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
showConnectionMenu(optPass: Partial<ICreateNodeOptions & { e: MouseEvent }>): void {
|
|
const opts = Object.assign<ICreateNodeOptions & HasShowSearchCallback, ICreateNodeOptions>({
|
|
nodeFrom: null,
|
|
slotFrom: null,
|
|
nodeTo: null,
|
|
slotTo: null,
|
|
e: undefined,
|
|
allow_searchbox: this.allow_searchbox,
|
|
showSearchBox: this.showSearchBox,
|
|
}, optPass || {})
|
|
const that = this
|
|
const { afterRerouteId } = opts
|
|
|
|
const isFrom = opts.nodeFrom && opts.slotFrom
|
|
const isTo = !isFrom && opts.nodeTo && opts.slotTo
|
|
|
|
if (!isFrom && !isTo) {
|
|
console.warn("No data passed to showConnectionMenu")
|
|
return
|
|
}
|
|
|
|
const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo
|
|
if (!nodeX) throw new TypeError("nodeX was null when creating default node for slot.")
|
|
let slotX = isFrom ? opts.slotFrom : opts.slotTo
|
|
|
|
let iSlotConn: number
|
|
switch (typeof slotX) {
|
|
case "string":
|
|
iSlotConn = isFrom
|
|
? nodeX.findOutputSlot(slotX, false)
|
|
: nodeX.findInputSlot(slotX, false)
|
|
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
|
|
break
|
|
case "object":
|
|
if (slotX === null) {
|
|
console.warn("Cant get slot information", slotX)
|
|
return
|
|
}
|
|
|
|
// ok slotX
|
|
iSlotConn = isFrom
|
|
? nodeX.findOutputSlot(slotX.name)
|
|
: nodeX.findInputSlot(slotX.name)
|
|
break
|
|
case "number":
|
|
iSlotConn = slotX
|
|
slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX]
|
|
break
|
|
default:
|
|
console.warn("Cant get slot information", slotX)
|
|
return
|
|
}
|
|
|
|
const options = ["Add Node", null]
|
|
|
|
if (opts.allow_searchbox) {
|
|
options.push("Search", null)
|
|
}
|
|
|
|
// get defaults nodes for this slottype
|
|
const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type
|
|
const slotTypesDefault = isFrom
|
|
? LiteGraph.slot_types_default_out
|
|
: LiteGraph.slot_types_default_in
|
|
if (slotTypesDefault?.[fromSlotType]) {
|
|
if (typeof slotTypesDefault[fromSlotType] == "object") {
|
|
for (const typeX in slotTypesDefault[fromSlotType]) {
|
|
options.push(slotTypesDefault[fromSlotType][typeX])
|
|
}
|
|
} else {
|
|
options.push(slotTypesDefault[fromSlotType])
|
|
}
|
|
}
|
|
|
|
// build menu
|
|
const menu = new LiteGraph.ContextMenu<string>(options, {
|
|
event: opts.e,
|
|
title:
|
|
(slotX && slotX.name != ""
|
|
? slotX.name + (fromSlotType ? " | " : "")
|
|
: "") + (slotX && fromSlotType ? fromSlotType : ""),
|
|
callback: inner_clicked,
|
|
})
|
|
|
|
// callback
|
|
function inner_clicked(v: string, options: unknown, e: MouseEvent) {
|
|
// console.log("Process showConnectionMenu selection");
|
|
switch (v) {
|
|
case "Add Node":
|
|
LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) {
|
|
if (!node) return
|
|
|
|
if (isFrom) {
|
|
opts.nodeFrom?.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId })
|
|
} else {
|
|
opts.nodeTo?.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId })
|
|
}
|
|
})
|
|
break
|
|
case "Search":
|
|
if (isFrom) {
|
|
opts.showSearchBox(e, { node_from: opts.nodeFrom, slot_from: slotX, type_filter_in: fromSlotType })
|
|
} else {
|
|
opts.showSearchBox(e, { node_to: opts.nodeTo, slot_from: slotX, type_filter_out: fromSlotType })
|
|
}
|
|
break
|
|
default: {
|
|
const customProps = {
|
|
position: [opts.e?.canvasX ?? 0, opts.e?.canvasY ?? 0],
|
|
nodeType: v,
|
|
afterRerouteId,
|
|
} satisfies Partial<ICreateDefaultNodeOptions>
|
|
|
|
const options = Object.assign(opts, customProps)
|
|
that.createDefaultNodeForSlot(options)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// refactor: there are different dialogs, some uses createDialog some dont
|
|
prompt(
|
|
title: string,
|
|
value: any,
|
|
callback: (arg0: any) => void,
|
|
event: CanvasMouseEvent,
|
|
multiline?: boolean,
|
|
): HTMLDivElement {
|
|
const that = this
|
|
title = title || ""
|
|
|
|
const customProperties = {
|
|
is_modified: false,
|
|
className: "graphdialog rounded",
|
|
innerHTML: multiline
|
|
? "<span class='name'></span> <textarea autofocus class='value'></textarea><button class='rounded'>OK</button>"
|
|
: "<span class='name'></span> <input autofocus type='text' class='value'/><button class='rounded'>OK</button>",
|
|
close() {
|
|
that.prompt_box = null
|
|
if (dialog.parentNode) {
|
|
dialog.remove()
|
|
}
|
|
},
|
|
} satisfies Partial<IDialog>
|
|
|
|
const div = document.createElement("div")
|
|
const dialog: PromptDialog = Object.assign(div, customProperties)
|
|
|
|
const graphcanvas = LGraphCanvas.active_canvas
|
|
const { canvas } = graphcanvas
|
|
if (!canvas.parentNode) throw new TypeError("canvas element parentNode was null when opening a prompt.")
|
|
canvas.parentNode.append(dialog)
|
|
|
|
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
|
|
|
|
let dialogCloseTimer: number
|
|
let prevent_timeout = 0
|
|
LiteGraph.pointerListenerAdd(dialog, "leave", function () {
|
|
if (prevent_timeout) return
|
|
if (LiteGraph.dialog_close_on_mouse_leave) {
|
|
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
|
dialogCloseTimer = setTimeout(
|
|
dialog.close,
|
|
LiteGraph.dialog_close_on_mouse_leave_delay,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
LiteGraph.pointerListenerAdd(dialog, "enter", function () {
|
|
if (LiteGraph.dialog_close_on_mouse_leave && dialogCloseTimer)
|
|
clearTimeout(dialogCloseTimer)
|
|
})
|
|
const selInDia = dialog.querySelectorAll("select")
|
|
if (selInDia) {
|
|
// if filtering, check focus changed to comboboxes and prevent closing
|
|
for (const selIn of selInDia) {
|
|
selIn.addEventListener("click", function () {
|
|
prevent_timeout++
|
|
})
|
|
selIn.addEventListener("blur", function () {
|
|
prevent_timeout = 0
|
|
})
|
|
selIn.addEventListener("change", function () {
|
|
prevent_timeout = -1
|
|
})
|
|
}
|
|
}
|
|
this.prompt_box?.close()
|
|
this.prompt_box = dialog
|
|
|
|
const name_element: HTMLSpanElement | null = dialog.querySelector(".name")
|
|
if (!name_element) throw new TypeError("name_element was null")
|
|
|
|
name_element.textContent = title
|
|
const value_element: HTMLInputElement | null = dialog.querySelector(".value")
|
|
if (!value_element) throw new TypeError("value_element was null")
|
|
|
|
value_element.value = value
|
|
value_element.select()
|
|
|
|
const input = value_element
|
|
input.addEventListener("keydown", function (e: KeyboardEvent) {
|
|
dialog.is_modified = true
|
|
if (e.key == "Escape") {
|
|
// ESC
|
|
dialog.close()
|
|
} else if (
|
|
e.key == "Enter" &&
|
|
(e.target as Element).localName != "textarea"
|
|
) {
|
|
if (callback) {
|
|
callback(this.value)
|
|
}
|
|
dialog.close()
|
|
} else {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
|
|
const button = dialog.querySelector("button")
|
|
if (!button) throw new TypeError("button was null when opening prompt")
|
|
|
|
button.addEventListener("click", function () {
|
|
callback?.(input.value)
|
|
that.setDirty(true)
|
|
dialog.close()
|
|
})
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
let offsetx = -20
|
|
let offsety = -20
|
|
if (rect) {
|
|
offsetx -= rect.left
|
|
offsety -= rect.top
|
|
}
|
|
|
|
if (event) {
|
|
dialog.style.left = `${event.clientX + offsetx}px`
|
|
dialog.style.top = `${event.clientY + offsety}px`
|
|
} else {
|
|
dialog.style.left = `${canvas.width * 0.5 + offsetx}px`
|
|
dialog.style.top = `${canvas.height * 0.5 + offsety}px`
|
|
}
|
|
|
|
setTimeout(function () {
|
|
input.focus()
|
|
const clickTime = Date.now()
|
|
function handleOutsideClick(e: Event) {
|
|
if (e.target === canvas && Date.now() - clickTime > 256) {
|
|
dialog.close()
|
|
canvas.parentElement?.removeEventListener("click", handleOutsideClick)
|
|
canvas.parentElement?.removeEventListener("touchend", handleOutsideClick)
|
|
}
|
|
}
|
|
canvas.parentElement?.addEventListener("click", handleOutsideClick)
|
|
canvas.parentElement?.addEventListener("touchend", handleOutsideClick)
|
|
}, 10)
|
|
|
|
return dialog
|
|
}
|
|
|
|
showSearchBox(
|
|
event: MouseEvent,
|
|
searchOptions?: IShowSearchOptions,
|
|
): HTMLDivElement {
|
|
// proposed defaults
|
|
const options: IShowSearchOptions = {
|
|
slot_from: null,
|
|
node_from: null,
|
|
node_to: null,
|
|
// TODO check for registered_slot_[in/out]_types not empty
|
|
// this will be checked for functionality enabled : filter on slot type, in and out
|
|
do_type_filter: LiteGraph.search_filter_enabled,
|
|
|
|
// these are default: pass to set initially set values
|
|
// @ts-expect-error
|
|
type_filter_in: false,
|
|
|
|
type_filter_out: false,
|
|
show_general_if_none_on_typefilter: true,
|
|
show_general_after_typefiltered: true,
|
|
hide_on_mouse_leave: LiteGraph.search_hide_on_mouse_leave,
|
|
show_all_if_empty: true,
|
|
show_all_on_open: LiteGraph.search_show_all_on_open,
|
|
}
|
|
Object.assign(options, searchOptions)
|
|
|
|
// console.log(options);
|
|
const that = this
|
|
const graphcanvas = LGraphCanvas.active_canvas
|
|
const { canvas } = graphcanvas
|
|
const root_document = canvas.ownerDocument || document
|
|
|
|
const div = document.createElement("div")
|
|
const dialog = Object.assign(div, {
|
|
close(this: typeof div) {
|
|
that.search_box = undefined
|
|
this.blur()
|
|
canvas.focus()
|
|
root_document.body.style.overflow = ""
|
|
|
|
// important, if canvas loses focus keys wont be captured
|
|
setTimeout(() => canvas.focus(), 20)
|
|
dialog.remove()
|
|
},
|
|
} satisfies Partial<HTMLDivElement> & ICloseable)
|
|
dialog.className = "litegraph litesearchbox graphdialog rounded"
|
|
dialog.innerHTML = "<span class='name'>Search</span> <input autofocus type='text' class='value rounded'/>"
|
|
if (options.do_type_filter) {
|
|
dialog.innerHTML += "<select class='slot_in_type_filter'><option value=''></option></select>"
|
|
dialog.innerHTML += "<select class='slot_out_type_filter'><option value=''></option></select>"
|
|
}
|
|
const helper = document.createElement("div")
|
|
helper.className = "helper"
|
|
dialog.append(helper)
|
|
|
|
if (root_document.fullscreenElement) {
|
|
root_document.fullscreenElement.append(dialog)
|
|
} else {
|
|
root_document.body.append(dialog)
|
|
root_document.body.style.overflow = "hidden"
|
|
}
|
|
|
|
// dialog element has been appended
|
|
let selIn
|
|
let selOut
|
|
if (options.do_type_filter) {
|
|
selIn = dialog.querySelector(".slot_in_type_filter")
|
|
selOut = dialog.querySelector(".slot_out_type_filter")
|
|
}
|
|
|
|
if (this.ds.scale > 1) {
|
|
dialog.style.transform = `scale(${this.ds.scale})`
|
|
}
|
|
|
|
// hide on mouse leave
|
|
if (options.hide_on_mouse_leave) {
|
|
// FIXME: Remove "any" kludge
|
|
let prevent_timeout: any = false
|
|
let timeout_close: number | null = null
|
|
LiteGraph.pointerListenerAdd(dialog, "enter", function () {
|
|
if (timeout_close) {
|
|
clearTimeout(timeout_close)
|
|
timeout_close = null
|
|
}
|
|
})
|
|
dialog.addEventListener("pointerleave", function () {
|
|
if (prevent_timeout) return
|
|
|
|
const hideDelay = options.hide_on_mouse_leave
|
|
const delay = typeof hideDelay === "number" ? hideDelay : 500
|
|
timeout_close = setTimeout(dialog.close, delay)
|
|
})
|
|
// if filtering, check focus changed to comboboxes and prevent closing
|
|
if (options.do_type_filter) {
|
|
if (!selIn) throw new TypeError("selIn was null when showing search box")
|
|
if (!selOut) throw new TypeError("selOut was null when showing search box")
|
|
|
|
selIn.addEventListener("click", function () {
|
|
prevent_timeout++
|
|
})
|
|
selIn.addEventListener("blur", function () {
|
|
prevent_timeout = 0
|
|
})
|
|
selIn.addEventListener("change", function () {
|
|
prevent_timeout = -1
|
|
})
|
|
selOut.addEventListener("click", function () {
|
|
prevent_timeout++
|
|
})
|
|
selOut.addEventListener("blur", function () {
|
|
prevent_timeout = 0
|
|
})
|
|
selOut.addEventListener("change", function () {
|
|
prevent_timeout = -1
|
|
})
|
|
}
|
|
}
|
|
|
|
// @ts-expect-error Panel?
|
|
that.search_box?.close()
|
|
that.search_box = dialog
|
|
|
|
let first: string | null = null
|
|
let timeout: number | null = null
|
|
let selected: ChildNode | null = null
|
|
|
|
const maybeInput = dialog.querySelector("input")
|
|
if (!maybeInput) throw new TypeError("Could not create search input box.")
|
|
|
|
const input = maybeInput
|
|
|
|
if (input) {
|
|
input.addEventListener("blur", function () {
|
|
this.focus()
|
|
})
|
|
input.addEventListener("keydown", function (e) {
|
|
if (e.key == "ArrowUp") {
|
|
// UP
|
|
changeSelection(false)
|
|
} else if (e.key == "ArrowDown") {
|
|
// DOWN
|
|
changeSelection(true)
|
|
} else if (e.key == "Escape") {
|
|
// ESC
|
|
dialog.close()
|
|
} else if (e.key == "Enter") {
|
|
if (selected instanceof HTMLElement) {
|
|
select(unescape(String(selected.dataset.type)))
|
|
} else if (first) {
|
|
select(first)
|
|
} else {
|
|
dialog.close()
|
|
}
|
|
} else {
|
|
if (timeout) {
|
|
clearInterval(timeout)
|
|
}
|
|
timeout = setTimeout(refreshHelper, 10)
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.stopImmediatePropagation()
|
|
return true
|
|
})
|
|
}
|
|
|
|
// if should filter on type, load and fill selected and choose elements if passed
|
|
if (options.do_type_filter) {
|
|
if (selIn) {
|
|
const aSlots = LiteGraph.slot_types_in
|
|
const nSlots = aSlots.length
|
|
|
|
if (
|
|
options.type_filter_in == LiteGraph.EVENT ||
|
|
options.type_filter_in == LiteGraph.ACTION
|
|
) {
|
|
options.type_filter_in = "_event_"
|
|
}
|
|
for (let iK = 0; iK < nSlots; iK++) {
|
|
const opt = document.createElement("option")
|
|
opt.value = aSlots[iK]
|
|
opt.innerHTML = aSlots[iK]
|
|
selIn.append(opt)
|
|
if (
|
|
// @ts-expect-error
|
|
options.type_filter_in !== false &&
|
|
String(options.type_filter_in).toLowerCase() ==
|
|
String(aSlots[iK]).toLowerCase()
|
|
) {
|
|
opt.selected = true
|
|
}
|
|
}
|
|
selIn.addEventListener("change", function () {
|
|
refreshHelper()
|
|
})
|
|
}
|
|
if (selOut) {
|
|
const aSlots = LiteGraph.slot_types_out
|
|
|
|
if (
|
|
options.type_filter_out == LiteGraph.EVENT ||
|
|
options.type_filter_out == LiteGraph.ACTION
|
|
) {
|
|
options.type_filter_out = "_event_"
|
|
}
|
|
for (const aSlot of aSlots) {
|
|
const opt = document.createElement("option")
|
|
opt.value = aSlot
|
|
opt.innerHTML = aSlot
|
|
selOut.append(opt)
|
|
if (
|
|
options.type_filter_out !== false &&
|
|
String(options.type_filter_out).toLowerCase() ==
|
|
String(aSlot).toLowerCase()
|
|
) {
|
|
opt.selected = true
|
|
}
|
|
}
|
|
selOut.addEventListener("change", function () {
|
|
refreshHelper()
|
|
})
|
|
}
|
|
}
|
|
|
|
// compute best position
|
|
const rect = canvas.getBoundingClientRect()
|
|
|
|
const left = (event ? event.clientX : rect.left + rect.width * 0.5) - 80
|
|
const top = (event ? event.clientY : rect.top + rect.height * 0.5) - 20
|
|
dialog.style.left = `${left}px`
|
|
dialog.style.top = `${top}px`
|
|
|
|
// To avoid out of screen problems
|
|
if (event.layerY > rect.height - 200) {
|
|
helper.style.maxHeight = `${rect.height - event.layerY - 20}px`
|
|
}
|
|
requestAnimationFrame(function () {
|
|
input.focus()
|
|
})
|
|
if (options.show_all_on_open) refreshHelper()
|
|
|
|
function select(name: string) {
|
|
if (name) {
|
|
if (that.onSearchBoxSelection) {
|
|
that.onSearchBoxSelection(name, event, graphcanvas)
|
|
} else {
|
|
if (!graphcanvas.graph) throw new NullGraphError()
|
|
|
|
graphcanvas.graph.beforeChange()
|
|
const node = LiteGraph.createNode(name)
|
|
if (node) {
|
|
node.pos = graphcanvas.convertEventToCanvasOffset(event)
|
|
graphcanvas.graph.add(node, false)
|
|
}
|
|
|
|
// join node after inserting
|
|
if (options.node_from) {
|
|
// FIXME: any
|
|
let iS: any = false
|
|
switch (typeof options.slot_from) {
|
|
case "string":
|
|
iS = options.node_from.findOutputSlot(options.slot_from)
|
|
break
|
|
case "object":
|
|
if (options.slot_from == null) throw new TypeError("options.slot_from was null when showing search box")
|
|
|
|
iS = options.slot_from.name
|
|
? options.node_from.findOutputSlot(options.slot_from.name)
|
|
: -1
|
|
// @ts-expect-error change interface check
|
|
if (iS == -1 && options.slot_from.slot_index !== undefined) iS = options.slot_from.slot_index
|
|
break
|
|
case "number":
|
|
iS = options.slot_from
|
|
break
|
|
default:
|
|
// try with first if no name set
|
|
iS = 0
|
|
}
|
|
if (options.node_from.outputs[iS] !== undefined) {
|
|
if (iS !== false && iS > -1) {
|
|
if (node == null) throw new TypeError("options.slot_from was null when showing search box")
|
|
|
|
options.node_from.connectByType(iS, node, options.node_from.outputs[iS].type)
|
|
}
|
|
} else {
|
|
// console.warn("cant find slot " + options.slot_from);
|
|
}
|
|
}
|
|
if (options.node_to) {
|
|
// FIXME: any
|
|
let iS: any = false
|
|
switch (typeof options.slot_from) {
|
|
case "string":
|
|
iS = options.node_to.findInputSlot(options.slot_from)
|
|
break
|
|
case "object":
|
|
if (options.slot_from == null) throw new TypeError("options.slot_from was null when showing search box")
|
|
|
|
iS = options.slot_from.name
|
|
? options.node_to.findInputSlot(options.slot_from.name)
|
|
: -1
|
|
// @ts-expect-error change interface check
|
|
if (iS == -1 && options.slot_from.slot_index !== undefined) iS = options.slot_from.slot_index
|
|
break
|
|
case "number":
|
|
iS = options.slot_from
|
|
break
|
|
default:
|
|
// try with first if no name set
|
|
iS = 0
|
|
}
|
|
if (options.node_to.inputs[iS] !== undefined) {
|
|
if (iS !== false && iS > -1) {
|
|
if (node == null) throw new TypeError("options.slot_from was null when showing search box")
|
|
// try connection
|
|
options.node_to.connectByTypeOutput(iS, node, options.node_to.inputs[iS].type)
|
|
}
|
|
} else {
|
|
// console.warn("cant find slot_nodeTO " + options.slot_from);
|
|
}
|
|
}
|
|
|
|
graphcanvas.graph.afterChange()
|
|
}
|
|
}
|
|
|
|
dialog.close()
|
|
}
|
|
|
|
function changeSelection(forward: boolean) {
|
|
const prev = selected
|
|
if (!selected) {
|
|
selected = forward
|
|
? helper.childNodes[0]
|
|
: helper.childNodes[helper.childNodes.length]
|
|
} else if (selected instanceof Element) {
|
|
selected.classList.remove("selected")
|
|
selected = forward
|
|
? selected.nextSibling
|
|
: selected.previousSibling
|
|
selected ||= prev
|
|
}
|
|
|
|
if (selected instanceof Element) {
|
|
selected.classList.add("selected")
|
|
selected.scrollIntoView({ block: "end", behavior: "smooth" })
|
|
}
|
|
}
|
|
|
|
function refreshHelper() {
|
|
timeout = null
|
|
let str = input.value
|
|
first = null
|
|
helper.innerHTML = ""
|
|
if (!str && !options.show_all_if_empty) return
|
|
|
|
if (that.onSearchBox) {
|
|
const list = that.onSearchBox(helper, str, graphcanvas)
|
|
if (list) {
|
|
for (const item of list) {
|
|
addResult(item)
|
|
}
|
|
}
|
|
} else {
|
|
let c = 0
|
|
str = str.toLowerCase()
|
|
if (!graphcanvas.graph) throw new NullGraphError()
|
|
|
|
const filter = graphcanvas.filter || graphcanvas.graph.filter
|
|
|
|
// FIXME: any
|
|
// filter by type preprocess
|
|
let sIn: any = false
|
|
let sOut: any = false
|
|
if (options.do_type_filter && that.search_box) {
|
|
sIn = that.search_box.querySelector(".slot_in_type_filter")
|
|
sOut = that.search_box.querySelector(".slot_out_type_filter")
|
|
}
|
|
|
|
const keys = Object.keys(LiteGraph.registered_node_types)
|
|
const filtered = keys.filter(x => inner_test_filter(x))
|
|
|
|
for (const item of filtered) {
|
|
addResult(item)
|
|
if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit)
|
|
break
|
|
}
|
|
|
|
// add general type if filtering
|
|
if (
|
|
options.show_general_after_typefiltered &&
|
|
(sIn.value || sOut.value)
|
|
) {
|
|
// FIXME: Undeclared variable again
|
|
// @ts-expect-error
|
|
filtered_extra = []
|
|
for (const i in LiteGraph.registered_node_types) {
|
|
if (
|
|
inner_test_filter(i, {
|
|
inTypeOverride: sIn && sIn.value ? "*" : false,
|
|
outTypeOverride: sOut && sOut.value ? "*" : false,
|
|
})
|
|
) {
|
|
// @ts-expect-error
|
|
filtered_extra.push(i)
|
|
}
|
|
}
|
|
// @ts-expect-error
|
|
for (const extraItem of filtered_extra) {
|
|
addResult(extraItem, "generic_type")
|
|
if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit)
|
|
break
|
|
}
|
|
}
|
|
|
|
// check il filtering gave no results
|
|
if (
|
|
(sIn.value || sOut.value) &&
|
|
helper.childNodes.length == 0 &&
|
|
options.show_general_if_none_on_typefilter
|
|
) {
|
|
// @ts-expect-error
|
|
filtered_extra = []
|
|
for (const i in LiteGraph.registered_node_types) {
|
|
if (inner_test_filter(i, { skipFilter: true }))
|
|
// @ts-expect-error
|
|
filtered_extra.push(i)
|
|
}
|
|
// @ts-expect-error
|
|
for (const extraItem of filtered_extra) {
|
|
addResult(extraItem, "not_in_filter")
|
|
if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit)
|
|
break
|
|
}
|
|
}
|
|
|
|
function inner_test_filter(
|
|
type: string,
|
|
optsIn?: {
|
|
inTypeOverride?: string | boolean
|
|
outTypeOverride?: string | boolean
|
|
skipFilter?: boolean
|
|
},
|
|
): boolean {
|
|
optsIn = optsIn || {}
|
|
const optsDef = {
|
|
skipFilter: false,
|
|
inTypeOverride: false,
|
|
outTypeOverride: false,
|
|
}
|
|
const opts = Object.assign(optsDef, optsIn)
|
|
const ctor = LiteGraph.registered_node_types[type]
|
|
if (filter && ctor.filter != filter) return false
|
|
if (
|
|
(!options.show_all_if_empty || str) &&
|
|
!type.toLowerCase().includes(str) &&
|
|
(!ctor.title || !ctor.title.toLowerCase().includes(str))
|
|
) {
|
|
return false
|
|
}
|
|
|
|
// filter by slot IN, OUT types
|
|
if (options.do_type_filter && !opts.skipFilter) {
|
|
const sType = type
|
|
|
|
let sV = opts.inTypeOverride !== false
|
|
? opts.inTypeOverride
|
|
: sIn.value
|
|
// type is stored
|
|
if (sIn && sV && LiteGraph.registered_slot_in_types[sV]?.nodes) {
|
|
const doesInc = LiteGraph.registered_slot_in_types[sV].nodes.includes(sType)
|
|
if (doesInc === false) return false
|
|
}
|
|
|
|
sV = sOut.value
|
|
if (opts.outTypeOverride !== false) sV = opts.outTypeOverride
|
|
// type is stored
|
|
if (sOut && sV && LiteGraph.registered_slot_out_types[sV]?.nodes) {
|
|
const doesInc = LiteGraph.registered_slot_out_types[sV].nodes.includes(sType)
|
|
if (doesInc === false) return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
function addResult(type: string, className?: string): void {
|
|
const help = document.createElement("div")
|
|
first ||= type
|
|
|
|
const nodeType = LiteGraph.registered_node_types[type]
|
|
if (nodeType?.title) {
|
|
help.textContent = nodeType?.title
|
|
const typeEl = document.createElement("span")
|
|
typeEl.className = "litegraph lite-search-item-type"
|
|
typeEl.textContent = type
|
|
help.append(typeEl)
|
|
} else {
|
|
help.textContent = type
|
|
}
|
|
|
|
help.dataset["type"] = escape(type)
|
|
help.className = "litegraph lite-search-item"
|
|
if (className) {
|
|
help.className += ` ${className}`
|
|
}
|
|
help.addEventListener("click", function () {
|
|
select(unescape(String(this.dataset.type)))
|
|
})
|
|
helper.append(help)
|
|
}
|
|
}
|
|
|
|
return dialog
|
|
}
|
|
|
|
showEditPropertyValue(
|
|
node: LGraphNode,
|
|
property: string,
|
|
options: IDialogOptions,
|
|
): IDialog | undefined {
|
|
if (!node || node.properties[property] === undefined) return
|
|
|
|
options = options || {}
|
|
|
|
const info = node.getPropertyInfo(property)
|
|
const { type } = info
|
|
|
|
let input_html = ""
|
|
|
|
if (
|
|
type == "string" ||
|
|
type == "number" ||
|
|
type == "array" ||
|
|
type == "object"
|
|
) {
|
|
input_html = "<input autofocus type='text' class='value'/>"
|
|
} else if ((type == "enum" || type == "combo") && info.values) {
|
|
input_html = "<select autofocus type='text' class='value'>"
|
|
for (const i in info.values) {
|
|
const v = Array.isArray(info.values) ? info.values[i] : i
|
|
|
|
const selected = v == node.properties[property] ? "selected" : ""
|
|
input_html += `<option value='${v}' ${selected}>${info.values[i]}</option>`
|
|
}
|
|
input_html += "</select>"
|
|
} else if (type == "boolean" || type == "toggle") {
|
|
const checked = node.properties[property] ? "checked" : ""
|
|
input_html = `<input autofocus type='checkbox' class='value' ${checked}/>`
|
|
} else {
|
|
console.warn(`unknown type: ${type}`)
|
|
return
|
|
}
|
|
|
|
const dialog = this.createDialog(
|
|
`<span class='name'>${info.label || property}</span>${input_html}<button>OK</button>`,
|
|
options,
|
|
)
|
|
|
|
let input: HTMLInputElement | HTMLSelectElement | null
|
|
if ((type == "enum" || type == "combo") && info.values) {
|
|
input = dialog.querySelector("select")
|
|
input?.addEventListener("change", function (e) {
|
|
dialog.modified()
|
|
setValue((e.target as HTMLSelectElement)?.value)
|
|
})
|
|
} else if (type == "boolean" || type == "toggle") {
|
|
input = dialog.querySelector("input")
|
|
input?.addEventListener("click", function () {
|
|
dialog.modified()
|
|
// @ts-expect-error
|
|
setValue(!!input.checked)
|
|
})
|
|
} else {
|
|
input = dialog.querySelector("input")
|
|
if (input) {
|
|
input.addEventListener("blur", function () {
|
|
this.focus()
|
|
})
|
|
|
|
let v = node.properties[property] !== undefined
|
|
? node.properties[property]
|
|
: ""
|
|
if (type !== "string") {
|
|
v = JSON.stringify(v)
|
|
}
|
|
|
|
// @ts-expect-error
|
|
input.value = v
|
|
input.addEventListener("keydown", function (e) {
|
|
if (e.key == "Escape") {
|
|
// ESC
|
|
dialog.close()
|
|
} else if (e.key == "Enter") {
|
|
// ENTER
|
|
// save
|
|
inner()
|
|
} else {
|
|
dialog.modified()
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
}
|
|
}
|
|
input?.focus()
|
|
|
|
const button = dialog.querySelector("button")
|
|
if (!button) throw new TypeError("Show edit property value button was null.")
|
|
button.addEventListener("click", inner)
|
|
|
|
function inner() {
|
|
setValue(input?.value)
|
|
}
|
|
const dirty = () => this.#dirty()
|
|
|
|
function setValue(value: string | number | undefined) {
|
|
if (
|
|
info?.values &&
|
|
typeof info.values === "object" &&
|
|
info.values[value] != undefined
|
|
) {
|
|
value = info.values[value]
|
|
}
|
|
|
|
if (typeof node.properties[property] == "number") {
|
|
value = Number(value)
|
|
}
|
|
if (type == "array" || type == "object") {
|
|
// @ts-expect-error JSON.parse doesn't care.
|
|
value = JSON.parse(value)
|
|
}
|
|
node.properties[property] = value
|
|
if (node.graph) {
|
|
node.graph._version++
|
|
}
|
|
node.onPropertyChanged?.(property, value)
|
|
options.onclose?.()
|
|
dialog.close()
|
|
dirty()
|
|
}
|
|
|
|
return dialog
|
|
}
|
|
|
|
// TODO refactor, theer are different dialog, some uses createDialog, some dont
|
|
createDialog(html: string, options: IDialogOptions): IDialog {
|
|
const def_options = {
|
|
checkForInput: false,
|
|
closeOnLeave: true,
|
|
closeOnLeave_checkModified: true,
|
|
}
|
|
options = Object.assign(def_options, options || {})
|
|
|
|
const customProperties = {
|
|
className: "graphdialog",
|
|
innerHTML: html,
|
|
is_modified: false,
|
|
modified() {
|
|
this.is_modified = true
|
|
},
|
|
close(this: IDialog) {
|
|
this.remove()
|
|
},
|
|
} satisfies Partial<IDialog>
|
|
|
|
const div = document.createElement("div")
|
|
const dialog: IDialog = Object.assign(div, customProperties)
|
|
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
let offsetx = -20
|
|
let offsety = -20
|
|
if (rect) {
|
|
offsetx -= rect.left
|
|
offsety -= rect.top
|
|
}
|
|
|
|
if (options.position) {
|
|
offsetx += options.position[0]
|
|
offsety += options.position[1]
|
|
} else if (options.event) {
|
|
offsetx += options.event.clientX
|
|
offsety += options.event.clientY
|
|
} else {
|
|
// centered
|
|
offsetx += this.canvas.width * 0.5
|
|
offsety += this.canvas.height * 0.5
|
|
}
|
|
|
|
dialog.style.left = `${offsetx}px`
|
|
dialog.style.top = `${offsety}px`
|
|
|
|
if (!this.canvas.parentNode) throw new TypeError("Canvas parent element was null.")
|
|
this.canvas.parentNode.append(dialog)
|
|
|
|
// acheck for input and use default behaviour: save on enter, close on esc
|
|
if (options.checkForInput) {
|
|
const aI = dialog.querySelectorAll("input")
|
|
if (aI) {
|
|
for (const iX of aI) {
|
|
iX.addEventListener("keydown", function (e) {
|
|
dialog.modified()
|
|
if (e.key == "Escape") {
|
|
dialog.close()
|
|
} else if (e.key != "Enter") {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
iX.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
let dialogCloseTimer: number
|
|
let prevent_timeout = 0
|
|
dialog.addEventListener("mouseleave", function () {
|
|
if (prevent_timeout) return
|
|
|
|
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
|
dialogCloseTimer = setTimeout(
|
|
dialog.close,
|
|
LiteGraph.dialog_close_on_mouse_leave_delay,
|
|
)
|
|
}
|
|
})
|
|
dialog.addEventListener("mouseenter", function () {
|
|
if (options.closeOnLeave || LiteGraph.dialog_close_on_mouse_leave) {
|
|
if (dialogCloseTimer) clearTimeout(dialogCloseTimer)
|
|
}
|
|
})
|
|
const selInDia = dialog.querySelectorAll("select")
|
|
// if filtering, check focus changed to comboboxes and prevent closing
|
|
if (selInDia) {
|
|
for (const selIn of selInDia) {
|
|
selIn.addEventListener("click", function () {
|
|
prevent_timeout++
|
|
})
|
|
selIn.addEventListener("blur", function () {
|
|
prevent_timeout = 0
|
|
})
|
|
selIn.addEventListener("change", function () {
|
|
prevent_timeout = -1
|
|
})
|
|
}
|
|
}
|
|
|
|
return dialog
|
|
}
|
|
|
|
createPanel(title: string, options: ICreatePanelOptions) {
|
|
options = options || {}
|
|
|
|
const ref_window = options.window || window
|
|
// TODO: any kludge
|
|
const root: any = document.createElement("div")
|
|
root.className = "litegraph dialog"
|
|
root.innerHTML = "<div class='dialog-header'><span class='dialog-title'></span></div><div class='dialog-content'></div><div style='display:none;' class='dialog-alt-content'></div><div class='dialog-footer'></div>"
|
|
root.header = root.querySelector(".dialog-header")
|
|
|
|
if (options.width)
|
|
root.style.width = options.width + (typeof options.width === "number" ? "px" : "")
|
|
if (options.height)
|
|
root.style.height = options.height + (typeof options.height === "number" ? "px" : "")
|
|
if (options.closable) {
|
|
const close = document.createElement("span")
|
|
close.innerHTML = "✕"
|
|
close.classList.add("close")
|
|
close.addEventListener("click", function () {
|
|
root.close()
|
|
})
|
|
root.header.append(close)
|
|
}
|
|
root.title_element = root.querySelector(".dialog-title")
|
|
root.title_element.textContent = title
|
|
root.content = root.querySelector(".dialog-content")
|
|
root.alt_content = root.querySelector(".dialog-alt-content")
|
|
root.footer = root.querySelector(".dialog-footer")
|
|
|
|
root.close = function () {
|
|
if (typeof root.onClose == "function") root.onClose()
|
|
root.remove()
|
|
this.remove()
|
|
}
|
|
|
|
// function to swap panel content
|
|
root.toggleAltContent = function (force: unknown) {
|
|
let vTo: string
|
|
let vAlt: string
|
|
if (force !== undefined) {
|
|
vTo = force ? "block" : "none"
|
|
vAlt = force ? "none" : "block"
|
|
} else {
|
|
vTo = root.alt_content.style.display != "block" ? "block" : "none"
|
|
vAlt = root.alt_content.style.display != "block" ? "none" : "block"
|
|
}
|
|
root.alt_content.style.display = vTo
|
|
root.content.style.display = vAlt
|
|
}
|
|
|
|
root.toggleFooterVisibility = function (force: unknown) {
|
|
let vTo: string
|
|
if (force !== undefined) {
|
|
vTo = force ? "block" : "none"
|
|
} else {
|
|
vTo = root.footer.style.display != "block" ? "block" : "none"
|
|
}
|
|
root.footer.style.display = vTo
|
|
}
|
|
|
|
root.clear = function () {
|
|
this.content.innerHTML = ""
|
|
}
|
|
|
|
root.addHTML = function (code: string, classname: string, on_footer: any) {
|
|
const elem = document.createElement("div")
|
|
if (classname) elem.className = classname
|
|
elem.innerHTML = code
|
|
if (on_footer) root.footer.append(elem)
|
|
else root.content.append(elem)
|
|
return elem
|
|
}
|
|
|
|
root.addButton = function (name: any, callback: any, options: any) {
|
|
// TODO: any kludge
|
|
const elem: any = document.createElement("button")
|
|
elem.textContent = name
|
|
elem.options = options
|
|
elem.classList.add("btn")
|
|
elem.addEventListener("click", callback)
|
|
root.footer.append(elem)
|
|
return elem
|
|
}
|
|
|
|
root.addSeparator = function () {
|
|
const elem = document.createElement("div")
|
|
elem.className = "separator"
|
|
root.content.append(elem)
|
|
}
|
|
|
|
root.addWidget = function (type: string, name: any, value: unknown, options: { label?: any, type?: any, values?: any, callback?: any }, callback: (arg0: any, arg1: any, arg2: any) => void) {
|
|
options = options || {}
|
|
let str_value = String(value)
|
|
type = type.toLowerCase()
|
|
if (type == "number" && typeof value === "number") str_value = value.toFixed(3)
|
|
|
|
// FIXME: any kludge
|
|
const elem: HTMLDivElement & { options?: unknown, value?: unknown } = document.createElement("div")
|
|
elem.className = "property"
|
|
elem.innerHTML = "<span class='property_name'></span><span class='property_value'></span>"
|
|
const nameSpan = elem.querySelector(".property_name")
|
|
if (!nameSpan) throw new TypeError("Property name element was null.")
|
|
|
|
nameSpan.textContent = options.label || name
|
|
// TODO: any kludge
|
|
const value_element: HTMLSpanElement | null = elem.querySelector(".property_value")
|
|
if (!value_element) throw new TypeError("Property name element was null.")
|
|
value_element.textContent = str_value
|
|
elem.dataset["property"] = name
|
|
elem.dataset["type"] = options.type || type
|
|
elem.options = options
|
|
elem.value = value
|
|
|
|
if (type == "code") {
|
|
elem.addEventListener("click", function () {
|
|
root.inner_showCodePad(this.dataset["property"])
|
|
})
|
|
} else if (type == "boolean") {
|
|
elem.classList.add("boolean")
|
|
if (value) elem.classList.add("bool-on")
|
|
elem.addEventListener("click", () => {
|
|
const propname = elem.dataset["property"]
|
|
elem.value = !elem.value
|
|
elem.classList.toggle("bool-on")
|
|
if (!value_element) throw new TypeError("Property name element was null.")
|
|
|
|
value_element.textContent = elem.value
|
|
? "true"
|
|
: "false"
|
|
innerChange(propname, elem.value)
|
|
})
|
|
} else if (type == "string" || type == "number") {
|
|
if (!value_element) throw new TypeError("Property name element was null.")
|
|
value_element.setAttribute("contenteditable", "true")
|
|
value_element.addEventListener("keydown", function (e) {
|
|
// allow for multiline
|
|
if (e.code == "Enter" && (type != "string" || !e.shiftKey)) {
|
|
e.preventDefault()
|
|
this.blur()
|
|
}
|
|
})
|
|
value_element.addEventListener("blur", function () {
|
|
let v: string | number | null = this.textContent
|
|
const propname = this.parentElement?.dataset["property"]
|
|
const proptype = this.parentElement?.dataset["type"]
|
|
if (proptype == "number") v = Number(v)
|
|
innerChange(propname, v)
|
|
})
|
|
} else if (type == "enum" || type == "combo") {
|
|
const str_value = LGraphCanvas.getPropertyPrintableValue(value, options.values)
|
|
if (!value_element) throw new TypeError("Property name element was null.")
|
|
value_element.textContent = str_value ?? ""
|
|
|
|
value_element.addEventListener("click", function (event) {
|
|
const values = options.values || []
|
|
const propname = this.parentElement?.dataset["property"]
|
|
const inner_clicked = (v: string | null) => {
|
|
// node.setProperty(propname,v);
|
|
// graphcanvas.dirty_canvas = true;
|
|
this.textContent = v
|
|
innerChange(propname, v)
|
|
return false
|
|
}
|
|
new LiteGraph.ContextMenu(
|
|
values,
|
|
{
|
|
event,
|
|
className: "dark",
|
|
callback: inner_clicked,
|
|
},
|
|
// @ts-expect-error
|
|
ref_window,
|
|
)
|
|
})
|
|
}
|
|
|
|
root.content.append(elem)
|
|
|
|
function innerChange(name: string | undefined, value: unknown) {
|
|
options.callback?.(name, value, options)
|
|
callback?.(name, value, options)
|
|
}
|
|
|
|
return elem
|
|
}
|
|
|
|
if (typeof root.onOpen == "function") root.onOpen()
|
|
|
|
return root
|
|
}
|
|
|
|
closePanels(): void {
|
|
type MightHaveClose = HTMLDivElement & Partial<ICloseable>
|
|
document.querySelector<MightHaveClose>("#node-panel")?.close?.()
|
|
document.querySelector<MightHaveClose>("#option-panel")?.close?.()
|
|
}
|
|
|
|
showShowNodePanel(node: LGraphNode): void {
|
|
this.SELECTED_NODE = node
|
|
this.closePanels()
|
|
const ref_window = this.getCanvasWindow()
|
|
const panel = this.createPanel(node.title || "", {
|
|
closable: true,
|
|
window: ref_window,
|
|
onOpen: () => {
|
|
this.NODEPANEL_IS_OPEN = true
|
|
},
|
|
onClose: () => {
|
|
this.NODEPANEL_IS_OPEN = false
|
|
this.node_panel = null
|
|
},
|
|
})
|
|
this.node_panel = panel
|
|
panel.id = "node-panel"
|
|
panel.node = node
|
|
panel.classList.add("settings")
|
|
|
|
const inner_refresh = () => {
|
|
// clear
|
|
panel.content.innerHTML = ""
|
|
// @ts-expect-error ctor props
|
|
panel.addHTML(`<span class='node_type'>${node.type}</span><span class='node_desc'>${node.constructor.desc || ""}</span><span class='separator'></span>`)
|
|
|
|
panel.addHTML("<h3>Properties</h3>")
|
|
|
|
const fUpdate = (name: string, value: string | number | boolean | object | undefined) => {
|
|
if (!this.graph) throw new NullGraphError()
|
|
this.graph.beforeChange(node)
|
|
switch (name) {
|
|
case "Title":
|
|
if (typeof value !== "string") throw new TypeError("Attempting to set title to non-string value.")
|
|
|
|
node.title = value
|
|
break
|
|
case "Mode": {
|
|
if (typeof value !== "string") throw new TypeError("Attempting to set mode to non-string value.")
|
|
|
|
const kV = Object.values(LiteGraph.NODE_MODES).indexOf(value)
|
|
if (kV !== -1 && LiteGraph.NODE_MODES[kV]) {
|
|
node.changeMode(kV)
|
|
} else {
|
|
console.warn(`unexpected mode: ${value}`)
|
|
}
|
|
break
|
|
}
|
|
case "Color":
|
|
if (typeof value !== "string") throw new TypeError("Attempting to set colour to non-string value.")
|
|
|
|
if (LGraphCanvas.node_colors[value]) {
|
|
node.color = LGraphCanvas.node_colors[value].color
|
|
node.bgcolor = LGraphCanvas.node_colors[value].bgcolor
|
|
} else {
|
|
console.warn(`unexpected color: ${value}`)
|
|
}
|
|
break
|
|
default:
|
|
node.setProperty(name, value)
|
|
break
|
|
}
|
|
this.graph.afterChange()
|
|
this.dirty_canvas = true
|
|
}
|
|
|
|
panel.addWidget("string", "Title", node.title, {}, fUpdate)
|
|
|
|
const mode = node.mode == null ? undefined : LiteGraph.NODE_MODES[node.mode]
|
|
panel.addWidget("combo", "Mode", mode, { values: LiteGraph.NODE_MODES }, fUpdate)
|
|
|
|
const nodeCol = node.color !== undefined
|
|
? Object.keys(LGraphCanvas.node_colors).filter(function (nK) { return LGraphCanvas.node_colors[nK].color == node.color })
|
|
: ""
|
|
|
|
panel.addWidget("combo", "Color", nodeCol, { values: Object.keys(LGraphCanvas.node_colors) }, fUpdate)
|
|
|
|
for (const pName in node.properties) {
|
|
const value = node.properties[pName]
|
|
const info = node.getPropertyInfo(pName)
|
|
|
|
// in case the user wants control over the side panel widget
|
|
if (node.onAddPropertyToPanel?.(pName, panel)) continue
|
|
|
|
panel.addWidget(info.widget || info.type, pName, value, info, fUpdate)
|
|
}
|
|
|
|
panel.addSeparator()
|
|
|
|
node.onShowCustomPanelInfo?.(panel)
|
|
|
|
// clear
|
|
panel.footer.innerHTML = ""
|
|
panel.addButton("Delete", function () {
|
|
if (node.block_delete) return
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.remove(node)
|
|
panel.close()
|
|
}).classList.add("delete")
|
|
}
|
|
|
|
panel.inner_showCodePad = function (propname: string) {
|
|
panel.classList.remove("settings")
|
|
panel.classList.add("centered")
|
|
|
|
panel.alt_content.innerHTML = "<textarea class='code'></textarea>"
|
|
const textarea: HTMLTextAreaElement = panel.alt_content.querySelector("textarea")
|
|
const fDoneWith = function () {
|
|
panel.toggleAltContent(false)
|
|
panel.toggleFooterVisibility(true)
|
|
textarea.remove()
|
|
panel.classList.add("settings")
|
|
panel.classList.remove("centered")
|
|
inner_refresh()
|
|
}
|
|
textarea.value = String(node.properties[propname])
|
|
textarea.addEventListener("keydown", function (e: KeyboardEvent) {
|
|
if (e.code == "Enter" && e.ctrlKey) {
|
|
node.setProperty(propname, textarea.value)
|
|
fDoneWith()
|
|
}
|
|
})
|
|
panel.toggleAltContent(true)
|
|
panel.toggleFooterVisibility(false)
|
|
textarea.style.height = "calc(100% - 40px)"
|
|
|
|
const assign = panel.addButton("Assign", function () {
|
|
node.setProperty(propname, textarea.value)
|
|
fDoneWith()
|
|
})
|
|
panel.alt_content.append(assign)
|
|
const button = panel.addButton("Close", fDoneWith)
|
|
button.style.float = "right"
|
|
panel.alt_content.append(button)
|
|
}
|
|
|
|
inner_refresh()
|
|
|
|
if (!this.canvas.parentNode) throw new TypeError("showNodePanel - this.canvas.parentNode was null")
|
|
this.canvas.parentNode.append(panel)
|
|
}
|
|
|
|
checkPanels(): void {
|
|
if (!this.canvas) return
|
|
|
|
if (!this.canvas.parentNode) throw new TypeError("checkPanels - this.canvas.parentNode was null")
|
|
const panels = this.canvas.parentNode.querySelectorAll(".litegraph.dialog")
|
|
for (const panel of panels) {
|
|
// @ts-expect-error Panel
|
|
if (!panel.node) continue
|
|
// @ts-expect-error Panel
|
|
if (!panel.node.graph || panel.graph != this.graph) panel.close()
|
|
}
|
|
}
|
|
|
|
getCanvasMenuOptions(): IContextMenuValue<string>[] {
|
|
let options: IContextMenuValue<string>[]
|
|
if (this.getMenuOptions) {
|
|
options = this.getMenuOptions()
|
|
} else {
|
|
options = [
|
|
{
|
|
content: "Add Node",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onMenuAdd,
|
|
},
|
|
{ content: "Add Group", callback: LGraphCanvas.onGroupAdd },
|
|
// { content: "Arrange", callback: that.graph.arrange },
|
|
// {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll }
|
|
]
|
|
if (Object.keys(this.selected_nodes).length > 1) {
|
|
options.push({
|
|
content: "Align",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onGroupAlign,
|
|
})
|
|
}
|
|
}
|
|
|
|
const extra = this.getExtraMenuOptions?.(this, options)
|
|
return Array.isArray(extra)
|
|
? options.concat(extra)
|
|
: options
|
|
}
|
|
|
|
// called by processContextMenu to extract the menu list
|
|
getNodeMenuOptions(node: LGraphNode) {
|
|
let options: (IContextMenuValue<string> | IContextMenuValue<string | null> | IContextMenuValue<INodeSlotContextItem> | IContextMenuValue<unknown, LGraphNode> | IContextMenuValue<typeof LiteGraph.VALID_SHAPES[number]> | null)[]
|
|
|
|
if (node.getMenuOptions) {
|
|
options = node.getMenuOptions(this)
|
|
} else {
|
|
options = [
|
|
{
|
|
content: "Inputs",
|
|
has_submenu: true,
|
|
disabled: true,
|
|
},
|
|
{
|
|
content: "Outputs",
|
|
has_submenu: true,
|
|
disabled: true,
|
|
callback: LGraphCanvas.showMenuNodeOptionalOutputs,
|
|
},
|
|
null,
|
|
{
|
|
content: "Properties",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onShowMenuNodeProperties,
|
|
},
|
|
{
|
|
content: "Properties Panel",
|
|
callback: function (item: any, options: any, e: any, menu: any, node: LGraphNode) { LGraphCanvas.active_canvas.showShowNodePanel(node) },
|
|
},
|
|
null,
|
|
{
|
|
content: "Title",
|
|
callback: LGraphCanvas.onShowPropertyEditor,
|
|
},
|
|
{
|
|
content: "Mode",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onMenuNodeMode,
|
|
},
|
|
]
|
|
if (node.resizable !== false) {
|
|
options.push({
|
|
content: "Resize",
|
|
callback: LGraphCanvas.onMenuResizeNode,
|
|
})
|
|
}
|
|
if (node.collapsible) {
|
|
options.push({
|
|
content: node.collapsed ? "Expand" : "Collapse",
|
|
callback: LGraphCanvas.onMenuNodeCollapse,
|
|
})
|
|
}
|
|
if (node.widgets?.some(w => w.advanced)) {
|
|
options.push({
|
|
content: node.showAdvanced ? "Hide Advanced" : "Show Advanced",
|
|
callback: LGraphCanvas.onMenuToggleAdvanced,
|
|
})
|
|
}
|
|
options.push(
|
|
{
|
|
content: node.pinned ? "Unpin" : "Pin",
|
|
callback: () => {
|
|
for (const i in this.selected_nodes) {
|
|
const node = this.selected_nodes[i]
|
|
node.pin()
|
|
}
|
|
this.setDirty(true, true)
|
|
},
|
|
},
|
|
{
|
|
content: "Colors",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onMenuNodeColors,
|
|
},
|
|
{
|
|
content: "Shapes",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onMenuNodeShapes,
|
|
},
|
|
null,
|
|
)
|
|
}
|
|
|
|
const extra = node.getExtraMenuOptions?.(this, options)
|
|
if (Array.isArray(extra) && extra.length > 0) {
|
|
extra.push(null)
|
|
options = extra.concat(options)
|
|
}
|
|
|
|
if (node.clonable !== false) {
|
|
options.push({
|
|
content: "Clone",
|
|
callback: LGraphCanvas.onMenuNodeClone,
|
|
})
|
|
}
|
|
|
|
if (Object.keys(this.selected_nodes).length > 1) {
|
|
options.push({
|
|
content: "Align Selected To",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onNodeAlign,
|
|
}, {
|
|
content: "Distribute Nodes",
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.createDistributeMenu,
|
|
})
|
|
}
|
|
|
|
options.push(null, {
|
|
content: "Remove",
|
|
disabled: !(node.removable !== false && !node.block_delete),
|
|
callback: LGraphCanvas.onMenuNodeRemove,
|
|
})
|
|
|
|
node.graph?.onGetNodeMenuOptions?.(options, node)
|
|
|
|
return options
|
|
}
|
|
|
|
/** @deprecated */
|
|
getGroupMenuOptions(group: LGraphGroup) {
|
|
console.warn("LGraphCanvas.getGroupMenuOptions is deprecated, use LGraphGroup.getMenuOptions instead")
|
|
return group.getMenuOptions()
|
|
}
|
|
|
|
processContextMenu(node: LGraphNode | undefined, event: CanvasMouseEvent): void {
|
|
const canvas = LGraphCanvas.active_canvas
|
|
const ref_window = canvas.getCanvasWindow()
|
|
|
|
// TODO: Remove type kludge
|
|
let menu_info: (IContextMenuValue | string | null)[]
|
|
const options: IContextMenuOptions = {
|
|
event,
|
|
callback: inner_option_clicked,
|
|
extra: node,
|
|
}
|
|
|
|
if (node) {
|
|
options.title = node.type ?? undefined
|
|
LGraphCanvas.active_node = node
|
|
|
|
// check if mouse is in input
|
|
const slot = node.getSlotInPosition(event.canvasX, event.canvasY)
|
|
if (slot) {
|
|
// on slot
|
|
menu_info = []
|
|
if (node.getSlotMenuOptions) {
|
|
menu_info = node.getSlotMenuOptions(slot)
|
|
} else {
|
|
if (slot?.output?.links?.length)
|
|
menu_info.push({ content: "Disconnect Links", slot })
|
|
|
|
const _slot = slot.input || slot.output
|
|
if (!_slot) throw new TypeError("Both in put and output slots were null when processing context menu.")
|
|
|
|
if (_slot.removable) {
|
|
menu_info.push(
|
|
_slot.locked
|
|
? "Cannot remove"
|
|
: { content: "Remove Slot", slot },
|
|
)
|
|
}
|
|
if (!_slot.nameLocked)
|
|
menu_info.push({ content: "Rename Slot", slot })
|
|
|
|
if (node.getExtraSlotMenuOptions) {
|
|
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
|
}
|
|
}
|
|
// @ts-expect-error Slot type can be number and has number checks
|
|
options.title = (slot.input ? slot.input.type : slot.output.type) || "*"
|
|
if (slot.input && slot.input.type == LiteGraph.ACTION)
|
|
options.title = "Action"
|
|
|
|
if (slot.output && slot.output.type == LiteGraph.EVENT)
|
|
options.title = "Event"
|
|
} else {
|
|
// on node
|
|
menu_info = this.getNodeMenuOptions(node)
|
|
}
|
|
} else {
|
|
menu_info = this.getCanvasMenuOptions()
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
// Check for reroutes
|
|
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
|
const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY)
|
|
if (reroute) {
|
|
menu_info.unshift({
|
|
content: "Delete Reroute",
|
|
callback: () => {
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
this.graph.removeReroute(reroute.id)
|
|
},
|
|
}, null)
|
|
}
|
|
}
|
|
|
|
const group = this.graph.getGroupOnPos(
|
|
event.canvasX,
|
|
event.canvasY,
|
|
)
|
|
if (group) {
|
|
// on group
|
|
menu_info.push(null, {
|
|
content: "Edit Group",
|
|
has_submenu: true,
|
|
submenu: {
|
|
title: "Group",
|
|
extra: group,
|
|
options: group.getMenuOptions(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// show menu
|
|
if (!menu_info) return
|
|
|
|
// @ts-expect-error Remove param ref_window - unused
|
|
new LiteGraph.ContextMenu(menu_info, options, ref_window)
|
|
|
|
const createDialog = (options: IDialogOptions) => this.createDialog(
|
|
"<span class='name'>Name</span><input autofocus type='text'/><button>OK</button>",
|
|
options,
|
|
)
|
|
const setDirty = () => this.setDirty(true)
|
|
|
|
function inner_option_clicked(v: IContextMenuValue<unknown>, options: IDialogOptions) {
|
|
if (!v) return
|
|
|
|
if (v.content == "Remove Slot") {
|
|
if (!node?.graph) throw new NullGraphError()
|
|
|
|
const info = v.slot
|
|
if (!info) throw new TypeError("Found-slot info was null when processing context menu.")
|
|
|
|
node.graph.beforeChange()
|
|
if (info.input) {
|
|
node.removeInput(info.slot)
|
|
} else if (info.output) {
|
|
node.removeOutput(info.slot)
|
|
}
|
|
node.graph.afterChange()
|
|
return
|
|
} else if (v.content == "Disconnect Links") {
|
|
if (!node?.graph) throw new NullGraphError()
|
|
|
|
const info = v.slot
|
|
if (!info) throw new TypeError("Found-slot info was null when processing context menu.")
|
|
|
|
node.graph.beforeChange()
|
|
if (info.output) {
|
|
node.disconnectOutput(info.slot)
|
|
} else if (info.input) {
|
|
node.disconnectInput(info.slot, true)
|
|
}
|
|
node.graph.afterChange()
|
|
return
|
|
} else if (v.content == "Rename Slot") {
|
|
if (!node) throw new TypeError("`node` was null when processing the context menu.")
|
|
|
|
const info = v.slot
|
|
if (!info) throw new TypeError("Found-slot info was null when processing context menu.")
|
|
|
|
const slot_info = info.input
|
|
? node.getInputInfo(info.slot)
|
|
: node.getOutputInfo(info.slot)
|
|
const dialog = createDialog(options)
|
|
|
|
const input = dialog.querySelector("input")
|
|
if (input && slot_info) {
|
|
input.value = slot_info.label || ""
|
|
}
|
|
const inner = function () {
|
|
if (!node.graph) throw new NullGraphError()
|
|
|
|
node.graph.beforeChange()
|
|
if (input?.value) {
|
|
if (slot_info) {
|
|
slot_info.label = input.value
|
|
}
|
|
setDirty()
|
|
}
|
|
dialog.close()
|
|
node.graph.afterChange()
|
|
}
|
|
dialog.querySelector("button")?.addEventListener("click", inner)
|
|
if (!input) throw new TypeError("Input element was null when processing context menu.")
|
|
|
|
input.addEventListener("keydown", function (e) {
|
|
dialog.is_modified = true
|
|
if (e.key == "Escape") {
|
|
// ESC
|
|
dialog.close()
|
|
} else if (e.key == "Enter") {
|
|
// save
|
|
inner()
|
|
} else if ((e.target as Element).localName != "textarea") {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
input.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts an animation to fit the view around the specified selection of nodes.
|
|
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
|
*/
|
|
animateToBounds(bounds: ReadOnlyRect, options: AnimationOptions = {}) {
|
|
const setDirty = () => this.setDirty(true, true)
|
|
this.ds.animateToBounds(bounds, setDirty, options)
|
|
}
|
|
|
|
/**
|
|
* Fits the view to the selected nodes with animation.
|
|
* If nothing is selected, the view is fitted around all items in the graph.
|
|
*/
|
|
fitViewToSelectionAnimated(options: AnimationOptions = {}) {
|
|
const items = this.selectedItems.size
|
|
? Array.from(this.selectedItems)
|
|
: this.positionableItems
|
|
const bounds = createBounds(items)
|
|
if (!bounds) throw new TypeError("Attempted to fit to view but could not calculate bounds.")
|
|
|
|
const setDirty = () => this.setDirty(true, true)
|
|
this.ds.animateToBounds(bounds, setDirty, options)
|
|
}
|
|
}
|