feat(vue-nodes): snap link preview; connect on drop (#5780)

## Summary

Snap link preview to the nearest compatible slot while dragging in Vue
Nodes mode, and complete the connection on drop using the snapped
target. Mirrors LiteGraph’s first-compatible-slot logic for node-level
snapping and reuses the computed candidate for performance.

## Changes

- Snap preview end to compatible slot
  - slot under cursor via `data-slot-key` fast-path
  - node under cursor via `findInputByType` / `findOutputByType`
- Render path
- `slotLinkPreviewRenderer.ts` now renders to
`state.candidate.layout.position`
- Complete on drop
  - Prefer `state.candidate` (no re-hit-testing)
  - Fallbacks: DOM slot → node first-compatible → reroute
  - Disconnects moving input link when dropped on canvas

## Review Focus

- UX feel of snapping and drop completion (both directions)
- Performance on large graphs (mousemove path is O(1) with dataset +
single validation)
- Edge cases: reroutes, moving existing links, collapsed nodes

## Screenshots (if applicable)


https://github.com/user-attachments/assets/fbed0ae2-2231-473b-a05a-9aaf68e3f820

https://github.com/Comfy-Org/ComfyUI_frontend/pull/5780 (snapping) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5898 (drop on canvas
+ linkconnectoradapter refactor) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5903 (fix reroute
snapping)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5780-feat-vue-nodes-snap-link-preview-connect-on-drop-27a6d73d365081d89c8cf570e2049c89)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Benjamin Lu
2025-10-04 21:48:59 -07:00
committed by GitHub
parent 4e7e6e2bf3
commit cd7310cb8c
21 changed files with 365 additions and 36 deletions

View File

