Increase vue slot/link functionality (#5710)

## Summary

Increase functionality for slots and links, covered with playwright
tests.

## Features

- Allow for reroute anchors to work when dragging from input slot
- Allow for dragging existing links from input slot
- Allow for ctrl/command + alt to create new link from input slot
- Allow shift to drag all connected links on output slot
- Connect links with reroutes (only when dragged from vue slot)

## Tests Added

### Playwright

- Dragging input to input drags existing link
- Dropping an input link back on its slot restores the original
connection
- Ctrl+alt drag from an input starts a fresh link
- Should reuse the existing origin when dragging an input link
- Shift-dragging an output with multiple links should drag all links
- Rerouted input drag preview remains anchored to reroute
- Rerouted output shift-drag preview remains anchored to reroute

## Notes

The double rendering system for links being dragged, it works right now,
maybe they can be coalesced later.

Edit: As in the adapter, can be removed in a followup PR

Also, it's known that more features will arrive in smaller PRs, this PR
actually should've been much smaller.

The next ones coming up are drop on canvas support, snap to node, type
compatibility highlighting, and working with subgraphs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5710-Increase-vue-slot-link-functionality-2756d73d3650814f8995f7782244803b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Benjamin Lu
2025-09-29 18:23:32 -07:00
committed by GitHub
parent d852bab617
commit 5d0aee59a6
20 changed files with 1143 additions and 268 deletions

View File

@@ -11,6 +11,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import type { RenderLink } from './RenderLink'
@@ -99,6 +100,14 @@ export abstract class MovingLinkBase implements RenderLink {
this.inputPos = inputNode.getInputPos(inputIndex)
}
abstract canConnectToInput(
inputNode: NodeLike,
input: INodeInputSlot
): boolean
abstract canConnectToOutput(
outputNode: NodeLike,
output: INodeOutputSlot
): boolean
abstract connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -11,6 +11,7 @@ import type {
import type { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
export interface RenderLink {
@@ -38,6 +39,17 @@ export interface RenderLink {
/** The reroute that the link is being connected from. */
readonly fromReroute?: Reroute
/**
* Capability checks used for hit-testing and validation during drag.
* Implementations should return `false` when a connection is not possible
* rather than throwing.
*/
canConnectToInput(node: NodeLike, input: INodeInputSlot): boolean
canConnectToOutput(node: NodeLike, output: INodeOutputSlot): boolean
/** Optional: only some links support validating subgraph IO or reroutes. */
canConnectToSubgraphInput?(input: SubgraphInput): boolean
canConnectToReroute?(reroute: Reroute): boolean
connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -0,0 +1,152 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
// Keep one adapter per graph so rendering and interaction share state.
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
/**
* Rendereragnostic adapter around LiteGraph's LinkConnector.
*
* - Uses layoutStore for hittesting (nodes/reroutes).
* - Exposes minimal, imperative APIs to begin link drags and query drop validity.
* - Preserves existing Vue composable behavior.
*/
export class LinkConnectorAdapter {
readonly linkConnector: LinkConnector
constructor(
/** Network the links belong to (typically `app.canvas.graph`). */
readonly network: LGraph
) {
// No-op legacy setter to avoid side effects when connectors update
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
this.linkConnector = new LinkConnector(setConnectingLinks)
}
/**
* The currently rendered/dragged links, typed for consumer use.
* Prefer this over accessing `linkConnector.renderLinks` directly.
*/
get renderLinks(): ReadonlyArray<RenderLink> {
return this.linkConnector.renderLinks
}
// Drag helpers
/**
* Begin dragging from an output slot.
* @param nodeId Output node id
* @param outputIndex Output slot index
* @param opts Optional: moveExisting (shift), fromRerouteId
*/
beginFromOutput(
nodeId: NodeId,
outputIndex: number,
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
): void {
const node = this.network.getNodeById(nodeId)
const output = node?.outputs?.[outputIndex]
if (!node || !output) return
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveOutputLink(this.network, output)
} else {
this.linkConnector.dragNewFromOutput(
this.network,
node,
output,
fromReroute
)
}
}
/**
* Begin dragging from an input slot.
* @param nodeId Input node id
* @param inputIndex Input slot index
* @param opts Optional: moveExisting (when a link/floating exists), fromRerouteId
*/
beginFromInput(
nodeId: NodeId,
inputIndex: number,
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
): void {
const node = this.network.getNodeById(nodeId)
const input = node?.inputs?.[inputIndex]
if (!node || !input) return
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveInputLink(this.network, input)
} else {
this.linkConnector.dragNewFromInput(
this.network,
node,
input,
fromReroute
)
}
}
// Validation helpers
isNodeValidDrop(nodeId: NodeId): boolean {
const node = this.network.getNodeById(nodeId)
if (!node) return false
return this.linkConnector.isNodeValidDrop(node)
}
isInputValidDrop(nodeId: NodeId, inputIndex: number): boolean {
const node = this.network.getNodeById(nodeId)
const input = node?.inputs?.[inputIndex]
if (!node || !input) return false
return this.linkConnector.isInputValidDrop(node, input)
}
isOutputValidDrop(nodeId: NodeId, outputIndex: number): boolean {
const node = this.network.getNodeById(nodeId)
const output = node?.outputs?.[outputIndex]
if (!node || !output) return false
return this.linkConnector.renderLinks.some((link) =>
link.canConnectToOutput(node, output)
)
}
isRerouteValidDrop(rerouteId: RerouteId): boolean {
const reroute = this.network.getReroute(rerouteId)
if (!reroute) return false
return this.linkConnector.isRerouteValidDrop(reroute)
}
// Drop/cancel helpers for future flows
/** Disconnects moving links (drop on canvas/no target). */
disconnectMovingLinks(): void {
this.linkConnector.disconnectLinks()
}
/** Resets connector state and clears any temporary flags. */
reset(): void {
this.linkConnector.reset()
}
}
/** Convenience creator using the current app canvas graph. */
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph as LGraph | undefined
if (!graph) return null
let adapter = adapterByGraph.get(graph)
if (!adapter) {
adapter = new LinkConnectorAdapter(graph)
adapterByGraph.set(graph, adapter)
}
return adapter
}

View File

@@ -1,72 +0,0 @@
import { getActivePinia } from 'pinia'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/canvas/links/slotLinkDragState'
interface CompatibilityResult {
allowable: boolean
targetNode?: LGraphNode
targetSlot?: INodeInputSlot | INodeOutputSlot
}
function resolveNode(nodeId: NodeId) {
const pinia = getActivePinia()
const canvasStore = pinia ? useCanvasStore() : null
const graph = canvasStore?.canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null
return graph.getNodeById(id)
}
export function evaluateCompatibility(
source: SlotDragSource,
candidate: SlotDropCandidate
): CompatibilityResult {
if (candidate.layout.nodeId === source.nodeId) {
return { allowable: false }
}
const isOutputToInput =
source.type === 'output' && candidate.layout.type === 'input'
const isInputToOutput =
source.type === 'input' && candidate.layout.type === 'output'
if (!isOutputToInput && !isInputToOutput) {
return { allowable: false }
}
const sourceNode = resolveNode(source.nodeId)
const targetNode = resolveNode(candidate.layout.nodeId)
if (!sourceNode || !targetNode) {
return { allowable: false }
}
if (isOutputToInput) {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[candidate.layout.index]
if (!outputSlot || !inputSlot) {
return { allowable: false }
}
const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: inputSlot }
}
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[candidate.layout.index]
if (!inputSlot || !outputSlot) {
return { allowable: false }
}
const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: outputSlot }
}

