mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 19:49:58 +00:00
Allow moving links and support reroutes
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SlotDragSource {
|
||||
type: SlotDragType
|
||||
direction: LinkDirection
|
||||
position: Readonly<Point>
|
||||
linkId?: number
|
||||
}
|
||||
|
||||
export interface SlotDropCandidate {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user