Allow moving links and support reroutes

This commit is contained in:
Benjamin Lu
2025-09-20 18:08:44 -07:00
parent 22a1c61208
commit 70651dcde0
4 changed files with 341 additions and 42 deletions

View File

@@ -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 }
}

View File

@@ -13,6 +13,7 @@ export interface SlotDragSource {
type: SlotDragType
direction: LinkDirection
position: Readonly<Point>
linkId?: number
}
export interface SlotDropCandidate {

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

@@ -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
)