@@ -13,6 +13,13 @@ export class VueNodeHelpers {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for a Vue node by its NodeId
*/
getNodeLocator(nodeId: string): Locator {
return this.page.locator(`[data-node-id="${nodeId}"]`)
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -693,4 +693,99 @@ test.describe('Vue Node Link Interaction', () => {
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
})
test('should snap to node center while dragging and link on drop', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Start drag from CLIP output[0]
const clipOutputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
// Drag to the visual center of the KSampler Vue node (not a slot)
const samplerVue = comfyPage.vueNodes.getNodeLocator(String(samplerNode.id))
await expect(samplerVue).toBeVisible()
const samplerCenter = await getCenter(samplerVue)
await comfyMouse.move(clipOutputCenter)
await comfyMouse.drag(samplerCenter)
// During drag, the preview should snap/highlight a compatible input on KSampler
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-node.png')
// Drop to create the link
await comfyMouse.drop()
await comfyPage.nextFrame()
// Validate a link was created to one of KSampler's compatible inputs (1 or 2)
const linkOnInput1 = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
const linkOnInput2 = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
const linked = linkOnInput1 ?? linkOnInput2
expect(linked).not.toBeNull()
expect(linked?.originId).toBe(clipNode.id)
expect(linked?.targetId).toBe(samplerNode.id)
})
test('should snap to a specific compatible slot when targeting it', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Drag from CLIP output[0] to KSampler input[2] (third slot) which is the
// second compatible input for CLIP
const clipOutputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const samplerInput3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(clipOutputCenter)
await comfyMouse.drag(samplerInput3Center)
// Expect the preview to show snapping to the targeted slot
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-slot.png')
// Finish the connection
await comfyMouse.drop()
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -5625,7 +5625,9 @@ export class LGraphCanvas
const { link, inputNode, input } = resolved
if (!inputNode || !input) continue
const endPos = inputNode.getInputPos(link.target_slot)
const endPos = LiteGraph.vueNodesMode
? getSlotPosition(inputNode, link.target_slot, true)
: inputNode.getInputPos(link.target_slot)
this.#renderAllLinkSegments(
ctx,
@@ -5650,7 +5652,9 @@ export class LGraphCanvas
const { link, outputNode, output } = resolved
if (!outputNode || !output) continue
const startPos = outputNode.getOutputPos(link.origin_slot)
const startPos = LiteGraph.vueNodesMode
? getSlotPosition(outputNode, link.origin_slot, false)
: outputNode.getOutputPos(link.origin_slot)
this.#renderAllLinkSegments(
ctx,

View File

@@ -49,7 +49,9 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
const renderLinks = createLinkConnectorAdapter()?.renderLinks
if (!renderLinks || renderLinks.length === 0) return
const to: Readonly<Point> = [pointer.canvas.x, pointer.canvas.y]
const to: Readonly<Point> = state.candidate?.compatible
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
: [pointer.canvas.x, pointer.canvas.y]
ctx.save()
for (const link of renderLinks) {
const startDir = link.fromDirection ?? LinkDirection.RIGHT

View File

@@ -0,0 +1,45 @@
import type { SlotLayout } from '@/renderer/core/layout/types'
interface PendingMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
interface SlotLinkDragSession {
compatCache: Map<string, boolean>
nodePreferred: Map<
number,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: number | null
lastCandidateKey: string | null
pendingMove: PendingMoveData | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragSession(): SlotLinkDragSession {
const state: SlotLinkDragSession = {
compatCache: new Map(),
nodePreferred: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingMove: null,
reset: () => {
state.compatCache = new Map()
state.nodePreferred = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingMove = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -17,19 +17,17 @@ import {
isSizeEqual
} from '@/renderer/core/layout/utils/geometry'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
import { createRafBatch } from '@/utils/rafBatch'
// RAF batching
const pendingNodes = new Set<string>()
let rafId: number | null = null
const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
if (rafId == null) {
rafId = requestAnimationFrame(() => {
rafId = null
flushScheduledSlotLayoutSync()
})
}
raf.schedule()
}
function flushScheduledSlotLayoutSync() {

View File

@@ -22,7 +22,9 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
interface SlotInteractionOptions {
nodeId: string
@@ -79,14 +81,21 @@ export function useSlotLinkInteraction({
index,
type
}: SlotInteractionOptions): SlotInteractionHandlers {
const { state, beginDrag, endDrag, updatePointerPosition } =
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
useSlotLinkDragState()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
// Per-drag drag-state cache
const dragSession = createSlotLinkDragSession()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const key = target.dataset['slotKey']
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
@@ -94,32 +103,85 @@ export function useSlotLinkInteraction({
const candidate: SlotDropCandidate = { layout, compatible: false }
if (state.source) {
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
)
}
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (graph && adapter) {
const cached = dragSession.compatCache.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(layout.nodeId, layout.index)
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
dragSession.compatCache.set(key, compatible)
candidate.compatible = compatible
}
}
return candidate
}
const conversion = useSharedCanvasPositionConversion()
function candidateFromNodeTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdStr = elWithNode?.dataset['nodeId']
if (!nodeIdStr) return null
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
const adapter = ensureActiveAdapter()
const graph = app.canvas?.graph
if (!adapter || !graph) return null
const nodeId = Number(nodeIdStr)
// Cached preferred slot for this node within this drag
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
if (cachedPreferred !== undefined) {
return cachedPreferred
? { layout: cachedPreferred.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const res = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = res?.index
if (index == null) return null
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
if (compatible) {
dragSession.compatCache.set(key, true)
const preferred = { index, key, layout }
dragSession.nodePreferred.set(nodeId, preferred)
return { layout, compatible: true }
} else {
dragSession.compatCache.set(key, false)
dragSession.nodePreferred.set(nodeId, null)
return null
}
}
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
@@ -251,6 +313,8 @@ export function useSlotLinkInteraction({
pointerSession.clear()
endDrag()
activeAdapter = null
raf.cancel()
dragSession.dispose()
}
const updatePointerState = (event: PointerEvent) => {
@@ -264,10 +328,73 @@ export function useSlotLinkInteraction({
updatePointerPosition(clientX, clientY, canvasX, canvasY)
}
const processPointerMoveFrame = () => {
const data = dragSession.pendingMove
if (!data) return
dragSession.pendingMove = null
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
data.clientX,
data.clientY
])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null
const target = data.target
if (target instanceof HTMLElement) {
hoveredSlotKey =
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
null
if (!hoveredSlotKey) {
const nodeIdStr =
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
}
}
const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId
let candidate: SlotDropCandidate | null = state.candidate
if (hoverChanged) {
const slotCandidate = candidateFromTarget(target)
const nodeCandidate = slotCandidate
? null
: candidateFromNodeTarget(target)
candidate = slotCandidate ?? nodeCandidate
dragSession.lastHoverSlotKey = hoveredSlotKey
dragSession.lastHoverNodeId = hoveredNodeId
}
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)
: null
if (newCandidateKey !== dragSession.lastCandidateKey) {
setCandidate(newCandidate)
dragSession.lastCandidateKey = newCandidateKey
}
app.canvas?.setDirty(true)
}
const raf = createRafBatch(processPointerMoveFrame)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
updatePointerState(event)
app.canvas?.setDirty(true)
dragSession.pendingMove = {
clientX: event.clientX,
clientY: event.clientY,
target: event.target
}
raf.schedule()
}
// Attempt to finalize by connecting to a DOM slot candidate
@@ -359,18 +486,36 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return
event.preventDefault()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true)
return
}
const candidate = candidateFromTarget(event.target)
let connected = tryConnectToCandidate(candidate)
// Prefer using the snapped candidate captured during hover for perf + consistency
const snappedCandidate = state.candidate?.compatible
? state.candidate
: null
let connected = tryConnectToCandidate(snappedCandidate)
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
if (!connected) {
const domCandidate = candidateFromTarget(event.target)
connected = tryConnectToCandidate(domCandidate)
}
if (!connected) {
const nodeCandidate = candidateFromNodeTarget(event.target)
connected = tryConnectToCandidate(nodeCandidate)
}
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !candidate && state.source.type === 'input') {
if (!connected && !snappedCandidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
}
@@ -384,6 +529,8 @@ export function useSlotLinkInteraction({
const handlePointerCancel = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
raf.flush()
cleanupInteraction()
app.canvas?.setDirty(true)
}
@@ -398,6 +545,8 @@ export function useSlotLinkInteraction({
if (!canvas || !graph) return
ensureActiveAdapter()
raf.cancel()
dragSession.reset()
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')

29
src/utils/rafBatch.ts Normal file
View File

@@ -0,0 +1,29 @@
export function createRafBatch(run: () => void) {
let rafId: number | null = null
const schedule = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
rafId = null
run()
})
}
const cancel = () => {
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
const flush = () => {
if (rafId == null) return
cancelAnimationFrame(rafId)
rafId = null
run()
}
const isScheduled = () => rafId != null
return { schedule, cancel, flush, isScheduled }
}