mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 02:04:09 +00:00
Increase vue slot/link functionality (#5710)
## Summary Increase functionality for slots and links, covered with playwright tests. ## Features - Allow for reroute anchors to work when dragging from input slot - Allow for dragging existing links from input slot - Allow for ctrl/command + alt to create new link from input slot - Allow shift to drag all connected links on output slot - Connect links with reroutes (only when dragged from vue slot) ## Tests Added ### Playwright - Dragging input to input drags existing link - Dropping an input link back on its slot restores the original connection - Ctrl+alt drag from an input starts a fresh link - Should reuse the existing origin when dragging an input link - Shift-dragging an output with multiple links should drag all links - Rerouted input drag preview remains anchored to reroute - Rerouted output shift-drag preview remains anchored to reroute ## Notes The double rendering system for links being dragged, it works right now, maybe they can be coalesced later. Edit: As in the adapter, can be removed in a followup PR Also, it's known that more features will arrive in smaller PRs, this PR actually should've been much smaller. The next ones coming up are drop on canvas support, snap to node, type compatibility highlighting, and working with subgraphs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5710-Increase-vue-slot-link-functionality-2756d73d3650814f8995f7782244803b) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -2,15 +2,26 @@ import { type Fn, useEventListener } from '@vueuse/core'
|
||||
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 { 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 { 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 { 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'
|
||||
|
||||
interface SlotInteractionOptions {
|
||||
@@ -92,10 +103,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
|
||||
const adapter = ensureActiveAdapter()
|
||||
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
|
||||
@@ -104,10 +127,138 @@ export function useSlotLinkInteraction({
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
|
||||
const pointerSession = createPointerSession()
|
||||
let activeAdapter: LinkConnectorAdapter | null = null
|
||||
|
||||
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
|
||||
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
||||
return activeAdapter
|
||||
}
|
||||
|
||||
function hasCanConnectToReroute(
|
||||
link: RenderLink
|
||||
): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } {
|
||||
return 'canConnectToReroute' in link
|
||||
}
|
||||
|
||||
type ToInputLink = RenderLink & { toType: 'input' }
|
||||
type ToOutputLink = RenderLink & { toType: 'output' }
|
||||
const isToInputLink = (link: RenderLink): link is ToInputLink =>
|
||||
link.toType === 'input'
|
||||
const isToOutputLink = (link: RenderLink): link is ToOutputLink =>
|
||||
link.toType === 'output'
|
||||
|
||||
function connectLinksToInput(
|
||||
links: ReadonlyArray<RenderLink>,
|
||||
node: LGraphNode,
|
||||
inputSlot: INodeInputSlot
|
||||
): boolean {
|
||||
const validCandidates = links
|
||||
.filter(isToInputLink)
|
||||
.filter((link) => link.canConnectToInput(node, inputSlot))
|
||||
|
||||
for (const link of validCandidates) {
|
||||
link.connectToInput(node, inputSlot, activeAdapter?.linkConnector.events)
|
||||
}
|
||||
|
||||
return validCandidates.length > 0
|
||||
}
|
||||
|
||||
function connectLinksToOutput(
|
||||
links: ReadonlyArray<RenderLink>,
|
||||
node: LGraphNode,
|
||||
outputSlot: INodeOutputSlot
|
||||
): boolean {
|
||||
const validCandidates = links
|
||||
.filter(isToOutputLink)
|
||||
.filter((link) => link.canConnectToOutput(node, outputSlot))
|
||||
|
||||
for (const link of validCandidates) {
|
||||
link.connectToOutput(
|
||||
node,
|
||||
outputSlot,
|
||||
activeAdapter?.linkConnector.events
|
||||
)
|
||||
}
|
||||
|
||||
return validCandidates.length > 0
|
||||
}
|
||||
|
||||
const resolveLinkOrigin = (
|
||||
link: LLink | undefined
|
||||
): { position: Point; direction: LinkDirection } | null => {
|
||||
if (!link) return null
|
||||
|
||||
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
if (!layout) return null
|
||||
|
||||
return { position: { ...layout.position }, 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(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 cleanupInteraction = () => {
|
||||
activeAdapter?.reset()
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
activeAdapter = null
|
||||
}
|
||||
|
||||
const updatePointerState = (event: PointerEvent) => {
|
||||
@@ -127,44 +278,108 @@ export function useSlotLinkInteraction({
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
const connectSlots = (slotLayout: SlotLayout) => {
|
||||
const canvas = app.canvas
|
||||
const graph = canvas?.graph
|
||||
const source = state.source
|
||||
if (!canvas || !graph || !source) return
|
||||
// Attempt to finalize by connecting to a DOM slot candidate
|
||||
const tryConnectToCandidate = (
|
||||
candidate: SlotDropCandidate | null
|
||||
): boolean => {
|
||||
if (!candidate?.compatible) return false
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (!graph || !adapter) return false
|
||||
|
||||
const sourceNode = graph.getNodeById(Number(source.nodeId))
|
||||
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
|
||||
if (!sourceNode || !targetNode) return
|
||||
const nodeId = Number(candidate.layout.nodeId)
|
||||
const targetNode = graph.getNodeById(nodeId)
|
||||
if (!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 (candidate.layout.type === 'input') {
|
||||
const inputSlot = targetNode.inputs?.[candidate.layout.index]
|
||||
return (
|
||||
!!inputSlot &&
|
||||
connectLinksToInput(adapter.renderLinks, targetNode, inputSlot)
|
||||
)
|
||||
}
|
||||
|
||||
if (source.type === 'input' && slotLayout.type === 'output') {
|
||||
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 (candidate.layout.type === 'output') {
|
||||
const outputSlot = targetNode.outputs?.[candidate.layout.index]
|
||||
return (
|
||||
!!outputSlot &&
|
||||
connectLinksToOutput(adapter.renderLinks, targetNode, outputSlot)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Attempt to finalize by dropping on a reroute under the pointer
|
||||
const tryConnectViaRerouteAtPointer = (): boolean => {
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
||||
x: state.pointer.canvas.x,
|
||||
y: state.pointer.canvas.y
|
||||
})
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (!rerouteLayout || !graph || !adapter) return false
|
||||
|
||||
const reroute = graph.getReroute(rerouteLayout.id)
|
||||
if (!reroute || !adapter.isRerouteValidDrop(reroute.id)) return false
|
||||
|
||||
let didConnect = false
|
||||
|
||||
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 (!isToInputLink(link)) continue
|
||||
for (const result of results) {
|
||||
link.connectToRerouteInput(
|
||||
reroute,
|
||||
result,
|
||||
adapter.linkConnector.events,
|
||||
originalReroutes
|
||||
)
|
||||
didConnect = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceOutput = reroute.findSourceOutput()
|
||||
if (sourceOutput) {
|
||||
const { node, output } = sourceOutput
|
||||
for (const link of adapter.renderLinks) {
|
||||
if (!isToOutputLink(link)) continue
|
||||
if (hasCanConnectToReroute(link) && !link.canConnectToReroute(reroute))
|
||||
continue
|
||||
link.connectToRerouteOutput(
|
||||
reroute,
|
||||
node,
|
||||
output,
|
||||
adapter.linkConnector.events
|
||||
)
|
||||
didConnect = true
|
||||
}
|
||||
}
|
||||
|
||||
return didConnect
|
||||
}
|
||||
|
||||
const finishInteraction = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.preventDefault()
|
||||
|
||||
if (state.source) {
|
||||
const candidate = candidateFromTarget(event.target)
|
||||
if (candidate?.compatible) {
|
||||
connectSlots(candidate.layout)
|
||||
}
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = candidateFromTarget(event.target)
|
||||
let connected = tryConnectToCandidate(candidate)
|
||||
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
|
||||
|
||||
// Drop on canvas: disconnect moving input link(s)
|
||||
if (!connected && !candidate && state.source.type === 'input') {
|
||||
ensureActiveAdapter()?.disconnectMovingLinks()
|
||||
}
|
||||
|
||||
cleanupInteraction()
|
||||
@@ -190,19 +405,80 @@ export function useSlotLinkInteraction({
|
||||
const graph = canvas?.graph
|
||||
if (!canvas || !graph) return
|
||||
|
||||
ensureActiveAdapter()
|
||||
|
||||
const layout = layoutStore.getSlotLayout(
|
||||
getSlotKey(nodeId, index, type === 'input')
|
||||
)
|
||||
if (!layout) return
|
||||
|
||||
const resolvedNode = graph.getNodeById(Number(nodeId))
|
||||
const slot =
|
||||
type === 'input'
|
||||
? resolvedNode?.inputs?.[index]
|
||||
: resolvedNode?.outputs?.[index]
|
||||
const numericNodeId = Number(nodeId)
|
||||
const isInputSlot = type === 'input'
|
||||
const isOutputSlot = type === 'output'
|
||||
|
||||
const direction =
|
||||
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
|
||||
const resolvedNode = graph.getNodeById(numericNodeId)
|
||||
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
|
||||
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
|
||||
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey
|
||||
|
||||
const inputLinkId = inputSlot?.link ?? null
|
||||
const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0
|
||||
const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0
|
||||
|
||||
const outputLinkCount = outputSlot?.links?.length ?? 0
|
||||
const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0
|
||||
const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0
|
||||
|
||||
const shouldBreakExistingInputLink =
|
||||
isInputSlot &&
|
||||
hasExistingInputLink &&
|
||||
ctrlOrMeta &&
|
||||
event.altKey &&
|
||||
!event.shiftKey
|
||||
|
||||
const existingInputLink =
|
||||
isInputSlot && inputLinkId != null
|
||||
? graph.getLink(inputLinkId)
|
||||
: undefined
|
||||
|
||||
if (shouldBreakExistingInputLink && resolvedNode) {
|
||||
resolvedNode.disconnectInput(index, true)
|
||||
}
|
||||
|
||||
const baseDirection = isInputSlot
|
||||
? inputSlot?.dir ?? LinkDirection.LEFT
|
||||
: outputSlot?.dir ?? LinkDirection.RIGHT
|
||||
|
||||
const existingAnchor =
|
||||
isInputSlot && !shouldBreakExistingInputLink
|
||||
? resolveExistingInputLinkAnchor(graph, inputSlot)
|
||||
: null
|
||||
|
||||
const shouldMoveExistingOutput =
|
||||
isOutputSlot && event.shiftKey && hasExistingOutputLink
|
||||
|
||||
const shouldMoveExistingInput =
|
||||
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
|
||||
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (adapter) {
|
||||
if (isOutputSlot) {
|
||||
adapter.beginFromOutput(numericNodeId, index, {
|
||||
moveExisting: shouldMoveExistingOutput
|
||||
})
|
||||
} else {
|
||||
adapter.beginFromInput(numericNodeId, index, {
|
||||
moveExisting: shouldMoveExistingInput
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const direction = existingAnchor?.direction ?? baseDirection
|
||||
const startPosition = existingAnchor?.position ?? {
|
||||
x: layout.position.x,
|
||||
y: layout.position.y
|
||||
}
|
||||
|
||||
beginDrag(
|
||||
{
|
||||
@@ -210,7 +486,11 @@ export function useSlotLinkInteraction({
|
||||
slotIndex: index,
|
||||
type,
|
||||
direction,
|
||||
position: layout.position
|
||||
position: startPosition,
|
||||
linkId: !shouldBreakExistingInputLink
|
||||
? existingInputLink?.id
|
||||
: undefined,
|
||||
movingExistingOutput: shouldMoveExistingOutput
|
||||
},
|
||||
event.pointerId
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user