/* 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";

const {
  safeAsyncMethod,
} = require("resource://devtools/shared/async-utils.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js");
const {
  VIEW_NODE_VALUE_TYPE,
  VIEW_NODE_SHAPE_POINT_TYPE,
} = require("resource://devtools/client/inspector/shared/node-types.js");

const { TYPES } = ChromeUtils.importESModule(
  "resource://devtools/shared/highlighters.mjs"
);

loader.lazyRequireGetter(
  this,
  "parseURL",
  "resource://devtools/client/shared/source-utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "asyncStorage",
  "resource://devtools/shared/async-storage.js"
);
loader.lazyRequireGetter(
  this,
  "gridsReducer",
  "resource://devtools/client/inspector/grids/reducers/grids.js"
);
loader.lazyRequireGetter(
  this,
  "highlighterSettingsReducer",
  "resource://devtools/client/inspector/grids/reducers/highlighter-settings.js"
);
loader.lazyRequireGetter(
  this,
  "flexboxReducer",
  "resource://devtools/client/inspector/flexbox/reducers/flexbox.js"
);
loader.lazyRequireGetter(
  this,
  "deepEqual",
  "resource://devtools/shared/DevToolsUtils.js",
  true
);
loader.lazyGetter(this, "HighlightersBundle", () => {
  return new Localization(["devtools/shared/highlighters.ftl"], true);
});

const DEFAULT_HIGHLIGHTER_COLOR = "#9400FF";
const SUBGRID_PARENT_ALPHA = 0.5;

/**
 * While refactoring to an abstracted way to show and hide highlighters,
 * we did not update all tests and code paths which listen for exact events.
 *
 * When we show or hide highlighters we reference this mapping to
 * emit events that consumers may be listening to.
 *
 * This list should go away as we incrementally rewrite tests to use
 * abstract event names with data payloads indicating the highlighter.
 *
 * DO NOT OPTIMIZE THIS MAPPING AS CONCATENATED SUBSTRINGS!
 * It makes it difficult to do project-wide searches for exact matches.
 */
const HIGHLIGHTER_EVENTS = {
  [TYPES.GRID]: {
    shown: "grid-highlighter-shown",
    hidden: "grid-highlighter-hidden",
  },
  [TYPES.GEOMETRY]: {
    shown: "geometry-editor-highlighter-shown",
    hidden: "geometry-editor-highlighter-hidden",
  },
  [TYPES.SHAPES]: {
    shown: "shapes-highlighter-shown",
    hidden: "shapes-highlighter-hidden",
  },
  [TYPES.TRANSFORM]: {
    shown: "css-transform-highlighter-shown",
    hidden: "css-transform-highlighter-hidden",
  },
};

// Tool IDs mapped by highlighter type. Used to log telemetry for opening & closing tools.
const GLEAN_TOOL_IDS = {
  [TYPES.FLEXBOX]: "flexbox_highlighter",
  [TYPES.GRID]: "grid_highlighter",
};

// Glean counter names mapped by highlighter type. Used to log telemetry about highlighter triggers.
const GLEAN_COUNTER_NAMES = {
  [TYPES.FLEXBOX]: {
    layout: "devtoolsLayoutFlexboxhighlighter",
    markup: "devtoolsMarkupFlexboxhighlighter",
    rule: "devtoolsRulesFlexboxhighlighter",
  },

  [TYPES.GRID]: {
    grid: "devtoolsGridGridinspector",
    markup: "devtoolsMarkupGridinspector",
    rule: "devtoolsRulesGridinspector",
  },
};

/**
 * HighlightersOverlay manages the visibility of highlighters in the Inspector.
 */
