diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts index b517502997..e69b407afd 100644 --- a/browser_tests/fixtures/VueNodeHelpers.ts +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -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) */ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png index db69117858..e99c3f90d2 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index ebc09cf2eb..a95f9cf19b 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -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 + }) + }) }) diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png index 541ca18a69..773359c59e 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png index 838145eb47..61fddb5f31 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png index 99a372c0bc..b6d02cdaa7 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png index a055bb5258..9a8f766a2e 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png new file mode 100644 index 0000000000..bc963ddc6b Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png new file mode 100644 index 0000000000..a0b817c427 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png deleted file mode 100644 index 90df443abc..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png deleted file mode 100644 index a5e4e35c3c..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png deleted file mode 100644 index 80b96815e9..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png deleted file mode 100644 index 3be98b50fe..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png deleted file mode 100644 index de003b4ed3..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png deleted file mode 100644 index 05a22b8d21..0000000000 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png and /dev/null differ diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 38a79dfac7..13f5c9caf9 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -5626,7 +5626,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, @@ -5651,7 +5653,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, diff --git a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts index 978b2c4f22..cb4c43c5ec 100644 --- a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts +++ b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts @@ -49,7 +49,9 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) { const renderLinks = createLinkConnectorAdapter()?.renderLinks if (!renderLinks || renderLinks.length === 0) return - const to: Readonly = [pointer.canvas.x, pointer.canvas.y] + const to: Readonly = 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 diff --git a/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts b/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts new file mode 100644 index 0000000000..6e12a03a5b --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts @@ -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 + 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 +} diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts index 4b6cbf8117..3b859cee23 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -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() -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() { diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 71b3e985f5..12f94f5c43 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -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('[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('[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('[data-slot-key]')?.dataset['slotKey'] ?? + null + if (!hoveredSlotKey) { + const nodeIdStr = + target.closest('[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') diff --git a/src/utils/rafBatch.ts b/src/utils/rafBatch.ts new file mode 100644 index 0000000000..a8756ef245 --- /dev/null +++ b/src/utils/rafBatch.ts @@ -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 } +}