mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 15:10:06 +00:00
Group first click displays an empty node list in Right Side Panel. The reason is that when clicking the group, only `processSelect()` is called, and `recomputeInsideNodes()` is not called. related https://github.com/Comfy-Org/ComfyUI_frontend/pull/8275 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8274-fix-group-recompute-in-item-selection-for-properties-panel-2f16d73d3650812ca394dc9de3d6855b) by [Unito](https://www.unito.io)
8643 lines
257 KiB
TypeScript
8643 lines
257 KiB
TypeScript
import { toString } from 'es-toolkit/compat'
|
|
|
|
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
|
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
|
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
|
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
|
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
|
import { forEachNode } from '@/utils/graphTraversalUtil'
|
|
|
|
import { CanvasPointer } from './CanvasPointer'
|
|
import type { ContextMenu } from './ContextMenu'
|
|
import { DragAndScale } from './DragAndScale'
|
|
import type { AnimationOptions } from './DragAndScale'
|
|
import type { LGraph } from './LGraph'
|
|
import { LGraphGroup } from './LGraphGroup'
|
|
import { LGraphNode } from './LGraphNode'
|
|
import type { NodeId, NodeProperty } from './LGraphNode'
|
|
import { LLink } from './LLink'
|
|
import type { LinkId } from './LLink'
|
|
import { Reroute } from './Reroute'
|
|
import type { RerouteId } from './Reroute'
|
|
import { LinkConnector } from './canvas/LinkConnector'
|
|
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
|
|
import { strokeShape } from './draw'
|
|
import type {
|
|
CustomEventDispatcher,
|
|
ICustomEventTarget
|
|
} from './infrastructure/CustomEventTarget'
|
|
import type { LGraphCanvasEventMap } from './infrastructure/LGraphCanvasEventMap'
|
|
import { NullGraphError } from './infrastructure/NullGraphError'
|
|
import { Rectangle } from './infrastructure/Rectangle'
|
|
import type {
|
|
CanvasColour,
|
|
ColorOption,
|
|
ConnectingLink,
|
|
ContextMenuDivElement,
|
|
DefaultConnectionColors,
|
|
Dictionary,
|
|
Direction,
|
|
IBoundaryNodes,
|
|
IColorable,
|
|
IContextMenuOptions,
|
|
IContextMenuValue,
|
|
INodeInputSlot,
|
|
INodeOutputSlot,
|
|
INodeSlot,
|
|
INodeSlotContextItem,
|
|
ISlotType,
|
|
LinkNetwork,
|
|
LinkSegment,
|
|
NewNodePosition,
|
|
NullableProperties,
|
|
Panel,
|
|
PanelButton,
|
|
PanelWidget,
|
|
PanelWidgetCallback,
|
|
PanelWidgetOptions,
|
|
Point,
|
|
Positionable,
|
|
ReadOnlyRect,
|
|
Rect,
|
|
Size
|
|
} from './interfaces'
|
|
import { LiteGraph } from './litegraph'
|
|
import {
|
|
containsRect,
|
|
createBounds,
|
|
distance,
|
|
isInRect,
|
|
isInRectangle,
|
|
isPointInRect,
|
|
overlapBounding,
|
|
snapPoint
|
|
} from './measure'
|
|
import { NodeInputSlot } from './node/NodeInputSlot'
|
|
import type { Subgraph } from './subgraph/Subgraph'
|
|
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
|
|
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
|
import { SubgraphNode } from './subgraph/SubgraphNode'
|
|
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
|
|
import type {
|
|
CanvasPointerEvent,
|
|
CanvasPointerExtensions
|
|
} from './types/events'
|
|
import {
|
|
CanvasItem,
|
|
LGraphEventMode,
|
|
LinkDirection,
|
|
LinkMarkerShape,
|
|
LinkRenderType,
|
|
RenderShape,
|
|
TitleMode
|
|
} from './types/globalEnums'
|
|
import type {
|
|
ClipboardItems,
|
|
ISerialisedNode,
|
|
SubgraphIO
|
|
} from './types/serialisation'
|
|
import type { NeverNever, PickNevers } from './types/utility'
|
|
import type { IBaseWidget, TWidgetValue } from './types/widgets'
|
|
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
|
|
import { findFirstNode, getAllNestedItems } from './utils/collections'
|
|
import { resolveConnectingLinkColor } from './utils/linkColors'
|
|
import { createUuidv4 } from './utils/uuid'
|
|
import type { UUID } from './utils/uuid'
|
|
import { BaseWidget } from './widgets/BaseWidget'
|
|
import { toConcreteWidget } 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?: SubgraphInputNode | LGraphNode | null
|
|
/** input */
|
|
slotFrom?: number | INodeOutputSlot | INodeInputSlot | SubgraphIO | null
|
|
/** output */
|
|
nodeTo?: SubgraphOutputNode | LGraphNode | null
|
|
/** output */
|
|
slotTo?: number | INodeOutputSlot | INodeInputSlot | SubgraphIO | 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?: CanvasPointerEvent
|
|
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 | null,
|
|
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} */
|
|
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
|
|
|
|
/**
|
|
* Dirty flag indicating that {@link selectedItems} has changed.
|
|
* Downstream consumers may reset to false once actioned.
|
|
*/
|
|
selectionChanged: 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>
|
|
/** Map: original subgraph IDs to newly created subgraphs */
|
|
subgraphs: Map<UUID, Subgraph>
|
|
}
|
|
|
|
/** 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?: boolean
|
|
window?: Window
|
|
onOpen?: () => void
|
|
onClose?: () => void
|
|
width?: number | string
|
|
height?: number | string
|
|
}
|
|
|
|
interface SlotTypeDefaultNodeOpts {
|
|
node?: string
|
|
title?: string
|
|
properties?: Record<string, NodeProperty>
|
|
inputs?: [string, string][]
|
|
outputs?: [string, string][]
|
|
json?: Parameters<LGraphNode['configure']>[0]
|
|
}
|
|
|
|
const cursors = {
|
|
NE: 'nesw-resize',
|
|
SE: 'nwse-resize',
|
|
SW: 'nesw-resize',
|
|
NW: 'nwse-resize'
|
|
} as const
|
|
|
|
// Optimised buffers used during rendering
|
|
const temp = new Rectangle()
|
|
const temp_vec2: Point = [0, 0]
|
|
const tmp_area = new Rectangle()
|
|
const margin_area = new Rectangle()
|
|
const link_bounding = new Rectangle()
|
|
/**
|
|
* 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 CustomEventDispatcher<LGraphCanvasEventMap> {
|
|
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' }
|
|
}
|
|
|
|
/**
|
|
* @internal Exclusively a workaround for design limitation in {@link LGraphNode.computeSize}.
|
|
*/
|
|
static _measureText?: (text: string, fontStyle?: string) => number
|
|
|
|
/**
|
|
* 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,
|
|
selectionChanged: false
|
|
}
|
|
|
|
#subgraph?: Subgraph
|
|
get subgraph(): Subgraph | undefined {
|
|
return this.#subgraph
|
|
}
|
|
|
|
set subgraph(value: Subgraph | undefined) {
|
|
if (value !== this.#subgraph) {
|
|
this.#subgraph = value
|
|
if (value)
|
|
this.dispatch('litegraph:set-graph', {
|
|
oldGraph: this.#subgraph,
|
|
newGraph: value
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The location of the fps info widget. Leaving an element unset will use the default position for that element.
|
|
*/
|
|
fpsInfoLocation:
|
|
| [x: number | null | undefined, y: number | null | undefined]
|
|
| null
|
|
| undefined
|
|
|
|
/** Dispatches a custom event on the canvas. */
|
|
dispatch<T extends keyof NeverNever<LGraphCanvasEventMap>>(
|
|
type: T,
|
|
detail: LGraphCanvasEventMap[T]
|
|
): boolean
|
|
dispatch<T extends keyof PickNevers<LGraphCanvasEventMap>>(type: T): boolean
|
|
dispatch<T extends keyof LGraphCanvasEventMap>(
|
|
type: T,
|
|
detail?: LGraphCanvasEventMap[T]
|
|
) {
|
|
const event = new CustomEvent(type as string, { detail, bubbles: true })
|
|
return this.canvas.dispatchEvent(event)
|
|
}
|
|
|
|
dispatchEvent<TEvent extends keyof LGraphCanvasEventMap>(
|
|
type: TEvent,
|
|
detail: LGraphCanvasEventMap[TEvent]
|
|
) {
|
|
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
|
|
}
|
|
|
|
#updateCursorStyle() {
|
|
if (!this.state.shouldSetCursor) return
|
|
|
|
const crosshairItems =
|
|
CanvasItem.Node |
|
|
CanvasItem.RerouteSlot |
|
|
CanvasItem.SubgraphIoNode |
|
|
CanvasItem.SubgraphIoSlot
|
|
|
|
let cursor = 'default'
|
|
if (this.state.draggingCanvas) {
|
|
cursor = 'grabbing'
|
|
} else if (this.state.readOnly) {
|
|
cursor = 'grab'
|
|
} else if (this.pointer.resizeDirection) {
|
|
cursor = cursors[this.pointer.resizeDirection] ?? cursors.SE
|
|
} else if (this.state.hoveringOver & crosshairItems) {
|
|
cursor = 'crosshair'
|
|
} else if (this.state.hoveringOver & CanvasItem.Reroute) {
|
|
cursor = 'grab'
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use {@link LGraphNode.titleFontStyle} instead.
|
|
*/
|
|
get title_text_font(): string {
|
|
return `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
|
}
|
|
// #endregion Legacy accessors
|
|
|
|
get inner_text_font(): string {
|
|
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
|
}
|
|
|
|
#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
|
|
}
|
|
|
|
// Cached LOD threshold values for performance
|
|
private _lowQualityZoomThreshold: number = 0
|
|
private _isLowQuality: boolean = false
|
|
|
|
/**
|
|
* Updates the low quality zoom threshold based on current settings.
|
|
* Called when min_font_size_for_lod or DPR changes.
|
|
*/
|
|
private updateLowQualityThreshold(): void {
|
|
if (this._min_font_size_for_lod === 0) {
|
|
// LOD disabled
|
|
this._lowQualityZoomThreshold = 0
|
|
this._isLowQuality = false
|
|
return
|
|
}
|
|
|
|
const baseFontSize = LiteGraph.NODE_TEXT_SIZE // 14px
|
|
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1) //Using sqrt here because higher DPR monitors do not linearily scale the readability of the font, instead they increase the font by some heurisitc, and to approximate we use sqrt to say basically a DPR of 2 increases the readability by 40%, 3 by 70%
|
|
|
|
// Calculate the zoom level where text becomes unreadable
|
|
this._lowQualityZoomThreshold =
|
|
this._min_font_size_for_lod / (baseFontSize * dprAdjustment)
|
|
|
|
// Update current state based on current zoom
|
|
this._isLowQuality = this.ds.scale < this._lowQualityZoomThreshold
|
|
}
|
|
|
|
/**
|
|
* Render low quality when zoomed out based on minimum readable font size.
|
|
*/
|
|
get low_quality(): boolean {
|
|
return this._isLowQuality
|
|
}
|
|
|
|
options: {
|
|
skip_events?: boolean
|
|
viewport?: Rect
|
|
skip_render?: boolean
|
|
autoresize?: boolean
|
|
}
|
|
|
|
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>
|
|
|
|
/** Gets link colours. Extremely basic impl. until the legacy object dictionaries are removed. */
|
|
colourGetter: DefaultConnectionColors = {
|
|
getConnectedColor: (type: string) =>
|
|
this.default_connection_color_byType[type] ||
|
|
this.default_connection_color.output_on,
|
|
getDisconnectedColor: (type: string) =>
|
|
this.default_connection_color_byTypeOff[type] ||
|
|
this.default_connection_color_byType[type] ||
|
|
this.default_connection_color.output_off
|
|
}
|
|
|
|
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
|
|
/** Minimum font size in pixels before switching to low quality rendering.
|
|
* This initializes first and if we can't get the value from the settings we default to 8px
|
|
*/
|
|
private _min_font_size_for_lod: number = 8
|
|
|
|
get min_font_size_for_lod(): number {
|
|
return this._min_font_size_for_lod
|
|
}
|
|
|
|
set min_font_size_for_lod(value: number) {
|
|
if (this._min_font_size_for_lod !== value) {
|
|
this._min_font_size_for_lod = value
|
|
this.updateLowQualityThreshold()
|
|
}
|
|
}
|
|
/** 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: HTMLDivElement,
|
|
str: string,
|
|
canvas: LGraphCanvas
|
|
) => string[] | undefined
|
|
onSearchBoxSelection?: (
|
|
name: string,
|
|
event: MouseEvent,
|
|
canvas: LGraphCanvas
|
|
) => void
|
|
onMouse?: (e: CanvasPointerEvent) => boolean
|
|
/** to render background objects (behind nodes and connections) in the canvas affected by transform */
|
|
onDrawBackground?: (
|
|
ctx: CanvasRenderingContext2D,
|
|
visible_area: Rectangle
|
|
) => void
|
|
/** to render foreground objects (above nodes and connections) in the canvas affected by transform */
|
|
onDrawForeground?: (
|
|
ctx: CanvasRenderingContext2D,
|
|
visible_area: Rectangle
|
|
) => 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, IBaseWidget] | 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: Rectangle
|
|
/** 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> = {}
|
|
|
|
#visibleReroutes: Set<Reroute> = new Set()
|
|
|
|
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: Readonly<Point> = [0, 0]
|
|
last_mouseclick: number = 0
|
|
graph: LGraph | Subgraph | null
|
|
get _graph(): LGraph | Subgraph {
|
|
if (!this.graph) throw new NullGraphError()
|
|
return this.graph
|
|
}
|
|
|
|
canvas: HTMLCanvasElement & ICustomEventTarget<LGraphCanvasEventMap>
|
|
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: CanvasPointerEvent) => void
|
|
_highlight_pos?: Point
|
|
_highlight_input?: INodeInputSlot
|
|
// TODO: Check if panels are used
|
|
/** @deprecated Panels */
|
|
node_panel?: Panel
|
|
/** @deprecated Panels */
|
|
options_panel?: Panel
|
|
_bg_img?: HTMLImageElement
|
|
_pattern?: CanvasPattern
|
|
_pattern_img?: HTMLImageElement
|
|
bg_tint?: string | CanvasGradient | CanvasPattern
|
|
// 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
|
|
|
|
/** Link rendering adapter for litegraph-to-canvas integration */
|
|
linkRenderer: LitegraphLinkAdapter | null = null
|
|
|
|
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
|
|
dragZoomEnabled: boolean = false
|
|
/** The start position of the drag zoom and original read-only state. */
|
|
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
|
|
|
|
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
|
|
liveSelection: boolean = false
|
|
|
|
getMenuOptions?(): IContextMenuValue<string>[]
|
|
getExtraMenuOptions?(
|
|
canvas: LGraphCanvas,
|
|
options: (IContextMenuValue<string> | null)[]
|
|
): (IContextMenuValue<string> | null)[]
|
|
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
|
|
/** @deprecated Called with the deprecated {@link selected_nodes} when the selection changes. Replacement not yet impl. */
|
|
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
|
|
|
|
/**
|
|
* 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)
|
|
|
|
// Set up zoom change handler for efficient LOD updates
|
|
this.ds.onChanged = (scale: number, _offset: Point) => {
|
|
// Only check LOD threshold if it's enabled
|
|
if (this._lowQualityZoomThreshold > 0) {
|
|
this._isLowQuality = scale < this._lowQualityZoomThreshold
|
|
}
|
|
}
|
|
|
|
// Initialize link renderer if graph is available
|
|
if (graph) {
|
|
this.linkRenderer = new LitegraphLinkAdapter(false)
|
|
}
|
|
|
|
this.linkConnector.events.addEventListener('link-created', () =>
|
|
this.#dirty()
|
|
)
|
|
|
|
// @deprecated Workaround: Keep until connecting_links is removed.
|
|
this.linkConnector.events.addEventListener('reset', () => {
|
|
this.connecting_links = null
|
|
this.dirty_bgcanvas = true
|
|
})
|
|
|
|
// 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 as LGraphNode,
|
|
slot_from: firstLink.fromSlot as INodeOutputSlot,
|
|
type_filter_in: firstLink.fromSlot.type
|
|
}
|
|
: {
|
|
node_to: firstLink.node as LGraphNode,
|
|
slot_to: firstLink.fromSlot as INodeInputSlot,
|
|
type_filter_out: firstLink.fromSlot.type
|
|
}
|
|
|
|
const afterRerouteId = firstLink.fromReroute?.id
|
|
|
|
if ('shiftKey' in e && e.shiftKey) {
|
|
if (this.allow_searchbox) {
|
|
this.showSearchBox(
|
|
e as unknown as MouseEvent,
|
|
linkReleaseContext as IShowSearchOptions
|
|
)
|
|
}
|
|
} else if (this.linkConnector.state.connectingTo === 'input') {
|
|
this.showConnectionMenu({
|
|
nodeFrom: firstLink.node as LGraphNode,
|
|
slotFrom: firstLink.fromSlot as INodeOutputSlot,
|
|
e,
|
|
afterRerouteId
|
|
})
|
|
} else {
|
|
this.showConnectionMenu({
|
|
nodeTo: firstLink.node as LGraphNode,
|
|
slotTo: firstLink.fromSlot as INodeInputSlot,
|
|
e,
|
|
afterRerouteId
|
|
})
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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()
|
|
|
|
LGraphCanvas._measureText = (
|
|
text: string,
|
|
fontStyle = this.inner_text_font
|
|
) => {
|
|
const { ctx } = this
|
|
const { font } = ctx
|
|
try {
|
|
ctx.font = fontStyle
|
|
return ctx.measureText(text).width
|
|
} finally {
|
|
ctx.font = font
|
|
}
|
|
}
|
|
|
|
if (!options.skip_render) {
|
|
this.startRendering()
|
|
}
|
|
|
|
this.autoresize = options.autoresize ?? false
|
|
|
|
this.updateLowQualityThreshold()
|
|
}
|
|
|
|
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 {
|
|
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
|
|
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
|
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) {
|
|
const newPositions = alignNodes(
|
|
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
|
value.toLowerCase() as Direction,
|
|
node
|
|
)
|
|
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
|
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) {
|
|
const newPositions = alignNodes(
|
|
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
|
value.toLowerCase() as Direction
|
|
)
|
|
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
|
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
|
|
const newPositions = distributeNodes(
|
|
Object.values(canvas.selected_nodes),
|
|
value === 'Horizontally'
|
|
)
|
|
canvas.repositionNodesVueMode(newPositions)
|
|
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 { 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 occur in normal usage.'
|
|
)
|
|
node.pos = canvas.convertEventToCanvasOffset(first_event)
|
|
canvas.graph.add(node)
|
|
} else {
|
|
console.warn('Failed to create node of type:', value.value)
|
|
}
|
|
|
|
callback?.(node)
|
|
canvas.graph.afterChange()
|
|
}
|
|
}
|
|
|
|
entries.push(entry)
|
|
}
|
|
|
|
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
|
|
}
|
|
}
|
|
|
|
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 modifying 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?: string | IContextMenuValue<INodeSlotContextItem>,
|
|
_options?: unknown,
|
|
e?: MouseEvent,
|
|
prev?: ContextMenu<INodeSlotContextItem>
|
|
) {
|
|
if (!node) return
|
|
if (!v || typeof v === 'string') return
|
|
|
|
// TODO: This is a static method, so the below "that" appears broken.
|
|
if (v.callback) void 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 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(toString(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,
|
|
node
|
|
})
|
|
|
|
function inner_clicked(
|
|
this: ContextMenuDivElement,
|
|
v?: string | IContextMenuValue<string>
|
|
) {
|
|
if (!node || typeof v === 'string' || !v?.value) 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: ReturnType<typeof setTimeout> | undefined
|
|
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 canvas = LGraphCanvas.active_canvas
|
|
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
|
|
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
|
|
}
|
|
|
|
static cloneNodes(nodes: Positionable[]) {
|
|
const canvas = LGraphCanvas.active_canvas
|
|
|
|
// Find top-left-most boundary
|
|
let offsetX = Infinity
|
|
let offsetY = Infinity
|
|
for (const item of nodes) {
|
|
if (item.pos == null)
|
|
throw new TypeError(
|
|
'Invalid node encountered on clone. `pos` was null.'
|
|
)
|
|
offsetX = Math.min(offsetX, item.pos[0])
|
|
offsetY = Math.min(offsetY, item.pos[1])
|
|
}
|
|
|
|
return canvas._deserializeItems(canvas._serializeItems(nodes), {
|
|
position: [offsetX + 5, offsetY + 5]
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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.state.selectionChanged = true
|
|
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 new graph to this canvas.
|
|
*/
|
|
setGraph(newGraph: LGraph | Subgraph): void {
|
|
const { graph } = this
|
|
if (newGraph === graph) return
|
|
|
|
this.clear()
|
|
newGraph.attachCanvas(this)
|
|
|
|
// Re-initialize link renderer with new graph
|
|
this.linkRenderer = new LitegraphLinkAdapter(false)
|
|
|
|
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
|
|
this.#dirty()
|
|
}
|
|
|
|
openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
|
|
const { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const options = {
|
|
bubbles: true,
|
|
detail: { subgraph, closingGraph: graph, fromNode },
|
|
cancelable: true
|
|
}
|
|
const mayContinue = this.canvas.dispatchEvent(
|
|
new CustomEvent('subgraph-opening', options)
|
|
)
|
|
if (!mayContinue) return
|
|
|
|
this.clear()
|
|
this.subgraph = subgraph
|
|
this.setGraph(subgraph)
|
|
|
|
this.canvas.dispatchEvent(new CustomEvent('subgraph-opened', options))
|
|
}
|
|
|
|
/**
|
|
* @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 bound')
|
|
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 bound')
|
|
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
|
|
}
|
|
|
|
#linkConnectorDrop(): void {
|
|
const { graph, linkConnector, pointer } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
pointer.onDragEnd = (upEvent) => linkConnector.dropLinks(graph, upEvent)
|
|
pointer.finally = () => this.linkConnector.reset(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, if any is found.
|
|
* @deprecated Use {@link LGraphNode.getWidgetOnPos} instead.
|
|
* ```ts
|
|
* const [x, y] = canvas.graph_mouse
|
|
* const widget = canvas.node_over?.getWidgetOnPos(x, y, true)
|
|
* ```
|
|
*/
|
|
getWidgetAtCursor(node?: LGraphNode): IBaseWidget | undefined {
|
|
node ??= this.node_over
|
|
return node?.getWidgetOnPos(this.graph_mouse[0], this.graph_mouse[1], true)
|
|
}
|
|
|
|
/**
|
|
* 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: CanvasPointerEvent): void {
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
const { pointer } = this
|
|
const nodes = this.graph._nodes
|
|
for (const otherNode of nodes) {
|
|
if (otherNode.mouseOver && node != otherNode) {
|
|
// mouse leave
|
|
if (!pointer.eDown) pointer.resizeDirection = undefined
|
|
otherNode.mouseOver = undefined
|
|
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: MouseEvent): void {
|
|
if (
|
|
this.dragZoomEnabled &&
|
|
e.ctrlKey &&
|
|
e.shiftKey &&
|
|
!e.altKey &&
|
|
e.buttons
|
|
) {
|
|
this.#dragZoomStart = {
|
|
pos: [e.x, e.y],
|
|
scale: this.ds.scale,
|
|
readOnly: this.read_only
|
|
}
|
|
this.read_only = true
|
|
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
|
|
const { linkConnector, subgraph } = this
|
|
|
|
// Sticky select - won't remove single nodes
|
|
if (subgraph?.inputNode.containsPoint(this.graph_mouse)) {
|
|
// Subgraph input node
|
|
this.processSelect(subgraph.inputNode, e, true)
|
|
subgraph.inputNode.onPointerDown(e, pointer, linkConnector)
|
|
} else if (subgraph?.outputNode.containsPoint(this.graph_mouse)) {
|
|
// Subgraph output node
|
|
this.processSelect(subgraph.outputNode, e, true)
|
|
subgraph.outputNode.onPointerDown(e, pointer, linkConnector)
|
|
} else {
|
|
if (node) {
|
|
this.processSelect(node, e, true)
|
|
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
|
// Reroutes
|
|
// Try layout store first, fallback to old method
|
|
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
|
x: e.canvasX,
|
|
y: e.canvasY
|
|
})
|
|
|
|
let reroute: Reroute | undefined
|
|
if (rerouteLayout) {
|
|
reroute = graph.getReroute(rerouteLayout.id)
|
|
} else {
|
|
reroute = graph.getRerouteOnPos(
|
|
e.canvasX,
|
|
e.canvasY,
|
|
this.#visibleReroutes
|
|
)
|
|
}
|
|
if (reroute) {
|
|
if (e.altKey) {
|
|
pointer.onClick = (upEvent) => {
|
|
if (upEvent.altKey) {
|
|
// Ensure deselected
|
|
if (reroute.selected) {
|
|
this.deselect(reroute)
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
}
|
|
reroute.remove()
|
|
}
|
|
}
|
|
} else {
|
|
this.processSelect(reroute, e, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show context menu for the node or group under the pointer
|
|
pointer.onClick ??= () => 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)
|
|
}
|
|
|
|
/**
|
|
* Returns the first matching positionable item at the given co-ordinates.
|
|
*
|
|
* Order of preference:
|
|
* - Subgraph IO Nodes
|
|
* - Reroutes
|
|
* - Group titlebars
|
|
* @param x The x coordinate in canvas space
|
|
* @param y The y coordinate in canvas space
|
|
* @returns The positionable item or undefined
|
|
*/
|
|
#getPositionableOnPos(x: number, y: number): Positionable | undefined {
|
|
const ioNode = this.subgraph?.getIoNodeOnPos(x, y)
|
|
if (ioNode) return ioNode
|
|
|
|
for (const reroute of this.#visibleReroutes) {
|
|
if (reroute.containsPoint([x, y])) return reroute
|
|
}
|
|
|
|
return this.graph?.getGroupTitlebarOnPos(x, y)
|
|
}
|
|
|
|
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
|
|
const { pointer, graph, linkConnector, subgraph } = 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 &&
|
|
LiteGraph.leftMouseClickBehavior === 'panning'
|
|
) {
|
|
this.#setupNodeSelectionDrag(e, pointer, node)
|
|
|
|
return
|
|
}
|
|
|
|
if (this.read_only) {
|
|
pointer.finally = () => (this.dragging_canvas = false)
|
|
this.dragging_canvas = true
|
|
return
|
|
}
|
|
|
|
// clone node ALT dragging
|
|
if (
|
|
!LiteGraph.vueNodesMode &&
|
|
LiteGraph.alt_drag_do_clone_nodes &&
|
|
e.altKey &&
|
|
!e.ctrlKey &&
|
|
node &&
|
|
this.allow_interaction
|
|
) {
|
|
const items = this._deserializeItems(this._serializeItems([node]), {
|
|
position: node.pos
|
|
})
|
|
const cloned = items?.created[0] as LGraphNode | undefined
|
|
if (!cloned) return
|
|
|
|
cloned.pos[0] += 5
|
|
cloned.pos[1] += 5
|
|
|
|
if (this.allow_dragnodes) {
|
|
pointer.onDragStart = (pointer) => {
|
|
this.#startDraggingItems(cloned, pointer)
|
|
}
|
|
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Node clicked
|
|
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
|
|
this.#processNodeClick(e, ctrlOrMeta, node)
|
|
} else {
|
|
// Subgraph IO nodes
|
|
if (subgraph) {
|
|
const { inputNode, outputNode } = subgraph
|
|
|
|
if (processSubgraphIONode(this, inputNode)) return
|
|
if (processSubgraphIONode(this, outputNode)) return
|
|
|
|
function processSubgraphIONode(
|
|
canvas: LGraphCanvas,
|
|
ioNode: SubgraphInputNode | SubgraphOutputNode
|
|
) {
|
|
if (!ioNode.containsPoint([x, y])) return false
|
|
|
|
ioNode.onPointerDown(e, pointer, linkConnector)
|
|
pointer.onClick ??= () => canvas.processSelect(ioNode, e)
|
|
pointer.onDragStart ??= () =>
|
|
canvas.#startDraggingItems(ioNode, pointer, true)
|
|
pointer.onDragEnd ??= (eUp) => canvas.#processDraggedItems(eUp)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Reroutes
|
|
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
|
// Try layout store first for hit detection
|
|
const rerouteLayout = layoutStore.queryRerouteAtPoint({ x, y })
|
|
let foundReroute: Reroute | undefined
|
|
|
|
if (rerouteLayout) {
|
|
foundReroute = graph.getReroute(rerouteLayout.id)
|
|
}
|
|
|
|
// Fallback to checking visible reroutes directly
|
|
for (const reroute of this.#visibleReroutes) {
|
|
const overReroute =
|
|
foundReroute === reroute || reroute.containsPoint([x, y])
|
|
if (!reroute.isSlotHovered && !overReroute) continue
|
|
|
|
if (overReroute) {
|
|
pointer.onClick = () => this.processSelect(reroute, e)
|
|
if (!e.shiftKey) {
|
|
pointer.onDragStart = (pointer) =>
|
|
this.#startDraggingItems(reroute, pointer, true)
|
|
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
|
}
|
|
}
|
|
|
|
if (reroute.isOutputHovered || (overReroute && e.shiftKey)) {
|
|
linkConnector.dragFromReroute(graph, reroute)
|
|
this.#linkConnectorDrop()
|
|
}
|
|
|
|
if (reroute.isInputHovered) {
|
|
linkConnector.dragFromRerouteToOutput(graph, reroute)
|
|
this.#linkConnectorDrop()
|
|
}
|
|
|
|
reroute.hideSlots()
|
|
this.dirty_bgcanvas = true
|
|
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 = Math.max(window?.devicePixelRatio ?? 1, 1)
|
|
|
|
// Try layout store for segment hit testing first (more precise)
|
|
const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
|
|
|
|
for (const linkSegment of this.renderedPaths) {
|
|
const centre = linkSegment._pos
|
|
if (!centre) continue
|
|
|
|
// Check if this link segment was hit
|
|
let isLinkHit =
|
|
hitSegment &&
|
|
linkSegment.id ===
|
|
(linkSegment instanceof Reroute
|
|
? hitSegment.rerouteId
|
|
: hitSegment.linkId)
|
|
|
|
if (!isLinkHit && linkSegment.path) {
|
|
// Fallback to direct path hit testing if not found in layout store
|
|
isLinkHit = this.ctx.isPointInStroke(
|
|
linkSegment.path,
|
|
x * dpi,
|
|
y * dpi
|
|
)
|
|
}
|
|
|
|
// If we shift click on a link then start a link from that input
|
|
if ((e.shiftKey || e.altKey) && isLinkHit) {
|
|
this.ctx.lineWidth = lineWidth
|
|
|
|
if (e.shiftKey && !e.altKey) {
|
|
linkConnector.dragFromLinkSegment(graph, linkSegment)
|
|
this.#linkConnectorDrop()
|
|
|
|
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 (
|
|
this.linkMarkerShape !== LinkMarkerShape.None &&
|
|
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
|
|
) {
|
|
// allow dragging canvas based on leftMouseClickBehavior or read-only mode
|
|
if (LiteGraph.leftMouseClickBehavior === 'panning' || this.read_only) {
|
|
pointer.onClick = () => this.processSelect(null, e)
|
|
pointer.finally = () => (this.dragging_canvas = false)
|
|
this.dragging_canvas = true
|
|
} else {
|
|
this.#setupNodeSelectionDrag(e, pointer)
|
|
}
|
|
}
|
|
}
|
|
|
|
#setupNodeSelectionDrag(
|
|
e: CanvasPointerEvent,
|
|
pointer: CanvasPointer,
|
|
node?: LGraphNode | undefined
|
|
): void {
|
|
const dragRect: Rect = [0, 0, 0, 0]
|
|
|
|
dragRect[0] = e.canvasX
|
|
dragRect[1] = e.canvasY
|
|
dragRect[2] = 1
|
|
dragRect[3] = 1
|
|
|
|
pointer.onClick = (eUp) => {
|
|
// Click, not drag
|
|
const clickedItem =
|
|
node ?? this.#getPositionableOnPos(eUp.canvasX, eUp.canvasY)
|
|
this.processSelect(clickedItem, eUp)
|
|
}
|
|
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
|
|
|
|
if (this.liveSelection) {
|
|
const initialSelection = new Set(this.selectedItems)
|
|
|
|
pointer.onDrag = (eMove) =>
|
|
this.handleLiveSelect(eMove, dragRect, initialSelection)
|
|
|
|
pointer.onDragEnd = () => this.finalizeLiveSelect()
|
|
} else {
|
|
// Classic mode: select only when drag ends
|
|
pointer.onDragEnd = (upEvent) =>
|
|
this.#handleMultiSelect(upEvent, dragRect)
|
|
}
|
|
|
|
pointer.finally = () => (this.dragging_rectangle = null)
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// In Vue nodes mode, Vue components own all node-level interactions
|
|
// Skip LiteGraph handling to prevent dual event processing
|
|
if (LiteGraph.vueNodesMode) {
|
|
return
|
|
}
|
|
|
|
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) {
|
|
const { inputs, outputs } = node
|
|
|
|
function hasRelevantOutputLinks(
|
|
output: INodeOutputSlot,
|
|
network: LinkNetwork
|
|
): boolean {
|
|
const outputLinks = [
|
|
...(output.links ?? []),
|
|
...[...(output._floatingLinks ?? new Set())]
|
|
]
|
|
return outputLinks.some(
|
|
(linkId) =>
|
|
typeof linkId === 'number' && network.getLink(linkId) !== undefined
|
|
)
|
|
}
|
|
|
|
// 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 && hasRelevantOutputLinks(output, graph)) {
|
|
linkConnector.moveOutputLink(graph, output)
|
|
this.#linkConnectorDrop()
|
|
return
|
|
}
|
|
|
|
// New output link
|
|
linkConnector.dragNewFromOutput(graph, node, output)
|
|
this.#linkConnectorDrop()
|
|
|
|
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)
|
|
const isInSlot =
|
|
input instanceof NodeInputSlot
|
|
? isInRect(x, y, input.boundingRect)
|
|
: isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)
|
|
|
|
if (isInSlot) {
|
|
pointer.onDoubleClick = () => node.onInputDblClick?.(i, e)
|
|
pointer.onClick = () => node.onInputClick?.(i, e)
|
|
|
|
const shouldBreakLink =
|
|
LiteGraph.ctrl_alt_click_do_break_link &&
|
|
ctrlOrMeta &&
|
|
e.altKey &&
|
|
!e.shiftKey
|
|
if (input.link !== null || input._floatingLinks?.size) {
|
|
// Existing link
|
|
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
|
|
node.disconnectInput(i, true)
|
|
} else if (e.shiftKey || this.allow_reconnect_links) {
|
|
linkConnector.moveInputLink(graph, input)
|
|
}
|
|
}
|
|
|
|
// Dragging a new link from input to output
|
|
if (!linkConnector.isConnecting) {
|
|
linkConnector.dragNewFromInput(graph, node, input)
|
|
}
|
|
|
|
this.#linkConnectorDrop()
|
|
this.dirty_bgcanvas = true
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Click was inside the node, but not on input/output, or resize area
|
|
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 {
|
|
// Node background
|
|
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)
|
|
} else if (node instanceof SubgraphNode) {
|
|
this.openSubgraph(node.subgraph, node)
|
|
}
|
|
|
|
node.onDblClick?.(e, pos, this)
|
|
this.emitEvent({
|
|
subType: 'node-double-click',
|
|
originalEvent: e,
|
|
node
|
|
})
|
|
this.processNodeDblClicked(node)
|
|
}
|
|
|
|
// Check for title button clicks before calling onMouseDown
|
|
if (node.title_buttons?.length && !node.flags.collapsed) {
|
|
// pos contains the offset from the node's position, so we need to use node-relative coordinates
|
|
const nodeRelativeX = pos[0]
|
|
const nodeRelativeY = pos[1]
|
|
|
|
for (let i = 0; i < node.title_buttons.length; i++) {
|
|
const button = node.title_buttons[i]
|
|
if (
|
|
button.visible &&
|
|
button.isPointInside(nodeRelativeX, nodeRelativeY)
|
|
) {
|
|
node.onTitleButtonClick(button, this)
|
|
// Set a no-op click handler to prevent fallback canvas dragging
|
|
pointer.onClick = () => {}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mousedown callback - can block drag
|
|
if (node.onMouseDown?.(e, pos, this)) {
|
|
// Node handled the event (e.g., title button clicked)
|
|
// Set a no-op click handler to prevent fallback canvas dragging
|
|
pointer.onClick = () => {}
|
|
return
|
|
}
|
|
|
|
if (!this.allow_dragnodes) return
|
|
|
|
// Check for resize AFTER checking all other interaction areas
|
|
if (!node.flags.collapsed) {
|
|
const resizeDirection = node.findResizeDirection(x, y)
|
|
if (resizeDirection) {
|
|
pointer.resizeDirection = resizeDirection
|
|
const startBounds = new Rectangle(
|
|
node.pos[0],
|
|
node.pos[1],
|
|
node.size[0],
|
|
node.size[1]
|
|
)
|
|
|
|
pointer.onDragStart = () => {
|
|
graph.beforeChange()
|
|
this.resizing_node = node
|
|
}
|
|
|
|
pointer.onDrag = (eMove) => {
|
|
if (this.read_only) return
|
|
|
|
const deltaX = eMove.canvasX - x
|
|
const deltaY = eMove.canvasY - y
|
|
|
|
const newBounds = new Rectangle(
|
|
startBounds.x,
|
|
startBounds.y,
|
|
startBounds.width,
|
|
startBounds.height
|
|
)
|
|
|
|
// Handle resize based on the direction
|
|
switch (resizeDirection) {
|
|
case 'NE': // North-East (top-right)
|
|
newBounds.y = startBounds.y + deltaY
|
|
newBounds.width = startBounds.width + deltaX
|
|
newBounds.height = startBounds.height - deltaY
|
|
break
|
|
case 'SE': // South-East (bottom-right)
|
|
newBounds.width = startBounds.width + deltaX
|
|
newBounds.height = startBounds.height + deltaY
|
|
break
|
|
case 'SW': // South-West (bottom-left)
|
|
newBounds.x = startBounds.x + deltaX
|
|
newBounds.width = startBounds.width - deltaX
|
|
newBounds.height = startBounds.height + deltaY
|
|
break
|
|
case 'NW': // North-West (top-left)
|
|
newBounds.x = startBounds.x + deltaX
|
|
newBounds.y = startBounds.y + deltaY
|
|
newBounds.width = startBounds.width - deltaX
|
|
newBounds.height = startBounds.height - deltaY
|
|
break
|
|
}
|
|
|
|
// Apply snapping to position changes
|
|
if (this.#snapToGrid) {
|
|
if (
|
|
resizeDirection.includes('N') ||
|
|
resizeDirection.includes('W')
|
|
) {
|
|
const originalX = newBounds.x
|
|
const originalY = newBounds.y
|
|
|
|
snapPoint(newBounds.pos, this.#snapToGrid)
|
|
|
|
// Adjust size to compensate for snapped position
|
|
if (resizeDirection.includes('N')) {
|
|
newBounds.height += originalY - newBounds.y
|
|
}
|
|
if (resizeDirection.includes('W')) {
|
|
newBounds.width += originalX - newBounds.x
|
|
}
|
|
}
|
|
|
|
snapPoint(newBounds.size, this.#snapToGrid)
|
|
}
|
|
|
|
// Apply snapping to size changes
|
|
|
|
// Enforce minimum size
|
|
const min = node.computeSize()
|
|
if (newBounds.width < min[0]) {
|
|
// If resizing from left, adjust position to maintain right edge
|
|
if (resizeDirection.includes('W')) {
|
|
newBounds.x = startBounds.x + startBounds.width - min[0]
|
|
}
|
|
newBounds.width = min[0]
|
|
}
|
|
if (newBounds.height < min[1]) {
|
|
// If resizing from top, adjust position to maintain bottom edge
|
|
if (resizeDirection.includes('N')) {
|
|
newBounds.y = startBounds.y + startBounds.height - min[1]
|
|
}
|
|
newBounds.height = min[1]
|
|
}
|
|
|
|
node.pos = newBounds.pos
|
|
node.setSize(newBounds.size)
|
|
|
|
this.#dirty()
|
|
}
|
|
|
|
pointer.onDragEnd = () => {
|
|
this.#dirty()
|
|
graph.afterChange(node)
|
|
}
|
|
pointer.finally = () => {
|
|
this.resizing_node = null
|
|
pointer.resizeDirection = undefined
|
|
}
|
|
|
|
// Set appropriate cursor for resize direction
|
|
this.canvas.style.cursor = cursors[resizeDirection]
|
|
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: IBaseWidget,
|
|
pointer = this.pointer
|
|
) {
|
|
// 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 widgetInstance = toConcreteWidget(widget, node, false)
|
|
if (widgetInstance) {
|
|
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.#finishDragZoom()
|
|
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()
|
|
}
|
|
|
|
#finishDragZoom(): void {
|
|
const start = this.#dragZoomStart
|
|
if (!start) return
|
|
this.#dragZoomStart = null
|
|
this.read_only = start.readOnly
|
|
}
|
|
|
|
/**
|
|
* 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, pointer, subgraph } = this
|
|
if (!graph) return
|
|
|
|
LGraphCanvas.active_canvas = this
|
|
this.adjustMouseEvent(e)
|
|
const mouse: Readonly<Point> = [e.clientX, e.clientY]
|
|
this.mouse[0] = mouse[0]
|
|
this.mouse[1] = mouse[1]
|
|
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
|
|
this.last_mouse = mouse
|
|
const { canvasX: x, canvasY: y } = e
|
|
this.graph_mouse[0] = x
|
|
this.graph_mouse[1] = y
|
|
|
|
if (e.isPrimary) pointer.move(e)
|
|
|
|
/** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */
|
|
let underPointer = CanvasItem.Nothing
|
|
if (subgraph) {
|
|
underPointer |= subgraph.inputNode.onPointerMove(e)
|
|
underPointer |= subgraph.outputNode.onPointerMove(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 relativeX = x - node.pos[0]
|
|
const relativeY = y - node.pos[1]
|
|
const result = widget.mouse(e, [relativeX, relativeY], node)
|
|
if (result != null) this.dirty_canvas = result
|
|
}
|
|
}
|
|
|
|
// get node over
|
|
const node = LiteGraph.vueNodesMode
|
|
? null
|
|
: graph.getNodeOnPos(x, y, this.visible_nodes)
|
|
|
|
const dragRect = this.dragging_rectangle
|
|
if (dragRect) {
|
|
dragRect[2] = x - dragRect[0]
|
|
dragRect[3] = y - dragRect[1]
|
|
this.dirty_canvas = true
|
|
} else if (resizingGroup) {
|
|
// Resizing a group
|
|
underPointer |= CanvasItem.Group
|
|
pointer.resizeDirection = 'SE'
|
|
} 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]
|
|
|
|
// Try to use layout store for hit testing first, fallback to old method
|
|
let inputId: number = -1
|
|
let outputId: number = -1
|
|
|
|
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
|
|
if (slotLayout && slotLayout.nodeId === String(node.id)) {
|
|
if (slotLayout.type === 'input') {
|
|
inputId = slotLayout.index
|
|
pos[0] = slotLayout.position.x
|
|
pos[1] = slotLayout.position.y
|
|
} else {
|
|
outputId = slotLayout.index
|
|
pos[0] = slotLayout.position.x
|
|
pos[1] = slotLayout.position.y
|
|
}
|
|
} else {
|
|
// Fallback to old method
|
|
inputId = isOverNodeInput(node, x, y, pos)
|
|
outputId = isOverNodeOutput(node, x, y, pos)
|
|
}
|
|
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
|
|
|
|
if (!node.mouseOver) {
|
|
// mouse enter
|
|
node.mouseOver = {}
|
|
this.node_over = node
|
|
this.dirty_canvas = true
|
|
|
|
for (const reroute of this.#visibleReroutes) {
|
|
reroute.hideSlots()
|
|
this.dirty_bgcanvas = true
|
|
}
|
|
node.onMouseEnter?.(e)
|
|
}
|
|
|
|
// in case the node wants to do something
|
|
node.onMouseMove?.(e, [x - node.pos[0], y - node.pos[1]], this)
|
|
|
|
// The input the mouse is over has changed
|
|
const { mouseOver } = node
|
|
if (
|
|
mouseOver.inputId !== inputId ||
|
|
mouseOver.outputId !== outputId ||
|
|
mouseOver.overWidget !== overWidget
|
|
) {
|
|
mouseOver.inputId = inputId
|
|
mouseOver.outputId = outputId
|
|
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 || !linkConnector.isNodeValidDrop(node)) {
|
|
// No link, or none of the dragged links may be dropped here
|
|
} else if (linkConnector.state.connectingTo === 'input') {
|
|
if (overWidget) {
|
|
// Check widgets first - inputId is only valid if over the input socket
|
|
const slot = node.getSlotFromWidget(overWidget)
|
|
|
|
if (slot && linkConnector.isInputValidDrop(node, slot)) {
|
|
highlightInput = slot
|
|
if (LiteGraph.vueNodesMode) {
|
|
const idx = node.inputs.indexOf(slot)
|
|
highlightPos =
|
|
idx !== -1
|
|
? getSlotPosition(node, idx, true)
|
|
: node.getInputSlotPos(slot)
|
|
} else {
|
|
highlightPos = node.getInputSlotPos(slot)
|
|
}
|
|
linkConnector.overWidget = overWidget
|
|
}
|
|
}
|
|
|
|
// Not over a valid widget - treat drop on invalid widget same as node background
|
|
if (!linkConnector.overWidget) {
|
|
if (inputId === -1 && outputId === -1) {
|
|
// Node background / title under the pointer
|
|
const result = node.findInputByType(firstLink.fromSlot.type)
|
|
if (result) {
|
|
highlightInput = result.slot
|
|
highlightPos = LiteGraph.vueNodesMode
|
|
? getSlotPosition(node, result.index, true)
|
|
: node.getInputSlotPos(result.slot)
|
|
}
|
|
} 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]
|
|
}
|
|
|
|
if (highlightInput) {
|
|
const widget = node.getWidgetFromSlot(highlightInput)
|
|
if (widget) linkConnector.overWidget = widget
|
|
}
|
|
}
|
|
} 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 = LiteGraph.vueNodesMode
|
|
? getSlotPosition(node, result.index, false)
|
|
: 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 direction - only show resize cursor if not over inputs/outputs/widgets
|
|
if (!pointer.eDown) {
|
|
if (inputId === -1 && outputId === -1 && !overWidget) {
|
|
pointer.resizeDirection = node.findResizeDirection(x, y)
|
|
} else {
|
|
// Clear resize direction when over inputs/outputs/widgets
|
|
pointer.resizeDirection &&= undefined
|
|
}
|
|
}
|
|
} else {
|
|
// Reroutes
|
|
underPointer = this.#updateReroutes(underPointer)
|
|
|
|
// 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(x, y)
|
|
if (
|
|
group &&
|
|
!e.ctrlKey &&
|
|
!this.read_only &&
|
|
group.isInResize(x, y)
|
|
) {
|
|
pointer.resizeDirection = 'SE'
|
|
} else {
|
|
pointer.resizeDirection &&= undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
[
|
|
x - this.node_capturing_input.pos[0],
|
|
y - 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
|
|
|
|
if (LiteGraph.vueNodesMode) {
|
|
this.moveChildNodesInGroupVueMode(allItems, deltaX, deltaY)
|
|
} else {
|
|
for (const item of allItems) {
|
|
item.move(deltaX, deltaY, true)
|
|
}
|
|
}
|
|
|
|
this.#dirty()
|
|
}
|
|
}
|
|
|
|
this.hoveringOver = underPointer
|
|
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
/**
|
|
* Updates the hover / snap state of all visible reroutes.
|
|
* @returns The original value of {@link underPointer}, with any found reroute items added.
|
|
*/
|
|
#updateReroutes(underPointer: CanvasItem): CanvasItem {
|
|
const { graph, pointer, linkConnector } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
// Update reroute hover state
|
|
if (!pointer.isDown) {
|
|
let anyChanges = false
|
|
for (const reroute of this.#visibleReroutes) {
|
|
anyChanges ||= reroute.updateVisibility(this.graph_mouse)
|
|
|
|
if (reroute.isSlotHovered) underPointer |= CanvasItem.RerouteSlot
|
|
}
|
|
if (anyChanges) this.dirty_bgcanvas = true
|
|
} else if (linkConnector.isConnecting) {
|
|
// Highlight the reroute that the mouse is over
|
|
for (const reroute of this.#visibleReroutes) {
|
|
if (reroute.containsPoint(this.graph_mouse)) {
|
|
if (linkConnector.isRerouteValidDrop(reroute)) {
|
|
linkConnector.overReroute = reroute
|
|
this._highlight_pos = reroute.pos
|
|
}
|
|
|
|
return (underPointer |= CanvasItem.RerouteSlot)
|
|
}
|
|
}
|
|
}
|
|
|
|
this._highlight_pos &&= undefined
|
|
linkConnector.overReroute &&= undefined
|
|
return underPointer
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
this.#finishDragZoom()
|
|
|
|
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
|
|
|
|
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]
|
|
],
|
|
this
|
|
)
|
|
}
|
|
} 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: PointerEvent): 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
|
|
|
|
this.adjustMouseEvent(e)
|
|
|
|
const pos: Point = [e.clientX, e.clientY]
|
|
if (this.viewport && !isPointInRect(pos, this.viewport)) return
|
|
|
|
let { scale } = this.ds
|
|
|
|
// Detect if this is a trackpad gesture or mouse wheel
|
|
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
|
const isCtrlOrMacMeta =
|
|
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
|
|
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
|
|
|
|
if (isZoomModifier || LiteGraph.mouseWheelScroll === 'zoom') {
|
|
// Zoom mode or modifier key pressed - use wheel for zoom
|
|
if (isTrackpad) {
|
|
// Trackpad gesture - use smooth scaling
|
|
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
|
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
|
} else {
|
|
// Mouse wheel - use stepped scaling
|
|
if (e.deltaY < 0) {
|
|
scale *= this.zoom_speed
|
|
} else if (e.deltaY > 0) {
|
|
scale *= 1 / this.zoom_speed
|
|
}
|
|
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
|
}
|
|
} else {
|
|
// Trackpads and mice work on significantly different scales
|
|
const factor = isTrackpad ? 0.18 : 0.008_333
|
|
|
|
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
|
|
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
|
|
} else {
|
|
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
|
|
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
|
|
}
|
|
}
|
|
|
|
this.graph.change()
|
|
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
#noItemsSelected(): void {
|
|
const event = new CustomEvent('litegraph:no-items-selected', {
|
|
bubbles: true
|
|
})
|
|
this.canvas.dispatchEvent(event)
|
|
}
|
|
|
|
/**
|
|
* process a key event
|
|
*/
|
|
processKey(e: KeyboardEvent): void {
|
|
this.#shiftDown = e.shiftKey
|
|
|
|
const { graph } = this
|
|
if (!graph) return
|
|
|
|
let block_default = false
|
|
// @ts-expect-error EventTarget.localName is not in standard types
|
|
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()
|
|
if (this.node_panel || this.options_panel) 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 EventTarget.localName is not in standard types
|
|
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
|
|
if (this.selectedItems.size === 0) {
|
|
this.#noItemsSelected()
|
|
return
|
|
}
|
|
|
|
this.deleteSelected()
|
|
block_default = true
|
|
}
|
|
}
|
|
|
|
// TODO
|
|
for (const node of Object.values(this.selected_nodes)) {
|
|
node.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
|
|
}
|
|
|
|
for (const node of Object.values(this.selected_nodes)) {
|
|
node.onKeyUp?.(e)
|
|
}
|
|
}
|
|
|
|
// TODO: Do we need to remeasure and recalculate everything on every key down/up?
|
|
graph.change()
|
|
|
|
if (block_default) {
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
}
|
|
}
|
|
_serializeItems(items?: Iterable<Positionable>): ClipboardItems {
|
|
const serialisable: Required<ClipboardItems> = {
|
|
nodes: [],
|
|
groups: [],
|
|
reroutes: [],
|
|
links: [],
|
|
subgraphs: []
|
|
}
|
|
|
|
// NOTE: logic for traversing nested subgraphs depends on this being a set.
|
|
const subgraphs = new Set<Subgraph>()
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Find all unique referenced subgraphs
|
|
if (item instanceof SubgraphNode) {
|
|
subgraphs.add(item.subgraph)
|
|
}
|
|
} else if (item instanceof LGraphGroup) {
|
|
// Groups
|
|
serialisable.groups.push(item.serialize())
|
|
} else if (item instanceof Reroute) {
|
|
// Reroutes
|
|
serialisable.reroutes.push(item.asSerialisable())
|
|
}
|
|
}
|
|
|
|
// Add unique subgraph entries
|
|
// NOTE: subgraphs is appended to mid iteration.
|
|
for (const subgraph of subgraphs) {
|
|
for (const node of subgraph.nodes) {
|
|
if (node instanceof SubgraphNode) {
|
|
subgraphs.add(node.subgraph)
|
|
}
|
|
}
|
|
const cloned = subgraph.clone(true).asSerialisable()
|
|
serialisable.subgraphs.push(cloned)
|
|
}
|
|
return serialisable
|
|
}
|
|
|
|
/**
|
|
* 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>): string {
|
|
const serializedData = JSON.stringify(this._serializeItems(items))
|
|
localStorage.setItem('litegrapheditor_clipboard', serializedData)
|
|
return serializedData
|
|
}
|
|
|
|
emitEvent(detail: LGraphCanvasEventMap['litegraph:canvas']): 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 data = localStorage.getItem('litegrapheditor_clipboard')
|
|
if (!data) return
|
|
return this._deserializeItems(JSON.parse(data), options)
|
|
}
|
|
|
|
_deserializeItems(
|
|
parsed: ClipboardItems,
|
|
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 { graph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
graph.beforeChange()
|
|
this.emitBeforeChange()
|
|
|
|
// Parse & initialise
|
|
parsed.nodes ??= []
|
|
parsed.groups ??= []
|
|
parsed.reroutes ??= []
|
|
parsed.links ??= []
|
|
parsed.subgraphs ??= []
|
|
|
|
// 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 encountered 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>(),
|
|
subgraphs: new Map<UUID, Subgraph>()
|
|
}
|
|
const { created, nodes, links, reroutes } = results
|
|
|
|
// const failedNodes: ISerialisedNode[] = []
|
|
const subgraphIdMap: Record<string, string> = {}
|
|
// SubgraphV2: Remove always-clone behaviour
|
|
//Update subgraph ids
|
|
for (const subgraphInfo of parsed.subgraphs)
|
|
subgraphInfo.id = subgraphIdMap[subgraphInfo.id] = createUuidv4()
|
|
const allNodeInfo: ISerialisedNode[] = [
|
|
parsed.nodes ? [parsed.nodes] : [],
|
|
parsed.subgraphs ? parsed.subgraphs.map((s) => s.nodes ?? []) : []
|
|
].flat(2)
|
|
for (const nodeInfo of allNodeInfo)
|
|
if (nodeInfo.type in subgraphIdMap)
|
|
nodeInfo.type = subgraphIdMap[nodeInfo.type]
|
|
|
|
// Subgraphs
|
|
for (const info of parsed.subgraphs) {
|
|
const subgraph = graph.createSubgraph(info)
|
|
results.subgraphs.set(info.id, subgraph)
|
|
}
|
|
for (const info of parsed.subgraphs)
|
|
results.subgraphs.get(info.id)?.configure(info)
|
|
|
|
// 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`
|
|
|
|
const newPositions = created
|
|
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
|
.map((node) => {
|
|
const fullHeight = node.size?.[1] ?? 200
|
|
const layoutHeight = LiteGraph.vueNodesMode
|
|
? removeNodeTitleHeight(fullHeight)
|
|
: fullHeight
|
|
return {
|
|
nodeId: String(node.id),
|
|
bounds: {
|
|
x: node.pos[0],
|
|
y: node.pos[1],
|
|
width: node.size?.[0] ?? 100,
|
|
height: layoutHeight
|
|
}
|
|
}
|
|
})
|
|
|
|
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
|
layoutStore.batchUpdateNodeBounds(newPositions)
|
|
|
|
this.selectItems(created)
|
|
forEachNode(graph, (n) => n.onGraphConfigured?.())
|
|
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
|
|
|
|
graph.afterChange()
|
|
this.emitAfterChange()
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Normalizes a drag rectangle to have positive width and height.
|
|
* @param dragRect The drag rectangle to normalize (modified in place)
|
|
* @returns The normalized rectangle
|
|
*/
|
|
#normalizeDragRect(dragRect: Rect): Rect {
|
|
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
|
|
return dragRect
|
|
}
|
|
|
|
/**
|
|
* Gets all positionable items that overlap with the given rectangle.
|
|
* @param rect The rectangle to check against
|
|
* @returns Set of positionable items that overlap with the rectangle
|
|
*/
|
|
#getItemsInRect(rect: Rect): Set<Positionable> {
|
|
const { graph, subgraph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const items = new Set<Positionable>()
|
|
|
|
if (subgraph) {
|
|
const { inputNode, outputNode } = subgraph
|
|
if (overlapBounding(rect, inputNode.boundingRect)) items.add(inputNode)
|
|
if (overlapBounding(rect, outputNode.boundingRect)) items.add(outputNode)
|
|
}
|
|
|
|
for (const node of graph._nodes) {
|
|
if (overlapBounding(rect, node.boundingRect)) items.add(node)
|
|
}
|
|
|
|
// Check groups (must be wholly inside)
|
|
for (const group of graph.groups) {
|
|
if (containsRect(rect, group._bounding)) {
|
|
group.recomputeInsideNodes()
|
|
items.add(group)
|
|
}
|
|
}
|
|
|
|
// Check reroutes (center point must be inside)
|
|
for (const reroute of graph.reroutes.values()) {
|
|
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Handles live selection updates during drag. Called on each pointer move.
|
|
* @param e The pointer move event
|
|
* @param dragRect The current drag rectangle
|
|
* @param initialSelection The selection state before the drag started
|
|
*/
|
|
private handleLiveSelect(
|
|
e: CanvasPointerEvent,
|
|
dragRect: Rect,
|
|
initialSelection: Set<Positionable>
|
|
): void {
|
|
// Ensure rect is current even if pointer.onDrag fires before processMouseMove updates it
|
|
dragRect[2] = e.canvasX - dragRect[0]
|
|
dragRect[3] = e.canvasY - dragRect[1]
|
|
|
|
// Create a normalized copy for overlap checking
|
|
const normalizedRect: Rect = [
|
|
dragRect[0],
|
|
dragRect[1],
|
|
dragRect[2],
|
|
dragRect[3]
|
|
]
|
|
this.#normalizeDragRect(normalizedRect)
|
|
|
|
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
|
|
|
const desired = new Set<Positionable>()
|
|
if (e.shiftKey && !e.altKey) {
|
|
for (const item of initialSelection) desired.add(item)
|
|
for (const item of itemsInRect) desired.add(item)
|
|
} else if (e.altKey && !e.shiftKey) {
|
|
for (const item of initialSelection)
|
|
if (!itemsInRect.has(item)) desired.add(item)
|
|
} else {
|
|
for (const item of itemsInRect) desired.add(item)
|
|
}
|
|
|
|
let changed = false
|
|
for (const item of [...this.selectedItems]) {
|
|
if (!desired.has(item)) {
|
|
this.deselect(item)
|
|
changed = true
|
|
}
|
|
}
|
|
for (const item of desired) {
|
|
if (!this.selectedItems.has(item)) {
|
|
this.select(item)
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
this.setDirty(true)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finalizes the live selection when drag ends.
|
|
*/
|
|
private finalizeLiveSelect(): void {
|
|
// Selection is already updated by handleLiveSelect
|
|
// Just trigger the final selection change callback
|
|
this.onSelectionChange?.(this.selected_nodes)
|
|
}
|
|
|
|
/**
|
|
* Handles multi-select when drag ends (classic mode).
|
|
* @param e The pointer up event
|
|
* @param dragRect The drag rectangle
|
|
*/
|
|
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
|
|
const normalizedRect: Rect = [
|
|
dragRect[0],
|
|
dragRect[1],
|
|
dragRect[2],
|
|
dragRect[3]
|
|
]
|
|
this.#normalizeDragRect(normalizedRect)
|
|
|
|
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
|
const { selectedItems } = this
|
|
|
|
if (e.shiftKey) {
|
|
// Add to selection
|
|
for (const item of itemsInRect) this.select(item)
|
|
} else if (e.altKey) {
|
|
// Remove from selection
|
|
for (const item of itemsInRect) this.deselect(item)
|
|
} else {
|
|
// Replace selection
|
|
for (const item of selectedItems.values()) {
|
|
if (!itemsInRect.has(item)) this.deselect(item)
|
|
}
|
|
for (const item of itemsInRect) 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: CanvasPointerEvent | 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)
|
|
this.state.selectionChanged = true
|
|
|
|
if (item instanceof LGraphGroup) {
|
|
item.recomputeInsideNodes()
|
|
return
|
|
}
|
|
|
|
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)
|
|
this.state.selectionChanged = true
|
|
if (!(item instanceof LGraphNode)) return
|
|
|
|
// Node-specific handling
|
|
item.onDeselected?.()
|
|
delete this.selected_nodes[item.id]
|
|
|
|
this.onNodeDeselected?.(item)
|
|
|
|
// Should be moved to top of function, and throw if null
|
|
const { graph } = this
|
|
if (!graph) return
|
|
|
|
// Clear link highlight
|
|
if (item.inputs) {
|
|
for (const input of item.inputs) {
|
|
if (input.link == null) continue
|
|
|
|
const node = LLink.getOriginNode(graph, input.link)
|
|
if (node && this.selectedItems.has(node)) continue
|
|
|
|
delete this.highlighted_links[input.link]
|
|
}
|
|
}
|
|
if (item.outputs) {
|
|
for (const id of item.outputs.flatMap((x) => x.links)) {
|
|
if (id == null) continue
|
|
|
|
const node = LLink.getTargetNode(graph, id)
|
|
if (node && this.selectedItems.has(node)) continue
|
|
|
|
delete this.highlighted_links[id]
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @deprecated See {@link LGraphCanvas.processSelect} */
|
|
processNodeSelected(item: LGraphNode, e: CanvasPointerEvent): 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
|
|
if (!selected.size) return
|
|
|
|
const initialSelectionSize = selected.size
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only set selectionChanged if selection actually changed
|
|
const finalSelectionSize = selected.size
|
|
if (initialSelectionSize !== finalSelectionSize) {
|
|
this.state.selectionChanged = 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.state.selectionChanged = true
|
|
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 & CanvasPointerEvent {
|
|
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 {
|
|
return this.ds.convertOffsetToCanvas(pos)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
)
|
|
|
|
// Arrange subgraph IO nodes
|
|
const { subgraph } = this
|
|
if (subgraph) {
|
|
subgraph.inputNode.arrange()
|
|
subgraph.outputNode.arrange()
|
|
}
|
|
}
|
|
|
|
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, graph, linkConnector } = this
|
|
|
|
// @ts-expect-error start2D method not in standard CanvasRenderingContext2D
|
|
if (ctx.start2D && !this.viewport) {
|
|
// @ts-expect-error start2D method not in standard CanvasRenderingContext2D
|
|
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) {
|
|
const pos = this.fpsInfoLocation ?? area
|
|
this.renderInfo(ctx, pos?.[0] ?? 0, pos?.[1] ?? 0)
|
|
}
|
|
|
|
if (graph) {
|
|
// apply transformations
|
|
ctx.save()
|
|
this.ds.toCanvasContext(ctx)
|
|
|
|
// draw nodes
|
|
const { visible_nodes } = this
|
|
const drawSnapGuides =
|
|
this.#snapToGrid &&
|
|
(this.isDragging || layoutStore.isDraggingVueNodes.value)
|
|
|
|
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()
|
|
}
|
|
|
|
// Draw subgraph IO nodes
|
|
this.subgraph?.draw(
|
|
ctx,
|
|
this.colourGetter,
|
|
this.linkConnector.renderLinks[0]?.fromSlot,
|
|
this.editor_alpha
|
|
)
|
|
|
|
// on top (debug)
|
|
if (this.render_execution_order) {
|
|
this.drawExecutionOrder(ctx)
|
|
}
|
|
|
|
// connections ontop?
|
|
if (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 = resolveConnectingLinkColor(connType)
|
|
|
|
// the connection being dragged by the mouse
|
|
if (this.linkRenderer) {
|
|
this.linkRenderer.renderDraggingLink(
|
|
ctx,
|
|
pos,
|
|
highlightPos,
|
|
colour,
|
|
fromDirection,
|
|
dragDirection,
|
|
{
|
|
...this.buildLinkRenderContext(),
|
|
linkMarkerShape: LinkMarkerShape.None
|
|
}
|
|
)
|
|
}
|
|
|
|
ctx.fillStyle = colour
|
|
ctx.beginPath()
|
|
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
|
|
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
|
|
ctx.rect(
|
|
highlightPos[0] - 6 + 0.5,
|
|
highlightPos[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.arc(highlightPos[0], highlightPos[1], 4, 0, Math.PI * 2)
|
|
}
|
|
ctx.fill()
|
|
}
|
|
|
|
// Gradient half-border over target node
|
|
this.#renderSnapHighlight(ctx, highlightPos)
|
|
}
|
|
|
|
// on top of link center
|
|
if (
|
|
!this.isDragging &&
|
|
this.over_link_center &&
|
|
this.render_link_tooltip
|
|
) {
|
|
this.drawLinkTooltip(ctx, this.over_link_center)
|
|
} 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: CanvasPointerEvent): LinkSegment | undefined {
|
|
// Skip hit detection if center markers are disabled
|
|
if (this.linkMarkerShape === LinkMarkerShape.None) {
|
|
return 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(): Readonly<Point> {
|
|
return LiteGraph.snaps_for_comfy
|
|
? (this.linkConnector.state.snapLinksPos ??
|
|
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: Readonly<Point>
|
|
): void {
|
|
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
|
|
if (!this._highlight_pos && !linkConnectorSnap) 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()
|
|
|
|
const { linkConnector } = this
|
|
const { overReroute, overWidget } = linkConnector
|
|
if (
|
|
!LiteGraph.snap_highlights_node ||
|
|
!linkConnector.isConnecting ||
|
|
linkConnectorSnap
|
|
)
|
|
return
|
|
|
|
// Reroute highlight
|
|
overReroute?.drawHighlight(ctx, '#ffcc00aa')
|
|
|
|
// Ensure we're mousing over a node and connecting a link
|
|
const node = this.node_over
|
|
if (!node) 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 + BaseWidget.margin,
|
|
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 ${LiteGraph.DEFAULT_FONT}`
|
|
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
|
|
this._bg_img.addEventListener('load', () => {
|
|
this.draw(true, true)
|
|
})
|
|
}
|
|
|
|
let pattern = this._pattern
|
|
if (pattern == null && this._bg_img.width > 0) {
|
|
pattern = ctx.createPattern(this._bg_img, 'repeat') ?? undefined
|
|
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
|
|
}
|
|
if (this.bg_tint) {
|
|
ctx.fillStyle = this.bg_tint
|
|
ctx.fillRect(
|
|
this.visible_area[0],
|
|
this.visible_area[1],
|
|
this.visible_area[2],
|
|
this.visible_area[3]
|
|
)
|
|
ctx.fillStyle = 'transparent'
|
|
}
|
|
|
|
// 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
|
|
|
|
// When Vue nodes mode is enabled, LiteGraph should not draw node chrome or widgets.
|
|
// We still need to keep slot metrics and layout in sync for hit-testing and links.
|
|
// Interaction system changes coming later, chances are vue nodes mode will be mostly broken on land
|
|
if (LiteGraph.vueNodesMode) {
|
|
// Prepare concrete slots and compute layout measures without rendering visuals.
|
|
node._setConcreteSlots()
|
|
if (!node.collapsed) {
|
|
node.arrange()
|
|
}
|
|
// Skip all node body/widget/title rendering. Vue overlay handles visuals.
|
|
return
|
|
}
|
|
|
|
const color = node.renderingColor
|
|
const bgcolor = node.renderingBgColor
|
|
|
|
ctx.globalAlpha = this.getNodeModeAlpha(node)
|
|
|
|
if (this.render_shadows && !this.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 = temp_vec2
|
|
size[0] = node.renderingSize[0]
|
|
size[1] = node.renderingSize[1]
|
|
|
|
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)
|
|
|
|
// Render title buttons (if not collapsed)
|
|
if (node.title_buttons && !node.flags.collapsed) {
|
|
const title_height = LiteGraph.NODE_TITLE_HEIGHT
|
|
let current_x = size[0] // Start flush with right edge
|
|
|
|
for (let i = 0; i < node.title_buttons.length; i++) {
|
|
const button = node.title_buttons[i]
|
|
if (!button.visible) {
|
|
continue
|
|
}
|
|
|
|
const button_width = button.getWidth(ctx)
|
|
current_x -= button_width
|
|
|
|
// Center button vertically in title bar
|
|
const button_y = -title_height + (title_height - button.height) / 2
|
|
|
|
button.draw(ctx, current_x, button_y)
|
|
current_x -= 2
|
|
}
|
|
}
|
|
|
|
if (!this.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
|
|
node._setConcreteSlots()
|
|
if (!node.collapsed) {
|
|
node.arrange()
|
|
node.drawSlots(ctx, {
|
|
fromSlot: this.linkConnector.renderLinks[0]?.fromSlot as
|
|
| INodeOutputSlot
|
|
| INodeInputSlot,
|
|
colorContext: this.colourGetter,
|
|
editorAlpha: this.editor_alpha,
|
|
lowQuality: this.low_quality
|
|
})
|
|
|
|
ctx.textAlign = 'left'
|
|
ctx.globalAlpha = 1
|
|
|
|
this.drawNodeWidgets(node, null, 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 = 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 = 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.rect(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()
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Draw stroke styles
|
|
for (const getStyle of Object.values(node.strokeStyles)) {
|
|
const strokeStyle = getStyle.call(node)
|
|
if (strokeStyle) {
|
|
strokeShape(ctx, area, {
|
|
shape,
|
|
title_height,
|
|
title_mode,
|
|
collapsed,
|
|
...strokeStyle
|
|
})
|
|
}
|
|
}
|
|
|
|
node.drawProgressBar(ctx)
|
|
|
|
// 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 = 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, subgraph } = this
|
|
if (!graph) throw new NullGraphError()
|
|
|
|
const visibleReroutes: Reroute[] = []
|
|
|
|
const now = LiteGraph.getTime()
|
|
const { visible_area } = this
|
|
margin_area[0] = visible_area[0] - 20
|
|
margin_area[1] = visible_area[1] - 20
|
|
margin_area[2] = visible_area[2] + 40
|
|
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: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
|
? getSlotPosition(node, i, true)
|
|
: 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]
|
|
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
|
? getSlotPosition(start_node, outputId, false)
|
|
: 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 (subgraph) {
|
|
for (const output of subgraph.inputNode.slots) {
|
|
if (!output.linkIds.length) continue
|
|
|
|
// find link info
|
|
for (const linkId of output.linkIds) {
|
|
const resolved = LLink.resolve(linkId, graph)
|
|
if (!resolved) continue
|
|
|
|
const { link, inputNode, input } = resolved
|
|
if (!inputNode || !input) continue
|
|
|
|
const endPos = LiteGraph.vueNodesMode
|
|
? getSlotPosition(inputNode, link.target_slot, true)
|
|
: inputNode.getInputPos(link.target_slot)
|
|
|
|
this.#renderAllLinkSegments(
|
|
ctx,
|
|
link,
|
|
output.pos,
|
|
endPos,
|
|
visibleReroutes,
|
|
now,
|
|
input.dir,
|
|
input.dir
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const input of subgraph.outputNode.slots) {
|
|
if (!input.linkIds.length) continue
|
|
|
|
// find link info
|
|
const resolved = LLink.resolve(input.linkIds[0], graph)
|
|
if (!resolved) continue
|
|
|
|
const { link, outputNode, output } = resolved
|
|
if (!outputNode || !output) continue
|
|
|
|
const startPos = LiteGraph.vueNodesMode
|
|
? getSlotPosition(outputNode, link.origin_slot, false)
|
|
: outputNode.getOutputPos(link.origin_slot)
|
|
|
|
this.#renderAllLinkSegments(
|
|
ctx,
|
|
link,
|
|
startPos,
|
|
input.pos,
|
|
visibleReroutes,
|
|
now,
|
|
output.dir,
|
|
input.dir
|
|
)
|
|
}
|
|
}
|
|
|
|
if (graph.floatingLinks.size > 0) {
|
|
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
|
|
}
|
|
|
|
const rerouteSet = this.#visibleReroutes
|
|
rerouteSet.clear()
|
|
|
|
// Render reroutes, ordered by number of non-floating links
|
|
visibleReroutes.sort((a, b) => a.linkIds.size - b.linkIds.size)
|
|
for (const reroute of visibleReroutes) {
|
|
rerouteSet.add(reroute)
|
|
|
|
if (
|
|
this.#snapToGrid &&
|
|
this.isDragging &&
|
|
this.selectedItems.has(reroute)
|
|
) {
|
|
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
|
|
}
|
|
reroute.draw(ctx, this._pattern)
|
|
|
|
// Never draw slots when the pointer is down
|
|
if (!this.pointer.isDown) reroute.drawSlots(ctx)
|
|
}
|
|
ctx.globalAlpha = 1
|
|
}
|
|
|
|
private getNodeModeAlpha(node: LGraphNode) {
|
|
return node.mode === LGraphEventMode.BYPASS
|
|
? 0.2
|
|
: node.mode === LGraphEventMode.NEVER
|
|
? 0.4
|
|
: this.editor_alpha
|
|
}
|
|
|
|
#renderFloatingLinks(
|
|
ctx: CanvasRenderingContext2D,
|
|
graph: LGraph,
|
|
visibleReroutes: Reroute[],
|
|
now: number
|
|
) {
|
|
// Render floating links with 3/4 current alpha
|
|
const { globalAlpha } = ctx
|
|
ctx.globalAlpha = globalAlpha * 0.33
|
|
|
|
// 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: Point = LiteGraph.vueNodesMode
|
|
? getSlotPosition(node, link.target_slot, true)
|
|
: 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,
|
|
true
|
|
)
|
|
} else {
|
|
const node = graph.getNodeById(link.origin_id)
|
|
if (!node) continue
|
|
|
|
const startPos: Point = LiteGraph.vueNodesMode
|
|
? getSlotPosition(node, link.origin_slot, false)
|
|
: 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,
|
|
true
|
|
)
|
|
}
|
|
}
|
|
ctx.globalAlpha = globalAlpha
|
|
}
|
|
|
|
#renderAllLinkSegments(
|
|
ctx: CanvasRenderingContext2D,
|
|
link: LLink,
|
|
startPos: Point,
|
|
endPos: Point,
|
|
visibleReroutes: Reroute[],
|
|
now: number,
|
|
startDirection?: LinkDirection,
|
|
endDirection?: LinkDirection,
|
|
disabled: boolean = false
|
|
) {
|
|
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])
|
|
link_bounding[0] = Math.min(...pointsX)
|
|
link_bounding[1] = Math.min(...pointsY)
|
|
link_bounding[2] = Math.max(...pointsX) - link_bounding[0]
|
|
link_bounding[3] = Math.max(...pointsY) - link_bounding[1]
|
|
|
|
// skip links outside of the visible area of the canvas
|
|
if (!overlapBounding(link_bounding, 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 = graph.getReroute(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,
|
|
startControl === undefined ? start_dir : LinkDirection.CENTER,
|
|
LinkDirection.CENTER,
|
|
{
|
|
startControl,
|
|
endControl: reroute.controlPoint,
|
|
reroute,
|
|
disabled
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if (!startControl && reroutes.at(-1)?.floating?.slotType === 'input') {
|
|
// Floating link connected to an input
|
|
startControl = [0, 0]
|
|
} else {
|
|
// Calculate start control for the next iter control point
|
|
const nextPos = reroutes[j + 1]?.pos ?? endPos
|
|
const dist = Math.min(
|
|
Reroute.maxSplineOffset,
|
|
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,
|
|
LinkDirection.CENTER,
|
|
end_dir,
|
|
{ startControl, disabled }
|
|
)
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build LinkRenderContext from canvas properties
|
|
* Helper method for using LitegraphLinkAdapter
|
|
*/
|
|
private buildLinkRenderContext(): LinkRenderContext {
|
|
return {
|
|
// Canvas settings
|
|
renderMode: this.links_render_mode,
|
|
connectionWidth: this.connections_width,
|
|
renderBorder: this.render_connections_border,
|
|
lowQuality: this.low_quality,
|
|
highQualityRender: this.highquality_render,
|
|
scale: this.ds.scale,
|
|
linkMarkerShape: this.linkMarkerShape,
|
|
renderConnectionArrows: this.render_connection_arrows,
|
|
|
|
// State
|
|
highlightedLinks: new Set(Object.keys(this.highlighted_links)),
|
|
|
|
// Colors
|
|
defaultLinkColor: this.default_link_color,
|
|
linkTypeColors: LGraphCanvas.link_type_colors,
|
|
|
|
// Pattern for disabled links
|
|
disabledPattern: this._pattern
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: Readonly<Point>,
|
|
b: Readonly<Point>,
|
|
link: LLink | null,
|
|
skip_border: boolean,
|
|
flow: number | null,
|
|
color: CanvasColour | null,
|
|
start_dir: LinkDirection,
|
|
end_dir: LinkDirection,
|
|
{
|
|
startControl,
|
|
endControl,
|
|
reroute,
|
|
num_sublines = 1,
|
|
disabled = false
|
|
}: {
|
|
/** 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?: Readonly<Point>
|
|
/** Offset of the bezier curve control point from {@link b point b} (input side) */
|
|
endControl?: Readonly<Point>
|
|
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
|
|
num_sublines?: number
|
|
/** Whether this is a floating link segment */
|
|
disabled?: boolean
|
|
} = {}
|
|
): void {
|
|
if (this.linkRenderer) {
|
|
const context = this.buildLinkRenderContext()
|
|
this.linkRenderer.renderLinkDirect(
|
|
ctx,
|
|
a,
|
|
b,
|
|
link,
|
|
skip_border,
|
|
flow,
|
|
color,
|
|
start_dir,
|
|
end_dir,
|
|
context,
|
|
{
|
|
reroute,
|
|
startControl,
|
|
endControl,
|
|
num_sublines,
|
|
disabled
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
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(
|
|
toString(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: null,
|
|
ctx: CanvasRenderingContext2D
|
|
): void {
|
|
node.drawWidgets(ctx, {
|
|
lowQuality: this.low_quality,
|
|
editorAlpha: this.getNodeModeAlpha(node)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 || layoutStore.isDraggingVueNodes.value)
|
|
|
|
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: CanvasPointerEvent): 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', 'Add Reroute', null, 'Delete', null]
|
|
|
|
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': {
|
|
// segment can be a Reroute object, in which case segment.id is the reroute id
|
|
const linkId =
|
|
segment instanceof Reroute
|
|
? segment.linkIds.values().next().value
|
|
: segment.id
|
|
if (linkId !== undefined) {
|
|
graph.removeLink(linkId)
|
|
// Clean up layout store
|
|
layoutStore.deleteLinkLayout(linkId)
|
|
}
|
|
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
|
|
if (nodeX instanceof SubgraphIONodeBase) {
|
|
if (typeof slotX !== 'object' || !slotX) {
|
|
console.warn('Cant get slot information', slotX)
|
|
return false
|
|
}
|
|
const { name } = slotX
|
|
iSlotConn = nodeX.slots.findIndex((s) => s.name === name)
|
|
slotX = nodeX.slots[iSlotConn]
|
|
if (!slotX) {
|
|
console.warn('Cant get slot information', slotX)
|
|
return false
|
|
}
|
|
} else {
|
|
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]) {
|
|
let nodeNewType: string | Record<string, unknown> | false = 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) {
|
|
let nodeNewOpts: SlotTypeDefaultNodeOpts | undefined
|
|
let nodeTypeStr: string
|
|
if (typeof nodeNewType == 'object') {
|
|
nodeNewOpts = nodeNewType as SlotTypeDefaultNodeOpts
|
|
nodeTypeStr = nodeNewOpts.node ?? ''
|
|
} else {
|
|
nodeTypeStr = nodeNewType
|
|
}
|
|
|
|
// that.graph.beforeChange();
|
|
const xSizeFix = opts.posSizeFix[0] * LiteGraph.NODE_WIDTH
|
|
const ySizeFix = opts.posSizeFix[1] * LiteGraph.NODE_SLOT_HEIGHT
|
|
const nodeX = opts.position[0] + opts.posAdd[0] + xSizeFix
|
|
const nodeY = opts.position[1] + opts.posAdd[1] + ySizeFix
|
|
const pos = [nodeX, nodeY]
|
|
const newNode = LiteGraph.createNode(nodeTypeStr, nodeNewOpts?.title, {
|
|
pos
|
|
})
|
|
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 input of nodeNewOpts.inputs) {
|
|
newNode.addInput(input[0], input[1])
|
|
}
|
|
}
|
|
if (nodeNewOpts.outputs) {
|
|
newNode.outputs = []
|
|
for (const output of nodeNewOpts.outputs) {
|
|
newNode.addOutput(output[0], output[1])
|
|
}
|
|
}
|
|
if (nodeNewOpts.json) {
|
|
newNode.configure(nodeNewOpts.json)
|
|
}
|
|
}
|
|
|
|
// add the node
|
|
if (!this.graph) throw new NullGraphError()
|
|
|
|
this.graph.add(newNode)
|
|
|
|
// Interim API - allow the link connection to be canceled.
|
|
// TODO: https://github.com/Comfy-Org/litegraph.js/issues/946
|
|
const detail = { node: newNode, opts }
|
|
const mayConnectLinks = this.canvas.dispatchEvent(
|
|
new CustomEvent('connect-new-default-node', {
|
|
detail,
|
|
cancelable: true
|
|
})
|
|
)
|
|
if (!mayConnectLinks) return true
|
|
|
|
// 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.error(`failed creating ${nodeNewType}`)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
showConnectionMenu(
|
|
optPass: Partial<ICreateNodeOptions & { e: MouseEvent }>
|
|
): ContextMenu<string> | undefined {
|
|
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 dirty = () => this.#dirty()
|
|
|
|
const that = this
|
|
const { graph } = 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
|
|
if (nodeX instanceof SubgraphIONodeBase) {
|
|
if (typeof slotX !== 'object' || !slotX) {
|
|
console.warn('Cant get slot information', slotX)
|
|
return
|
|
}
|
|
const { name } = slotX
|
|
iSlotConn = nodeX.slots.findIndex((s) => s.name === name)
|
|
// If it's not found in the main slots, it might be the empty slot from a Subgraph node.
|
|
// In that case, the original `slotX` object is the correct one, so don't overwrite it.
|
|
if (iSlotConn !== -1) {
|
|
slotX = nodeX.slots[iSlotConn]
|
|
}
|
|
if (!slotX) {
|
|
console.warn('Cant get slot information', slotX)
|
|
return
|
|
}
|
|
} else {
|
|
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', 'Add Reroute', 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,
|
|
extra: slotX,
|
|
title:
|
|
(slotX && slotX.name != ''
|
|
? slotX.name + (fromSlotType ? ' | ' : '')
|
|
: '') + (slotX && fromSlotType ? fromSlotType : ''),
|
|
callback: inner_clicked
|
|
})
|
|
|
|
return menu
|
|
|
|
// callback
|
|
function inner_clicked(
|
|
v: string | undefined,
|
|
options: IContextMenuOptions<string, INodeInputSlot | INodeOutputSlot>,
|
|
e: MouseEvent
|
|
) {
|
|
switch (v) {
|
|
case 'Add Node':
|
|
LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) {
|
|
if (!node) return
|
|
|
|
if (isFrom) {
|
|
if (!opts.nodeFrom)
|
|
throw new TypeError(
|
|
'Cannot add node to SubgraphInputNode: nodeFrom was null'
|
|
)
|
|
const slot = opts.nodeFrom.connectByType(
|
|
iSlotConn,
|
|
node,
|
|
fromSlotType,
|
|
{ afterRerouteId }
|
|
)
|
|
if (!slot) console.warn('Failed to make new connection.')
|
|
// }
|
|
} else {
|
|
if (!opts.nodeTo)
|
|
throw new TypeError(
|
|
'Cannot add node to SubgraphInputNode: nodeTo was null'
|
|
)
|
|
opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType, {
|
|
afterRerouteId
|
|
})
|
|
}
|
|
})
|
|
break
|
|
case 'Add Reroute': {
|
|
const node = isFrom ? opts.nodeFrom : opts.nodeTo
|
|
const slot = options.extra
|
|
|
|
if (!graph) throw new NullGraphError()
|
|
if (!node) throw new TypeError('Cannot add reroute: node was null')
|
|
if (!slot) throw new TypeError('Cannot add reroute: slot was null')
|
|
if (!opts.e)
|
|
throw new TypeError(
|
|
'Cannot add reroute: CanvasPointerEvent was null'
|
|
)
|
|
|
|
if (node instanceof SubgraphIONodeBase) {
|
|
throw new TypeError(
|
|
'Cannot add floating reroute to Subgraph IO Nodes'
|
|
)
|
|
} else {
|
|
const reroute = node.connectFloatingReroute(
|
|
[opts.e.canvasX, opts.e.canvasY],
|
|
slot,
|
|
afterRerouteId
|
|
)
|
|
if (!reroute) throw new Error('Failed to create reroute')
|
|
}
|
|
|
|
dirty()
|
|
break
|
|
}
|
|
case 'Search':
|
|
if (isFrom) {
|
|
opts.showSearchBox(e, {
|
|
// @ts-expect-error - Subgraph types
|
|
node_from: opts.nodeFrom,
|
|
// @ts-expect-error - Subgraph types
|
|
slot_from: slotX,
|
|
type_filter_in: fromSlotType
|
|
})
|
|
} else {
|
|
opts.showSearchBox(e, {
|
|
// @ts-expect-error - Subgraph types
|
|
node_to: opts.nodeTo,
|
|
// @ts-expect-error - Subgraph types
|
|
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)
|
|
if (!that.createDefaultNodeForSlot(options)) break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// refactor: there are different dialogs, some uses createDialog some dont
|
|
prompt(
|
|
title: string,
|
|
value: string | number,
|
|
callback: (value: string) => void,
|
|
event: CanvasPointerEvent,
|
|
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: ReturnType<typeof setTimeout> | undefined
|
|
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 = String(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 | null,
|
|
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 Property missing from interface definition
|
|
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 won't 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) {
|
|
let prevent_timeout = 0
|
|
let timeout_close: ReturnType<typeof setTimeout> | 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: ReturnType<typeof setTimeout> | 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 Property missing from interface definition
|
|
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()
|
|
|
|
// Handles cases where the searchbox is initiated by
|
|
// non-click events. e.g. Keyboard shortcuts
|
|
const safeEvent =
|
|
event ??
|
|
new MouseEvent('click', {
|
|
clientX: rect.left + rect.width * 0.5,
|
|
clientY: rect.top + rect.height * 0.5,
|
|
// @ts-expect-error layerY is a nonstandard property
|
|
layerY: rect.top + rect.height * 0.5
|
|
})
|
|
|
|
const left = safeEvent.clientX - 80
|
|
const top = safeEvent.clientY - 20
|
|
dialog.style.left = `${left}px`
|
|
dialog.style.top = `${top}px`
|
|
|
|
// To avoid out of screen problems
|
|
if (safeEvent.layerY > rect.height - 200) {
|
|
helper.style.maxHeight = `${rect.height - safeEvent.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, safeEvent, graphcanvas)
|
|
} else {
|
|
if (!graphcanvas.graph) throw new NullGraphError()
|
|
|
|
graphcanvas.graph.beforeChange()
|
|
const node = LiteGraph.createNode(name)
|
|
if (node) {
|
|
node.pos = graphcanvas.convertEventToCanvasOffset(safeEvent)
|
|
graphcanvas.graph.add(node, false)
|
|
}
|
|
|
|
// join node after inserting
|
|
if (options.node_from) {
|
|
let iS: number | false = 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 - slot_index property
|
|
if (iS == -1 && options.slot_from.slot_index !== undefined)
|
|
// @ts-expect-error - slot_index property
|
|
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 (iS !== false && options.node_from.outputs[iS] !== undefined) {
|
|
if (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("can't find slot " + options.slot_from);
|
|
}
|
|
}
|
|
if (options.node_to) {
|
|
let iS: number | false = 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 - slot_index property
|
|
if (iS == -1 && options.slot_from.slot_index !== undefined)
|
|
// @ts-expect-error - slot_index property
|
|
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 (iS !== false && options.node_to.inputs[iS] !== undefined) {
|
|
if (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("can't 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
|
|
|
|
// filter by type preprocess
|
|
let sIn: HTMLSelectElement | null = null
|
|
let sOut: HTMLSelectElement | null = null
|
|
if (options.do_type_filter && that.search_box) {
|
|
sIn = that.search_box.querySelector<HTMLSelectElement>(
|
|
'.slot_in_type_filter'
|
|
)
|
|
sOut = that.search_box.querySelector<HTMLSelectElement>(
|
|
'.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)
|
|
) {
|
|
const filtered_extra: string[] = []
|
|
for (const i in LiteGraph.registered_node_types) {
|
|
if (
|
|
inner_test_filter(i, {
|
|
inTypeOverride: sIn && sIn.value ? '*' : false,
|
|
outTypeOverride: sOut && sOut.value ? '*' : false
|
|
})
|
|
) {
|
|
filtered_extra.push(i)
|
|
}
|
|
}
|
|
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
|
|
) {
|
|
const filtered_extra: string[] = []
|
|
for (const i in LiteGraph.registered_node_types) {
|
|
if (inner_test_filter(i, { skipFilter: true }))
|
|
filtered_extra.push(i)
|
|
}
|
|
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: string | undefined =
|
|
typeof opts.inTypeOverride === 'string'
|
|
? 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 (typeof opts.outTypeOverride === 'string')
|
|
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 function signature not strictly typed
|
|
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 HTMLInputElement.value expects string but v can be other types
|
|
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, there 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: ReturnType<typeof setTimeout> | undefined
|
|
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): Panel {
|
|
options = options || {}
|
|
|
|
const root = document.createElement('div') as Panel
|
|
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.footer.style.marginTop = '-96px'
|
|
|
|
root.close = function () {
|
|
if (typeof root.onClose == 'function') root.onClose()
|
|
root.remove()
|
|
this.remove()
|
|
}
|
|
|
|
// function to swap panel content
|
|
root.toggleAltContent = function (force?: boolean) {
|
|
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?: boolean) {
|
|
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?: boolean
|
|
) {
|
|
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: string,
|
|
callback: () => void,
|
|
options?: unknown
|
|
): PanelButton {
|
|
const elem = document.createElement('button') as PanelButton
|
|
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: string,
|
|
value: TWidgetValue,
|
|
options?: PanelWidgetOptions,
|
|
callback?: PanelWidgetCallback
|
|
): PanelWidget {
|
|
options = options || {}
|
|
let str_value = String(value)
|
|
type = type.toLowerCase()
|
|
if (type == 'number' && typeof value === 'number')
|
|
str_value = value.toFixed(3)
|
|
|
|
const elem: PanelWidget = document.createElement('div') as PanelWidget
|
|
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
|
|
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 () {
|
|
const property = this.dataset['property']
|
|
if (property) root.inner_showCodePad?.(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) => {
|
|
this.textContent = v ?? null
|
|
innerChange(propname, v)
|
|
return false
|
|
}
|
|
new LiteGraph.ContextMenu(values, {
|
|
event,
|
|
className: 'dark',
|
|
callback: inner_clicked
|
|
})
|
|
})
|
|
}
|
|
|
|
root.content.append(elem)
|
|
|
|
function innerChange(name: string | undefined, value: TWidgetValue) {
|
|
const opts = options || {}
|
|
opts.callback?.(name, value, opts)
|
|
callback?.(name, value, opts)
|
|
}
|
|
|
|
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 = undefined
|
|
}
|
|
})
|
|
this.node_panel = panel
|
|
panel.id = 'node-panel'
|
|
panel.node = node
|
|
panel.classList.add('settings')
|
|
panel.style.position = 'absolute'
|
|
panel.style.top = '96px'
|
|
panel.style.left = '65px'
|
|
|
|
const inner_refresh = () => {
|
|
// clear
|
|
panel.content.innerHTML = ''
|
|
panel.addHTML(
|
|
// @ts-expect-error - desc property
|
|
`<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: PanelWidgetCallback = (name, value) => {
|
|
if (!this.graph) throw new NullGraphError()
|
|
if (!name) return
|
|
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 | null)[] {
|
|
let options: (IContextMenuValue<string> | null)[]
|
|
if (this.getMenuOptions) {
|
|
options = this.getMenuOptions()
|
|
} else {
|
|
options = [
|
|
{
|
|
content: 'Add Node',
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onMenuAdd
|
|
},
|
|
{ content: 'Add Group', callback: LGraphCanvas.onGroupAdd },
|
|
{
|
|
content: 'Paste',
|
|
callback: () => {
|
|
this.pasteFromClipboard()
|
|
}
|
|
}
|
|
// { content: "Arrange", callback: that.graph.arrange },
|
|
// {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll }
|
|
]
|
|
if (Object.keys(this.selected_nodes).length > 1) {
|
|
options.push(
|
|
{
|
|
content: 'Convert to Subgraph',
|
|
callback: () => {
|
|
if (!this.selectedItems.size)
|
|
throw new Error('Convert to Subgraph: Nothing selected.')
|
|
this._graph.convertToSubgraph(this.selectedItems)
|
|
}
|
|
},
|
|
{
|
|
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: 'Convert to Subgraph',
|
|
callback: () => {
|
|
// find groupnodes, degroup and select children
|
|
if (this.selectedItems.size) {
|
|
let hasGroups = false
|
|
for (const item of this.selectedItems) {
|
|
const node = item as LGraphNode
|
|
const isGroup =
|
|
typeof node.type === 'string' &&
|
|
node.type.startsWith(`${PREFIX}${SEPARATOR}`)
|
|
if (isGroup && node.convertToNodes) {
|
|
hasGroups = true
|
|
const nodes = node.convertToNodes()
|
|
|
|
requestAnimationFrame(() => {
|
|
this.selectItems(nodes, true)
|
|
|
|
if (!this.selectedItems.size)
|
|
throw new Error('Convert to Subgraph: Nothing selected.')
|
|
this._graph.convertToSubgraph(this.selectedItems)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// If no groups were found, continue normally
|
|
if (!hasGroups) {
|
|
if (!this.selectedItems.size)
|
|
throw new Error('Convert to Subgraph: Nothing selected.')
|
|
this._graph.convertToSubgraph(this.selectedItems)
|
|
}
|
|
} else {
|
|
throw new Error('Convert to Subgraph: Nothing selected.')
|
|
}
|
|
}
|
|
},
|
|
{
|
|
content: 'Properties',
|
|
has_submenu: true,
|
|
callback: LGraphCanvas.onShowMenuNodeProperties
|
|
},
|
|
{
|
|
content: 'Properties Panel',
|
|
callback: function (
|
|
_item: Positionable,
|
|
_options: IContextMenuOptions | undefined,
|
|
_e: MouseEvent | undefined,
|
|
_menu: ContextMenu<unknown> | undefined,
|
|
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: CanvasPointerEvent
|
|
): void {
|
|
// 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.displayType ?? 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 || slot.input?.link != null) {
|
|
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.nameLocked && !('link' in _slot && _slot.widget)) {
|
|
menu_info.push({ content: 'Rename Slot', slot })
|
|
}
|
|
|
|
if (_slot.removable) {
|
|
menu_info.push(null)
|
|
menu_info.push(
|
|
_slot.locked
|
|
? 'Cannot remove'
|
|
: { content: 'Remove Slot', slot, className: 'danger' }
|
|
)
|
|
}
|
|
|
|
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) {
|
|
// Try layout store first, fallback to old method
|
|
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
|
x: event.canvasX,
|
|
y: event.canvasY
|
|
})
|
|
|
|
let reroute: Reroute | undefined
|
|
if (rerouteLayout) {
|
|
reroute = this.graph.getReroute(rerouteLayout.id)
|
|
} else {
|
|
reroute = this.graph.getRerouteOnPos(
|
|
event.canvasX,
|
|
event.canvasY,
|
|
this.#visibleReroutes
|
|
)
|
|
}
|
|
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
|
|
|
|
new LiteGraph.ContextMenu(menu_info, options)
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Calculate new position with delta
|
|
*/
|
|
private calculateNewPosition(
|
|
node: LGraphNode,
|
|
deltaX: number,
|
|
deltaY: number
|
|
): { x: number; y: number } {
|
|
return {
|
|
x: node.pos[0] + deltaX,
|
|
y: node.pos[1] + deltaY
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply batched node position updates
|
|
*/
|
|
private applyNodePositionUpdates(
|
|
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>,
|
|
mutations: ReturnType<typeof useLayoutMutations>
|
|
): void {
|
|
for (const { node, newPos } of nodesToMove) {
|
|
// Update LiteGraph position first so next drag uses correct base position
|
|
node.pos[0] = newPos.x
|
|
node.pos[1] = newPos.y
|
|
// Then update layout store which will update Vue nodes
|
|
mutations.moveNode(node.id, newPos)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize layout mutations with Canvas source
|
|
*/
|
|
private initLayoutMutations(): ReturnType<typeof useLayoutMutations> {
|
|
const mutations = useLayoutMutations()
|
|
mutations.setSource(LayoutSource.Canvas)
|
|
return mutations
|
|
}
|
|
|
|
/**
|
|
* Collect all nodes that are children of groups in the selection
|
|
*/
|
|
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
|
|
const nodesInGroups = new Set<LGraphNode>()
|
|
for (const item of items) {
|
|
if (item instanceof LGraphGroup) {
|
|
for (const child of item._children) {
|
|
if (child instanceof LGraphNode) {
|
|
nodesInGroups.add(child)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nodesInGroups
|
|
}
|
|
|
|
/**
|
|
* Move group children (both nodes and non-nodes)
|
|
*/
|
|
private moveGroupChildren(
|
|
group: LGraphGroup,
|
|
deltaX: number,
|
|
deltaY: number,
|
|
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
|
): void {
|
|
for (const child of group._children) {
|
|
if (child instanceof LGraphNode) {
|
|
const node = child as LGraphNode
|
|
nodesToMove.push({
|
|
node,
|
|
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
|
})
|
|
} else if (!(child instanceof LGraphGroup)) {
|
|
// Non-node, non-group children (reroutes, etc.)
|
|
// Skip groups here - they're already in allItems and will be
|
|
// processed in the main loop of moveChildNodesInGroupVueMode
|
|
child.move(deltaX, deltaY, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
moveChildNodesInGroupVueMode(
|
|
allItems: Set<Positionable>,
|
|
deltaX: number,
|
|
deltaY: number
|
|
) {
|
|
const mutations = this.initLayoutMutations()
|
|
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
|
const nodesToMove: NewNodePosition[] = []
|
|
|
|
// First, collect all the moves we need to make
|
|
for (const item of allItems) {
|
|
const isNode = item instanceof LGraphNode
|
|
if (isNode) {
|
|
const node = item as LGraphNode
|
|
if (nodesInMovingGroups.has(node)) {
|
|
continue
|
|
}
|
|
nodesToMove.push({
|
|
node,
|
|
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
|
})
|
|
} else if (item instanceof LGraphGroup) {
|
|
item.move(deltaX, deltaY, true)
|
|
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
|
|
} else {
|
|
// Other items (reroutes, etc.)
|
|
item.move(deltaX, deltaY, true)
|
|
}
|
|
}
|
|
|
|
// Now apply all the node moves at once
|
|
this.applyNodePositionUpdates(nodesToMove, mutations)
|
|
}
|
|
|
|
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
|
|
const mutations = this.initLayoutMutations()
|
|
this.applyNodePositionUpdates(nodesToReposition, mutations)
|
|
}
|
|
}
|