Fix moving existing links can result in loopback (#838)

Prevents nodes connecting links to themselves when moving existing
links.

If moving multiple links with reroutes, this will instead _reconnect_
any links that would become loopbacks, only without any rereoutes.
This commit is contained in:
filtered
2025-03-24 06:00:55 +11:00
committed by GitHub
parent ff6281c840
commit 05587d8a19
8 changed files with 187 additions and 54 deletions

View File

@@ -2597,8 +2597,8 @@ export class LGraphCanvas implements ConnectionColorContext {
let highlightPos: Point | undefined
let highlightInput: INodeInputSlot | undefined
if (!firstLink || firstLink.node === node) {
// No link / node loopback
if (!firstLink || !linkConnector.isNodeValidDrop(node)) {
// No link, or none of the dragged links may be dropped here
} else if (linkConnector.state.connectingTo === "input") {
if (inputId === -1 && outputId === -1) {
// Allow support for linking to widgets, handled externally to LiteGraph

View File

@@ -2330,6 +2330,14 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
return null
}
canConnectTo(
node: LGraphNode,
toSlot: INodeInputSlot,
fromSlot: INodeOutputSlot,
) {
return this.id !== node.id && LiteGraph.isValidConnection(fromSlot.type, toSlot.type)
}
/**
* Connect an output of this node to an input of another node
* @param slot (could be the number of the slot or the string with the name of the slot)

View File

@@ -96,6 +96,14 @@ export class FloatingRenderLink implements RenderLink {
this.fromPos = fromReroute.pos
}
canConnectToInput(): true {
return true
}
canConnectToOutput(): true {
return true
}
canConnectToReroute(reroute: Reroute): boolean {
if (this.toType === "input") {
if (reroute.origin_id === this.inputNode?.id) return false

View File

@@ -1,5 +1,5 @@
import type { RenderLink } from "./RenderLink"
import type { ConnectingLink, ISlotType, ItemLocator, LinkNetwork, LinkSegment } from "@/interfaces"
import type { ConnectingLink, ItemLocator, LinkNetwork, LinkSegment } from "@/interfaces"
import type { INodeInputSlot, INodeOutputSlot } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { Reroute } from "@/Reroute"
@@ -7,7 +7,6 @@ import type { CanvasPointerEvent } from "@/types/events"
import type { IWidget } from "@/types/widgets"
import { LinkConnectorEventMap, LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget"
import { LiteGraph } from "@/litegraph"
import { LLink } from "@/LLink"
import { LinkDirection } from "@/types/globalEnums"
@@ -338,6 +337,9 @@ export class LinkConnector {
const { connectingTo } = state
const { canvasX, canvasY } = event
// Do nothing if every connection would loop back
if (renderLinks.every(link => link.node === node)) return
// To output
if (connectingTo === "output") {
const output = node.getOutputOnPos([canvasX, canvasY])
@@ -437,7 +439,7 @@ export class LinkConnector {
if (!result) continue
const { node, output } = result
if (!isValidConnectionToOutput(link, node, output)) continue
if (!link.canConnectToOutput(node, output)) continue
link.connectToRerouteOutput(reroute, node, output, this.events)
}
@@ -468,7 +470,7 @@ export class LinkConnector {
// Assume all links are the same type, disallow loopback
const firstLink = this.renderLinks[0]
if (!firstLink || firstLink.node === node) return
if (!firstLink) return
// Use a single type check before looping; ensures all dropped links go to the same slot
if (connectingTo === "output") {
@@ -479,9 +481,7 @@ export class LinkConnector {
return
}
for (const link of this.renderLinks) {
link.connectToOutput(node, output, this.events)
}
this.#dropOnOutput(node, output)
} else if (connectingTo === "input") {
// Dropping new input link
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
@@ -490,15 +490,13 @@ export class LinkConnector {
return
}
for (const link of this.renderLinks) {
link.connectToInput(node, input, this.events)
}
this.#dropOnInput(node, input)
}
}
#dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
for (const link of this.renderLinks) {
if (link.toType !== "input") continue
if (!link.canConnectToInput(node, input)) continue
link.connectToInput(node, input, this.events)
}
@@ -506,12 +504,24 @@ export class LinkConnector {
#dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
for (const link of this.renderLinks) {
if (link.toType !== "output") continue
if (!link.canConnectToOutput(node, output)) {
if (link instanceof MovingRenderLink && link.link.parentId !== undefined) {
// Reconnect link without reroutes
link.outputNode.connectSlots(link.outputSlot, link.inputNode, link.inputSlot, undefined!)
}
continue
}
link.connectToOutput(node, output, this.events)
}
}
isNodeValidDrop(node: LGraphNode): boolean {
return this.state.connectingTo === "output"
? node.outputs.some(output => this.renderLinks.some(link => link.canConnectToOutput(node, output)))
: node.inputs.some(input => this.renderLinks.some(link => link.canConnectToInput(node, input)))
}
/**
* Checks if a reroute is a valid drop target for any of the links being connected.
* @param reroute The reroute that would be dropped on.
@@ -537,7 +547,7 @@ export class LinkConnector {
for (const renderLink of this.renderLinks) {
if (renderLink.toType !== "output") continue
if (!renderLink.canConnectToReroute(reroute)) continue
if (isValidConnectionToOutput(renderLink, node, output)) return true
if (renderLink.canConnectToOutput(node, output)) return true
}
}
@@ -622,36 +632,21 @@ export class LinkConnector {
}
}
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 } } = link
// Link is already connected here / type mismatch
if (!LiteGraph.isValidConnection(type, output.type)) {
return false
}
} else {
const { fromSlot: { type } } = link
if (!LiteGraph.isValidConnection(type, output.type)) return false
}
return true
}
/** Validates that a single {@link RenderLink} can be dropped on the specified reroute. */
function canConnectInputLinkToReroute(link: ToInputRenderLink | MovingRenderLink | FloatingRenderLink, inputNode: LGraphNode, input: INodeInputSlot, reroute: Reroute): boolean {
const { node: fromNode, fromSlot, fromReroute } = link
function canConnectInputLinkToReroute(
link: ToInputRenderLink | MovingRenderLink | FloatingRenderLink,
inputNode: LGraphNode,
input: INodeInputSlot,
reroute: Reroute,
): boolean {
const { fromReroute } = link
if (
// Node cannot connect to itself
fromNode === inputNode ||
!link.canConnectToInput(inputNode, input) ||
// Would result in no change
fromReroute?.id === reroute.id ||
isInvalid(fromSlot.type, reroute, fromReroute)
// Cannot connect from child to parent reroute
fromReroute?.getReroutes()?.includes(reroute)
) {
return false
}
@@ -666,14 +661,4 @@ function canConnectInputLinkToReroute(link: ToInputRenderLink | MovingRenderLink
}
}
return true
/** Checks connection type & rejects infinite loops. */
function isInvalid(type: ISlotType, reroute: Reroute, fromReroute?: Reroute): boolean {
return Boolean(
// Type mismatch
!LiteGraph.isValidConnection(type, input.type) ||
// Cannot connect from child to parent reroute
fromReroute?.getReroutes()?.includes(reroute),
)
}
}

View File

@@ -86,6 +86,14 @@ export class MovingRenderLink implements RenderLink {
this.fromSlotIndex = this.toType === "input" ? outputIndex : inputIndex
}
canConnectToInput(inputNode: LGraphNode, input: INodeInputSlot): this is this {
return this.node.canConnectTo(inputNode, input, this.outputSlot)
}
canConnectToOutput(outputNode: LGraphNode, output: INodeOutputSlot): this is this {
return outputNode.canConnectTo(this.node, this.inputSlot, output)
}
canConnectToReroute(reroute: Reroute): boolean {
if (this.toType === "input") {
if (reroute.origin_id === this.inputNode.id) return false

View File

@@ -1,8 +1,8 @@
import type { RenderLink } from "./RenderLink"
import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget"
import type { LinkNetwork, Point } from "@/interfaces"
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { INodeInputSlot, INodeOutputSlot, LLink } from "@/litegraph"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import { LinkDirection } from "@/types/globalEnums"
@@ -31,6 +31,14 @@ export class ToInputRenderLink implements RenderLink {
: this.node.getOutputPos(outputIndex)
}
canConnectToInput(inputNode: LGraphNode, input: INodeInputSlot): this is this {
return this.node.canConnectTo(inputNode, input, this.fromSlot)
}
canConnectToOutput(): false {
return false
}
connectToInput(node: LGraphNode, input: INodeInputSlot, events: LinkConnectorEventTarget) {
const { node: outputNode, fromSlot, fromReroute } = this
if (node === outputNode) return

View File

@@ -1,8 +1,7 @@
import type { RenderLink } from "./RenderLink"
import type { LinkConnectorEventTarget } from "@/infrastructure/LinkConnectorEventTarget"
import type { LinkNetwork, Point } from "@/interfaces"
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
import type { LGraphNode } from "@/LGraphNode"
import type { INodeInputSlot, INodeOutputSlot } from "@/litegraph"
import type { Reroute } from "@/Reroute"
import { LinkDirection } from "@/types/globalEnums"
@@ -31,6 +30,14 @@ export class ToOutputRenderLink implements RenderLink {
: this.node.getInputPos(inputIndex)
}
canConnectToInput(): false {
return false
}
canConnectToOutput(outputNode: LGraphNode, output: INodeOutputSlot): this is this {
return this.node.canConnectTo(outputNode, this.fromSlot, output)
}
canConnectToReroute(reroute: Reroute): boolean {
if (reroute.origin_id === this.node.id) return false
return true

View File

@@ -1,3 +1,5 @@
import type { CanvasPointerEvent } from "@/types/events"
import { afterEach, describe, expect, vi } from "vitest"
import { LinkConnector } from "@/canvas/LinkConnector"
@@ -120,6 +122,29 @@ const test = baseTest.extend<TestContext>({
},
})
function mockedNodeTitleDropEvent(node: LGraphNode): CanvasPointerEvent {
return {
canvasX: node.pos[0] + node.size[0] / 2,
canvasY: node.pos[1] + 16,
} as any
}
function mockedInputDropEvent(node: LGraphNode, slot: number): CanvasPointerEvent {
const pos = node.getInputPos(slot)
return {
canvasX: pos[0],
canvasY: pos[1],
} as any
}
function mockedOutputDropEvent(node: LGraphNode, slot: number): CanvasPointerEvent {
const pos = node.getOutputPos(slot)
return {
canvasX: pos[0],
canvasY: pos[1],
} as any
}
describe("LinkConnector Integration", () => {
afterEach<TestContext>(({ validateLinkIntegrity }) => {
validateLinkIntegrity()
@@ -223,6 +248,44 @@ describe("LinkConnector Integration", () => {
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
}
})
test("Should prevent node loopback when dropping on node", ({ graph, connector }) => {
const hasOutputNode = graph.getNodeById(1)!
const hasInputNode = graph.getNodeById(2)!
const hasInputNode2 = graph.getNodeById(3)!
const reroutesBefore = LLink.getReroutes(graph, graph.links.get(hasInputNode.inputs[0].link!)!)
const atOutputNodeEvent = mockedNodeTitleDropEvent(hasOutputNode)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.dropLinks(graph, atOutputNodeEvent)
const outputNodes = hasOutputNode.getOutputNodes(0)
expect(outputNodes).toEqual([hasInputNode, hasInputNode2])
const reroutesAfter = LLink.getReroutes(graph, graph.links.get(hasInputNode.inputs[0].link!)!)
expect(reroutesAfter).toEqual(reroutesBefore)
})
test("Should prevent node loopback when dropping on input", ({ graph, connector }) => {
const hasOutputNode = graph.getNodeById(1)!
const hasInputNode = graph.getNodeById(2)!
const originalOutputNodes = hasOutputNode.getOutputNodes(0)
const reroutesBefore = LLink.getReroutes(graph, graph.links.get(hasInputNode.inputs[0].link!)!)
const atHasOutputNode = mockedInputDropEvent(hasOutputNode, 0)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.dropLinks(graph, atHasOutputNode)
const outputNodes = hasOutputNode.getOutputNodes(0)
expect(outputNodes).toEqual(originalOutputNodes)
const reroutesAfter = LLink.getReroutes(graph, graph.links.get(hasInputNode.inputs[0].link!)!)
expect(reroutesAfter).toEqual(reroutesBefore)
})
})
describe("Moving output links", () => {
@@ -392,6 +455,52 @@ describe("LinkConnector Integration", () => {
expect(graph.floatingLinks.size).toBe(1)
expect(floatingReroute.linkIds.size).toBe(0)
})
test("Should prevent node loopback when dropping on node", ({ graph, connector }) => {
const hasOutputNode = graph.getNodeById(1)!
const hasInputNode = graph.getNodeById(2)!
const reroutesBefore = LLink.getReroutes(graph, graph.links.get(hasOutputNode.outputs[0].links![0])!)
const atInputNodeEvent = mockedNodeTitleDropEvent(hasInputNode)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeEvent)
expect(hasOutputNode.getOutputNodes(0)).toEqual([hasInputNode])
expect(hasInputNode.getOutputNodes(0)).toEqual([graph.getNodeById(3)])
// Moved link should have the same reroutes
const reroutesAfter = LLink.getReroutes(graph, graph.links.get(hasInputNode.outputs[0].links![0])!)
expect(reroutesAfter).toEqual(reroutesBefore)
// Link recreated to avoid loopback should have no reroutes
const reroutesAfter2 = LLink.getReroutes(graph, graph.links.get(hasOutputNode.outputs[0].links![0])!)
expect(reroutesAfter2).toEqual([])
})
test("Should prevent node loopback when dropping on output", ({ graph, connector }) => {
const hasOutputNode = graph.getNodeById(1)!
const hasInputNode = graph.getNodeById(2)!
const reroutesBefore = LLink.getReroutes(graph, graph.links.get(hasOutputNode.outputs[0].links![0])!)
const atInputNodeOutSlot = mockedOutputDropEvent(hasInputNode, 0)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeOutSlot)
expect(hasOutputNode.getOutputNodes(0)).toEqual([hasInputNode])
expect(hasInputNode.getOutputNodes(0)).toEqual([graph.getNodeById(3)])
// Moved link should have the same reroutes
const reroutesAfter = LLink.getReroutes(graph, graph.links.get(hasInputNode.outputs[0].links![0])!)
expect(reroutesAfter).toEqual(reroutesBefore)
// Link recreated to avoid loopback should have no reroutes
const reroutesAfter2 = LLink.getReroutes(graph, graph.links.get(hasOutputNode.outputs[0].links![0])!)
expect(reroutesAfter2).toEqual([])
})
})
describe("Floating links", () => {