From 70651dcde01c77237713641d65b1f388619575f3 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 20 Sep 2025 18:08:44 -0700 Subject: [PATCH] Allow moving links and support reroutes --- .../canvas/links/slotLinkCompatibility.ts | 72 +++-- .../core/canvas/links/slotLinkDragState.ts | 1 + src/renderer/core/layout/utils/geometry.ts | 4 + .../composables/useSlotLinkInteraction.ts | 306 ++++++++++++++++-- 4 files changed, 341 insertions(+), 42 deletions(-) diff --git a/src/renderer/core/canvas/links/slotLinkCompatibility.ts b/src/renderer/core/canvas/links/slotLinkCompatibility.ts index b8beffc384..41c8a8040b 100644 --- a/src/renderer/core/canvas/links/slotLinkCompatibility.ts +++ b/src/renderer/core/canvas/links/slotLinkCompatibility.ts @@ -32,26 +32,17 @@ 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) { + if (source.type === 'output') { + if (candidate.layout.type !== 'input') { + return { allowable: false } + } + const outputSlot = sourceNode.outputs?.[source.slotIndex] const inputSlot = targetNode.inputs?.[candidate.layout.index] if (!outputSlot || !inputSlot) { @@ -62,12 +53,53 @@ export function evaluateCompatibility( return { allowable, targetNode, targetSlot: inputSlot } } - const inputSlot = sourceNode.inputs?.[source.slotIndex] - const outputSlot = targetNode.outputs?.[candidate.layout.index] - if (!inputSlot || !outputSlot) { - return { allowable: false } + 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 } + } } - const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot) - return { allowable, targetNode, targetSlot: outputSlot } + return { allowable: false } } diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragState.ts index 5d2bbcfc43..018c529ea4 100644 --- a/src/renderer/core/canvas/links/slotLinkDragState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragState.ts @@ -13,6 +13,7 @@ export interface SlotDragSource { type: SlotDragType direction: LinkDirection position: Readonly + linkId?: number } export interface SlotDropCandidate { diff --git a/src/renderer/core/layout/utils/geometry.ts b/src/renderer/core/layout/utils/geometry.ts index 176cb0c98b..131223dfa9 100644 --- a/src/renderer/core/layout/utils/geometry.ts +++ b/src/renderer/core/layout/utils/geometry.ts @@ -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 } diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index f82deab7de..2ca40c4733 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -1,7 +1,12 @@ import { type Fn, useEventListener } from '@vueuse/core' +import log from 'loglevel' 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 { INodeInputSlot } from '@/lib/litegraph/src/interfaces' import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility' import { @@ -10,9 +15,12 @@ import { } 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, SlotLayout } from '@/renderer/core/layout/types' +import { toPoint } from '@/renderer/core/layout/utils/geometry' import { app } from '@/scripts/app' +const logger = log.getLogger('useSlotLinkInteraction') + interface SlotInteractionOptions { nodeId: string index: number @@ -105,11 +113,184 @@ export function useSlotLinkInteraction({ const pointerSession = createPointerSession() + const resolveLinkOrigin = ( + graph: LGraph, + link: LLink | undefined + ): { position: Point; direction: LinkDirection } | null => { + if (!link) return null + + const originNodeId = link.origin_id + const originSlotIndex = link.origin_slot + + const slotKey = getSlotKey(String(originNodeId), originSlotIndex, false) + const layout = layoutStore.getSlotLayout(slotKey) + + if (layout) { + return { position: { ...layout.position }, direction: LinkDirection.NONE } + } else { + const originNode = graph.getNodeById(originNodeId) + + logger.warn('Slot layout missing', { + slotKey, + originNodeId, + originSlotIndex, + linkId: link.id, + fallback: originNode ? 'graph' : 'none' + }) + + if (!originNode) return null + + const [x, y] = originNode.getOutputPos(originSlotIndex) + return { position: toPoint(x, y), 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(graph, 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 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 || !source) return + + if (source.linkId != null) { + const activeLink = graph.getLink(source.linkId) + if (activeLink) { + delete activeLink._dragging + } + } + } + const cleanupInteraction = () => { + clearDraggingFlags() pointerSession.clear() endDrag() } + 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) => { const clientX = event.clientX const clientY = event.clientY @@ -127,33 +308,76 @@ export function useSlotLinkInteraction({ app.canvas?.setDirty(true) } - const connectSlots = (slotLayout: SlotLayout) => { + const connectSlots = (slotLayout: SlotLayout): boolean => { const canvas = app.canvas const graph = canvas?.graph const source = state.source - if (!canvas || !graph || !source) return + if (!canvas || !graph || !source) return false const sourceNode = graph.getNodeById(Number(source.nodeId)) const targetNode = graph.getNodeById(Number(slotLayout.nodeId)) - if (!sourceNode || !targetNode) return + if (!sourceNode || !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 (!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' && slotLayout.type === 'output') { + if (source.type === 'input') { 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 (!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) => { @@ -162,8 +386,13 @@ export function useSlotLinkInteraction({ if (state.source) { const candidate = candidateFromTarget(event.target) + let connected = false if (candidate?.compatible) { - connectSlots(candidate.layout) + connected = connectSlots(candidate.layout) + } + + if (!connected && !candidate && state.source.type === 'input') { + disconnectSourceLink() } } @@ -196,13 +425,45 @@ export function useSlotLinkInteraction({ if (!layout) return const resolvedNode = graph.getNodeById(Number(nodeId)) - const slot = - type === 'input' - ? resolvedNode?.inputs?.[index] - : resolvedNode?.outputs?.[index] + const inputSlot = + type === 'input' ? resolvedNode?.inputs?.[index] : undefined - const direction = - slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT) + const ctrlOrMeta = event.ctrlKey || event.metaKey + const hasExistingInputLink = Boolean( + inputSlot && (inputSlot.link != null || inputSlot._floatingLinks?.size) + ) + + const shouldBreakExistingLink = + hasExistingInputLink && ctrlOrMeta && event.altKey && !event.shiftKey + + const existingLink = + type === 'input' && inputSlot?.link != null + ? graph.getLink(inputSlot.link) + : undefined + + if (shouldBreakExistingLink && resolvedNode) { + resolvedNode.disconnectInput(index, true) + } + + const baseDirection = + type === 'input' + ? inputSlot?.dir ?? LinkDirection.LEFT + : resolvedNode?.outputs?.[index]?.dir ?? LinkDirection.RIGHT + + const existingAnchor = + type === 'input' && !shouldBreakExistingLink + ? resolveExistingInputLinkAnchor(graph, inputSlot) + : null + + if (!shouldBreakExistingLink && existingLink) { + existingLink._dragging = true + } + + const direction = existingAnchor?.direction ?? baseDirection + const startPosition = existingAnchor?.position ?? { + x: layout.position.x, + y: layout.position.y + } beginDrag( { @@ -210,7 +471,8 @@ export function useSlotLinkInteraction({ slotIndex: index, type, direction, - position: layout.position + position: startPosition, + linkId: !shouldBreakExistingLink ? existingLink?.id : undefined }, event.pointerId )