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