View File

@@ -7,12 +7,14 @@ import type { Point, SlotLayout } from '@/renderer/core/layout/types'
type SlotDragType = 'input' | 'output'
export interface SlotDragSource {
interface SlotDragSource {
nodeId: string
slotIndex: number
type: SlotDragType
direction: LinkDirection
position: Readonly<Point>
linkId?: number
movingExistingOutput?: boolean
}
export interface SlotDropCandidate {

View File

@@ -1,16 +1,13 @@
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type {
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import {
type SlotDragSource,
useSlotLinkDragState
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
@@ -39,57 +36,73 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
originalOnDrawForeground?.(ctx, area)
const { state } = useSlotLinkDragState()
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
if (canvas.linkConnector?.isConnecting) return
if (!state.active || !state.source) return
const { pointer, source } = state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const { pointer } = state
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const from: ReadOnlyPoint = [start.x, start.y]
const renderLinks = createLinkConnectorAdapter()?.renderLinks
if (!renderLinks || renderLinks.length === 0) return
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
const startDir = source.direction ?? LinkDirection.RIGHT
const endDir = LinkDirection.CENTER
const colour = resolveConnectingLinkColor(sourceSlot?.type)
ctx.save()
for (const link of renderLinks) {
const startDir = link.fromDirection ?? LinkDirection.RIGHT
const endDir = link.dragDirection ?? LinkDirection.CENTER
const colour = resolveConnectingLinkColor(link.fromSlot.type)
linkRenderer.renderDraggingLink(
ctx,
from,
to,
colour,
startDir,
endDir,
context
)
const fromPoint = resolveRenderLinkOrigin(link)
linkRenderer.renderDraggingLink(
ctx,
fromPoint,
to,
colour,
startDir,
endDir,
context
)
}
ctx.restore()
}
canvas.onDrawForeground = patched
}
function resolveSourceSlot(
canvas: LGraphCanvas,
source: SlotDragSource
): INodeInputSlot | INodeOutputSlot | undefined {
const graph = canvas.graph
if (!graph) return undefined
function resolveRenderLinkOrigin(link: RenderLink): ReadOnlyPoint {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) {
return [rerouteLayout.position.x, rerouteLayout.position.y]
}
const nodeId = Number(source.nodeId)
if (!Number.isFinite(nodeId)) return undefined
const [x, y] = link.fromReroute.pos
return [x, y]
}
const node = graph.getNodeById(nodeId)
if (!node) return undefined
const nodeId = getRenderLinkNodeId(link)
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) {
return [layout.position.x, layout.position.y]
}
}
return source.type === 'output'
? node.outputs?.[source.slotIndex]
: node.inputs?.[source.slotIndex]
return link.fromPos
}
function getRenderLinkNodeId(link: RenderLink): number | null {
const node = link.node
if (typeof node === 'object' && node !== null && 'id' in node) {
const maybeId = node.id
if (typeof maybeId === 'number') return maybeId
}
return null
}

View File

@@ -1,5 +1,9 @@
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
export function toPoint(x: number, y: number): Point {
return { x, y }
}
export function isPointEqual(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y
}

View File

@@ -2,15 +2,26 @@ import { type Fn, useEventListener } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
type SlotDropCandidate,
useSlotLinkDragState
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { app } from '@/scripts/app'
interface SlotInteractionOptions {
@@ -92,10 +103,22 @@ export function useSlotLinkInteraction({
const candidate: SlotDropCandidate = { layout, compatible: false }
if (state.source) {
candidate.compatible = evaluateCompatibility(
state.source,
candidate
).allowable
const canvas = app.canvas
const graph = canvas?.graph
const adapter = ensureActiveAdapter()
if (graph && adapter) {
if (layout.type === 'input') {
candidate.compatible = adapter.isInputValidDrop(
layout.nodeId,
layout.index
)
} else if (layout.type === 'output') {
candidate.compatible = adapter.isOutputValidDrop(
layout.nodeId,
layout.index
)
}
}
}
return candidate
@@ -104,10 +127,138 @@ export function useSlotLinkInteraction({
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
return activeAdapter
}
function hasCanConnectToReroute(
link: RenderLink
): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } {
return 'canConnectToReroute' in link
}
type ToInputLink = RenderLink & { toType: 'input' }
type ToOutputLink = RenderLink & { toType: 'output' }
const isToInputLink = (link: RenderLink): link is ToInputLink =>
link.toType === 'input'
const isToOutputLink = (link: RenderLink): link is ToOutputLink =>
link.toType === 'output'
function connectLinksToInput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
inputSlot: INodeInputSlot
): boolean {
const validCandidates = links
.filter(isToInputLink)
.filter((link) => link.canConnectToInput(node, inputSlot))
for (const link of validCandidates) {
link.connectToInput(node, inputSlot, activeAdapter?.linkConnector.events)
}
return validCandidates.length > 0
}
function connectLinksToOutput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
outputSlot: INodeOutputSlot
): boolean {
const validCandidates = links
.filter(isToOutputLink)
.filter((link) => link.canConnectToOutput(node, outputSlot))
for (const link of validCandidates) {
link.connectToOutput(
node,
outputSlot,
activeAdapter?.linkConnector.events
)
}
return validCandidates.length > 0
}
const resolveLinkOrigin = (
link: LLink | undefined
): { position: Point; direction: LinkDirection } | null => {
if (!link) return null
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
const layout = layoutStore.getSlotLayout(slotKey)
if (!layout) return null
return { position: { ...layout.position }, direction: LinkDirection.NONE }
}
const resolveExistingInputLinkAnchor = (
graph: LGraph,
inputSlot: INodeInputSlot | undefined
): { position: Point; direction: LinkDirection } | null => {
if (!inputSlot) return null
const directLink = graph.getLink(inputSlot.link)
if (directLink) {
const reroutes = LLink.getReroutes(graph, directLink)
const lastReroute = reroutes.at(-1)
if (lastReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(lastReroute.id)
if (rerouteLayout) {
return {
position: { ...rerouteLayout.position },
direction: LinkDirection.NONE
}
}
const pos = lastReroute.pos
if (pos) {
return {
position: toPoint(pos[0], pos[1]),
direction: LinkDirection.NONE
}
}
}
const directAnchor = resolveLinkOrigin(directLink)
if (directAnchor) return directAnchor
}
const floatingLinkIterator = inputSlot._floatingLinks?.values()
const floatingLink = floatingLinkIterator
? floatingLinkIterator.next().value
: undefined
if (!floatingLink) return null
if (floatingLink.parentId != null) {
const rerouteLayout = layoutStore.getRerouteLayout(floatingLink.parentId)
if (rerouteLayout) {
return {
position: { ...rerouteLayout.position },
direction: LinkDirection.NONE
}
}
const reroute = graph.getReroute(floatingLink.parentId)
if (reroute) {
return {
position: toPoint(reroute.pos[0], reroute.pos[1]),
direction: LinkDirection.NONE
}
}
}
return null
}
const cleanupInteraction = () => {
activeAdapter?.reset()
pointerSession.clear()
endDrag()
activeAdapter = null
}
const updatePointerState = (event: PointerEvent) => {
@@ -127,44 +278,108 @@ export function useSlotLinkInteraction({
app.canvas?.setDirty(true)
}
const connectSlots = (slotLayout: SlotLayout) => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return
// Attempt to finalize by connecting to a DOM slot candidate
const tryConnectToCandidate = (
candidate: SlotDropCandidate | null
): boolean => {
if (!candidate?.compatible) return false
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (!graph || !adapter) return false
const sourceNode = graph.getNodeById(Number(source.nodeId))
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
if (!sourceNode || !targetNode) return
const nodeId = Number(candidate.layout.nodeId)
const targetNode = graph.getNodeById(nodeId)
if (!targetNode) return false
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[slotLayout.index]
if (!outputSlot || !inputSlot) return
graph.beforeChange()
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
return
if (candidate.layout.type === 'input') {
const inputSlot = targetNode.inputs?.[candidate.layout.index]
return (
!!inputSlot &&
connectLinksToInput(adapter.renderLinks, targetNode, inputSlot)
)
}
if (source.type === 'input' && slotLayout.type === 'output') {
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[slotLayout.index]
if (!inputSlot || !outputSlot) return
graph.beforeChange()
sourceNode.disconnectInput(source.slotIndex, true)
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
if (candidate.layout.type === 'output') {
const outputSlot = targetNode.outputs?.[candidate.layout.index]
return (
!!outputSlot &&
connectLinksToOutput(adapter.renderLinks, targetNode, outputSlot)
)
}
return false
}
// Attempt to finalize by dropping on a reroute under the pointer
const tryConnectViaRerouteAtPointer = (): boolean => {
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: state.pointer.canvas.x,
y: state.pointer.canvas.y
})
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (!rerouteLayout || !graph || !adapter) return false
const reroute = graph.getReroute(rerouteLayout.id)
if (!reroute || !adapter.isRerouteValidDrop(reroute.id)) return false
let didConnect = false
const results = reroute.findTargetInputs() ?? []
const maybeReroutes = reroute.getReroutes()
if (results.length && maybeReroutes !== null) {
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
for (const link of adapter.renderLinks) {
if (!isToInputLink(link)) continue
for (const result of results) {
link.connectToRerouteInput(
reroute,
result,
adapter.linkConnector.events,
originalReroutes
)
didConnect = true
}
}
}
const sourceOutput = reroute.findSourceOutput()
if (sourceOutput) {
const { node, output } = sourceOutput
for (const link of adapter.renderLinks) {
if (!isToOutputLink(link)) continue
if (hasCanConnectToReroute(link) && !link.canConnectToReroute(reroute))
continue
link.connectToRerouteOutput(
reroute,
node,
output,
adapter.linkConnector.events
)
didConnect = true
}
}
return didConnect
}
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
if (state.source) {
const candidate = candidateFromTarget(event.target)
if (candidate?.compatible) {
connectSlots(candidate.layout)
}
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true)
return
}
const candidate = candidateFromTarget(event.target)
let connected = tryConnectToCandidate(candidate)
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !candidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
}
cleanupInteraction()
@@ -190,19 +405,80 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph
if (!canvas || !graph) return
ensureActiveAdapter()
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const resolvedNode = graph.getNodeById(Number(nodeId))
const slot =
type === 'input'
? resolvedNode?.inputs?.[index]
: resolvedNode?.outputs?.[index]
const numericNodeId = Number(nodeId)
const isInputSlot = type === 'input'
const isOutputSlot = type === 'output'
const direction =
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
const resolvedNode = graph.getNodeById(numericNodeId)
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
const ctrlOrMeta = event.ctrlKey || event.metaKey
const inputLinkId = inputSlot?.link ?? null
const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0
const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0
const outputLinkCount = outputSlot?.links?.length ?? 0
const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0
const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0
const shouldBreakExistingInputLink =
isInputSlot &&
hasExistingInputLink &&
ctrlOrMeta &&
event.altKey &&
!event.shiftKey
const existingInputLink =
isInputSlot && inputLinkId != null
? graph.getLink(inputLinkId)
: undefined
if (shouldBreakExistingInputLink && resolvedNode) {
resolvedNode.disconnectInput(index, true)
}
const baseDirection = isInputSlot
? inputSlot?.dir ?? LinkDirection.LEFT
: outputSlot?.dir ?? LinkDirection.RIGHT
const existingAnchor =
isInputSlot && !shouldBreakExistingInputLink
? resolveExistingInputLinkAnchor(graph, inputSlot)
: null
const shouldMoveExistingOutput =
isOutputSlot && event.shiftKey && hasExistingOutputLink
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
const adapter = ensureActiveAdapter()
if (adapter) {
if (isOutputSlot) {
adapter.beginFromOutput(numericNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
adapter.beginFromInput(numericNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
}
const direction = existingAnchor?.direction ?? baseDirection
const startPosition = existingAnchor?.position ?? {
x: layout.position.x,
y: layout.position.y
}
beginDrag(
{
@@ -210,7 +486,11 @@ export function useSlotLinkInteraction({
slotIndex: index,
type,
direction,
position: layout.position
position: startPosition,
linkId: !shouldBreakExistingInputLink
? existingInputLink?.id
: undefined,
movingExistingOutput: shouldMoveExistingOutput
},
event.pointerId
)