From e6a914117bae1bfe0a98897226cc4bec1919bcce Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Fri, 14 Mar 2025 06:56:57 +1100 Subject: [PATCH] Add floating reroutes (#773) ### Floating reroutes Native reroutes can now be kept in a disconnected state. Link chains may be kept provided they are connected to _either_ an input or an output. By design, reroutes will be automatically removed if both sides are disconnected. --- src/LGraph.ts | 37 +++++++- src/LGraphCanvas.ts | 60 +++++++++++-- src/LGraphNode.ts | 32 +++++-- src/LLink.ts | 41 +++++++-- src/Reroute.ts | 172 +++++++++++++++++++++++++----------- src/canvas/LinkConnector.ts | 51 ++++++++--- src/interfaces.ts | 3 +- 7 files changed, 312 insertions(+), 84 deletions(-) diff --git a/src/LGraph.ts b/src/LGraph.ts index ea65602db..e2633e981 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -883,7 +883,7 @@ export class LGraph implements LinkNetwork, Serialisable { // disconnect inputs if (inputs) { for (const [i, slot] of inputs.entries()) { - if (slot.link != null) node.disconnectInput(i) + if (slot.link != null) node.disconnectInput(i, true) } } @@ -1373,6 +1373,31 @@ export class LGraph implements LinkNetwork, Serialisable { this.canvasAction(c => c.setDirty(fg, bg)) } + addFloatingLink(link: LLink): LLink { + if (link.id === -1) { + link.id = ++this.#lastFloatingLinkId + } + this.#floatingLinks.set(link.id, link) + + const reroutes = LLink.getReroutes(this, link) + for (const reroute of reroutes) { + reroute.floatingLinkIds.add(link.id) + } + return link + } + + removeFloatingLink(link: LLink): void { + this.#floatingLinks.delete(link.id) + + const reroutes = LLink.getReroutes(this, link) + for (const reroute of reroutes) { + reroute.floatingLinkIds.delete(link.id) + if (reroute.floatingLinkIds.size === 0) { + delete reroute.floating + } + } + } + /** * Configures a reroute on the graph where ID is already known (probably deserialisation). * Creates the object if it does not exist. @@ -1444,8 +1469,12 @@ export class LGraph implements LinkNetwork, Serialisable { } // Remove floating links with no reroutes - if (parentId === undefined) this.#floatingLinks.delete(linkId) - else if (link.parentId === id) link.parentId = parentId + const floatingReroutes = LLink.getReroutes(this, link) + if (!(floatingReroutes.length > 0)) { + this.#floatingLinks.delete(linkId) + } else if (link.parentId === id) { + link.parentId = parentId + } } reroutes.delete(id) @@ -1460,7 +1489,7 @@ export class LGraph implements LinkNetwork, Serialisable { if (!link) return const node = this.getNodeById(link.target_id) - node?.disconnectInput(link.target_slot) + node?.disconnectInput(link.target_slot, false) link.disconnect(this) } diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 0d4e47b50..4a828d427 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -2250,10 +2250,16 @@ export class LGraphCanvas implements ConnectionColorContext { e.altKey && !e.shiftKey) ) { - node.disconnectInput(i) + node.disconnectInput(i, true) } else if (e.shiftKey || this.allow_reconnect_links) { linkConnector.moveInputLink(graph, input) } + } else { + for (const link of graph.floatingLinks.values()) { + if (link.target_id === node.id && link.target_slot === i) { + graph.removeFloatingLink(link) + } + } } // Dragging a new link from input to output @@ -4596,6 +4602,10 @@ export class LGraphCanvas implements ConnectionColorContext { } } + if (graph.floatingLinks.size > 0) { + this.#renderFloatingLinks(ctx, graph, visibleReroutes, now) + } + // Render the reroute circles for (const reroute of visibleReroutes) { if ( @@ -4610,6 +4620,39 @@ export class LGraphCanvas implements ConnectionColorContext { ctx.globalAlpha = 1 } + #renderFloatingLinks(ctx: CanvasRenderingContext2D, graph: LGraph, visibleReroutes: Reroute[], now: number) { + // Floating reroutes + for (const link of graph.floatingLinks.values()) { + const reroutes = LLink.getReroutes(graph, link) + const firstReroute = reroutes[0] + const reroute = reroutes.at(-1) + if (!firstReroute || !reroute?.floating) continue + + // Input not connected + if (reroute.floating.slotType === "input") { + const node = graph.getNodeById(link.target_id) + if (!node) continue + + const startPos = firstReroute.pos + const endPos = node.getInputPos(link.target_slot) + const endDirection = node.inputs[link.target_slot]?.dir + + firstReroute._dragging = true + this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, LinkDirection.CENTER, endDirection) + } else { + const node = graph.getNodeById(link.origin_id) + if (!node) continue + + const startPos = node.getOutputPos(link.origin_slot) + const endPos = reroute.pos + const startDirection = node.outputs[link.origin_slot]?.dir + + link._dragging = true + this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, startDirection, LinkDirection.CENTER) + } + } + } + #renderAllLinkSegments( ctx: CanvasRenderingContext2D, link: LLink, @@ -4687,10 +4730,15 @@ export class LGraphCanvas implements ConnectionColorContext { } } - // Calculate start control for the next iter control point - const nextPos = reroutes[j + 1]?.pos ?? endPos - const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25) - startControl = [dist * reroute.cos, dist * reroute.sin] + if (!startControl && reroutes.at(-1)?.floating?.slotType === "input") { + // Floating link connected to an input + startControl = [0, 0] satisfies Point + } else { + // Calculate start control for the next iter control point + const nextPos = reroutes[j + 1]?.pos ?? endPos + const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25) + startControl = [dist * reroute.cos, dist * reroute.sin] + } } // Skip the last segment if it is being dragged @@ -7091,7 +7139,7 @@ export class LGraphCanvas implements ConnectionColorContext { if (info.output) { node.disconnectOutput(info.slot) } else if (info.input) { - node.disconnectInput(info.slot) + node.disconnectInput(info.slot, true) } node.graph.afterChange() return diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 720362fab..10a66c30e 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -1471,7 +1471,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { * remove an existing input slot */ removeInput(slot: number): void { - this.disconnectInput(slot) + this.disconnectInput(slot, true) const { inputs } = this const slot_info = inputs.splice(slot, 1) @@ -2503,8 +2503,22 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { inputNode.inputs[inputIndex].link = link.id // Reroutes - for (const reroute of LLink.getReroutes(graph, link)) { - reroute?.linkIds.add(link.id) + const reroutes = LLink.getReroutes(graph, link) + for (const reroute of reroutes) { + reroute.linkIds.add(link.id) + if (reroute.floating) delete reroute.floating + reroute._dragging = undefined + } + + // If this is the terminus of a floating link, remove it + const lastReroute = reroutes.at(-1) + if (lastReroute) { + for (const linkId of lastReroute.floatingLinkIds) { + const link = graph.floatingLinks.get(linkId) + if (link?.parentId === lastReroute.id) { + graph.removeFloatingLink(link) + } + } } graph._version++ @@ -2551,6 +2565,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { return false } + for (const link of this.graph?.floatingLinks.values() ?? []) { + if (link.origin_id === this.id && link.origin_slot === slot) { + this.graph?.removeFloatingLink(link) + } + } + // get output slot const output = this.outputs[slot] if (!output || !output.links || output.links.length == 0) return false @@ -2578,7 +2598,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { input.link = null // remove the link from the links pool - link_info.disconnect(graph) + link_info.disconnect(graph, "input") graph._version++ // link_info hasn't been modified so its ok @@ -2625,7 +2645,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { ) } // remove the link from the links pool - link_info.disconnect(graph) + link_info.disconnect(graph, "input") this.onConnectionsChange?.( NodeSlotType.OUTPUT, @@ -2691,7 +2711,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { } } - link_info.disconnect(this.graph, keepReroutes) + link_info.disconnect(this.graph, keepReroutes ? "output" : undefined) if (this.graph) this.graph._version++ this.onConnectionsChange?.( diff --git a/src/LLink.ts b/src/LLink.ts index 0b1e555c6..3532cbc09 100644 --- a/src/LLink.ts +++ b/src/LLink.ts @@ -104,12 +104,12 @@ export class LLink implements LinkSegment, Serialisable { } /** - * Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element. + * Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will not be included. * @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: ReadonlyLinkNetwork, + network: Pick, linkSegment: LinkSegment, ): Reroute[] { if (!linkSegment.parentId) return [] @@ -119,7 +119,7 @@ export class LLink implements LinkSegment, Serialisable { } static getFirstReroute( - network: LinkNetwork, + network: Pick, linkSegment: LinkSegment, ): Reroute | undefined { return LLink.getReroutes(network, linkSegment).at(0) @@ -166,15 +166,44 @@ export class LLink implements LinkSegment, Serialisable { /** * 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. + * @param keepReroutes If `undefined`, reroutes will be automatically removed if no links remain. + * If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively. */ - disconnect(network: LinkNetwork, keepReroutes?: boolean): void { + disconnect(network: LinkNetwork, keepReroutes?: "input" | "output"): void { const reroutes = LLink.getReroutes(network, this) + const lastReroute = reroutes.at(-1) + + // When floating from output, 1-to-1 ratio of floating link to final reroute (tree-like) + const outputFloating = keepReroutes === "output" && + lastReroute?.linkIds.size === 1 && + lastReroute.floatingLinkIds.size === 0 + + // When floating from inputs, the final (input side) reroute may have many floating links + if (outputFloating || (keepReroutes === "input" && lastReroute)) { + const newLink = LLink.create(this) + newLink.id = -1 + + if (keepReroutes === "input") { + newLink.origin_id = -1 + newLink.origin_slot = -1 + + lastReroute.floating = { slotType: "input" } + } else { + newLink.target_id = -1 + newLink.target_slot = -1 + + lastReroute.floating = { slotType: "output" } + } + + network.addFloatingLink(newLink) + } + for (const reroute of reroutes) { reroute.linkIds.delete(this.id) - if (!keepReroutes && !reroute.linkIds.size) + if (!keepReroutes && !reroute.linkIds.size && !reroute.floatingLinkIds.size) { network.reroutes.delete(reroute.id) + } } network.links.delete(this.id) } diff --git a/src/Reroute.ts b/src/Reroute.ts index 5462faf6e..21917c3de 100644 --- a/src/Reroute.ts +++ b/src/Reroute.ts @@ -1,5 +1,6 @@ import type { CanvasColour, + INodeInputSlot, LinkNetwork, LinkSegment, Point, @@ -7,7 +8,7 @@ import type { ReadonlyLinkNetwork, ReadOnlyRect, } from "./interfaces" -import type { NodeId } from "./LGraphNode" +import type { LGraphNode, NodeId } from "./LGraphNode" import type { Serialisable, SerialisableReroute } from "./types/serialisation" import { type LinkId, LLink } from "./LLink" @@ -17,10 +18,6 @@ export type RerouteId = number /** The input or output slot that an incomplete reroute link is connected to. */ export interface FloatingRerouteSlot { - /** The ID of the node that the slot belongs to */ - nodeId: NodeId - /** The index of the slot on the node */ - slot: number /** Floating connection to an input or output */ slotType: "input" | "output" } @@ -52,7 +49,7 @@ export class Reroute implements Positionable, LinkSegment, Serialisable, ) { this.#network = new WeakRef(network) - this.update(parentId, pos, linkIds) - this.linkIds ??= new Set() + this.parentId = parentId + if (pos) this.pos = pos + this.linkIds = new Set(linkIds) this.floatingLinkIds = new Set(floatingLinkIds) } @@ -243,10 +246,9 @@ export class Reroute implements Positionable, LinkSegment, Serialisable, links: ReadonlyMap) { + for (const linkId of linkIds) { + const link = links.get(linkId) + if (!link) continue + + const node = network.getNodeById(link.target_id) + const input = node?.inputs[link.target_slot] + if (!input) continue + + results.push({ node, input, inputIndex: link.target_slot, link }) + } + } + } + /** @inheritdoc */ move(deltaX: number, deltaY: number) { this.#pos[0] += deltaX @@ -276,31 +311,36 @@ export class Reroute implements Positionable, LinkSegment, Serialisable this.#lastRenderTime)) return this.#lastRenderTime = lastRenderTime - const { links } = network - const { linkIds, id } = this + const { links, floatingLinks } = network + const { id, linkIds, floatingLinkIds, pos: thisPos } = 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) - ?.getInputPos(link.target_slot) - if (!pos) continue + // Add all link angles + calculateLinks(linkIds, links) + calculateLinks(floatingLinkIds, floatingLinks) - // 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 + // Invalid - reset + if (!angles.length) { + this.cos = 0 + this.sin = 0 + this.controlPoint[0] = 0 + this.controlPoint[1] = 0 + return } - if (!angles.length) return sum /= angles.length @@ -321,6 +361,23 @@ export class Reroute implements Positionable, LinkSegment, Serialisable, links: ReadonlyMap) { + for (const linkId of linkIds) { + const link = links.get(linkId) + const pos = getNextPos(network, link, id) + if (!pos) continue + + const angle = getDirection(thisPos, pos) + angles.push(angle) + sum += angle + } + } } /** @@ -357,23 +414,36 @@ export class Reroute implements Positionable, LinkSegment, Serialisable { - e.detail.link.disconnect(network, true) + e.detail.link.disconnect(network, "output") }) } catch (error) { console.warn(`Could not create render link for link id: [${link.id}].`, link, error) @@ -215,13 +215,7 @@ export class LinkConnector { dragFromReroute(network: LinkNetwork, reroute: Reroute): void { if (this.isConnecting) throw new Error("Already dragging links.") - const { state } = this - - // Connect new link from reroute - const linkId = reroute.linkIds.values().next().value - if (linkId == null) return - - const link = network.links.get(linkId) + const link = reroute.firstLink ?? reroute.firstFloatingLink if (!link) return const outputNode = network.getNodeById(link.origin_id) @@ -234,7 +228,7 @@ export class LinkConnector { renderLink.fromDirection = LinkDirection.NONE this.renderLinks.push(renderLink) - state.connectingTo = "input" + this.state.connectingTo = "input" } dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void { @@ -278,7 +272,7 @@ export class LinkConnector { // Get reroute if no node is found const reroute = locator.getRerouteOnPos(canvasX, canvasY) // Drop output->input link on reroute is not impl. - if (reroute && this.state.connectingTo === "output") { + if (reroute) { this.dropOnReroute(reroute, event) } else { this.dropOnNothing(event) @@ -329,6 +323,43 @@ export class LinkConnector { const mayContinue = this.events.dispatch("dropped-on-reroute", { reroute, event }) if (mayContinue === false) return + if (this.state.connectingTo === "input") { + const results = reroute.findTargetInputs() + if (!results?.length) return + + for (const { node: inputNode, input, link: resultLink } of results) { + for (const renderLink of this.renderLinks) { + if (renderLink.toType !== "input") continue + + if (renderLink instanceof MovingRenderLink) { + const { outputNode, inputSlot, outputSlot, fromReroute } = renderLink + // Link is already connected here + if (inputSlot === input) continue + + const newLink = outputNode.connectSlots(outputSlot, inputNode, input, fromReroute?.id) + if (newLink) this.events.dispatch("input-moved", renderLink) + } else { + const reroutes = reroute.getReroutes() + if (reroutes === null) throw new Error("Reroute loop detected.") + + if (reroutes) { + for (const reroute of reroutes.slice(0, -1)) { + reroute.remove() + } + } + const { node: outputNode, fromSlot, fromReroute } = renderLink + // Set the parentId of the reroute we dropped on, to the reroute we dragged from + reroute.parentId = fromReroute?.id + + const newLink = outputNode.connectSlots(fromSlot, inputNode, input, resultLink.parentId) + this.events.dispatch("link-created", newLink) + } + } + } + + return + } + for (const link of this.renderLinks) { if (link.toType !== "output") continue diff --git a/src/interfaces.ts b/src/interfaces.ts index 83e892c73..47eb61441 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -124,7 +124,8 @@ export interface ReadonlyLinkNetwork { export interface LinkNetwork extends ReadonlyLinkNetwork { readonly links: Map readonly reroutes: Map - getNodeById(id: NodeId): LGraphNode | null + addFloatingLink(link: LLink): LLink + removeReroute(id: number): unknown } /**