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]')
|
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)
|
* 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(() => {})
|
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 |
@@ -5625,7 +5625,9 @@ export class LGraphCanvas
|
|||||||
const { link, inputNode, input } = resolved
|
const { link, inputNode, input } = resolved
|
||||||
if (!inputNode || !input) continue
|
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(
|
this.#renderAllLinkSegments(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -5650,7 +5652,9 @@ export class LGraphCanvas
|
|||||||
const { link, outputNode, output } = resolved
|
const { link, outputNode, output } = resolved
|
||||||
if (!outputNode || !output) continue
|
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(
|
this.#renderAllLinkSegments(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
|||||||
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
||||||
if (!renderLinks || renderLinks.length === 0) return
|
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()
|
ctx.save()
|
||||||
for (const link of renderLinks) {
|
for (const link of renderLinks) {
|
||||||
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,19 +17,17 @@ import {
|
|||||||
isSizeEqual
|
isSizeEqual
|
||||||
} from '@/renderer/core/layout/utils/geometry'
|
} from '@/renderer/core/layout/utils/geometry'
|
||||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||||
|
import { createRafBatch } from '@/utils/rafBatch'
|
||||||
|
|
||||||
// RAF batching
|
// RAF batching
|
||||||
const pendingNodes = new Set<string>()
|
const pendingNodes = new Set<string>()
|
||||||
let rafId: number | null = null
|
const raf = createRafBatch(() => {
|
||||||
|
flushScheduledSlotLayoutSync()
|
||||||
|
})
|
||||||
|
|
||||||
function scheduleSlotLayoutSync(nodeId: string) {
|
function scheduleSlotLayoutSync(nodeId: string) {
|
||||||
pendingNodes.add(nodeId)
|
pendingNodes.add(nodeId)
|
||||||
if (rafId == null) {
|
raf.schedule()
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = null
|
|
||||||
flushScheduledSlotLayoutSync()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushScheduledSlotLayoutSync() {
|
function flushScheduledSlotLayoutSync() {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
|||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import type { Point } from '@/renderer/core/layout/types'
|
import type { Point } from '@/renderer/core/layout/types'
|
||||||
import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
||||||
|
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
|
import { createRafBatch } from '@/utils/rafBatch'
|
||||||
|
|
||||||
interface SlotInteractionOptions {
|
interface SlotInteractionOptions {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
@@ -79,14 +81,21 @@ export function useSlotLinkInteraction({
|
|||||||
index,
|
index,
|
||||||
type
|
type
|
||||||
}: SlotInteractionOptions): SlotInteractionHandlers {
|
}: SlotInteractionOptions): SlotInteractionHandlers {
|
||||||
const { state, beginDrag, endDrag, updatePointerPosition } =
|
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
|
||||||
useSlotLinkDragState()
|
useSlotLinkDragState()
|
||||||
|
const conversion = useSharedCanvasPositionConversion()
|
||||||
|
const pointerSession = createPointerSession()
|
||||||
|
let activeAdapter: LinkConnectorAdapter | null = null
|
||||||
|
|
||||||
|
// Per-drag drag-state cache
|
||||||
|
const dragSession = createSlotLinkDragSession()
|
||||||
|
|
||||||
function candidateFromTarget(
|
function candidateFromTarget(
|
||||||
target: EventTarget | null
|
target: EventTarget | null
|
||||||
): SlotDropCandidate | null {
|
): SlotDropCandidate | null {
|
||||||
if (!(target instanceof HTMLElement)) return 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
|
if (!key) return null
|
||||||
|
|
||||||
const layout = layoutStore.getSlotLayout(key)
|
const layout = layoutStore.getSlotLayout(key)
|
||||||
@@ -94,32 +103,85 @@ export function useSlotLinkInteraction({
|
|||||||
|
|
||||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||||
|
|
||||||
if (state.source) {
|
const graph = app.canvas?.graph
|
||||||
const canvas = app.canvas
|
const adapter = ensureActiveAdapter()
|
||||||
const graph = canvas?.graph
|
if (graph && adapter) {
|
||||||
const adapter = ensureActiveAdapter()
|
const cached = dragSession.compatCache.get(key)
|
||||||
if (graph && adapter) {
|
if (cached != null) {
|
||||||
if (layout.type === 'input') {
|
candidate.compatible = cached
|
||||||
candidate.compatible = adapter.isInputValidDrop(
|
} else {
|
||||||
layout.nodeId,
|
const compatible =
|
||||||
layout.index
|
layout.type === 'input'
|
||||||
)
|
? adapter.isInputValidDrop(layout.nodeId, layout.index)
|
||||||
} else if (layout.type === 'output') {
|
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
|
||||||
candidate.compatible = adapter.isOutputValidDrop(
|
dragSession.compatCache.set(key, compatible)
|
||||||
layout.nodeId,
|
candidate.compatible = compatible
|
||||||
layout.index
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate
|
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()
|
const adapter = ensureActiveAdapter()
|
||||||
let activeAdapter: LinkConnectorAdapter | null = null
|
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 => {
|
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
|
||||||
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
||||||
@@ -251,6 +313,8 @@ export function useSlotLinkInteraction({
|
|||||||
pointerSession.clear()
|
pointerSession.clear()
|
||||||
endDrag()
|
endDrag()
|
||||||
activeAdapter = null
|
activeAdapter = null
|
||||||
|
raf.cancel()
|
||||||
|
dragSession.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePointerState = (event: PointerEvent) => {
|
const updatePointerState = (event: PointerEvent) => {
|
||||||
@@ -264,10 +328,73 @@ export function useSlotLinkInteraction({
|
|||||||
updatePointerPosition(clientX, clientY, canvasX, canvasY)
|
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) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
if (!pointerSession.matches(event)) return
|
if (!pointerSession.matches(event)) return
|
||||||
updatePointerState(event)
|
dragSession.pendingMove = {
|
||||||
app.canvas?.setDirty(true)
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
target: event.target
|
||||||
|
}
|
||||||
|
raf.schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to finalize by connecting to a DOM slot candidate
|
// Attempt to finalize by connecting to a DOM slot candidate
|
||||||
@@ -359,18 +486,36 @@ export function useSlotLinkInteraction({
|
|||||||
if (!pointerSession.matches(event)) return
|
if (!pointerSession.matches(event)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
raf.flush()
|
||||||
|
|
||||||
if (!state.source) {
|
if (!state.source) {
|
||||||
cleanupInteraction()
|
cleanupInteraction()
|
||||||
app.canvas?.setDirty(true)
|
app.canvas?.setDirty(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = candidateFromTarget(event.target)
|
// Prefer using the snapped candidate captured during hover for perf + consistency
|
||||||
let connected = tryConnectToCandidate(candidate)
|
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
|
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
|
||||||
|
|
||||||
// Drop on canvas: disconnect moving input link(s)
|
// Drop on canvas: disconnect moving input link(s)
|
||||||
if (!connected && !candidate && state.source.type === 'input') {
|
if (!connected && !snappedCandidate && state.source.type === 'input') {
|
||||||
ensureActiveAdapter()?.disconnectMovingLinks()
|
ensureActiveAdapter()?.disconnectMovingLinks()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +529,8 @@ export function useSlotLinkInteraction({
|
|||||||
|
|
||||||
const handlePointerCancel = (event: PointerEvent) => {
|
const handlePointerCancel = (event: PointerEvent) => {
|
||||||
if (!pointerSession.matches(event)) return
|
if (!pointerSession.matches(event)) return
|
||||||
|
|
||||||
|
raf.flush()
|
||||||
cleanupInteraction()
|
cleanupInteraction()
|
||||||
app.canvas?.setDirty(true)
|
app.canvas?.setDirty(true)
|
||||||
}
|
}
|
||||||
@@ -398,6 +545,8 @@ export function useSlotLinkInteraction({
|
|||||||
if (!canvas || !graph) return
|
if (!canvas || !graph) return
|
||||||
|
|
||||||
ensureActiveAdapter()
|
ensureActiveAdapter()
|
||||||
|
raf.cancel()
|
||||||
|
dragSession.reset()
|
||||||
|
|
||||||
const layout = layoutStore.getSlotLayout(
|
const layout = layoutStore.getSlotLayout(
|
||||||
getSlotKey(nodeId, index, type === 'input')
|
getSlotKey(nodeId, index, type === 'input')
|
||||||
|
|||||||
29
src/utils/rafBatch.ts
Normal 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 }
|
||||||
|
}
|
||||||