diff --git a/src/LGraph.ts b/src/LGraph.ts index 42fed51b..ea96b606 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -1,12 +1,14 @@ -import type { Dictionary, IContextMenuValue, ISlotType, MethodNames, Point } from "./interfaces" -import type { ISerialisedGraph, Serialisable, SerialisableGraph } from "./types/serialisation" +import type { Dictionary, IContextMenuValue, LinkNetwork, ISlotType, MethodNames, Point, LinkSegment } from "./interfaces" +import type { ISerialisedGraph, Serialisable, SerialisableGraph, SerialisableReroute } from "./types/serialisation" +import { Reroute, RerouteId } from "./Reroute" import { LGraphEventMode } from "./types/globalEnums" import { LiteGraph } from "./litegraph" import { LGraphCanvas } from "./LGraphCanvas" import { LGraphGroup } from "./LGraphGroup" import { type NodeId, LGraphNode } from "./LGraphNode" -import { type LinkId, LLink, type SerialisedLLinkArray } from "./LLink" +import { type LinkId, LLink } from "./LLink" import { MapProxyHandler } from "./MapProxyHandler" +import { isSortaInsideOctagon } from "./measure" interface IGraphInput { name: string @@ -30,7 +32,7 @@ type ParamsArray, K extends MethodNames> = Paramet + onNodeRemoved: when a node inside this graph is removed + onNodeConnectionChange: some connection has changed in the graph (connected or disconnected) */ -export class LGraph implements Serialisable { +export class LGraph implements LinkNetwork, Serialisable { static serialisedSchemaVersion = 1 as const //default supported types @@ -88,6 +90,28 @@ export class LGraph implements Serialisable { inputs: Dictionary outputs: Dictionary + #reroutes = new Map() + /** All reroutes in this graph. */ + public get reroutes(): Map { + return this.#reroutes + } + public set reroutes(value: Map) { + if (!value) throw new TypeError("Attempted to set LGraph.reroutes to a falsy value.") + + const reroutes = this.#reroutes + if (value.size === 0) { + reroutes.clear() + return + } + + for (const rerouteId of reroutes.keys()) { + if (!value.has(rerouteId)) reroutes.delete(rerouteId) + } + for (const [id, reroute] of value) { + reroutes.set(id, reroute) + } + } + /** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */ get last_node_id() { return this.state.lastNodeId @@ -185,7 +209,9 @@ export class LGraph implements Serialisable { this._nodes_by_id = {} this._nodes_in_order = [] //nodes sorted in execution order this._nodes_executable = null //nodes that contain onExecute sorted in execution order + this._links.clear() + this.reroutes.clear() //other scene stuff this._groups = [] @@ -913,6 +939,31 @@ export class LGraph implements Serialisable { return this._groups.toReversed().find(g => g.isPointInside(x, y)) } + /** + * Returns the top-most group with a titlebar in the provided position. + * @param x The x coordinate in canvas space + * @param y The y coordinate in canvas space + * @return The group or null + */ + getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined { + return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y)) + } + + /** + * Finds a reroute a the given graph point + * @param x X co-ordinate in graph space + * @param y Y co-ordinate in graph space + * @returns The first reroute under the given co-ordinates, or undefined + */ + getRerouteOnPos(x: number, y: number): Reroute | undefined { + for (const reroute of this.reroutes.values()) { + const pos = reroute.pos + + if (isSortaInsideOctagon(x - pos[0], y - pos[1], 20)) + return reroute + } + } + /** * Checks that the node type matches the node type registered, used when replacing a nodetype by a newer version during execution * this replaces the ones using the old version with the new version @@ -1205,6 +1256,72 @@ export class LGraph implements Serialisable { setDirtyCanvas(fg: boolean, bg?: boolean): void { this.canvasAction(c => c.setDirty(fg, bg)) } + + /** + * Configures a reroute on the graph where ID is already known (probably deserialisation). + * Creates the object if it does not exist. + * @param id Reroute ID + * @param pos Position in graph space + * @param linkIds IDs of links that pass through this reroute + */ + setReroute({ id, parentId, pos, linkIds }: SerialisableReroute): Reroute { + if (id > this.state.lastRerouteId) this.state.lastRerouteId = id + const reroute = this.reroutes.get(id) ?? new Reroute(id, this) + reroute.update(parentId, pos, linkIds) + this.reroutes.set(id, reroute) + return reroute + } + + /** + * Creates a new reroute and adds it to the graph. + * @param pos Position in graph space + * @param links The links that will use this reroute (e.g. if from an output with multiple outputs, and all will use it) + * @param afterRerouteId If set, this reroute will be shown after the specified ID. Otherwise, the reroute will be added as the last on the link. + * @returns The newly created reroute - typically ignored. + */ + createReroute(pos: Point, before: LinkSegment): Reroute { + const rerouteId = ++this.state.lastRerouteId + const linkIds = before instanceof Reroute + ? before.linkIds + : [before.id] + const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds) + this.reroutes.set(rerouteId, reroute) + for (const linkId of linkIds) { + const link = this._links.get(linkId) + if (!link) continue + if (link.parentId === before.parentId) link.parentId = rerouteId + LLink.getReroutes(this, link) + ?.filter(x => x.parentId === before.parentId) + .forEach(x => x.parentId = rerouteId) + } + + return reroute + } + + /** + * Removes a reroute from the graph + * @param id ID of reroute to remove + */ + removeReroute(id: RerouteId): void { + const { reroutes } = this + const reroute = reroutes.get(id) + if (!reroute) return + + // Extract reroute from the reroute chain + const { parentId, linkIds } = reroute + for (const reroute of reroutes.values()) { + if (reroute.parentId === id) reroute.parentId = parentId + } + + for (const linkId of linkIds) { + const link = this._links.get(linkId) + if (link && link.parentId === id) link.parentId = parentId + } + + reroutes.delete(id) + this.setDirtyCanvas(false, true) + } + /** * Destroys a link * @param {Number} link_id @@ -1215,8 +1332,10 @@ export class LGraph implements Serialisable { const node = this.getNodeById(link.target_id) node?.disconnectInput(link.target_slot) + + link.disconnect(this) } - //save and recover app state *************************************** + /** * Creates a Object containing all the info about this graph, it can be serialized * @deprecated Use {@link asSerialisable}, which returns the newer schema version. @@ -1224,9 +1343,18 @@ export class LGraph implements Serialisable { * @return {Object} value of the node */ serialize(option?: { sortNodes: boolean }): ISerialisedGraph { - const { config, state, groups, nodes, extra } = this.asSerialisable(option) - const links = [...this._links.values()].map(x => x.serialize()) + const { config, state, groups, nodes, reroutes, extra } = this.asSerialisable(option) + const linkArray = [...this._links.values()] + const links = linkArray.map(x => x.serialize()) + + if (reroutes.length) { + extra.reroutes = reroutes + // Link parent IDs cannot go in 0.4 schema arrays + extra.linkExtensions = linkArray + .filter(x => x.parentId !== undefined) + .map(x => ({ id: x.id, parentId: x.parentId })) + } return { last_node_id: state.lastNodeId, last_link_id: state.lastLinkId, @@ -1259,6 +1387,7 @@ export class LGraph implements Serialisable { const groups = this._groups.map(x => x.serialize()) const links = [...this._links.values()].map(x => x.asSerialisable()) + const reroutes = [...this.reroutes.values()].map(x => x.asSerialisable()) const data: SerialisableGraph = { version: LGraph.serialisedSchemaVersion, @@ -1267,6 +1396,7 @@ export class LGraph implements Serialisable { groups, nodes, links, + reroutes, extra } @@ -1284,6 +1414,10 @@ export class LGraph implements Serialisable { if (!data) return if (!keep_old) this.clear() + const { extra } = data + let reroutes: SerialisableReroute[] | undefined + + // TODO: Determine whether this should this fall back to 0.4. if (data.version === 0.4) { // Deprecated - old schema version, links are arrays if (Array.isArray(data.links)) { @@ -1292,6 +1426,20 @@ export class LGraph implements Serialisable { this._links.set(link.id, link) } } + //#region `extra` embeds for v0.4 + + // LLink parentIds + if (Array.isArray(extra?.linkExtensions)) { + for (const linkEx of extra.linkExtensions) { + const link = this._links.get(linkEx.id) + if (link) link.parentId = linkEx.parentId + } + } + + // Reroutes + reroutes = extra?.reroutes + + //#endregion `extra` embeds for v0.4 } else { // New schema - one version so far, no check required. @@ -1311,6 +1459,19 @@ export class LGraph implements Serialisable { this._links.set(link.id, link) } } + + reroutes = data.reroutes + } + + // Reroutes + if (Array.isArray(reroutes)) { + for (const rerouteData of reroutes) { + const reroute = this.setReroute(rerouteData) + + // Drop broken links, and ignore reroutes with no valid links + if (!reroute.validateLinks(this._links)) + this.reroutes.delete(rerouteData.id) + } } const nodesData = data.nodes @@ -1318,7 +1479,7 @@ export class LGraph implements Serialisable { //copy all stored fields for (const i in data) { //links must be accepted - if (i == "nodes" || i == "groups" || i == "links" || i === "state") + if (i == "nodes" || i == "groups" || i == "links" || i === "state" || i === "reroutes") continue this[i] = data[i] } diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index c1f8b7f5..ad3b1c95 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -1,19 +1,21 @@ -import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuOptions, INodeSlot, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, Point, Rect, Rect32, Size, IContextMenuValue, ISlotType, ConnectingLink, NullableProperties, Positionable, ReadOnlyPoint, ReadOnlyRect } from "./interfaces" +import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuOptions, INodeSlot, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, Point, Rect, Rect32, Size, IContextMenuValue, ISlotType, ConnectingLink, NullableProperties, Positionable, LinkSegment, ReadOnlyPoint, ReadOnlyRect } from "./interfaces" import type { IWidget, TWidgetValue } from "./types/widgets" import { LGraphNode, type NodeId } from "./LGraphNode" import type { CanvasDragEvent, CanvasMouseEvent, CanvasWheelEvent, CanvasEventDetail, CanvasPointerEvent } from "./types/events" import type { IClipboardContents } from "./types/serialisation" -import type { LLink } from "./LLink" +import { LLink } from "./LLink" import type { LGraph } from "./LGraph" import type { ContextMenu } from "./ContextMenu" -import { EaseFunction, LGraphEventMode, LinkDirection, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" +import { EaseFunction, LGraphEventMode, LinkDirection, LinkMarkerShape, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" import { LGraphGroup } from "./LGraphGroup" -import { isInsideRectangle, distance, overlapBounding, isPointInRectangle, containsRect, createBounds } from "./measure" +import { isInsideRectangle, distance, overlapBounding, isPointInRectangle, findPointOnCurve, containsRect, createBounds } from "./measure" import { drawSlot, LabelPosition } from "./draw" import { DragAndScale } from "./DragAndScale" import { LinkReleaseContextExtended, LiteGraph, clamp } from "./litegraph" import { stringOrEmpty, stringOrNull } from "./strings" import { alignNodes, distributeNodes, getBoundaryNodes } from "./utils/arrange" +import { Reroute, type RerouteId } from "./Reroute" +import { getAllNestedItems } from "./utils/collections" interface IShowSearchOptions { node_to?: LGraphNode @@ -31,7 +33,7 @@ interface IShowSearchOptions { show_all_on_open?: boolean } -interface INodeFromTo { +interface ICreateNodeOptions { /** input */ nodeFrom?: LGraphNode /** input */ @@ -41,12 +43,12 @@ interface INodeFromTo { /** output */ slotTo?: number | INodeOutputSlot | INodeInputSlot /** pass the event coords */ -} -interface ICreateNodeOptions extends INodeFromTo { // FIXME: Should not be optional /** Position of new node */ position?: Point + /** Create the connection from a reroute */ + afterRerouteId?: RerouteId // FIXME: Should not be optional /** choose a nodetype to add, AUTO to set at first good */ @@ -57,7 +59,8 @@ interface ICreateNodeOptions extends INodeFromTo { posSizeFix?: Point //-alphaPosY*2*/ e?: CanvasMouseEvent allow_searchbox?: boolean - showSearchBox?: LGraphCanvas["showSearchBox"] + /** See {@link LGraphCanvas.showSearchBox} */ + showSearchBox?: ((event: CanvasMouseEvent, options?: IShowSearchOptions) => HTMLDivElement | void) } interface ICloseableDiv extends HTMLDivElement { @@ -115,6 +118,9 @@ export class LGraphCanvas { static #link_bounding = new Float32Array(4) static #tempA = new Float32Array(2) static #tempB = new Float32Array(2) + static #lTempA: Point = new Float32Array(2) + static #lTempB: Point = new Float32Array(2) + static #lTempC: Point = new Float32Array(2) static DEFAULT_BACKGROUND_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=" @@ -229,6 +235,12 @@ export class LGraphCanvas { render_execution_order: boolean render_title_colored: boolean render_link_tooltip: boolean + + /** Controls whether reroutes are rendered at all. */ + reroutesEnabled: boolean = false + + /** Shape of the markers shown at the midpoint of links. Default: Circle */ + linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle links_render_mode: number /** mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle */ mouse: Point @@ -250,9 +262,11 @@ export class LGraphCanvas { current_node: LGraphNode | null /** used for widgets */ node_widget?: [LGraphNode, IWidget] | null - over_link_center: LLink | null + over_link_center: LinkSegment | null last_mouse_position: Point visible_area?: Rect32 + /** Contains all links and reroutes that were rendered. Repopulated every render cycle. */ + renderedPaths: Set = new Set() visible_links?: LLink[] connecting_links: ConnectingLink[] | null viewport?: Rect @@ -284,7 +298,7 @@ export class LGraphCanvas { /** A map of nodes that require selective-redraw */ dirty_nodes = new Map() dirty_area?: Rect - // Unused + /** @deprecated Unused */ node_in_panel?: LGraphNode last_mouse: Point = [0, 0] last_mouseclick: number = 0 @@ -303,10 +317,13 @@ export class LGraphCanvas { _mouseout_callback?(e: CanvasMouseEvent): boolean _key_callback?(e: KeyboardEvent): boolean _ondrop_callback?(e: CanvasDragEvent): unknown + /** @deprecated WebGL */ gl?: never bgctx?: CanvasRenderingContext2D is_rendering?: boolean + /** @deprecated Panels */ block_click?: boolean + /** @deprecated Panels */ last_click_position?: Point resizing_node?: LGraphNode /** @deprecated See {@link LGraphCanvas.resizingGroup} */ @@ -316,7 +333,9 @@ export class LGraphCanvas { _highlight_pos?: Point _highlight_input?: INodeInputSlot // TODO: Check if panels are used + /** @deprecated Panels */ node_panel + /** @deprecated Panels */ options_panel onDropItem: (e: Event) => any _bg_img: HTMLImageElement @@ -325,7 +344,9 @@ export class LGraphCanvas { // TODO: This looks like another panel thing prompt_box: IDialog search_box: HTMLDivElement + /** @deprecated Panels */ SELECTED_NODE: LGraphNode + /** @deprecated Panels */ NODEPANEL_IS_OPEN: boolean getMenuOptions?(): IContextMenuValue[] getExtraMenuOptions?(canvas: LGraphCanvas, options: IContextMenuValue[]): IContextMenuValue[] @@ -1739,8 +1760,8 @@ export class LGraphCanvas { let skip_action = false const now = LiteGraph.getTime() const is_double_click = (now - this.last_mouseclick < 300) - this.mouse[0] = e.clientX - this.mouse[1] = e.clientY + 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]] @@ -2027,46 +2048,79 @@ export class LGraphCanvas { } } //clicked outside of nodes else { + if (this.reroutesEnabled && !skip_action && !this.read_only) { + // Check for reroutes + const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY) + if (reroute) { + this.processSelect(reroute, e) + + if (e.shiftKey) { + // Connect new link from reroute + const link = graph._links.get(reroute.linkIds.values().next().value) + + const outputNode = graph.getNodeById(link.origin_id) + const slot = link.origin_slot + this.connecting_links = [{ + node: outputNode, + slot, + input: null, + output: outputNode.outputs[slot], + pos: outputNode.getConnectionPos(false, slot), + afterRerouteId: reroute.id, + }] + } else { + this.isDragging = true + } + + skip_action = true + } + } + if (!skip_action) { //search for link connector if (!this.read_only) { // Set the width of the line for isPointInStroke checks const lineWidth = this.ctx.lineWidth this.ctx.lineWidth = this.connections_width + 7 - for (let i = 0; i < this.visible_links.length; ++i) { - const link = this.visible_links[i] - const center = link._pos - let overLink: LLink = null - if (!center || - e.canvasX < center[0] - 4 || - e.canvasX > center[0] + 4 || - e.canvasY < center[1] - 4 || - e.canvasY > center[1] + 4) { - // If we shift click on a link then start a link from that input - if (e.shiftKey && link.path && this.ctx.isPointInStroke(link.path, e.canvasX, e.canvasY)) { - overLink = link - } else { - continue - } + + for (const linkSegment of this.renderedPaths) { + const centre = linkSegment._pos + if (!centre) continue + + // FIXME: Clean up + if (isInsideRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { + this.showLinkMenu(linkSegment, e) + //clear tooltip + this.over_link_center = null + break } - if (overLink) { - const slot = overLink.origin_slot - const originNode = graph._nodes_by_id[overLink.origin_id] - - this.connecting_links ??= [] - this.connecting_links.push({ - node: originNode, - slot, - output: originNode.outputs[slot], - pos: originNode.getConnectionPos(false, slot), - }) - skip_action = true - } else { - //link clicked - this.showLinkMenu(link, e) - this.over_link_center = null //clear tooltip + + // If we shift click on a link then start a link from that input + if ((e.shiftKey || e.altKey) && linkSegment.path && this.ctx.isPointInStroke(linkSegment.path, e.canvasX, e.canvasY)) { + if (e.shiftKey && !e.altKey) { + const slot = linkSegment.origin_slot + const originNode = graph._nodes_by_id[linkSegment.origin_id] + + const connecting: ConnectingLink = { + node: originNode, + slot, + output: originNode.outputs[slot], + pos: originNode.getConnectionPos(false, slot), + } + this.connecting_links = [connecting] + if (this.reroutesEnabled && linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId + + skip_action = true + break + } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { + const newReroute = graph.createReroute([e.canvasX, e.canvasY], linkSegment) + this.processSelect(newReroute, e) + this.isDragging = true + + skip_action = true + break + } } - break } // Restore line width @@ -2209,7 +2263,7 @@ export class LGraphCanvas { } - this.last_mouse = [e.clientX, e.clientY] + this.last_mouse = [x, y] this.last_mouseclick = LiteGraph.getTime() this.last_mouse_dragging = true @@ -2402,25 +2456,12 @@ export class LGraphCanvas { ? "se-resize" : "crosshair" } - } else { //not over a node - //search for link connector - let over_link: LLink = null - for (let i = 0; i < this.visible_links.length; ++i) { - const link = this.visible_links[i] - const center = link._pos - if (!center || - e.canvasX < center[0] - 4 || - e.canvasX > center[0] + 4 || - e.canvasY < center[1] - 4 || - e.canvasY > center[1] + 4) { - continue - } - over_link = link - break - } - if (over_link != this.over_link_center) { - this.over_link_center = over_link - this.dirty_canvas = true + } else { + // Not over a node + const segment = this.#getLinkCentreOnPos(e) + if (this.over_link_center !== segment) { + this.over_link_center = segment + this.dirty_bgcanvas = true } if (this.canvas) { @@ -2436,22 +2477,15 @@ export class LGraphCanvas { // Items being dragged if (this.isDragging && !this.live_mode) { const selected = this.selectedItems - const allItems = e.ctrlKey ? selected : new Set() - - if (!e.ctrlKey) - selected?.forEach(x => addToSetRecursively(x, allItems)) + const allItems = e.ctrlKey ? selected : getAllNestedItems(selected) const deltaX = delta[0] / this.ds.scale const deltaY = delta[1] / this.ds.scale - allItems.forEach(x => x.move(deltaX, deltaY, true)) + for (const item of allItems) { + if (!item.pinned) item.move(deltaX, deltaY, true) + } this.#dirty() - - function addToSetRecursively(item: Positionable, items: Set): void { - if (items.has(item) || item.pinned) return - items.add(item) - item.children?.forEach(x => addToSetRecursively(x, items)) - } } if (this.resizing_node && !this.live_mode) { @@ -2557,6 +2591,14 @@ export class LGraphCanvas { group.recomputeInsideNodes() group.selected = true } + + if (this.reroutesEnabled) { + for (const reroute of this.graph.reroutes.values()) { + if (!isPointInRectangle(reroute.pos, dragRect)) continue + this.selectedItems.add(reroute) + reroute.selected = true + } + } } else { // will select of update selection this.selectNodes([node], e.shiftKey || e.ctrlKey || e.metaKey) // add to selection add to selection with ctrlKey or shiftKey @@ -2582,7 +2624,7 @@ export class LGraphCanvas { e.canvasY ) if (slot != -1) { - link.node.connect(link.slot, node, slot) + link.node.connect(link.slot, node, slot, link.afterRerouteId) } else if (this.link_over_widget) { this.emitEvent({ subType: "connectingWidgetLink", @@ -2594,7 +2636,7 @@ export class LGraphCanvas { } else { //not on top of an input // look for a good slot - link.node.connectByType(link.slot, node, link.output.type) + link.node.connectByType(link.slot, node, link.output.type, { afterRerouteId: link.afterRerouteId }) } } else if (link.input) { const slot = this.isOverNodeOutput( @@ -2604,11 +2646,11 @@ export class LGraphCanvas { ) if (slot != -1) { - node.connect(slot, link.node, link.slot) // this is inverted has output-input nature like + node.connect(slot, link.node, link.slot, link.afterRerouteId) // this is inverted has output-input nature like } else { //not on top of an input // look for a good slot - link.node.connectByTypeOutput(link.slot, node, link.input.type) + link.node.connectByTypeOutput(link.slot, node, link.input.type, { afterRerouteId: link.afterRerouteId }) } } } @@ -2632,6 +2674,7 @@ export class LGraphCanvas { originalEvent: e, linkReleaseContext: linkReleaseContextExtended, }) + // No longer in use // add menu when releasing link in empty space if (LiteGraph.release_link_on_empty_shows_menu) { if (e.shiftKey) { @@ -2647,8 +2690,6 @@ export class LGraphCanvas { } } } - - this.connecting_links = null } //not dragging connection else if (this.resizing_node) { this.#dirty() @@ -2672,8 +2713,13 @@ export class LGraphCanvas { this.node_dragged = null } //no node being dragged else { - if (!node && e.click_time < 300 && !this.graph.groups.some(x => x.isPointInTitlebar(e.canvasX, e.canvasY))) { - this.deselectAll() + if ( + !node && + e.click_time < 300 && + !this.graph.getGroupTitlebarOnPos(e.canvasX, e.canvasY) && + (!this.reroutesEnabled || !this.graph.getRerouteOnPos(e.canvasX, e.canvasY)) + ) { + this.processSelect(null, e) } this.dirty_canvas = true @@ -2686,6 +2732,8 @@ export class LGraphCanvas { e.canvasY - this.node_capturing_input.pos[1] ]) } + + this.connecting_links = null } else if (e.which == 2) { //middle button this.dirty_canvas = true @@ -3243,7 +3291,7 @@ export class LGraphCanvas { * @returns All items on the canvas that can be selected */ get positionableItems(): Positionable[] { - return [...this.graph._nodes, ...this.graph._groups] + return [...this.graph._nodes, ...this.graph._groups, ...this.graph.reroutes.values()] } /** @@ -3337,9 +3385,12 @@ export class LGraphCanvas { this.onNodeDeselected?.(node) } else if (item instanceof LGraphGroup) { graph.remove(item) + } else if (item instanceof Reroute) { + graph.removeReroute(item.id) } } + this.selectedItems.clear() this.selected_nodes = {} this.selectedItems.clear() this.current_node = null @@ -3595,7 +3646,7 @@ export class LGraphCanvas { } } - if (this.connecting_links) { + if (this.connecting_links?.length) { //current connection (the one being dragged by the mouse) for (const link of this.connecting_links) { ctx.lineWidth = this.connections_width @@ -3603,8 +3654,8 @@ export class LGraphCanvas { const connInOrOut = link.output || link.input - const connType = connInOrOut.type - let connDir = connInOrOut.dir + const connType = connInOrOut?.type + let connDir = connInOrOut?.dir if (connDir == null) { if (link.output) connDir = link.node.horizontal ? LinkDirection.DOWN : LinkDirection.RIGHT @@ -3612,7 +3663,7 @@ export class LGraphCanvas { else connDir = link.node.horizontal ? LinkDirection.UP : LinkDirection.LEFT } - const connShape = connInOrOut.shape + const connShape = connInOrOut?.shape switch (connType) { case LiteGraph.EVENT: @@ -3622,11 +3673,13 @@ export class LGraphCanvas { link_color = LiteGraph.CONNECTING_LINK_COLOR } - const highlightPos: Point = this.#getHighlightPosition() + // If not using reroutes, link.afterRerouteId should be undefined. + const pos = this.graph.reroutes.get(link.afterRerouteId)?.pos ?? link.pos + const highlightPos = this.#getHighlightPosition() //the connection being dragged by the mouse this.renderLink( ctx, - link.pos, + pos, highlightPos, null, false, @@ -3640,8 +3693,8 @@ export class LGraphCanvas { if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) { ctx.rect( - link.pos[0] - 6 + 0.5, - link.pos[1] - 5 + 0.5, + pos[0] - 6 + 0.5, + pos[1] - 5 + 0.5, 14, 10 ) @@ -3654,15 +3707,15 @@ export class LGraphCanvas { 10 ) } else if (connShape === RenderShape.ARROW) { - ctx.moveTo(link.pos[0] + 8, link.pos[1] + 0.5) - ctx.lineTo(link.pos[0] - 4, link.pos[1] + 6 + 0.5) - ctx.lineTo(link.pos[0] - 4, link.pos[1] - 6 + 0.5) + ctx.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( - link.pos[0], - link.pos[1], + pos[0], + pos[1], 4, 0, Math.PI * 2 @@ -3724,6 +3777,18 @@ export class LGraphCanvas { if (ctx.finish2D) ctx.finish2D() } + /** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */ + #getLinkCentreOnPos(e: CanvasMouseEvent): LinkSegment | undefined { + for (const linkSegment of this.renderedPaths) { + const centre = linkSegment._pos + if (!centre) continue + + if (isInsideRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { + return linkSegment + } + } + } + /** Get the target snap / highlight point in graph space */ #getHighlightPosition(): Point { return LiteGraph.snaps_for_comfy @@ -4312,8 +4377,8 @@ export class LGraphCanvas { const render_text = !low_quality const highlightColour = LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR - const out_slot = this.connecting_links ? this.connecting_links[0].output : null - const in_slot = this.connecting_links ? this.connecting_links[0].input : null + const out_slot = this.connecting_links?.[0]?.output + const in_slot = this.connecting_links?.[0]?.input ctx.lineWidth = 1 let max_y = 0 @@ -4509,22 +4574,40 @@ export class LGraphCanvas { ctx.globalAlpha = 1.0 } - //used by this.over_link_center - drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LLink): void { + + /** + * 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() - ctx.arc(pos[0], pos[1], 3, 0, Math.PI * 2) + if (this.linkMarkerShape === LinkMarkerShape.Arrow) { + const transform = ctx.getTransform() + ctx.translate(pos[0], pos[1]) + if (Number.isFinite(link._centreAngle)) ctx.rotate(link._centreAngle) + 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() - if (link.data == null) - return + // @ts-expect-error TODO: Better value typing + const data = link.data + if (data == null) return + // @ts-expect-error TODO: Better value typing if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return - // TODO: Better value typing - const data = link.data let text: string = null if (typeof data === "number") @@ -4913,6 +4996,8 @@ export class LGraphCanvas { } drawConnections(ctx: CanvasRenderingContext2D): void { + const rendered = this.renderedPaths + rendered.clear() const now = LiteGraph.getTime() const visible_area = this.visible_area LGraphCanvas.#margin_area[0] = visible_area[0] - 20 @@ -4945,41 +5030,30 @@ export class LGraphCanvas { const start_node = this.graph.getNodeById(link.origin_id) if (start_node == null) continue - const start_node_slot = link.origin_slot - let start_node_slotpos: Point = null - if (start_node_slot == -1) { - start_node_slotpos = [ - start_node.pos[0] + 10, - start_node.pos[1] + 10 - ] - } else { - start_node_slotpos = start_node.getConnectionPos( - false, - start_node_slot, - LGraphCanvas.#tempA - ) - } + const outputId = link.origin_slot + const start_node_slotpos: Point = outputId == -1 + ? [start_node.pos[0] + 10, start_node.pos[1] + 10] + : start_node.getConnectionPos(false, outputId, LGraphCanvas.#tempA) + const end_node_slotpos = node.getConnectionPos(true, i, LGraphCanvas.#tempB) - //compute link bounding - LGraphCanvas.#link_bounding[0] = start_node_slotpos[0] - LGraphCanvas.#link_bounding[1] = start_node_slotpos[1] - LGraphCanvas.#link_bounding[2] = end_node_slotpos[0] - start_node_slotpos[0] - LGraphCanvas.#link_bounding[3] = end_node_slotpos[1] - start_node_slotpos[1] - if (LGraphCanvas.#link_bounding[2] < 0) { - LGraphCanvas.#link_bounding[0] += LGraphCanvas.#link_bounding[2] - LGraphCanvas.#link_bounding[2] = Math.abs(LGraphCanvas.#link_bounding[2]) - } - if (LGraphCanvas.#link_bounding[3] < 0) { - LGraphCanvas.#link_bounding[1] += LGraphCanvas.#link_bounding[3] - LGraphCanvas.#link_bounding[3] = Math.abs(LGraphCanvas.#link_bounding[3]) - } + // Get all points this link passes through + const reroutes = this.reroutesEnabled ? LLink.getReroutes(this.graph, link) : [] + const points = [start_node_slotpos, ...reroutes.map(x => x.pos), end_node_slotpos] + + // Bounding box of all points (bezier overshoot on long links will be cut) + const pointsX = points.map(x => x[0]) + const pointsY = points.map(x => x[1]) + LGraphCanvas.#link_bounding[0] = Math.min(...pointsX) + LGraphCanvas.#link_bounding[1] = Math.min(...pointsY) + LGraphCanvas.#link_bounding[2] = Math.max(...pointsX) - LGraphCanvas.#link_bounding[0] + LGraphCanvas.#link_bounding[3] = Math.max(...pointsY) - LGraphCanvas.#link_bounding[1] //skip links outside of the visible area of the canvas if (!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)) continue - const start_slot = start_node.outputs[start_node_slot] + const start_slot = start_node.outputs[outputId] const end_slot = node.inputs[i] if (!start_slot || !end_slot) continue @@ -4988,17 +5062,78 @@ export class LGraphCanvas { const end_dir = end_slot.dir || (node.horizontal ? LinkDirection.UP : LinkDirection.LEFT) - this.renderLink( - ctx, - start_node_slotpos, - end_node_slotpos, - link, - false, - 0, - null, - start_dir, - end_dir - ) + // Has reroutes + if (reroutes.length) { + let startControl: Point + + const l = reroutes.length + for (let j = 0; j < l; j++) { + const reroute = reroutes[j] + + if (!rendered.has(reroute)) { + rendered.add(reroute) + + const prevReroute = this.graph.reroutes.get(reroute.parentId) + const startPos = prevReroute?.pos ?? start_node_slotpos + reroute.calculateAngle(this.last_draw_time, this.graph, startPos) + + this.renderLink( + ctx, + startPos, + reroute.pos, + link, + false, + 0, + null, + start_dir, + end_dir, + { + startControl, + endControl: reroute.controlPoint, + reroute, + }, + ) + } + + // Calculate start control for the next iter control point + const nextPos = reroutes[j + 1]?.pos ?? end_node_slotpos + const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25) + startControl = [dist * reroute.cos, dist * reroute.sin] + } + + // Render final link segment + this.renderLink( + ctx, + points.at(-2), + points.at(-1), + link, + false, + 0, + null, + start_dir, + end_dir, + { startControl }, + ) + + // Render the reroute circles + const defaultColor = LGraphCanvas.link_type_colors[link.type] || this.default_link_color + for (const reroute of reroutes) { + reroute.draw(ctx, link.color || defaultColor) + } + } else { + this.renderLink( + ctx, + start_node_slotpos, + end_node_slotpos, + link, + false, + 0, + null, + start_dir, + end_dir + ) + } + rendered.add(link) //event triggered rendered on top if (link && link._last_time && now - link._last_time < 1000) { @@ -5025,6 +5160,7 @@ export class LGraphCanvas { /** * draws a link between two points + * @param ctx Canvas 2D rendering context * @param {vec2} a start pos * @param {vec2} b end pos * @param {Object} link the link object with all the link info @@ -5034,35 +5170,44 @@ export class LGraphCanvas { * @param {LinkDirection} start_dir the direction enum * @param {LinkDirection} end_dir the direction enum * @param {number} num_sublines number of sublines (useful to represent vec3 or rgb) - **/ - renderLink(ctx: CanvasRenderingContext2D, - a: Point, - b: Point, + */ + renderLink( + ctx: CanvasRenderingContext2D, + a: ReadOnlyPoint, + b: ReadOnlyPoint, link: LLink, skip_border: boolean, flow: number, color: CanvasColour, start_dir: LinkDirection, end_dir: LinkDirection, - num_sublines?: number): void { - - if (link) { - this.visible_links.push(link) - } - - //choose color - if (!color && link) { - color = link.color || LGraphCanvas.link_type_colors[link.type] - } - color ||= this.default_link_color - if (link != null && this.highlighted_links[link.id]) { - color = "#FFF" - } + { + startControl, + endControl, + reroute, + num_sublines = 1 + }: { + /** When defined, render data will be saved to this reroute instead of the {@link link}. */ + reroute?: Reroute + /** Offset of the bezier curve control point from {@link a point a} (output side) */ + startControl?: ReadOnlyPoint + /** Offset of the bezier curve control point from {@link b point b} (input side) */ + endControl?: ReadOnlyPoint + /** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */ + num_sublines?: number + } = {}, + ): void { + if (link) this.visible_links.push(link) - start_dir = start_dir || LinkDirection.RIGHT - end_dir = end_dir || LinkDirection.LEFT + const linkColour = link != null && this.highlighted_links[link.id] + ? "#FFF" + : color || link?.color || LGraphCanvas.link_type_colors[link.type] || this.default_link_color + const startDir = start_dir || LinkDirection.RIGHT + const endDir = end_dir || LinkDirection.LEFT - const dist = distance(a, b) + const dist = this.links_render_mode == LinkRenderType.SPLINE_LINK && (!endControl || !startControl) + ? distance(a, b) + : null // TODO: Subline code below was inserted in the wrong place - should be before this statement if (this.render_connections_border && this.ds.scale > 0.6) { @@ -5070,126 +5215,132 @@ export class LGraphCanvas { } ctx.lineJoin = "round" num_sublines ||= 1 - if (num_sublines > 1) { - ctx.lineWidth = 0.5 - } + if (num_sublines > 1) ctx.lineWidth = 0.5 //begin line shape const path = new Path2D() - if (link) { - // Store the path on the link for hittests - link.path = path - } + + /** The link or reroute we're currently rendering */ + const linkSegment = reroute ?? link + if (linkSegment) linkSegment.path = path + + const innerA = LGraphCanvas.#lTempA + const innerB = LGraphCanvas.#lTempB + + /** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */ + const pos: Point = linkSegment?._pos ?? [0, 0] + for (let i = 0; i < num_sublines; i += 1) { const offsety = (i - (num_sublines - 1) * 0.5) * 5 + innerA[0] = a[0] + innerA[1] = a[1] + innerB[0] = b[0] + innerB[1] = b[1] if (this.links_render_mode == LinkRenderType.SPLINE_LINK) { - path.moveTo(a[0], a[1] + offsety) - let start_offset_x = 0 - let start_offset_y = 0 - let end_offset_x = 0 - let end_offset_y = 0 - switch (start_dir) { - case LinkDirection.LEFT: - start_offset_x = dist * -0.25 - break - case LinkDirection.RIGHT: - start_offset_x = dist * 0.25 - break - case LinkDirection.UP: - start_offset_y = dist * -0.25 - break - case LinkDirection.DOWN: - start_offset_y = dist * 0.25 - break + if (endControl) { + innerB[0] = b[0] + endControl[0] + innerB[1] = b[1] + endControl[1] + } else { + this.#addSplineOffset(innerB, endDir, dist) } - switch (end_dir) { - case LinkDirection.LEFT: - end_offset_x = dist * -0.25 - break - case LinkDirection.RIGHT: - end_offset_x = dist * 0.25 - break - case LinkDirection.UP: - end_offset_y = dist * -0.25 - break - case LinkDirection.DOWN: - end_offset_y = dist * 0.25 - break + if (startControl) { + innerA[0] = a[0] + startControl[0] + innerA[1] = a[1] + startControl[1] + } else { + this.#addSplineOffset(innerA, startDir, dist) } + path.moveTo(a[0], a[1] + offsety) path.bezierCurveTo( - a[0] + start_offset_x, - a[1] + start_offset_y + offsety, - b[0] + end_offset_x, - b[1] + end_offset_y + offsety, + innerA[0], + innerA[1] + offsety, + innerB[0], + innerB[1] + offsety, b[0], b[1] + offsety ) + + // Calculate centre point + findPointOnCurve(pos, a, b, innerA, innerB, 0.5) + + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + const justPastCentre = LGraphCanvas.#lTempC + findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51) + + linkSegment._centreAngle = Math.atan2(justPastCentre[1] - pos[1], justPastCentre[0] - pos[0]) + } } else if (this.links_render_mode == LinkRenderType.LINEAR_LINK) { - path.moveTo(a[0], a[1] + offsety) - let start_offset_x = 0 - let start_offset_y = 0 - let end_offset_x = 0 - let end_offset_y = 0 - switch (start_dir) { + const l = 15 + switch (startDir) { case LinkDirection.LEFT: - start_offset_x = -1 + innerA[0] += -l break case LinkDirection.RIGHT: - start_offset_x = 1 + innerA[0] += l break case LinkDirection.UP: - start_offset_y = -1 + innerA[1] += -l break case LinkDirection.DOWN: - start_offset_y = 1 + innerA[1] += l break } - switch (end_dir) { + switch (endDir) { case LinkDirection.LEFT: - end_offset_x = -1 + innerB[0] += -l break case LinkDirection.RIGHT: - end_offset_x = 1 + innerB[0] += l break case LinkDirection.UP: - end_offset_y = -1 + innerB[1] += -l break case LinkDirection.DOWN: - end_offset_y = 1 + innerB[1] += l break } - const l = 15 - path.lineTo( - a[0] + start_offset_x * l, - a[1] + start_offset_y * l + offsety - ) - path.lineTo( - b[0] + end_offset_x * l, - b[1] + end_offset_y * l + offsety - ) + path.moveTo(a[0], a[1] + offsety) + path.lineTo(innerA[0], innerA[1] + offsety) + path.lineTo(innerB[0], innerB[1] + offsety) path.lineTo(b[0], b[1] + offsety) + + // Calculate centre point + pos[0] = (innerA[0] + innerB[0]) * 0.5 + pos[1] = (innerA[1] + innerB[1]) * 0.5 + + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + linkSegment._centreAngle = Math.atan2(innerB[1] - innerA[1], innerB[0] - innerA[0]) + } } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) { - path.moveTo(a[0], a[1]) - let start_x = a[0] - let start_y = a[1] - let end_x = b[0] - let end_y = b[1] - if (start_dir == LinkDirection.RIGHT) { - start_x += 10 + if (startDir == LinkDirection.RIGHT) { + innerA[0] += 10 } else { - start_y += 10 + innerA[1] += 10 } - if (end_dir == LinkDirection.LEFT) { - end_x -= 10 + if (endDir == LinkDirection.LEFT) { + innerB[0] -= 10 } else { - end_y -= 10 + innerB[1] -= 10 } - path.lineTo(start_x, start_y) - path.lineTo((start_x + end_x) * 0.5, start_y) - path.lineTo((start_x + end_x) * 0.5, end_y) - path.lineTo(end_x, end_y) + const midX = (innerA[0] + innerB[0]) * 0.5 + + path.moveTo(a[0], a[1]) + path.lineTo(innerA[0], innerA[1]) + path.lineTo(midX, innerA[1]) + path.lineTo(midX, innerB[1]) + path.lineTo(innerB[0], innerB[1]) path.lineTo(b[0], b[1]) + + // Calculate centre point + pos[0] = midX + pos[1] = (innerA[1] + innerB[1]) * 0.5 + + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + const diff = innerB[1] - innerA[1] + if (Math.abs(diff) < 4) linkSegment._centreAngle = 0 + else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5 + else linkSegment._centreAngle = -(Math.PI * 0.5) + } } else { return } //unknown @@ -5204,19 +5355,15 @@ export class LGraphCanvas { } ctx.lineWidth = this.connections_width - ctx.fillStyle = ctx.strokeStyle = color + ctx.fillStyle = ctx.strokeStyle = linkColour ctx.stroke(path) - //end line shape - const pos = this.computeConnectionPoint(a, b, 0.5, start_dir, end_dir) - if (link?._pos) { - link._pos[0] = pos[0] - link._pos[1] = pos[1] - } //render arrow in the middle if (this.ds.scale >= 0.6 && this.highquality_render && - end_dir != LinkDirection.CENTER) { + linkSegment && + // TODO: Re-assess this usage - likely a workaround that linkSegment truthy check resolves + endDir != LinkDirection.CENTER) { //render arrow if (this.render_connection_arrows) { //compute two points in the connection @@ -5224,29 +5371,29 @@ export class LGraphCanvas { a, b, 0.25, - start_dir, - end_dir + startDir, + endDir ) const posB = this.computeConnectionPoint( a, b, 0.26, - start_dir, - end_dir + startDir, + endDir ) const posC = this.computeConnectionPoint( a, b, 0.75, - start_dir, - end_dir + startDir, + endDir ) const posD = this.computeConnectionPoint( a, b, 0.76, - start_dir, - end_dir + startDir, + endDir ) //compute the angle between them so the arrow points in the right direction @@ -5280,23 +5427,35 @@ export class LGraphCanvas { ctx.setTransform(transform) } - //circle + // Draw link centre marker ctx.beginPath() - ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) + if (this.linkMarkerShape === LinkMarkerShape.Arrow) { + const transform = ctx.getTransform() + ctx.translate(pos[0], pos[1]) + ctx.rotate(linkSegment._centreAngle) + // The math is off, but it currently looks better in chromium + ctx.moveTo(-3.2, -5) + ctx.lineTo(+7, 0) + ctx.lineTo(-3.2, +5) + ctx.fill() + ctx.setTransform(transform) + } else if (this.linkMarkerShape == null || this.linkMarkerShape === LinkMarkerShape.Circle) { + ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) + } ctx.fill() } //render flowing points if (flow) { - ctx.fillStyle = color + ctx.fillStyle = linkColour for (let i = 0; i < 5; ++i) { const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 const flowPos = this.computeConnectionPoint( a, b, f, - start_dir, - end_dir + startDir, + endDir ) ctx.beginPath() ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) @@ -5948,44 +6107,49 @@ export class LGraphCanvas { boundaryNodesForSelection(): NullableProperties { return LGraphCanvas.getBoundaryNodes(this.selected_nodes) } - showLinkMenu(link: LLink, e: CanvasMouseEvent): boolean { - const graph = this.graph - const node_left = graph.getNodeById(link.origin_id) - const node_right = graph.getNodeById(link.target_id) - // TODO: Replace ternary with ?? "" - const fromType = node_left?.outputs?.[link.origin_slot] - ? node_left.outputs[link.origin_slot].type - : false - const destType = node_right?.outputs?.[link.target_slot] - ? node_right.inputs[link.target_slot].type - : false + + showLinkMenu(segment: LinkSegment, e: CanvasMouseEvent): boolean { + const { graph } = this + const node_left = graph.getNodeById(segment.origin_id) + const fromType = node_left?.outputs?.[segment.origin_slot]?.type ?? "*" const options = ["Add Node", null, "Delete", null] + if (this.reroutesEnabled) options.splice(1, 0, "Add Reroute") + const title = "data" in segment && segment.data != null + ? segment.data.constructor.name + : null const menu = new LiteGraph.ContextMenu(options, { event: e, - title: link.data != null ? link.data.constructor.name : null, - callback: inner_clicked + title, + callback: inner_clicked.bind(this) }) - function inner_clicked(v: string, options: unknown, e: MouseEvent) { + function inner_clicked(this: LGraphCanvas, v: string, options: unknown, e: MouseEvent) { switch (v) { case "Add Node": LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { if (!node.inputs?.length || !node.outputs?.length) return // leave the connection type checking inside connectByType - // @ts-expect-error Assigning from check to false results in the type being treated as "*". This should fail. - if (node_left.connectByType(link.origin_slot, node, fromType)) { - // @ts-expect-error Assigning from check to false results in the type being treated as "*". This should fail. - node.connectByType(link.target_slot, node_right, destType) + const options = this.reroutesEnabled + ? { afterRerouteId: segment.parentId } + : undefined + if (node_left.connectByType(segment.origin_slot, node, fromType, options)) { node.pos[0] -= node.size[0] * 0.5 } }) break + case "Add Reroute": { + this.adjustMouseEvent(e) + graph.createReroute([e.canvasX, e.canvasY], segment) + this.setDirty(false, true) + break + } + case "Delete": - graph.removeLink(link.id) + graph.removeLink(segment.id) break default: } @@ -5993,6 +6157,7 @@ export class LGraphCanvas { return false } + createDefaultNodeForSlot(optPass: ICreateNodeOptions): boolean { const opts = Object.assign({ nodeFrom: null, @@ -6004,6 +6169,7 @@ export class LGraphCanvas { 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 @@ -6110,9 +6276,9 @@ export class LGraphCanvas { // connect the two! if (isFrom) { - opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType) + opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType, { afterRerouteId }) } else { - opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType) + opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType, { afterRerouteId }) } // if connecting in between @@ -6138,6 +6304,7 @@ export class LGraphCanvas { showSearchBox: this.showSearchBox, }, optPass || {}) const that = this + const { afterRerouteId } = opts const isFrom = opts.nodeFrom && opts.slotFrom const isTo = !isFrom && opts.nodeTo && opts.slotTo @@ -6203,9 +6370,9 @@ export class LGraphCanvas { case "Add Node": LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { if (isFrom) { - opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType) + opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId }) } else { - opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType) + opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId }) } }) break @@ -6221,7 +6388,8 @@ export class LGraphCanvas { // eslint-disable-next-line @typescript-eslint/no-unused-vars const nodeCreated = that.createDefaultNodeForSlot(Object.assign(opts, { position: [opts.e.canvasX, opts.e.canvasY], - nodeType: v + nodeType: v, + afterRerouteId, })) break } @@ -7760,6 +7928,18 @@ export class LGraphCanvas { menu_info = this.getNodeMenuOptions(node) } else { menu_info = this.getCanvasMenuOptions() + + // Check for reroutes + if (this.reroutesEnabled) { + const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY) + if (reroute) { + menu_info.unshift({ + content: "Delete Reroute", + callback: () => this.graph.removeReroute(reroute.id) + }, null) + } + } + const group = this.graph.getGroupOnPos( event.canvasX, event.canvasY diff --git a/src/LGraphGroup.ts b/src/LGraphGroup.ts index 28477fee..11d709f4 100644 --- a/src/LGraphGroup.ts +++ b/src/LGraphGroup.ts @@ -3,7 +3,7 @@ import type { LGraph } from "./LGraph" import type { ISerialisedGroup } from "./types/serialisation" import { LiteGraph } from "./litegraph" import { LGraphCanvas } from "./LGraphCanvas" -import { isInsideRectangle, containsCentre, containsRect, createBounds } from "./measure" +import { containsCentre, containsRect, isInsideRectangle, isPointInRectangle, createBounds } from "./measure" import { LGraphNode } from "./LGraphNode" import { RenderShape, TitleMode } from "./types/globalEnums" @@ -194,21 +194,26 @@ export class LGraphGroup implements Positionable, IPinnable { } recomputeInsideNodes(): void { - const { nodes, groups } = this.graph + const { nodes, reroutes, groups } = this.graph const children = this._children - const node_bounding = new Float32Array(4) this._nodes.length = 0 children.clear() - // move any nodes we partially overlap + // Move nodes we overlap the centre point of for (const node of nodes) { - node.getBounding(node_bounding) - if (containsCentre(this._bounding, node_bounding)) { + if (containsCentre(this._bounding, node.boundingRect)) { this._nodes.push(node) children.add(node) } } + // Move reroutes we overlap the centre point of + for (const reroute of reroutes.values()) { + if (isPointInRectangle(reroute.pos, this._bounding)) + children.add(reroute) + } + + // Move groups we wholly contain for (const group of groups) { if (containsRect(this._bounding, group._bounding)) children.add(group) @@ -283,7 +288,7 @@ export class LGraphGroup implements Positionable, IPinnable { } isInResize(x: number, y: number): boolean { - const b = this._bounding + const b = this.boundingRect const right = b[0] + b[2] const bottom = b[1] + b[3] diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index c2620dd2..69084ab0 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -5,6 +5,7 @@ import type { ISerialisedNode } from "./types/serialisation" import type { LGraphCanvas } from "./LGraphCanvas" import type { CanvasMouseEvent } from "./types/events" import type { DragAndScale } from "./DragAndScale" +import type { Reroute, RerouteId } from "./Reroute" import { LGraphEventMode, NodeSlotType, TitleMode, RenderShape } from "./types/globalEnums" import { BadgePosition, LGraphBadge } from "./LGraphBadge" import { type LGraphNodeConstructor, LiteGraph } from "./litegraph" @@ -36,6 +37,8 @@ interface ConnectByTypeOptions { wildcardToTyped?: boolean /** Allow our typed slot to connect to wildcard slots on remote node. Default: true */ typedToWildcard?: boolean + /** The {@link Reroute.id} that the connection is being dragged from. */ + afterRerouteId?: RerouteId } /** Internal type used for type safety when implementing generic checks for inputs & outputs */ @@ -1291,7 +1294,7 @@ export class LGraphNode implements Positionable, IPinnable { if (this.widgets?.length) { for (let i = 0, l = this.widgets.length; i < l; ++i) { const widget = this.widgets[i] - if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue; + if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue widgets_height += widget.computeSize ? widget.computeSize(size[0])[1] + 4 @@ -1795,7 +1798,7 @@ export class LGraphNode implements Positionable, IPinnable { */ connectByType(slot: number | string, target_node: LGraphNode, target_slotType: ISlotType, optsIn?: ConnectByTypeOptions): LLink | null { const slotIndex = this.findConnectByTypeSlot(true, target_node, target_slotType, optsIn) - if (slotIndex !== null) return this.connect(slot, target_node, slotIndex) + if (slotIndex !== null) return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId) console.debug("[connectByType]: no way to connect type: ", target_slotType, " to node: ", target_node) return null @@ -1816,7 +1819,7 @@ export class LGraphNode implements Positionable, IPinnable { if ("generalTypeInCase" in optsIn) optsIn.typedToWildcard = !!optsIn.generalTypeInCase } const slotIndex = this.findConnectByTypeSlot(false, source_node, source_slotType, optsIn) - if (slotIndex !== null) return source_node.connect(slotIndex, this, slot) + if (slotIndex !== null) return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId) console.debug("[connectByType]: no way to connect type: ", source_slotType, " to node: ", source_node) return null @@ -1829,7 +1832,7 @@ export class LGraphNode implements Positionable, IPinnable { * @param {number | string} target_slot the input slot of the target node (could be the number of the slot or the string with the name of the slot, or -1 to connect a trigger) * @return {Object} the link_info is created, otherwise null */ - connect(slot: number | string, target_node: LGraphNode, target_slot: ISlotType): LLink | null { + connect(slot: number | string, target_node: LGraphNode, target_slot: ISlotType, afterRerouteId?: RerouteId): LLink | null { // Allow legacy API support for searching target_slot by string, without mutating the input variables let targetIndex: number @@ -1919,7 +1922,7 @@ export class LGraphNode implements Positionable, IPinnable { //if there is something already plugged there, disconnect if (target_node.inputs[targetIndex]?.link != null) { graph.beforeChange() - target_node.disconnectInput(targetIndex) + target_node.disconnectInput(targetIndex, true) changed = true } if (output.links?.length) { @@ -1942,7 +1945,8 @@ export class LGraphNode implements Positionable, IPinnable { this.id, slot, target_node.id, - targetIndex + targetIndex, + afterRerouteId ) //add to graph links list @@ -1953,6 +1957,10 @@ export class LGraphNode implements Positionable, IPinnable { output.links.push(link_info.id) //connect in input target_node.inputs[targetIndex].link = link_info.id + + // Reroutes + LLink.getReroutes(graph, link_info) + .forEach(x => x?.linkIds.add(nextId)) graph._version++ //link_info has been created now, so its updated @@ -2108,9 +2116,10 @@ export class LGraphNode implements Positionable, IPinnable { /** * Disconnect one input * @param slot Input slot index, or the name of the slot + * @param keepReroutes If `true`, reroutes will not be garbage collected. * @return true if disconnected successfully or already disconnected, otherwise false */ - disconnectInput(slot: number | string): boolean { + disconnectInput(slot: number | string, keepReroutes?: boolean): boolean { // Allow search by string if (typeof slot === "string") { slot = this.findInputSlot(slot) @@ -2150,7 +2159,7 @@ export class LGraphNode implements Positionable, IPinnable { } } - this.graph._links.delete(link_id) + link_info.disconnect(this.graph, keepReroutes) if (this.graph) this.graph._version++ this.onConnectionsChange?.( @@ -2453,8 +2462,7 @@ export class LGraphNode implements Positionable, IPinnable { const outNode = graph.getNodeById(outLink.target_id) if (!outNode) return - // TODO: Add 4th param (afterRerouteId: inLink.parentId) when reroutes are merged. - const result = inNode.connect(inLink.origin_slot, outNode, outLink.target_slot) + const result = inNode.connect(inLink.origin_slot, outNode, outLink.target_slot, inLink.parentId) madeAnyConnections ||= !!result } } diff --git a/src/LLink.ts b/src/LLink.ts index 9e135902..56b56941 100644 --- a/src/LLink.ts +++ b/src/LLink.ts @@ -1,15 +1,17 @@ -import type { CanvasColour, ISlotType } from "./interfaces" +import type { CanvasColour, LinkNetwork, ISlotType, LinkSegment } from "./interfaces" import type { NodeId } from "./LGraphNode" +import type { Reroute, RerouteId } from "./Reroute" import type { Serialisable, SerialisableLLink } from "./types/serialisation" export type LinkId = number -export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType] +export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType] //this is the class in charge of storing link information -export class LLink implements Serialisable { +export class LLink implements LinkSegment, Serialisable { /** Link ID */ id: LinkId + parentId?: RerouteId type: ISlotType /** Output node ID */ origin_id: NodeId @@ -19,6 +21,7 @@ export class LLink implements Serialisable { target_id: NodeId /** Input slot index */ target_slot: number + data?: number | string | boolean | { toToolTip?(): string } _data?: unknown /** Centre point of the link, calculated during render only - can be inaccurate */ @@ -27,6 +30,8 @@ export class LLink implements Serialisable { _last_time?: number /** The last canvas 2D path that was used to render this link */ path?: Path2D + /** @inheritdoc */ + _centreAngle?: number #color?: CanvasColour /** Custom colour for this link only */ @@ -35,13 +40,14 @@ export class LLink implements Serialisable { this.#color = value === "" ? null : value } - constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number) { + constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, parentId?: RerouteId) { this.id = id this.type = type this.origin_id = origin_id this.origin_slot = origin_slot this.target_id = target_id this.target_slot = target_slot + this.parentId = parentId this._data = null this._pos = new Float32Array(2) //center @@ -58,7 +64,28 @@ export class LLink implements Serialisable { * @returns A new LLink */ static create(data: SerialisableLLink): LLink { - return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot) + return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot, data.parentId) + } + + /** + * Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element. + * @returns An ordered array of all reroutes from the node output to this reroute or the reroute before it. Otherwise, an empty array. + */ + static getReroutes(network: LinkNetwork, linkSegment: LinkSegment): Reroute[] { + return network.reroutes.get(linkSegment.parentId) + ?.getReroutes() ?? [] + } + + /** + * Finds the reroute in the chain after the provided reroute ID. + * @param network The network this link belongs to + * @param linkSegment The starting point of the search (input side). Typically the LLink object itself, but can be any link segment. + * @param rerouteId The matching reroute will have this set as its {@link parentId}. + * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. + */ + static findNextReroute(network: LinkNetwork, linkSegment: LinkSegment, rerouteId: RerouteId): Reroute | null | undefined { + return network.reroutes.get(linkSegment.parentId) + ?.findNextReroute(rerouteId) } configure(o: LLink | SerialisedLLinkArray) { @@ -76,7 +103,23 @@ export class LLink implements Serialisable { this.origin_slot = o.origin_slot this.target_id = o.target_id this.target_slot = o.target_slot + this.parentId = o.parentId + } + } + + /** + * Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used + * @param network The container (LGraph) where reroutes should be updated + * @param keepReroutes If `true`, reroutes will not be garbage collected. + */ + disconnect(network: LinkNetwork, keepReroutes?: boolean): void { + const reroutes = LLink.getReroutes(network, this) + + for (const reroute of reroutes) { + reroute.linkIds.delete(this.id) + if (!keepReroutes && !reroute.linkIds.size) network.reroutes.delete(reroute.id) } + network.links.delete(this.id) } /** @@ -103,6 +146,7 @@ export class LLink implements Serialisable { target_slot: this.target_slot, type: this.type } + if (this.parentId) copy.parentId = this.parentId return copy } } diff --git a/src/Reroute.ts b/src/Reroute.ts new file mode 100644 index 00000000..10cb5f5d --- /dev/null +++ b/src/Reroute.ts @@ -0,0 +1,279 @@ +import type { CanvasColour, LinkSegment, LinkNetwork, Point, Positionable, ReadOnlyRect } from "./interfaces" +import { LLink, type LinkId } from "./LLink" +import type { SerialisableReroute, Serialisable } from "./types/serialisation" +import { distance } from "./measure" +import type { NodeId } from "./LGraphNode" + +export type RerouteId = number + +/** + * Represents an additional point on the graph that a link path will travel through. Used for visual organisation only. + * + * Requires no disposal or clean up. + * Stores only primitive values (IDs) to reference other items in its network, and a `WeakRef` to a {@link LinkNetwork} to resolve them. + */ +export class Reroute implements Positionable, LinkSegment, Serialisable { + static radius: number = 10 + + #malloc = new Float32Array(8) + + /** The network this reroute belongs to. Contains all valid links and reroutes. */ + #network: WeakRef + + #parentId?: RerouteId + /** @inheritdoc */ + public get parentId(): RerouteId { + return this.#parentId + } + /** Ignores attempts to create an infinite loop. @inheritdoc */ + public set parentId(value: RerouteId) { + if (value === this.id) return + if (this.getReroutes() === null) return + this.#parentId = value + } + + #pos = this.#malloc.subarray(0, 2) + /** @inheritdoc */ + get pos(): Point { + return this.#pos + } + set pos(value: Point) { + if (!(value?.length >= 2)) throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.") + this.#pos[0] = value[0] + this.#pos[1] = value[1] + } + + /** @inheritdoc */ + get boundingRect(): ReadOnlyRect { + const { radius } = Reroute + const [x, y] = this.#pos + return [x - radius, y - radius, 2 * radius, 2 * radius] + } + + /** @inheritdoc */ + selected?: boolean + + /** The ID ({@link LLink.id}) of every link using this reroute */ + linkIds: Set + + /** The averaged angle of every link through this reroute. */ + otherAngle: number = 0 + + /** Cached cos */ + cos: number = 0 + sin: number = 0 + + /** Bezier curve control point for the "target" (input) side of the link */ + controlPoint: Point = this.#malloc.subarray(4, 6) + + /** @inheritdoc */ + path?: Path2D + /** @inheritdoc */ + _centreAngle?: number + /** @inheritdoc */ + _pos: Float32Array = this.#malloc.subarray(6, 8) + + /** + * Used to ensure reroute angles are only executed once per frame. + * @todo Calculate on change instead. + */ + #lastRenderTime: number = -Infinity + #buffer: Point = this.#malloc.subarray(2, 4) + + /** @inheritdoc */ + get origin_id(): NodeId | undefined { + // if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId) + return this.#network.deref() + ?.links.get(this.linkIds.values().next().value) + ?.origin_id + } + + /** @inheritdoc */ + get origin_slot(): number | undefined { + return this.#network.deref() + ?.links.get(this.linkIds.values().next().value) + ?.origin_slot + } + + /** + * Initialises a new link reroute object. + * @param id Unique identifier for this reroute + * @param network The network of links this reroute belongs to. Internally converted to a WeakRef. + * @param pos Position in graph coordinates + * @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute + */ + constructor( + public readonly id: RerouteId, + network: LinkNetwork, + pos?: Point, + parentId?: RerouteId, + linkIds?: Iterable + ) { + this.#network = new WeakRef(network) + this.update(parentId, pos, linkIds) + this.linkIds ??= new Set() + } + + /** + * Applies a new parentId to the reroute, and optinoally a new position and linkId. + * Primarily used for deserialisation. + * @param parentId The ID of the reroute prior to this reroute, or `undefined` if it is the first reroute connected to a nodes output + * @param pos The position of this reroute + * @param linkIds All link IDs that pass through this reroute + */ + update(parentId: RerouteId | undefined, pos?: Point, linkIds?: Iterable): void { + this.parentId = parentId + if (pos) this.pos = pos + if (linkIds) this.linkIds = new Set(linkIds) + } + + /** + * Validates the linkIds this reroute has. Removes broken links. + * @param links Collection of valid links + * @returns true if any links remain after validation + */ + validateLinks(links: Map): boolean { + const { linkIds } = this + for (const linkId of linkIds) { + if (!links.get(linkId)) linkIds.delete(linkId) + } + return linkIds.size > 0 + } + + /** + * Retrieves an ordered array of all reroutes from the node output. + * @param visited Internal. A set of reroutes that this function has already visited whilst recursing up the chain. + * @returns An ordered array of all reroutes from the node output to this reroute, inclusive. + * `null` if an infinite loop is detected. + * `undefined` if the reroute chain or {@link LinkNetwork} are invalid. + */ + getReroutes(visited = new Set()): Reroute[] | null | undefined { + // No parentId - last in the chain + if (this.#parentId === undefined) return [this] + // Invalid chain - looped + if (visited.has(this)) return null + visited.add(this) + + const parent = this.#network.deref()?.reroutes.get(this.#parentId) + // Invalid parent (or network) - drop silently to recover + if (!parent) { + this.#parentId = undefined + return [this] + } + + const reroutes = parent.getReroutes(visited) + reroutes?.push(this) + return reroutes + } + + /** + * Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself. + * @param withParentId The rerouteId to look for + * @param visited A set of reroutes that have already been visited + * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. + */ + findNextReroute(withParentId: RerouteId, visited = new Set()): Reroute | null | undefined { + if (this.#parentId === withParentId) return this + if (visited.has(this)) return null + visited.add(this) + + return this.#network.deref() + ?.reroutes.get(this.#parentId) + ?.findNextReroute(withParentId, visited) + } + + /** @inheritdoc */ + move(deltaX: number, deltaY: number) { + this.#pos[0] += deltaX + this.#pos[1] += deltaY + } + + calculateAngle(lastRenderTime: number, network: LinkNetwork, linkStart: Point): void { + // Ensure we run once per render + if (!(lastRenderTime > this.#lastRenderTime)) return + this.#lastRenderTime = lastRenderTime + + const { links } = network + const { linkIds, id } = this + const angles: number[] = [] + let sum = 0 + for (const linkId of linkIds) { + const link = links.get(linkId) + // Remove the linkId or just ignore? + if (!link) continue + + const pos = LLink.findNextReroute(network, link, id)?.pos ?? + network.getNodeById(link.target_id) + ?.getConnectionPos(true, link.target_slot, this.#buffer) + if (!pos) continue + + // TODO: Store points/angles, check if changed, skip calcs. + const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0]) + angles.push(angle) + sum += angle + } + if (!angles.length) return + + sum /= angles.length + + const originToReroute = Math.atan2(this.#pos[1] - linkStart[1], this.#pos[0] - linkStart[0]) + let diff = (originToReroute - sum) * 0.5 + if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI + const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25) + + // Store results + const originDiff = originToReroute - diff + const cos = Math.cos(originDiff) + const sin = Math.sin(originDiff) + + this.otherAngle = originDiff + this.cos = cos + this.sin = sin + this.controlPoint[0] = dist * -cos + this.controlPoint[1] = dist * -sin + return + } + + /** + * Renders the reroute on the canvas. + * @param ctx Canvas context to draw on + * @param colour Reroute colour (typically link colour) + * + * @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.). + */ + draw(ctx: CanvasRenderingContext2D, colour: CanvasColour): void { + const { pos } = this + ctx.fillStyle = colour + ctx.beginPath() + ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI) + ctx.fill() + + ctx.lineWidth = 1 + ctx.strokeStyle = "rgb(0,0,0,0.5)" + ctx.stroke() + + ctx.fillStyle = "#ffffff55" + ctx.strokeStyle = "rgb(0,0,0,0.3)" + ctx.beginPath() + ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + + if (this.selected) { + ctx.strokeStyle = "#fff" + ctx.beginPath() + ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI) + ctx.stroke() + } + } + + /** @inheritdoc */ + asSerialisable(): SerialisableReroute { + return { + id: this.id, + parentId: this.parentId, + pos: [this.pos[0], this.pos[1]], + linkIds: [...this.linkIds] + } + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts index b0770c9a..afe01e04 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,7 +1,8 @@ import type { ContextMenu } from "./ContextMenu" import type { LGraphNode, NodeId } from "./LGraphNode" import type { LinkDirection, RenderShape } from "./types/globalEnums" -import type { LinkId } from "./LLink" +import type { LinkId, LLink } from "./LLink" +import type { Reroute, RerouteId } from "./Reroute" export type Dictionary = { [key: string]: T } @@ -12,13 +13,19 @@ export type NullableProperties = { export type CanvasColour = string | CanvasGradient | CanvasPattern +/** An object containing a set of child objects */ +export interface Parent { + /** All objects owned by the parent object. */ + readonly children?: ReadonlySet +} + /** * An object that can be positioned, selected, and moved. * * May contain other {@link Positionable} objects. */ -export interface Positionable { - id: NodeId | number +export interface Positionable extends Parent { + id: NodeId | RerouteId | number /** Position in graph coordinates. Default: 0,0 */ pos: Point /** true if this object is part of the selection, otherwise false. */ @@ -27,8 +34,6 @@ export interface Positionable { /** See {@link IPinnable.pinned} */ readonly pinned?: boolean - readonly children?: ReadonlySet - /** * Adds a delta to the current position. * @param deltaX X value to add to current position @@ -60,6 +65,35 @@ export interface IPinnable { unpin(): void } +/** + * Contains a list of links, reroutes, and nodes. + */ +export interface LinkNetwork { + links: Map + reroutes: Map + getNodeById(id: NodeId): LGraphNode | null +} + +/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */ +export interface LinkSegment { + /** Link / reroute ID */ + readonly id: LinkId | RerouteId + /** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */ + readonly parentId?: RerouteId + + /** The last canvas 2D path that was used to render this segment */ + path?: Path2D + /** Centre point of the {@link path}. Calculated during render only - can be inaccurate */ + readonly _pos: Float32Array + /** Y-forward along the {@link path} from its centre point, in radians. `undefined` if using circles for link centres. Calculated during render only - can be inaccurate. */ + _centreAngle?: number + + /** Output node ID */ + readonly origin_id: NodeId + /** Output slot index */ + readonly origin_slot: number +} + export interface IInputOrOutput { // If an input, this will be defined input?: INodeInputSlot @@ -167,6 +201,7 @@ export interface ConnectingLink extends IInputOrOutput { slot: number pos: Point direction?: LinkDirection + afterRerouteId?: RerouteId } interface IContextMenuBase { diff --git a/src/litegraph.ts b/src/litegraph.ts index 36bf75dc..148069a4 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -23,7 +23,7 @@ export { INodeSlot, INodeInputSlot, INodeOutputSlot, ConnectingLink, CanvasColou export { IWidget } export { LGraphBadge, BadgePosition } export { SlotShape, LabelPosition, SlotDirection, SlotType } -export { EaseFunction } from "./types/globalEnums" +export { EaseFunction, LinkMarkerShape } from "./types/globalEnums" export type { SerialisableGraph, SerialisableLLink } from "./types/serialisation" export { createBounds } from "./measure" diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index 266201c1..3af4e50e 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -37,6 +37,16 @@ export enum LinkRenderType { SPLINE_LINK = 2, } +/** The marker in the middle of a link */ +export enum LinkMarkerShape { + /** Do not display markers */ + None = 0, + /** Circles (default) */ + Circle = 1, + /** Directional arrows */ + Arrow = 2, +} + export enum TitleMode { NORMAL_TITLE = 0, NO_TITLE = 1, diff --git a/src/types/serialisation.ts b/src/types/serialisation.ts index 6b828dbe..071cd2a4 100644 --- a/src/types/serialisation.ts +++ b/src/types/serialisation.ts @@ -1,11 +1,12 @@ -import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Rect, Size } from "../interfaces" +import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Size } from "../interfaces" import type { LGraph, LGraphState } from "../LGraph" import type { IGraphGroupFlags, LGraphGroup } from "../LGraphGroup" import type { LGraphNode, NodeId } from "../LGraphNode" import type { LiteGraph } from "../litegraph" import type { LinkId, LLink } from "../LLink" +import type { RerouteId } from "../Reroute" import type { TWidgetValue } from "../types/widgets" -import { RenderShape } from "./globalEnums" +import type { RenderShape } from "./globalEnums" /** * An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}. @@ -27,6 +28,7 @@ export interface SerialisableGraph { groups?: ISerialisedGroup[] nodes?: ISerialisedNode[] links?: SerialisableLLink[] + reroutes?: SerialisableReroute[] extra?: Record } @@ -67,7 +69,7 @@ export type ISerialisedGraph< groups: TGroup[] config: LGraph["config"] version: typeof LiteGraph.VERSION - extra?: unknown + extra?: Record } /** Serialised LGraphGroup */ @@ -87,6 +89,14 @@ export interface IClipboardContents { nodes?: ISerialisedNode[] links?: TClipboardLink[] } + +export interface SerialisableReroute { + id: RerouteId + parentId?: RerouteId + pos: Point + linkIds: LinkId[] +} + export interface SerialisableLLink { /** Link ID */ id: LinkId @@ -100,4 +110,6 @@ export interface SerialisableLLink { target_slot: number /** Data type of the link */ type: ISlotType + /** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */ + parentId?: RerouteId } diff --git a/src/utils/collections.ts b/src/utils/collections.ts new file mode 100644 index 00000000..b2e44206 --- /dev/null +++ b/src/utils/collections.ts @@ -0,0 +1,18 @@ +import type { Parent } from "../interfaces" + +/** + * Creates a flat set of all items by recursively iterating through all child items. + * @param items The original set of items to iterate through + * @returns All items in the original set, and recursively, their children + */ +export function getAllNestedItems>(items: ReadonlySet): Set { + const allItems = new Set() + items?.forEach(x => addRecursively(x, allItems)) + return allItems + + function addRecursively(item: TParent, flatSet: Set): void { + if (flatSet.has(item)) return + flatSet.add(item) + item.children?.forEach(x => addRecursively(x, flatSet)) + } +}