Portfolio: EventCalmer class

By

Some events (such as scroll, resize, etc.) can fire often. If their handlers do a non-trivial amount of work, calling them repeatedly for each event can cause significant performance issues. This class, implementing the IEventCalmer interface, calls its associated event handler no more than once every minimum_interval milliseconds, and ensures that multiple handlers don't run simultaneously. This can result in significantly smoother visual updates to a web page.

import type { Nullable } from "../../types/meta";
import { EvtListener } from "../event_listener/EvtListener";
import type { SharedState } from "./SharedState";
import type { EventCalmerOptions, IEventCalmer } from "./types";

/**
 * The `EventCalmer` class is designed to manage and throttle event handling.
 * It listens for a specified event on a target and ensures that the event handler
 * is called at most once per specified timeout interval.
 *
 * Once a new instance is created, you can use the `listen` method to begin
 * listening for the event and the `unlisten` method to stop listening.
 * Listening is NOT started automatically upon instantiation.
 */
export class EventCalmer implements IEventCalmer {
  // #region Properties (8)

  #currentEvent: Nullable<Event> = null;
  #eventFired: boolean;
  #handler: (evt: Nullable<Event>) => void | Promise<void>;
  #handlerRunning: boolean = false;
  #listener_obj: EvtListener;
  #minimum_interval: number;
  #timer: Nullable<Timer> | Nullable<number> = null;

  /**
   * Represents the shared state that can be accessed and modified by multiple
   * instances. Any changes made to this shared state will be reflected in all
   * instances that have access to it.
   */
  shared_state: Nullable<SharedState> = null;

  // #endregion Properties (8)

  // #region Constructors (1)

  /**
   * Creates an instance of `EventCalmer`.
   * @param options - The options for configuring the `EventCalmer`.
   * @param options.target - The `EventTarget` to which the event listener will
   *  be attached.
   * @param options.event - The name of the event to listen for (e.g.,
   *  `"click"`, `"mousemove"`).
   * @param options.handler - The event handler function that will be called
   *  when the event occurs. This can be a synchronous or asynchronous function.
   * @param options.minimum_interval - The minimum interval in milliseconds to
   *  wait before invoking the handler. Successive invocations will happen no
   *  sooner than this duration.
   * @param options.options - Options to pass to `addEventListener`. These are:
   *  `capture`, `once`, and `passive`.
   * @param fire_on_start - A flag indicating whether to fire the event handler
   *  on start or wait for the first event to occur.
   */
  constructor(options: EventCalmerOptions, fire_on_start: boolean = false) {
    this.#handler = options.handler;
    this.#minimum_interval = options.minimum_interval;
    this.#eventFired = fire_on_start;
    this.#listener_obj = new EvtListener({
      target: options.target,
      event: options.event,
      callback: this.#internal_handler.bind(this),
      ...options.options || {},
    }, false);
  }

  // #endregion Constructors (1)

  // #region Public Methods (3)

  /**
   * Starts listening for the event and sets up the interval for throttling.
   */
  listen(): void {
    this.#listener_obj.listen();
    this.#timer = setInterval(async () => {
      try {
        await this.#handle_event();
      } catch (error) {
        console.error('Error handling event:', error);
      }
    }, this.#minimum_interval);
  }

  /**
   * Manually triggers the event handler.
   */
  trigger(): void {
    this.#eventFired = true;
    this.#handle_event();
  }

  /**
   * Stops listening for the event and clears the interval.
   */
  unlisten(): void {
    this.#listener_obj.unlisten();
    if (this.#timer) clearInterval(this.#timer);
  }

  // #endregion Public Methods (3)

  // #region Private Methods (2)

  /**
   * This method is invoked every `this.#minimum_interval` milliseconds to
   * handle any events that have occurred since the last invocation. It checks
   * if an event has occurred. Then, if a previous invokation of the handler is
   * not already running, it calls the handler with the most recent event
   * object.
   */
  async #handle_event(): Promise<void> {
    if (this.#eventFired && !this.#handlerRunning) {
      if(this.shared_state !== null && this.shared_state.handlerRunning) return;
      this.#handlerRunning = true;
      if (this.shared_state !== null) this.shared_state.handlerRunning = true;
      this.#eventFired = false;
      await this.#handler(this.#currentEvent);
      this.#currentEvent = null;
      this.#handlerRunning = false;
      if (this.shared_state !== null) this.shared_state.handlerRunning = false;
    }
  }

  /**
   * This is the actual callback passed to the event listener. It sets a flag
   * indicating that an event has occurred and stores the event object. Because
   * this method has to be fast since it can be called many times per second, it
   * does not handle the event itself.
   */
  #internal_handler(evt: Event): void {
    this.#currentEvent = evt;
    this.#eventFired = true;
  }

  // #endregion Private Methods (2)
}