import type { DOMElement } from './dom.js' import { ClickEvent } from './events/click-event.js' import type { EventHandlerProps } from './events/event-handlers.js' import { nodeCache } from './node-cache.js' /** * Find the deepest DOM element whose rendered rect contains (col, row). * * Uses the nodeCache populated by renderNodeToOutput — rects are in screen * coordinates with all offsets (including scrollTop translation) already * applied. Children are traversed in reverse so later siblings (painted on * top) win. Nodes not in nodeCache (not rendered this frame, or lacking a * yogaNode) are skipped along with their subtrees. * * Returns the hit node even if it has no onClick — dispatchClick walks up * via parentNode to find handlers. */ export function hitTest( node: DOMElement, col: number, row: number, ): DOMElement | null { const rect = nodeCache.get(node) if (!rect) return null if ( col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height ) { return null } // Later siblings paint on top; reversed traversal returns topmost hit. for (let i = node.childNodes.length - 1; i >= 0; i--) { const child = node.childNodes[i]! if (child.nodeName === '#text') continue const hit = hitTest(child, col, row) if (hit) return hit } return node } /** * Hit-test the root at (col, row) and bubble a ClickEvent from the deepest * containing node up through parentNode. Only nodes with an onClick handler * fire. Stops when a handler calls stopImmediatePropagation(). Returns * true if at least one onClick handler fired. */ export function dispatchClick( root: DOMElement, col: number, row: number, cellIsBlank = false, ): boolean { let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined if (!target) return false // Click-to-focus: find the closest focusable ancestor and focus it. // root is always ink-root, which owns the FocusManager. if (root.focusManager) { let focusTarget: DOMElement | undefined = target while (focusTarget) { if (typeof focusTarget.attributes['tabIndex'] === 'number') { root.focusManager.handleClickFocus(focusTarget) break } focusTarget = focusTarget.parentNode } } const event = new ClickEvent(col, row, cellIsBlank) let handled = false while (target) { const handler = target._eventHandlers?.onClick as | ((event: ClickEvent) => void) | undefined if (handler) { handled = true const rect = nodeCache.get(target) if (rect) { event.localCol = col - rect.x event.localRow = row - rect.y } handler(event) if (event.didStopImmediatePropagation()) return true } target = target.parentNode } return handled } /** * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM * mouseenter/mouseleave: does NOT bubble — moving between children does * not re-fire on the parent. Walks up from the hit node collecting every * ancestor with a hover handler; diffs against the previous hovered set; * fires leave on the nodes exited, enter on the nodes entered. * * Mutates `hovered` in place so the caller (App instance) can hold it * across calls. Clears the set when the hit is null (cursor moved into a * non-rendered gap or off the root rect). */ export function dispatchHover( root: DOMElement, col: number, row: number, hovered: Set, ): void { const next = new Set() let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined while (node) { const h = node._eventHandlers as EventHandlerProps | undefined if (h?.onMouseEnter || h?.onMouseLeave) next.add(node) node = node.parentNode } for (const old of hovered) { if (!next.has(old)) { hovered.delete(old) // Skip handlers on detached nodes (removed between mouse events) if (old.parentNode) { ;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.() } } } for (const n of next) { if (!hovered.has(n)) { hovered.add(n) ;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.() } } }