From 850d1b96522f6a7b1bc2c411de5a5bb36e548337 Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Sat, 22 Mar 2025 06:41:20 +1100 Subject: [PATCH] [API] Finalise LinkConnector design, adding reroute logic (#817) - Splits link connect logic out of `LinkConnector` to individual `RenderLink` classes - Add support for connecting / reconnecting reroutes in various configurations - Adds support for moving existing floating links from outputs / inputs - Fixes numerous corruption issues when reconnecting reroutes / moving links - Tests in separate PR #816 --- src/LGraphCanvas.ts | 2 +- src/Reroute.ts | 64 +++- src/canvas/FloatingRenderLink.ts | 157 ++++++++ src/canvas/LinkConnector.ts | 336 +++++++++--------- src/canvas/MovingRenderLink.ts | 66 ++++ src/canvas/RenderLink.ts | 20 +- src/canvas/ToInputRenderLink.ts | 52 ++- src/canvas/ToOutputRenderLink.ts | 30 +- .../LinkConnectorEventTarget.ts | 9 +- test/LinkConnector.test.ts | 112 +++--- 10 files changed, 624 insertions(+), 224 deletions(-) create mode 100644 src/canvas/FloatingRenderLink.ts diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 89beeb4ac..18950fd01 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -4716,7 +4716,7 @@ export class LGraphCanvas implements ConnectionColorContext { LGraphCanvas.link_type_colors[link.type] || this.default_link_color - const prevReroute = reroute.parentId == null ? undefined : graph.reroutes.get(reroute.parentId) + const prevReroute = graph.getReroute(reroute.parentId) const rerouteStartPos = prevReroute?.pos ?? startPos reroute.calculateAngle(this.last_draw_time, graph, rerouteStartPos) diff --git a/src/Reroute.ts b/src/Reroute.ts index ecab54653..c0c7fa6bb 100644 --- a/src/Reroute.ts +++ b/src/Reroute.ts @@ -1,6 +1,7 @@ import type { CanvasColour, INodeInputSlot, + INodeOutputSlot, LinkNetwork, LinkSegment, Point, @@ -255,14 +256,11 @@ export class Reroute implements Positionable, LinkSegment, Serialisable = new Set() @@ -101,31 +105,51 @@ export class LinkConnector { const { state, inputLinks, renderLinks } = this const linkId = input.link - if (linkId == null) return + if (linkId == null) { + // No link connected, check for a floating link + const floatingLink = input._floatingLinks?.values().next().value + if (floatingLink?.parentId == null) return - const link = network.links.get(linkId) - if (!link) return + try { + const reroute = network.reroutes.get(floatingLink.parentId) + if (!reroute) throw new Error(`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`) - try { - const reroute = link.parentId != null ? network.reroutes.get(link.parentId) : undefined - const renderLink = new MovingRenderLink(network, link, "input", reroute) + const renderLink = new FloatingRenderLink(network, floatingLink, "input", reroute) + const mayContinue = this.events.dispatch("before-move-input", renderLink) + if (mayContinue === false) return - const mayContinue = this.events.dispatch("before-move-input", renderLink) - if (mayContinue === false) return + renderLinks.push(renderLink) + } catch (error) { + console.warn(`Could not create render link for link id: [${floatingLink.id}].`, floatingLink, error) + } - renderLinks.push(renderLink) + floatingLink._dragging = true + this.floatingLinks.push(floatingLink) + } else { + const link = network.links.get(linkId) + if (!link) return - this.listenUntilReset("input-moved", (e) => { - e.detail.link.disconnect(network, "output") - }) - } catch (error) { - console.warn(`Could not create render link for link id: [${link.id}].`, link, error) - return + try { + const reroute = network.getReroute(link.parentId) + const renderLink = new MovingRenderLink(network, link, "input", reroute) + + const mayContinue = this.events.dispatch("before-move-input", renderLink) + if (mayContinue === false) return + + renderLinks.push(renderLink) + + this.listenUntilReset("input-moved", (e) => { + e.detail.link.disconnect(network, "output") + }) + } catch (error) { + console.warn(`Could not create render link for link id: [${link.id}].`, link, error) + return + } + + link._dragging = true + inputLinks.push(link) } - link._dragging = true - inputLinks.push(link) - state.connectingTo = "input" state.draggingExistingLinks = true @@ -137,31 +161,52 @@ export class LinkConnector { if (this.isConnecting) throw new Error("Already dragging links.") const { state, renderLinks } = this - if (!output.links?.length) return - for (const linkId of output.links) { - const link = network.links.get(linkId) - if (!link) continue + // Floating links + if (output._floatingLinks?.size) { + for (const floatingLink of output._floatingLinks.values()) { + try { + const reroute = LLink.getFirstReroute(network, floatingLink) + if (!reroute) throw new Error(`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`) - const firstReroute = LLink.getFirstReroute(network, link) - if (firstReroute) { - firstReroute._dragging = true - this.hiddenReroutes.add(firstReroute) - } else { - link._dragging = true + const renderLink = new FloatingRenderLink(network, floatingLink, "output", reroute) + const mayContinue = this.events.dispatch("before-move-output", renderLink) + if (mayContinue === false) continue + + renderLinks.push(renderLink) + this.floatingLinks.push(floatingLink) + } catch (error) { + console.warn(`Could not create render link for link id: [${floatingLink.id}].`, floatingLink, error) + } } - this.outputLinks.push(link) + } - try { - const renderLink = new MovingRenderLink(network, link, "output", firstReroute, LinkDirection.RIGHT) + // Normal links + if (output.links?.length) { + for (const linkId of output.links) { + const link = network.links.get(linkId) + if (!link) continue - const mayContinue = this.events.dispatch("before-move-output", renderLink) - if (mayContinue === false) continue + const firstReroute = LLink.getFirstReroute(network, link) + if (firstReroute) { + firstReroute._dragging = true + this.hiddenReroutes.add(firstReroute) + } else { + link._dragging = true + } + this.outputLinks.push(link) - renderLinks.push(renderLink) - } catch (error) { - console.warn(`Could not create render link for link id: [${link.id}].`, link, error) - continue + try { + const renderLink = new MovingRenderLink(network, link, "output", firstReroute, LinkDirection.RIGHT) + + const mayContinue = this.events.dispatch("before-move-output", renderLink) + if (mayContinue === false) continue + + renderLinks.push(renderLink) + } catch (error) { + console.warn(`Could not create render link for link id: [${link.id}].`, link, error) + continue + } } } @@ -332,42 +377,57 @@ export class LinkConnector { // Connecting to input if (this.state.connectingTo === "input") { + if (this.renderLinks.length !== 1) throw new Error(`Attempted to connect ${this.renderLinks.length} input links to a reroute.`) + + const renderLink = this.renderLinks[0] + 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 (!canConnectInputLinkToReroute(renderLink, input, reroute)) continue + const maybeReroutes = reroute.getReroutes() + if (maybeReroutes === null) throw new Error("Reroute loop detected.") - if (renderLink instanceof MovingRenderLink) { - const { outputNode, outputSlot, fromReroute } = renderLink + const originalReroutes = maybeReroutes.slice(0, -1).reverse() - const newLink = outputNode.connectSlots(outputSlot, inputNode, input, fromReroute?.id) - if (newLink) this.events.dispatch("input-moved", renderLink) - } else { - const { node: outputNode, fromSlot, fromReroute } = renderLink + // From reroute to reroute + if (this.renderLinks.length === 1 && renderLink instanceof ToInputRenderLink) { + const { node, fromSlotIndex, fromReroute } = renderLink + const floatingOutLinks = reroute.getFloatingLinks("output") + const floatingInLinks = reroute.getFloatingLinks("input") - const reroutes = reroute.getReroutes() - if (reroutes === null) throw new Error("Reroute loop detected.") + // Clean floating link IDs from reroutes about to be removed from the chain + if (floatingOutLinks && floatingInLinks) { + for (const link of floatingOutLinks) { + link.origin_id = node.id + link.origin_slot = fromSlotIndex - // Clean up reroutes - if (reroutes) { - for (const reroute of reroutes.slice(0, -1).reverse()) { - if (reroute.id === fromReroute?.id) break + for (const originalReroute of originalReroutes) { + if (fromReroute != null && originalReroute.id === fromReroute.id) break - if (reroute.totalLinks === 1) reroute.remove() - } + originalReroute.floatingLinkIds.delete(link.id) } - // 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) + for (const link of floatingInLinks) { + for (const originalReroute of originalReroutes) { + if (fromReroute != null && originalReroute.id === fromReroute.id) break + originalReroute.floatingLinkIds.delete(link.id) + } } } } + // Flat map and filter before any connections are re-created + const better = this.renderLinks + .flatMap(renderLink => results.map(result => ({ renderLink, result }))) + .filter(({ renderLink, result }) => renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute)) + + for (const { renderLink, result } of better) { + if (renderLink.toType !== "input") continue + + renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes) + } + return } @@ -376,29 +436,12 @@ export class LinkConnector { if (link.toType !== "output") continue const result = reroute.findSourceOutput() - if (!result) return + if (!result) continue const { node, output } = result - if (!isValidConnectionToOutput(link, output)) continue + if (!isValidConnectionToOutput(link, node, output)) continue - if (link instanceof MovingRenderLink) { - const { inputNode, inputSlot, fromReroute } = link - - // Connect the first reroute of the link being dragged to the reroute being dropped on - if (fromReroute) { - fromReroute.parentId = reroute.id - } else { - // If there are no reroutes, directly connect the link - link.link.parentId = reroute.id - } - // Use the last reroute id on the link to retain all reroutes - node.connectSlots(output, inputNode, inputSlot, link.link.parentId) - this.events.dispatch("output-moved", link) - } else { - const { node: inputNode, fromSlot } = link - const newLink = node.connectSlots(output, inputNode, fromSlot, reroute?.id) - this.events.dispatch("link-created", newLink) - } + link.connectToRerouteOutput(reroute, node, output, this.events) } } @@ -429,30 +472,9 @@ export class LinkConnector { const firstLink = this.renderLinks[0] if (!firstLink || firstLink.node === node) return - // Dragging output links - if (connectingTo === "output" && this.draggingExistingLinks) { - const output = node.findOutputByType(firstLink.fromSlot.type)?.slot - if (!output) { - console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) - return - } - this.#dropOnOutput(node, output) - return - } - - // Dragging input links - if (connectingTo === "input" && this.draggingExistingLinks) { - const input = node.findInputByType(firstLink.fromSlot.type)?.slot - if (!input) { - console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) - return - } - this.#dropOnInput(node, input) - return - } - - // Dropping new output link + // Use a single type check before looping; ensures all dropped links go to the same slot if (connectingTo === "output") { + // Dropping new output link const output = node.findOutputByType(firstLink.fromSlot.type)?.slot if (!output) { console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) @@ -460,12 +482,10 @@ export class LinkConnector { } for (const link of this.renderLinks) { - if ("link" in link.fromSlot) { - node.connectSlots(output, link.node, link.fromSlot, link.fromReroute?.id) - } + link.connectToOutput(node, output, this.events) } - // Dropping new input link } else if (connectingTo === "input") { + // Dropping new input link const input = node.findInputByType(firstLink.fromSlot.type)?.slot if (!input) { console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) @@ -473,9 +493,7 @@ export class LinkConnector { } for (const link of this.renderLinks) { - if ("links" in link.fromSlot) { - link.node.connectSlots(link.fromSlot, node, input, link.fromReroute?.id) - } + link.connectToInput(node, input, this.events) } } } @@ -484,20 +502,7 @@ export class LinkConnector { for (const link of this.renderLinks) { if (link.toType !== "input") continue - if (link instanceof MovingRenderLink) { - const { outputNode, inputSlot, outputSlot, fromReroute } = link - // Link is already connected here - if (inputSlot === input) continue - - const newLink = outputNode.connectSlots(outputSlot, node, input, fromReroute?.id) - if (newLink) this.events.dispatch("input-moved", link) - } else { - const { node: outputNode, fromSlot, fromReroute } = link - if (node === outputNode) continue - - const newLink = outputNode.connectSlots(fromSlot, node, input, fromReroute?.id) - this.events.dispatch("link-created", newLink) - } + link.connectToInput(node, input, this.events) } } @@ -505,21 +510,7 @@ export class LinkConnector { for (const link of this.renderLinks) { if (link.toType !== "output") continue - if (link instanceof MovingRenderLink) { - const { inputNode, inputSlot, outputSlot } = link - // Link is already connected here - if (outputSlot === output) continue - - // Use the last reroute id on the link to retain all reroutes - node.connectSlots(output, inputNode, inputSlot, link.link.parentId) - this.events.dispatch("output-moved", link) - } else { - const { node: inputNode, fromSlot, fromReroute } = link - if (inputNode) continue - - const newLink = node.connectSlots(output, inputNode, fromSlot, fromReroute?.id) - this.events.dispatch("link-created", newLink) - } + link.connectToOutput(node, output, this.events) } } @@ -533,19 +524,21 @@ export class LinkConnector { const results = reroute.findTargetInputs() if (!results?.length) return false - for (const { input } of results) { + for (const { node, input } of results) { for (const renderLink of this.renderLinks) { if (renderLink.toType !== "input") continue - if (canConnectInputLinkToReroute(renderLink, input, reroute)) return true + if (canConnectInputLinkToReroute(renderLink, node, input, reroute)) return true } } } else { - const output = reroute.findSourceOutput()?.output - if (!output) return false + const result = reroute.findSourceOutput() + if (!result) return false + + const { node, output } = result for (const renderLink of this.renderLinks) { if (renderLink.toType !== "output") continue - if (isValidConnectionToOutput(renderLink, output)) return true + if (isValidConnectionToOutput(renderLink, node, output)) return true } } @@ -583,6 +576,7 @@ export class LinkConnector { renderLinks: [...this.renderLinks], inputLinks: [...this.inputLinks], outputLinks: [...this.outputLinks], + floatingLinks: [...this.floatingLinks], state: { ...this.state }, network, } @@ -610,7 +604,7 @@ export class LinkConnector { reset(force = false): void { this.events.dispatch("reset", force) - const { state, outputLinks, inputLinks, hiddenReroutes, renderLinks } = this + const { state, outputLinks, inputLinks, hiddenReroutes, renderLinks, floatingLinks } = this if (!force && state.connectingTo === undefined) return state.connectingTo = undefined @@ -622,18 +616,24 @@ export class LinkConnector { renderLinks.length = 0 inputLinks.length = 0 outputLinks.length = 0 + floatingLinks.length = 0 hiddenReroutes.clear() state.multi = false state.draggingExistingLinks = false } } -function isValidConnectionToOutput(link: ToOutputRenderLink | MovingRenderLink, output: INodeOutputSlot): boolean { +function isValidConnectionToOutput(link: ToOutputRenderLink | MovingRenderLink | FloatingRenderLink, outputNode: LGraphNode, output: INodeOutputSlot): boolean { + const { node: fromNode } = link + + // Node cannot connect to itself + if (fromNode === outputNode) return false + if (link instanceof MovingRenderLink) { - const { inputSlot: { type }, outputSlot } = link + const { inputSlot: { type } } = link // Link is already connected here / type mismatch - if (outputSlot === output || !LiteGraph.isValidConnection(type, output.type)) { + if (!LiteGraph.isValidConnection(type, output.type)) { return false } } else { @@ -644,37 +644,33 @@ function isValidConnectionToOutput(link: ToOutputRenderLink | MovingRenderLink, } /** Validates that a single {@link RenderLink} can be dropped on the specified reroute. */ -function canConnectInputLinkToReroute(link: ToInputRenderLink | MovingRenderLink, input: INodeInputSlot, reroute: Reroute): boolean { - if (link instanceof MovingRenderLink) { - const { inputSlot, outputSlot, fromReroute } = link +function canConnectInputLinkToReroute(link: ToInputRenderLink | MovingRenderLink | FloatingRenderLink, inputNode: LGraphNode, input: INodeInputSlot, reroute: Reroute): boolean { + const { node: fromNode, fromSlot, fromReroute } = link - // Link is already connected here - if (inputSlot === input || validate(outputSlot.type, reroute, fromReroute)) { - return false - } - } else { - const { fromSlot, fromReroute } = link + if ( + // Node cannot connect to itself + fromNode === inputNode || + // Would result in no change + fromReroute?.id === reroute.id || + isInvalid(fromSlot.type, reroute, fromReroute) + ) { + return false + } - // Connect to yourself - if (fromReroute?.id === reroute.id || validate(fromSlot.type, reroute, fromReroute)) { - return false - } - - // Link would make no change - output to reroute - if ( - reroute.parentId == null && - reroute.firstLink?.hasOrigin(link.node.id, link.fromSlotIndex) - ) { + // Would result in no change + if (link instanceof ToInputRenderLink) { + if (reroute.parentId == null) { + // Link would make no change - output to reroute + if (reroute.firstLink?.hasOrigin(link.node.id, link.fromSlotIndex)) return false + } else if (link.fromReroute?.id === reroute.parentId) { return false } } return true /** Checks connection type & rejects infinite loops. */ - function validate(type: ISlotType, reroute: Reroute, fromReroute?: Reroute): boolean { + function isInvalid(type: ISlotType, reroute: Reroute, fromReroute?: Reroute): boolean { return Boolean( - // Link would make no changes - (fromReroute?.id != null && fromReroute.id === reroute.parentId) || // Type mismatch !LiteGraph.isValidConnection(type, input.type) || // Cannot connect from child to parent reroute diff --git a/src/canvas/MovingRenderLink.ts b/src/canvas/MovingRenderLink.ts index ee4a36cce..5d779371c 100644 --- a/src/canvas/MovingRenderLink.ts +++ b/src/canvas/MovingRenderLink.ts @@ -1,4 +1,5 @@ import type { RenderLink } from "./RenderLink" +import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget" import type { INodeOutputSlot, LinkNetwork } from "@/interfaces" import type { INodeInputSlot } from "@/interfaces" import type { Point } from "@/interfaces" @@ -84,4 +85,69 @@ export class MovingRenderLink implements RenderLink { this.fromDirection = this.toType === "input" ? LinkDirection.NONE : LinkDirection.LEFT this.fromSlotIndex = this.toType === "input" ? outputIndex : inputIndex } + + connectToInput(inputNode: LGraphNode, input: INodeInputSlot, events: LinkConnectorEventTarget): LLink | null | undefined { + if (input === this.inputSlot) return + + const link = this.outputNode.connectSlots(this.outputSlot, inputNode, input, this.fromReroute?.id) + if (link) events.dispatch("input-moved", this) + return link + } + + connectToOutput(outputNode: LGraphNode, output: INodeOutputSlot, events: LinkConnectorEventTarget): LLink | null | undefined { + if (output === this.outputSlot) return + + const link = outputNode.connectSlots(output, this.inputNode, this.inputSlot, this.link.parentId) + if (link) events.dispatch("output-moved", this) + return link + } + + connectToRerouteInput( + reroute: Reroute, + { node: inputNode, input, link: existingLink }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, + events: LinkConnectorEventTarget, + originalReroutes: Reroute[], + ): void { + const { outputNode, outputSlot, fromReroute } = this + + // Clean up reroutes + for (const reroute of originalReroutes) { + if (reroute.id === this.link.parentId) break + + if (reroute.totalLinks === 1) reroute.remove() + } + // Set the parentId of the reroute we dropped on, to the reroute we dragged from + reroute.parentId = fromReroute?.id + + const newLink = outputNode.connectSlots(outputSlot, inputNode, input, existingLink.parentId) + if (newLink) events.dispatch("input-moved", this) + } + + connectToRerouteOutput( + reroute: Reroute, + outputNode: LGraphNode, + output: INodeOutputSlot, + events: LinkConnectorEventTarget, + ): void { + // Moving output side of links + const { inputNode, inputSlot, fromReroute } = this + + // Creating a new link removes floating prop - check before connecting + const floatingTerminus = reroute?.floating?.slotType === "output" + + // Connect the first reroute of the link being dragged to the reroute being dropped on + if (fromReroute) { + fromReroute.parentId = reroute.id + } else { + // If there are no reroutes, directly connect the link + this.link.parentId = reroute.id + } + // Use the last reroute id on the link to retain all reroutes + outputNode.connectSlots(output, inputNode, inputSlot, this.link.parentId) + + // Connecting from the final reroute of a floating reroute chain + if (floatingTerminus) reroute.removeAllFloatingLinks() + + events.dispatch("output-moved", this) + } } diff --git a/src/canvas/RenderLink.ts b/src/canvas/RenderLink.ts index 754818bb6..c154887ac 100644 --- a/src/canvas/RenderLink.ts +++ b/src/canvas/RenderLink.ts @@ -1,6 +1,7 @@ +import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget" import type { LinkNetwork, Point } from "@/interfaces" import type { LGraphNode } from "@/LGraphNode" -import type { INodeInputSlot, INodeOutputSlot, Reroute } from "@/litegraph" +import type { INodeInputSlot, INodeOutputSlot, LLink, Reroute } from "@/litegraph" import type { LinkDirection } from "@/types/globalEnums" export interface RenderLink { @@ -23,4 +24,21 @@ export interface RenderLink { readonly fromSlotIndex: number /** The reroute that the link is being connected from. */ readonly fromReroute?: Reroute + + connectToInput(node: LGraphNode, input: INodeInputSlot, events?: LinkConnectorEventTarget): void + connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: LinkConnectorEventTarget): void + + connectToRerouteInput( + reroute: Reroute, + { node, input, link }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, + events: LinkConnectorEventTarget, + originalReroutes: Reroute[], + ): void + + connectToRerouteOutput( + reroute: Reroute, + outputNode: LGraphNode, + output: INodeOutputSlot, + events: LinkConnectorEventTarget, + ): void } diff --git a/src/canvas/ToInputRenderLink.ts b/src/canvas/ToInputRenderLink.ts index 9c921dd78..de75e045f 100644 --- a/src/canvas/ToInputRenderLink.ts +++ b/src/canvas/ToInputRenderLink.ts @@ -1,7 +1,8 @@ import type { RenderLink } from "./RenderLink" +import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget" import type { LinkNetwork, Point } from "@/interfaces" import type { LGraphNode } from "@/LGraphNode" -import type { INodeOutputSlot } from "@/litegraph" +import type { INodeInputSlot, INodeOutputSlot, LLink } from "@/litegraph" import type { Reroute } from "@/Reroute" import { LinkDirection } from "@/types/globalEnums" @@ -29,4 +30,53 @@ export class ToInputRenderLink implements RenderLink { ? fromReroute.pos : this.node.getOutputPos(outputIndex) } + + connectToInput(node: LGraphNode, input: INodeInputSlot, events: LinkConnectorEventTarget) { + const { node: outputNode, fromSlot, fromReroute } = this + if (node === outputNode) return + + const newLink = outputNode.connectSlots(fromSlot, node, input, fromReroute?.id) + events.dispatch("link-created", newLink) + } + + connectToRerouteInput( + reroute: Reroute, + { + node: inputNode, + input, + link: existingLink, + }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, + events: LinkConnectorEventTarget, + originalReroutes: Reroute[], + ) { + const { node: outputNode, fromSlot, fromReroute } = this + + // Check before creating new link overwrites the value + const floatingTerminus = fromReroute?.floating?.slotType === "output" + + // 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, existingLink.parentId) + + // Connecting from the final reroute of a floating reroute chain + if (floatingTerminus) fromReroute.removeAllFloatingLinks() + + // Clean up reroutes + for (const reroute of originalReroutes) { + if (reroute.id === fromReroute?.id) break + + reroute.removeLink(existingLink) + if (reroute.totalLinks === 0) reroute.remove() + } + events.dispatch("link-created", newLink) + } + + connectToOutput() { + throw new Error("ToInputRenderLink cannot connect to an output.") + } + + connectToRerouteOutput() { + throw new Error("ToInputRenderLink cannot connect to an output.") + } } diff --git a/src/canvas/ToOutputRenderLink.ts b/src/canvas/ToOutputRenderLink.ts index 1bd3d2796..f68eea391 100644 --- a/src/canvas/ToOutputRenderLink.ts +++ b/src/canvas/ToOutputRenderLink.ts @@ -1,7 +1,8 @@ import type { RenderLink } from "./RenderLink" +import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget" import type { LinkNetwork, Point } from "@/interfaces" import type { LGraphNode } from "@/LGraphNode" -import type { INodeInputSlot } from "@/litegraph" +import type { INodeInputSlot, INodeOutputSlot } from "@/litegraph" import type { Reroute } from "@/Reroute" import { LinkDirection } from "@/types/globalEnums" @@ -29,4 +30,31 @@ export class ToOutputRenderLink implements RenderLink { ? fromReroute.pos : this.node.getInputPos(inputIndex) } + + connectToOutput(node: LGraphNode, output: INodeOutputSlot, events: LinkConnectorEventTarget) { + const { node: inputNode, fromSlot, fromReroute } = this + if (inputNode) return + + const newLink = node.connectSlots(output, inputNode, fromSlot, fromReroute?.id) + events.dispatch("link-created", newLink) + } + + connectToRerouteOutput( + reroute: Reroute, + outputNode: LGraphNode, + output: INodeOutputSlot, + events: LinkConnectorEventTarget, + ): void { + const { node: inputNode, fromSlot } = this + const newLink = outputNode.connectSlots(output, inputNode, fromSlot, reroute?.id) + events.dispatch("link-created", newLink) + } + + connectToInput() { + throw new Error("ToOutputRenderLink cannot connect to an input.") + } + + connectToRerouteInput() { + throw new Error("ToOutputRenderLink cannot connect to an input.") + } } diff --git a/src/infrastructure/LinkConnectorEventTarget.ts b/src/infrastructure/LinkConnectorEventTarget.ts index 08315d854..05bb1eeb7 100644 --- a/src/infrastructure/LinkConnectorEventTarget.ts +++ b/src/infrastructure/LinkConnectorEventTarget.ts @@ -1,3 +1,4 @@ +import type { FloatingRenderLink } from "@/canvas/FloatingRenderLink" import type { MovingRenderLink } from "@/canvas/MovingRenderLink" import type { RenderLink } from "@/canvas/RenderLink" import type { ToInputRenderLink } from "@/canvas/ToInputRenderLink" @@ -19,11 +20,11 @@ export interface LinkConnectorEventMap { event: CanvasPointerEvent } - "before-move-input": MovingRenderLink - "before-move-output": MovingRenderLink + "before-move-input": MovingRenderLink | FloatingRenderLink + "before-move-output": MovingRenderLink | FloatingRenderLink - "input-moved": MovingRenderLink - "output-moved": MovingRenderLink + "input-moved": MovingRenderLink | FloatingRenderLink + "output-moved": MovingRenderLink | FloatingRenderLink "link-created": LLink | null | undefined diff --git a/test/LinkConnector.test.ts b/test/LinkConnector.test.ts index 6b30126c5..8f9d7ec9c 100644 --- a/test/LinkConnector.test.ts +++ b/test/LinkConnector.test.ts @@ -5,54 +5,43 @@ import type { ISlotType } from "@/interfaces" import { describe, expect, test as baseTest, vi } from "vitest" import { LinkConnector } from "@/canvas/LinkConnector" +import { ToInputRenderLink } from "@/canvas/ToInputRenderLink" import { LGraph } from "@/LGraph" import { LGraphNode } from "@/LGraphNode" import { LLink } from "@/LLink" -import { Reroute } from "@/Reroute" +import { Reroute, type RerouteId } from "@/Reroute" import { LinkDirection } from "@/types/globalEnums" -type TestNetwork = LinkNetwork & { add(node: LGraphNode): void } - interface TestContext { - network: TestNetwork + network: LinkNetwork & { add(node: LGraphNode): void } connector: LinkConnector setConnectingLinks: ReturnType createTestNode: (id: number, slotType?: ISlotType) => LGraphNode createTestLink: (id: number, sourceId: number, targetId: number, slotType?: ISlotType) => LLink } -function createNetwork(): TestNetwork { - const graph = new LGraph() - const floatingLinks = new Map() - return { - links: new Map(), - reroutes: new Map(), - floatingLinks, - getNodeById: (id: number) => graph.getNodeById(id), - addFloatingLink: (link: LLink) => { - floatingLinks.set(link.id, link) - return link - }, - removeReroute: () => true, - add: (node: LGraphNode) => graph.add(node), - } -} - -function createTestNode(id: number): LGraphNode { - const node = new LGraphNode("test") - node.id = id - return node -} - -function createTestLink(id: number, sourceId: number, targetId: number, slotType: ISlotType = "number"): LLink { - return new LLink(id, slotType, sourceId, 0, targetId, 0) -} - const test = baseTest.extend({ network: async ({}, use) => { - const network = createNetwork() - await use(network) + const graph = new LGraph() + const floatingLinks = new Map() + const reroutes = new Map() + + await use({ + links: new Map(), + reroutes, + floatingLinks, + getNodeById: (id: number) => graph.getNodeById(id), + addFloatingLink: (link: LLink) => { + floatingLinks.set(link.id, link) + return link + }, + removeFloatingLink: (link: LLink) => floatingLinks.delete(link.id), + getReroute: ((id: RerouteId | null | undefined) => id == null ? undefined : reroutes.get(id)) as LinkNetwork["getReroute"], + removeReroute: (id: number) => reroutes.delete(id), + add: (node: LGraphNode) => graph.add(node), + }) }, + setConnectingLinks: async ({}, use: (mock: ReturnType) => Promise) => { const mock = vi.fn() await use(mock) @@ -61,11 +50,26 @@ const test = baseTest.extend({ const connector = new LinkConnector(setConnectingLinks) await use(connector) }, - createTestNode: async ({}, use) => { - await use(createTestNode) + + createTestNode: async ({ network }, use) => { + await use((id: number): LGraphNode => { + const node = new LGraphNode("test") + node.id = id + network.add(node) + return node + }) }, - createTestLink: async ({}, use) => { - await use(createTestLink) + createTestLink: async ({ network }, use) => { + await use(( + id: number, + sourceId: number, + targetId: number, + slotType: ISlotType = "number", + ): LLink => { + const link = new LLink(id, slotType, sourceId, 0, targetId, 0) + network.links.set(link.id, link) + return link + }) }, }) @@ -90,8 +94,6 @@ describe("LinkConnector", () => { const slotType: ISlotType = "number" sourceNode.addOutput("out", slotType) targetNode.addInput("in", slotType) - network.add(sourceNode) - network.add(targetNode) const link = new LLink(1, slotType, 1, 0, 2, 0) network.links.set(link.id, link) @@ -122,8 +124,6 @@ describe("LinkConnector", () => { const slotType: ISlotType = "number" sourceNode.addOutput("out", slotType) targetNode.addInput("in", slotType) - network.add(sourceNode) - network.add(targetNode) const link = new LLink(1, slotType, 1, 0, 2, 0) network.links.set(link.id, link) @@ -173,6 +173,36 @@ describe("LinkConnector", () => { }) }) + describe("Dragging from reroutes", () => { + test("should handle dragging from reroutes", ({ network, connector, createTestNode, createTestLink }) => { + const originNode = createTestNode(1) + const targetNode = createTestNode(2) + + const output = originNode.addOutput("out", "number") + targetNode.addInput("in", "number") + + const link = createTestLink(1, 1, 2) + const reroute = new Reroute(1, network, [0, 0], undefined, [link.id]) + network.reroutes.set(reroute.id, reroute) + link.parentId = reroute.id + + connector.dragFromReroute(network, reroute) + + expect(connector.state.connectingTo).toBe("input") + expect(connector.state.draggingExistingLinks).toBe(false) + expect(connector.renderLinks.length).toBe(1) + + const renderLink = connector.renderLinks[0] + expect(renderLink instanceof ToInputRenderLink).toBe(true) + expect(renderLink.toType).toEqual("input") + expect(renderLink.node).toEqual(originNode) + expect(renderLink.fromSlot).toEqual(output) + expect(renderLink.fromReroute).toEqual(reroute) + expect(renderLink.fromDirection).toEqual(LinkDirection.NONE) + expect(renderLink.network).toEqual(network) + }) + }) + describe("Reset", () => { test("should reset state and clear links", ({ network, connector }) => { connector.state.connectingTo = "input"