[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
This commit is contained in:
filtered
2025-03-22 06:41:20 +11:00
committed by GitHub
parent 87aeab16a0
commit 850d1b9652
10 changed files with 624 additions and 224 deletions

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
LinkNetwork,
LinkSegment,
Point,
@@ -255,14 +256,11 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
?.findNextReroute(withParentId, visited)
}
findSourceOutput() {
const network = this.#network.deref()
if (!network) return
findSourceOutput(): { node: LGraphNode, output: INodeOutputSlot, outputIndex: number, link: LLink } | undefined {
const link = this.firstLink ?? this.firstFloatingLink
if (!link) return
const node = network.getNodeById(link.origin_id)
const node = this.#network.deref()?.getNodeById(link.origin_id)
if (!node) return
return {
@@ -310,6 +308,25 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
}
}
/**
* Retrieves all floating links passing through this reroute.
* @param from Filters the links by the currently connected link side.
* @returns An array of floating links
*/
getFloatingLinks(from: "input" | "output"): LLink[] | undefined {
const floatingLinks = this.#network.deref()?.floatingLinks
if (!floatingLinks) return
const idProp = from === "input" ? "origin_id" : "target_id"
const out: LLink[] = []
for (const linkId of this.floatingLinkIds) {
const link = floatingLinks.get(linkId)
if (link?.[idProp] === -1) out.push(link)
}
return out
}
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
this.#pos[0] += deltaX
@@ -326,6 +343,43 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
return true
}
removeAllFloatingLinks() {
for (const linkId of this.floatingLinkIds) {
this.removeFloatingLink(linkId)
}
}
removeFloatingLink(linkId: LinkId) {
const network = this.#network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(linkId)
if (!floatingLink) {
console.warn(`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`)
this.floatingLinkIds.delete(linkId)
return
}
network.removeFloatingLink(floatingLink)
}
/**
* Removes a link or floating link from this reroute, by matching link object instance equality.
* @param link The link to remove.
* @remarks Does not remove the link from the network.
*/
removeLink(link: LLink) {
const network = this.#network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(link.id)
if (link === floatingLink) {
this.floatingLinkIds.delete(link.id)
} else {
this.linkIds.delete(link.id)
}
}
remove() {
const network = this.#network.deref()
if (!network) return

View File

@@ -0,0 +1,157 @@
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"
import type { LGraphNode, NodeId } from "@/LGraphNode"
import type { LLink } from "@/LLink"
import type { Reroute } from "@/Reroute"
import { LinkDirection } from "@/types/globalEnums"
/**
* Represents a floating link that is currently being dragged from one slot to another.
*
* This is a heavier, but short-lived convenience data structure. All refs to FloatingRenderLinks should be discarded on drop.
* @remarks
* At time of writing, Litegraph is using several different styles and methods to handle link dragging.
*
* Once the library has undergone more substantial changes to the way links are managed,
* many properties of this class will be superfluous and removable.
*/
export class FloatingRenderLink implements RenderLink {
readonly node: LGraphNode
readonly fromSlot: INodeOutputSlot | INodeInputSlot
readonly fromPos: Point
readonly fromDirection: LinkDirection
readonly fromSlotIndex: number
readonly outputNodeId: NodeId = -1
readonly outputNode?: LGraphNode
readonly outputSlot?: INodeOutputSlot
readonly outputIndex: number = -1
readonly outputPos?: Point
readonly inputNodeId: NodeId = -1
readonly inputNode?: LGraphNode
readonly inputSlot?: INodeInputSlot
readonly inputIndex: number = -1
readonly inputPos?: Point
constructor(
readonly network: LinkNetwork,
readonly link: LLink,
readonly toType: "input" | "output",
readonly fromReroute: Reroute,
readonly dragDirection: LinkDirection = LinkDirection.CENTER,
) {
const {
origin_id: outputNodeId,
target_id: inputNodeId,
origin_slot: outputIndex,
target_slot: inputIndex,
} = link
if (outputNodeId !== -1) {
// Output connected
const outputNode = network.getNodeById(outputNodeId) ?? undefined
if (!outputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output node [${outputNodeId}] not found.`)
const outputSlot = outputNode?.outputs.at(outputIndex)
if (!outputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output slot [${outputIndex}] not found.`)
this.outputNodeId = outputNodeId
this.outputNode = outputNode
this.outputSlot = outputSlot
this.outputIndex = outputIndex
this.outputPos = outputNode.getOutputPos(outputIndex)
// RenderLink props
this.node = outputNode
this.fromSlot = outputSlot
this.fromPos = fromReroute?.pos ?? this.outputPos
this.fromDirection = LinkDirection.LEFT
this.dragDirection = LinkDirection.RIGHT
this.fromSlotIndex = outputIndex
} else {
// Input connected
const inputNode = network.getNodeById(inputNodeId) ?? undefined
if (!inputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input node [${inputNodeId}] not found.`)
const inputSlot = inputNode?.inputs.at(inputIndex)
if (!inputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input slot [${inputIndex}] not found.`)
this.inputNodeId = inputNodeId
this.inputNode = inputNode
this.inputSlot = inputSlot
this.inputIndex = inputIndex
this.inputPos = inputNode.getInputPos(inputIndex)
// RenderLink props
this.node = inputNode
this.fromSlot = inputSlot
this.fromDirection = LinkDirection.RIGHT
this.fromSlotIndex = inputIndex
}
this.fromPos = fromReroute.pos
}
connectToInput(node: LGraphNode, input: INodeInputSlot, _events?: LinkConnectorEventTarget): void {
const floatingLink = this.link
floatingLink.target_id = node.id
floatingLink.target_slot = node.inputs.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToOutput(node: LGraphNode, output: INodeOutputSlot, _events?: LinkConnectorEventTarget): void {
const floatingLink = this.link
floatingLink.origin_id = node.id
floatingLink.origin_slot = node.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
console.debug(`Set origin_id:origin_slot [${floatingLink.origin_id}:${floatingLink.origin_slot}].`)
}
connectToRerouteInput(
reroute: Reroute,
{ node: inputNode, input }: { node: LGraphNode, input: INodeInputSlot },
events: LinkConnectorEventTarget,
) {
const floatingLink = this.link
floatingLink.target_id = inputNode.id
floatingLink.target_slot = inputNode.inputs.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
console.debug(`Set target_id:target_slot [${floatingLink.target_id}:${floatingLink.target_slot}].`)
events.dispatch("input-moved", this)
}
connectToRerouteOutput(
reroute: Reroute,
outputNode: LGraphNode,
output: INodeOutputSlot,
events: LinkConnectorEventTarget,
) {
const floatingLink = this.link
floatingLink.origin_id = outputNode.id
floatingLink.origin_slot = outputNode.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
console.debug(`Set origin_id:origin_slot [${floatingLink.origin_id}:${floatingLink.origin_slot}].`)
events.dispatch("output-moved", this)
}
}

View File

@@ -11,6 +11,7 @@ import { LiteGraph } from "@/litegraph"
import { LLink } from "@/LLink"
import { LinkDirection } from "@/types/globalEnums"
import { FloatingRenderLink } from "./FloatingRenderLink"
import { MovingRenderLink } from "./MovingRenderLink"
import { ToInputRenderLink } from "./ToInputRenderLink"
import { ToOutputRenderLink } from "./ToOutputRenderLink"
@@ -33,12 +34,13 @@ export interface LinkConnectorState {
}
/** Discriminated union to simplify type narrowing. */
type RenderLinkUnion = MovingRenderLink | ToInputRenderLink | ToOutputRenderLink
type RenderLinkUnion = MovingRenderLink | FloatingRenderLink | ToInputRenderLink | ToOutputRenderLink
export interface LinkConnectorExport {
renderLinks: RenderLink[]
inputLinks: LLink[]
outputLinks: LLink[]
floatingLinks: LLink[]
state: LinkConnectorState
network: LinkNetwork
}
@@ -69,6 +71,8 @@ export class LinkConnector {
readonly inputLinks: LLink[] = []
/** Existing links that are being moved **to** a new output slot. */
readonly outputLinks: LLink[] = []
/** Existing floating links that are being moved to a new slot. */
readonly floatingLinks: LLink[] = []
readonly hiddenReroutes: Set<Reroute> = 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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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

View File

@@ -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<typeof vi.fn>
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<number, LLink>()
return {
links: new Map<number, LLink>(),
reroutes: new Map<number, Reroute>(),
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<TestContext>({
network: async ({}, use) => {
const network = createNetwork()
await use(network)
const graph = new LGraph()
const floatingLinks = new Map<number, LLink>()
const reroutes = new Map<number, Reroute>()
await use({
links: new Map<number, LLink>(),
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<typeof vi.fn>) => Promise<void>) => {
const mock = vi.fn()
await use(mock)
@@ -61,11 +50,26 @@ const test = baseTest.extend<TestContext>({
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"