/ ink / hit-test.ts
hit-test.ts
  1  import type { DOMElement } from './dom.js'
  2  import { ClickEvent } from './events/click-event.js'
  3  import type { EventHandlerProps } from './events/event-handlers.js'
  4  import { nodeCache } from './node-cache.js'
  5  
  6  /**
  7   * Find the deepest DOM element whose rendered rect contains (col, row).
  8   *
  9   * Uses the nodeCache populated by renderNodeToOutput — rects are in screen
 10   * coordinates with all offsets (including scrollTop translation) already
 11   * applied. Children are traversed in reverse so later siblings (painted on
 12   * top) win. Nodes not in nodeCache (not rendered this frame, or lacking a
 13   * yogaNode) are skipped along with their subtrees.
 14   *
 15   * Returns the hit node even if it has no onClick — dispatchClick walks up
 16   * via parentNode to find handlers.
 17   */
 18  export function hitTest(
 19    node: DOMElement,
 20    col: number,
 21    row: number,
 22  ): DOMElement | null {
 23    const rect = nodeCache.get(node)
 24    if (!rect) return null
 25    if (
 26      col < rect.x ||
 27      col >= rect.x + rect.width ||
 28      row < rect.y ||
 29      row >= rect.y + rect.height
 30    ) {
 31      return null
 32    }
 33    // Later siblings paint on top; reversed traversal returns topmost hit.
 34    for (let i = node.childNodes.length - 1; i >= 0; i--) {
 35      const child = node.childNodes[i]!
 36      if (child.nodeName === '#text') continue
 37      const hit = hitTest(child, col, row)
 38      if (hit) return hit
 39    }
 40    return node
 41  }
 42  
 43  /**
 44   * Hit-test the root at (col, row) and bubble a ClickEvent from the deepest
 45   * containing node up through parentNode. Only nodes with an onClick handler
 46   * fire. Stops when a handler calls stopImmediatePropagation(). Returns
 47   * true if at least one onClick handler fired.
 48   */
 49  export function dispatchClick(
 50    root: DOMElement,
 51    col: number,
 52    row: number,
 53    cellIsBlank = false,
 54  ): boolean {
 55    let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
 56    if (!target) return false
 57  
 58    // Click-to-focus: find the closest focusable ancestor and focus it.
 59    // root is always ink-root, which owns the FocusManager.
 60    if (root.focusManager) {
 61      let focusTarget: DOMElement | undefined = target
 62      while (focusTarget) {
 63        if (typeof focusTarget.attributes['tabIndex'] === 'number') {
 64          root.focusManager.handleClickFocus(focusTarget)
 65          break
 66        }
 67        focusTarget = focusTarget.parentNode
 68      }
 69    }
 70    const event = new ClickEvent(col, row, cellIsBlank)
 71    let handled = false
 72    while (target) {
 73      const handler = target._eventHandlers?.onClick as
 74        | ((event: ClickEvent) => void)
 75        | undefined
 76      if (handler) {
 77        handled = true
 78        const rect = nodeCache.get(target)
 79        if (rect) {
 80          event.localCol = col - rect.x
 81          event.localRow = row - rect.y
 82        }
 83        handler(event)
 84        if (event.didStopImmediatePropagation()) return true
 85      }
 86      target = target.parentNode
 87    }
 88    return handled
 89  }
 90  
 91  /**
 92   * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
 93   * mouseenter/mouseleave: does NOT bubble — moving between children does
 94   * not re-fire on the parent. Walks up from the hit node collecting every
 95   * ancestor with a hover handler; diffs against the previous hovered set;
 96   * fires leave on the nodes exited, enter on the nodes entered.
 97   *
 98   * Mutates `hovered` in place so the caller (App instance) can hold it
 99   * across calls. Clears the set when the hit is null (cursor moved into a
100   * non-rendered gap or off the root rect).
101   */
102  export function dispatchHover(
103    root: DOMElement,
104    col: number,
105    row: number,
106    hovered: Set<DOMElement>,
107  ): void {
108    const next = new Set<DOMElement>()
109    let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
110    while (node) {
111      const h = node._eventHandlers as EventHandlerProps | undefined
112      if (h?.onMouseEnter || h?.onMouseLeave) next.add(node)
113      node = node.parentNode
114    }
115    for (const old of hovered) {
116      if (!next.has(old)) {
117        hovered.delete(old)
118        // Skip handlers on detached nodes (removed between mouse events)
119        if (old.parentNode) {
120          ;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
121        }
122      }
123    }
124    for (const n of next) {
125      if (!hovered.has(n)) {
126        hovered.add(n)
127        ;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
128      }
129    }
130  }