From 418f8fff4e652b97fb73c56efebd760d73689622 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:55:13 +0100 Subject: [PATCH] fix: Vue Nodes 2.0 slot link drag not working on mobile/touch devices (#7233) ## Summary - Fix slot link drag and snap/attraction not working on mobile browsers in Vue Nodes 2.0 mode - Use `document.elementFromPoint()` to get the actual element under the pointer instead of relying on `event.target` ## Root Cause On touch/mobile devices, pointer events have "implicit pointer capture" - when you touch an element, all subsequent pointer events (`pointermove`, `pointerup`) for that touch are sent to the same element where the touch started, regardless of where the pointer moves. The code was using `event.target` to find slots under the pointer for snap/attraction. On touch devices, this always returned the original slot element (where the drag started), not the element currently under the touch point. This caused: - No snap/attraction when dragging links over other slots - Connections failing when dropping on target slots ## Before https://github.com/user-attachments/assets/55b56d5c-9744-4d6c-abfd-3a2136ab25bc ## After https://github.com/user-attachments/assets/5bdf2a22-0025-4ae1-9358-35f0100b67d4 ## Test plan - [ ] Enable Vue Nodes 2.0 mode in settings - [ ] Test on mobile browser or Chrome DevTools mobile simulation - [ ] Drag a link from one node's output slot to another node's input slot - [ ] Verify the link snaps/attracts to compatible slots during drag - [ ] Verify the connection is made successfully on drop Fixes #7224 --- .../useSlotLinkInteraction.test.ts | 116 ++++++++++++++++++ .../composables/useSlotLinkInteraction.ts | 33 ++++- 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.test.ts diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.test.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.test.ts new file mode 100644 index 000000000..fa2d16045 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { resolvePointerTarget } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' + +describe('resolvePointerTarget', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns element from elementFromPoint when available', () => { + const targetElement = document.createElement('div') + targetElement.className = 'lg-slot' + + const spy = vi + .spyOn(document, 'elementFromPoint') + .mockReturnValue(targetElement) + + const fallback = document.createElement('span') + const result = resolvePointerTarget(100, 200, fallback) + + expect(spy).toHaveBeenCalledWith(100, 200) + expect(result).toBe(targetElement) + }) + + it('returns fallback when elementFromPoint returns null', () => { + const spy = vi.spyOn(document, 'elementFromPoint').mockReturnValue(null) + + const fallback = document.createElement('span') + fallback.className = 'fallback-element' + + const result = resolvePointerTarget(100, 200, fallback) + + expect(spy).toHaveBeenCalledWith(100, 200) + expect(result).toBe(fallback) + }) + + it('returns null fallback when both elementFromPoint and fallback are null', () => { + vi.spyOn(document, 'elementFromPoint').mockReturnValue(null) + + const result = resolvePointerTarget(100, 200, null) + + expect(result).toBeNull() + }) + + describe('touch/mobile pointer capture simulation', () => { + it('resolves correct target when touch moves over different element', () => { + // Simulate the touch scenario: + // - User touches slot A (event.target = slotA) + // - User drags over slot B (elementFromPoint returns slotB) + // - resolvePointerTarget should return slotB, not slotA + + const slotA = document.createElement('div') + slotA.className = 'lg-slot slot-a' + slotA.setAttribute('data-slot-key', 'node1-0-input') + + const slotB = document.createElement('div') + slotB.className = 'lg-slot slot-b' + slotB.setAttribute('data-slot-key', 'node2-0-input') + + // When pointer is over slotB, elementFromPoint returns slotB + vi.spyOn(document, 'elementFromPoint').mockReturnValue(slotB) + + // But the fallback (event.target on touch) is still slotA + const result = resolvePointerTarget(150, 250, slotA) + + // Should return slotB (the actual element under pointer), not slotA + expect(result).toBe(slotB) + expect(result).not.toBe(slotA) + }) + + it('falls back to original target when pointer is outside viewport', () => { + // When pointer is outside the document (e.g., dragged off screen), + // elementFromPoint returns null + + const slotA = document.createElement('div') + slotA.className = 'lg-slot slot-a' + + vi.spyOn(document, 'elementFromPoint').mockReturnValue(null) + + const result = resolvePointerTarget(-100, -100, slotA) + + // Should fall back to the original target + expect(result).toBe(slotA) + }) + }) + + describe('integration with slot detection', () => { + it('returned element can be used with closest() for slot detection', () => { + // Create a nested structure like the real DOM + const nodeContainer = document.createElement('div') + nodeContainer.setAttribute('data-node-id', 'node123') + + const slotWrapper = document.createElement('div') + slotWrapper.className = 'lg-slot' + + const slotDot = document.createElement('div') + slotDot.className = 'slot-dot' + slotDot.setAttribute('data-slot-key', 'node123-0-input') + + slotWrapper.appendChild(slotDot) + nodeContainer.appendChild(slotWrapper) + + // elementFromPoint returns the innermost element (slot dot) + vi.spyOn(document, 'elementFromPoint').mockReturnValue(slotDot) + + const result = resolvePointerTarget(100, 100, null) + + // Verify we can use closest() to find parent slot and node + expect(result).toBeInstanceOf(HTMLElement) + const htmlResult = result as HTMLElement + expect(htmlResult.closest('.lg-slot')).toBe(slotWrapper) + expect(htmlResult.closest('[data-node-id]')).toBe(nodeContainer) + expect(htmlResult.getAttribute('data-slot-key')).toBe('node123-0-input') + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index a7e5f1a67..6e11902d8 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -85,6 +85,28 @@ function createPointerSession(): PointerSession { return { begin, register, matches, isActive, clear } } +/** + * Resolves the actual DOM element under the pointer position. + * + * On touch/mobile devices, pointer events have "implicit pointer capture" - + * event.target stays as the element where the touch started, not the element + * currently under the pointer. This helper uses document.elementFromPoint() + * to get the actual element under the pointer, falling back to the provided + * fallback target if elementFromPoint returns null. + * + * @param clientX - The client X coordinate of the pointer + * @param clientY - The client Y coordinate of the pointer + * @param fallback - Fallback target to use if elementFromPoint returns null + * @returns The resolved target element + */ +export function resolvePointerTarget( + clientX: number, + clientY: number, + fallback: EventTarget | null +): EventTarget | null { + return document.elementFromPoint(clientX, clientY) ?? fallback +} + export function useSlotLinkInteraction({ nodeId, index, @@ -299,7 +321,7 @@ export function useSlotLinkInteraction({ let hoveredSlotKey: string | null = null let hoveredNodeId: NodeId | null = null - const target = data.target + const target = resolvePointerTarget(data.clientX, data.clientY, data.target) if (target === dragContext.lastPointerEventTarget) { hoveredSlotKey = dragContext.lastPointerTargetSlotKey hoveredNodeId = dragContext.lastPointerTargetNodeId @@ -501,9 +523,14 @@ export function useSlotLinkInteraction({ ? state.candidate : null - const hasConnected = connectByPriority(canvasEvent.target, snappedCandidate) + const dropTarget = resolvePointerTarget( + event.clientX, + event.clientY, + canvasEvent.target + ) + const hasConnected = connectByPriority(dropTarget, snappedCandidate) - if (!hasConnected && event.target === app.canvas?.canvas) { + if (!hasConnected && dropTarget === app.canvas?.canvas) { activeAdapter?.dropOnCanvas(canvasEvent) }