From 20d136dff358224b9e66aaba6686776e6707456b Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 22 Sep 2025 16:54:47 -0700 Subject: [PATCH] Switch to adapter approach --- .../litegraph/src/canvas/MovingInputLink.ts | 63 ++- .../core/canvas/links/linkConnectorAdapter.ts | 187 +++++++ .../canvas/links/slotLinkCompatibility.ts | 105 ---- .../core/canvas/links/slotLinkDragState.ts | 13 +- .../canvas/links/slotLinkPreviewRenderer.ts | 53 +- .../composables/useSlotLinkInteraction.ts | 463 ++++++++---------- 6 files changed, 485 insertions(+), 399 deletions(-) create mode 100644 src/renderer/core/canvas/links/linkConnectorAdapter.ts delete mode 100644 src/renderer/core/canvas/links/slotLinkCompatibility.ts diff --git a/src/lib/litegraph/src/canvas/MovingInputLink.ts b/src/lib/litegraph/src/canvas/MovingInputLink.ts index fb094222c..4463c7873 100644 --- a/src/lib/litegraph/src/canvas/MovingInputLink.ts +++ b/src/lib/litegraph/src/canvas/MovingInputLink.ts @@ -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 + ): 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 + ): 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 { diff --git a/src/renderer/core/canvas/links/linkConnectorAdapter.ts b/src/renderer/core/canvas/links/linkConnectorAdapter.ts new file mode 100644 index 000000000..d45bb42e7 --- /dev/null +++ b/src/renderer/core/canvas/links/linkConnectorAdapter.ts @@ -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() + +/** + * Renderer‑agnostic adapter around LiteGraph's LinkConnector. + * + * - Uses layoutStore for hit‑testing (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 { + return this.linkConnector + .renderLinks as unknown as ReadonlyArray + } + + /** 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 } +} diff --git a/src/renderer/core/canvas/links/slotLinkCompatibility.ts b/src/renderer/core/canvas/links/slotLinkCompatibility.ts deleted file mode 100644 index 921f9b5f8..000000000 --- a/src/renderer/core/canvas/links/slotLinkCompatibility.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragState.ts index 0f4d91fbb..0c83c70cc 100644 --- a/src/renderer/core/canvas/links/slotLinkDragState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragState.ts @@ -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 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 diff --git a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts index b69cd9b7a..9f5e1d6ae 100644 --- a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts +++ b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts @@ -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() } diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 80985c8ac..9f9747bbd 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -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() - const draggingRerouteIds = new Set() + 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, + 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, + 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 (shift‑drag 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 )