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
This commit is contained in:
Johnpaul Chiwetelu
2025-12-09 00:55:13 +01:00
committed by GitHub
parent 5c01861f4e
commit 418f8fff4e
2 changed files with 146 additions and 3 deletions

View File

@@ -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')
})
})
})

View File

@@ -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)
}