From 19c538c36ceebd5173796bf65a7703b9ca3b61c0 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 22 Sep 2025 11:42:22 -0700 Subject: [PATCH] Support dragging from output to output --- .../canvas/links/slotLinkCompatibility.ts | 4 +- .../core/canvas/links/slotLinkDragState.ts | 12 ++ .../composables/useSlotLinkInteraction.ts | 122 +++++++++++++++++- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/renderer/core/canvas/links/slotLinkCompatibility.ts b/src/renderer/core/canvas/links/slotLinkCompatibility.ts index 41c8a8040b..921f9b5f88 100644 --- a/src/renderer/core/canvas/links/slotLinkCompatibility.ts +++ b/src/renderer/core/canvas/links/slotLinkCompatibility.ts @@ -39,8 +39,8 @@ export function evaluateCompatibility( } if (source.type === 'output') { - if (candidate.layout.type !== 'input') { - return { allowable: false } + if (candidate.layout.type === 'output') { + return { allowable: Boolean(source.multiOutputDrag), targetNode } } const outputSlot = sourceNode.outputs?.[source.slotIndex] diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragState.ts index 018c529ea4..0f4d91fbbc 100644 --- a/src/renderer/core/canvas/links/slotLinkDragState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragState.ts @@ -1,5 +1,8 @@ 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' @@ -14,6 +17,7 @@ export interface SlotDragSource { direction: LinkDirection position: Readonly linkId?: number + multiOutputDrag?: boolean } export interface SlotDropCandidate { @@ -21,6 +25,14 @@ 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/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 2ca40c4733..80985c8aca 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -5,10 +5,12 @@ 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 { 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 { 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 { type SlotDropCandidate, useSlotLinkDragState @@ -113,6 +115,12 @@ export function useSlotLinkInteraction({ const pointerSession = createPointerSession() + const draggingLinkIds = new Set() + const draggingRerouteIds = new Set() + + const movedOutputNormalLinks: MovedOutputNormalLink[] = [] + const movedOutputFloatingLinks: LLink[] = [] + const resolveLinkOrigin = ( graph: LGraph, link: LLink | undefined @@ -258,20 +266,32 @@ export function useSlotLinkInteraction({ const canvas = app.canvas const graph = canvas?.graph const source = state.source - if (!canvas || !graph || !source) return + if (!canvas || !graph) return - if (source.linkId != null) { + if (source?.linkId != null) { const activeLink = graph.getLink(source.linkId) - if (activeLink) { - delete activeLink._dragging - } + 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() pointerSession.clear() endDrag() + movedOutputNormalLinks.length = 0 + movedOutputFloatingLinks.length = 0 } const disconnectSourceLink = (): boolean => { @@ -318,6 +338,49 @@ export function useSlotLinkInteraction({ 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] @@ -459,6 +522,50 @@ export function useSlotLinkInteraction({ 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 + + 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) + } + } + } + const direction = existingAnchor?.direction ?? baseDirection const startPosition = existingAnchor?.position ?? { x: layout.position.x, @@ -472,7 +579,8 @@ export function useSlotLinkInteraction({ type, direction, position: startPosition, - linkId: !shouldBreakExistingLink ? existingLink?.id : undefined + linkId: !shouldBreakExistingLink ? existingLink?.id : undefined, + multiOutputDrag: isMultiOutputDrag }, event.pointerId )