/ ink / events / dispatcher.ts
dispatcher.ts
  1  import {
  2    ContinuousEventPriority,
  3    DefaultEventPriority,
  4    DiscreteEventPriority,
  5    NoEventPriority,
  6  } from 'react-reconciler/constants.js'
  7  import { logError } from '../../utils/log.js'
  8  import { HANDLER_FOR_EVENT } from './event-handlers.js'
  9  import type { EventTarget, TerminalEvent } from './terminal-event.js'
 10  
 11  // --
 12  
 13  type DispatchListener = {
 14    node: EventTarget
 15    handler: (event: TerminalEvent) => void
 16    phase: 'capturing' | 'at_target' | 'bubbling'
 17  }
 18  
 19  function getHandler(
 20    node: EventTarget,
 21    eventType: string,
 22    capture: boolean,
 23  ): ((event: TerminalEvent) => void) | undefined {
 24    const handlers = node._eventHandlers
 25    if (!handlers) return undefined
 26  
 27    const mapping = HANDLER_FOR_EVENT[eventType]
 28    if (!mapping) return undefined
 29  
 30    const propName = capture ? mapping.capture : mapping.bubble
 31    if (!propName) return undefined
 32  
 33    return handlers[propName] as ((event: TerminalEvent) => void) | undefined
 34  }
 35  
 36  /**
 37   * Collect all listeners for an event in dispatch order.
 38   *
 39   * Uses react-dom's two-phase accumulation pattern:
 40   * - Walk from target to root
 41   * - Capture handlers are prepended (unshift) → root-first
 42   * - Bubble handlers are appended (push) → target-first
 43   *
 44   * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
 45   */
 46  function collectListeners(
 47    target: EventTarget,
 48    event: TerminalEvent,
 49  ): DispatchListener[] {
 50    const listeners: DispatchListener[] = []
 51  
 52    let node: EventTarget | undefined = target
 53    while (node) {
 54      const isTarget = node === target
 55  
 56      const captureHandler = getHandler(node, event.type, true)
 57      const bubbleHandler = getHandler(node, event.type, false)
 58  
 59      if (captureHandler) {
 60        listeners.unshift({
 61          node,
 62          handler: captureHandler,
 63          phase: isTarget ? 'at_target' : 'capturing',
 64        })
 65      }
 66  
 67      if (bubbleHandler && (event.bubbles || isTarget)) {
 68        listeners.push({
 69          node,
 70          handler: bubbleHandler,
 71          phase: isTarget ? 'at_target' : 'bubbling',
 72        })
 73      }
 74  
 75      node = node.parentNode
 76    }
 77  
 78    return listeners
 79  }
 80  
 81  /**
 82   * Execute collected listeners with propagation control.
 83   *
 84   * Before each handler, calls event._prepareForTarget(node) so event
 85   * subclasses can do per-node setup.
 86   */
 87  function processDispatchQueue(
 88    listeners: DispatchListener[],
 89    event: TerminalEvent,
 90  ): void {
 91    let previousNode: EventTarget | undefined
 92  
 93    for (const { node, handler, phase } of listeners) {
 94      if (event._isImmediatePropagationStopped()) {
 95        break
 96      }
 97  
 98      if (event._isPropagationStopped() && node !== previousNode) {
 99        break
100      }
101  
102      event._setEventPhase(phase)
103      event._setCurrentTarget(node)
104      event._prepareForTarget(node)
105  
106      try {
107        handler(event)
108      } catch (error) {
109        logError(error)
110      }
111  
112      previousNode = node
113    }
114  }
115  
116  // --
117  
118  /**
119   * Map terminal event types to React scheduling priorities.
120   * Mirrors react-dom's getEventPriority() switch.
121   */
122  function getEventPriority(eventType: string): number {
123    switch (eventType) {
124      case 'keydown':
125      case 'keyup':
126      case 'click':
127      case 'focus':
128      case 'blur':
129      case 'paste':
130        return DiscreteEventPriority as number
131      case 'resize':
132      case 'scroll':
133      case 'mousemove':
134        return ContinuousEventPriority as number
135      default:
136        return DefaultEventPriority as number
137    }
138  }
139  
140  // --
141  
142  type DiscreteUpdates = <A, B>(
143    fn: (a: A, b: B) => boolean,
144    a: A,
145    b: B,
146    c: undefined,
147    d: undefined,
148  ) => boolean
149  
150  /**
151   * Owns event dispatch state and the capture/bubble dispatch loop.
152   *
153   * The reconciler host config reads currentEvent and currentUpdatePriority
154   * to implement resolveUpdatePriority, resolveEventType, and
155   * resolveEventTimeStamp — mirroring how react-dom's host config reads
156   * ReactDOMSharedInternals and window.event.
157   *
158   * discreteUpdates is injected after construction (by InkReconciler)
159   * to break the import cycle.
160   */
161  export class Dispatcher {
162    currentEvent: TerminalEvent | null = null
163    currentUpdatePriority: number = DefaultEventPriority as number
164    discreteUpdates: DiscreteUpdates | null = null
165  
166    /**
167     * Infer event priority from the currently-dispatching event.
168     * Called by the reconciler host config's resolveUpdatePriority
169     * when no explicit priority has been set.
170     */
171    resolveEventPriority(): number {
172      if (this.currentUpdatePriority !== (NoEventPriority as number)) {
173        return this.currentUpdatePriority
174      }
175      if (this.currentEvent) {
176        return getEventPriority(this.currentEvent.type)
177      }
178      return DefaultEventPriority as number
179    }
180  
181    /**
182     * Dispatch an event through capture and bubble phases.
183     * Returns true if preventDefault() was NOT called.
184     */
185    dispatch(target: EventTarget, event: TerminalEvent): boolean {
186      const previousEvent = this.currentEvent
187      this.currentEvent = event
188      try {
189        event._setTarget(target)
190  
191        const listeners = collectListeners(target, event)
192        processDispatchQueue(listeners, event)
193  
194        event._setEventPhase('none')
195        event._setCurrentTarget(null)
196  
197        return !event.defaultPrevented
198      } finally {
199        this.currentEvent = previousEvent
200      }
201    }
202  
203    /**
204     * Dispatch with discrete (sync) priority.
205     * For user-initiated events: keyboard, click, focus, paste.
206     */
207    dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
208      if (!this.discreteUpdates) {
209        return this.dispatch(target, event)
210      }
211      return this.discreteUpdates(
212        (t, e) => this.dispatch(t, e),
213        target,
214        event,
215        undefined,
216        undefined,
217      )
218    }
219  
220    /**
221     * Dispatch with continuous priority.
222     * For high-frequency events: resize, scroll, mouse move.
223     */
224    dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
225      const previousPriority = this.currentUpdatePriority
226      try {
227        this.currentUpdatePriority = ContinuousEventPriority as number
228        return this.dispatch(target, event)
229      } finally {
230        this.currentUpdatePriority = previousPriority
231      }
232    }
233  }