Source: dom.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

this.EXPORTED_SYMBOLS = [
  "ContentEventObserverService",
  "WebElementEventTarget",
];

/**
 * The {@link EventTarget} for web elements can be used to observe DOM
 * events in the content document.
 *
 * A caveat of the current implementation is that it is only possible
 * to listen for top-level <code>window</code> global events.
 *
 * It needs to be backed by a {@link ContentEventObserverService} in a
 * content frame script.
 *
 * Usage:
 *
 * <pre><code>
 *     let observer = new WebElementEventTarget(messageManager);
 *     await new Promise(resolve => {
 *       observer.addEventListener("visibilitychange", resolve, {once: true});
 *       chromeWindow.minimize();
 *     });
 * </code></pre>
 */
class WebElementEventTarget {
  /**
   * @param {function(): nsIMessageListenerManager} messageManagerFn
   *     Message manager to the current browser.
   */
  constructor(messageManager) {
    this.mm = messageManager;
    this.listeners = {};
    this.mm.addMessageListener("Marionette:DOM:OnEvent", this);
  }

  /**
   * Register an event handler of a specific event type from the content
   * frame.
   *
   * @param {string} type
   *     Event type to listen for.
   * @param {EventListener} listener
   *     Object which receives a notification (a {@link BareEvent})
   *     when an event of the specified type occurs.  This must be
   *     an object implementing the {@link EventListener} interface,
   *     or a JavaScript function.
   * @param {boolean=} once
   *     Indicates that the <var>listener</var> should be invoked at
   *     most once after being added.  If true, the <var>listener</var>
   *     would automatically be removed when invoked.
   */
  addEventListener(type, listener, {once = false} = {}) {
    if (!(type in this.listeners)) {
      this.listeners[type] = [];
    }

    if (!this.listeners[type].includes(listener)) {
      listener.once = once;
      this.listeners[type].push(listener);
    }

    this.mm.sendAsyncMessage("Marionette:DOM:AddEventListener", {type});
  }

  /**
   * Removes an event listener.
   *
   * @param {string} type
   *     Type of event to cease listening for.
   * @param {EventListener} listener
   *     Event handler to remove from the event target.
   */
  removeEventListener(type, listener) {
    if (!(type in this.listeners)) {
      return;
    }

    let stack = this.listeners[type];
    for (let i = stack.length - 1; i >= 0; --i) {
      if (stack[i] === listener) {
        stack.splice(i, 1);
        if (stack.length == 0) {
          this.mm.sendAsyncMessage("Marionette:DOM:RemoveEventListener", {type});
        }
        return;
      }
    }
  }

  dispatchEvent(event) {
    if (!(event.type in this.listeners)) {
      return;
    }

    event.target = this;

    let stack = this.listeners[event.type].slice(0);
    stack.forEach(listener => {
      listener.call(this, event);

      if (listener.once) {
        this.removeEventListener(event.type, listener);
      }
    });
  }

  receiveMessage({name, data, objects}) {
    if (name != "Marionette:DOM:OnEvent") {
      return;
    }

    let ev = {
      type: data.type,
      target: objects.target,
    };
    this.dispatchEvent(ev);
  }
}
this.WebElementEventTarget = WebElementEventTarget;

/**
 * Provides the frame script backend for the
 * {@link WebElementEventTarget}.
 *
 * This service receives requests for new DOM events to listen for and
 * to cease listening for, and despatches IPC messages to the browser
 * when they fire.
 */
class ContentEventObserverService {
  /**
   * @param {WindowProxy} windowGlobal
   *     Window.
   * @param {nsIMessageSender.sendAsyncMessage} sendAsyncMessage
   *     Function for sending an async message to the parent browser.
   */
  constructor(windowGlobal, sendAsyncMessage) {
    this.window = windowGlobal;
    this.sendAsyncMessage = sendAsyncMessage;
    this.events = new Set();
  }

  /**
   * Observe a new DOM event.
   *
   * When the DOM event of <var>type</var> fires, a message is passed
   * to the parent browser's event observer.
   *
   * If event type is already being observed, only a single message
   * is sent.  E.g. multiple registration for events will only ever emit
   * a maximum of one message.
   *
   * @param {string} type
   *     DOM event to listen for.
   */
  add(type) {
    if (this.events.has(type)) {
      return;
    }
    this.window.addEventListener(type, this);
    this.events.add(type);
  }

  /**
   * Ceases observing a DOM event.
   *
   * @param {string} type
   *     DOM event to stop listening for.
   */
  remove(type) {
    if (!this.events.has(type)) {
      return;
    }
    this.window.removeEventListener(type, this);
    this.events.delete(type);
  }

  /** Ceases observing all previously registered DOM events. */
  clear() {
    for (let ev of this) {
      this.remove(ev);
    }
  }

  * [Symbol.iterator]() {
    for (let ev of this.events) {
      yield ev;
    }
  }

  handleEvent({type, target}) {
    this.sendAsyncMessage("Marionette:DOM:OnEvent", {type}, {target});
  }
}
this.ContentEventObserverService = ContentEventObserverService;