class HighlightersOverlay {
  /**
   * @param  {Inspector} inspector
   *         Inspector toolbox panel.
   */
  constructor(inspector) {
    this.inspector = inspector;
    this.store = this.inspector.store;

    this.telemetry = this.inspector.telemetry;
    this.maxGridHighlighters = Services.prefs.getIntPref(
      "devtools.gridinspector.maxHighlighters"
    );

    // Collection of instantiated highlighter actors like FlexboxHighlighter,
    // ShapesHighlighter and GeometryEditorHighlighter.
    this.highlighters = {};
    // Map of grid container node to an object with the grid highlighter instance
    // and, if the node is a subgrid, the parent grid node and parent grid highlighter.
    // Ex: {NodeFront} => {
    //  highlighter: {CustomHighlighterFront},
    //  parentGridNode: {NodeFront|null},
    //  parentGridHighlighter: {CustomHighlighterFront|null}
    // }
    this.gridHighlighters = new Map();
    // Collection of instantiated in-context editors, like ShapesInContextEditor, which
    // behave like highlighters but with added editing capabilities that need to map value
    // changes to properties in the Rule view.
    this.editors = {};

    // Highlighter state.
    this.state = {
      // Map of grid container NodeFront to the their stored grid options
      // Used to restore grid highlighters on reload (should be migrated to
      // #restorableHighlighters in Bug 1572652).
      grids: new Map(),
      // Shape Path Editor highlighter options.
      // Used as a cache for the latest configuration when showing the highlighter.
      // It is reused and augmented when hovering coordinates in the Rules view which
      // mark the corresponding points in the highlighter overlay.
      shapes: {},
    };

    // NodeFront of element that is highlighted by the geometry editor.
    this.geometryEditorHighlighterShown = null;
    // Name of the highlighter shown on mouse hover.
    this.hoveredHighlighterShown = null;
    // NodeFront of the shape that is highlighted
    this.shapesHighlighterShown = null;

    this.onClick = this.onClick.bind(this);
    this.onDisplayChange = this.onDisplayChange.bind(this);
    this.onMarkupMutation = this.onMarkupMutation.bind(this);

    this.onMouseMove = this.onMouseMove.bind(this);
    this.onMouseOut = this.onMouseOut.bind(this);
    this.hideAllHighlighters = this.hideAllHighlighters.bind(this);
    this.hideFlexboxHighlighter = this.hideFlexboxHighlighter.bind(this);
    this.hideGridHighlighter = this.hideGridHighlighter.bind(this);
    this.hideShapesHighlighter = this.hideShapesHighlighter.bind(this);
    this.showFlexboxHighlighter = this.showFlexboxHighlighter.bind(this);
    this.showGridHighlighter = this.showGridHighlighter.bind(this);
    this.showShapesHighlighter = this.showShapesHighlighter.bind(this);
    this.onShapesHighlighterShown = this.onShapesHighlighterShown.bind(this);
    this.onShapesHighlighterHidden = this.onShapesHighlighterHidden.bind(this);

    // Catch unexpected errors from async functions if the manager has been destroyed.
    this.hideHighlighterType = safeAsyncMethod(
      this.hideHighlighterType.bind(this),
      () => this.destroyed
    );
    this.showHighlighterTypeForNode = safeAsyncMethod(
      this.showHighlighterTypeForNode.bind(this),
      () => this.destroyed
    );
    this.showGridHighlighter = safeAsyncMethod(
      this.showGridHighlighter.bind(this),
      () => this.destroyed
    );
    this.restoreState = safeAsyncMethod(
      this.restoreState.bind(this),
      () => this.destroyed
    );

    // Add inspector events, not specific to a given view.
    this.inspector.on("markupmutation", this.onMarkupMutation);

    this.resourceCommand = this.inspector.commands.resourceCommand;
    this.resourceCommand.watchResources(
      [this.resourceCommand.TYPES.ROOT_NODE],
      { onAvailable: this.#onResourceAvailable }
    );

    this.walkerEventListener = new WalkerEventListener(this.inspector, {
      "display-change": this.onDisplayChange,
    });

    if (this.toolbox.win.matchMedia("(prefers-reduced-motion)").matches) {
      this.#showSimpleHighlightersMessage();
    }

    EventEmitter.decorate(this);
  }

  // Map of active highlighter types to objects with the highlighted nodeFront and the
  // highlighter instance. Ex: "BoxModelHighlighter" => { nodeFront, highlighter }
  // It will fully replace this.highlighters when all highlighter consumers are updated
  // to use it as the single source of truth for which highlighters are visible.
  #activeHighlighters = new Map();
  // Map of highlighter types to symbols. Showing highlighters is an async operation,
  // until it doesn't complete, this map will be populated with the requested type and
  // a unique symbol identifying that request. Once completed, the entry is removed.
  #pendingHighlighters = new Map();
  // Map of highlighter types to objects with metadata used to restore active
  // highlighters after a page reload.
  #restorableHighlighters = new Map();

  #lastHovered = null;

  get inspectorFront() {
    return this.inspector.inspectorFront;
  }

  get target() {
    return this.inspector.currentTarget;
  }

  get toolbox() {
    return this.inspector.toolbox;
  }

  /**
   * Optionally run some operations right after showing a highlighter of a given type,
   * but before notifying consumers by emitting the "highlighter-shown" event.
   *
   * This is a chance to run some non-essential operations like: logging telemetry data,
   * storing metadata about the highlighter to enable restoring it after refresh, etc.
   *
   * @param  {string} type
   *          Highlighter type shown.
   * @param  {NodeFront} nodeFront
   *          Node front of the element that was highlighted.
   * @param  {Options} options
   *          Optional object with options passed to the highlighter.
   */
  #afterShowHighlighterTypeForNode(type, nodeFront, options) {
    switch (type) {
      // Log telemetry for showing the flexbox and grid highlighters.
      case TYPES.FLEXBOX:
      case TYPES.GRID: {
        const toolID = GLEAN_TOOL_IDS[type];
        if (toolID) {
          this.telemetry.toolOpened(toolID, this);
        }

        const counterName = GLEAN_COUNTER_NAMES[type]?.[options?.trigger];
        if (counterName) {
          Glean[counterName].opened.add(1);
        }

        break;
      }
    }

    // Set metadata necessary to restore the active highlighter upon page refresh.
    if (type === TYPES.FLEXBOX) {
      const { url } = this.target;
      const selectors = [...this.inspector.selectionCssSelectors];

      this.#restorableHighlighters.set(type, {
        options,
        selectors,
        type,
        url,
      });
    }
  }

  /**
   * Optionally run some operations before showing a highlighter of a given type.
   *
   * Depending its type, before showing a new instance of a highlighter, we may do extra
   * operations, like hiding another visible highlighter, or preventing the show
   * operation, for example due to a duplicate call with the same arguments.
   *
   * Returns a promise that resovles with a boolean indicating whether to skip showing
   * the highlighter with these arguments.
   *
   * @param  {string} type
   *          Highlighter type to show.
   * @param  {NodeFront} nodeFront
   *          Node front of the element to be highlighted.
   * @param  {Options} options
   *          Optional object with options to pass to the highlighter.
   * @return {Promise}
   */
  async #beforeShowHighlighterTypeForNode(type, nodeFront, options) {
    // Get the data associated with the visible highlighter of this type, if any.
    const {
      highlighter: activeHighlighter,
      nodeFront: activeNodeFront,
      options: activeOptions,
      timer: activeTimer,
    } = this.getDataForActiveHighlighter(type);

    // There isn't an active highlighter of this type. Early return, proceed with showing.
    if (!activeHighlighter) {
      return false;
    }

    // Whether conditions are met to skip showing the highlighter (ex: duplicate calls).
    let skipShow = false;

    // Clear any autohide timer associated with this highlighter type.
    // This clears any existing timer for duplicate calls to show() if:
    // - called with different options.duration
    // - called once with options.duration, then without (see deepEqual() above)
    clearTimeout(activeTimer);

    switch (type) {
      // Hide the visible selector highlighter if called for the same node,
      // but with a different selector.
      case TYPES.SELECTOR:
        if (
          nodeFront === activeNodeFront &&
          options?.selector !== activeOptions?.selector
        ) {
          await this.hideHighlighterType(TYPES.SELECTOR);
        }
        break;

      // For others, hide the existing highlighter before showing it for a different node.
      // Else, if the node is the same and options are the same, skip a duplicate call.
      // Duplicate calls to show the highlighter for the same node are allowed
      // if the options are different (for example, when scheduling autohide).
      default:
        if (nodeFront !== activeNodeFront) {
          await this.hideHighlighterType(type);
        } else if (deepEqual(options, activeOptions)) {
          skipShow = true;
        }
    }

    return skipShow;
  }

  /**
   * Optionally run some operations before hiding a highlighter of a given type.
   * Runs only if a highlighter of that type exists.
   *
   * @param {string} type
   *         highlighter type
   * @return {Promise}
   */
  #beforeHideHighlighterType(type) {
    switch (type) {
      // Log telemetry for hiding the flexbox and grid highlighters.
      case TYPES.FLEXBOX:
      case TYPES.GRID: {
        const toolID = GLEAN_TOOL_IDS[type];
        const conditions = {
          [TYPES.FLEXBOX]: () => {
            // always stop the timer when the flexbox highlighter is about to be hidden.
            return true;
          },
          [TYPES.GRID]: () => {
            // stop the timer only once the last grid highlighter is about to be hidden.
            return this.gridHighlighters.size === 1;
          },
        };

        if (toolID && conditions[type].call(this)) {
          this.telemetry.toolClosed(toolID, this);
        }

        break;
      }
    }
  }

  /**
   * Get the maximum number of possible active highlighter instances of a given type.
   *
   * @param  {string} type
   *         Highlighter type
   * @return {number}
   *         Default 1
   */
  #getMaxActiveHighlighters(type) {
    let max;

    switch (type) {
      // Grid highligthters are special (there is a parent-child relationship between
      // subgrid and parent grid) so we suppport multiple visible instances.
      // Grid highlighters are performance-intensive and this limit is somewhat arbitrary
      // to guard against performance degradation.
      case TYPES.GRID:
        max = this.maxGridHighlighters;
        break;
      // By default, for all other highlighter types, only one instance may visible.
      // Before showing a new highlighter, any other instance will be hidden.
      default:
        max = 1;
    }

    return max;
  }

  /**
   * Get a highlighter instance of the given type for the given node front.
   *
   * @param  {string} type
   *         Highlighter type.
   * @param  {NodeFront} nodeFront
   *         Node front of the element to be highlighted with the requested highlighter.
   * @return {Promise}
   *         Promise which resolves with a highlighter instance
   */
  async #getHighlighterTypeForNode(type, nodeFront) {
    const { inspectorFront } = nodeFront;
    const max = this.#getMaxActiveHighlighters(type);
    let highlighter;

    // If only one highlighter instance may be visible, get a highlighter front
    // and cache it to return it on future requests.
    // Otherwise, return a new highlighter front every time and clean-up manually.
    if (max === 1) {
      highlighter = await inspectorFront.getOrCreateHighlighterByType(type);
    } else {
      highlighter = await inspectorFront.getHighlighterByType(type);
    }

    return highlighter;
  }

  /**
   * Get the currently active highlighter of a given type.
   *
   * @param  {string} type
   *         Highlighter type.
   * @return {Highlighter|null}
   *         Highlighter instance
   *         or null if no highlighter of that type is active.
   */
  getActiveHighlighter(type) {
    if (!this.#activeHighlighters.has(type)) {
      return null;
    }

    const { highlighter } = this.#activeHighlighters.get(type);
    return highlighter;
  }

  /**
   * Get an object with data associated with the active highlighter of a given type.
   * This data object contains:
   *   - nodeFront: NodeFront of the highlighted node
   *   - highlighter: Highlighter instance
   *   - options: Configuration options passed to the highlighter
   *   - timer: (Optional) index of timer set with setTimout() to autohide the highlighter
   * Returns an empty object if a highlighter of the given type is not active.
   *
   * @param  {string} type
   *         Highlighter type.
   * @return {object}
   */
  getDataForActiveHighlighter(type) {
    if (!this.#activeHighlighters.has(type)) {
      return {};
    }

    return this.#activeHighlighters.get(type);
  }

  /**
   * Get the configuration options of the active highlighter of a given type.
   *
   * @param  {string} type
   *         Highlighter type.
   * @return {object}
   */
  getOptionsForActiveHighlighter(type) {
    const { options } = this.getDataForActiveHighlighter(type);
    return options;
  }

  /**
   * Get the node front highlighted by a given highlighter type.
   *
   * @param  {string} type
   *         Highlighter type.
   * @return {NodeFront|null}
   *         Node front of the element currently being highlighted
   *         or null if no highlighter of that type is active.
   */
  getNodeForActiveHighlighter(type) {
    if (!this.#activeHighlighters.has(type)) {
      return null;
    }

    const { nodeFront } = this.#activeHighlighters.get(type);
    return nodeFront;
  }

  /**
   * Highlight a given node front with a given type of highlighter.
   *
   * Highlighters are shown for one node at a time. Before showing the same highlighter
   * type on another node, it will first be hidden from the previously highlighted node.
   * In pages with frames running in different processes, this ensures highlighters from
   * other frames do not stay visible.
   *
   * @param  {string} type
   *          Highlighter type to show.
   * @param  {NodeFront} nodeFront
   *          Node front of the element to be highlighted.
   * @param  {Options} options
   *         Optional object with options to pass to the highlighter.
   * @return {Promise}
   */
  async showHighlighterTypeForNode(type, nodeFront, options) {
    const promise = this.#beforeShowHighlighterTypeForNode(
      type,
      nodeFront,
      options
    );

    // Set a pending highlighter in order to detect if, while we were awaiting, there was
    // a more recent request to highlight a node with the same type, or a request to hide
    // the highlighter. Then we will abort this one in favor of the newer one.
    // This needs to be done before the 'await' in order to be synchronous, but after
    // calling #beforeShowHighlighterTypeForNode, since it can call hideHighlighterType.
    const id = Symbol();
    this.#pendingHighlighters.set(type, id);
    const skipShow = await promise;

    if (this.#pendingHighlighters.get(type) !== id) {
      return;
    } else if (skipShow || nodeFront.isDestroyed()) {
      this.#pendingHighlighters.delete(type);
      return;
    }

    const highlighter = await this.#getHighlighterTypeForNode(type, nodeFront);

    if (this.#pendingHighlighters.get(type) !== id) {
      return;
    }
    this.#pendingHighlighters.delete(type);

    // Set a timer to automatically hide the highlighter if a duration is provided.
    const timer = this.scheduleAutoHideHighlighterType(type, options?.duration);
    // TODO: support case for multiple highlighter instances (ex: multiple grids)
    this.#activeHighlighters.set(type, {
      nodeFront,
      highlighter,
      options,
      timer,
    });
    await highlighter.show(nodeFront, options);
    this.#afterShowHighlighterTypeForNode(type, nodeFront, options);

    // Emit any type-specific highlighter shown event for tests
    // which have not yet been updated to listen for the generic event
    if (HIGHLIGHTER_EVENTS[type]?.shown) {
      this.emit(HIGHLIGHTER_EVENTS[type].shown, nodeFront, options);
    }
    this.emit("highlighter-shown", { type, highlighter, nodeFront, options });
  }

  /**
   * Set a timer to automatically hide all highlighters of a given type after a delay.
   *
   * @param  {string} type
   *         Highlighter type to hide.
   * @param  {number | undefined} duration
   *         Delay in milliseconds after which to hide the highlighter.
   *         If a duration is not provided, return early without scheduling a task.
   * @return {number | undefined}
   *         Index of the scheduled task returned by setTimeout().
   */
  scheduleAutoHideHighlighterType(type, duration) {
    if (!duration) {
      return undefined;
    }

    const timer = setTimeout(async () => {
      await this.hideHighlighterType(type);
      clearTimeout(timer);
    }, duration);

    return timer;
  }

  /**
   * Hide all instances of a given highlighter type.
   *
   * @param  {string} type
   *         Highlighter type to hide.
   * @return {Promise}
   */
  async hideHighlighterType(type) {
    if (this.#pendingHighlighters.has(type)) {
      // Abort pending highlighters for the given type.
      this.#pendingHighlighters.delete(type);
    }
    if (!this.#activeHighlighters.has(type)) {
      return;
    }

    const data = this.getDataForActiveHighlighter(type);
    const { highlighter, nodeFront, timer } = data;
    // Clear any autohide timer associated with this highlighter type.
    clearTimeout(timer);
    // Remove any metadata used to restore this highlighter type on page refresh.
    this.#restorableHighlighters.delete(type);
    this.#activeHighlighters.delete(type);
    this.#beforeHideHighlighterType(type);
    await highlighter.hide();

    // Emit any type-specific highlighter hidden event for tests
    // which have not yet been updated to listen for the generic event
    if (HIGHLIGHTER_EVENTS[type]?.hidden) {
      this.emit(HIGHLIGHTER_EVENTS[type].hidden, nodeFront);
    }
    this.emit("highlighter-hidden", { type, ...data });
  }

  /**
   * Returns true if the grid highlighter can be toggled on/off for the given node, and
   * false otherwise. A grid container can be toggled on if the max grid highlighters
   * is only 1 or less than the maximum grid highlighters that can be displayed or if
   * the grid highlighter already highlights the given node.
   *
   * @param  {NodeFront} node
   *         Grid container NodeFront.
   * @return {boolean}
   */
  canGridHighlighterToggle(node) {
    return (
      this.maxGridHighlighters === 1 ||
      this.gridHighlighters.size < this.maxGridHighlighters ||
      this.gridHighlighters.has(node)
    );
  }

  /**
   * Returns true when the maximum number of grid highlighter instances is reached.
   * FIXME: Bug 1572652 should address this constraint.
   *
   * @return {boolean}
   */
  isGridHighlighterLimitReached() {
    return this.gridHighlighters.size === this.maxGridHighlighters;
  }

  /**
   * Returns whether `node` is somewhere inside the DOM of the rule view.
   *
   * @param {DOMNode} node
   * @return {boolean}
   */
  isRuleView(node) {
    return !!node.closest("#ruleview-panel");
  }

  /**
   * Add the highlighters overlay to the view. This will start tracking mouse events
   * and display highlighters when needed.
   *
   * @param  {CssRuleView|CssComputedView|LayoutView} view
   *         Either the rule-view or computed-view panel to add the highlighters overlay.
   */
  addToView(view) {
    const el = view.element;
    el.addEventListener("click", this.onClick, true);
    el.addEventListener("mousemove", this.onMouseMove);
    el.addEventListener("mouseout", this.onMouseOut);
    el.ownerDocument.defaultView.addEventListener("mouseout", this.onMouseOut);
  }

  /**
   * Remove the overlay from the given view. This will stop tracking mouse movement and
   * showing highlighters.
   *
   * @param  {CssRuleView|CssComputedView|LayoutView} view
   *         Either the rule-view or computed-view panel to remove the highlighters
   *         overlay.
   */
  removeFromView(view) {
    const el = view.element;
    el.removeEventListener("click", this.onClick, true);
    el.removeEventListener("mousemove", this.onMouseMove);
    el.removeEventListener("mouseout", this.onMouseOut);
  }

  /**
   * Toggle the shapes highlighter for the given node.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the element with a shape to highlight.
   * @param  {object} options
   *         Object used for passing options to the shapes highlighter.
   * @param {TextProperty} textProperty
   *        TextProperty where to write changes.
   */
  async toggleShapesHighlighter(node, options, textProperty) {
    const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
    if (!shapesEditor) {
      return;
    }
    shapesEditor.toggle(node, options, textProperty);
  }

  /**
   * Show the shapes highlighter for the given node.
   * This method delegates to the in-context shapes editor.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the element with a shape to highlight.
   * @param  {object} options
   *         Object used for passing options to the shapes highlighter.
   */
  async showShapesHighlighter(node, options) {
    const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
    if (!shapesEditor) {
      return;
    }
    shapesEditor.show(node, options);
  }

  /**
   * Called after the shape highlighter was shown.
   *
   * @param  {object} data
   *         Data associated with the event.
   *         Contains:
   *         - {NodeFront} node: The NodeFront of the element that is highlighted.
   *         - {Object} options: Options that were passed to ShapesHighlighter.show()
   */
  onShapesHighlighterShown(data) {
    const { node, options } = data;
    this.shapesHighlighterShown = node;
    this.state.shapes.options = options;
    this.emit("shapes-highlighter-shown", node, options);
  }

  /**
   * Hide the shapes highlighter if visible.
   * This method delegates the to the in-context shapes editor which wraps
   * the shapes highlighter with additional functionality.
   *
   * @param  {NodeFront} node.
   */
  async hideShapesHighlighter(node) {
    const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
    if (!shapesEditor) {
      return;
    }
    shapesEditor.hide();
  }

  /**
   * Called after the shapes highlighter was hidden.
   */
  onShapesHighlighterHidden() {
    this.emit(
      "shapes-highlighter-hidden",
      this.shapesHighlighterShown,
      this.state.shapes.options
    );
    this.shapesHighlighterShown = null;
    this.state.shapes = {};
  }

  /**
   * Show the shapes highlighter for the given element, with the given point highlighted.
   *
   * @param {NodeFront} node
   *        The NodeFront of the element to highlight.
   * @param {string} point
   *        The point to highlight in the shapes highlighter.
   */
  async hoverPointShapesHighlighter(node, point) {
    if (node == this.shapesHighlighterShown) {
      const options = Object.assign({}, this.state.shapes.options);
      options.hoverPoint = point;
      await this.showShapesHighlighter(node, options);
    }
  }

  /**
   * Returns the flexbox highlighter color for the given node.
   */
  async getFlexboxHighlighterColor() {
    // Load the Redux slice for flexbox if not yet available.
    const state = this.store.getState();
    if (!state.flexbox) {
      this.store.injectReducer("flexbox", flexboxReducer);
    }

    // Attempt to get the flexbox highlighter color from the Redux store.
    const { flexbox } = this.store.getState();
    const color = flexbox.color;

    if (color) {
      return color;
    }

    // If the flexbox inspector has not been initialized, attempt to get the flexbox
    // highlighter from the async storage.
    const customHostColors =
      (await asyncStorage.getItem("flexboxInspectorHostColors")) || {};

    // Get the hostname, if there is no hostname, fall back on protocol
    // ex: `data:` uri, and `about:` pages
    let hostname;
    try {
      hostname =
        parseURL(this.target.url).hostname ||
        parseURL(this.target.url).protocol;
    } catch (e) {
      this.#handleRejection(e);
    }

    return hostname && customHostColors[hostname]
      ? customHostColors[hostname]
      : DEFAULT_HIGHLIGHTER_COLOR;
  }

  /**
   * Toggle the flexbox highlighter for the given flexbox container element.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the flexbox container element to highlight.
   * @param. {string} trigger
   *         String name matching "layout", "markup" or "rule" to indicate where the
   *         flexbox highlighter was toggled on from. "layout" represents the layout view.
   *         "markup" represents the markup view. "rule" represents the rule view.
   */
  async toggleFlexboxHighlighter(node, trigger) {
    const highlightedNode = this.getNodeForActiveHighlighter(TYPES.FLEXBOX);
    if (node == highlightedNode) {
      await this.hideFlexboxHighlighter(node);
      return;
    }

    await this.showFlexboxHighlighter(node, {}, trigger);
  }

  /**
   * Show the flexbox highlighter for the given flexbox container element.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the flexbox container element to highlight.
   * @param  {object} options
   *         Object used for passing options to the flexbox highlighter.
   * @param. {string} trigger
   *         String name matching "layout", "markup" or "rule" to indicate where the
   *         flexbox highlighter was toggled on from. "layout" represents the layout view.
   *         "markup" represents the markup view. "rule" represents the rule view.
   *         Will be passed as an option even though the highlighter doesn't use it
   *         in order to log telemetry in #afterShowHighlighterTypeForNode()
   */
  async showFlexboxHighlighter(node, options, trigger) {
    const color = await this.getFlexboxHighlighterColor(node);
    await this.showHighlighterTypeForNode(TYPES.FLEXBOX, node, {
      ...options,
      trigger,
      color,
    });
  }

  /**
   * Hide the flexbox highlighter if any instance is visible.
   */
  async hideFlexboxHighlighter() {
    await this.hideHighlighterType(TYPES.FLEXBOX);
  }

  /**
   * Create a grid highlighter settings object for the provided nodeFront.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront for which we need highlighter settings.
   */
  getGridHighlighterSettings(nodeFront) {
    // Load the Redux slices for grids and grid highlighter settings if not yet available.
    const state = this.store.getState();
    if (!state.grids) {
      this.store.injectReducer("grids", gridsReducer);
    }

    if (!state.highlighterSettings) {
      this.store.injectReducer(
        "highlighterSettings",
        highlighterSettingsReducer
      );
    }

    // Get grids and grid highlighter settings from the latest Redux state
    // in case they were just added above.
    const { grids, highlighterSettings } = this.store.getState();
    const grid = grids.find(g => g.nodeFront === nodeFront);
    const color = grid ? grid.color : DEFAULT_HIGHLIGHTER_COLOR;
    const zIndex = grid ? grid.zIndex : 0;
    return Object.assign({}, highlighterSettings, { color, zIndex });
  }

  /**
   * Return a list of all node fronts that are highlighted with a Grid highlighter.
   *
   * @return {Array}
   */
  getHighlightedGridNodes() {
    return [...Array.from(this.gridHighlighters.keys())];
  }

  /**
   * Toggle the grid highlighter for the given grid container element.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the grid container element to highlight.
   * @param. {string} trigger
   *         String name matching "grid", "markup" or "rule" to indicate where the
   *         grid highlighter was toggled on from. "grid" represents the grid view.
   *         "markup" represents the markup view. "rule" represents the rule view.
   */
  async toggleGridHighlighter(node, trigger) {
    if (this.gridHighlighters.has(node)) {
      await this.hideGridHighlighter(node);
      return;
    }

    await this.showGridHighlighter(node, {}, trigger);
  }

  /**
   * Show the grid highlighter for the given grid container element.
   * Allow as many active highlighter instances as permitted by the
   * maxGridHighlighters limit (default 3).
   *
   * Logic of showing grid highlighters:
   * - GRID:
   *  - Show a highlighter for a grid container when explicitly requested
   *    (ex. click badge in Markup view) and count it against the limit.
   *  - When the limit of active highlighters is reached, do no show any more
   *    until other instances are hidden. If configured to show only one instance,
   *    hide the existing highlighter before showing a new one.
   *
   * - SUBGRID:
   *  - When a highlighter for a subgrid is shown, also show a highlighter for its parent
   *    grid, but with faded-out colors (serves as a visual reference for the subgrid)
   *  - The "active" state of the highlighter for the parent grid is not reflected
   *    in the UI (checkboxes in the Layout panel, badges in the Markup view, etc.)
   *  - The highlighter for the parent grid DOES NOT count against the highlighter limit
   *  - If the highlighter for the parent grid is explicitly requested to be shown
   *    (ex: click badge in Markup view), show it in full color and reflect its "active"
   *    state in the UI (checkboxes in the Layout panel, badges in the Markup view)
   *  - When a highlighter for a subgrid is hidden, also hide the highlighter for its
   *    parent grid; if the parent grid was explicitly requested separately, keep the
   *    highlighter for the parent grid visible, but show it in full color.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the grid container element to highlight.
   * @param  {object} options
   *         Object used for passing options to the grid highlighter.
   * @param  {string} trigger
   *         String name matching "grid", "markup" or "rule" to indicate where the
   *         grid highlighter was toggled on from. "grid" represents the grid view.
   *         "markup" represents the markup view. "rule" represents the rule view.
   */
  async showGridHighlighter(node, options, trigger) {
    if (!this.gridHighlighters.has(node)) {
      // If only one grid highlighter can be shown at a time, hide the other instance.
      // Otherwise, if the max highlighter limit is reached, do not show another one.
      if (this.maxGridHighlighters === 1) {
        await this.hideGridHighlighter(
          this.gridHighlighters.keys().next().value
        );
      } else if (this.gridHighlighters.size === this.maxGridHighlighters) {
        return;
      }
    }

    // If the given node is already highlighted as the parent grid for a subgrid,
    // hide the parent grid highlighter because it will be explicitly shown below.
    const isHighlightedAsParentGrid = Array.from(this.gridHighlighters.values())
      .map(value => value.parentGridNode)
      .includes(node);
    if (isHighlightedAsParentGrid) {
      await this.hideParentGridHighlighter(node);
    }

    // Show a translucent highlight of the parent grid container if the given node is
    // a subgrid and the parent grid container is not already explicitly highlighted.
    let parentGridNode = null;
    let parentGridHighlighter = null;
    if (node.displayType === "subgrid") {
      parentGridNode = await node.walkerFront.getParentGridNode(node);
      parentGridHighlighter =
        await this.showParentGridHighlighter(parentGridNode);
    }

    // When changing highlighter colors, we call highlighter.show() again with new options
    // Reuse the active highlighter instance if present; avoid creating new highlighters
    let highlighter;
    if (this.gridHighlighters.has(node)) {
      highlighter = this.gridHighlighters.get(node).highlighter;
    }

    if (!highlighter) {
      highlighter = await this.#getHighlighterTypeForNode(TYPES.GRID, node);
    }

    this.gridHighlighters.set(node, {
      highlighter,
      parentGridNode,
      parentGridHighlighter,
    });

    options = { ...options, ...this.getGridHighlighterSettings(node) };
    await highlighter.show(node, options);

    this.#afterShowHighlighterTypeForNode(TYPES.GRID, node, {
      ...options,
      trigger,
    });

    try {
      // Save grid highlighter state.
      const { url } = this.target;

      const selectors =
        await this.inspector.commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument(
          node
        );

      this.state.grids.set(node, { selectors, options, url });

      // Emit the NodeFront of the grid container element that the grid highlighter was
      // shown for, and its options for testing the highlighter setting options.
      this.emit("grid-highlighter-shown", node, options);

      // XXX: Shim to use generic highlighter events until addressing Bug 1572652
      // Ensures badges in the Markup view reflect the state of the grid highlighter.
      this.emit("highlighter-shown", {
        type: TYPES.GRID,
        nodeFront: node,
        highlighter,
        options,
      });
    } catch (e) {
      this.#handleRejection(e);
    }
  }

  /**
   * Show the grid highlighter for the given subgrid's parent grid container element.
   * The parent grid highlighter is shown with faded-out colors, as opposed
   * to the full-color grid highlighter shown when calling showGridHighlighter().
   * If the grid container is already explicitly highlighted (i.e. standalone grid),
   * skip showing the another grid highlighter for it.
   *
   * @param   {NodeFront} node
   *          The NodeFront of the parent grid container element to highlight.
   * @returns {Promise}
   *          Resolves with either the highlighter instance or null if it was skipped.
   */
  async showParentGridHighlighter(node) {
    const isHighlighted = Array.from(this.gridHighlighters.keys()).includes(
      node
    );

    if (!node || isHighlighted) {
      return null;
    }

    // Get the parent grid highlighter for the parent grid container if one already exists
    let highlighter = this.getParentGridHighlighter(node);
    if (!highlighter) {
      highlighter = await this.#getHighlighterTypeForNode(TYPES.GRID, node);
    }
    const options = {
      ...this.getGridHighlighterSettings(node),
      // Configure the highlighter with faded-out colors.
      globalAlpha: SUBGRID_PARENT_ALPHA,
      isParent: true,
    };
    await highlighter.show(node, options);

    this.emitForTests("highlighter-shown", {
      type: TYPES.GRID,
      nodeFront: node,
      highlighter,
      options,
    });

    return highlighter;
  }

  /**
   * Get the parent grid highlighter associated with the given node
   * if the node is a parent grid container for a highlighted subgrid.
   *
   * @param  {NodeFront} node
   *         NodeFront of the parent grid container for a subgrid.
   * @return {CustomHighlighterFront|null}
   */
  getParentGridHighlighter(node) {
    // Find the highlighter map value for the subgrid whose parent grid is the given node.
    const value = Array.from(this.gridHighlighters.values()).find(
      ({ parentGridNode }) => {
        return parentGridNode === node;
      }
    );

    if (!value) {
      return null;
    }

    const { parentGridHighlighter } = value;
    return parentGridHighlighter;
  }

  /**
   * Restore the parent grid highlighter for a subgrid.
   *
   * A grid node can be highlighted both explicitly (ex: by clicking a badge in the
   * Markup view) and implicitly, as a parent grid for a subgrid.
   *
   * An explicit grid highlighter overwrites a subgrid's parent grid highlighter.
   * After an explicit grid highlighter for a node is hidden, but that node is also the
   * parent grid container for a subgrid which is still highlighted, restore the implicit
   * parent grid highlighter.
   *
   * @param  {NodeFront} node
   *         NodeFront for a grid node which may also be a subgrid's parent grid
   *         container.
   * @return {Promise}
   */
  async restoreParentGridHighlighter(node) {
    // Find the highlighter map entry for the subgrid whose parent grid is the given node.
    const entry = Array.from(this.gridHighlighters.entries()).find(
      ([, value]) => {
        return value?.parentGridNode === node;
      }
    );

    if (!Array.isArray(entry)) {
      return;
    }

    const [highlightedSubgridNode, data] = entry;
    if (!data.parentGridHighlighter) {
      const parentGridHighlighter = await this.showParentGridHighlighter(node);
      this.gridHighlighters.set(highlightedSubgridNode, {
        ...data,
        parentGridHighlighter,
      });
    }
  }

  /**
   * Hide the grid highlighter for the given grid container element.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the grid container element to unhighlight.
   */
  async hideGridHighlighter(node) {
    const { highlighter, parentGridNode } =
      this.gridHighlighters.get(node) || {};

    if (!highlighter) {
      return;
    }

    // Hide the subgrid's parent grid highlighter, if any.
    if (parentGridNode) {
      await this.hideParentGridHighlighter(parentGridNode);
    }

    this.#beforeHideHighlighterType(TYPES.GRID);
    // Don't just hide the highlighter, destroy the front instance to release memory.
    // If another highlighter is shown later, a new front will be created.
    highlighter.destroy();
    this.gridHighlighters.delete(node);
    this.state.grids.delete(node);

    // It's possible we just destroyed the grid highlighter for a node which also serves
    // as a subgrid's parent grid. If so, restore the parent grid highlighter.
    await this.restoreParentGridHighlighter(node);

    // Emit the NodeFront of the grid container element that the grid highlighter was
    // hidden for.
    this.emit("grid-highlighter-hidden", node);

    // XXX: Shim to use generic highlighter events until addressing Bug 1572652
    // Ensures badges in the Markup view reflect the state of the grid highlighter.
    this.emit("highlighter-hidden", {
      type: TYPES.GRID,
      nodeFront: node,
    });
  }

  /**
   * Hide the parent grid highlighter for the given parent grid container element.
   * If there are multiple subgrids with the same parent grid, do not hide the parent
   * grid highlighter.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the parent grid container element to unhiglight.
   */
  async hideParentGridHighlighter(node) {
    let count = 0;
    let parentGridHighlighter;
    let subgridNode;
    for (const [key, value] of this.gridHighlighters.entries()) {
      if (value.parentGridNode === node) {
        parentGridHighlighter = value.parentGridHighlighter;
        subgridNode = key;
        count++;
      }
    }

    if (!parentGridHighlighter || count > 1) {
      return;
    }

    // Destroy the highlighter front instance to release memory.
    parentGridHighlighter.destroy();

    // Update the grid highlighter entry to indicate the parent grid highlighter is gone.
    this.gridHighlighters.set(subgridNode, {
      ...this.gridHighlighters.get(subgridNode),
      parentGridHighlighter: null,
    });
  }

  /**
   * Toggle the geometry editor highlighter for the given element.
   *
   * @param {NodeFront} node
   *        The NodeFront of the element to highlight.
   */
  async toggleGeometryHighlighter(node) {
    if (node == this.geometryEditorHighlighterShown) {
      await this.hideGeometryEditor();
      return;
    }

    await this.showGeometryEditor(node);
  }

  /**
   * Show the geometry editor highlightor for the given element.
   *
   * @param {NodeFront} node
   *        THe NodeFront of the element to highlight.
   */
  async showGeometryEditor(node) {
    const highlighter = await this.#getHighlighterTypeForNode(
      TYPES.GEOMETRY,
      node
    );
    if (!highlighter) {
      return;
    }

    const isShown = await highlighter.show(node);
    if (!isShown) {
      return;
    }

    this.emit("geometry-editor-highlighter-shown");
    this.geometryEditorHighlighterShown = node;
  }

  /**
   * Hide the geometry editor highlighter.
   */
  async hideGeometryEditor() {
    if (!this.geometryEditorHighlighterShown) {
      return;
    }

    const highlighter =
      this.geometryEditorHighlighterShown.inspectorFront.getKnownHighlighter(
        TYPES.GEOMETRY
      );

    if (!highlighter) {
      return;
    }

    await highlighter.hide();

    this.emit("geometry-editor-highlighter-hidden");
    this.geometryEditorHighlighterShown = null;
  }

  /**
   * Restores the saved flexbox highlighter state.
   */
  async restoreFlexboxState() {
    const state = this.#restorableHighlighters.get(TYPES.FLEXBOX);
    if (!state) {
      return;
    }

    this.#restorableHighlighters.delete(TYPES.FLEXBOX);
    await this.restoreState(TYPES.FLEXBOX, state, this.showFlexboxHighlighter);
  }

  /**
   * Restores the saved grid highlighter state.
   */
  async restoreGridState() {
    // The NodeFronts that are used as the keys in the grid state Map are no longer in the
    // tree after a reload. To clean up the grid state, we create a copy of the values of
    // the grid state before restoring and clear it.
    const values = [...this.state.grids.values()];
    this.state.grids.clear();

    try {
      for (const gridState of values) {
        await this.restoreState(
          TYPES.GRID,
          gridState,
          this.showGridHighlighter
        );
      }
    } catch (e) {
      this.#handleRejection(e);
    }
  }

  /**
   * Helper function called by restoreFlexboxState, restoreGridState.
   * Restores the saved highlighter state for the given highlighter
   * and their state.
   *
   * @param  {string} type
   *         Highlighter type to be restored.
   * @param  {object} state
   *         Object containing the metadata used to restore the highlighter.
   *         {Array} state.selectors
   *         Array of CSS selector which identifies the node to be highlighted.
   *         If the node is in the top-level document, the array contains just one item.
   *         Otherwise, if the node is nested within a stack of iframes, each iframe is
   *         identified by its unique selector; the last item in the array identifies
   *         the target node within its host iframe document.
   *         {Object} state.options
   *         Configuration options to use when showing the highlighter.
   *         {String} state.url
   *         URL of the top-level target when the metadata was stored. Used to identify
   *         if there was a page refresh or a navigation away to a different page.
   * @param  {Function} showFunction
   *         The function that shows the highlighter
   * @return {Promise} that resolves when the highlighter was restored and shown.
   */
  async restoreState(type, state, showFunction) {
    const { selectors = [], options, url } = state;

    if (!selectors.length || url !== this.target.url) {
      // Bail out if no selector was saved, or if we are on a different page.
      this.emit(`highlighter-discarded`, { type });
      return;
    }

    const nodeFront =
      await this.inspector.commands.inspectorCommand.findNodeFrontFromSelectors(
        selectors
      );

    if (nodeFront) {
      await showFunction(nodeFront, options);
      this.emit(`highlighter-restored`, { type });
    } else {
      this.emit(`highlighter-discarded`, { type });
    }
  }

  /**
   * Get an instance of an in-context editor for the given type.
   *
   * In-context editors behave like highlighters but with added editing capabilities which
   * need to write value changes back to something, like to properties in the Rule view.
   * They typically exist in the context of the page, like the ShapesInContextEditor.
   *
   * @param  {NodeFront} node.
   * @param  {string} type
   *         Type of in-context editor. Currently supported: "shapesEditor"
   * @return {object | null}
   *         Reference to instance for given type of in-context editor or null.
   */
  async getInContextEditor(node, type) {
    if (this.editors[type]) {
      return this.editors[type];
    }

    let editor;

    switch (type) {
      case "shapesEditor": {
        const highlighter = await this.#getHighlighterTypeForNode(
          TYPES.SHAPES,
          node
        );
        if (!highlighter) {
          return null;
        }
        const ShapesInContextEditor = require("resource://devtools/client/shared/widgets/ShapesInContextEditor.js");

        editor = new ShapesInContextEditor(
          highlighter,
          this.inspector,
          this.state
        );
        editor.on("show", this.onShapesHighlighterShown);
        editor.on("hide", this.onShapesHighlighterHidden);
        break;
      }
      default:
        throw new Error(`Unsupported in-context editor '${name}'`);
    }

    this.editors[type] = editor;

    return editor;
  }

  /**
   * Get a highlighter front given a type. It will only be initialized once.
   *
   * @param  {string} type
   *         The highlighter type. One of this.highlighters.
   * @return {Promise} that resolves to the highlighter
   */
  async #getHighlighter(type) {
    if (this.highlighters[type]) {
      return this.highlighters[type];
    }

    let highlighter;

    try {
      highlighter = await this.inspectorFront.getHighlighterByType(type);
    } catch (e) {
      this.#handleRejection(e);
    }

    if (!highlighter) {
      return null;
    }

    this.highlighters[type] = highlighter;
    return highlighter;
  }

  /**
   * Ignore unexpected errors from async function calls
   * if HighlightersOverlay has been destroyed.
   *
   * @param {Error} error
   */
  #handleRejection = error => {
    if (!this.destroyed) {
      console.error(error);
    }
  };

  /**
   * Toggle the class "active" on the given shape point in the rule view if the current
   * inspector selection is highlighted by the shapes highlighter.
   *
   * @param {NodeFront} node
   *        The NodeFront of the shape point to toggle
   * @param {boolean} active
   *        Whether the shape point should be active
   */
  _toggleShapePointActive(node, active) {
    if (this.inspector.selection.nodeFront != this.shapesHighlighterShown) {
      return;
    }

    node.classList.toggle("active", active);
  }

  /**
   * Hide the currently shown hovered highlighter.
   */
  #hideHoveredHighlighter() {
    if (
      !this.hoveredHighlighterShown ||
      !this.highlighters[this.hoveredHighlighterShown]
    ) {
      return;
    }

    // For some reason, the call to highlighter.hide doesn't always return a
    // promise. This causes some tests to fail when trying to install a
    // rejection handler on the result of the call. To avoid this, check
    // whether the result is truthy before installing the handler.
    const onHidden = this.highlighters[this.hoveredHighlighterShown].hide();
    if (onHidden) {
      onHidden.catch(console.error);
    }

    this.hoveredHighlighterShown = null;
    this.emit("css-transform-highlighter-hidden");
  }

  /**
   * Given a node front and a function that hides the given node's highlighter, hides
   * the highlighter if the node front is no longer in the DOM tree. This is called
   * from the "markupmutation" event handler.
   *
   * @param  {NodeFront} node
   *         The NodeFront of a highlighted DOM node.
   * @param  {Function} hideHighlighter
   *         The function that will hide the highlighter of the highlighted node.
   */
  async #hideHighlighterIfDeadNode(node, hideHighlighter) {
    if (!node) {
      return;
    }

    try {
      const isInTree =
        node.walkerFront && (await node.walkerFront.isInDOMTree(node));
      if (!isInTree) {
        await hideHighlighter(node);
      }
    } catch (e) {
      this.#handleRejection(e);
    }
  }

  /**
   * Is the current hovered node a css transform property value in the
   * computed-view.
   *
   * @param  {object} nodeInfo
   * @return {boolean}
   */
  #isComputedViewTransform(nodeInfo) {
    if (nodeInfo.view != "computed") {
      return false;
    }
    return (
      nodeInfo.type === VIEW_NODE_VALUE_TYPE &&
      nodeInfo.value.property === "transform"
    );
  }

  /**
   * Does the current clicked node have the shapes highlighter toggle in the
   * rule-view.
   *
   * @param  {DOMNode} node
   * @return {boolean}
   */
  #isRuleViewShapeSwatch(node) {
    return (
      this.isRuleView(node) && node.classList.contains("inspector-shapeswatch")
    );
  }

  /**
   * Is the current hovered node a css transform property value in the rule-view.
   *
   * @param  {object} nodeInfo
   * @return {boolean}
   */
  #isRuleViewTransform(nodeInfo) {
    if (nodeInfo.view != "rule") {
      return false;
    }
    const isTransform =
      nodeInfo.type === VIEW_NODE_VALUE_TYPE &&
      nodeInfo.value.property === "transform";
    const isEnabled =
      nodeInfo.value.enabled &&
      !nodeInfo.value.overridden &&
      !nodeInfo.value.pseudoElement;
    return isTransform && isEnabled;
  }

  /**
   * Is the current hovered node a highlightable shape point in the rule-view.
   *
   * @param  {object} nodeInfo
   * @return {boolean}
   */
  isRuleViewShapePoint(nodeInfo) {
    if (nodeInfo.view != "rule") {
      return false;
    }
    const isShape =
      nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE &&
      (nodeInfo.value.property === "clip-path" ||
        nodeInfo.value.property === "shape-outside");
    const isEnabled =
      nodeInfo.value.enabled &&
      !nodeInfo.value.overridden &&
      !nodeInfo.value.pseudoElement;
    return (
      isShape &&
      isEnabled &&
      nodeInfo.value.toggleActive &&
      !this.state.shapes.options.transformMode
    );
  }

  onClick(event) {
    if (this.#isRuleViewShapeSwatch(event.target)) {
      event.stopPropagation();

      const view = this.inspector.getPanel("ruleview").view;
      const nodeInfo = view.getNodeInfo(event.target);

      this.toggleShapesHighlighter(
        this.inspector.selection.nodeFront,
        {
          mode: event.target.dataset.mode,
          transformMode: event.metaKey || event.ctrlKey,
        },
        nodeInfo.value.textProperty
      );
    }
  }

  /**
   * Handler for "display-change" events from walker fronts. Hides the flexbox or
   * grid highlighter if their respective node is no longer a flex container or
   * grid container.
   *
   * @param  {Array} nodes
   *         An array of nodeFronts
   */
  async onDisplayChange(nodes) {
    const highlightedGridNodes = this.getHighlightedGridNodes();

    for (const node of nodes) {
      const display = node.displayType;

      // Hide the flexbox highlighter if the node is no longer a flexbox container.
      if (
        display !== "flex" &&
        display !== "inline-flex" &&
        node == this.getNodeForActiveHighlighter(TYPES.FLEXBOX)
      ) {
        await this.hideFlexboxHighlighter(node);
        return;
      }

      // Hide the grid highlighter if the node is no longer a grid container.
      if (
        display !== "grid" &&
        display !== "inline-grid" &&
        display !== "subgrid" &&
        highlightedGridNodes.includes(node)
      ) {
        await this.hideGridHighlighter(node);
        return;
      }
    }
  }

  onMouseMove(event) {
    // Bail out if the target is the same as for the last mousemove.
    if (event.target === this.#lastHovered) {
      return;
    }

    // Only one highlighter can be displayed at a time, hide the currently shown.
    this.#hideHoveredHighlighter();

    this.#lastHovered = event.target;

    const view = this.isRuleView(this.#lastHovered)
      ? this.inspector.getPanel("ruleview").view
      : this.inspector.getPanel("computedview").computedView;
    const nodeInfo = view.getNodeInfo(event.target);
    if (!nodeInfo) {
      return;
    }

    if (this.isRuleViewShapePoint(nodeInfo)) {
      const { point } = nodeInfo.value;
      this.hoverPointShapesHighlighter(
        this.inspector.selection.nodeFront,
        point
      );
      return;
    }

    // Choose the type of highlighter required for the hovered node.
    let type;
    if (
      this.#isRuleViewTransform(nodeInfo) ||
      this.#isComputedViewTransform(nodeInfo)
    ) {
      type = TYPES.TRANSFORM;
    }

    if (type) {
      this.hoveredHighlighterShown = type;
      const node = this.inspector.selection.nodeFront;
      this.#getHighlighter(type).then(highlighter =>
        highlighter.show(node).then(shown => {
          if (shown) {
            this.emit("css-transform-highlighter-shown", highlighter);
          }
        })
      );
    }
  }

  onMouseOut(event) {
    // Only hide the highlighter if the mouse leaves the currently hovered node.
    if (
      !this.#lastHovered ||
      (event && this.#lastHovered.contains(event.relatedTarget))
    ) {
      return;
    }

    // Otherwise, hide the highlighter.
    const view = this.isRuleView(this.#lastHovered)
      ? this.inspector.getPanel("ruleview").view
      : this.inspector.getPanel("computedview").computedView;
    const nodeInfo = view.getNodeInfo(this.#lastHovered);
    if (nodeInfo && this.isRuleViewShapePoint(nodeInfo)) {
      this.hoverPointShapesHighlighter(
        this.inspector.selection.nodeFront,
        null
      );
    }
    this.#lastHovered = null;
    this.#hideHoveredHighlighter();
  }

  /**
   * Handler function called when a new root-node has been added in the
   * inspector. Nodes may have been added / removed and highlighters should
   * be updated.
   */
  #onResourceAvailable = async resources => {
    for (const resource of resources) {
      if (
        resource.resourceType !== this.resourceCommand.TYPES.ROOT_NODE ||
        // It might happen that the ROOT_NODE resource (which is a Front) is already
        // destroyed, and in such case we want to ignore it.
        resource.isDestroyed()
      ) {
        // Only handle root-node resources.
        // Note that we could replace this with DOCUMENT_EVENT resources, since
        // the actual root-node resource is not used here.
        continue;
      }

      if (resource.targetFront.isTopLevel && resource.isTopLevelDocument) {
        // The topmost root node will lead to the destruction and recreation of
        // the MarkupView, and highlighters will be refreshed afterwards. This is
        // handled by the inspector.
        continue;
      }

      await this.#hideOrphanedHighlighters();
    }
  };

  /**
   * Handler function for "markupmutation" events. Hides the flexbox/grid/shapes
   * highlighter if the flexbox/grid/shapes container is no longer in the DOM tree.
   */
  async onMarkupMutation(mutations) {
    const hasInterestingMutation = mutations.some(
      mut => mut.type === "childList"
    );
    if (!hasInterestingMutation) {
      // Bail out if the mutations did not remove nodes, or if no grid highlighter is
      // displayed.
      return;
    }

    await this.#hideOrphanedHighlighters();
  }

  /**
   * Hide every active highlighter whose nodeFront is no longer present in the DOM.
   * Returns a promise that resolves when all orphaned highlighters are hidden.
   *
   * @return {Promise}
   */
  async #hideOrphanedHighlighters() {
    await this.#hideHighlighterIfDeadNode(
      this.shapesHighlighterShown,
      this.hideShapesHighlighter
    );

    // Hide all active highlighters whose nodeFront is no longer attached.
    const promises = [];
    for (const [type, data] of this.#activeHighlighters) {
      promises.push(
        this.#hideHighlighterIfDeadNode(data.nodeFront, () => {
          return this.hideHighlighterType(type);
        })
      );
    }

    const highlightedGridNodes = this.getHighlightedGridNodes();
    for (const node of highlightedGridNodes) {
      promises.push(
        this.#hideHighlighterIfDeadNode(node, this.hideGridHighlighter)
      );
    }

    return Promise.all(promises);
  }

  /**
   * Hides any visible highlighter and clear internal state. This should be called to
   * have a clean slate, for example when the page navigates or when a given frame is
   * selected in the iframe picker.
   */
  async hideAllHighlighters() {
    this.destroyEditors();

    // Hide any visible highlighters and clear any timers set to autohide highlighters.
    for (const { highlighter, timer } of this.#activeHighlighters.values()) {
      await highlighter.hide();
      clearTimeout(timer);
    }

    this.#activeHighlighters.clear();
    this.#pendingHighlighters.clear();
    this.gridHighlighters.clear();

    this.geometryEditorHighlighterShown = null;
    this.hoveredHighlighterShown = null;
    this.shapesHighlighterShown = null;
  }

  /**
   * Display a message about the simple highlighters which can be enabled for
   * users relying on prefers-reduced-motion. This message will be a toolbox
   * notification, which will contain a button to open the settings panel and
   * will no longer be displayed if the user decides to explicitly close the
   * message.
   */
  #showSimpleHighlightersMessage() {
    const pref = "devtools.inspector.simple-highlighters.message-dismissed";
    const messageDismissed = Services.prefs.getBoolPref(pref, false);
    if (messageDismissed) {
      return;
    }
    const notificationBox = this.inspector.toolbox.getNotificationBox();
    const message = HighlightersBundle.formatValueSync(
      "simple-highlighters-message"
    );

    notificationBox.appendNotification(
      message,
      "simple-highlighters-message",
      null,
      notificationBox.PRIORITY_INFO_MEDIUM,
      [
        {
          label: HighlightersBundle.formatValueSync(
            "simple-highlighters-settings-button"
          ),
          callback: async () => {
            const { panelDoc } = await this.toolbox.selectTool("options");
            const option = panelDoc.querySelector(
              "[data-pref='devtools.inspector.simple-highlighters-reduced-motion']"
            ).parentNode;
            option.scrollIntoView({ block: "center" });
            option.classList.add("options-panel-highlight");

            // Emit a test-only event to know when the settings panel is opened.
            this.toolbox.emitForTests("test-highlighters-settings-opened");
          },
        },
      ],
      evt => {
        if (evt === "removed") {
          // Flip the preference when the message is dismissed.
          Services.prefs.setBoolPref(pref, true);
        }
      }
    );
  }

  /**
   * Destroy and clean-up all instances of in-context editors.
   */
  destroyEditors() {
    for (const type in this.editors) {
      this.editors[type].off("show");
      this.editors[type].off("hide");
      this.editors[type].destroy();
    }

    this.editors = {};
  }

  /**
   * Destroy and clean-up all instances of highlighters.
   */
  destroyHighlighters() {
    // Destroy all highlighters and clear any timers set to autohide highlighters.
    const values = [
      ...this.#activeHighlighters.values(),
      ...this.gridHighlighters.values(),
    ];
    for (const { highlighter, parentGridHighlighter, timer } of values) {
      if (highlighter) {
        highlighter.destroy();
      }

      if (parentGridHighlighter) {
        parentGridHighlighter.destroy();
      }

      if (timer) {
        clearTimeout(timer);
      }
    }

    this.#activeHighlighters.clear();
    this.#pendingHighlighters.clear();
    this.gridHighlighters.clear();

    for (const type in this.highlighters) {
      if (this.highlighters[type]) {
        this.highlighters[type].finalize();
        this.highlighters[type] = null;
      }
    }
  }

  /**
   * Destroy this overlay instance, removing it from the view and destroying
   * all initialized highlighters.
   */
  destroy() {
    this.inspector.off("markupmutation", this.onMarkupMutation);
    this.resourceCommand.unwatchResources(
      [this.resourceCommand.TYPES.ROOT_NODE],
      { onAvailable: this.#onResourceAvailable }
    );

    this.walkerEventListener.destroy();
    this.walkerEventListener = null;

    this.destroyEditors();
    this.destroyHighlighters();

    this.#lastHovered = null;

    this.inspector = null;
    this.state = null;
    this.store = null;
    this.telemetry = null;

    this.geometryEditorHighlighterShown = null;
    this.hoveredHighlighterShown = null;
    this.shapesHighlighterShown = null;

    this.destroyed = true;
  }
}

HighlightersOverlay.TYPES = HighlightersOverlay.prototype.TYPES = TYPES;

module.exports = HighlightersOverlay;
