Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { resolvePointerTarget } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'

describe('resolvePointerTarget', () => {
let originalElementFromPoint: typeof document.elementFromPoint

beforeEach(() => {
originalElementFromPoint = document.elementFromPoint
})

afterEach(() => {
document.elementFromPoint = originalElementFromPoint
vi.restoreAllMocks()
})

it('returns element from elementFromPoint when available', () => {
const targetElement = document.createElement('div')
targetElement.className = 'lg-slot'

document.elementFromPoint = vi.fn().mockReturnValue(targetElement)

const fallback = document.createElement('span')
const result = resolvePointerTarget(100, 200, fallback)

expect(document.elementFromPoint).toHaveBeenCalledWith(100, 200)
expect(result).toBe(targetElement)
})

it('returns fallback when elementFromPoint returns null', () => {
document.elementFromPoint = vi.fn().mockReturnValue(null)

const fallback = document.createElement('span')
fallback.className = 'fallback-element'

const result = resolvePointerTarget(100, 200, fallback)

expect(document.elementFromPoint).toHaveBeenCalledWith(100, 200)
expect(result).toBe(fallback)
})

it('returns null fallback when both elementFromPoint and fallback are null', () => {
document.elementFromPoint = vi.fn().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
document.elementFromPoint = vi.fn().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'

document.elementFromPoint = vi.fn().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)
document.elementFromPoint = vi.fn().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')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Loading