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>
@@ -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)
|
||||
*/
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 53 KiB |