feat(controller): enhance click action with elementFromPoint

This commit is contained in:
Simon
2026-03-31 20:02:39 +08:00
parent 8eee3b27e2
commit 296459924a
3 changed files with 45 additions and 16 deletions

View File

@@ -218,6 +218,7 @@ export class PageController extends EventTarget {
* Clean up all element highlights * Clean up all element highlights
*/ */
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
console.log('[PageController] cleanUpHighlights')
dom.cleanUpHighlights() dom.cleanUpHighlights()
} }

View File

@@ -4,6 +4,9 @@
*/ */
import type { InteractiveElementDomNode } from './dom/dom_tree/type' import type { InteractiveElementDomNode } from './dom/dom_tree/type'
import { import {
clickPointer,
disablePassThrough,
enablePassThrough,
getNativeValueSetter, getNativeValueSetter,
isHTMLElement, isHTMLElement,
isInputElement, isInputElement,
@@ -68,43 +71,56 @@ export async function clickElement(element: HTMLElement) {
if (frame) await scrollIntoViewIfNeeded(frame) if (frame) await scrollIntoViewIfNeeded(frame)
await movePointerToElement(element) await movePointerToElement(element)
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer')) await clickPointer()
await waitFor(0.1) await waitFor(0.1)
const rect = element.getBoundingClientRect() const rect = element.getBoundingClientRect()
const x = rect.left + rect.width / 2 const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2 const y = rect.top + rect.height / 2
// Hit-test to find the deepest element at click coordinates, matching
// real browser behavior where events target the innermost element.
// @note This may hit a element in the blacklist
// TODO: This is a temporary workaround. Should have been handled during dom extraction.
const doc = element.ownerDocument
await enablePassThrough()
const hitTarget = doc.elementFromPoint(x, y)
await disablePassThrough()
const target =
hitTarget instanceof HTMLElement && element.contains(hitTarget) ? hitTarget : element
const pointerOpts = { const pointerOpts = {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
clientX: x, clientX: x,
clientY: y, clientY: y,
pointerType: 'mouse' as const, pointerType: 'mouse',
} }
const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 } const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }
// Hover — pointer events first, then mouse events (spec order) // Hover — pointer events first, then mouse events (spec order)
element.dispatchEvent(new PointerEvent('pointerover', pointerOpts)) target.dispatchEvent(new PointerEvent('pointerover', pointerOpts))
element.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false })) target.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false }))
element.dispatchEvent(new MouseEvent('mouseover', mouseOpts)) target.dispatchEvent(new MouseEvent('mouseover', mouseOpts))
element.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) target.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false }))
// Press // Press
element.dispatchEvent(new PointerEvent('pointerdown', pointerOpts)) target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
element.dispatchEvent(new MouseEvent('mousedown', mouseOpts)) target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
// Focus is not part of the standard "undefined and varies between user agents". // Focus is not part of the standard pointer/mouse event sequence
// Browsers implicitly focus focusable elements on mousedown as an internal behavior. // "undefined and varies between user agents".
// We focus the original element (nearest focusable ancestor), not the hit-test target, matching browser behavior.
element.focus({ preventScroll: true }) element.focus({ preventScroll: true })
// Release // Release
element.dispatchEvent(new PointerEvent('pointerup', pointerOpts)) target.dispatchEvent(new PointerEvent('pointerup', pointerOpts))
element.dispatchEvent(new MouseEvent('mouseup', mouseOpts)) target.dispatchEvent(new MouseEvent('mouseup', mouseOpts))
// Click — element.click() triggers default behaviors (e.g. <a> navigation, // Click — activation behavior (navigation, form submit, etc.) triggers
// form submission) that dispatchEvent(new MouseEvent('click')) may not. // via bubbling from target up to the interactive ancestor.
element.click() target.click()
await waitFor(0.2) await waitFor(0.2)
} }

View File

@@ -48,7 +48,7 @@ export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
} }
// ======= dom utils ======= // ======= mask events =======
export async function movePointerToElement(element: HTMLElement) { export async function movePointerToElement(element: HTMLElement) {
const rect = element.getBoundingClientRect() const rect = element.getBoundingClientRect()
@@ -60,3 +60,15 @@ export async function movePointerToElement(element: HTMLElement) {
await waitFor(0.3) await waitFor(0.3)
} }
export async function clickPointer() {
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
}
export async function enablePassThrough() {
window.dispatchEvent(new CustomEvent('PageAgent::EnablePassThrough'))
}
export async function disablePassThrough() {
window.dispatchEvent(new CustomEvent('PageAgent::DisablePassThrough'))
}