From 5c41e4e09cbbd22829ce91a312581532cacbd595 Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Sun, 27 Apr 2025 03:00:01 +1000 Subject: [PATCH] Add virtual slots to Reroutes (#970) ### Virtual helper "slots" Adds a virtual input and output slot to native reroutes, allowing links to be dragged from them to other reroutes or nodes. https://github.com/user-attachments/assets/67d308c4-4732-4b04-a2b9-0a2b0c79b413 ### Notes - Reroute slots automatically show an outline as the pointer gets close - When the slot is clickable, it will highlight in the same colour as the reroute - Enables opposite direction connecting: from reroute to node outputs - Floating reroutes only show one slot - to whichever side is not connected --- src/LGraph.ts | 10 +- src/LGraphCanvas.ts | 103 +++++++++---- src/Reroute.ts | 203 +++++++++++++++++++++++++- src/canvas/LinkConnector.ts | 123 +++++++++++----- src/canvas/ToOutputFromRerouteLink.ts | 31 ++++ src/types/globalEnums.ts | 2 + 6 files changed, 399 insertions(+), 73 deletions(-) create mode 100644 src/canvas/ToOutputFromRerouteLink.ts diff --git a/src/LGraph.ts b/src/LGraph.ts index e6cd811fd..7bef76e64 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -24,7 +24,6 @@ import { LGraphNode, type NodeId } from "./LGraphNode" import { LiteGraph } from "./litegraph" import { type LinkId, LLink } from "./LLink" import { MapProxyHandler } from "./MapProxyHandler" -import { isSortaInsideOctagon } from "./measure" import { Reroute, RerouteId } from "./Reroute" import { stringOrEmpty } from "./strings" import { LGraphEventMode } from "./types/globalEnums" @@ -1001,12 +1000,9 @@ export class LGraph implements LinkNetwork, Serialisable { * @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 - - if (isSortaInsideOctagon(x - pos[0], y - pos[1], 2 * Reroute.radius)) - return reroute + getRerouteOnPos(x: number, y: number, reroutes?: Iterable): Reroute | undefined { + for (const reroute of reroutes ?? this.reroutes.values()) { + if (reroute.containsPoint([x, y])) return reroute } } diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 579118413..10cc2f6cd 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -272,6 +272,10 @@ export class LGraphCanvas implements ConnectionColorContext { cursor = "se-resize" } else if (this.state.hoveringOver & CanvasItem.Node) { cursor = "crosshair" + } else if (this.state.hoveringOver & CanvasItem.Reroute) { + cursor = "grab" + } else if (this.state.hoveringOver & CanvasItem.RerouteSlot) { + cursor = "crosshair" } this.canvas.style.cursor = cursor @@ -488,6 +492,8 @@ export class LGraphCanvas implements ConnectionColorContext { node_capturing_input?: LGraphNode | null highlighted_links: Dictionary = {} + #visibleReroutes: Set = new Set() + dirty_canvas: boolean = true dirty_bgcanvas: boolean = true /** A map of nodes that require selective-redraw */ @@ -1907,7 +1913,7 @@ export class LGraphCanvas implements ConnectionColorContext { this.processSelect(node, e, true) } else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { // Reroutes - const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY) + const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes) if (reroute) { if (e.altKey) { pointer.onClick = (upEvent) => { @@ -1970,7 +1976,7 @@ export class LGraphCanvas implements ConnectionColorContext { pointer.onClick = (eUp) => { // Click, not drag const clickedItem = node ?? - graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) ?? + graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY, this.#visibleReroutes) ?? graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY) this.processSelect(clickedItem, eUp) } @@ -2020,20 +2026,30 @@ export class LGraphCanvas implements ConnectionColorContext { } else { // Reroutes if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { - const reroute = graph.getRerouteOnPos(x, y) - if (reroute) { - if (e.shiftKey) { + for (const reroute of this.#visibleReroutes) { + const overReroute = 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() - - this.dirty_bgcanvas = true } - pointer.onClick = () => this.processSelect(reroute, e) - if (!pointer.onDragEnd) { - pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true) - pointer.onDragEnd = e => this.#processDraggedItems(e) + if (reroute.isInputHovered) { + linkConnector.dragFromRerouteToOutput(graph, reroute) + this.#linkConnectorDrop() } + + reroute.hideSlots() + this.dirty_bgcanvas = true return } } @@ -2612,6 +2628,10 @@ export class LGraphCanvas implements ConnectionColorContext { this.node_over = node this.dirty_canvas = true + for (const reroute of this.#visibleReroutes) { + reroute.hideSlots() + this.dirty_bgcanvas = true + } node.onMouseEnter?.(e) } @@ -2690,19 +2710,8 @@ export class LGraphCanvas implements ConnectionColorContext { underPointer |= CanvasItem.ResizeSe } } else { - // Reroute - const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY) - if (reroute) { - underPointer |= CanvasItem.Reroute - linkConnector.overReroute = reroute - - if (linkConnector.isConnecting && linkConnector.isRerouteValidDrop(reroute)) { - this._highlight_pos = reroute.pos - } - } else { - this._highlight_pos &&= undefined - linkConnector.overReroute &&= undefined - } + // Reroutes + underPointer = this.#updateReroutes(underPointer) // Not over a node const segment = this.#getLinkCentreOnPos(e) @@ -2760,6 +2769,42 @@ export class LGraphCanvas implements ConnectionColorContext { 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. * @@ -4645,9 +4690,14 @@ export class LGraphCanvas implements ConnectionColorContext { 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 && @@ -4656,6 +4706,9 @@ export class LGraphCanvas implements ConnectionColorContext { 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 } @@ -7143,7 +7196,7 @@ export class LGraphCanvas implements ConnectionColorContext { // Check for reroutes if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { - const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY) + const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY, this.#visibleReroutes) if (reroute) { menu_info.unshift({ content: "Delete Reroute", diff --git a/src/Reroute.ts b/src/Reroute.ts index 4f8edaad1..a6344056d 100644 --- a/src/Reroute.ts +++ b/src/Reroute.ts @@ -14,7 +14,7 @@ import type { Serialisable, SerialisableReroute } from "./types/serialisation" import { LGraphBadge } from "./LGraphBadge" import { type LinkId, LLink } from "./LLink" -import { distance } from "./measure" +import { distance, isPointInRect } from "./measure" export type RerouteId = number @@ -36,6 +36,12 @@ export class Reroute implements Positionable, LinkSegment, Serialisable renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute)) - - for (const result of filtered) { - renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes) - } + this._connectOutputToReroute(reroute, renderLink) return } @@ -438,6 +451,44 @@ export class LinkConnector { } } + /** @internal Temporary workaround - requires refactor. */ + _connectOutputToReroute(reroute: Reroute, renderLink: RenderLinkUnion): void { + const results = reroute.findTargetInputs() + if (!results?.length) return + + const maybeReroutes = reroute.getReroutes() + if (maybeReroutes === null) throw new Error("Reroute loop detected.") + + const originalReroutes = maybeReroutes.slice(0, -1).reverse() + + // From reroute to reroute + if (renderLink instanceof ToInputRenderLink) { + const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink + + reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex) + + // Clean floating link IDs from reroutes about to be removed from the chain + if (fromReroute != null) { + for (const originalReroute of originalReroutes) { + if (originalReroute.id === fromReroute.id) break + + for (const linkId of reroute.floatingLinkIds) { + originalReroute.floatingLinkIds.delete(linkId) + } + } + } + } + + // Filter before any connections are re-created + const filtered = results.filter(result => renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute)) + + for (const result of filtered) { + renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes) + } + + return + } + dropOnNothing(event: CanvasPointerEvent): void { // For external event only. const mayContinue = this.events.dispatch("dropped-on-canvas", event) diff --git a/src/canvas/ToOutputFromRerouteLink.ts b/src/canvas/ToOutputFromRerouteLink.ts new file mode 100644 index 000000000..ec37819f0 --- /dev/null +++ b/src/canvas/ToOutputFromRerouteLink.ts @@ -0,0 +1,31 @@ +import type { LinkConnector } from "./LinkConnector" +import type { LGraphNode } from "@/LGraphNode" +import type { INodeInputSlot, INodeOutputSlot, LinkNetwork } from "@/litegraph" +import type { Reroute } from "@/Reroute" + +import { ToInputRenderLink } from "./ToInputRenderLink" +import { ToOutputRenderLink } from "./ToOutputRenderLink" + +/** + * @internal A workaround class to support connecting to reroutes to node outputs. + */ +export class ToOutputFromRerouteLink extends ToOutputRenderLink { + constructor( + network: LinkNetwork, + node: LGraphNode, + fromSlot: INodeInputSlot, + readonly fromReroute: Reroute, + readonly linkConnector: LinkConnector, + ) { + super(network, node, fromSlot, fromReroute) + } + + override canConnectToReroute(): false { + return false + } + + override connectToOutput(node: LGraphNode, output: INodeOutputSlot) { + const nuRenderLink = new ToInputRenderLink(this.network, node, output) + this.linkConnector._connectOutputToReroute(this.fromReroute, nuRenderLink) + } +} diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index 45ccca39c..6d95d1aa2 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -36,6 +36,8 @@ export enum CanvasItem { Link = 1 << 3, /** A resize in the bottom-right corner */ ResizeSe = 1 << 4, + /** A reroute slot */ + RerouteSlot = 1 << 5, } /** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */