Switch to adapter approach

This commit is contained in:
Benjamin Lu
2025-09-22 16:54:47 -07:00
parent e7f0ee40e4
commit 20d136dff3
6 changed files with 485 additions and 399 deletions

View File

@@ -47,8 +47,11 @@ export class MovingInputLink extends MovingLinkBase {
return this.node.canConnectTo(inputNode, input, this.outputSlot)
}
canConnectToOutput(): false {
return false
canConnectToOutput(
outputNode: NodeLike,
output: INodeOutputSlot | SubgraphIO
): boolean {
return outputNode.canConnectTo(this.inputNode, this.inputSlot, output)
}
canConnectToReroute(reroute: Reroute): boolean {
@@ -73,8 +76,30 @@ export class MovingInputLink extends MovingLinkBase {
return link
}
connectToOutput(): never {
throw new Error('MovingInputLink cannot connect to an output.')
connectToOutput(
outputNode: LGraphNode,
output: INodeOutputSlot,
events: CustomEventTarget<LinkConnectorEventMap>
): LLink | null | undefined {
if (
outputNode === this.outputNode &&
output === this.outputSlot &&
this.inputSlot === this.inputNode.inputs[this.inputIndex]
) {
return
}
const afterRerouteId = this.fromReroute?.id ?? this.link.parentId
this.inputNode.disconnectInput(this.inputIndex, true)
const newLink = outputNode.connectSlots(
output,
this.inputNode,
this.inputSlot,
afterRerouteId
)
if (newLink) events.dispatch('input-moved', this)
return newLink
}
connectToSubgraphInput(): void {
@@ -123,8 +148,34 @@ export class MovingInputLink extends MovingLinkBase {
if (newLink) events.dispatch('input-moved', this)
}
connectToRerouteOutput(): never {
throw new Error('MovingInputLink cannot connect to an output.')
connectToRerouteOutput(
reroute: Reroute,
outputNode: LGraphNode,
output: INodeOutputSlot,
events: CustomEventTarget<LinkConnectorEventMap>
): void {
const { inputNode, inputSlot, fromReroute } = this
this.inputNode.disconnectInput(this.inputIndex, true)
const floatingTerminus = reroute?.floating?.slotType === 'output'
if (fromReroute) {
fromReroute.parentId = reroute.id
} else {
this.link.parentId = reroute.id
}
const newLink = outputNode.connectSlots(
output,
inputNode,
inputSlot,
this.link.parentId
)
if (floatingTerminus) reroute.removeAllFloatingLinks()
if (newLink) events.dispatch('input-moved', this)
}
disconnect(): boolean {

View File

@@ -0,0 +1,187 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Reroute, 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 { ItemLocator } from '@/lib/litegraph/src/interfaces'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
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: unknown[]) => {}
this.linkConnector = new LinkConnector(
setConnectingLinks as (value: any[]) => void
)
}
/**
* The currently rendered/dragged links, typed for consumer use.
* Prefer this over accessing `linkConnector.renderLinks` directly.
*/
get renderLinks(): ReadonlyArray<RenderLink> {
return this.linkConnector
.renderLinks as unknown as ReadonlyArray<RenderLink>
}
/** ItemLocator backed by layoutStore for nodes/reroutes. */
get locator(): ItemLocator {
const graph = this.network
return {
getNodeOnPos: (x: number, y: number): LGraphNode | null => {
const id = layoutStore.queryNodeAtPoint(point(x, y))
if (id == null) return null
return graph.getNodeById(id) as LGraphNode | null
},
getRerouteOnPos: (x: number, y: number): Reroute | undefined => {
const r = layoutStore.queryRerouteAtPoint(point(x, y))
if (!r) return undefined
return graph.getReroute(r.id)
}
// getIoNodeOnPos: not required yet; can be added when UI exposes IO nodes
}
}
// 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 =
opts?.fromRerouteId != null
? this.network.getReroute(opts.fromRerouteId)
: undefined
if (opts?.moveExisting) {
console.log("I'm fully aware")
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 =
opts?.fromRerouteId != null
? this.network.getReroute(opts.fromRerouteId)
: undefined
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 as any[]).some(
(link) => link?.canConnectToOutput?.(node, output) === true
)
}
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
}
function point(x: number, y: number): Point {
return { x, y }
}

View File

@@ -1,105 +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'
import { app } from '@/scripts/app'
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 ?? app.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 {
const sourceNode = resolveNode(source.nodeId)
const targetNode = resolveNode(candidate.layout.nodeId)
if (!sourceNode || !targetNode) {
return { allowable: false }
}
if (source.type === 'output') {
if (candidate.layout.type === 'output') {
return { allowable: Boolean(source.multiOutputDrag), targetNode }
}
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 }
}
if (source.type === 'input') {
if (candidate.layout.type === 'output') {
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 }
}
if (candidate.layout.type === 'input') {
const graph = sourceNode.graph
if (!graph) {
return { allowable: false }
}
const linkId = source.linkId
if (linkId == null) {
return { allowable: false }
}
const link = graph.getLink(linkId)
if (!link) {
return { allowable: false }
}
const outputNode = resolveNode(link.origin_id)
const outputSlot = outputNode?.outputs?.[link.origin_slot]
const inputSlotTarget = targetNode.inputs?.[candidate.layout.index]
if (!outputNode || !outputSlot || !inputSlotTarget) {
return { allowable: false }
}
const allowable = outputNode.canConnectTo(
targetNode,
inputSlotTarget,
outputSlot
)
return { allowable, targetNode, targetSlot: inputSlotTarget }
}
}
return { allowable: false }
}

