mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
committed by
GitHub
parent
5c01861f4e
commit
418f8fff4e
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user