View File

@@ -1,8 +1,5 @@
import { reactive, readonly } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -17,7 +14,7 @@ export interface SlotDragSource {
direction: LinkDirection
position: Readonly<Point>
linkId?: number
multiOutputDrag?: boolean
movingExistingOutput?: boolean
}
export interface SlotDropCandidate {
@@ -25,14 +22,6 @@ export interface SlotDropCandidate {
compatible: boolean
}
// Types shared by multi-output drag logic
export interface MovedOutputNormalLink {
linkId: LinkId
inputNodeId: NodeId
inputSlotIndex: number
parentRerouteId?: RerouteId
}
interface PointerPosition {
client: Point
canvas: Point

View File

@@ -6,11 +6,14 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
type SlotDragSource,
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 {
@@ -42,24 +45,59 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
if (!state.active || !state.source) return
const { pointer, source } = state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
// Prefer LinkConnector render links when available (multi-link drags, move-existing, reroutes)
const adapter = createLinkConnectorAdapter()
const renderLinks = adapter?.renderLinks
if (adapter && renderLinks && renderLinks.length > 0) {
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
ctx.save()
for (const link of renderLinks) {
// Prefer Vue slot layout position for accuracy in Vue Nodes mode
let fromPoint = link.fromPos
const nodeId = (link.node as any)?.id
if (typeof nodeId === 'number') {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(
String(nodeId),
link.fromSlotIndex,
isInputFrom
)
const layout = layoutStore.getSlotLayout(key)
if (layout) fromPoint = [layout.position.x, layout.position.y]
}
const colour = resolveConnectingLinkColor(link.fromSlot.type)
const startDir = link.fromDirection ?? LinkDirection.RIGHT
const endDir = link.dragDirection ?? LinkDirection.CENTER
linkRenderer.renderDraggingLink(
ctx,
fromPoint,
to,
colour,
startDir,
endDir,
context
)
}
ctx.restore()
return
}
// Fallback to legacy single-link preview based on composable state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const from: ReadOnlyPoint = [start.x, start.y]
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()
linkRenderer.renderDraggingLink(
ctx,
from,
@@ -69,7 +107,6 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
endDir,
context
)
ctx.restore()
}

View File

@@ -5,19 +5,23 @@ 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, type LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
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 type { MovedOutputNormalLink } from '@/renderer/core/canvas/links/slotLinkDragState'
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 { Point, 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'
@@ -102,10 +106,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
adapter ??= createLinkConnectorAdapter()
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
@@ -114,12 +130,73 @@ export function useSlotLinkInteraction({
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let adapter: LinkConnectorAdapter | null = null
const draggingLinkIds = new Set<LinkId>()
const draggingRerouteIds = new Set<RerouteId>()
function hasCanConnectToReroute(
link: RenderLink
): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } {
return 'canConnectToReroute' in link
}
const movedOutputNormalLinks: MovedOutputNormalLink[] = []
const movedOutputFloatingLinks: LLink[] = []
type InputConnectableLink = RenderLink & {
toType: 'input'
canConnectToInput: (node: LGraphNode, input: INodeInputSlot) => boolean
}
type OutputConnectableLink = RenderLink & {
toType: 'output'
canConnectToOutput: (node: LGraphNode, output: INodeOutputSlot) => boolean
}
function isInputConnectableLink(
link: RenderLink
): link is InputConnectableLink {
return (
link.toType === 'input' &&
typeof (link as { canConnectToInput?: unknown }).canConnectToInput ===
'function'
)
}
function isOutputConnectableLink(
link: RenderLink
): link is OutputConnectableLink {
return (
link.toType === 'output' &&
typeof (link as { canConnectToOutput?: unknown }).canConnectToOutput ===
'function'
)
}
function connectLinksToInput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
inputSlot: INodeInputSlot
): boolean {
let didConnect = false
for (const link of links) {
if (!isInputConnectableLink(link)) continue
if (!link.canConnectToInput(node, inputSlot)) continue
link.connectToInput(node, inputSlot, adapter?.linkConnector.events)
didConnect = true
}
return didConnect
}
function connectLinksToOutput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
outputSlot: INodeOutputSlot
): boolean {
let didConnect = false
for (const link of links) {
if (!isOutputConnectableLink(link)) continue
if (!link.canConnectToOutput(node, outputSlot)) continue
link.connectToOutput(node, outputSlot, adapter?.linkConnector.events)
didConnect = true
}
return didConnect
}
const resolveLinkOrigin = (
graph: LGraph,
@@ -212,103 +289,10 @@ export function useSlotLinkInteraction({
return null
}
const resolveInputDragOrigin = (
graph: LGraph,
sourceNode: LGraphNode,
slotIndex: number,
linkId: number | undefined
) => {
const inputSlot = sourceNode.inputs?.[slotIndex]
if (!inputSlot) return null
const mapLinkToOrigin = (link: LLink | undefined | null) => {
if (!link) return null
const originNode = graph.getNodeById(link.origin_id)
const originSlot = originNode?.outputs?.[link.origin_slot]
if (!originNode || !originSlot) return null
return {
node: originNode,
slot: originSlot,
slotIndex: link.origin_slot,
afterRerouteId: link.parentId ?? null
}
}
if (linkId != null) {
const fromStoredLink = mapLinkToOrigin(graph.getLink(linkId))
if (fromStoredLink) return fromStoredLink
}
const fromDirectLink = mapLinkToOrigin(graph.getLink(inputSlot.link))
if (fromDirectLink) return fromDirectLink
const floatingLinkIterator = inputSlot._floatingLinks?.values()
const floatingLink = floatingLinkIterator
? floatingLinkIterator.next().value
: undefined
if (!floatingLink || floatingLink.isFloating) return null
const originNode = graph.getNodeById(floatingLink.origin_id)
const originSlot = originNode?.outputs?.[floatingLink.origin_slot]
if (!originNode || !originSlot) return null
return {
node: originNode,
slot: originSlot,
slotIndex: floatingLink.origin_slot,
afterRerouteId: floatingLink.parentId ?? null
}
}
const clearDraggingFlags = () => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph) return
if (source?.linkId != null) {
const activeLink = graph.getLink(source.linkId)
if (activeLink) delete activeLink._dragging
}
for (const id of draggingLinkIds) {
const link = graph.getLink(id)
if (link) delete link._dragging
}
for (const id of draggingRerouteIds) {
const reroute = graph.getReroute(id)
if (reroute) reroute._dragging = undefined
}
draggingLinkIds.clear()
draggingRerouteIds.clear()
}
const cleanupInteraction = () => {
clearDraggingFlags()
adapter?.reset()
pointerSession.clear()
endDrag()
movedOutputNormalLinks.length = 0
movedOutputFloatingLinks.length = 0
}
const disconnectSourceLink = (): boolean => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return false
const sourceNode = graph.getNodeById(Number(source.nodeId))
if (!sourceNode) return false
graph.beforeChange()
if (source.type === 'input') {
return sourceNode.disconnectInput(source.slotIndex, true)
}
return sourceNode.disconnectOutput(source.slotIndex)
}
const updatePointerState = (event: PointerEvent) => {
@@ -328,121 +312,6 @@ export function useSlotLinkInteraction({
app.canvas?.setDirty(true)
}
const connectSlots = (slotLayout: SlotLayout): boolean => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return false
const sourceNode = graph.getNodeById(Number(source.nodeId))
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
if (!sourceNode || !targetNode) return false
// Output ➝ Output (shiftdrag move all links)
if (source.type === 'output' && slotLayout.type === 'output') {
if (!source.multiOutputDrag) return false
const targetOutput = targetNode.outputs?.[slotLayout.index]
if (!targetOutput) return false
// Reconnect all normal links captured at drag start
for (const {
inputNodeId,
inputSlotIndex,
parentRerouteId
} of movedOutputNormalLinks) {
const inputNode = graph.getNodeById(inputNodeId)
const inputSlot = inputNode?.inputs?.[inputSlotIndex]
if (!inputNode || !inputSlot) continue
targetNode.connectSlots(
targetOutput,
inputNode,
inputSlot,
parentRerouteId
)
}
// Move any floating links across to the new output
const sourceNodeAtStart = graph.getNodeById(Number(source.nodeId))
const sourceOutputAtStart = sourceNodeAtStart?.outputs?.[source.slotIndex]
if (sourceOutputAtStart?._floatingLinks?.size) {
for (const floatingLink of movedOutputFloatingLinks) {
sourceOutputAtStart._floatingLinks?.delete(floatingLink)
floatingLink.origin_id = targetNode.id
floatingLink.origin_slot = slotLayout.index
targetOutput._floatingLinks ??= new Set()
targetOutput._floatingLinks.add(floatingLink)
}
}
return true
}
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[slotLayout.index]
if (!outputSlot || !inputSlot) return false
const existingLink = graph.getLink(inputSlot.link)
const afterRerouteId = existingLink?.parentId ?? undefined
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, afterRerouteId)
return true
}
if (source.type === 'input') {
const inputSlot = sourceNode.inputs?.[source.slotIndex]
if (!inputSlot) return false
const origin = resolveInputDragOrigin(
graph,
sourceNode,
source.slotIndex,
source.linkId
)
if (slotLayout.type === 'output') {
const outputSlot = targetNode.outputs?.[slotLayout.index]
if (!outputSlot) return false
const afterRerouteId =
origin &&
String(origin.node.id) === slotLayout.nodeId &&
origin.slotIndex === slotLayout.index
? origin.afterRerouteId ?? undefined
: undefined
targetNode.connectSlots(
outputSlot,
sourceNode,
inputSlot,
afterRerouteId
)
return true
}
if (slotLayout.type === 'input') {
if (!origin) return false
const outputNode = origin.node
const outputSlot = origin.slot
const newInputSlot = targetNode.inputs?.[slotLayout.index]
if (!outputSlot || !newInputSlot) return false
sourceNode.disconnectInput(source.slotIndex, true)
outputNode.connectSlots(
outputSlot,
targetNode,
newInputSlot,
origin.afterRerouteId ?? undefined
)
return true
}
}
return false
}
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
@@ -451,11 +320,91 @@ export function useSlotLinkInteraction({
const candidate = candidateFromTarget(event.target)
let connected = false
if (candidate?.compatible) {
connected = connectSlots(candidate.layout)
const canvas = app.canvas
const graph = canvas?.graph
if (graph) {
adapter ??= createLinkConnectorAdapter()
const targetNode = graph.getNodeById(Number(candidate.layout.nodeId))
if (adapter && targetNode) {
if (candidate.layout.type === 'input') {
const inputSlot = targetNode.inputs?.[candidate.layout.index]
if (
inputSlot &&
connectLinksToInput(adapter.renderLinks, targetNode, inputSlot)
)
connected = true
} else if (candidate.layout.type === 'output') {
const outputSlot = targetNode.outputs?.[candidate.layout.index]
if (
outputSlot &&
connectLinksToOutput(
adapter.renderLinks,
targetNode,
outputSlot
)
)
connected = true
}
}
}
}
// Try reroute drop when no DOM slot was detected
if (!connected) {
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: state.pointer.canvas.x,
y: state.pointer.canvas.y
})
const graph = app.canvas?.graph
adapter ??= createLinkConnectorAdapter()
if (rerouteLayout && graph && adapter) {
const reroute = graph.getReroute(rerouteLayout.id)
if (reroute && adapter.isRerouteValidDrop(reroute.id)) {
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 (!isInputConnectableLink(link)) continue
for (const result of results) {
link.connectToRerouteInput(
reroute,
result,
adapter.linkConnector.events,
originalReroutes
)
connected = true
}
}
}
const sourceOutput = reroute.findSourceOutput()
if (sourceOutput) {
const { node, output } = sourceOutput
for (const link of adapter.renderLinks) {
if (!isOutputConnectableLink(link)) continue
if (
hasCanConnectToReroute(link) &&
!link.canConnectToReroute(reroute)
)
continue
link.connectToRerouteOutput(
reroute,
node,
output,
adapter.linkConnector.events
)
connected = true
}
}
}
}
}
// Drop on canvas: disconnect moving input link(s)
if (!connected && !candidate && state.source.type === 'input') {
disconnectSourceLink()
adapter ??= createLinkConnectorAdapter()
adapter?.disconnectMovingLinks()
}
}
@@ -518,51 +467,29 @@ export function useSlotLinkInteraction({
? resolveExistingInputLinkAnchor(graph, inputSlot)
: null
if (!shouldBreakExistingLink && existingLink) {
existingLink._dragging = true
}
const outputSlot =
type === 'output' ? resolvedNode?.outputs?.[index] : undefined
const isMultiOutputDrag =
type === 'output' &&
Boolean(
outputSlot &&
(outputSlot.links?.length || outputSlot._floatingLinks?.size)
) &&
event.shiftKey
const hasExistingOutputLink = Boolean(
outputSlot &&
((outputSlot.links?.length ?? 0) > 0 ||
(outputSlot._floatingLinks?.size ?? 0) > 0)
)
const shouldMoveExistingOutput =
type === 'output' && event.shiftKey && hasExistingOutputLink
if (isMultiOutputDrag && outputSlot) {
movedOutputNormalLinks.length = 0
movedOutputFloatingLinks.length = 0
if (outputSlot.links?.length) {
for (const linkId of outputSlot.links) {
const link = graph.getLink(linkId)
if (!link) continue
const firstReroute = LLink.getFirstReroute(graph, link)
if (firstReroute) {
firstReroute._dragging = true
draggingRerouteIds.add(firstReroute.id)
} else {
link._dragging = true
draggingLinkIds.add(link.id)
}
movedOutputNormalLinks.push({
linkId: link.id,
inputNodeId: link.target_id,
inputSlotIndex: link.target_slot,
parentRerouteId: link.parentId
})
}
}
if (outputSlot._floatingLinks?.size) {
for (const floatingLink of outputSlot._floatingLinks) {
movedOutputFloatingLinks.push(floatingLink)
}
adapter ??= createLinkConnectorAdapter()
if (adapter) {
if (type === 'output') {
adapter.beginFromOutput(Number(nodeId), index, {
moveExisting: shouldMoveExistingOutput
})
} else {
const moveExisting = !!(
inputSlot &&
!shouldBreakExistingLink &&
(inputSlot.link != null || inputSlot._floatingLinks?.size)
)
adapter.beginFromInput(Number(nodeId), index, { moveExisting })
}
}
@@ -580,7 +507,7 @@ export function useSlotLinkInteraction({
direction,
position: startPosition,
linkId: !shouldBreakExistingLink ? existingLink?.id : undefined,
multiOutputDrag: isMultiOutputDrag
movingExistingOutput: shouldMoveExistingOutput
},
event.pointerId
)