summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/highlighters
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/highlighters')
-rw-r--r--devtools/server/actors/highlighters/accessible.js395
-rw-r--r--devtools/server/actors/highlighters/auto-refresh.js368
-rw-r--r--devtools/server/actors/highlighters/box-model.js892
-rw-r--r--devtools/server/actors/highlighters/css-grid.js1962
-rw-r--r--devtools/server/actors/highlighters/css-transform.js265
-rw-r--r--devtools/server/actors/highlighters/css/highlighters.css1059
-rw-r--r--devtools/server/actors/highlighters/css/moz.build9
-rw-r--r--devtools/server/actors/highlighters/eye-dropper.js608
-rw-r--r--devtools/server/actors/highlighters/flexbox.js1033
-rw-r--r--devtools/server/actors/highlighters/fonts.js121
-rw-r--r--devtools/server/actors/highlighters/geometry-editor.js808
-rw-r--r--devtools/server/actors/highlighters/measuring-tool.js853
-rw-r--r--devtools/server/actors/highlighters/moz.build31
-rw-r--r--devtools/server/actors/highlighters/node-tabbing-order.js399
-rw-r--r--devtools/server/actors/highlighters/paused-debugger.js260
-rw-r--r--devtools/server/actors/highlighters/remote-node-picker-notice.js188
-rw-r--r--devtools/server/actors/highlighters/rulers.js312
-rw-r--r--devtools/server/actors/highlighters/selector.js97
-rw-r--r--devtools/server/actors/highlighters/shapes.js3263
-rw-r--r--devtools/server/actors/highlighters/tabbing-order.js247
-rw-r--r--devtools/server/actors/highlighters/utils/accessibility.js774
-rw-r--r--devtools/server/actors/highlighters/utils/canvas.js596
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js787
-rw-r--r--devtools/server/actors/highlighters/utils/moz.build7
-rw-r--r--devtools/server/actors/highlighters/viewport-size.js129
25 files changed, 15463 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/accessible.js b/devtools/server/actors/highlighters/accessible.js
new file mode 100644
index 0000000000..71124239f2
--- /dev/null
+++ b/devtools/server/actors/highlighters/accessible.js
@@ -0,0 +1,395 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ TEXT_NODE,
+ DOCUMENT_NODE,
+} = require("resource://devtools/shared/dom-node-constants.js");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getBounds", "getBoundsXUL", "Infobar"],
+ "resource://devtools/server/actors/highlighters/utils/accessibility.js",
+ true
+);
+
+/**
+ * The AccessibleHighlighter draws the bounds of an accessible object.
+ *
+ * Usage example:
+ *
+ * let h = new AccessibleHighlighter(env);
+ * h.show(node, { x, y, w, h, [duration] });
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {Number} options.x
+ * X coordinate of the top left corner of the accessible object
+ * @param {Number} options.y
+ * Y coordinate of the top left corner of the accessible object
+ * @param {Number} options.w
+ * Width of the the accessible object
+ * @param {Number} options.h
+ * Height of the the accessible object
+ * @param {Number} options.duration
+ * Duration of time that the highlighter should be shown.
+ * @param {String|null} options.name
+ * Name of the the accessible object
+ * @param {String} options.role
+ * Role of the the accessible object
+ *
+ * Structure:
+ * <div class="highlighter-container" aria-hidden="true">
+ * <div class="accessible-root">
+ * <svg class="accessible-elements" hidden="true">
+ * <path class="accessible-bounds" points="..." />
+ * </svg>
+ * <div class="accessible-infobar-container">
+ * <div class="accessible-infobar">
+ * <div class="accessible-infobar-text">
+ * <span class="accessible-infobar-role">Accessible Role</span>
+ * <span class="accessible-infobar-name">Accessible Name</span>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ */
+class AccessibleHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+ this.ID_CLASS_PREFIX = "accessible-";
+ this.accessibleInfobar = new Infobar(this);
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ this.pageListenerTarget = highlighterEnv.pageListenerTarget;
+ this.pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that AccessibleHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get supportsSimpleHighlighters() {
+ return true;
+ }
+
+ /**
+ * Build highlighter markup.
+ *
+ * @return {Object} Container element for the highlighter markup.
+ */
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ "aria-hidden": "true",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class:
+ "root" +
+ (this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ ? " use-simple-highlighters"
+ : ""),
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the SVG element.
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: svg,
+ attributes: {
+ class: "bounds",
+ id: "bounds",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the accessible's infobar markup.
+ this.accessibleInfobar.buildMarkup(root);
+
+ return container;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ if (this._highlightTimer) {
+ clearTimeout(this._highlightTimer);
+ this._highlightTimer = null;
+ }
+
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+ this.pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ this.pageListenerTarget = null;
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+
+ this.accessibleInfobar.destroy();
+ this.accessibleInfobar = null;
+ this.markup.destroy();
+ }
+
+ /**
+ * Find an element in highlighter markup.
+ *
+ * @param {String} id
+ * Highlighter markup elemet id attribute.
+ * @return {DOMNode} Element in the highlighter markup.
+ */
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Check if node is a valid element, document or text node.
+ *
+ * @override AutoRefreshHighlighter.prototype._isNodeValid
+ * @param {DOMNode} node
+ * The node to highlight.
+ * @return {Boolean} whether or not node is valid.
+ */
+ _isNodeValid(node) {
+ return (
+ super._isNodeValid(node) ||
+ isNodeValid(node, TEXT_NODE) ||
+ isNodeValid(node, DOCUMENT_NODE)
+ );
+ }
+
+ /**
+ * Show the highlighter on a given accessible.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _show() {
+ if (this._highlightTimer) {
+ clearTimeout(this._highlightTimer);
+ this._highlightTimer = null;
+ }
+
+ const { duration } = this.options;
+ const shown = this._update();
+ if (shown) {
+ this.emit("highlighter-event", { options: this.options, type: "shown" });
+ if (duration) {
+ this._highlightTimer = setTimeout(() => {
+ this.hide();
+ }, duration);
+ }
+ }
+
+ return shown;
+ }
+
+ /**
+ * Update and show accessible bounds for a current accessible.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _update() {
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateAccessibleBounds()) {
+ this._showAccessibleBounds();
+
+ this.accessibleInfobar.show();
+
+ shown = true;
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this.hide();
+ }
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideAccessibleBounds();
+ this.accessibleInfobar.hide();
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Public API method to temporarily hide accessible bounds for things like
+ * color contrast calculation.
+ */
+ hideAccessibleBounds() {
+ if (this.getElement("elements").hasAttribute("hidden")) {
+ return;
+ }
+
+ this._hideAccessibleBounds();
+ this._shouldRestoreBoundsVisibility = true;
+ }
+
+ /**
+ * Public API method to show accessible bounds in case they were temporarily
+ * hidden.
+ */
+ showAccessibleBounds() {
+ if (this._shouldRestoreBoundsVisibility) {
+ this._showAccessibleBounds();
+ }
+ }
+
+ /**
+ * Hide the accessible bounds container.
+ */
+ _hideAccessibleBounds() {
+ this._shouldRestoreBoundsVisibility = null;
+ setIgnoreLayoutChanges(true);
+ this.getElement("elements").setAttribute("hidden", "true");
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Show the accessible bounds container.
+ */
+ _showAccessibleBounds() {
+ this._shouldRestoreBoundsVisibility = null;
+ if (!this.currentNode || !this.highlighterEnv.window) {
+ return;
+ }
+
+ setIgnoreLayoutChanges(true);
+ this.getElement("elements").removeAttribute("hidden");
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Get current accessible bounds.
+ *
+ * @return {Object|null} Returns, if available, positioning and bounds
+ * information for the accessible object.
+ */
+ get _bounds() {
+ let { win, options } = this;
+ let getBoundsFn = getBounds;
+ if (this.options.isXUL) {
+ // Zoom level for the top level browser window does not change and only
+ // inner frames do. So we need to get the zoom level of the current node's
+ // parent window.
+ let zoom = getCurrentZoom(this.currentNode);
+ zoom *= zoom;
+ options = { ...options, zoom };
+ getBoundsFn = getBoundsXUL;
+ win = this.win.parent.ownerGlobal;
+ }
+
+ return getBoundsFn(win, options);
+ }
+
+ /**
+ * Update accessible bounds for a current accessible. Re-draw highlighter
+ * markup.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _updateAccessibleBounds() {
+ const bounds = this._bounds;
+ if (!bounds) {
+ this._hide();
+ return false;
+ }
+
+ const boundsEl = this.getElement("bounds");
+ const { left, right, top, bottom } = bounds;
+ const path = `M${left},${top} L${right},${top} L${right},${bottom} L${left},${bottom} L${left},${top}`;
+ boundsEl.setAttribute("d", path);
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "elements";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ /**
+ * Hide highlighter on page hide.
+ */
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide
+ // the highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Hide highlighter on navigation.
+ */
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.AccessibleHighlighter = AccessibleHighlighter;
diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js
new file mode 100644
index 0000000000..35e870e795
--- /dev/null
+++ b/devtools/server/actors/highlighters/auto-refresh.js
@@ -0,0 +1,368 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ getAdjustedQuads,
+ getWindowDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+
+// Note that the order of items in this array is important because it is used
+// for drawing the BoxModelHighlighter's path elements correctly.
+const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"];
+const QUADS_PROPS = ["p1", "p2", "p3", "p4"];
+
+function arePointsDifferent(pointA, pointB) {
+ return (
+ Math.abs(pointA.x - pointB.x) >= 0.5 ||
+ Math.abs(pointA.y - pointB.y) >= 0.5 ||
+ Math.abs(pointA.w - pointB.w) >= 0.5
+ );
+}
+
+function areQuadsDifferent(oldQuads, newQuads) {
+ for (const region of BOX_MODEL_REGIONS) {
+ const { length } = oldQuads[region];
+
+ if (length !== newQuads[region].length) {
+ return true;
+ }
+
+ for (let i = 0; i < length; i++) {
+ for (const prop of QUADS_PROPS) {
+ const oldPoint = oldQuads[region][i][prop];
+ const newPoint = newQuads[region][i][prop];
+
+ if (arePointsDifferent(oldPoint, newPoint)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Base class for auto-refresh-on-change highlighters. Sub classes will have a
+ * chance to update whenever the current node's geometry changes.
+ *
+ * Sub classes must implement the following methods:
+ * _show: called when the highlighter should be shown,
+ * _hide: called when the highlighter should be hidden,
+ * _update: called while the highlighter is shown and the geometry of the
+ * current node changes.
+ *
+ * Sub classes will have access to the following properties:
+ * - this.currentNode: the node to be shown
+ * - this.currentQuads: all of the node's box model region quads
+ * - this.win: the current window
+ *
+ * Emits the following events:
+ * - shown
+ * - hidden
+ * - updated
+ */
+class AutoRefreshHighlighter extends EventEmitter {
+ constructor(highlighterEnv) {
+ super();
+
+ this.highlighterEnv = highlighterEnv;
+
+ this._updateSimpleHighlighters = this._updateSimpleHighlighters.bind(this);
+ this.highlighterEnv.on(
+ "use-simple-highlighters-updated",
+ this._updateSimpleHighlighters
+ );
+
+ this.currentNode = null;
+ this.currentQuads = {};
+
+ this._winDimensions = getWindowDimensions(this.win);
+ this._scroll = { x: this.win.pageXOffset, y: this.win.pageYOffset };
+
+ this.update = this.update.bind(this);
+ }
+
+ _ignoreZoom = false;
+ _ignoreScroll = false;
+
+ /**
+ * Window corresponding to the current highlighterEnv.
+ */
+ get win() {
+ if (!this.highlighterEnv) {
+ return null;
+ }
+ return this.highlighterEnv.window;
+ }
+
+ /* Window containing the target content. */
+ get contentWindow() {
+ return this.win;
+ }
+
+ get supportsSimpleHighlighters() {
+ return false;
+ }
+
+ /**
+ * Show the highlighter on a given node
+ * @param {DOMNode} node
+ * @param {Object} options
+ * Object used for passing options
+ */
+ show(node, options = {}) {
+ const isSameNode = node === this.currentNode;
+ const isSameOptions = this._isSameOptions(options);
+
+ if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) {
+ return false;
+ }
+
+ this.options = options;
+
+ this._stopRefreshLoop();
+ this.currentNode = node;
+
+ // For offset-path, the highlighter needs to be computed from the containing block
+ // of the node, not the node itself.
+ this.useContainingBlock = this.options.mode === "cssOffsetPath";
+ this.drawingNode = this.useContainingBlock
+ ? InspectorUtils.containingBlockOf(this.currentNode)
+ : this.currentNode;
+
+ this._updateAdjustedQuads();
+ this._startRefreshLoop();
+
+ const shown = this._show();
+ if (shown) {
+ this.emit("shown");
+ }
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter
+ */
+ hide() {
+ if (!this.currentNode || !this.highlighterEnv.window) {
+ return;
+ }
+
+ this._hide();
+ this._stopRefreshLoop();
+ this.currentNode = null;
+ this.currentQuads = {};
+ this.options = null;
+
+ this.emit("hidden");
+ }
+
+ /**
+ * Whether the current node is valid for this highlighter type.
+ * This is implemented by default to check if the node is an element node. Highlighter
+ * sub-classes should override this method if they want to highlight other node types.
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ _isNodeValid(node) {
+ return isNodeValid(node);
+ }
+
+ /**
+ * Are the provided options the same as the currently stored options?
+ * Returns false if there are no options stored currently.
+ */
+ _isSameOptions(options) {
+ if (!this.options) {
+ return false;
+ }
+
+ const keys = Object.keys(options);
+
+ if (keys.length !== Object.keys(this.options).length) {
+ return false;
+ }
+
+ for (const key of keys) {
+ if (this.options[key] !== options[key]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Update the stored box quads by reading the current node's box quads.
+ */
+ _updateAdjustedQuads() {
+ this.currentQuads = {};
+
+ // If we need to use the containing block, and if it is the <html> element,
+ // we need to use the viewport quads.
+ const useViewport =
+ this.useContainingBlock &&
+ this.drawingNode === this.currentNode.ownerDocument.documentElement;
+ const node = useViewport
+ ? this.drawingNode.ownerDocument
+ : this.drawingNode;
+
+ for (const region of BOX_MODEL_REGIONS) {
+ this.currentQuads[region] = getAdjustedQuads(
+ this.contentWindow,
+ node,
+ region,
+ { ignoreScroll: this._ignoreScroll, ignoreZoom: this._ignoreZoom }
+ );
+ }
+ }
+
+ /**
+ * Update the knowledge we have of the current node's boxquads and return true
+ * if any of the points x/y or bounds have change since.
+ * @return {Boolean}
+ */
+ _hasMoved() {
+ const oldQuads = this.currentQuads;
+ this._updateAdjustedQuads();
+
+ return areQuadsDifferent(oldQuads, this.currentQuads);
+ }
+
+ /**
+ * Update the knowledge we have of the current window's scrolling offset, both
+ * horizontal and vertical, and return `true` if they have changed since.
+ * @return {Boolean}
+ */
+ _hasWindowScrolled() {
+ if (!this.win) {
+ return false;
+ }
+
+ const { pageXOffset, pageYOffset } = this.win;
+ const hasChanged =
+ this._scroll.x !== pageXOffset || this._scroll.y !== pageYOffset;
+
+ this._scroll = { x: pageXOffset, y: pageYOffset };
+
+ return hasChanged;
+ }
+
+ /**
+ * Update the knowledge we have of the current window's dimensions and return `true`
+ * if they have changed since.
+ * @return {Boolean}
+ */
+ _haveWindowDimensionsChanged() {
+ const { width, height } = getWindowDimensions(this.win);
+ const haveChanged =
+ this._winDimensions.width !== width ||
+ this._winDimensions.height !== height;
+
+ this._winDimensions = { width, height };
+ return haveChanged;
+ }
+
+ /**
+ * Update the highlighter if the node has moved since the last update.
+ */
+ update() {
+ if (
+ !this._isNodeValid(this.currentNode) ||
+ (!this._hasMoved() && !this._haveWindowDimensionsChanged())
+ ) {
+ // At this point we're not calling the `_update` method. However, if the window has
+ // scrolled, we want to invoke `_scrollUpdate`.
+ if (this._hasWindowScrolled()) {
+ this._scrollUpdate();
+ }
+
+ return;
+ }
+
+ this._update();
+ this.emit("updated");
+ }
+
+ _show() {
+ // To be implemented by sub classes
+ // When called, sub classes should actually show the highlighter for
+ // this.currentNode, potentially using options in this.options
+ throw new Error("Custom highlighter class had to implement _show method");
+ }
+
+ _update() {
+ // To be implemented by sub classes
+ // When called, sub classes should update the highlighter shown for
+ // this.currentNode
+ // This is called as a result of a page zoom or repaint
+ throw new Error("Custom highlighter class had to implement _update method");
+ }
+
+ _scrollUpdate() {
+ // Can be implemented by sub classes
+ // When called, sub classes can upate the highlighter shown for
+ // this.currentNode
+ // This is called as a result of a page scroll
+ }
+
+ _hide() {
+ // To be implemented by sub classes
+ // When called, sub classes should actually hide the highlighter
+ throw new Error("Custom highlighter class had to implement _hide method");
+ }
+
+ _startRefreshLoop() {
+ const win = this.currentNode.ownerGlobal;
+ this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this));
+ this.rafWin = win;
+ this.update();
+ }
+
+ _stopRefreshLoop() {
+ if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) {
+ this.rafWin.cancelAnimationFrame(this.rafID);
+ }
+ this.rafID = this.rafWin = null;
+ }
+
+ _updateSimpleHighlighters() {
+ if (!this.supportsSimpleHighlighters) {
+ return;
+ }
+
+ const root = this.getElement("root");
+ if (!root) {
+ // Highlighters which support simple highlighters are expected to use a
+ // root element with the id "root".
+ return;
+ }
+
+ // Add/remove the `user-simple-highlighters` class based on the current
+ // toolbox configuration.
+ root.classList.toggle(
+ "use-simple-highlighters",
+ this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ );
+ }
+
+ destroy() {
+ this.hide();
+
+ this.highlighterEnv.off(
+ "use-simple-highlighters-updated",
+ this._updateSimpleHighlighters
+ );
+ this.highlighterEnv = null;
+ this.currentNode = null;
+ }
+}
+exports.AutoRefreshHighlighter = AutoRefreshHighlighter;
diff --git a/devtools/server/actors/highlighters/box-model.js b/devtools/server/actors/highlighters/box-model.js
new file mode 100644
index 0000000000..9368f2f292
--- /dev/null
+++ b/devtools/server/actors/highlighters/box-model.js
@@ -0,0 +1,892 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getBindingElementAndPseudo,
+ hasPseudoClassLock,
+ isNodeValid,
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ PSEUDO_CLASSES,
+} = require("resource://devtools/shared/css/constants.js");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getNodeDisplayName,
+ getNodeGridFlexType,
+} = require("resource://devtools/server/actors/inspector/utils.js");
+const nodeConstants = require("resource://devtools/shared/dom-node-constants.js");
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+// Note that the order of items in this array is important because it is used
+// for drawing the BoxModelHighlighter's path elements correctly.
+const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"];
+const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"];
+// Width of boxmodelhighlighter guides
+const GUIDE_STROKE_WIDTH = 1;
+
+/**
+ * The BoxModelHighlighter draws the box model regions on top of a node.
+ * If the node is a block box, then each region will be displayed as 1 polygon.
+ * If the node is an inline box though, each region may be represented by 1 or
+ * more polygons, depending on how many line boxes the inline element has.
+ *
+ * Usage example:
+ *
+ * let h = new BoxModelHighlighter(env);
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {String} options.region
+ * Specifies the region that the guides should outline:
+ * "content" (default), "padding", "border" or "margin".
+ * @param {Boolean} options.hideGuides
+ * Defaults to false
+ * @param {Boolean} options.hideInfoBar
+ * Defaults to false
+ * @param {String} options.showOnly
+ * If set, only this region will be highlighted. Use with onlyRegionArea
+ * to only highlight the area of the region:
+ * "content", "padding", "border" or "margin"
+ * @param {Boolean} options.onlyRegionArea
+ * This can be set to true to make each region's box only highlight the
+ * area of the corresponding region rather than the area of nested
+ * regions too. This is useful when used with showOnly.
+ *
+ * Structure:
+ * <div class="highlighter-container" aria-hidden="true">
+ * <div class="box-model-root">
+ * <svg class="box-model-elements" hidden="true">
+ * <g class="box-model-regions">
+ * <path class="box-model-margin" points="..." />
+ * <path class="box-model-border" points="..." />
+ * <path class="box-model-padding" points="..." />
+ * <path class="box-model-content" points="..." />
+ * </g>
+ * <line class="box-model-guide-top" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-right" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-bottom" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-left" x1="..." y1="..." x2="..." y2="..." />
+ * </svg>
+ * <div class="box-model-infobar-container">
+ * <div class="box-model-infobar-arrow highlighter-infobar-arrow-top" />
+ * <div class="box-model-infobar">
+ * <div class="box-model-infobar-text" align="center">
+ * <span class="box-model-infobar-tagname">Node name</span>
+ * <span class="box-model-infobar-id">Node id</span>
+ * <span class="box-model-infobar-classes">.someClass</span>
+ * <span class="box-model-infobar-pseudo-classes">:hover</span>
+ * <span class="box-model-infobar-grid-type">Grid Type</span>
+ * <span class="box-model-infobar-flex-type">Flex Type</span>
+ * </div>
+ * </div>
+ * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/>
+ * </div>
+ * </div>
+ * </div>
+ */
+class BoxModelHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "box-model-";
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that BoxModelHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get supportsSimpleHighlighters() {
+ return true;
+ }
+
+ _buildMarkup() {
+ const highlighterContainer =
+ this.markup.anonymousContentDocument.createElement("div");
+ highlighterContainer.className = "highlighter-container box-model";
+
+ this.highlighterContainer = highlighterContainer;
+ // We need a better solution for how to handle the highlighter from the
+ // accessibility standpoint. For now, in order to avoid displaying it in the
+ // accessibility tree lets hide it altogether. See bug 1598667 for more
+ // context.
+ highlighterContainer.setAttribute("aria-hidden", "true");
+
+ // Build the root wrapper, used to adapt to the page zoom.
+ const rootWrapper = this.markup.createNode({
+ parent: highlighterContainer,
+ attributes: {
+ id: "root",
+ class:
+ "root" +
+ (this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ ? " use-simple-highlighters"
+ : ""),
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the SVG element with its polygons and lines
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const regions = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ class: "regions",
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ for (const region of BOX_MODEL_REGIONS) {
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: region,
+ id: region,
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ for (const side of BOX_MODEL_SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: "guide-" + side,
+ id: "guide-" + side,
+ "stroke-width": GUIDE_STROKE_WIDTH,
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ // Building the nodeinfo bar markup
+
+ const infobarContainer = this.markup.createNode({
+ parent: rootWrapper,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: infobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const texthbox = this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-tagname",
+ id: "infobar-tagname",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-id",
+ id: "infobar-id",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-classes",
+ id: "infobar-classes",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-pseudo-classes",
+ id: "infobar-pseudo-classes",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-dimensions",
+ id: "infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-grid-type",
+ id: "infobar-grid-type",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-flex-type",
+ id: "infobar-flex-type",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return highlighterContainer;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Override the AutoRefreshHighlighter's _isNodeValid method to also return true for
+ * text nodes since these can also be highlighted.
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ _isNodeValid(node) {
+ return (
+ node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE))
+ );
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ if (!BOX_MODEL_REGIONS.includes(this.options.region)) {
+ this.options.region = "content";
+ }
+
+ const shown = this._update();
+ this._trackMutations();
+ return shown;
+ }
+
+ /**
+ * Track the current node markup mutations so that the node info bar can be
+ * updated to reflects the node's attributes
+ */
+ _trackMutations() {
+ if (isNodeValid(this.currentNode)) {
+ const win = this.currentNode.ownerGlobal;
+ this.currentNodeObserver = new win.MutationObserver(this.update);
+ this.currentNodeObserver.observe(this.currentNode, { attributes: true });
+ }
+ }
+
+ _untrackMutations() {
+ if (isNodeValid(this.currentNode) && this.currentNodeObserver) {
+ this.currentNodeObserver.disconnect();
+ this.currentNodeObserver = null;
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ const node = this.currentNode;
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateBoxModel()) {
+ // Show the infobar only if configured to do so and the node is an element or a text
+ // node.
+ if (
+ !this.options.hideInfoBar &&
+ (node.nodeType === node.ELEMENT_NODE ||
+ node.nodeType === node.TEXT_NODE)
+ ) {
+ this._showInfobar();
+ } else {
+ this._hideInfobar();
+ }
+ this._updateSimpleHighlighters();
+ this._showBoxModel();
+
+ shown = true;
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this._hide();
+ }
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return shown;
+ }
+
+ _scrollUpdate() {
+ this._moveInfobar();
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._untrackMutations();
+ this._hideBoxModel();
+ this._hideInfobar();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Hide the infobar
+ */
+ _hideInfobar() {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the infobar
+ */
+ _showInfobar() {
+ this.getElement("infobar-container").removeAttribute("hidden");
+ this._updateInfobar();
+ }
+
+ /**
+ * Hide the box model
+ */
+ _hideBoxModel() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the box model
+ */
+ _showBoxModel() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+
+ /**
+ * Calculate an outer quad based on the quads returned by getAdjustedQuads.
+ * The BoxModelHighlighter may highlight more than one boxes, so in this case
+ * create a new quad that "contains" all of these quads.
+ * This is useful to position the guides and infobar.
+ * This may happen if the BoxModelHighlighter is used to highlight an inline
+ * element that spans line breaks.
+ * @param {String} region The box-model region to get the outer quad for.
+ * @return {Object} A quad-like object {p1,p2,p3,p4,bounds}
+ */
+ _getOuterQuad(region) {
+ const quads = this.currentQuads[region];
+ if (!quads || !quads.length) {
+ return null;
+ }
+
+ const quad = {
+ p1: { x: Infinity, y: Infinity },
+ p2: { x: -Infinity, y: Infinity },
+ p3: { x: -Infinity, y: -Infinity },
+ p4: { x: Infinity, y: -Infinity },
+ bounds: {
+ bottom: -Infinity,
+ height: 0,
+ left: Infinity,
+ right: -Infinity,
+ top: Infinity,
+ width: 0,
+ x: 0,
+ y: 0,
+ },
+ };
+
+ for (const q of quads) {
+ quad.p1.x = Math.min(quad.p1.x, q.p1.x);
+ quad.p1.y = Math.min(quad.p1.y, q.p1.y);
+ quad.p2.x = Math.max(quad.p2.x, q.p2.x);
+ quad.p2.y = Math.min(quad.p2.y, q.p2.y);
+ quad.p3.x = Math.max(quad.p3.x, q.p3.x);
+ quad.p3.y = Math.max(quad.p3.y, q.p3.y);
+ quad.p4.x = Math.min(quad.p4.x, q.p4.x);
+ quad.p4.y = Math.max(quad.p4.y, q.p4.y);
+
+ quad.bounds.bottom = Math.max(quad.bounds.bottom, q.bounds.bottom);
+ quad.bounds.top = Math.min(quad.bounds.top, q.bounds.top);
+ quad.bounds.left = Math.min(quad.bounds.left, q.bounds.left);
+ quad.bounds.right = Math.max(quad.bounds.right, q.bounds.right);
+ }
+ quad.bounds.x = quad.bounds.left;
+ quad.bounds.y = quad.bounds.top;
+ quad.bounds.width = quad.bounds.right - quad.bounds.left;
+ quad.bounds.height = quad.bounds.bottom - quad.bounds.top;
+
+ return quad;
+ }
+
+ /**
+ * Update the box model as per the current node.
+ *
+ * @return {boolean}
+ * True if the current node has a box model to be highlighted
+ */
+ _updateBoxModel() {
+ const options = this.options;
+ options.region = options.region || "content";
+
+ if (!this._nodeNeedsHighlighting()) {
+ this._hideBoxModel();
+ return false;
+ }
+
+ for (let i = 0; i < BOX_MODEL_REGIONS.length; i++) {
+ const boxType = BOX_MODEL_REGIONS[i];
+ const nextBoxType = BOX_MODEL_REGIONS[i + 1];
+ const box = this.getElement(boxType);
+
+ // Highlight all quads for this region by setting the "d" attribute of the
+ // corresponding <path>.
+ const path = [];
+ for (let j = 0; j < this.currentQuads[boxType].length; j++) {
+ const boxQuad = this.currentQuads[boxType][j];
+ const nextBoxQuad = this.currentQuads[nextBoxType]
+ ? this.currentQuads[nextBoxType][j]
+ : null;
+ path.push(this._getBoxPathCoordinates(boxQuad, nextBoxQuad));
+ }
+
+ box.setAttribute("d", path.join(" "));
+ box.removeAttribute("faded");
+
+ // If showOnly is defined, either hide the other regions, or fade them out
+ // if onlyRegionArea is set too.
+ if (options.showOnly && options.showOnly !== boxType) {
+ if (options.onlyRegionArea) {
+ box.setAttribute("faded", "true");
+ } else {
+ box.removeAttribute("d");
+ }
+ }
+
+ if (boxType === options.region && !options.hideGuides) {
+ this._showGuides(boxType);
+ } else if (options.hideGuides) {
+ this._hideGuides();
+ }
+ }
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "elements";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ _getBoxPathCoordinates(boxQuad, nextBoxQuad) {
+ const { p1, p2, p3, p4 } = boxQuad;
+
+ let path;
+ if (!nextBoxQuad || !this.options.onlyRegionArea) {
+ // If this is the content box (inner-most box) or if we're not being asked
+ // to highlight only region areas, then draw a simple rectangle.
+ path =
+ "M" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ "L" +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ "L" +
+ p4.x +
+ "," +
+ p4.y +
+ " " +
+ "L" +
+ p1.x +
+ "," +
+ p1.y;
+ } else {
+ // Otherwise, just draw the region itself, not a filled rectangle.
+ const { p1: np1, p2: np2, p3: np3, p4: np4 } = nextBoxQuad;
+ path =
+ "M" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ "L" +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ "L" +
+ p4.x +
+ "," +
+ p4.y +
+ " " +
+ "L" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ np1.x +
+ "," +
+ np1.y +
+ " " +
+ "L" +
+ np4.x +
+ "," +
+ np4.y +
+ " " +
+ "L" +
+ np3.x +
+ "," +
+ np3.y +
+ " " +
+ "L" +
+ np2.x +
+ "," +
+ np2.y +
+ " " +
+ "L" +
+ np1.x +
+ "," +
+ np1.y;
+ }
+
+ return path;
+ }
+
+ /**
+ * Can the current node be highlighted? Does it have quads.
+ * @return {Boolean}
+ */
+ _nodeNeedsHighlighting() {
+ return (
+ this.currentQuads.margin.length ||
+ this.currentQuads.border.length ||
+ this.currentQuads.padding.length ||
+ this.currentQuads.content.length
+ );
+ }
+
+ _getOuterBounds() {
+ for (const region of ["margin", "border", "padding", "content"]) {
+ const quad = this._getOuterQuad(region);
+
+ if (!quad) {
+ // Invisible element such as a script tag.
+ break;
+ }
+
+ const { bottom, height, left, right, top, width, x, y } = quad.bounds;
+
+ if (width > 0 || height > 0) {
+ return { bottom, height, left, right, top, width, x, y };
+ }
+ }
+
+ return {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ }
+
+ /**
+ * We only want to show guides for horizontal and vertical edges as this helps
+ * to line them up. This method finds these edges and displays a guide there.
+ * @param {String} region The region around which the guides should be shown.
+ */
+ _showGuides(region) {
+ const quad = this._getOuterQuad(region);
+
+ if (!quad) {
+ // Invisible element such as a script tag.
+ return;
+ }
+
+ const { p1, p2, p3, p4 } = quad;
+
+ const allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b);
+ const allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b);
+ const toShowX = [];
+ const toShowY = [];
+
+ for (const arr of [allX, allY]) {
+ for (let i = 0; i < arr.length; i++) {
+ const val = arr[i];
+
+ if (i !== arr.lastIndexOf(val)) {
+ if (arr === allX) {
+ toShowX.push(val);
+ } else {
+ toShowY.push(val);
+ }
+ arr.splice(arr.lastIndexOf(val), 1);
+ }
+ }
+ }
+
+ // Move guide into place or hide it if no valid co-ordinate was found.
+ this._updateGuide("top", Math.round(toShowY[0]));
+ this._updateGuide("right", Math.round(toShowX[1]) - 1);
+ this._updateGuide("bottom", Math.round(toShowY[1]) - 1);
+ this._updateGuide("left", Math.round(toShowX[0]));
+ }
+
+ _hideGuides() {
+ for (const side of BOX_MODEL_SIDES) {
+ this.getElement("guide-" + side).setAttribute("hidden", "true");
+ }
+ }
+
+ /**
+ * Move a guide to the appropriate position and display it. If no point is
+ * passed then the guide is hidden.
+ *
+ * @param {String} side
+ * The guide to update
+ * @param {Integer} point
+ * x or y co-ordinate. If this is undefined we hide the guide.
+ */
+ _updateGuide(side, point) {
+ const guide = this.getElement("guide-" + side);
+
+ if (!point || point <= 0) {
+ guide.setAttribute("hidden", "true");
+ return false;
+ }
+
+ if (side === "top" || side === "bottom") {
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", point + "");
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", point + "");
+ } else {
+ guide.setAttribute("x1", point + "");
+ guide.setAttribute("y1", "0");
+ guide.setAttribute("x2", point + "");
+ guide.setAttribute("y2", "100%");
+ }
+
+ guide.removeAttribute("hidden");
+
+ return true;
+ }
+
+ /**
+ * Update node information (displayName#id.class)
+ */
+ _updateInfobar() {
+ if (!this.currentNode) {
+ return;
+ }
+
+ const { bindingElement: node, pseudo } = getBindingElementAndPseudo(
+ this.currentNode
+ );
+
+ // Update the tag, id, classes, pseudo-classes and dimensions
+ const displayName = getNodeDisplayName(node);
+
+ const id = node.id ? "#" + node.id : "";
+
+ const classList = (node.classList || []).length
+ ? "." + [...node.classList].join(".")
+ : "";
+
+ let pseudos = this._getPseudoClasses(node).join("");
+ if (pseudo) {
+ pseudos += pseudo;
+ }
+
+ // We want to display the original `width` and `height`, instead of the ones affected
+ // by any zoom. Since the infobar can be displayed also for text nodes, we can't
+ // access the computed style for that, and this is why we recalculate them here.
+ const zoom = getCurrentZoom(this.win);
+ const quad = this._getOuterQuad("border");
+
+ if (!quad) {
+ return;
+ }
+
+ const { width, height } = quad.bounds;
+ const dim =
+ parseFloat((width / zoom).toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat((height / zoom).toPrecision(6));
+
+ const { grid: gridType, flex: flexType } = getNodeGridFlexType(node);
+ const gridLayoutTextType = this._getLayoutTextType("gridtype", gridType);
+ const flexLayoutTextType = this._getLayoutTextType("flextype", flexType);
+
+ this.getElement("infobar-tagname").setTextContent(displayName);
+ this.getElement("infobar-id").setTextContent(id);
+ this.getElement("infobar-classes").setTextContent(classList);
+ this.getElement("infobar-pseudo-classes").setTextContent(pseudos);
+ this.getElement("infobar-dimensions").setTextContent(dim);
+ this.getElement("infobar-grid-type").setTextContent(gridLayoutTextType);
+ this.getElement("infobar-flex-type").setTextContent(flexLayoutTextType);
+
+ this._moveInfobar();
+ }
+
+ _getLayoutTextType(layoutTypeKey, { isContainer, isItem }) {
+ if (!isContainer && !isItem) {
+ return "";
+ }
+ if (isContainer && !isItem) {
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-container`);
+ }
+ if (!isContainer && isItem) {
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-item`);
+ }
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-dual`);
+ }
+
+ _getPseudoClasses(node) {
+ if (node.nodeType !== nodeConstants.ELEMENT_NODE) {
+ // hasPseudoClassLock can only be used on Elements.
+ return [];
+ }
+
+ return PSEUDO_CLASSES.filter(pseudo => hasPseudoClassLock(node, pseudo));
+ }
+
+ /**
+ * Move the Infobar to the right place in the highlighter.
+ */
+ _moveInfobar() {
+ const bounds = this._getOuterBounds();
+ const container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ }
+
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.BoxModelHighlighter = BoxModelHighlighter;
diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js
new file mode 100644
index 0000000000..04c612eb02
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-grid.js
@@ -0,0 +1,1962 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CANVAS_SIZE,
+ DEFAULT_COLOR,
+ drawBubbleRect,
+ drawLine,
+ drawRect,
+ drawRoundedRect,
+ getBoundsFromPoints,
+ getCurrentMatrix,
+ getPathDescriptionFromPoints,
+ getPointsFromDiagonal,
+ updateCanvasElement,
+ updateCanvasPosition,
+} = require("resource://devtools/server/actors/highlighters/utils/canvas.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const { apply } = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ getCurrentZoom,
+ getDisplayPixelRatio,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+const COLUMNS = "cols";
+const ROWS = "rows";
+
+const GRID_FONT_SIZE = 10;
+const GRID_FONT_FAMILY = "sans-serif";
+const GRID_AREA_NAME_FONT_SIZE = "20";
+
+const GRID_LINES_PROPERTIES = {
+ edge: {
+ lineDash: [0, 0],
+ alpha: 1,
+ },
+ explicit: {
+ lineDash: [5, 3],
+ alpha: 0.75,
+ },
+ implicit: {
+ lineDash: [2, 2],
+ alpha: 0.5,
+ },
+ areaEdge: {
+ lineDash: [0, 0],
+ alpha: 1,
+ lineWidth: 3,
+ },
+};
+
+const GRID_GAP_PATTERN_WIDTH = 14; // px
+const GRID_GAP_PATTERN_HEIGHT = 14; // px
+const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; // px
+const GRID_GAP_ALPHA = 0.5;
+
+// This is the minimum distance a line can be to the edge of the document under which we
+// push the line number arrow to be inside the grid. This offset is enough to fit the
+// entire arrow + a stacked arrow behind it.
+const OFFSET_FROM_EDGE = 32;
+// This is how much inside the grid we push the arrow. This a factor of the arrow size.
+// The goal here is for a row and a column arrow that have both been pushed inside the
+// grid, in a corner, not to overlap.
+const FLIP_ARROW_INSIDE_FACTOR = 2.5;
+
+/**
+ * Given an `edge` of a box, return the name of the edge one move to the right.
+ */
+function rotateEdgeRight(edge) {
+ switch (edge) {
+ case "top":
+ return "right";
+ case "right":
+ return "bottom";
+ case "bottom":
+ return "left";
+ case "left":
+ return "top";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Given an `edge` of a box, return the name of the edge one move to the left.
+ */
+function rotateEdgeLeft(edge) {
+ switch (edge) {
+ case "top":
+ return "left";
+ case "right":
+ return "top";
+ case "bottom":
+ return "right";
+ case "left":
+ return "bottom";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Given an `edge` of a box, return the name of the opposite edge.
+ */
+function reflectEdge(edge) {
+ switch (edge) {
+ case "top":
+ return "bottom";
+ case "right":
+ return "left";
+ case "bottom":
+ return "top";
+ case "left":
+ return "right";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Cached used by `CssGridHighlighter.getGridGapPattern`.
+ */
+const gCachedGridPattern = new Map();
+
+/**
+ * The CssGridHighlighter is the class that overlays a visual grid on top of
+ * display:[inline-]grid elements.
+ *
+ * Usage example:
+ * let h = new CssGridHighlighter(env);
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {String} options.color
+ * The color that should be used to draw the highlighter for this grid.
+ * @param {Number} options.globalAlpha
+ * The alpha (transparency) value that should be used to draw the highlighter for
+ * this grid.
+ * @param {Boolean} options.showAllGridAreas
+ * Shows all the grid area highlights for the current grid if isShown is
+ * true.
+ * @param {String} options.showGridArea
+ * Shows the grid area highlight for the given area name.
+ * @param {Boolean} options.showGridAreasOverlay
+ * Displays an overlay of all the grid areas for the current grid
+ * container if isShown is true.
+ * @param {Object} options.showGridCell
+ * An object containing the grid fragment index, row and column numbers
+ * to the corresponding grid cell to highlight for the current grid.
+ * @param {Number} options.showGridCell.gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} options.showGridCell.rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} options.showGridCell.columnNumber
+ * Column number of the grid cell to highlight.
+ * @param {Object} options.showGridLineNames
+ * An object containing the grid fragment index and line number to the
+ * corresponding grid line to highlight for the current grid.
+ * @param {Number} options.showGridLineNames.gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} options.showGridLineNames.lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} options.showGridLineNames.type
+ * The dimension type of the grid line.
+ * @param {Boolean} options.showGridLineNumbers
+ * Displays the grid line numbers on the grid lines if isShown is true.
+ * @param {Boolean} options.showInfiniteLines
+ * Displays an infinite line to represent the grid lines if isShown is
+ * true.
+ * @param {Number} options.zIndex
+ * The z-index to decide the displaying order.
+ *
+ * Structure:
+ * <div class="highlighter-container">
+ * <canvas id="css-grid-canvas" class="css-grid-canvas">
+ * <svg class="css-grid-elements" hidden="true">
+ * <g class="css-grid-regions">
+ * <path class="css-grid-areas" points="..." />
+ * <path class="css-grid-cells" points="..." />
+ * </g>
+ * </svg>
+ * <div class="css-grid-area-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-area-infobar-name">Grid Area Name</span>
+ * <span class="css-grid-area-infobar-dimensions">Grid Area Dimensions></span>
+ * </div>
+ * </div>
+ * </div>
+ * <div class="css-grid-cell-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-cell-infobar-position">Grid Cell Position</span>
+ * <span class="css-grid-cell-infobar-dimensions">Grid Cell Dimensions></span>
+ * </div>
+ * </div>
+ * <div class="css-grid-line-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-line-infobar-number">Grid Line Number</span>
+ * <span class="css-grid-line-infobar-names">Grid Line Names></span>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ */
+
+class CssGridHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "css-grid-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+
+ // Initialize the <canvas> position to the top left corner of the page.
+ this._canvasPosition = {
+ x: 0,
+ y: 0,
+ };
+
+ // Calling `updateCanvasPosition` anyway since the highlighter could be initialized
+ // on a page that has scrolled already.
+ updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // We use a <canvas> element so that we can draw an arbitrary number of lines
+ // which wouldn't be possible with HTML or SVG without having to insert and remove
+ // the whole markup on every update.
+ this.markup.createNode({
+ parent: root,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ hidden: "true",
+ width: CANVAS_SIZE,
+ height: CANVAS_SIZE,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the SVG element.
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const regions = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ class: "regions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: "areas",
+ id: "areas",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: "cells",
+ id: "cells",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid area infobar markup.
+ const areaInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "area-infobar-container",
+ id: "area-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const areaInfobar = this.markup.createNode({
+ parent: areaInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const areaTextbox = this.markup.createNode({
+ parent: areaInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: areaTextbox,
+ attributes: {
+ class: "area-infobar-name",
+ id: "area-infobar-name",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: areaTextbox,
+ attributes: {
+ class: "area-infobar-dimensions",
+ id: "area-infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid cell infobar markup.
+ const cellInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "cell-infobar-container",
+ id: "cell-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const cellInfobar = this.markup.createNode({
+ parent: cellInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const cellTextbox = this.markup.createNode({
+ parent: cellInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: cellTextbox,
+ attributes: {
+ class: "cell-infobar-position",
+ id: "cell-infobar-position",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: cellTextbox,
+ attributes: {
+ class: "cell-infobar-dimensions",
+ id: "cell-infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid line infobar markup.
+ const lineInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "line-infobar-container",
+ id: "line-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const lineInfobar = this.markup.createNode({
+ parent: lineInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const lineTextbox = this.markup.createNode({
+ parent: lineInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: lineTextbox,
+ attributes: {
+ class: "line-infobar-number",
+ id: "line-infobar-number",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: lineTextbox,
+ attributes: {
+ class: "line-infobar-names",
+ id: "line-infobar-names",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ clearCache() {
+ gCachedGridPattern.clear();
+ }
+
+ /**
+ * Clear the grid area highlights.
+ */
+ clearGridAreas() {
+ const areas = this.getElement("areas");
+ areas.setAttribute("d", "");
+ }
+
+ /**
+ * Clear the grid cell highlights.
+ */
+ clearGridCell() {
+ const cells = this.getElement("cells");
+ cells.setAttribute("d", "");
+ }
+
+ destroy() {
+ const { highlighterEnv } = this;
+ highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ // Clear the pattern cache to avoid dead object exceptions (Bug 1342051).
+ this.clearCache();
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ get canvas() {
+ return this.getElement("canvas");
+ }
+
+ get color() {
+ return this.options.color || DEFAULT_COLOR;
+ }
+
+ get ctx() {
+ return this.canvas.getCanvasContext("2d");
+ }
+
+ get globalAlpha() {
+ return this.options.globalAlpha || 1;
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ getFirstColLinePos(fragment) {
+ return fragment.cols.lines[0].start;
+ }
+
+ getFirstRowLinePos(fragment) {
+ return fragment.rows.lines[0].start;
+ }
+
+ /**
+ * Gets the grid gap pattern used to render the gap regions based on the device
+ * pixel ratio given.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @param {Object} dimension
+ * Refers to the Map key for the grid dimension type which is either the
+ * constant COLUMNS or ROWS.
+ * @return {CanvasPattern} grid gap pattern.
+ */
+ getGridGapPattern(devicePixelRatio, dimension) {
+ let gridPatternMap = null;
+
+ if (gCachedGridPattern.has(devicePixelRatio)) {
+ gridPatternMap = gCachedGridPattern.get(devicePixelRatio);
+ } else {
+ gridPatternMap = new Map();
+ }
+
+ if (gridPatternMap.has(dimension)) {
+ return gridPatternMap.get(dimension);
+ }
+
+ // Create the diagonal lines pattern for the rendering the grid gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const width = (canvas.width = GRID_GAP_PATTERN_WIDTH * devicePixelRatio);
+ const height = (canvas.height = GRID_GAP_PATTERN_HEIGHT * devicePixelRatio);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ if (dimension === COLUMNS) {
+ ctx.moveTo(0, 0);
+ ctx.lineTo(width, height);
+ } else {
+ ctx.moveTo(width, 0);
+ ctx.lineTo(0, height);
+ }
+
+ ctx.strokeStyle = this.color;
+ ctx.globalAlpha = GRID_GAP_ALPHA * this.globalAlpha;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+
+ gridPatternMap.set(dimension, pattern);
+ gCachedGridPattern.set(devicePixelRatio, gridPatternMap);
+
+ return pattern;
+ }
+
+ getLastColLinePos(fragment) {
+ return fragment.cols.lines[fragment.cols.lines.length - 1].start;
+ }
+
+ /**
+ * Get the GridLine index of the last edge of the explicit grid for a grid dimension.
+ *
+ * @param {GridTracks} tracks
+ * The grid track of a given grid dimension.
+ * @return {Number} index of the last edge of the explicit grid for a grid dimension.
+ */
+ getLastEdgeLineIndex(tracks) {
+ let trackIndex = tracks.length - 1;
+
+ // Traverse the grid track backwards until we find an explicit track.
+ while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") {
+ trackIndex--;
+ }
+
+ // The grid line index is the grid track index + 1.
+ return trackIndex + 1;
+ }
+
+ getLastRowLinePos(fragment) {
+ return fragment.rows.lines[fragment.rows.lines.length - 1].start;
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the
+ * element's quads have changed. Override it so it also returns true if the
+ * element's grid has changed (which can happen when you change the
+ * grid-template-* CSS properties with the highlighter displayed). This
+ * check is prone to false positives, because it does a direct object
+ * comparison of the first grid fragment structure. This structure is
+ * generated by the first call to getGridFragments, and on any subsequent
+ * calls where a reflow is needed. Since a reflow is needed when the CSS
+ * changes, this will correctly detect that the grid structure has changed.
+ * However, it's possible that the reflow could generate a novel grid
+ * fragment object containing information that is unchanged -- a false
+ * positive.
+ */
+ _hasMoved() {
+ const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ const oldFirstGridFragment = this.gridData?.[0];
+ this.gridData = this.currentNode.getGridFragments();
+ const newFirstGridFragment = this.gridData[0];
+
+ return hasMoved || oldFirstGridFragment !== newFirstGridFragment;
+ }
+
+ /**
+ * Hide the highlighter, the canvas and the infobars.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideGrid();
+ this._hideGridElements();
+ this._hideGridAreaInfoBar();
+ this._hideGridCellInfoBar();
+ this._hideGridLineInfoBar();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ _hideGrid() {
+ this.getElement("canvas").setAttribute("hidden", "true");
+ }
+
+ _hideGridAreaInfoBar() {
+ this.getElement("area-infobar-container").setAttribute("hidden", "true");
+ }
+
+ _hideGridCellInfoBar() {
+ this.getElement("cell-infobar-container").setAttribute("hidden", "true");
+ }
+
+ _hideGridElements() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ _hideGridLineInfoBar() {
+ this.getElement("line-infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Checks if the current node has a CSS Grid layout.
+ *
+ * @return {Boolean} true if the current node has a CSS grid layout, false otherwise.
+ */
+ isGrid() {
+ return this.currentNode.hasGridFragments();
+ }
+
+ /**
+ * Is a given grid fragment valid? i.e. does it actually have tracks? In some cases, we
+ * may have a fragment that defines column tracks but doesn't have any rows (or vice
+ * versa). In which case we do not want to draw anything for that fragment.
+ *
+ * @param {Object} fragment
+ * @return {Boolean}
+ */
+ isValidFragment(fragment) {
+ return fragment.cols.tracks.length && fragment.rows.tracks.length;
+ }
+
+ /**
+ * The <canvas>'s position needs to be updated if the page scrolls too much, in order
+ * to give the illusion that it always covers the viewport.
+ */
+ _scrollUpdate() {
+ const hasUpdated = updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+
+ if (hasUpdated) {
+ this._update();
+ }
+ }
+
+ _show() {
+ if (!this.isGrid()) {
+ this.hide();
+ return false;
+ }
+
+ // The grid pattern cache should be cleared in case the color changed.
+ this.clearCache();
+
+ // Hide the canvas, grid element highlights and infobar.
+ this._hide();
+
+ return this._update();
+ }
+
+ _showGrid() {
+ this.getElement("canvas").removeAttribute("hidden");
+ }
+
+ _showGridAreaInfoBar() {
+ this.getElement("area-infobar-container").removeAttribute("hidden");
+ }
+
+ _showGridCellInfoBar() {
+ this.getElement("cell-infobar-container").removeAttribute("hidden");
+ }
+
+ _showGridElements() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+
+ _showGridLineInfoBar() {
+ this.getElement("line-infobar-container").removeAttribute("hidden");
+ }
+
+ /**
+ * Shows all the grid area highlights for the current grid.
+ */
+ showAllGridAreas() {
+ this.renderGridArea();
+ }
+
+ /**
+ * Shows the grid area highlight for the given area name.
+ *
+ * @param {String} areaName
+ * Grid area name.
+ */
+ showGridArea(areaName) {
+ this.renderGridArea(areaName);
+ }
+
+ /**
+ * Shows the grid cell highlight for the given grid cell options.
+ *
+ * @param {Number} options.gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} options.rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} options.columnNumber
+ * Column number of the grid cell to highlight.
+ */
+ showGridCell({ gridFragmentIndex, rowNumber, columnNumber }) {
+ this.renderGridCell(gridFragmentIndex, rowNumber, columnNumber);
+ }
+
+ /**
+ * Shows the grid line highlight for the given grid line options.
+ *
+ * @param {Number} options.gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} options.lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} options.type
+ * The dimension type of the grid line.
+ */
+ showGridLineNames({ gridFragmentIndex, lineNumber, type }) {
+ this.renderGridLineNames(gridFragmentIndex, lineNumber, type);
+ }
+
+ /**
+ * If a page hide event is triggered for current window's highlighter, hide the
+ * highlighter.
+ */
+ onPageHide({ target }) {
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Called when the page will-navigate. Used to hide the grid highlighter and clear
+ * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the
+ * next time.
+ */
+ onWillNavigate({ isTopLevel }) {
+ this.clearCache();
+
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+
+ renderFragment(fragment) {
+ if (!this.isValidFragment(fragment)) {
+ return;
+ }
+
+ this.renderLines(
+ fragment.cols,
+ COLUMNS,
+ this.getFirstRowLinePos(fragment),
+ this.getLastRowLinePos(fragment)
+ );
+ this.renderLines(
+ fragment.rows,
+ ROWS,
+ this.getFirstColLinePos(fragment),
+ this.getLastColLinePos(fragment)
+ );
+
+ if (this.options.showGridAreasOverlay) {
+ this.renderGridAreaOverlay();
+ }
+
+ // Line numbers are rendered in a 2nd step to avoid overlapping with existing lines.
+ if (this.options.showGridLineNumbers) {
+ this.renderLineNumbers(
+ fragment.cols,
+ COLUMNS,
+ this.getFirstRowLinePos(fragment)
+ );
+ this.renderLineNumbers(
+ fragment.rows,
+ ROWS,
+ this.getFirstColLinePos(fragment)
+ );
+ this.renderNegativeLineNumbers(
+ fragment.cols,
+ COLUMNS,
+ this.getLastRowLinePos(fragment)
+ );
+ this.renderNegativeLineNumbers(
+ fragment.rows,
+ ROWS,
+ this.getLastColLinePos(fragment)
+ );
+ }
+ }
+
+ /**
+ * Render the grid area highlight for the given area name or for all the grid areas.
+ *
+ * @param {String} areaName
+ * Name of the grid area to be highlighted. If no area name is provided, all
+ * the grid areas should be highlighted.
+ */
+ renderGridArea(areaName) {
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const paths = [];
+
+ for (let i = 0; i < this.gridData.length; i++) {
+ const fragment = this.gridData[i];
+
+ for (const area of fragment.areas) {
+ if (areaName && areaName != area.name) {
+ continue;
+ }
+
+ const rowStart = fragment.rows.lines[area.rowStart - 1];
+ const rowEnd = fragment.rows.lines[area.rowEnd - 1];
+ const columnStart = fragment.cols.lines[area.columnStart - 1];
+ const columnEnd = fragment.cols.lines[area.columnEnd - 1];
+
+ const x1 = columnStart.start + columnStart.breadth;
+ const y1 = rowStart.start + rowStart.breadth;
+ const x2 = columnEnd.start;
+ const y2 = rowEnd.start;
+
+ const points = getPointsFromDiagonal(
+ x1,
+ y1,
+ x2,
+ y2,
+ this.currentMatrix
+ );
+
+ // Scale down by `devicePixelRatio` since SVG element already take them into
+ // account.
+ const svgPoints = points.map(point => ({
+ x: Math.round(point.x / devicePixelRatio),
+ y: Math.round(point.y / devicePixelRatio),
+ }));
+
+ // Scale down by `displayPixelRatio` since infobar's HTML elements already take it
+ // into account; and the zoom scaling is handled by `moveInfobar`.
+ const bounds = getBoundsFromPoints(
+ points.map(point => ({
+ x: Math.round(point.x / displayPixelRatio),
+ y: Math.round(point.y / displayPixelRatio),
+ }))
+ );
+
+ paths.push(getPathDescriptionFromPoints(svgPoints));
+
+ // Update and show the info bar when only displaying a single grid area.
+ if (areaName) {
+ this._showGridAreaInfoBar();
+ this._updateGridAreaInfobar(area, bounds);
+ }
+ }
+ }
+
+ const areas = this.getElement("areas");
+ areas.setAttribute("d", paths.join(" "));
+ }
+
+ /**
+ * Render grid area name on the containing grid area cell.
+ *
+ * @param {Object} fragment
+ * The grid fragment of the grid container.
+ * @param {Object} area
+ * The area overlay to render on the CSS highlighter canvas.
+ */
+ renderGridAreaName(fragment, area) {
+ const { rowStart, rowEnd, columnStart, columnEnd } = area;
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const offset = (displayPixelRatio / 2) % 1;
+ let fontSize = GRID_AREA_NAME_FONT_SIZE * displayPixelRatio;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+ this.ctx.globalAlpha = this.globalAlpha;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+
+ // Draw the text for the grid area name.
+ for (let rowNumber = rowStart; rowNumber < rowEnd; rowNumber++) {
+ for (
+ let columnNumber = columnStart;
+ columnNumber < columnEnd;
+ columnNumber++
+ ) {
+ const row = fragment.rows.tracks[rowNumber - 1];
+ const column = fragment.cols.tracks[columnNumber - 1];
+
+ // If the font size exceeds the bounds of the containing grid cell, size it its
+ // row or column dimension, whichever is smallest.
+ if (
+ fontSize > column.breadth * displayPixelRatio ||
+ fontSize > row.breadth * displayPixelRatio
+ ) {
+ fontSize = Math.min([column.breadth, row.breadth]);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+ }
+
+ const textWidth = this.ctx.measureText(area.name).width;
+ // The width of the character 'm' approximates the height of the text.
+ const textHeight = this.ctx.measureText("m").width;
+ // Padding in pixels for the line number text inside of the line number container.
+ const padding = 3 * displayPixelRatio;
+
+ const boxWidth = textWidth + 2 * padding;
+ const boxHeight = textHeight + 2 * padding;
+
+ let x = column.start + column.breadth / 2;
+ let y = row.start + row.breadth / 2;
+
+ [x, y] = apply(this.currentMatrix, [x, y]);
+
+ const rectXPos = x - boxWidth / 2;
+ const rectYPos = y - boxHeight / 2;
+
+ // Draw a rounded rectangle with a border width of 1 pixel,
+ // a border color matching the grid color, and a white background.
+ this.ctx.lineWidth = 1 * displayPixelRatio;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.fillStyle = "white";
+ const radius = 2 * displayPixelRatio;
+ drawRoundedRect(
+ this.ctx,
+ rectXPos,
+ rectYPos,
+ boxWidth,
+ boxHeight,
+ radius
+ );
+
+ this.ctx.fillStyle = this.color;
+ this.ctx.fillText(area.name, x, y + padding);
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Renders the grid area overlay on the css grid highlighter canvas.
+ */
+ renderGridAreaOverlay() {
+ const padding = 1;
+
+ for (let i = 0; i < this.gridData.length; i++) {
+ const fragment = this.gridData[i];
+
+ for (const area of fragment.areas) {
+ const { rowStart, rowEnd, columnStart, columnEnd, type } = area;
+
+ if (type === "implicit") {
+ continue;
+ }
+
+ // Draw the line edges for the grid area.
+ const areaColStart = fragment.cols.lines[columnStart - 1];
+ const areaColEnd = fragment.cols.lines[columnEnd - 1];
+
+ const areaRowStart = fragment.rows.lines[rowStart - 1];
+ const areaRowEnd = fragment.rows.lines[rowEnd - 1];
+
+ const areaColStartLinePos = areaColStart.start + areaColStart.breadth;
+ const areaRowStartLinePos = areaRowStart.start + areaRowStart.breadth;
+
+ this.renderLine(
+ areaColStartLinePos + padding,
+ areaRowStartLinePos,
+ areaRowEnd.start,
+ COLUMNS,
+ "areaEdge"
+ );
+ this.renderLine(
+ areaColEnd.start - padding,
+ areaRowStartLinePos,
+ areaRowEnd.start,
+ COLUMNS,
+ "areaEdge"
+ );
+
+ this.renderLine(
+ areaRowStartLinePos + padding,
+ areaColStartLinePos,
+ areaColEnd.start,
+ ROWS,
+ "areaEdge"
+ );
+ this.renderLine(
+ areaRowEnd.start - padding,
+ areaColStartLinePos,
+ areaColEnd.start,
+ ROWS,
+ "areaEdge"
+ );
+
+ this.renderGridAreaName(fragment, area);
+ }
+ }
+ }
+
+ /**
+ * Render the grid cell highlight for the given grid fragment index, row and column
+ * number.
+ *
+ * @param {Number} gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} columnNumber
+ * Column number of the grid cell to highlight.
+ */
+ renderGridCell(gridFragmentIndex, rowNumber, columnNumber) {
+ const fragment = this.gridData[gridFragmentIndex];
+
+ if (!fragment) {
+ return;
+ }
+
+ const row = fragment.rows.tracks[rowNumber - 1];
+ const column = fragment.cols.tracks[columnNumber - 1];
+
+ if (!row || !column) {
+ return;
+ }
+
+ const x1 = column.start;
+ const y1 = row.start;
+ const x2 = column.start + column.breadth;
+ const y2 = row.start + row.breadth;
+
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const points = getPointsFromDiagonal(x1, y1, x2, y2, this.currentMatrix);
+
+ // Scale down by `devicePixelRatio` since SVG element already take them into account.
+ const svgPoints = points.map(point => ({
+ x: Math.round(point.x / devicePixelRatio),
+ y: Math.round(point.y / devicePixelRatio),
+ }));
+
+ // Scale down by `displayPixelRatio` since infobar's HTML elements already take it
+ // into account, and the zoom scaling is handled by `moveInfobar`.
+ const bounds = getBoundsFromPoints(
+ points.map(point => ({
+ x: Math.round(point.x / displayPixelRatio),
+ y: Math.round(point.y / displayPixelRatio),
+ }))
+ );
+
+ const cells = this.getElement("cells");
+ cells.setAttribute("d", getPathDescriptionFromPoints(svgPoints));
+
+ this._showGridCellInfoBar();
+ this._updateGridCellInfobar(rowNumber, columnNumber, bounds);
+ }
+
+ /**
+ * Render the grid gap area on the css grid highlighter canvas.
+ *
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} endPos
+ * The end position of the cross side of the grid line.
+ * @param {Number} breadth
+ * The grid line breadth value.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ */
+ renderGridGap(linePos, startPos, endPos, breadth, dimensionType) {
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const offset = (displayPixelRatio / 2) % 1;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ breadth = Math.round(breadth);
+
+ this.ctx.save();
+ this.ctx.fillStyle = this.getGridGapPattern(
+ devicePixelRatio,
+ dimensionType
+ );
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ if (dimensionType === COLUMNS) {
+ if (isFinite(endPos)) {
+ endPos = Math.round(endPos);
+ } else {
+ endPos = this._winDimensions.height;
+ startPos = -endPos;
+ }
+ drawRect(
+ this.ctx,
+ linePos,
+ startPos,
+ linePos + breadth,
+ endPos,
+ this.currentMatrix
+ );
+ } else {
+ if (isFinite(endPos)) {
+ endPos = Math.round(endPos);
+ } else {
+ endPos = this._winDimensions.width;
+ startPos = -endPos;
+ }
+ drawRect(
+ this.ctx,
+ startPos,
+ linePos,
+ endPos,
+ linePos + breadth,
+ this.currentMatrix
+ );
+ }
+
+ // Find current angle of grid by measuring the angle of two arbitrary points,
+ // then rotate canvas, so the hash pattern stays 45deg to the gridlines.
+ const p1 = apply(this.currentMatrix, [0, 0]);
+ const p2 = apply(this.currentMatrix, [1, 0]);
+ const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
+ this.ctx.rotate(angleRad);
+
+ this.ctx.fill();
+ this.ctx.restore();
+ }
+
+ /**
+ * Render the grid line name highlight for the given grid fragment index, lineNumber,
+ * and dimensionType.
+ *
+ * @param {Number} gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} dimensionType
+ * The dimension type of the grid line.
+ */
+ renderGridLineNames(gridFragmentIndex, lineNumber, dimensionType) {
+ const fragment = this.gridData[gridFragmentIndex];
+
+ if (!fragment || !lineNumber || !dimensionType) {
+ return;
+ }
+
+ const { names } = fragment[dimensionType].lines[lineNumber - 1];
+ let linePos;
+
+ if (dimensionType === ROWS) {
+ linePos = fragment.rows.lines[lineNumber - 1];
+ } else if (dimensionType === COLUMNS) {
+ linePos = fragment.cols.lines[lineNumber - 1];
+ }
+
+ if (!linePos) {
+ return;
+ }
+
+ const currentZoom = getCurrentZoom(this.win);
+ const { bounds } = this.currentQuads.content[gridFragmentIndex];
+
+ const rowYPosition = fragment.rows.lines[0];
+ const colXPosition = fragment.rows.lines[0];
+
+ const x =
+ dimensionType === COLUMNS
+ ? linePos.start + bounds.left / currentZoom
+ : colXPosition.start + bounds.left / currentZoom;
+
+ const y =
+ dimensionType === ROWS
+ ? linePos.start + bounds.top / currentZoom
+ : rowYPosition.start + bounds.top / currentZoom;
+
+ this._showGridLineInfoBar();
+ this._updateGridLineInfobar(names.join(", "), lineNumber, x, y);
+ }
+
+ /**
+ * Render the grid line number on the css grid highlighter canvas.
+ *
+ * @param {Number} lineNumber
+ * The grid line number.
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} breadth
+ * The grid line breadth value.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Boolean||undefined} isStackedLine
+ * Boolean indicating if the line is stacked.
+ */
+ // eslint-disable-next-line complexity
+ renderGridLineNumber(
+ lineNumber,
+ linePos,
+ startPos,
+ breadth,
+ dimensionType,
+ isStackedLine
+ ) {
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const { devicePixelRatio } = this.win;
+ const offset = (displayPixelRatio / 2) % 1;
+ const fontSize = GRID_FONT_SIZE * devicePixelRatio;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ breadth = Math.round(breadth);
+
+ if (linePos + breadth < 0) {
+ // Don't render the line number since the line is not visible on screen.
+ return;
+ }
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+
+ // For a general grid box, the height of the character "m" will be its minimum width
+ // and height. If line number's text width is greater, then use the grid box's text
+ // width instead.
+ const textHeight = this.ctx.measureText("m").width;
+ const textWidth = Math.max(
+ textHeight,
+ this.ctx.measureText(lineNumber).width
+ );
+
+ // Padding in pixels for the line number text inside of the line number container.
+ const padding = 3 * devicePixelRatio;
+ const offsetFromEdge = 2 * devicePixelRatio;
+
+ let boxWidth = textWidth + 2 * padding;
+ let boxHeight = textHeight + 2 * padding;
+
+ // Calculate the x & y coordinates for the line number container, so that its arrow
+ // tip is centered on the line (or the gap if there is one), and is offset by the
+ // calculated padding value from the grid container edge.
+ let x, y;
+
+ if (dimensionType === COLUMNS) {
+ x = linePos + breadth / 2;
+ y =
+ lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge;
+ } else if (dimensionType === ROWS) {
+ y = linePos + breadth / 2;
+ x =
+ lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge;
+ }
+
+ [x, y] = apply(this.currentMatrix, [x, y]);
+
+ // Draw a bubble rectangular arrow with a border width of 2 pixels, a border color
+ // matching the grid color and a white background (the line number will be written in
+ // black).
+ this.ctx.lineWidth = 2 * displayPixelRatio;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.fillStyle = "white";
+ this.ctx.globalAlpha = this.globalAlpha;
+
+ // See param definitions of drawBubbleRect.
+ const radius = 2 * displayPixelRatio;
+ const margin = 2 * displayPixelRatio;
+ const arrowSize = 8 * displayPixelRatio;
+
+ const minBoxSize = arrowSize * 2 + padding;
+ boxWidth = Math.max(boxWidth, minBoxSize);
+ boxHeight = Math.max(boxHeight, minBoxSize);
+
+ // Determine which edge of the box to aim the line number arrow at.
+ const boxEdge = this.getBoxEdge(dimensionType, lineNumber);
+
+ let { width, height } = this._winDimensions;
+ width *= displayPixelRatio;
+ height *= displayPixelRatio;
+
+ // Don't draw if the line is out of the viewport.
+ if (
+ (dimensionType === ROWS && (y < 0 || y > height)) ||
+ (dimensionType === COLUMNS && (x < 0 || x > width))
+ ) {
+ this.ctx.restore();
+ return;
+ }
+
+ // If the arrow's edge (the one perpendicular to the line direction) is too close to
+ // the edge of the viewport. Push the arrow inside the grid.
+ const minOffsetFromEdge = OFFSET_FROM_EDGE * displayPixelRatio;
+ switch (boxEdge) {
+ case "left":
+ if (x < minOffsetFromEdge) {
+ x += FLIP_ARROW_INSIDE_FACTOR * boxWidth;
+ }
+ break;
+ case "right":
+ if (width - x < minOffsetFromEdge) {
+ x -= FLIP_ARROW_INSIDE_FACTOR * boxWidth;
+ }
+ break;
+ case "top":
+ if (y < minOffsetFromEdge) {
+ y += FLIP_ARROW_INSIDE_FACTOR * boxHeight;
+ }
+ break;
+ case "bottom":
+ if (height - y < minOffsetFromEdge) {
+ y -= FLIP_ARROW_INSIDE_FACTOR * boxHeight;
+ }
+ break;
+ }
+
+ // Offset stacked line numbers by a quarter of the box's width/height, so a part of
+ // them remains visible behind the number that sits at the top of the stack.
+ if (isStackedLine) {
+ const xOffset = boxWidth / 4;
+ const yOffset = boxHeight / 4;
+
+ if (lineNumber > 0) {
+ x -= xOffset;
+ y -= yOffset;
+ } else {
+ x += xOffset;
+ y += yOffset;
+ }
+ }
+
+ // If one the edges of the arrow that's parallel to the line is too close to the edge
+ // of the viewport (and therefore partly hidden), grow the arrow's size in the
+ // opposite direction.
+ // The goal is for the part that's not hidden to be exactly the size of a normal
+ // arrow and for the arrow to keep pointing at the line (keep being centered on it).
+ let grewBox = false;
+ const boxWidthBeforeGrowth = boxWidth;
+ const boxHeightBeforeGrowth = boxHeight;
+
+ if (dimensionType === ROWS && y <= boxHeight / 2) {
+ grewBox = true;
+ boxHeight = 2 * (boxHeight - y);
+ } else if (dimensionType === ROWS && y >= height - boxHeight / 2) {
+ grewBox = true;
+ boxHeight = 2 * (y - height + boxHeight);
+ } else if (dimensionType === COLUMNS && x <= boxWidth / 2) {
+ grewBox = true;
+ boxWidth = 2 * (boxWidth - x);
+ } else if (dimensionType === COLUMNS && x >= width - boxWidth / 2) {
+ grewBox = true;
+ boxWidth = 2 * (x - width + boxWidth);
+ }
+
+ // Draw the arrow box itself
+ drawBubbleRect(
+ this.ctx,
+ x,
+ y,
+ boxWidth,
+ boxHeight,
+ radius,
+ margin,
+ arrowSize,
+ boxEdge
+ );
+
+ // Determine the text position for it to be centered nicely inside the arrow box.
+ switch (boxEdge) {
+ case "left":
+ x -= boxWidth + arrowSize + radius - boxWidth / 2;
+ break;
+ case "right":
+ x += boxWidth + arrowSize + radius - boxWidth / 2;
+ break;
+ case "top":
+ y -= boxHeight + arrowSize + radius - boxHeight / 2;
+ break;
+ case "bottom":
+ y += boxHeight + arrowSize + radius - boxHeight / 2;
+ break;
+ }
+
+ // Do a second pass to adjust the position, along the other axis, if the box grew
+ // during the previous step, so the text is also centered on that axis.
+ if (grewBox) {
+ if (dimensionType === ROWS && y <= boxHeightBeforeGrowth / 2) {
+ y = boxHeightBeforeGrowth / 2;
+ } else if (
+ dimensionType === ROWS &&
+ y >= height - boxHeightBeforeGrowth / 2
+ ) {
+ y = height - boxHeightBeforeGrowth / 2;
+ } else if (dimensionType === COLUMNS && x <= boxWidthBeforeGrowth / 2) {
+ x = boxWidthBeforeGrowth / 2;
+ } else if (
+ dimensionType === COLUMNS &&
+ x >= width - boxWidthBeforeGrowth / 2
+ ) {
+ x = width - boxWidthBeforeGrowth / 2;
+ }
+ }
+
+ // Write the line number inside of the rectangle.
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillStyle = "black";
+ const numberText = isStackedLine ? "" : lineNumber;
+ this.ctx.fillText(numberText, x, y);
+ this.ctx.restore();
+ }
+
+ /**
+ * Determine which edge of a line number box to aim the line number arrow at.
+ *
+ * @param {String} dimensionType
+ * The grid line dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} lineNumber
+ * The grid line number.
+ * @return {String} The edge of the box: top, right, bottom or left.
+ */
+ getBoxEdge(dimensionType, lineNumber) {
+ let boxEdge;
+
+ if (dimensionType === COLUMNS) {
+ boxEdge = lineNumber > 0 ? "top" : "bottom";
+ } else if (dimensionType === ROWS) {
+ boxEdge = lineNumber > 0 ? "left" : "right";
+ }
+
+ // Rotate box edge as needed for writing mode and text direction.
+ const { direction, writingMode } = getComputedStyle(this.currentNode);
+
+ switch (writingMode) {
+ case "horizontal-tb":
+ // This is the initial value. No further adjustment needed.
+ break;
+ case "vertical-rl":
+ boxEdge = rotateEdgeRight(boxEdge);
+ break;
+ case "vertical-lr":
+ if (dimensionType === COLUMNS) {
+ boxEdge = rotateEdgeLeft(boxEdge);
+ } else {
+ boxEdge = rotateEdgeRight(boxEdge);
+ }
+ break;
+ case "sideways-rl":
+ boxEdge = rotateEdgeRight(boxEdge);
+ break;
+ case "sideways-lr":
+ boxEdge = rotateEdgeLeft(boxEdge);
+ break;
+ default:
+ console.error(`Unexpected writing-mode: ${writingMode}`);
+ }
+
+ switch (direction) {
+ case "ltr":
+ // This is the initial value. No further adjustment needed.
+ break;
+ case "rtl":
+ if (dimensionType === ROWS) {
+ boxEdge = reflectEdge(boxEdge);
+ }
+ break;
+ default:
+ console.error(`Unexpected direction: ${direction}`);
+ }
+
+ return boxEdge;
+ }
+
+ /**
+ * Render the grid line on the css grid highlighter canvas.
+ *
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} endPos
+ * The end position of the cross side of the grid line.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {String} lineType
+ * The grid line type - "edge", "explicit", or "implicit".
+ */
+ renderLine(linePos, startPos, endPos, dimensionType, lineType) {
+ const { devicePixelRatio } = this.win;
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const offset = (lineWidth / 2) % 1;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ endPos = Math.round(endPos);
+
+ this.ctx.save();
+ this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash);
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ const lineOptions = {
+ matrix: this.currentMatrix,
+ };
+
+ if (this.options.showInfiniteLines) {
+ lineOptions.extendToBoundaries = [
+ canvasX,
+ canvasY,
+ canvasX + CANVAS_SIZE,
+ canvasY + CANVAS_SIZE,
+ ];
+ }
+
+ if (dimensionType === COLUMNS) {
+ drawLine(this.ctx, linePos, startPos, linePos, endPos, lineOptions);
+ } else {
+ drawLine(this.ctx, startPos, linePos, endPos, linePos, lineOptions);
+ }
+
+ this.ctx.strokeStyle = this.color;
+ this.ctx.globalAlpha =
+ GRID_LINES_PROPERTIES[lineType].alpha * this.globalAlpha;
+
+ if (GRID_LINES_PROPERTIES[lineType].lineWidth) {
+ this.ctx.lineWidth =
+ GRID_LINES_PROPERTIES[lineType].lineWidth * devicePixelRatio;
+ } else {
+ this.ctx.lineWidth = lineWidth;
+ }
+
+ this.ctx.stroke();
+ this.ctx.restore();
+ }
+
+ /**
+ * Render the grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {Object} quad.bounds
+ * The content bounds of the box model region quads.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ * @param {Number} endPos
+ * The end position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderLines(gridDimension, dimensionType, startPos, endPos) {
+ const { lines, tracks } = gridDimension;
+ const lastEdgeLineIndex = this.getLastEdgeLineIndex(tracks);
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const linePos = line.start;
+
+ if (i == 0 || i == lastEdgeLineIndex) {
+ this.renderLine(linePos, startPos, endPos, dimensionType, "edge");
+ } else {
+ this.renderLine(
+ linePos,
+ startPos,
+ endPos,
+ dimensionType,
+ tracks[i - 1].type
+ );
+ }
+
+ // Render a second line to illustrate the gutter for non-zero breadth.
+ if (line.breadth > 0) {
+ this.renderGridGap(
+ linePos,
+ startPos,
+ endPos,
+ line.breadth,
+ dimensionType
+ );
+ this.renderLine(
+ linePos + line.breadth,
+ startPos,
+ endPos,
+ dimensionType,
+ tracks[i].type
+ );
+ }
+ }
+ }
+
+ /**
+ * Render the grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderLineNumbers(gridDimension, dimensionType, startPos) {
+ const { lines, tracks } = gridDimension;
+
+ for (let i = 0, line; (line = lines[i++]); ) {
+ // If you place something using negative numbers, you can trigger some implicit
+ // grid creation above and to the left of the explicit grid (assuming a
+ // horizontal-tb writing mode).
+ //
+ // The first explicit grid line gets the number of 1, and any implicit grid lines
+ // before 1 get negative numbers. Since here we're rendering only the positive line
+ // numbers, we have to skip any implicit grid lines before the first one that is
+ // explicit. The API returns a 0 as the line's number for these implicit lines that
+ // occurs before the first explicit line.
+ if (line.number === 0) {
+ continue;
+ }
+
+ // Check for overlapping lines by measuring the track width between them.
+ // We render a second box beneath the last overlapping
+ // line number to indicate there are lines beneath it.
+ const gridTrack = tracks[i - 1];
+
+ if (gridTrack) {
+ const { breadth } = gridTrack;
+
+ if (breadth === 0) {
+ this.renderGridLineNumber(
+ line.number,
+ line.start,
+ startPos,
+ line.breadth,
+ dimensionType,
+ true
+ );
+ continue;
+ }
+ }
+
+ this.renderGridLineNumber(
+ line.number,
+ line.start,
+ startPos,
+ line.breadth,
+ dimensionType
+ );
+ }
+ }
+
+ /**
+ * Render the negative grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderNegativeLineNumbers(gridDimension, dimensionType, startPos) {
+ const { lines, tracks } = gridDimension;
+
+ for (let i = 0, line; (line = lines[i++]); ) {
+ const linePos = line.start;
+ const negativeLineNumber = line.negativeNumber;
+
+ // Don't render any negative line number greater than -1.
+ if (negativeLineNumber == 0) {
+ break;
+ }
+
+ // Check for overlapping lines by measuring the track width between them.
+ // We render a second box beneath the last overlapping
+ // line number to indicate there are lines beneath it.
+ const gridTrack = tracks[i - 1];
+ if (gridTrack) {
+ const { breadth } = gridTrack;
+
+ // Ensure "-1" is always visible, since it is always the largest number.
+ if (breadth === 0 && negativeLineNumber != -1) {
+ this.renderGridLineNumber(
+ negativeLineNumber,
+ linePos,
+ startPos,
+ line.breadth,
+ dimensionType,
+ true
+ );
+ continue;
+ }
+ }
+
+ this.renderGridLineNumber(
+ negativeLineNumber,
+ linePos,
+ startPos,
+ line.breadth,
+ dimensionType
+ );
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)). Should be called whenever node's geometry
+ * or grid changes.
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ // Set z-index.
+ this.markup.content.root.firstElementChild.style.setProperty(
+ "z-index",
+ this.options.zIndex
+ );
+
+ const root = this.getElement("root");
+ const cells = this.getElement("cells");
+ const areas = this.getElement("areas");
+
+ // Set the grid cells and areas fill to the current grid colour.
+ cells.setAttribute("style", `fill: ${this.color}`);
+ areas.setAttribute("style", `fill: ${this.color}`);
+
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ root.setAttribute("style", "display: none");
+ this.win.document.documentElement.offsetWidth;
+ this._winDimensions = getWindowDimensions(this.win);
+ const { width, height } = this._winDimensions;
+
+ // Updates the <canvas> element's position and size.
+ // It also clear the <canvas>'s drawing context.
+ updateCanvasElement(
+ this.canvas,
+ this._canvasPosition,
+ this.win.devicePixelRatio
+ );
+
+ // Clear the grid area highlights.
+ this.clearGridAreas();
+ this.clearGridCell();
+
+ // Update the current matrix used in our canvas' rendering.
+ const { currentMatrix, hasNodeTransformations } = getCurrentMatrix(
+ this.currentNode,
+ this.win
+ );
+ this.currentMatrix = currentMatrix;
+ this.hasNodeTransformations = hasNodeTransformations;
+
+ // Start drawing the grid fragments.
+ for (let i = 0; i < this.gridData.length; i++) {
+ this.renderFragment(this.gridData[i]);
+ }
+
+ // Display the grid area highlights if needed.
+ if (this.options.showAllGridAreas) {
+ this.showAllGridAreas();
+ } else if (this.options.showGridArea) {
+ this.showGridArea(this.options.showGridArea);
+ }
+
+ // Display the grid cell highlights if needed.
+ if (this.options.showGridCell) {
+ this.showGridCell(this.options.showGridCell);
+ }
+
+ // Display the grid line names if needed.
+ if (this.options.showGridLineNames) {
+ this.showGridLineNames(this.options.showGridLineNames);
+ }
+
+ this._showGrid();
+ this._showGridElements();
+
+ root.setAttribute(
+ "style",
+ `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden`
+ );
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+
+ /**
+ * Update the grid information displayed in the grid area info bar.
+ *
+ * @param {GridArea} area
+ * The grid area object.
+ * @param {Object} bounds
+ * A DOMRect-like object represent the grid area rectangle.
+ */
+ _updateGridAreaInfobar(area, bounds) {
+ const { width, height } = bounds;
+ const dim =
+ parseFloat(width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(height.toPrecision(6));
+
+ this.getElement("area-infobar-name").setTextContent(area.name);
+ this.getElement("area-infobar-dimensions").setTextContent(dim);
+
+ const container = this.getElement("area-infobar-container");
+ moveInfobar(container, bounds, this.win, {
+ position: "bottom",
+ });
+ }
+
+ /**
+ * Update the grid information displayed in the grid cell info bar.
+ *
+ * @param {Number} rowNumber
+ * The grid cell's row number.
+ * @param {Number} columnNumber
+ * The grid cell's column number.
+ * @param {Object} bounds
+ * A DOMRect-like object represent the grid cell rectangle.
+ */
+ _updateGridCellInfobar(rowNumber, columnNumber, bounds) {
+ const { width, height } = bounds;
+ const dim =
+ parseFloat(width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(height.toPrecision(6));
+ const position = HighlightersBundle.formatValueSync(
+ "grid-row-column-positions",
+ { row: rowNumber, column: columnNumber }
+ );
+
+ this.getElement("cell-infobar-position").setTextContent(position);
+ this.getElement("cell-infobar-dimensions").setTextContent(dim);
+
+ const container = this.getElement("cell-infobar-container");
+ moveInfobar(container, bounds, this.win, {
+ position: "top",
+ });
+ }
+
+ /**
+ * Update the grid information displayed in the grid line info bar.
+ *
+ * @param {String} gridLineNames
+ * Comma-separated string of names for the grid line.
+ * @param {Number} gridLineNumber
+ * The grid line number.
+ * @param {Number} x
+ * The x-coordinate of the grid line.
+ * @param {Number} y
+ * The y-coordinate of the grid line.
+ */
+ _updateGridLineInfobar(gridLineNames, gridLineNumber, x, y) {
+ this.getElement("line-infobar-number").setTextContent(gridLineNumber);
+ this.getElement("line-infobar-names").setTextContent(gridLineNames);
+
+ const container = this.getElement("line-infobar-container");
+ moveInfobar(
+ container,
+ getBoundsFromPoints([
+ { x, y },
+ { x, y },
+ { x, y },
+ { x, y },
+ ]),
+ this.win
+ );
+ }
+}
+
+exports.CssGridHighlighter = CssGridHighlighter;
diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js
new file mode 100644
index 0000000000..c9f16b42e0
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-transform.js
@@ -0,0 +1,265 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getNodeBounds,
+} = require("resource://devtools/shared/layout/utils.js");
+
+// The minimum distance a line should be before it has an arrow marker-end
+const ARROW_LINE_MIN_DISTANCE = 10;
+
+var MARKER_COUNTER = 1;
+
+/**
+ * The CssTransformHighlighter is the class that draws an outline around a
+ * transformed element and an outline around where it would be if untransformed
+ * as well as arrows connecting the 2 outlines' corners.
+ */
+class CssTransformHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "css-transform-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ const rootWrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "elements",
+ hidden: "true",
+ width: "100%",
+ height: "100%",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Add a marker tag to the svg root for the arrow tip
+ this.markerId = "arrow-marker-" + MARKER_COUNTER;
+ MARKER_COUNTER++;
+ const marker = this.markup.createSVGNode({
+ nodeType: "marker",
+ parent: svg,
+ attributes: {
+ id: this.markerId,
+ markerWidth: "10",
+ markerHeight: "5",
+ orient: "auto",
+ markerUnits: "strokeWidth",
+ refX: "10",
+ refY: "5",
+ viewBox: "0 0 10 10",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: marker,
+ attributes: {
+ d: "M 0 0 L 10 5 L 0 10 z",
+ fill: "#08C",
+ },
+ });
+
+ const shapesGroup = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ });
+
+ // Create the 2 polygons (transformed and untransformed)
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ id: "untransformed",
+ class: "untransformed",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ id: "transformed",
+ class: "transformed",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Create the arrows
+ for (const nb of ["1", "2", "3", "4"]) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: shapesGroup,
+ attributes: {
+ id: "line" + nb,
+ class: "line",
+ "marker-end": "url(#" + this.markerId + ")",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ return container;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ this.markup.destroy();
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ if (!this._isTransformed(this.currentNode)) {
+ this.hide();
+ return false;
+ }
+
+ return this._update();
+ }
+
+ /**
+ * Checks if the supplied node is transformed and not inline
+ */
+ _isTransformed(node) {
+ const style = getComputedStyle(node);
+ return style && style.transform !== "none" && style.display !== "inline";
+ }
+
+ _setPolygonPoints(quad, id) {
+ const points = [];
+ for (const point of ["p1", "p2", "p3", "p4"]) {
+ points.push(quad[point].x + "," + quad[point].y);
+ }
+ this.getElement(id).setAttribute("points", points.join(" "));
+ }
+
+ _setLinePoints(p1, p2, id) {
+ const line = this.getElement(id);
+ line.setAttribute("x1", p1.x);
+ line.setAttribute("y1", p1.y);
+ line.setAttribute("x2", p2.x);
+ line.setAttribute("y2", p2.y);
+
+ const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
+ if (dist < ARROW_LINE_MIN_DISTANCE) {
+ line.removeAttribute("marker-end");
+ } else {
+ line.setAttribute("marker-end", "url(#" + this.markerId + ")");
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ // Getting the points for the transformed shape
+ const quads = this.currentQuads.border;
+ if (
+ !quads.length ||
+ quads[0].bounds.width <= 0 ||
+ quads[0].bounds.height <= 0
+ ) {
+ this._hideShapes();
+ return false;
+ }
+
+ const [quad] = quads;
+
+ // Getting the points for the untransformed shape
+ const untransformedQuad = getNodeBounds(this.win, this.currentNode);
+
+ this._setPolygonPoints(quad, "transformed");
+ this._setPolygonPoints(untransformedQuad, "untransformed");
+ for (const nb of ["1", "2", "3", "4"]) {
+ this._setLinePoints(
+ untransformedQuad["p" + nb],
+ quad["p" + nb],
+ "line" + nb
+ );
+ }
+
+ // Adapt to the current zoom
+ this.markup.scaleRootElement(
+ this.currentNode,
+ this.ID_CLASS_PREFIX + "root"
+ );
+
+ this._showShapes();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ return true;
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideShapes();
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ _hideShapes() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ _showShapes() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+}
+
+exports.CssTransformHighlighter = CssTransformHighlighter;
diff --git a/devtools/server/actors/highlighters/css/highlighters.css b/devtools/server/actors/highlighters/css/highlighters.css
new file mode 100644
index 0000000000..33c8a04aae
--- /dev/null
+++ b/devtools/server/actors/highlighters/css/highlighters.css
@@ -0,0 +1,1059 @@
+/* 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/. */
+
+:host { display: contents; }
+
+.highlighter-container {
+ --highlighter-accessibility-bounds-color: #6a5acd;
+ --highlighter-accessibility-bounds-opacity: 0.6;
+ --highlighter-box-border-color: #444444;
+ --highlighter-box-content-color: hsl(197, 71%, 73%);
+ --highlighter-box-margin-color: #edff64;
+ --highlighter-box-padding-color: #6a5acd;
+ --highlighter-bubble-text-color: hsl(216, 33%, 97%);
+ --highlighter-bubble-background-color: hsl(214, 13%, 24%);
+ --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2);
+ --highlighter-bubble-arrow-size: 8px;
+ --highlighter-font-family: message-box;
+ --highlighter-font-size: 11px;
+ --highlighter-guide-color: hsl(200, 100%, 40%);
+ --highlighter-infobar-color: hsl(210, 30%, 85%);
+
+ --grey-40: #b1b1b3;
+ --red-40: #ff3b6b;
+ --yellow-60: #d7b600;
+ --blue-60: #0060df;
+}
+
+/**
+ * Highlighters are absolute positioned in the page by default.
+ * A single highlighter can have fixed position in its css class if needed (see below the
+ * eye dropper or rulers highlighter, for example); but if it has to handle the
+ * document's scrolling (as rulers does), it would lag a bit behind due the APZ (Async
+ * Pan/Zoom module), that performs asynchronously panning and zooming on the compositor
+ * thread rather than the main thread.
+ */
+.highlighter-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ /* The container for all highlighters doesn't react to pointer-events by
+ default. This is because most highlighters cover the whole viewport but
+ don't contain UIs that need to be accessed.
+ If your highlighter has UI that needs to be interacted with, add
+ 'pointer-events:auto;' on its container element. */
+ pointer-events: none;
+}
+
+.highlighter-container.box-model {
+ /* Make the box-model container have a z-index other than auto so it always sits above
+ other highlighters. */
+ z-index: 1;
+}
+
+.highlighter-container [hidden] {
+ display: none !important;
+}
+
+.highlighter-container [dragging] {
+ cursor: grabbing;
+}
+
+/* Box Model Highlighter */
+
+.box-model-regions {
+ opacity: 0.6;
+}
+
+/* Box model regions can be faded (see the onlyRegionArea option in
+ highlighters.js) in order to only display certain regions. */
+.box-model-regions [faded] {
+ display: none;
+}
+
+.box-model-content {
+ fill: var(--highlighter-box-content-color);
+}
+
+.box-model-padding {
+ fill: var(--highlighter-box-padding-color);
+}
+
+.box-model-border {
+ fill: var(--highlighter-box-border-color);
+}
+
+.box-model-margin {
+ fill: var(--highlighter-box-margin-color);
+}
+
+.box-model-content,
+.box-model-padding,
+.box-model-border,
+.box-model-margin {
+ stroke: none;
+}
+
+.box-model-guide-top,
+.box-model-guide-right,
+.box-model-guide-bottom,
+.box-model-guide-left {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ shape-rendering: crispEdges;
+}
+
+@media (prefers-reduced-motion) {
+ .use-simple-highlighters :is(
+ .box-model-content,
+ .box-model-padding,
+ .box-model-border,
+ .box-model-margin
+ ) {
+ fill: none;
+ stroke-width: 3;
+ }
+
+ .use-simple-highlighters .box-model-content {
+ stroke: var(--highlighter-box-content-color);
+ }
+
+ .use-simple-highlighters .box-model-padding {
+ stroke: var(--highlighter-box-padding-color);
+ }
+
+ .use-simple-highlighters .box-model-border {
+ stroke: var(--highlighter-box-border-color);
+ }
+
+ .use-simple-highlighters .box-model-margin {
+ stroke: var(--highlighter-box-margin-color);
+ }
+}
+
+/* Highlighter - Infobar */
+
+[class$="infobar-container"] {
+ position: absolute;
+ max-width: 95%;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+[class$="infobar"] {
+ position: relative;
+
+ padding: 5px;
+ min-width: 75px;
+
+ border-radius: 3px;
+ background: var(--highlighter-bubble-background-color) no-repeat padding-box;
+
+ color: var(--highlighter-bubble-text-color);
+ text-shadow: none;
+
+ border: 1px solid var(--highlighter-bubble-border-color);
+}
+
+/* Arrows */
+
+[class$="infobar-container"] > [class$="infobar"]:before {
+ left: calc(50% - var(--highlighter-bubble-arrow-size));
+ border: var(--highlighter-bubble-arrow-size) solid
+ var(--highlighter-bubble-border-color);
+}
+
+[class$="infobar-container"] > [class$="infobar"]:after {
+ left: calc(50% - 7px);
+ border: 7px solid var(--highlighter-bubble-background-color);
+}
+
+[class$="infobar-container"] > [class$="infobar"]:before,
+[class$="infobar-container"] > [class$="infobar"]:after {
+ content: "";
+ display: none;
+ position: absolute;
+ height: 0;
+ width: 0;
+ border-left-color: transparent;
+ border-right-color: transparent;
+}
+
+[class$="infobar-container"][position="top"]:not([hide-arrow])
+ > [class$="infobar"]:before,
+[class$="infobar-container"][position="top"]:not([hide-arrow])
+ > [class$="infobar"]:after {
+ border-bottom: 0;
+ top: 100%;
+ display: block;
+}
+
+[class$="infobar-container"][position="bottom"]:not([hide-arrow])
+ > [class$="infobar"]:before,
+[class$="infobar-container"][position="bottom"]:not([hide-arrow])
+ > [class$="infobar"]:after {
+ border-top: 0;
+ bottom: 100%;
+ display: block;
+}
+
+/* Text Container */
+
+[class$="infobar-text"] {
+ overflow: hidden;
+ white-space: nowrap;
+ direction: ltr;
+ padding-bottom: 1px;
+ display: flex;
+ justify-content: center;
+ max-width: 768px;
+}
+
+.box-model-infobar-tagname {
+ color: hsl(285, 100%, 75%);
+}
+
+.box-model-infobar-id {
+ color: hsl(103, 46%, 54%);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.box-model-infobar-classes,
+.box-model-infobar-pseudo-classes {
+ color: hsl(200, 74%, 57%);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[class$="infobar-dimensions"],
+[class$="infobar-grid-type"],
+[class$="infobar-flex-type"] {
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+[class$="infobar-grid-type"]:empty,
+[class$="infobar-flex-type"]:empty {
+ display: none;
+}
+
+[class$="infobar-dimensions"] {
+ color: var(--highlighter-infobar-color);
+}
+
+[class$="infobar-grid-type"],
+[class$="infobar-flex-type"] {
+ color: var(--grey-40);
+}
+
+/* CSS Grid Highlighter */
+
+.css-grid-canvas {
+ position: absolute;
+ pointer-events: none;
+ top: 0;
+ left: 0;
+ image-rendering: -moz-crisp-edges;
+}
+
+.css-grid-regions {
+ opacity: 0.6;
+}
+
+.css-grid-areas,
+.css-grid-cells {
+ opacity: 0.5;
+ stroke: none;
+}
+
+.css-grid-area-infobar-name,
+.css-grid-cell-infobar-position,
+.css-grid-line-infobar-number {
+ color: hsl(285, 100%, 75%);
+}
+
+.css-grid-line-infobar-names:not(:empty) {
+ color: var(--highlighter-infobar-color);
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+/* CSS Transform Highlighter */
+
+.css-transform-transformed {
+ fill: var(--highlighter-box-content-color);
+ opacity: 0.8;
+}
+
+.css-transform-untransformed {
+ fill: #66cc52;
+ opacity: 0.8;
+}
+
+.css-transform-transformed,
+.css-transform-untransformed,
+.css-transform-line {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ stroke-width: 2;
+}
+
+/* Element Geometry Highlighter */
+
+.geometry-editor-root {
+ /* The geometry editor can be interacted with, so it needs to react to
+ pointer events */
+ pointer-events: auto;
+ user-select: none;
+}
+
+.geometry-editor-offset-parent {
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: crispEdges;
+ stroke-dasharray: 5 3;
+ fill: transparent;
+}
+
+.geometry-editor-current-node {
+ stroke: var(--highlighter-guide-color);
+ fill: var(--highlighter-box-content-color);
+ shape-rendering: crispEdges;
+ opacity: 0.6;
+}
+
+.geometry-editor-arrow {
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: crispEdges;
+}
+
+.geometry-editor-root circle {
+ stroke: var(--highlighter-guide-color);
+ fill: var(--highlighter-box-content-color);
+}
+
+.geometry-editor-handler-top,
+.geometry-editor-handler-bottom {
+ cursor: ns-resize;
+}
+
+.geometry-editor-handler-right,
+.geometry-editor-handler-left {
+ cursor: ew-resize;
+}
+
+[dragging] .geometry-editor-handler-top,
+[dragging] .geometry-editor-handler-right,
+[dragging] .geometry-editor-handler-bottom,
+[dragging] .geometry-editor-handler-left {
+ cursor: grabbing;
+}
+
+.geometry-editor-handler-top.dragging,
+.geometry-editor-handler-right.dragging,
+.geometry-editor-handler-bottom.dragging,
+.geometry-editor-handler-left.dragging {
+ fill: var(--highlighter-guide-color);
+}
+
+.geometry-editor-label-bubble {
+ fill: var(--highlighter-bubble-background-color);
+ shape-rendering: crispEdges;
+}
+
+.geometry-editor-label-text {
+ fill: var(--highlighter-bubble-text-color);
+ font: var(--highlighter-font-family);
+ font-size: 10px;
+ text-anchor: middle;
+ dominant-baseline: middle;
+}
+
+/* Rulers Highlighter */
+
+.rulers-highlighter-elements {
+ shape-rendering: crispEdges;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+}
+
+.rulers-highlighter-elements > g {
+ opacity: 0.8;
+}
+
+.rulers-highlighter-elements > g > rect {
+ fill: #fff;
+}
+
+.rulers-highlighter-ruler-graduations {
+ stroke: #bebebe;
+}
+
+.rulers-highlighter-ruler-markers {
+ stroke: #202020;
+}
+
+.rulers-highlighter-horizontal-labels > text,
+.rulers-highlighter-vertical-labels > text {
+ stroke: none;
+ fill: #202020;
+ font: var(--highlighter-font-family);
+ font-size: 9px;
+ dominant-baseline: hanging;
+}
+
+.rulers-highlighter-horizontal-labels > text {
+ text-anchor: start;
+}
+
+.rulers-highlighter-vertical-labels > text {
+ transform: rotate(-90deg);
+ text-anchor: end;
+}
+
+.viewport-size-highlighter-viewport-infobar-container {
+ shape-rendering: crispEdges;
+ background-color: rgba(255, 255, 255, 0.7);
+ font: var(--highlighter-font-family);
+ position: fixed;
+ top: 30px;
+ right: 0px;
+ font-size: 12px;
+ padding: 4px;
+}
+
+/* Measuring Tool Highlighter */
+
+.measuring-tool-tool {
+ pointer-events: auto;
+}
+
+.measuring-tool-root {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: auto;
+ cursor: crosshair;
+}
+
+.measuring-tool-elements {
+ position: absolute;
+}
+
+.measuring-tool-root path {
+ shape-rendering: geometricPrecision;
+ pointer-events: auto;
+}
+
+.measuring-tool-root .measuring-tool-box-path,
+.measuring-tool-root .measuring-tool-diagonal-path {
+ fill: rgba(135, 206, 235, 0.6);
+ stroke: var(--highlighter-guide-color);
+}
+
+.measuring-tool-root circle {
+ stroke: var(--highlighter-guide-color);
+ stroke-width: 2px;
+ fill: #fff;
+ vector-effect: non-scaling-stroke;
+}
+
+.measuring-tool-root circle.highlight {
+ fill: var(--highlighter-guide-color);
+}
+
+.measuring-tool-handler-top,
+.measuring-tool-handler-bottom {
+ cursor: ns-resize;
+}
+
+.measuring-tool-handler-right,
+.measuring-tool-handler-left {
+ cursor: ew-resize;
+}
+
+.measuring-tool-handler-topleft,
+.measuring-tool-handler-bottomright {
+ cursor: nwse-resize;
+}
+
+.measuring-tool-handler-topright,
+.measuring-tool-handler-bottomleft {
+ cursor: nesw-resize;
+}
+
+.mirrored .measuring-tool-handler-topleft,
+.mirrored .measuring-tool-handler-bottomright {
+ cursor: nesw-resize;
+}
+
+.mirrored .measuring-tool-handler-topright,
+.mirrored .measuring-tool-handler-bottomleft {
+ cursor: nwse-resize;
+}
+
+[class^=measuring-tool-handler].dragging {
+ fill: var(--highlighter-guide-color);
+}
+
+.dragging .measuring-tool-box-path,
+.dragging .measuring-tool-diagonal-path {
+ opacity: 0.45;
+}
+
+.measuring-tool-label-size,
+.measuring-tool-label-position {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: inline-block;
+ border-radius: 4px;
+ padding: 4px;
+ white-space: pre-line;
+ font: var(--highlighter-font-family);
+ font-size: 10px;
+ pointer-events: none;
+ user-select: none;
+ box-sizing: border-box;
+}
+
+.measuring-tool-label-position {
+ color: #fff;
+ background: hsla(214, 13%, 24%, 0.8);
+}
+
+.measuring-tool-label-size {
+ color: var(--highlighter-bubble-text-color);
+ background: var(--highlighter-bubble-background-color);
+ border: 1px solid var(--highlighter-bubble-border-color);
+ line-height: 1.5em;
+}
+
+[class^=measuring-tool-guide] {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ shape-rendering: crispEdges;
+}
+
+/* Eye Dropper */
+
+.eye-dropper-root {
+ --magnifier-width: 96px;
+ --magnifier-height: 96px;
+ /* Width accounts for all color formats (hsl being the longest) */
+ --label-width: 160px;
+ --label-height: 23px;
+ --background-color: #e0e0e0;
+ color: #333;
+
+ position: fixed;
+ /* Tool start position. This should match the X/Y defines in JS */
+ top: 100px;
+ left: 100px;
+
+ /* Prevent interacting with the page when hovering and clicking */
+ pointer-events: auto;
+
+ /* Offset the UI so it is centered around the pointer */
+ transform: translate(
+ calc(var(--magnifier-width) / -2),
+ calc(var(--magnifier-height) / -2)
+ );
+
+ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.4));
+
+ /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear
+ to the right of the hex code. Force LTR */
+ direction: ltr;
+}
+
+.eye-dropper-canvas {
+ image-rendering: -moz-crisp-edges;
+ cursor: none;
+ width: var(--magnifier-width);
+ height: var(--magnifier-height);
+ border-radius: 50%;
+ box-shadow: 0 0 0 3px var(--background-color);
+ display: block;
+}
+
+.eye-dropper-color-container {
+ background-color: var(--background-color);
+ border-radius: 2px;
+ width: var(--label-width);
+ height: var(--label-height);
+ position: relative;
+
+ --label-horizontal-center: translateX(
+ calc((var(--magnifier-width) - var(--label-width)) / 2)
+ );
+ --label-horizontal-left: translateX(
+ calc((-1 * var(--label-width) + var(--magnifier-width) / 2))
+ );
+ --label-horizontal-right: translateX(calc(var(--magnifier-width) / 2));
+ --label-vertical-top: translateY(
+ calc((-1 * var(--magnifier-height)) - var(--label-height))
+ );
+
+ /* By default the color label container sits below the canvas.
+ Here we just center it horizontally */
+ transform: var(--label-horizontal-center);
+ transition: transform 0.1s ease-in-out;
+}
+
+/* If there isn't enough space below the canvas, we move the label container to the top */
+.eye-dropper-root[top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-center) var(--label-vertical-top);
+}
+
+/* If there isn't enough space right of the canvas to horizontally center the label
+ container, offset it to the left */
+.eye-dropper-root[left] .eye-dropper-color-container {
+ transform: var(--label-horizontal-left);
+}
+
+.eye-dropper-root[left][top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-left) var(--label-vertical-top);
+}
+
+/* If there isn't enough space left of the canvas to horizontally center the label
+ container, offset it to the right */
+.eye-dropper-root[right] .eye-dropper-color-container {
+ transform: var(--label-horizontal-right);
+}
+
+.eye-dropper-root[right][top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-right) var(--label-vertical-top);
+}
+
+.eye-dropper-color-preview {
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ inset-inline-start: 3px;
+ inset-block-start: 3px;
+ box-shadow: 0px 0px 0px black;
+ border: solid 1px #fff;
+}
+
+.eye-dropper-color-value {
+ text-shadow: 1px 1px 1px #fff;
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+ text-align: center;
+ padding: 4px 0;
+}
+
+/* Paused Debugger Overlay */
+
+.paused-dbg-root {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ width: 100vw;
+ height: 100vh;
+
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+
+ /* We don't have access to DevTools themes here, but some of these colors come from the
+ themes. Theme variable names are given in comments. */
+ --text-color: #585959; /* --theme-body-color-alt */
+ --toolbar-background: #fcfcfc; /* --theme-toolbar-background */
+ --toolbar-border: #dde1e4; /* --theme-splitter-color */
+ --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */
+ --overlay-background: #dde1e4a8;
+}
+
+.paused-dbg-root[overlay] {
+ background-color: var(--overlay-background);
+ pointer-events: auto;
+}
+
+.paused-dbg-toolbar {
+ /* Show the toolbar at the top, but not too high to prevent it overlaping OS toolbar on Android */
+ margin-top: 30px;
+ display: inline-flex;
+ user-select: none;
+
+ color: var(--text-color);
+ box-shadow: var(--toolbar-box-shadow);
+ background-color: var(--toolbar-background);
+ border: 1px solid var(--toolbar-border);
+ border-radius: 4px;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+.paused-dbg-toolbar button {
+ margin: 8px 4px 6px 6px;
+ width: 16px;
+ height: 16px;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: 16px 16px;
+ background-color: var(--text-color);
+
+ border: 0px;
+ appearance: none;
+}
+
+.paused-dbg-divider {
+ width: 1px;
+ height: 16px;
+ margin-top: 10px;
+ background-color: var(--toolbar-border);
+}
+
+.paused-dbg-reason,
+.paused-dbg-step-button-wrapper,
+.paused-dbg-resume-button-wrapper {
+ margin-top: 2px;
+ margin-bottom: 2px;
+}
+
+.paused-dbg-step-button-wrapper,
+.paused-dbg-resume-button-wrapper {
+ margin-left: 2px;
+ margin-right: 2px;
+}
+
+button.paused-dbg-step-button {
+ margin-left: 6px;
+ margin-right: 6px;
+ mask-image: url(resource://devtools-shared-images/stepOver.svg);
+ padding: 0;
+}
+
+button.paused-dbg-resume-button {
+ margin-right: 6px;
+ mask-image: url(resource://devtools-shared-images/resume.svg);
+ padding: 0;
+}
+
+.paused-dbg-step-button-wrapper.hover,
+.paused-dbg-resume-button-wrapper.hover {
+ background-color: var(--toolbar-border);
+ border-radius: 2px;
+}
+
+.paused-dbg-reason {
+ padding: 3px 16px;
+ margin: 8px 0px;
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+
+/* Remote Node Picker Notice Highlighter */
+
+#node-picker-notice-root {
+ position: fixed;
+ max-width: 100vw;
+ /* Position at the bottom of the screen so it doesn't get into the user's way */
+ bottom: 0;
+ left: 0;
+ right: 0;
+
+ z-index: 2;
+
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+
+ /* We don't have access to DevTools themes here, but some of these colors come from the
+ themes. Theme variable names are given in comments. */
+ --text-color: #585959; /* --theme-body-color-alt */
+ --toolbar-background: #fcfcfc; /* --theme-toolbar-background */
+ --toolbar-border: #dde1e4; /* --theme-splitter-color */
+ --toolbar-button-hover-background: rgba(12, 12, 13, 0.15); /* --theme-toolbarbutton-hover-background */
+ --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */
+}
+
+#node-picker-notice-root[overlay] {
+ pointer-events: auto;
+}
+
+#node-picker-notice-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ padding: 8px 16px;
+
+ color: var(--text-color);
+ box-shadow: var(--toolbar-box-shadow);
+ background-color: var(--toolbar-background);
+ border: 1px solid var(--toolbar-border);
+ border-radius: 2px;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+
+ user-select: none;
+}
+
+#node-picker-notice-info {
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+ text-align: center;
+}
+
+#node-picker-notice-icon {
+ width: 16px;
+ height: 16px;
+
+ background-image: url(resource://devtools-shared-images/command-pick.svg);
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+
+#node-picker-notice-icon.touch {
+ background-image: url(resource://devtools-shared-images/command-pick-remote-touch.svg);
+}
+
+
+#node-picker-notice-hide-button {
+ border: 0px;
+ border-radius: 2px;
+ appearance: none;
+ background-color: var(--toolbar-border);
+ color: currentColor;
+ font-size: 1em;
+ padding-inline: 4px;
+}
+
+/* We can't use :hover as it wouldn't work if the page is paused, so we add a specific class for this */
+#node-picker-notice-hide-button.hover {
+ background-color: var(--toolbar-button-hover-background);
+}
+
+/* Shapes highlighter */
+
+.shapes-root {
+ pointer-events: none;
+}
+
+.shapes-shape-container {
+ position: absolute;
+ overflow: visible;
+}
+
+.shapes-polygon,
+.shapes-ellipse,
+.shapes-rect,
+.shapes-bounding-box,
+.shapes-rotate-line,
+.shapes-quad {
+ fill: transparent;
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: geometricPrecision;
+ vector-effect: non-scaling-stroke;
+}
+
+.shapes-markers {
+ fill: #fff;
+}
+
+.shapes-markers-outline {
+ fill: var(--highlighter-guide-color);
+}
+
+.shapes-marker-hover {
+ fill: var(--highlighter-guide-color);
+}
+
+/* Accessible highlighter */
+
+.accessible-infobar {
+ min-width: unset;
+}
+
+.accessible-infobar-text {
+ display: grid;
+ grid-template-areas:
+ "role name"
+ "audit audit";
+ grid-template-columns: min-content 1fr;
+}
+
+.accessible-infobar-role {
+ grid-area: role;
+ color: #9cdcfe;
+}
+
+.accessible-infobar-name {
+ grid-area: name;
+}
+
+.accessible-infobar-audit {
+ grid-area: audit;
+ padding-top: 5px;
+ padding-bottom: 2px;
+}
+
+.accessible-bounds {
+ fill: var(--highlighter-accessibility-bounds-color);
+ opacity: var(--highlighter-accessibility-bounds-opacity);
+}
+
+@media (prefers-reduced-motion) {
+ .use-simple-highlighters .accessible-bounds {
+ fill: none;
+ stroke: var(--highlighter-accessibility-bounds-color);
+ stroke-width: 3;
+ }
+}
+
+.accessible-infobar-name,
+.accessible-infobar-audit {
+ color: var(--highlighter-infobar-color);
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio:empty::before,
+.accessible-infobar-audit .accessible-contrast-ratio:empty::after,
+.accessible-infobar-name:empty {
+ display: none;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio::before {
+ content: "";
+ height: 8px;
+ width: 8px;
+ display: inline-flex;
+ background-color: var(--accessibility-highlighter-contrast-ratio-color);
+ box-shadow: 0 0 0 1px var(--grey-40),
+ 4px 3px var(--accessibility-highlighter-contrast-ratio-bg),
+ 4px 3px 0 1px var(--grey-40);
+ margin-inline-start: 3px;
+ margin-inline-end: 9px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio::after {
+ margin-inline-start: 2px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AA::after,
+.accessible-infobar-audit .accessible-contrast-ratio.AAA::after {
+ color: #90E274;
+}
+
+.accessible-infobar-audit .accessible-audit::before,
+.accessible-infobar-audit .accessible-contrast-ratio.FAIL::after {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ content: "";
+ vertical-align: -2px;
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.FAIL:after {
+ color: #E57180;
+ margin-inline-start: 3px;
+ background-image: url(resource://devtools-shared-images/error-small.svg);
+ fill: var(--red-40);
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AA::after {
+ content: "AA\2713";
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AAA::after {
+ content: "AAA\2713";
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio-label,
+.accessible-infobar-audit .accessible-contrast-ratio-separator::before {
+ margin-inline-end: 3px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio-separator::before {
+ content: "-";
+ margin-inline-start: 3px;
+}
+
+.accessible-infobar-audit .accessible-audit {
+ display: block;
+ padding-block-end: 5px;
+}
+
+.accessible-infobar-audit .accessible-audit:last-child {
+ padding-block-end: 0;
+}
+
+.accessible-infobar-audit .accessible-audit::before {
+ margin-inline-end: 4px;
+ background-image: none;
+ fill: currentColor;
+}
+
+.accessible-infobar-audit .accessible-audit.FAIL::before {
+ background-image: url(resource://devtools-shared-images/error-small.svg);
+ fill: var(--red-40);
+}
+
+.accessible-infobar-audit .accessible-audit.WARNING::before {
+ background-image: url(chrome://devtools/skin/images/alert-small.svg);
+ fill: var(--yellow-60);
+}
+
+.accessible-infobar-audit .accessible-audit.BEST_PRACTICES::before {
+ background-image: url(chrome://devtools/skin/images/info-small.svg);
+}
+
+.accessible-infobar-name {
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+/* Tabbing-order highlighter */
+
+.tabbing-order-infobar {
+ min-width: unset;
+}
+
+.tabbing-order .tabbing-order-infobar-container {
+ font-size:calc(var(--highlighter-font-size) + 2px);
+}
+
+.tabbing-order .tabbing-order-bounds {
+ position: absolute;
+ display: block;
+ outline: 2px solid #000;
+ outline-offset: -2px;
+}
+
+.tabbing-order.focused .tabbing-order-bounds {
+ outline-color: var(--blue-60);
+}
+
+.tabbing-order.focused .tabbing-order-infobar {
+ background-color: var(--blue-60);
+}
+
+.tabbing-order.focused .tabbing-order-infobar-text {
+ text-decoration: underline;
+}
+
+.tabbing-order.focused .tabbing-order-infobar:after {
+ border-top-color: var(--blue-60);
+ border-bottom-color: var(--blue-60);
+}
diff --git a/devtools/server/actors/highlighters/css/moz.build b/devtools/server/actors/highlighters/css/moz.build
new file mode 100644
index 0000000000..6bdf0f9579
--- /dev/null
+++ b/devtools/server/actors/highlighters/css/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "highlighters.css",
+)
diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js
new file mode 100644
index 0000000000..8a206bc84f
--- /dev/null
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -0,0 +1,608 @@
+/* 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";
+
+// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the
+// content page.
+// It basically displays a magnifier that tracks mouse moves and shows a magnified version
+// of the page. On click, it samples the color at the pixel being hovered.
+
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { rgbToHsl } =
+ require("resource://devtools/shared/css/color.js").colorUtils;
+const {
+ getCurrentZoom,
+ getFrameOffsets,
+} = require("resource://devtools/shared/layout/utils.js");
+
+loader.lazyGetter(this, "clipboardHelper", () =>
+ Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)
+);
+loader.lazyGetter(this, "l10n", () =>
+ Services.strings.createBundle(
+ "chrome://devtools-shared/locale/eyedropper.properties"
+ )
+);
+
+const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom";
+const FORMAT_PREF = "devtools.defaultColorUnit";
+// Width of the canvas.
+const MAGNIFIER_WIDTH = 96;
+// Height of the canvas.
+const MAGNIFIER_HEIGHT = 96;
+// Start position, when the tool is first shown. This should match the top/left position
+// defined in CSS.
+const DEFAULT_START_POS_X = 100;
+const DEFAULT_START_POS_Y = 100;
+// How long to wait before closing after copy.
+const CLOSE_DELAY = 750;
+
+/**
+ * The EyeDropper allows the user to select a color of a pixel within the content page,
+ * showing a magnified circle and color preview while the user hover the page.
+ */
+class EyeDropper {
+ #pageEventListenersAbortController;
+ constructor(highlighterEnv) {
+ EventEmitter.decorate(this);
+
+ this.highlighterEnv = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ // Get a couple of settings from prefs.
+ this.format = Services.prefs.getCharPref(FORMAT_PREF);
+ this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF);
+ }
+
+ ID_CLASS_PREFIX = "eye-dropper-";
+
+ get win() {
+ return this.highlighterEnv.window;
+ }
+
+ _buildMarkup() {
+ // Highlighter main container.
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The magnifier canvas element.
+ this.markup.createNode({
+ parent: wrapper,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The color label element.
+ const colorLabelContainer = this.markup.createNode({
+ parent: wrapper,
+ attributes: { class: "color-container" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-preview", class: "color-preview" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-value", class: "color-value" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ destroy() {
+ this.hide();
+ this.markup.destroy();
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Show the eye-dropper highlighter.
+ *
+ * @param {DOMNode} node The node which document the highlighter should be inserted in.
+ * @param {Object} options The options object may contain the following properties:
+ * - {Boolean} copyOnSelect: Whether selecting a color should copy it to the clipboard.
+ * - {String|null} screenshot: a dataURL representation of the page screenshot. If null,
+ * the eyedropper will use `drawWindow` to get the the screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ show(node, options = {}) {
+ if (this.highlighterEnv.isXUL) {
+ return false;
+ }
+
+ this.options = options;
+
+ // Get the page's current zoom level.
+ this.pageZoom = getCurrentZoom(this.win);
+
+ // Take a screenshot of the viewport. This needs to be done first otherwise the
+ // eyedropper UI will appear in the screenshot itself (since the UI is injected as
+ // native anonymous content in the page).
+ // Once the screenshot is ready, the magnified area will be drawn.
+ this.prepareImageCapture(options.screenshot);
+
+ // Start listening for user events.
+ const { pageListenerTarget } = this.highlighterEnv;
+ this.#pageEventListenersAbortController = new AbortController();
+ const signal = this.#pageEventListenersAbortController.signal;
+ pageListenerTarget.addEventListener("mousemove", this, { signal });
+ pageListenerTarget.addEventListener("click", this, {
+ signal,
+ useCapture: true,
+ });
+ pageListenerTarget.addEventListener("keydown", this, { signal });
+ pageListenerTarget.addEventListener("DOMMouseScroll", this, { signal });
+ pageListenerTarget.addEventListener("FullZoomChange", this, { signal });
+
+ // Show the eye-dropper.
+ this.getElement("root").removeAttribute("hidden");
+
+ // Prepare the canvas context on which we're drawing the magnified page portion.
+ this.ctx = this.getElement("canvas").getCanvasContext();
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.magnifiedArea = {
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ x: DEFAULT_START_POS_X,
+ y: DEFAULT_START_POS_Y,
+ };
+
+ this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y);
+
+ // Focus the content so the keyboard can be used.
+ this.win.focus();
+
+ // Make sure we receive mouse events when the debugger has paused execution
+ // in the page.
+ this.win.document.setSuppressedEventListener(this);
+
+ return true;
+ }
+
+ /**
+ * Hide the eye-dropper highlighter.
+ */
+ hide() {
+ this.pageImage = null;
+
+ if (this.#pageEventListenersAbortController) {
+ this.#pageEventListenersAbortController.abort();
+ this.#pageEventListenersAbortController = null;
+
+ const rootElement = this.getElement("root");
+ rootElement.setAttribute("hidden", "true");
+ rootElement.removeAttribute("drawn");
+
+ this.emit("hidden");
+
+ this.win.document.setSuppressedEventListener(null);
+ }
+ }
+
+ /**
+ * Convert a base64 png data-uri to raw binary data.
+ */
+ #dataURItoBlob(dataURI) {
+ const byteString = atob(dataURI.split(",")[1]);
+
+ // write the bytes of the string to an ArrayBuffer
+ const buffer = new ArrayBuffer(byteString.length);
+ // Update the buffer through a typed array.
+ const typedArray = new Uint8Array(buffer);
+ for (let i = 0; i < byteString.length; i++) {
+ typedArray[i] = byteString.charCodeAt(i);
+ }
+
+ return new Blob([buffer], { type: "image/png" });
+ }
+
+ /**
+ * Create an image bitmap from the page screenshot, draw the eyedropper and set the
+ * "drawn" attribute on the "root" element once it's done.
+ *
+ * @params {String|null} screenshot: a dataURL representation of the page screenshot.
+ * If null, we'll use `drawWindow` to get the the page screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ async prepareImageCapture(screenshot) {
+ let imageSource;
+ if (screenshot) {
+ imageSource = this.#dataURItoBlob(screenshot);
+ } else {
+ imageSource = getWindowAsImageData(this.win);
+ }
+
+ // We need to transform the blob/imageData to something drawWindow will consume.
+ // An ImageBitmap works well. We could have used an Image, but doing so results
+ // in errors if the page defines CSP headers.
+ const image = await this.win.createImageBitmap(imageSource);
+
+ this.pageImage = image;
+ // We likely haven't drawn anything yet (no mousemove events yet), so start now.
+ this.draw();
+
+ // Set an attribute on the root element to be able to run tests after the first draw
+ // was done.
+ this.getElement("root").setAttribute("drawn", "true");
+ }
+
+ /**
+ * Get the number of cells (blown-up pixels) per direction in the grid.
+ */
+ get cellsWide() {
+ // Canvas will render whole "pixels" (cells) only, and an even number at that. Round
+ // up to the nearest even number of pixels.
+ let cellsWide = Math.ceil(
+ this.magnifiedArea.width / this.eyeDropperZoomLevel
+ );
+ cellsWide += cellsWide % 2;
+
+ return cellsWide;
+ }
+
+ /**
+ * Get the size of each cell (blown-up pixel) in the grid.
+ */
+ get cellSize() {
+ return this.magnifiedArea.width / this.cellsWide;
+ }
+
+ /**
+ * Get index of cell in the center of the grid.
+ */
+ get centerCell() {
+ return Math.floor(this.cellsWide / 2);
+ }
+
+ /**
+ * Get color of center cell in the grid.
+ */
+ get centerColor() {
+ const pos = this.centerCell * this.cellSize + this.cellSize / 2;
+ const rgb = this.ctx.getImageData(pos, pos, 1, 1).data;
+ return rgb;
+ }
+
+ draw() {
+ // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove.
+ if (!this.pageImage) {
+ return;
+ }
+
+ const { width, height, x, y } = this.magnifiedArea;
+
+ const zoomedWidth = width / this.eyeDropperZoomLevel;
+ const zoomedHeight = height / this.eyeDropperZoomLevel;
+
+ const sx = x - zoomedWidth / 2;
+ const sy = y - zoomedHeight / 2;
+ const sw = zoomedWidth;
+ const sh = zoomedHeight;
+
+ this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height);
+
+ // Draw the grid on top, but only at 3x or more, otherwise it's too busy.
+ if (this.eyeDropperZoomLevel > 2) {
+ this.drawGrid();
+ }
+
+ this.drawCrosshair();
+
+ // Update the color preview and value.
+ const rgb = this.centerColor;
+ this.getElement("color-preview").setAttribute(
+ "style",
+ `background-color:${toColorString(rgb, "rgb")};`
+ );
+ this.getElement("color-value").setTextContent(
+ toColorString(rgb, this.format)
+ );
+ }
+
+ /**
+ * Draw a grid on the canvas representing pixel boundaries.
+ */
+ drawGrid() {
+ const { width, height } = this.magnifiedArea;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)";
+
+ for (let i = 0; i < width; i += this.cellSize) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(i - 0.5, 0);
+ this.ctx.lineTo(i - 0.5, height);
+ this.ctx.stroke();
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, i - 0.5);
+ this.ctx.lineTo(width, i - 0.5);
+ this.ctx.stroke();
+ }
+ }
+
+ /**
+ * Draw a box on the canvas to highlight the center cell.
+ */
+ drawCrosshair() {
+ const pos = this.centerCell * this.cellSize;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.lineJoin = "miter";
+ this.ctx.strokeStyle = "rgba(0, 0, 0, 1)";
+ this.ctx.strokeRect(
+ pos - 1.5,
+ pos - 1.5,
+ this.cellSize + 2,
+ this.cellSize + 2
+ );
+
+ this.ctx.strokeStyle = "rgba(255, 255, 255, 1)";
+ this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize);
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousemove":
+ // We might be getting an event from a child frame, so account for the offset.
+ const [xOffset, yOffset] = getFrameOffsets(this.win, e.target);
+ const x = xOffset + e.pageX - this.win.scrollX;
+ const y = yOffset + e.pageY - this.win.scrollY;
+ // Update the zoom area.
+ this.magnifiedArea.x = x * this.pageZoom;
+ this.magnifiedArea.y = y * this.pageZoom;
+ // Redraw the portion of the screenshot that is now under the mouse.
+ this.draw();
+ // And move the eye-dropper's UI so it follows the mouse.
+ this.moveTo(x, y);
+ break;
+ // Note: when events are suppressed we will only get mousedown/mouseup and
+ // not any click events.
+ case "click":
+ case "mouseup":
+ this.selectColor();
+ break;
+ case "keydown":
+ this.handleKeyDown(e);
+ break;
+ case "DOMMouseScroll":
+ // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+ // scrolling out of the viewport wouldn't draw the expected things. In the future
+ // we can take the screenshot again on scroll, but for now it doesn't seem
+ // important.
+ e.preventDefault();
+ break;
+ case "FullZoomChange":
+ this.hide();
+ this.show();
+ break;
+ }
+ }
+
+ moveTo(x, y) {
+ const root = this.getElement("root");
+ root.setAttribute("style", `top:${y}px;left:${x}px;`);
+
+ // Move the label container to the top if the magnifier is close to the bottom edge.
+ if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) {
+ root.setAttribute("top", "");
+ } else {
+ root.removeAttribute("top");
+ }
+
+ // Also offset the label container to the right or left if the magnifier is close to
+ // the edge.
+ root.removeAttribute("left");
+ root.removeAttribute("right");
+ if (x <= MAGNIFIER_WIDTH) {
+ root.setAttribute("right", "");
+ } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) {
+ root.setAttribute("left", "");
+ }
+ }
+
+ /**
+ * Select the current color that's being previewed. Depending on the current options,
+ * selecting might mean copying to the clipboard and closing the
+ */
+ selectColor() {
+ let onColorSelected = Promise.resolve();
+ if (this.options.copyOnSelect) {
+ onColorSelected = this.copyColor();
+ }
+
+ this.emit("selected", toColorString(this.centerColor, this.format));
+ onColorSelected.then(() => this.hide(), console.error);
+ }
+
+ /**
+ * Handler for the keydown event. Either select the color or move the panel in a
+ * direction depending on the key pressed.
+ */
+ handleKeyDown(e) {
+ // Bail out early if any unsupported modifier is used, so that we let
+ // keyboard shortcuts through.
+ if (e.metaKey || e.ctrlKey || e.altKey) {
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_RETURN) {
+ this.selectColor();
+ e.preventDefault();
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_ESCAPE) {
+ this.emit("canceled");
+ this.hide();
+ e.preventDefault();
+ return;
+ }
+
+ let offsetX = 0;
+ let offsetY = 0;
+ let modifier = 1;
+
+ if (e.keyCode === e.DOM_VK_LEFT) {
+ offsetX = -1;
+ } else if (e.keyCode === e.DOM_VK_RIGHT) {
+ offsetX = 1;
+ } else if (e.keyCode === e.DOM_VK_UP) {
+ offsetY = -1;
+ } else if (e.keyCode === e.DOM_VK_DOWN) {
+ offsetY = 1;
+ }
+
+ if (e.shiftKey) {
+ modifier = 10;
+ }
+
+ offsetY *= modifier;
+ offsetX *= modifier;
+
+ if (offsetX !== 0 || offsetY !== 0) {
+ this.magnifiedArea.x = cap(
+ this.magnifiedArea.x + offsetX,
+ 0,
+ this.win.innerWidth * this.pageZoom
+ );
+ this.magnifiedArea.y = cap(
+ this.magnifiedArea.y + offsetY,
+ 0,
+ this.win.innerHeight * this.pageZoom
+ );
+
+ this.draw();
+
+ this.moveTo(
+ this.magnifiedArea.x / this.pageZoom,
+ this.magnifiedArea.y / this.pageZoom
+ );
+
+ e.preventDefault();
+ }
+ }
+
+ /**
+ * Copy the currently inspected color to the clipboard.
+ * @return {Promise} Resolves when the copy has been done (after a delay that is used to
+ * let users know that something was copied).
+ */
+ copyColor() {
+ // Copy to the clipboard.
+ const color = toColorString(this.centerColor, this.format);
+ clipboardHelper.copyString(color);
+
+ // Provide some feedback.
+ this.getElement("color-value").setTextContent(
+ "✓ " + l10n.GetStringFromName("colorValue.copied")
+ );
+
+ // Hide the tool after a delay.
+ clearTimeout(this._copyTimeout);
+ return new Promise(resolve => {
+ this._copyTimeout = setTimeout(resolve, CLOSE_DELAY);
+ });
+ }
+}
+
+exports.EyeDropper = EyeDropper;
+
+/**
+ * Draw the visible portion of the window on a canvas and get the resulting ImageData.
+ * @param {Window} win
+ * @return {ImageData} The image data for the window.
+ */
+function getWindowAsImageData(win) {
+ const canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ const scale = getCurrentZoom(win);
+ const width = win.innerWidth;
+ const height = win.innerHeight;
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+ canvas.mozOpaque = true;
+
+ const ctx = canvas.getContext("2d");
+
+ ctx.scale(scale, scale);
+ ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff");
+
+ return ctx.getImageData(0, 0, canvas.width, canvas.height);
+}
+
+/**
+ * Get a formatted CSS color string from a color value.
+ * @param {array} rgb Rgb values of a color to format.
+ * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name".
+ * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)".
+ */
+function toColorString(rgb, format) {
+ const [r, g, b] = rgb;
+
+ switch (format) {
+ case "hex":
+ return hexString(rgb);
+ case "rgb":
+ return "rgb(" + r + ", " + g + ", " + b + ")";
+ case "hsl":
+ const [h, s, l] = rgbToHsl(rgb);
+ return "hsl(" + h + ", " + s + "%, " + l + "%)";
+ case "name":
+ const str = InspectorUtils.rgbToColorName(r, g, b) || hexString(rgb);
+ return str;
+ default:
+ return hexString(rgb);
+ }
+}
+
+/**
+ * Produce a hex-formatted color string from rgb values.
+ * @param {array} rgb Rgb values of color to stringify.
+ * @return {string} Hex formatted string for color, e.g. "#FFEE00".
+ */
+function hexString([r, g, b]) {
+ const val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
+ return "#" + val.toString(16).substr(-6);
+}
+
+function cap(value, min, max) {
+ return Math.max(min, Math.min(value, max));
+}
diff --git a/devtools/server/actors/highlighters/flexbox.js b/devtools/server/actors/highlighters/flexbox.js
new file mode 100644
index 0000000000..820e4f8a73
--- /dev/null
+++ b/devtools/server/actors/highlighters/flexbox.js
@@ -0,0 +1,1033 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const { apply } = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ CANVAS_SIZE,
+ DEFAULT_COLOR,
+ clearRect,
+ drawLine,
+ drawRect,
+ getCurrentMatrix,
+ updateCanvasElement,
+ updateCanvasPosition,
+} = require("resource://devtools/server/actors/highlighters/utils/canvas.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ getAbsoluteScrollOffsetsForNode,
+ getCurrentZoom,
+ getDisplayPixelRatio,
+ getUntransformedQuad,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+
+const FLEXBOX_LINES_PROPERTIES = {
+ edge: {
+ lineDash: [5, 3],
+ },
+ item: {
+ lineDash: [0, 0],
+ },
+ alignItems: {
+ lineDash: [0, 0],
+ },
+};
+
+const FLEXBOX_CONTAINER_PATTERN_LINE_DASH = [5, 3]; // px
+const FLEXBOX_CONTAINER_PATTERN_WIDTH = 14; // px
+const FLEXBOX_CONTAINER_PATTERN_HEIGHT = 14; // px
+const FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH = 7; // px
+const FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT = 7; // px
+
+/**
+ * Cached used by `FlexboxHighlighter.getFlexContainerPattern`.
+ */
+const gCachedFlexboxPattern = new Map();
+
+const FLEXBOX = "flexbox";
+const JUSTIFY_CONTENT = "justify-content";
+
+/**
+ * The FlexboxHighlighter is the class that overlays a visual canvas on top of
+ * display: [inline-]flex elements.
+ *
+ * @param {String} options.color
+ * The color that should be used to draw the highlighter for this flexbox.
+ * Structure:
+ * <div class="highlighter-container">
+ * <div id="flexbox-root" class="flexbox-root">
+ * <canvas id="flexbox-canvas"
+ * class="flexbox-canvas"
+ * width="4096"
+ * height="4096"
+ * hidden="true">
+ * </canvas>
+ * </div>
+ * </div>
+ */
+class FlexboxHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "flexbox-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+
+ // Initialize the <canvas> position to the top left corner of the page
+ this._canvasPosition = {
+ x: 0,
+ y: 0,
+ };
+
+ this._ignoreZoom = true;
+
+ // Calling `updateCanvasPosition` anyway since the highlighter could be initialized
+ // on a page that has scrolled already.
+ updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // We use a <canvas> element because there is an arbitrary number of items and texts
+ // to draw which wouldn't be possible with HTML or SVG without having to insert and
+ // remove the whole markup on every update.
+ this.markup.createNode({
+ parent: root,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ hidden: "true",
+ width: CANVAS_SIZE,
+ height: CANVAS_SIZE,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ clearCache() {
+ gCachedFlexboxPattern.clear();
+ }
+
+ destroy() {
+ const { highlighterEnv } = this;
+ highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ // Clear the pattern cache to avoid dead object exceptions (Bug 1342051).
+ this.clearCache();
+
+ this.axes = null;
+ this.crossAxisDirection = null;
+ this.flexData = null;
+ this.mainAxisDirection = null;
+ this.transform = null;
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ /**
+ * Draw the justify content for a given flex item (left, top, right, bottom) position.
+ */
+ drawJustifyContent(left, top, right, bottom) {
+ const { devicePixelRatio } = this.win;
+ this.ctx.fillStyle = this.getJustifyContentPattern(devicePixelRatio);
+ drawRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ this.ctx.fill();
+ }
+
+ get canvas() {
+ return this.getElement("canvas");
+ }
+
+ get color() {
+ return this.options.color || DEFAULT_COLOR;
+ }
+
+ get container() {
+ return this.currentNode;
+ }
+
+ get ctx() {
+ return this.canvas.getCanvasContext("2d");
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Gets the flexbox container pattern used to render the container regions.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @return {CanvasPattern} flex container pattern.
+ */
+ getFlexContainerPattern(devicePixelRatio) {
+ let flexboxPatternMap = null;
+
+ if (gCachedFlexboxPattern.has(devicePixelRatio)) {
+ flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio);
+ } else {
+ flexboxPatternMap = new Map();
+ }
+
+ if (gCachedFlexboxPattern.has(FLEXBOX)) {
+ return gCachedFlexboxPattern.get(FLEXBOX);
+ }
+
+ // Create the diagonal lines pattern for the rendering the flexbox gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const width = (canvas.width =
+ FLEXBOX_CONTAINER_PATTERN_WIDTH * devicePixelRatio);
+ const height = (canvas.height =
+ FLEXBOX_CONTAINER_PATTERN_HEIGHT * devicePixelRatio);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ ctx.moveTo(0, 0);
+ ctx.lineTo(width, height);
+
+ ctx.strokeStyle = this.color;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+ flexboxPatternMap.set(FLEXBOX, pattern);
+ gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap);
+
+ return pattern;
+ }
+
+ /**
+ * Gets the flexbox justify content pattern used to render the justify content regions.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @return {CanvasPattern} flex justify content pattern.
+ */
+ getJustifyContentPattern(devicePixelRatio) {
+ let flexboxPatternMap = null;
+
+ if (gCachedFlexboxPattern.has(devicePixelRatio)) {
+ flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio);
+ } else {
+ flexboxPatternMap = new Map();
+ }
+
+ if (flexboxPatternMap.has(JUSTIFY_CONTENT)) {
+ return flexboxPatternMap.get(JUSTIFY_CONTENT);
+ }
+
+ // Create the inversed diagonal lines pattern
+ // for the rendering the justify content gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const zoom = getCurrentZoom(this.win);
+ const width = (canvas.width =
+ FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH * devicePixelRatio * zoom);
+ const height = (canvas.height =
+ FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT * devicePixelRatio * zoom);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ ctx.moveTo(0, height);
+ ctx.lineTo(width, 0);
+
+ ctx.strokeStyle = this.color;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+ flexboxPatternMap.set(JUSTIFY_CONTENT, pattern);
+ gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap);
+
+ return pattern;
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the
+ * element's quads have changed. Override it so it also returns true if the
+ * flex container and its flex items have changed.
+ */
+ _hasMoved() {
+ const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ if (!this.computedStyle) {
+ this.computedStyle = getComputedStyle(this.container);
+ }
+
+ const flex = this.container.getAsFlexContainer();
+
+ const oldCrossAxisDirection = this.crossAxisDirection;
+ this.crossAxisDirection = flex ? flex.crossAxisDirection : null;
+ const newCrossAxisDirection = this.crossAxisDirection;
+
+ const oldMainAxisDirection = this.mainAxisDirection;
+ this.mainAxisDirection = flex ? flex.mainAxisDirection : null;
+ const newMainAxisDirection = this.mainAxisDirection;
+
+ // Concatenate the axes to simplify conditionals.
+ this.axes = `${this.mainAxisDirection} ${this.crossAxisDirection}`;
+
+ const oldFlexData = this.flexData;
+ this.flexData = getFlexData(this.container);
+ const hasFlexDataChanged = compareFlexData(oldFlexData, this.flexData);
+
+ const oldAlignItems = this.alignItemsValue;
+ this.alignItemsValue = this.computedStyle.alignItems;
+ const newAlignItems = this.alignItemsValue;
+
+ const oldFlexDirection = this.flexDirection;
+ this.flexDirection = this.computedStyle.flexDirection;
+ const newFlexDirection = this.flexDirection;
+
+ const oldFlexWrap = this.flexWrap;
+ this.flexWrap = this.computedStyle.flexWrap;
+ const newFlexWrap = this.flexWrap;
+
+ const oldJustifyContent = this.justifyContentValue;
+ this.justifyContentValue = this.computedStyle.justifyContent;
+ const newJustifyContent = this.justifyContentValue;
+
+ const oldTransform = this.transformValue;
+ this.transformValue = this.computedStyle.transform;
+ const newTransform = this.transformValue;
+
+ return (
+ hasMoved ||
+ hasFlexDataChanged ||
+ oldAlignItems !== newAlignItems ||
+ oldFlexDirection !== newFlexDirection ||
+ oldFlexWrap !== newFlexWrap ||
+ oldJustifyContent !== newJustifyContent ||
+ oldCrossAxisDirection !== newCrossAxisDirection ||
+ oldMainAxisDirection !== newMainAxisDirection ||
+ oldTransform !== newTransform
+ );
+ }
+
+ _hide() {
+ this.alignItemsValue = null;
+ this.computedStyle = null;
+ this.flexData = null;
+ this.flexDirection = null;
+ this.flexWrap = null;
+ this.justifyContentValue = null;
+
+ setIgnoreLayoutChanges(true);
+ this._hideFlexbox();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ _hideFlexbox() {
+ this.getElement("canvas").setAttribute("hidden", "true");
+ }
+
+ /**
+ * The <canvas>'s position needs to be updated if the page scrolls too much, in order
+ * to give the illusion that it always covers the viewport.
+ */
+ _scrollUpdate() {
+ const hasUpdated = updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+
+ if (hasUpdated) {
+ this._update();
+ }
+ }
+
+ _show() {
+ this._hide();
+ return this._update();
+ }
+
+ _showFlexbox() {
+ this.getElement("canvas").removeAttribute("hidden");
+ }
+
+ /**
+ * If a page hide event is triggered for current window's highlighter, hide the
+ * highlighter.
+ */
+ onPageHide({ target }) {
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Called when the page will-navigate. Used to hide the flexbox highlighter and clear
+ * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the
+ * next time.
+ */
+ onWillNavigate({ isTopLevel }) {
+ this.clearCache();
+
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+
+ renderFlexContainer() {
+ if (!this.currentQuads.content || !this.currentQuads.content[0]) {
+ return;
+ }
+
+ const { devicePixelRatio } = this.win;
+ const containerQuad = getUntransformedQuad(this.container, "content");
+ const { width, height } = containerQuad.getBounds();
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash,
+ lineWidthMultiplier: 2,
+ });
+
+ this.ctx.fillStyle = this.getFlexContainerPattern(devicePixelRatio);
+
+ drawRect(this.ctx, 0, 0, width, height, this.currentMatrix);
+
+ // Find current angle of outer flex element by measuring the angle of two arbitrary
+ // points, then rotate canvas, so the hash pattern stays 45deg to the boundary.
+ const p1 = apply(this.currentMatrix, [0, 0]);
+ const p2 = apply(this.currentMatrix, [1, 0]);
+ const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
+ this.ctx.rotate(angleRad);
+
+ this.ctx.fill();
+ this.ctx.stroke();
+ this.ctx.restore();
+ }
+
+ renderFlexItems() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.item.lineDash,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ for (const flexItem of flexLine.items) {
+ const { left, top, right, bottom } = flexItem.rect;
+
+ clearRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ drawRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ this.ctx.stroke();
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ renderFlexLines() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const options = { matrix: this.currentMatrix };
+ const { width: containerWidth, height: containerHeight } =
+ getUntransformedQuad(this.container, "content").getBounds();
+
+ this.setupCanvas({
+ useContainerScrollOffsets: true,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ const { crossStart, crossSize } = flexLine;
+
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ case "horizontal-rl vertical-bt":
+ clearRect(
+ this.ctx,
+ 0,
+ crossStart,
+ containerWidth,
+ crossStart + crossSize,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ 0,
+ crossStart,
+ containerWidth,
+ crossStart,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerHeight - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ 0,
+ crossStart + crossSize,
+ containerWidth,
+ crossStart + crossSize,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ clearRect(
+ this.ctx,
+ crossStart,
+ 0,
+ crossStart + crossSize,
+ containerHeight,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ crossStart,
+ 0,
+ crossStart,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerWidth - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ crossStart + crossSize,
+ 0,
+ crossStart + crossSize,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ clearRect(
+ this.ctx,
+ containerWidth - crossStart,
+ 0,
+ containerWidth - crossStart - crossSize,
+ containerHeight,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ containerWidth - crossStart,
+ 0,
+ containerWidth - crossStart,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerWidth - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ containerWidth - crossStart - crossSize,
+ 0,
+ containerWidth - crossStart - crossSize,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Clear the whole alignment container along the main axis for each flex item.
+ */
+ // eslint-disable-next-line complexity
+ renderJustifyContent() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ const { width: containerWidth, height: containerHeight } =
+ getUntransformedQuad(this.container, "content").getBounds();
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash,
+ offset: (getDisplayPixelRatio(this.win) / 2) % 1,
+ skipLineAndStroke: true,
+ useContainerScrollOffsets: true,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ const { crossStart, crossSize } = flexLine;
+ let mainStart = 0;
+
+ // In these two situations mainStart goes from right to left so set it's
+ // value as appropriate.
+ if (
+ this.axes === "horizontal-lr vertical-bt" ||
+ this.axes === "horizontal-rl vertical-tb"
+ ) {
+ mainStart = containerWidth;
+ }
+
+ for (const flexItem of flexLine.items) {
+ const { left, top, right, bottom } = flexItem.rect;
+
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-rl vertical-bt":
+ this.drawJustifyContent(
+ mainStart,
+ crossStart,
+ left,
+ crossStart + crossSize
+ );
+ mainStart = right;
+ break;
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ this.drawJustifyContent(
+ right,
+ crossStart,
+ mainStart,
+ crossStart + crossSize
+ );
+ mainStart = left;
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ this.drawJustifyContent(
+ crossStart,
+ mainStart,
+ crossStart + crossSize,
+ top
+ );
+ mainStart = bottom;
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ this.drawJustifyContent(
+ containerWidth - crossStart - crossSize,
+ mainStart,
+ containerWidth - crossStart,
+ top
+ );
+ mainStart = bottom;
+ break;
+ }
+ }
+
+ // Draw the last justify-content area after the last flex item.
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-rl vertical-bt":
+ this.drawJustifyContent(
+ mainStart,
+ crossStart,
+ containerWidth,
+ crossStart + crossSize
+ );
+ break;
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ this.drawJustifyContent(
+ 0,
+ crossStart,
+ mainStart,
+ crossStart + crossSize
+ );
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ this.drawJustifyContent(
+ crossStart,
+ mainStart,
+ crossStart + crossSize,
+ containerHeight
+ );
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ this.drawJustifyContent(
+ containerWidth - crossStart - crossSize,
+ mainStart,
+ containerWidth - crossStart,
+ containerHeight
+ );
+ break;
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Set up the canvas with the given options prior to drawing.
+ *
+ * @param {String} [options.lineDash = null]
+ * An Array of numbers that specify distances to alternately draw a
+ * line and a gap (in coordinate space units). If the number of
+ * elements in the array is odd, the elements of the array get copied
+ * and concatenated. For example, [5, 15, 25] will become
+ * [5, 15, 25, 5, 15, 25]. If the array is empty, the line dash list is
+ * cleared and line strokes return to being solid.
+ *
+ * We use the following constants here:
+ * FLEXBOX_LINES_PROPERTIES.edge.lineDash,
+ * FLEXBOX_LINES_PROPERTIES.item.lineDash
+ * FLEXBOX_LINES_PROPERTIES.alignItems.lineDash
+ * @param {Number} [options.lineWidthMultiplier = 1]
+ * The width of the line.
+ * @param {Number} [options.offset = `(displayPixelRatio / 2) % 1`]
+ * The single line width used to obtain a crisp line.
+ * @param {Boolean} [options.skipLineAndStroke = false]
+ * Skip the setting of lineWidth and strokeStyle.
+ * @param {Boolean} [options.useContainerScrollOffsets = false]
+ * Take the flexbox container's scroll and zoom offsets into account.
+ * This is needed for drawing flex lines and justify content when the
+ * flexbox container itself is display:scroll.
+ */
+ setupCanvas({
+ lineDash = null,
+ lineWidthMultiplier = 1,
+ offset = (getDisplayPixelRatio(this.win) / 2) % 1,
+ skipLineAndStroke = false,
+ useContainerScrollOffsets = false,
+ }) {
+ const { devicePixelRatio } = this.win;
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const zoom = getCurrentZoom(this.win);
+ const style = getComputedStyle(this.container);
+ const position = style.position;
+ let offsetX = this._canvasPosition.x;
+ let offsetY = this._canvasPosition.y;
+
+ if (useContainerScrollOffsets) {
+ offsetX += this.container.scrollLeft / zoom;
+ offsetY += this.container.scrollTop / zoom;
+ }
+
+ // If the flexbox container is position:fixed we need to subtract the scroll
+ // positions of all ancestral elements.
+ if (position === "fixed") {
+ const { scrollLeft, scrollTop } = getAbsoluteScrollOffsetsForNode(
+ this.container
+ );
+ offsetX -= scrollLeft / zoom;
+ offsetY -= scrollTop / zoom;
+ }
+
+ const canvasX = Math.round(offsetX * devicePixelRatio * zoom);
+ const canvasY = Math.round(offsetY * devicePixelRatio * zoom);
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ if (lineDash) {
+ this.ctx.setLineDash(lineDash);
+ }
+
+ if (!skipLineAndStroke) {
+ this.ctx.lineWidth = lineWidth * lineWidthMultiplier;
+ this.ctx.strokeStyle = this.color;
+ }
+ }
+
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ const root = this.getElement("root");
+
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ root.setAttribute("style", "display: none");
+ this.win.document.documentElement.offsetWidth;
+ this._winDimensions = getWindowDimensions(this.win);
+ const { width, height } = this._winDimensions;
+
+ // Updates the <canvas> element's position and size.
+ // It also clear the <canvas>'s drawing context.
+ updateCanvasElement(
+ this.canvas,
+ this._canvasPosition,
+ this.win.devicePixelRatio,
+ {
+ zoomWindow: this.win,
+ }
+ );
+
+ // Update the current matrix used in our canvas' rendering
+ const { currentMatrix, hasNodeTransformations } = getCurrentMatrix(
+ this.container,
+ this.win,
+ {
+ ignoreWritingModeAndTextDirection: true,
+ }
+ );
+ this.currentMatrix = currentMatrix;
+ this.hasNodeTransformations = hasNodeTransformations;
+
+ if (this.prevColor != this.color) {
+ this.clearCache();
+ }
+ this.renderFlexContainer();
+ this.renderFlexLines();
+ this.renderJustifyContent();
+ this.renderFlexItems();
+ this._showFlexbox();
+ this.prevColor = this.color;
+
+ root.setAttribute(
+ "style",
+ `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden`
+ );
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+}
+
+/**
+ * Returns an object representation of the Flex data object and its array of FlexLine
+ * and FlexItem objects along with the DOMRects of the flex items.
+ *
+ * @param {DOMNode} container
+ * The flex container.
+ * @return {Object|null} representation of the Flex data object.
+ */
+function getFlexData(container) {
+ const flex = container.getAsFlexContainer();
+
+ if (!flex) {
+ return null;
+ }
+
+ return {
+ lines: flex.getLines().map(line => {
+ return {
+ crossSize: line.crossSize,
+ crossStart: line.crossStart,
+ firstBaselineOffset: line.firstBaselineOffset,
+ growthState: line.growthState,
+ lastBaselineOffset: line.lastBaselineOffset,
+ items: line.getItems().map(item => {
+ return {
+ crossMaxSize: item.crossMaxSize,
+ crossMinSize: item.crossMinSize,
+ mainBaseSize: item.mainBaseSize,
+ mainDeltaSize: item.mainDeltaSize,
+ mainMaxSize: item.mainMaxSize,
+ mainMinSize: item.mainMinSize,
+ node: item.node,
+ rect: getRectFromFlexItemValues(item, container),
+ };
+ }),
+ };
+ }),
+ };
+}
+
+/**
+ * Given a FlexItemValues, return a DOMRect representing the flex item taking
+ * into account its flex container's border and padding.
+ *
+ * @param {FlexItemValues} item
+ * The FlexItemValues for which we need the DOMRect.
+ * @param {DOMNode}
+ * Flex container containing the flex item.
+ * @return {DOMRect} representing the flex item.
+ */
+function getRectFromFlexItemValues(item, container) {
+ const rect = item.frameRect;
+ const domRect = new DOMRect(rect.x, rect.y, rect.width, rect.height);
+ const win = container.ownerGlobal;
+ const style = win.getComputedStyle(container);
+ const borderLeftWidth = parseInt(style.borderLeftWidth, 10) || 0;
+ const borderTopWidth = parseInt(style.borderTopWidth, 10) || 0;
+ const paddingLeft = parseInt(style.paddingLeft, 10) || 0;
+ const paddingTop = parseInt(style.paddingTop, 10) || 0;
+ const scrollX = container.scrollLeft || 0;
+ const scrollY = container.scrollTop || 0;
+
+ domRect.x -= paddingLeft + scrollX;
+ domRect.y -= paddingTop + scrollY;
+
+ if (style.overflow === "visible" || style.overflow === "clip") {
+ domRect.x -= borderLeftWidth;
+ domRect.y -= borderTopWidth;
+ }
+
+ return domRect;
+}
+
+/**
+ * Returns whether or not the flex data has changed.
+ *
+ * @param {Flex} oldFlexData
+ * The old Flex data object.
+ * @param {Flex} newFlexData
+ * The new Flex data object.
+ * @return {Boolean} true if the flex data has changed and false otherwise.
+ */
+// eslint-disable-next-line complexity
+function compareFlexData(oldFlexData, newFlexData) {
+ if (!oldFlexData || !newFlexData) {
+ return true;
+ }
+
+ const oldLines = oldFlexData.lines;
+ const newLines = newFlexData.lines;
+
+ if (oldLines.length !== newLines.length) {
+ return true;
+ }
+
+ for (let i = 0; i < oldLines.length; i++) {
+ const oldLine = oldLines[i];
+ const newLine = newLines[i];
+
+ if (
+ oldLine.crossSize !== newLine.crossSize ||
+ oldLine.crossStart !== newLine.crossStart ||
+ oldLine.firstBaselineOffset !== newLine.firstBaselineOffset ||
+ oldLine.growthState !== newLine.growthState ||
+ oldLine.lastBaselineOffset !== newLine.lastBaselineOffset
+ ) {
+ return true;
+ }
+
+ const oldItems = oldLine.items;
+ const newItems = newLine.items;
+
+ if (oldItems.length !== newItems.length) {
+ return true;
+ }
+
+ for (let j = 0; j < oldItems.length; j++) {
+ const oldItem = oldItems[j];
+ const newItem = newItems[j];
+
+ if (
+ oldItem.crossMaxSize !== newItem.crossMaxSize ||
+ oldItem.crossMinSize !== newItem.crossMinSize ||
+ oldItem.mainBaseSize !== newItem.mainBaseSize ||
+ oldItem.mainDeltaSize !== newItem.mainDeltaSize ||
+ oldItem.mainMaxSize !== newItem.mainMaxSize ||
+ oldItem.mainMinSize !== newItem.mainMinSize
+ ) {
+ return true;
+ }
+
+ const oldItemRect = oldItem.rect;
+ const newItemRect = newItem.rect;
+
+ // We are using DOMRects so we only need to compare x, y, width and
+ // height (left, top, right and bottom are calculated from these values).
+ if (
+ oldItemRect.x !== newItemRect.x ||
+ oldItemRect.y !== newItemRect.y ||
+ oldItemRect.width !== newItemRect.width ||
+ oldItemRect.height !== newItemRect.height
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+exports.FlexboxHighlighter = FlexboxHighlighter;
diff --git a/devtools/server/actors/highlighters/fonts.js b/devtools/server/actors/highlighters/fonts.js
new file mode 100644
index 0000000000..0fe6b066c7
--- /dev/null
+++ b/devtools/server/actors/highlighters/fonts.js
@@ -0,0 +1,121 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "removeSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+// How many text runs are we highlighting at a time. There may be many text runs, and we
+// want to prevent performance problems.
+const MAX_TEXT_RANGES = 100;
+
+// This stylesheet is inserted into the page to customize the color of the selected text
+// runs.
+// Note that this color is defined as --highlighter-content-color in the highlighters.css
+// file, and corresponds to the box-model content color. We want to give it an opacity of
+// 0.6 here.
+const STYLESHEET_URI =
+ "data:text/css," +
+ encodeURIComponent(
+ "::selection{background-color:hsl(197,71%,73%,.6)!important;}"
+ );
+
+/**
+ * This highlighter highlights runs of text in the page that have been rendered given a
+ * certain font. The highlighting is done with window selection ranges, so no extra
+ * markup is being inserted into the content page.
+ */
+class FontsHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ }
+
+ destroy() {
+ this.hide();
+ this.env = this.currentNode = null;
+ }
+
+ get currentNodeDocument() {
+ if (!this.currentNode) {
+ return this.env.document;
+ }
+
+ if (this.currentNode.nodeType === this.currentNode.DOCUMENT_NODE) {
+ return this.currentNode;
+ }
+
+ return this.currentNode.ownerDocument;
+ }
+
+ /**
+ * Show the highlighter for a given node.
+ * @param {DOMNode} node The node in which we want to search for text runs.
+ * @param {Object} options A bunch of options that can be set:
+ * - {String} name The actual font name to look for in the node.
+ * - {String} CSSFamilyName The CSS font-family name given to this font.
+ */
+ show(node, options) {
+ this.currentNode = node;
+ const doc = this.currentNodeDocument;
+
+ // Get all of the fonts used to render content inside the node.
+ const searchRange = doc.createRange();
+ searchRange.selectNodeContents(node);
+
+ const fonts = InspectorUtils.getUsedFontFaces(searchRange, MAX_TEXT_RANGES);
+
+ // Find the ones we want, based on the provided option.
+ const matchingFonts = fonts.filter(
+ f => f.CSSFamilyName === options.CSSFamilyName && f.name === options.name
+ );
+ if (!matchingFonts.length) {
+ return;
+ }
+
+ // Load the stylesheet that will customize the color of the highlighter (using a
+ // ::selection rule).
+ loadSheet(this.env.window, STYLESHEET_URI);
+
+ // Create a multi-selection in the page to highlight the text runs.
+ const selection = doc.defaultView.getSelection();
+ selection.removeAllRanges();
+
+ for (const matchingFont of matchingFonts) {
+ for (const range of matchingFont.ranges) {
+ selection.addRange(range);
+ }
+ }
+ }
+
+ hide() {
+ // No node was highlighted before, don't need to continue any further.
+ if (!this.currentNode) {
+ return;
+ }
+
+ try {
+ removeSheet(this.env.window, STYLESHEET_URI);
+ } catch (e) {
+ // Silently fail here as we might not have inserted the stylesheet at all.
+ }
+
+ // Simply remove all current ranges in the seletion.
+ const doc = this.currentNodeDocument;
+ const selection = doc.defaultView.getSelection();
+ selection.removeAllRanges();
+ }
+}
+
+exports.FontsHighlighter = FontsHighlighter;
diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js
new file mode 100644
index 0000000000..ce8ec92bd5
--- /dev/null
+++ b/devtools/server/actors/highlighters/geometry-editor.js
@@ -0,0 +1,808 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getAdjustedQuads,
+ getCurrentZoom,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getCSSStyleRules,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const GEOMETRY_LABEL_SIZE = 6;
+
+// List of all DOM Events subscribed directly to the document from the
+// Geometry Editor highlighter
+const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"];
+
+const _dragging = Symbol("geometry/dragging");
+
+/**
+ * Element geometry properties helper that gives names of position and size
+ * properties.
+ */
+var GeoProp = {
+ SIDES: ["top", "right", "bottom", "left"],
+ SIZES: ["width", "height"],
+
+ allProps() {
+ return [...this.SIDES, ...this.SIZES];
+ },
+
+ isSide(name) {
+ return this.SIDES.includes(name);
+ },
+
+ isSize(name) {
+ return this.SIZES.includes(name);
+ },
+
+ containsSide(names) {
+ return names.some(name => this.SIDES.includes(name));
+ },
+
+ containsSize(names) {
+ return names.some(name => this.SIZES.includes(name));
+ },
+
+ isHorizontal(name) {
+ return name === "left" || name === "right" || name === "width";
+ },
+
+ isInverted(name) {
+ return name === "right" || name === "bottom";
+ },
+
+ mainAxisStart(name) {
+ return this.isHorizontal(name) ? "left" : "top";
+ },
+
+ crossAxisStart(name) {
+ return this.isHorizontal(name) ? "top" : "left";
+ },
+
+ mainAxisSize(name) {
+ return this.isHorizontal(name) ? "width" : "height";
+ },
+
+ crossAxisSize(name) {
+ return this.isHorizontal(name) ? "height" : "width";
+ },
+
+ axis(name) {
+ return this.isHorizontal(name) ? "x" : "y";
+ },
+
+ crossAxis(name) {
+ return this.isHorizontal(name) ? "y" : "x";
+ },
+};
+
+/**
+ * Get the provided node's offsetParent dimensions.
+ * Returns an object with the {parent, dimension} properties.
+ * Note that the returned parent will be null if the offsetParent is the
+ * default, non-positioned, body or html node.
+ *
+ * node.offsetParent returns the nearest positioned ancestor but if it is
+ * non-positioned itself, we just return null to let consumers know the node is
+ * actually positioned relative to the viewport.
+ *
+ * @return {Object}
+ */
+function getOffsetParent(node) {
+ const win = node.ownerGlobal;
+
+ let offsetParent = node.offsetParent;
+ if (offsetParent && getComputedStyle(offsetParent).position === "static") {
+ offsetParent = null;
+ }
+
+ let width, height;
+ if (!offsetParent) {
+ height = win.innerHeight;
+ width = win.innerWidth;
+ } else {
+ height = offsetParent.offsetHeight;
+ width = offsetParent.offsetWidth;
+ }
+
+ return {
+ element: offsetParent,
+ dimension: { width, height },
+ };
+}
+
+/**
+ * Get the list of geometry properties that are actually set on the provided
+ * node.
+ *
+ * @param {Node} node The node to analyze.
+ * @return {Map} A map indexed by property name and where the value is an
+ * object having the cssRule property.
+ */
+function getDefinedGeometryProperties(node) {
+ const props = new Map();
+ if (!node) {
+ return props;
+ }
+
+ // Get the list of css rules applying to the current node.
+ const cssRules = getCSSStyleRules(node);
+ for (let i = 0; i < cssRules.length; i++) {
+ const rule = cssRules[i];
+ for (const name of GeoProp.allProps()) {
+ const value = rule.style.getPropertyValue(name);
+ if (value && value !== "auto") {
+ // getCSSStyleRules returns rules ordered from least to most specific
+ // so just override any previous properties we have set.
+ props.set(name, {
+ cssRule: rule,
+ });
+ }
+ }
+ }
+
+ // Go through the inline styles last, only if the node supports inline style
+ // (e.g. pseudo elements don't have a style property)
+ if (node.style) {
+ for (const name of GeoProp.allProps()) {
+ const value = node.style.getPropertyValue(name);
+ if (value && value !== "auto") {
+ props.set(name, {
+ // There's no cssRule to store here, so store the node instead since
+ // node.style exists.
+ cssRule: node,
+ });
+ }
+ }
+ }
+
+ // Post-process the list for invalid properties. This is done after the fact
+ // because of cases like relative positioning with both top and bottom where
+ // only top will actually be used, but both exists in css rules and computed
+ // styles.
+ const { position } = getComputedStyle(node);
+ for (const [name] of props) {
+ // Top/left/bottom/right on static positioned elements have no effect.
+ if (position === "static" && GeoProp.SIDES.includes(name)) {
+ props.delete(name);
+ }
+
+ // Bottom/right on relative positioned elements are only used if top/left
+ // are not defined.
+ const hasRightAndLeft = name === "right" && props.has("left");
+ const hasBottomAndTop = name === "bottom" && props.has("top");
+ if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
+ props.delete(name);
+ }
+ }
+
+ return props;
+}
+exports.getDefinedGeometryProperties = getDefinedGeometryProperties;
+
+/**
+ * The GeometryEditor highlights an elements's top, left, bottom, right, width
+ * and height dimensions, when they are set.
+ *
+ * To determine if an element has a set size and position, the highlighter lists
+ * the CSS rules that apply to the element and checks for the top, left, bottom,
+ * right, width and height properties.
+ * The highlighter won't be shown if the element doesn't have any of these
+ * properties set, but will be shown when at least 1 property is defined.
+ *
+ * The highlighter displays lines and labels for each of the defined properties
+ * in and around the element (relative to the offset parent when one exists).
+ * The highlighter also highlights the element itself and its offset parent if
+ * there is one.
+ *
+ * Note that the class name contains the word Editor because the aim is for the
+ * handles to be draggable in content to make the geometry editable.
+ */
+class GeometryEditorHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "geometry-editor-";
+
+ // The list of element geometry properties that can be set.
+ this.definedProperties = new Map();
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.initialize();
+
+ const { pageListenerTarget } = this.highlighterEnv;
+
+ // Register the geometry editor instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+ }
+
+ async initialize() {
+ await this.markup.initialize();
+ // Register the mousedown event for each Geometry Editor's handler.
+ // Those events are automatically removed when the markup is destroyed.
+ const onMouseDown = this.handleEvent.bind(this);
+
+ for (const side of GeoProp.SIDES) {
+ this.getElement("handler-" + side).addEventListener(
+ "mousedown",
+ onMouseDown
+ );
+ }
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Offset parent node highlighter.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: svg,
+ attributes: {
+ class: "offset-parent",
+ id: "offset-parent",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Current node highlighter (margin box).
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: svg,
+ attributes: {
+ class: "current-node",
+ id: "current-node",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the 4 side arrows, handlers and labels.
+ for (const name of GeoProp.SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: "arrow " + name,
+ id: "arrow-" + name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "circle",
+ parent: svg,
+ attributes: {
+ class: "handler-" + name,
+ id: "handler-" + name,
+ r: "4",
+ "data-side": name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Labels are positioned by using a translated <g>. This group contains
+ // a path and text that are themselves positioned using another translated
+ // <g>. This is so that the label arrow points at the 0,0 coordinates of
+ // parent <g>.
+ const labelG = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ id: "label-" + name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const subG = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: labelG,
+ attributes: {
+ transform: GeoProp.isHorizontal(name)
+ ? "translate(-30 -30)"
+ : "translate(5 -10)",
+ },
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: subG,
+ attributes: {
+ class: "label-bubble",
+ d: GeoProp.isHorizontal(name)
+ ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z"
+ : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "text",
+ parent: subG,
+ attributes: {
+ class: "label-text",
+ id: "label-text-" + name,
+ x: GeoProp.isHorizontal(name) ? "30" : "35",
+ y: "10",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ return container;
+ }
+
+ destroy() {
+ // Avoiding exceptions if `destroy` is called multiple times; and / or the
+ // highlighter environment was already destroyed.
+ if (!this.highlighterEnv) {
+ return;
+ }
+
+ const { pageListenerTarget } = this.highlighterEnv;
+
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+
+ this.markup.destroy();
+ this.definedProperties.clear();
+ this.definedProperties = null;
+ this.offsetParent = null;
+ }
+
+ handleEvent(event, id) {
+ // No event handling if the highlighter is hidden
+ if (this.getElement("root").hasAttribute("hidden")) {
+ return;
+ }
+
+ const { target, type, pageX, pageY } = event;
+
+ switch (type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.destroy();
+ }
+
+ break;
+ case "mousedown":
+ // The mousedown event is intended only for the handler
+ if (!id) {
+ return;
+ }
+
+ const handlerSide = this.markup
+ .getElement(id)
+ .getAttribute("data-side");
+
+ if (handlerSide) {
+ const side = handlerSide;
+ const sideProp = this.definedProperties.get(side);
+
+ if (!sideProp) {
+ return;
+ }
+
+ let value = sideProp.cssRule.style.getPropertyValue(side);
+ const computedValue = this.computedStyle.getPropertyValue(side);
+
+ const [unit] = value.match(/[^\d]+$/) || [""];
+
+ value = parseFloat(value);
+
+ const ratio = value / parseFloat(computedValue) || 1;
+ const dir = GeoProp.isInverted(side) ? -1 : 1;
+
+ // Store all the initial values needed for drag & drop
+ this[_dragging] = {
+ side,
+ value,
+ unit,
+ x: pageX,
+ y: pageY,
+ inc: ratio * dir,
+ };
+
+ this.getElement("handler-" + side).classList.add("dragging");
+ }
+
+ this.getElement("root").setAttribute("dragging", "true");
+ break;
+ case "mouseup":
+ // If we're dragging, drop it.
+ if (this[_dragging]) {
+ const { side } = this[_dragging];
+ this.getElement("root").removeAttribute("dragging");
+ this.getElement("handler-" + side).classList.remove("dragging");
+ this[_dragging] = null;
+ }
+ break;
+ case "mousemove":
+ if (!this[_dragging]) {
+ return;
+ }
+
+ const { side, x, y, value, unit, inc } = this[_dragging];
+ const sideProps = this.definedProperties.get(side);
+
+ if (!sideProps) {
+ return;
+ }
+
+ const delta =
+ (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc;
+
+ // The inline style has usually the priority over any other CSS rule
+ // set in stylesheets. However, if a rule has `!important` keyword,
+ // it will override the inline style too. To ensure Geometry Editor
+ // will always update the element, we have to add `!important` as
+ // well.
+ this.currentNode.style.setProperty(
+ side,
+ value + delta + unit,
+ "important"
+ );
+
+ break;
+ }
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ _show() {
+ this.computedStyle = getComputedStyle(this.currentNode);
+ const pos = this.computedStyle.position;
+ // XXX: sticky positioning is ignored for now. To be implemented next.
+ if (pos === "sticky") {
+ this.hide();
+ return false;
+ }
+
+ const hasUpdated = this._update();
+ if (!hasUpdated) {
+ this.hide();
+ return false;
+ }
+
+ this.getElement("root").removeAttribute("hidden");
+
+ return true;
+ }
+
+ _update() {
+ // At each update, the position or/and size may have changed, so get the
+ // list of defined properties, and re-position the arrows and highlighters.
+ this.definedProperties = getDefinedGeometryProperties(this.currentNode);
+ // We need the zoom factor to fix the original position of the node
+ // as well as the arrows.
+ this.zoomFactor = getCurrentZoom(this.currentNode);
+
+ if (!this.definedProperties.size) {
+ console.warn("The element does not have editable geometry properties");
+ return false;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ // Update the highlighters and arrows.
+ this.updateOffsetParent();
+ this.updateCurrentNode();
+ this.updateArrows();
+
+ // Avoid zooming the arrows when content is zoomed.
+ const node = this.currentNode;
+ this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root");
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+
+ /**
+ * Update the offset parent rectangle.
+ * There are 3 different cases covered here:
+ * - the node is absolutely/fixed positioned, and an offsetParent is defined
+ * (i.e. it's not just positioned in the viewport): the offsetParent node
+ * is highlighted (i.e. the rectangle is shown),
+ * - the node is relatively positioned: the rectangle is shown where the node
+ * would originally have been (because that's where the relative positioning
+ * is calculated from),
+ * - the node has no offset parent at all: the offsetParent rectangle is
+ * hidden.
+ */
+ updateOffsetParent() {
+ // Get the offsetParent, if any.
+ this.offsetParent = getOffsetParent(this.currentNode);
+ // And the offsetParent quads.
+ this.parentQuads = getAdjustedQuads(
+ this.win,
+ this.offsetParent.element,
+ "padding"
+ );
+
+ const el = this.getElement("offset-parent");
+
+ const isPositioned =
+ this.computedStyle.position === "absolute" ||
+ this.computedStyle.position === "fixed";
+ const isRelative = this.computedStyle.position === "relative";
+ let isHighlighted = false;
+
+ if (this.offsetParent.element && isPositioned) {
+ const { p1, p2, p3, p4 } = this.parentQuads[0];
+ const points =
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ p4.x +
+ "," +
+ p4.y;
+ el.setAttribute("points", points);
+ isHighlighted = true;
+ } else if (isRelative) {
+ const xDelta = parseFloat(this.computedStyle.left) * this.zoomFactor;
+ const yDelta = parseFloat(this.computedStyle.top) * this.zoomFactor;
+ if (xDelta || yDelta) {
+ const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
+ const points =
+ p1.x -
+ xDelta +
+ "," +
+ (p1.y - yDelta) +
+ " " +
+ (p2.x - xDelta) +
+ "," +
+ (p2.y - yDelta) +
+ " " +
+ (p3.x - xDelta) +
+ "," +
+ (p3.y - yDelta) +
+ " " +
+ (p4.x - xDelta) +
+ "," +
+ (p4.y - yDelta);
+ el.setAttribute("points", points);
+ isHighlighted = true;
+ }
+ }
+
+ if (isHighlighted) {
+ el.removeAttribute("hidden");
+ } else {
+ el.setAttribute("hidden", "true");
+ }
+ }
+
+ updateCurrentNode() {
+ const box = this.getElement("current-node");
+ const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
+ const attr =
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ p4.x +
+ "," +
+ p4.y;
+ box.setAttribute("points", attr);
+ box.removeAttribute("hidden");
+ }
+
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("root").setAttribute("hidden", "true");
+ this.getElement("current-node").setAttribute("hidden", "true");
+ this.getElement("offset-parent").setAttribute("hidden", "true");
+ this.hideArrows();
+
+ this.definedProperties.clear();
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ hideArrows() {
+ for (const side of GeoProp.SIDES) {
+ this.getElement("arrow-" + side).setAttribute("hidden", "true");
+ this.getElement("label-" + side).setAttribute("hidden", "true");
+ this.getElement("handler-" + side).setAttribute("hidden", "true");
+ }
+ }
+
+ updateArrows() {
+ this.hideArrows();
+
+ // Position arrows always end at the node's margin box.
+ const marginBox = this.currentQuads.margin[0].bounds;
+
+ // Position the side arrows which need to be visible.
+ // Arrows always start at the offsetParent edge, and end at the middle
+ // position of the node's margin edge.
+ // Note that for relative positioning, the offsetParent is considered to be
+ // the node itself, where it would have been originally.
+ // +------------------+----------------+
+ // | offsetparent | top |
+ // | or viewport | |
+ // | +--------+--------+ |
+ // | | node | |
+ // +---------+ +-------+
+ // | left | | right |
+ // | +--------+--------+ |
+ // | | bottom |
+ // +------------------+----------------+
+ const getSideArrowStartPos = side => {
+ // In case of relative positioning.
+ if (this.computedStyle.position === "relative") {
+ if (GeoProp.isInverted(side)) {
+ return (
+ marginBox[side] +
+ parseFloat(this.computedStyle[side]) * this.zoomFactor
+ );
+ }
+ return (
+ marginBox[side] -
+ parseFloat(this.computedStyle[side]) * this.zoomFactor
+ );
+ }
+
+ // In case an offsetParent exists and is highlighted.
+ if (this.parentQuads && this.parentQuads.length) {
+ return this.parentQuads[0].bounds[side];
+ }
+
+ // In case the element is positioned in the viewport.
+ if (GeoProp.isInverted(side)) {
+ return this.offsetParent.dimension[GeoProp.mainAxisSize(side)];
+ }
+ return (
+ -1 *
+ this.currentNode.ownerGlobal[
+ "scroll" + GeoProp.axis(side).toUpperCase()
+ ]
+ );
+ };
+
+ for (const side of GeoProp.SIDES) {
+ const sideProp = this.definedProperties.get(side);
+ if (!sideProp) {
+ continue;
+ }
+
+ const mainAxisStartPos = getSideArrowStartPos(side);
+ const mainAxisEndPos = marginBox[side];
+ const crossAxisPos =
+ marginBox[GeoProp.crossAxisStart(side)] +
+ marginBox[GeoProp.crossAxisSize(side)] / 2;
+
+ this.updateArrow(
+ side,
+ mainAxisStartPos,
+ mainAxisEndPos,
+ crossAxisPos,
+ sideProp.cssRule.style.getPropertyValue(side)
+ );
+ }
+ }
+
+ updateArrow(side, mainStart, mainEnd, crossPos, labelValue) {
+ const arrowEl = this.getElement("arrow-" + side);
+ const labelEl = this.getElement("label-" + side);
+ const labelTextEl = this.getElement("label-text-" + side);
+ const handlerEl = this.getElement("handler-" + side);
+
+ // Position the arrow <line>.
+ arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart);
+ arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos);
+ arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd);
+ arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos);
+ arrowEl.removeAttribute("hidden");
+
+ handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd);
+ handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos);
+ handlerEl.removeAttribute("hidden");
+
+ // Position the label <text> in the middle of the arrow (making sure it's
+ // not hidden below the fold).
+ const capitalize = str => str[0].toUpperCase() + str.substring(1);
+ const winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))];
+ let labelMain = mainStart + (mainEnd - mainStart) / 2;
+ if (
+ (mainStart > 0 && mainStart < winMain) ||
+ (mainEnd > 0 && mainEnd < winMain)
+ ) {
+ if (labelMain < GEOMETRY_LABEL_SIZE) {
+ labelMain = GEOMETRY_LABEL_SIZE;
+ } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) {
+ labelMain = winMain - GEOMETRY_LABEL_SIZE;
+ }
+ }
+ const labelCross = crossPos;
+ labelEl.setAttribute(
+ "transform",
+ GeoProp.isHorizontal(side)
+ ? "translate(" + labelMain + " " + labelCross + ")"
+ : "translate(" + labelCross + " " + labelMain + ")"
+ );
+ labelEl.removeAttribute("hidden");
+ labelTextEl.setTextContent(labelValue);
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.GeometryEditorHighlighter = GeometryEditorHighlighter;
diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js
new file mode 100644
index 0000000000..41c3b3098f
--- /dev/null
+++ b/devtools/server/actors/highlighters/measuring-tool.js
@@ -0,0 +1,853 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getCurrentZoom,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// Hard coded value about the size of measuring tool label, in order to
+// position and flip it when is needed.
+const LABEL_SIZE_MARGIN = 8;
+const LABEL_SIZE_WIDTH = 80;
+const LABEL_SIZE_HEIGHT = 52;
+const LABEL_POS_MARGIN = 4;
+const LABEL_POS_WIDTH = 40;
+const LABEL_POS_HEIGHT = 34;
+const LABEL_TYPE_SIZE = "size";
+const LABEL_TYPE_POSITION = "position";
+
+// List of all DOM Events subscribed directly to the document from the
+// Measuring Tool highlighter
+const DOM_EVENTS = [
+ "mousedown",
+ "mousemove",
+ "mouseup",
+ "mouseleave",
+ "scroll",
+ "pagehide",
+ "keydown",
+ "keyup",
+];
+
+const SIDES = ["top", "right", "bottom", "left"];
+const HANDLERS = [...SIDES, "topleft", "topright", "bottomleft", "bottomright"];
+const HANDLER_SIZE = 6;
+const HIGHLIGHTED_HANDLER_CLASSNAME = "highlight";
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+/**
+ * The MeasuringToolHighlighter is used to measure distances in a content page.
+ * It allows users to click and drag with their mouse to draw an area whose
+ * dimensions will be displayed in a tooltip next to it.
+ * This allows users to measure distances between elements on a page.
+ */
+class MeasuringToolHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.rect = { x: 0, y: 0, w: 0, h: 0 };
+ this.mouseCoords = { x: 0, y: 0 };
+
+ const { pageListenerTarget } = highlighterEnv;
+
+ // Register the measuring tool instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+ }
+
+ ID_CLASS_PREFIX = "measuring-tool-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ class: "elements",
+ width: "100%",
+ height: "100%",
+ },
+ prefix,
+ });
+
+ for (const side of SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: `guide-${side}`,
+ id: `guide-${side}`,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-size",
+ class: "label-size",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-position",
+ class: "label-position",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ // Creating a <g> element in order to group all the paths below, that
+ // together represent the measuring tool; so that would be easier move them
+ // around
+ const g = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: "tool",
+ },
+ parent: svg,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "box-path",
+ class: "box-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "diagonal-path",
+ class: "diagonal-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ for (const handler of HANDLERS) {
+ this.markup.createSVGNode({
+ nodeType: "circle",
+ parent: g,
+ attributes: {
+ class: `handler-${handler}`,
+ id: `handler-${handler}`,
+ r: HANDLER_SIZE,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ return container;
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ const zoom = getCurrentZoom(window);
+
+ const { width, height } = getWindowDimensions(window);
+
+ const { rect } = this;
+
+ const isZoomChanged = zoom !== rect.zoom;
+
+ if (isZoomChanged) {
+ rect.zoom = zoom;
+ this.updateLabel();
+ }
+
+ const isDocumentSizeChanged =
+ width !== rect.documentWidth || height !== rect.documentHeight;
+
+ if (isDocumentSizeChanged) {
+ rect.documentWidth = width;
+ rect.documentHeight = height;
+ }
+
+ // If either the document's size or the zoom is changed since the last
+ // repaint, we update the tool's size as well.
+ if (isZoomChanged || isDocumentSizeChanged) {
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+
+ destroy() {
+ this.hide();
+
+ this._cancelUpdate();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("root").removeAttribute("hidden");
+
+ this._update();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.hideLabel(LABEL_TYPE_SIZE);
+ this.hideLabel(LABEL_TYPE_POSITION);
+
+ this.getElement("root").setAttribute("hidden", "true");
+
+ this._cancelUpdate();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ setSize(w, h) {
+ this.setRect(undefined, undefined, w, h);
+ }
+
+ setRect(x, y, w, h) {
+ const { rect } = this;
+
+ if (typeof x !== "undefined") {
+ rect.x = x;
+ }
+
+ if (typeof y !== "undefined") {
+ rect.y = y;
+ }
+
+ if (typeof w !== "undefined") {
+ rect.w = w;
+ }
+
+ if (typeof h !== "undefined") {
+ rect.h = h;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ if (this._dragging) {
+ this.updatePaths();
+ this.updateHandlers();
+ }
+
+ this.updateLabel();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ updatePaths() {
+ const { x, y, w, h } = this.rect;
+ const dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`;
+
+ // Adding correction to the line path, otherwise some pixels are drawn
+ // outside the main rectangle area.
+ const x1 = w > 0 ? 0.5 : 0;
+ const y1 = w < 0 && h < 0 ? -0.5 : 0;
+ const w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
+ const h1 = h + (h > 0 && w > 0 ? -0.5 : 0);
+
+ const linedir = `M${x1} ${y1} L${w1} ${h1}`;
+
+ this.getElement("box-path").setAttribute("d", dir);
+ this.getElement("diagonal-path").setAttribute("d", linedir);
+ this.getElement("tool").setAttribute("transform", `translate(${x},${y})`);
+ }
+
+ updateLabel(type) {
+ type = type || (this._dragging ? LABEL_TYPE_SIZE : LABEL_TYPE_POSITION);
+
+ const isSizeLabel = type === LABEL_TYPE_SIZE;
+
+ const label = this.getElement(`label-${type}`);
+
+ let origin = "top left";
+
+ const { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
+ const { x: mouseX, y: mouseY } = this.mouseCoords;
+ let { x, y, w, h, zoom } = this.rect;
+ const scale = 1 / zoom;
+
+ w = w || 0;
+ h = h || 0;
+ x = x || 0;
+ y = y || 0;
+ if (type === LABEL_TYPE_SIZE) {
+ x += w;
+ y += h;
+ } else {
+ x = mouseX;
+ y = mouseY;
+ }
+
+ let labelMargin, labelHeight, labelWidth;
+
+ if (isSizeLabel) {
+ labelMargin = LABEL_SIZE_MARGIN;
+ labelWidth = LABEL_SIZE_WIDTH;
+ labelHeight = LABEL_SIZE_HEIGHT;
+
+ const d = Math.hypot(w, h).toFixed(2);
+
+ label.setTextContent(`W: ${Math.abs(w)} px
+ H: ${Math.abs(h)} px
+ ↘: ${d}px`);
+ } else {
+ labelMargin = LABEL_POS_MARGIN;
+ labelWidth = LABEL_POS_WIDTH;
+ labelHeight = LABEL_POS_HEIGHT;
+
+ label.setTextContent(`${mouseX}
+ ${mouseY}`);
+ }
+
+ // Size used to position properly the label
+ const labelBoxWidth = (labelWidth + labelMargin) * scale;
+ const labelBoxHeight = (labelHeight + labelMargin) * scale;
+
+ const isGoingLeft = w < scrollX;
+ const isSizeGoingLeft = isSizeLabel && isGoingLeft;
+ const isExceedingLeftMargin = x - labelBoxWidth < scrollX;
+ const isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
+ const isExceedingTopMargin = y - labelBoxHeight < scrollY;
+ const isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY;
+
+ if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) {
+ x -= labelBoxWidth;
+ origin = "top right";
+ } else {
+ x += labelMargin * scale;
+ }
+
+ if (isSizeLabel) {
+ y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight;
+ } else {
+ y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale;
+ }
+
+ label.setAttribute(
+ "style",
+ `
+ width: ${labelWidth}px;
+ height: ${labelHeight}px;
+ transform-origin: ${origin};
+ transform: translate(${x}px,${y}px) scale(${scale})
+ `
+ );
+
+ if (!isSizeLabel) {
+ const labelSize = this.getElement("label-size");
+ const style = labelSize.getAttribute("style");
+
+ if (style) {
+ labelSize.setAttribute(
+ "style",
+ style.replace(/scale[^)]+\)/, `scale(${scale})`)
+ );
+ }
+ }
+ }
+
+ updateViewport() {
+ const { devicePixelRatio } = this.env.window;
+ const { documentWidth, documentHeight, zoom } = this.rect;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ const pixelRatio = devicePixelRatio / zoom;
+
+ // The "real" device pixel ratio is used to calculate the max stroke
+ // width we can actually assign: on retina, for instance, it would be 0.5,
+ // where on non high dpi monitor would be 1.
+ const minWidth = 1 / pixelRatio;
+ const strokeWidth = minWidth / zoom;
+
+ this.getElement("root").setAttribute(
+ "style",
+ `stroke-width:${strokeWidth};
+ width:${documentWidth}px;
+ height:${documentHeight}px;`
+ );
+ }
+
+ updateGuides() {
+ const { x, y, w, h } = this.rect;
+
+ let guide = this.getElement("guide-top");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y);
+
+ guide = this.getElement("guide-right");
+
+ guide.setAttribute("x1", x + w);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x + w);
+ guide.setAttribute("y2", "100%");
+
+ guide = this.getElement("guide-bottom");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y + h);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y + h);
+
+ guide = this.getElement("guide-left");
+
+ guide.setAttribute("x1", x);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x);
+ guide.setAttribute("y2", "100%");
+ }
+
+ setHandlerPosition(handler, x, y) {
+ const handlerElement = this.getElement(`handler-${handler}`);
+ handlerElement.setAttribute("cx", x);
+ handlerElement.setAttribute("cy", y);
+ }
+
+ updateHandlers() {
+ const { w, h } = this.rect;
+
+ this.setHandlerPosition("top", w / 2, 0);
+ this.setHandlerPosition("topright", w, 0);
+ this.setHandlerPosition("right", w, h / 2);
+ this.setHandlerPosition("bottomright", w, h);
+ this.setHandlerPosition("bottom", w / 2, h);
+ this.setHandlerPosition("bottomleft", 0, h);
+ this.setHandlerPosition("left", 0, h / 2);
+ this.setHandlerPosition("topleft", 0, 0);
+ }
+
+ showLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).removeAttribute("hidden");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ hideLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).setAttribute("hidden", "true");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ showGuides() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
+ }
+ }
+
+ hideGuides() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
+ }
+ }
+
+ showHandler(id) {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ this.markup.removeAttributeForElement(prefix + id, "hidden");
+ }
+
+ showHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.removeAttributeForElement(prefix + handler, "hidden");
+ }
+ }
+
+ hideAll() {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ this.hideLabel(LABEL_TYPE_SIZE);
+ this.hideGuides();
+ this.hideHandlers();
+ }
+
+ showGuidesAndHandlers() {
+ // Shows the guides and handlers only if an actual area is selected
+ if (this.rect.w !== 0 && this.rect.h !== 0) {
+ this.updateGuides();
+ this.showGuides();
+ this.updateHandlers();
+ this.showHandlers();
+ }
+ }
+
+ hideHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.setAttributeForElement(prefix + handler, "hidden", "true");
+ }
+ }
+
+ handleEvent(event) {
+ const { target, type } = event;
+
+ switch (type) {
+ case "mousedown":
+ if (event.button || this._dragging) {
+ return;
+ }
+
+ const isHandler = event.originalTarget.id.includes("handler");
+ if (isHandler) {
+ this.handleResizingMouseDownEvent(event);
+ } else {
+ this.handleMouseDownEvent(event);
+ }
+ break;
+ case "mousemove":
+ if (this._dragging && this._dragging.handler) {
+ this.handleResizingMouseMoveEvent(event);
+ } else {
+ this.handleMouseMoveEvent(event);
+ }
+ break;
+ case "mouseup":
+ if (this._dragging) {
+ if (this._dragging.handler) {
+ this.handleResizingMouseUpEvent();
+ } else {
+ this.handleMouseUpEvent();
+ }
+ }
+ break;
+ case "mouseleave": {
+ if (!this._dragging) {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ }
+ break;
+ }
+ case "scroll": {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ break;
+ }
+ case "pagehide": {
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ case "keydown": {
+ this.handleKeyDown(event);
+ break;
+ }
+ case "keyup": {
+ if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
+ this.getElement("handler-topleft").classList.remove(
+ HIGHLIGHTED_HANDLER_CLASSNAME
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ handleMouseDownEvent(event) {
+ const { pageX, pageY } = event;
+ const { window } = this.env;
+ const elementId = `${this.ID_CLASS_PREFIX}tool`;
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(elementId).classList.add("dragging");
+
+ this.hideAll();
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler: null,
+ x: pageX,
+ y: pageY,
+ };
+
+ this.setRect(pageX, pageY, 0, 0);
+ }
+
+ handleMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { mouseCoords } = this;
+ let { x, y, w, h } = this.rect;
+ let labelType;
+
+ if (this._dragging) {
+ w = pageX - x;
+ h = pageY - y;
+
+ this.setRect(x, y, w, h);
+
+ labelType = LABEL_TYPE_SIZE;
+ } else {
+ mouseCoords.x = pageX;
+ mouseCoords.y = pageY;
+ this.updateLabel(LABEL_TYPE_POSITION);
+
+ labelType = LABEL_TYPE_POSITION;
+ }
+
+ this.showLabel(labelType);
+ }
+
+ handleMouseUpEvent() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").classList.remove("dragging");
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+
+ handleResizingMouseDownEvent(event) {
+ const { originalTarget, pageX, pageY } = event;
+ const { window } = this.env;
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ const handler = originalTarget.id.replace(prefix, "");
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(originalTarget.id).classList.add("dragging");
+
+ this.hideAll();
+ this.showHandler(handler);
+
+ // Set coordinates to the current measurement area's position
+ const [, x, y] = this.getElement("tool")
+ .getAttribute("transform")
+ .match(/(\d+),(\d+)/);
+ this.setRect(Number(x), Number(y));
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler,
+ x: pageX,
+ y: pageY,
+ };
+ }
+
+ handleResizingMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { rect } = this;
+ let { x, y, w, h } = rect;
+
+ const { handler } = this._dragging;
+
+ switch (handler) {
+ case "top":
+ y = pageY;
+ h = rect.y + rect.h - pageY;
+ break;
+ case "topright":
+ y = pageY;
+ w = pageX - rect.x;
+ h = rect.y + rect.h - pageY;
+ break;
+ case "right":
+ w = pageX - rect.x;
+ break;
+ case "bottomright":
+ w = pageX - rect.x;
+ h = pageY - rect.y;
+ break;
+ case "bottom":
+ h = pageY - rect.y;
+ break;
+ case "bottomleft":
+ x = pageX;
+ w = rect.x + rect.w - pageX;
+ h = pageY - rect.y;
+ break;
+ case "left":
+ x = pageX;
+ w = rect.x + rect.w - pageX;
+ break;
+ case "topleft":
+ x = pageX;
+ y = pageY;
+ w = rect.x + rect.w - pageX;
+ h = rect.y + rect.h - pageY;
+ break;
+ }
+
+ this.setRect(x, y, w, h);
+
+ // Changes the resizing cursors in case the measuring box is mirrored
+ const isMirrored =
+ (rect.w < 0 || rect.h < 0) && !(rect.w < 0 && rect.h < 0);
+ this.getElement("tool").classList.toggle("mirrored", isMirrored);
+
+ this.showLabel("size");
+ }
+
+ handleResizingMouseUpEvent() {
+ const { handler } = this._dragging;
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`handler-${handler}`).classList.remove("dragging");
+ this.showHandlers();
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+
+ handleKeyDown(event) {
+ if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
+ this.getElement("handler-topleft").classList.add(
+ HIGHLIGHTED_HANDLER_CLASSNAME
+ );
+ }
+
+ if (
+ !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
+ ) {
+ return;
+ }
+
+ const { x, y, w, h } = this.rect;
+ const modifier = event.shiftKey ? 10 : 1;
+
+ event.preventDefault();
+ if (MeasuringToolHighlighter.#isResizeModifierHeld(event)) {
+ // If Ctrl (or Command on OS X) is held, resize the tool
+ switch (event.key) {
+ case "ArrowUp":
+ this.setSize(undefined, h - modifier);
+ break;
+ case "ArrowDown":
+ this.setSize(undefined, h + modifier);
+ break;
+ case "ArrowLeft":
+ this.setSize(w - modifier, undefined);
+ break;
+ case "ArrowRight":
+ this.setSize(w + modifier, undefined);
+ break;
+ }
+ } else {
+ // Arrow keys with no modifier move the tool
+ switch (event.key) {
+ case "ArrowUp":
+ this.setRect(undefined, y - modifier);
+ break;
+ case "ArrowDown":
+ this.setRect(undefined, y + modifier);
+ break;
+ case "ArrowLeft":
+ this.setRect(x - modifier, undefined);
+ break;
+ case "ArrowRight":
+ this.setRect(x + modifier, undefined);
+ break;
+ }
+ }
+
+ this.updatePaths();
+ this.updateGuides();
+ this.updateHandlers();
+ this.updateLabel(LABEL_TYPE_SIZE);
+ }
+
+ static #isResizeModifierPressed(event) {
+ return (
+ (!IS_OSX && event.key === "Control") || (IS_OSX && event.key === "Meta")
+ );
+ }
+
+ static #isResizeModifierHeld(event) {
+ return (!IS_OSX && event.ctrlKey) || (IS_OSX && event.metaKey);
+ }
+}
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build
new file mode 100644
index 0000000000..eeaca89992
--- /dev/null
+++ b/devtools/server/actors/highlighters/moz.build
@@ -0,0 +1,31 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "utils",
+ "css",
+]
+
+DevToolsModules(
+ "accessible.js",
+ "auto-refresh.js",
+ "box-model.js",
+ "css-grid.js",
+ "css-transform.js",
+ "eye-dropper.js",
+ "flexbox.js",
+ "fonts.js",
+ "geometry-editor.js",
+ "measuring-tool.js",
+ "node-tabbing-order.js",
+ "paused-debugger.js",
+ "remote-node-picker-notice.js",
+ "rulers.js",
+ "selector.js",
+ "shapes.js",
+ "tabbing-order.js",
+ "viewport-size.js",
+)
diff --git a/devtools/server/actors/highlighters/node-tabbing-order.js b/devtools/server/actors/highlighters/node-tabbing-order.js
new file mode 100644
index 0000000000..229342ee98
--- /dev/null
+++ b/devtools/server/actors/highlighters/node-tabbing-order.js
@@ -0,0 +1,399 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ ["setIgnoreLayoutChanges", "getCurrentZoom"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "AutoRefreshHighlighter",
+ "resource://devtools/server/actors/highlighters/auto-refresh.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CanvasFrameAnonymousContentHelper"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+
+/**
+ * The NodeTabbingOrderHighlighter draws an outline around a node (based on its
+ * border bounds).
+ *
+ * Usage example:
+ *
+ * const h = new NodeTabbingOrderHighlighter(env);
+ * await h.isReady();
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {Number} options.index
+ * Tabbing index value to be displayed in the highlighter info bar.
+ */
+class NodeTabbingOrderHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this._doNotStartRefreshLoop = true;
+ this.ID_CLASS_PREFIX = "tabbing-order-";
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ _buildMarkup() {
+ const root = this.markup.createNode({
+ attributes: {
+ id: "root",
+ class: "root highlighter-container tabbing-order",
+ "aria-hidden": "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const container = this.markup.createNode({
+ parent: root,
+ attributes: {
+ id: "container",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the SVG element
+ this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "bounds",
+ id: "bounds",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the nodeinfo bar markup
+
+ const infobarContainer = this.markup.createNode({
+ parent: root,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: infobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ id: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return root;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ this.markup.destroy();
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Update focused styling for a node tabbing index highlight.
+ *
+ * @param {Boolean} focused
+ * Indicates if the highlighted node needs to be focused.
+ */
+ updateFocus(focused) {
+ const root = this.getElement("root");
+ root.classList.toggle("focused", focused);
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ return this._update();
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateTabbingOrder()) {
+ this._showInfobar();
+ this._showTabbingOrder();
+ shown = true;
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this._hide();
+ }
+
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._hideTabbingOrder();
+ this._hideInfobar();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Hide the infobar
+ */
+ _hideInfobar() {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the infobar
+ */
+ _showInfobar() {
+ if (!this.currentNode) {
+ return;
+ }
+
+ this.getElement("infobar-container").removeAttribute("hidden");
+ this.getElement("infobar-text").setTextContent(this.options.index);
+ const bounds = this._getBounds();
+ const container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ }
+
+ /**
+ * Hide the tabbing order highlighter
+ */
+ _hideTabbingOrder() {
+ this.getElement("container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the tabbing order highlighter
+ */
+ _showTabbingOrder() {
+ this.getElement("container").removeAttribute("hidden");
+ }
+
+ /**
+ * Calculate border bounds based on the quads returned by getAdjustedQuads.
+ * @return {Object} A bounds object {bottom,height,left,right,top,width,x,y}
+ */
+ _getBorderBounds() {
+ const quads = this.currentQuads.border;
+ if (!quads || !quads.length) {
+ return null;
+ }
+
+ const bounds = {
+ bottom: -Infinity,
+ height: 0,
+ left: Infinity,
+ right: -Infinity,
+ top: Infinity,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+
+ for (const q of quads) {
+ bounds.bottom = Math.max(bounds.bottom, q.bounds.bottom);
+ bounds.top = Math.min(bounds.top, q.bounds.top);
+ bounds.left = Math.min(bounds.left, q.bounds.left);
+ bounds.right = Math.max(bounds.right, q.bounds.right);
+ }
+ bounds.x = bounds.left;
+ bounds.y = bounds.top;
+ bounds.width = bounds.right - bounds.left;
+ bounds.height = bounds.bottom - bounds.top;
+
+ return bounds;
+ }
+
+ /**
+ * Update the tabbing order index as per the current node.
+ *
+ * @return {boolean}
+ * True if the current node has a tabbing order index to be
+ * highlighted
+ */
+ _updateTabbingOrder() {
+ if (!this._nodeNeedsHighlighting()) {
+ this._hideTabbingOrder();
+ return false;
+ }
+
+ const boundsEl = this.getElement("bounds");
+ const { left, top, width, height } = this._getBounds();
+ boundsEl.setAttribute(
+ "style",
+ `top: ${top}px; left: ${left}px; width: ${width}px; height: ${height}px;`
+ );
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "container";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ /**
+ * Can the current node be highlighted? Does it have quads.
+ * @return {Boolean}
+ */
+ _nodeNeedsHighlighting() {
+ return (
+ this.currentQuads.margin.length ||
+ this.currentQuads.border.length ||
+ this.currentQuads.padding.length ||
+ this.currentQuads.content.length
+ );
+ }
+
+ _getBounds() {
+ const borderBounds = this._getBorderBounds();
+ let bounds = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+
+ if (!borderBounds) {
+ // Invisible element such as a script tag.
+ return bounds;
+ }
+
+ const { bottom, height, left, right, top, width, x, y } = borderBounds;
+ if (width > 0 || height > 0) {
+ bounds = { bottom, height, left, right, top, width, x, y };
+ }
+
+ return bounds;
+ }
+}
+
+/**
+ * Move the infobar to the right place in the highlighter. The infobar is used
+ * to display element's tabbing order index.
+ *
+ * @param {DOMNode} container
+ * The container element which will be used to position the infobar.
+ * @param {Object} bounds
+ * The content bounds of the container element.
+ * @param {Window} win
+ * The window object.
+ */
+function moveInfobar(container, bounds, win) {
+ const zoom = getCurrentZoom(win);
+ const { computedStyle } = container;
+ const margin = 2;
+ const arrowSize =
+ parseFloat(
+ computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
+ ) - 2;
+ const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
+ const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
+
+ const topBoundary = margin;
+ const bottomBoundary =
+ win.document.scrollingElement.scrollHeight - containerHeight - margin - 1;
+ const leftBoundary = containerWidth / 2 + margin;
+
+ let top = bounds.y - containerHeight - arrowSize;
+ let left = bounds.x + bounds.width / 2;
+ const bottom = bounds.bottom + arrowSize;
+ let positionAttribute = "top";
+
+ const canBePlacedOnTop = top >= topBoundary;
+ const canBePlacedOnBottom = bottomBoundary - bottom > 0;
+
+ if (!canBePlacedOnTop && canBePlacedOnBottom) {
+ top = bottom;
+ positionAttribute = "bottom";
+ }
+
+ let hideArrow = false;
+ if (top < topBoundary) {
+ hideArrow = true;
+ top = topBoundary;
+ } else if (top > bottomBoundary) {
+ hideArrow = true;
+ top = bottomBoundary;
+ }
+
+ if (left < leftBoundary) {
+ hideArrow = true;
+ left = leftBoundary;
+ }
+
+ if (hideArrow) {
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ container.removeAttribute("hide-arrow");
+ }
+
+ container.setAttribute(
+ "style",
+ `
+ position: absolute;
+ transform-origin: 0 0;
+ transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
+ );
+
+ container.setAttribute("position", positionAttribute);
+}
+
+exports.NodeTabbingOrderHighlighter = NodeTabbingOrderHighlighter;
diff --git a/devtools/server/actors/highlighters/paused-debugger.js b/devtools/server/actors/highlighters/paused-debugger.js
new file mode 100644
index 0000000000..5035ab04c2
--- /dev/null
+++ b/devtools/server/actors/highlighters/paused-debugger.js
@@ -0,0 +1,260 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+loader.lazyGetter(this, "PausedReasonsBundle", () => {
+ return new Localization(
+ ["devtools/shared/debugger-paused-reasons.ftl"],
+ true
+ );
+});
+
+loader.lazyRequireGetter(
+ this,
+ "DEBUGGER_PAUSED_REASONS_L10N_MAPPING",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+/**
+ * The PausedDebuggerOverlay is a class that displays a semi-transparent mask on top of
+ * the whole page and a toolbar at the top of the page.
+ * This is used to signal to users that script execution is current paused.
+ * The toolbar is used to display the reason for the pause in script execution as well as
+ * buttons to resume or step through the program.
+ */
+class PausedDebuggerOverlay {
+ constructor(highlighterEnv, options = {}) {
+ this.env = highlighterEnv;
+ this.resume = options.resume;
+ this.stepOver = options.stepOver;
+
+ this.lastTarget = null;
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this),
+ { waitForDocumentToLoad: false }
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ ID_CLASS_PREFIX = "paused-dbg-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ overlay: "true",
+ },
+ prefix,
+ });
+
+ const toolbar = this.markup.createNode({
+ parent: wrapper,
+ attributes: {
+ id: "toolbar",
+ class: "toolbar",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: toolbar,
+ attributes: {
+ id: "reason",
+ class: "reason",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "divider",
+ class: "divider",
+ },
+ prefix,
+ });
+
+ const stepWrapper = this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "step-button-wrapper",
+ class: "step-button-wrapper",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: stepWrapper,
+ attributes: {
+ id: "step-button",
+ class: "step-button",
+ },
+ prefix,
+ });
+
+ const resumeWrapper = this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "resume-button-wrapper",
+ class: "resume-button-wrapper",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: resumeWrapper,
+ attributes: {
+ id: "resume-button",
+ class: "resume-button",
+ },
+ prefix,
+ });
+
+ return container;
+ }
+
+ destroy() {
+ this.hide();
+ this.markup.destroy();
+ this.env = null;
+ this.lastTarget = null;
+ }
+
+ onClick(target) {
+ const { id } = target;
+ if (!id) {
+ return;
+ }
+
+ if (id.includes("paused-dbg-step-button")) {
+ this.stepOver();
+ } else if (id.includes("paused-dbg-resume-button")) {
+ this.resume();
+ }
+ }
+
+ onMouseMove(target) {
+ // Not an element we care about
+ if (!target || !target.id) {
+ return;
+ }
+
+ // If the user didn't change targets, do nothing
+ if (this.lastTarget && this.lastTarget.id === target.id) {
+ return;
+ }
+
+ if (
+ target.id.includes("step-button") ||
+ target.id.includes("resume-button")
+ ) {
+ // The hover should be applied to the wrapper (icon's parent node)
+ const newTarget = target.parentNode.id.includes("wrapper")
+ ? target.parentNode
+ : target;
+
+ // Remove the hover class if the user has changed buttons
+ if (this.lastTarget && this.lastTarget != newTarget) {
+ this.lastTarget.classList.remove("hover");
+ }
+ newTarget.classList.add("hover");
+ this.lastTarget = newTarget;
+ } else if (this.lastTarget) {
+ // Remove the hover class if the user isn't on a button
+ this.lastTarget.classList.remove("hover");
+ }
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousedown":
+ this.onClick(e.target);
+ break;
+ case "DOMMouseScroll":
+ // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+ // scrolling out of the viewport wouldn't draw the expected things. In the future
+ // we can take the screenshot again on scroll, but for now it doesn't seem
+ // important.
+ e.preventDefault();
+ break;
+
+ case "mousemove":
+ this.onMouseMove(e.target);
+ break;
+ }
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ show(reason) {
+ if (this.env.isXUL || !reason) {
+ return false;
+ }
+
+ // Only track mouse movement when the the overlay is shown
+ // Prevents mouse tracking when the user isn't paused
+ const { pageListenerTarget } = this.env;
+ pageListenerTarget.addEventListener("mousemove", this);
+
+ // Show the highlighter's root element.
+ const root = this.getElement("root");
+ root.removeAttribute("hidden");
+ root.setAttribute("overlay", "true");
+
+ // Set the text to appear in the toolbar.
+ const toolbar = this.getElement("toolbar");
+ this.getElement("reason").setTextContent(
+ PausedReasonsBundle.formatValueSync(
+ DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reason]
+ )
+ );
+ toolbar.removeAttribute("hidden");
+
+ // When the debugger pauses execution in a page, events will not be delivered
+ // to any handlers added to elements on that page. So here we use the
+ // document's setSuppressedEventListener interface to still be able to act on mouse
+ // events (they'll be handled by the `handleEvent` method)
+ this.env.window.document.setSuppressedEventListener(this);
+ return true;
+ }
+
+ hide() {
+ if (this.env.isXUL) {
+ return;
+ }
+
+ const { pageListenerTarget } = this.env;
+ pageListenerTarget.removeEventListener("mousemove", this);
+
+ // Hide the overlay.
+ this.getElement("root").setAttribute("hidden", "true");
+ // Remove the hover state
+ this.getElement("step-button-wrapper").classList.remove("hover");
+ this.getElement("resume-button-wrapper").classList.remove("hover");
+ }
+}
+exports.PausedDebuggerOverlay = PausedDebuggerOverlay;
diff --git a/devtools/server/actors/highlighters/remote-node-picker-notice.js b/devtools/server/actors/highlighters/remote-node-picker-notice.js
new file mode 100644
index 0000000000..64b131d2a2
--- /dev/null
+++ b/devtools/server/actors/highlighters/remote-node-picker-notice.js
@@ -0,0 +1,188 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+loader.lazyGetter(this, "isAndroid", () => {
+ return Services.appinfo.OS === "Android";
+});
+
+/**
+ * The RemoteNodePickerNotice is a class that displays a notice in a remote debugged page.
+ * This is used to signal to users they can click/tap an element to select it in the
+ * about:devtools-toolbox toolbox inspector.
+ */
+class RemoteNodePickerNotice {
+ #highlighterEnvironment;
+ #previousHoveredElement;
+
+ rootElementId = "node-picker-notice-root";
+ hideButtonId = "node-picker-notice-hide-button";
+ infoNoticeElementId = "node-picker-notice-info";
+
+ /**
+ * @param {highlighterEnvironment} highlighterEnvironment
+ */
+ constructor(highlighterEnvironment) {
+ this.#highlighterEnvironment = highlighterEnvironment;
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.#highlighterEnvironment,
+ this.#buildMarkup
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ #buildMarkup = () => {
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: this.rootElementId,
+ hidden: "true",
+ overlay: "true",
+ },
+ });
+
+ const toolbar = this.markup.createNode({
+ parent: wrapper,
+ attributes: {
+ id: "node-picker-notice-toolbar",
+ class: "toolbar",
+ },
+ });
+
+ this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "node-picker-notice-icon",
+ class: isAndroid ? "touch" : "",
+ },
+ });
+
+ const actionStr = HighlightersBundle.formatValueSync(
+ isAndroid
+ ? "remote-node-picker-notice-action-touch"
+ : "remote-node-picker-notice-action-desktop"
+ );
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: toolbar,
+ text: HighlightersBundle.formatValueSync("remote-node-picker-notice", {
+ action: actionStr,
+ }),
+ attributes: {
+ id: this.infoNoticeElementId,
+ },
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: toolbar,
+ text: HighlightersBundle.formatValueSync(
+ "remote-node-picker-notice-hide-button"
+ ),
+ attributes: {
+ id: this.hideButtonId,
+ },
+ });
+
+ return container;
+ };
+
+ destroy() {
+ // hide will nullify take care of this.#abortController.
+ this.hide();
+ this.markup.destroy();
+ this.#highlighterEnvironment = null;
+ this.#previousHoveredElement = null;
+ }
+
+ /**
+ * We can't use event listener directly on the anonymous content because they aren't
+ * working while the page is paused.
+ * This is called from the NodePicker instance for easier events management.
+ *
+ * @param {ClickEvent}
+ */
+ onClick(e) {
+ const target = e.originalTarget || e.target;
+ const targetId = target?.id;
+
+ if (targetId === this.hideButtonId) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Since we can't use :hover in the CSS for the anonymous content as it wouldn't work
+ * when the page is paused, we have to roll our own implementation, adding a `.hover`
+ * class for the element we want to style on hover (e.g. the close button).
+ * This is called from the NodePicker instance for easier events management.
+ *
+ * @param {MouseMoveEvent}
+ */
+ handleHoveredElement(e) {
+ const hideButton = this.markup.getElement(this.hideButtonId);
+
+ const target = e.originalTarget || e.target;
+ const targetId = target?.id;
+
+ // If the user didn't change targets, do nothing
+ if (this.#previousHoveredElement?.id === targetId) {
+ return;
+ }
+
+ if (targetId === this.hideButtonId) {
+ hideButton.classList.add("hover");
+ } else {
+ hideButton.classList.remove("hover");
+ }
+ this.#previousHoveredElement = target;
+ }
+
+ getMarkupRootElement() {
+ return this.markup.getElement(this.rootElementId);
+ }
+
+ async show() {
+ if (this.#highlighterEnvironment.isXUL) {
+ return false;
+ }
+ await this.isReady;
+
+ // Show the highlighter's root element.
+ const root = this.getMarkupRootElement();
+ root.removeAttribute("hidden");
+ root.setAttribute("overlay", "true");
+
+ return true;
+ }
+
+ hide() {
+ if (this.#highlighterEnvironment.isXUL) {
+ return;
+ }
+
+ // Hide the overlay.
+ this.getMarkupRootElement().setAttribute("hidden", "true");
+ // Reset the hover state
+ this.markup.getElement(this.hideButtonId).classList.remove("hover");
+ this.#previousHoveredElement = null;
+ }
+}
+exports.RemoteNodePickerNotice = RemoteNodePickerNotice;
diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js
new file mode 100644
index 0000000000..b201757d8c
--- /dev/null
+++ b/devtools/server/actors/highlighters/rulers.js
@@ -0,0 +1,312 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// Maximum size, in pixel, for the horizontal ruler and vertical ruler
+// used by RulersHighlighter
+const RULERS_MAX_X_AXIS = 10000;
+const RULERS_MAX_Y_AXIS = 15000;
+// Number of steps after we add a graduation, marker and text in
+// RulersHighliter; currently the unit is in pixel.
+const RULERS_GRADUATION_STEP = 5;
+const RULERS_MARKER_STEP = 50;
+const RULERS_TEXT_STEP = 100;
+
+/**
+ * The RulersHighlighter is a class that displays both horizontal and
+ * vertical rules on the page, along the top and left edges, with pixel
+ * graduations, useful for users to quickly check distances
+ */
+class RulersHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("scroll", this);
+ pageListenerTarget.addEventListener("pagehide", this);
+ }
+
+ ID_CLASS_PREFIX = "rulers-highlighter-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const createRuler = (axis, size) => {
+ let width, height;
+ let isHorizontal = true;
+
+ if (axis === "x") {
+ width = size;
+ height = 16;
+ } else if (axis === "y") {
+ width = 16;
+ height = size;
+ isHorizontal = false;
+ } else {
+ throw new Error(
+ `Invalid type of axis given; expected "x" or "y" but got "${axis}"`
+ );
+ }
+
+ const g = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis`,
+ },
+ parent: svg,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ attributes: {
+ y: isHorizontal ? 0 : 16,
+ width,
+ height,
+ },
+ parent: g,
+ });
+
+ const gRule = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis-ruler`,
+ },
+ parent: g,
+ prefix,
+ });
+
+ const pathGraduations = this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ class: "ruler-graduations",
+ width,
+ height,
+ },
+ parent: gRule,
+ prefix,
+ });
+
+ const pathMarkers = this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ class: "ruler-markers",
+ width,
+ height,
+ },
+ parent: gRule,
+ prefix,
+ });
+
+ const gText = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis-text`,
+ class: (isHorizontal ? "horizontal" : "vertical") + "-labels",
+ },
+ parent: g,
+ prefix,
+ });
+
+ let dGraduations = "";
+ let dMarkers = "";
+ let graduationLength;
+
+ for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) {
+ if (i === 0) {
+ continue;
+ }
+
+ graduationLength = i % 2 === 0 ? 6 : 4;
+
+ if (i % RULERS_TEXT_STEP === 0) {
+ graduationLength = 8;
+ this.markup.createSVGNode({
+ nodeType: "text",
+ parent: gText,
+ attributes: {
+ x: isHorizontal ? 2 + i : -i - 1,
+ y: 5,
+ },
+ }).textContent = i;
+ }
+
+ if (isHorizontal) {
+ if (i % RULERS_MARKER_STEP === 0) {
+ dMarkers += `M${i} 0 L${i} ${graduationLength}`;
+ } else {
+ dGraduations += `M${i} 0 L${i} ${graduationLength} `;
+ }
+ } else if (i % 50 === 0) {
+ dMarkers += `M0 ${i} L${graduationLength} ${i}`;
+ } else {
+ dGraduations += `M0 ${i} L${graduationLength} ${i}`;
+ }
+ }
+
+ pathGraduations.setAttribute("d", dGraduations);
+ pathMarkers.setAttribute("d", dMarkers);
+
+ return g;
+ };
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ class: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix,
+ });
+
+ createRuler("x", RULERS_MAX_X_AXIS);
+ createRuler("y", RULERS_MAX_Y_AXIS);
+
+ return container;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "scroll":
+ this._onScroll(event);
+ break;
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (event.target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ }
+
+ _onScroll(event) {
+ const prefix = this.ID_CLASS_PREFIX;
+ const { scrollX, scrollY } = event.view;
+
+ this.markup
+ .getElement(`${prefix}x-axis-ruler`)
+ .setAttribute("transform", `translate(${-scrollX})`);
+ this.markup
+ .getElement(`${prefix}x-axis-text`)
+ .setAttribute("transform", `translate(${-scrollX})`);
+ this.markup
+ .getElement(`${prefix}y-axis-ruler`)
+ .setAttribute("transform", `translate(0, ${-scrollY})`);
+ this.markup
+ .getElement(`${prefix}y-axis-text`)
+ .setAttribute("transform", `translate(0, ${-scrollY})`);
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ const zoom = getCurrentZoom(window);
+ const isZoomChanged = zoom !== this._zoom;
+
+ if (isZoomChanged) {
+ this._zoom = zoom;
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+ updateViewport() {
+ const { devicePixelRatio } = this.env.window;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ const pixelRatio = devicePixelRatio / this._zoom;
+
+ // The "real" device pixel ratio is used to calculate the max stroke
+ // width we can actually assign: on retina, for instance, it would be 0.5,
+ // where on non high dpi monitor would be 1.
+ const minWidth = 1 / pixelRatio;
+ const strokeWidth = Math.min(minWidth, minWidth / this._zoom);
+
+ this.markup
+ .getElement(this.ID_CLASS_PREFIX + "root")
+ .setAttribute("style", `stroke-width:${strokeWidth};`);
+ }
+
+ destroy() {
+ this.hide();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("scroll", this);
+ pageListenerTarget.removeEventListener("pagehide", this);
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ this.markup.removeAttributeForElement(
+ this.ID_CLASS_PREFIX + "elements",
+ "hidden"
+ );
+
+ this._update();
+
+ return true;
+ }
+
+ hide() {
+ this.markup.setAttributeForElement(
+ this.ID_CLASS_PREFIX + "elements",
+ "hidden",
+ "true"
+ );
+
+ this._cancelUpdate();
+ }
+}
+exports.RulersHighlighter = RulersHighlighter;
diff --git a/devtools/server/actors/highlighters/selector.js b/devtools/server/actors/highlighters/selector.js
new file mode 100644
index 0000000000..249060fd3b
--- /dev/null
+++ b/devtools/server/actors/highlighters/selector.js
@@ -0,0 +1,97 @@
+/* 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 {
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ BoxModelHighlighter,
+} = require("resource://devtools/server/actors/highlighters/box-model.js");
+
+// How many maximum nodes can be highlighted at the same time by the SelectorHighlighter
+const MAX_HIGHLIGHTED_ELEMENTS = 100;
+
+/**
+ * The SelectorHighlighter runs a given selector through querySelectorAll on the
+ * document of the provided context node and then uses the BoxModelHighlighter
+ * to highlight the matching nodes
+ */
+class SelectorHighlighter {
+ constructor(highlighterEnv) {
+ this.highlighterEnv = highlighterEnv;
+ this._highlighters = [];
+ }
+
+ /**
+ * Show a BoxModelHighlighter on each node that matches a given selector.
+ *
+ * @param {DOMNode} node
+ * A context node used to get the document element on which to run
+ * querySelectorAll(). This node will not be highlighted.
+ * @param {Object} options
+ * Configuration options for SelectorHighlighter.
+ * All of the options for BoxModelHighlighter.show() are also valid here.
+ * @param {String} options.selector
+ * Required. CSS selector used with querySelectorAll() to find matching elements.
+ */
+ async show(node, options = {}) {
+ this.hide();
+
+ if (!isNodeValid(node) || !options.selector) {
+ return false;
+ }
+
+ let nodes = [];
+ try {
+ nodes = [...node.ownerDocument.querySelectorAll(options.selector)];
+ } catch (e) {
+ // It's fine if the provided selector is invalid, `nodes` will be an empty array.
+ }
+
+ // Prevent passing the `selector` option to BoxModelHighlighter
+ delete options.selector;
+
+ const promises = [];
+ for (let i = 0; i < Math.min(nodes.length, MAX_HIGHLIGHTED_ELEMENTS); i++) {
+ promises.push(this._showHighlighter(nodes[i], options));
+ }
+
+ await Promise.all(promises);
+ return true;
+ }
+
+ /**
+ * Create an instance of BoxModelHighlighter, wait for it to be ready
+ * (see CanvasFrameAnonymousContentHelper.initialize()),
+ * then show the highlighter on the given node with the given configuration options.
+ *
+ * @param {DOMNode} node
+ * Node to be highlighted
+ * @param {Object} options
+ * Configuration options for the BoxModelHighlighter
+ * @return {Promise} Promise that resolves when the BoxModelHighlighter is ready
+ */
+ async _showHighlighter(node, options) {
+ const highlighter = new BoxModelHighlighter(this.highlighterEnv);
+ await highlighter.isReady;
+
+ highlighter.show(node, options);
+ this._highlighters.push(highlighter);
+ }
+
+ hide() {
+ for (const highlighter of this._highlighters) {
+ highlighter.destroy();
+ }
+ this._highlighters = [];
+ }
+
+ destroy() {
+ this.hide();
+ this.highlighterEnv = null;
+ }
+}
+exports.SelectorHighlighter = SelectorHighlighter;
diff --git a/devtools/server/actors/highlighters/shapes.js b/devtools/server/actors/highlighters/shapes.js
new file mode 100644
index 0000000000..a77e8c31be
--- /dev/null
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -0,0 +1,3263 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getCurrentZoom,
+ getAdjustedQuads,
+ getFrameOffsets,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ getDistance,
+ clickedOnEllipseEdge,
+ distanceToLine,
+ projection,
+ clickedOnPoint,
+} = require("resource://devtools/server/actors/utils/shapes-utils.js");
+const {
+ identity,
+ apply,
+ translate,
+ multiply,
+ scale,
+ rotate,
+ changeMatrixBase,
+ getBasis,
+} = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getCSSStyleRules,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const BASE_MARKER_SIZE = 5;
+// the width of the area around highlighter lines that can be clicked, in px
+const LINE_CLICK_WIDTH = 5;
+const ROTATE_LINE_LENGTH = 50;
+const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"];
+const _dragging = Symbol("shapes/dragging");
+
+/**
+ * The ShapesHighlighter draws an outline shapes in the page.
+ * The idea is to have something that is able to wrap complex shapes for css properties
+ * such as shape-outside/inside, clip-path but also SVG elements.
+ *
+ * Notes on shape transformation:
+ *
+ * When using transform mode to translate, scale, and rotate shapes, a transformation
+ * matrix keeps track of the transformations done to the original shape. When the
+ * highlighter is toggled on/off or between transform mode and point editing mode,
+ * the transformations applied to the shape become permanent.
+ *
+ * While transformations are being performed on a shape, there is an "original" and
+ * a "transformed" coordinate system. This is used when scaling or rotating a rotated
+ * shape.
+ *
+ * The "original" coordinate system is the one where (0,0) is at the top left corner
+ * of the page, the x axis is horizontal, and the y axis is vertical.
+ *
+ * The "transformed" coordinate system is the one where (0,0) is at the top left
+ * corner of the current shape. The x axis follows the north edge of the shape
+ * (from the northwest corner to the northeast corner) and the y axis follows
+ * the west edge of the shape (from the northwest corner to the southwest corner).
+ *
+ * Because of rotation, the "north" and "west" edges might not actually be at the
+ * top and left of the transformed shape. Imagine that the compass directions are
+ * also rotated along with the shape.
+ *
+ * A refresher for coordinates and change of basis that may be helpful:
+ * https://www.math.ubc.ca/~behrend/math221/Coords.pdf
+ *
+ * @param {String} options.hoverPoint
+ * The point to highlight.
+ * @param {Boolean} options.transformMode
+ * Whether to show the highlighter in transforms mode.
+ * @param {} options.mode
+ */
+class ShapesHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+ EventEmitter.decorate(this);
+
+ this.ID_CLASS_PREFIX = "shapes-";
+
+ this.referenceBox = "border";
+ this.useStrokeBox = false;
+ this.geometryBox = "";
+ this.hoveredPoint = null;
+ this.fillRule = "";
+ this.numInsetPoints = 0;
+ this.transformMode = false;
+ this.viewport = {};
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ this.onPageHide = this.onPageHide.bind(this);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ DOM_EVENTS.forEach(event =>
+ pageListenerTarget.addEventListener(event, this)
+ );
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ const rootWrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const mainSvg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "shape-container",
+ class: "shape-container",
+ viewBox: "0 0 100 100",
+ preserveAspectRatio: "none",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // This clipPath and its children make sure the element quad outline
+ // is only shown when the shape extends past the element quads.
+ const clipSvg = this.markup.createSVGNode({
+ nodeType: "clipPath",
+ parent: mainSvg,
+ attributes: {
+ id: "clip-path",
+ class: "clip-path",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-polygon",
+ class: "clip-polygon",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-ellipse",
+ class: "clip-ellipse",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-rect",
+ class: "clip-rect",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Rectangle that displays the element quads. Only shown for shape-outside.
+ // Only the parts of the rectangle's outline that overlap with the shape is shown.
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainSvg,
+ attributes: {
+ id: "quad",
+ class: "quad",
+ hidden: "true",
+ "clip-path": "url(#shapes-clip-path)",
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // clipPath that corresponds to the element's quads. Only applied for shape-outside.
+ // This ensures only the parts of the shape that are within the element's quads are
+ // outlined by a solid line.
+ const shapeClipSvg = this.markup.createSVGNode({
+ nodeType: "clipPath",
+ parent: mainSvg,
+ attributes: {
+ id: "quad-clip-path",
+ class: "quad-clip-path",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: shapeClipSvg,
+ attributes: {
+ id: "quad-clip",
+ class: "quad-clip",
+ x: -1,
+ y: -1,
+ width: 102,
+ height: 102,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const mainGroup = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: mainSvg,
+ attributes: {
+ id: "group",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a polygon for polygon shapes.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: mainGroup,
+ attributes: {
+ id: "polygon",
+ class: "polygon",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append an ellipse for circle/ellipse shapes.
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: mainGroup,
+ attributes: {
+ id: "ellipse",
+ class: "ellipse",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a rect for inset().
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainGroup,
+ attributes: {
+ id: "rect",
+ class: "rect",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Dashed versions of each shape. Only shown for the parts of the shape
+ // that extends past the element's quads.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-polygon",
+ class: "polygon",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-ellipse",
+ class: "ellipse",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-rect",
+ class: "rect",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "bounding-box",
+ class: "bounding-box",
+ "stroke-dasharray": "5, 5",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "rotate-line",
+ class: "rotate-line",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a path to display the markers for the shape.
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "markers-outline",
+ class: "markers-outline",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "markers",
+ class: "markers",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "marker-hover",
+ class: "marker-hover",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ get currentDimensions() {
+ let dims = this.currentQuads[this.referenceBox][0].bounds;
+ const zoom = getCurrentZoom(this.win);
+
+ // If an SVG element has a stroke, currentQuads will return the stroke bounding box.
+ // However, clip-path always uses the object bounding box unless "stroke-box" is
+ // specified. So, we must calculate the object bounding box if there is a stroke
+ // and "stroke-box" is not specified. stroke only applies to SVG elements, so use
+ // getBBox, which only exists for SVG, to check if currentNode is an SVG element.
+ if (
+ this.drawingNode.getBBox &&
+ getComputedStyle(this.drawingNode).stroke !== "none" &&
+ !this.useStrokeBox
+ ) {
+ dims = getObjectBoundingBox(
+ dims.top,
+ dims.left,
+ dims.width,
+ dims.height,
+ this.drawingNode
+ );
+ }
+
+ return {
+ top: dims.top / zoom,
+ left: dims.left / zoom,
+ width: dims.width / zoom,
+ height: dims.height / zoom,
+ };
+ }
+
+ get frameDimensions() {
+ // In an iframe, we get the node's quads relative to the frame, instead of the parent
+ // document.
+ let dims =
+ this.highlighterEnv.window.document === this.drawingNode.ownerDocument
+ ? this.currentQuads[this.referenceBox][0].bounds
+ : getAdjustedQuads(
+ this.drawingNode.ownerGlobal,
+ this.drawingNode,
+ this.referenceBox
+ )[0].bounds;
+ const zoom = getCurrentZoom(this.win);
+
+ // If an SVG element has a stroke, currentQuads will return the stroke bounding box.
+ // However, clip-path always uses the object bounding box unless "stroke-box" is
+ // specified. So, we must calculate the object bounding box if there is a stroke
+ // and "stroke-box" is not specified. stroke only applies to SVG elements, so use
+ // getBBox, which only exists for SVG, to check if currentNode is an SVG element.
+ if (
+ this.drawingNode.getBBox &&
+ getComputedStyle(this.drawingNode).stroke !== "none" &&
+ !this.useStrokeBox
+ ) {
+ dims = getObjectBoundingBox(
+ dims.top,
+ dims.left,
+ dims.width,
+ dims.height,
+ this.drawingNode
+ );
+ }
+
+ return {
+ top: dims.top / zoom,
+ left: dims.left / zoom,
+ width: dims.width / zoom,
+ height: dims.height / zoom,
+ };
+ }
+
+ /**
+ * Changes the appearance of the mouse cursor on the highlighter.
+ *
+ * Because we can't attach event handlers to individual elements in the
+ * highlighter, we determine if the mouse is hovering over a point by seeing if
+ * it's within 5 pixels of it. This creates a square hitbox that doesn't match
+ * perfectly with the circular markers. So if we were to use the :hover
+ * pseudo-class to apply changes to the mouse cursor, the cursor change would not
+ * always accurately reflect whether you can interact with the point. This is
+ * also the reason we have the hidden marker-hover element instead of using CSS
+ * to fill in the marker.
+ *
+ * In addition, the cursor CSS property is applied to .shapes-root because if
+ * it were attached to .shapes-marker, the cursor change no longer applies if
+ * you are for example resizing the shape and your mouse goes off the point.
+ * Also, if you are dragging a polygon point, the marker plays catch up to your
+ * mouse position, resulting in an undesirable visual effect where the cursor
+ * rapidly flickers between "grab" and "auto".
+ *
+ * @param {String} cursorType the name of the cursor to display
+ */
+ setCursor(cursorType) {
+ const container = this.getElement("root");
+ let style = container.getAttribute("style");
+ // remove existing cursor definitions in the style
+ style = style.replace(/cursor:.*?;/g, "");
+ style = style.replace(/pointer-events:.*?;/g, "");
+ const pointerEvents = cursorType === "auto" ? "none" : "auto";
+ container.setAttribute(
+ "style",
+ `${style}pointer-events:${pointerEvents};cursor:${cursorType};`
+ );
+ }
+
+ /**
+ * Set the absolute pixel offsets which define the current viewport in relation to
+ * the full page size.
+ *
+ * If a padding value is given, inset the viewport by this value. This is used to define
+ * a virtual viewport which ensures some element remains visible even when at the edges
+ * of the actual viewport.
+ *
+ * @param {Number} padding
+ * Optional. Amount by which to inset the viewport in all directions.
+ */
+ setViewport(padding = 0) {
+ let xOffset = 0;
+ let yOffset = 0;
+
+ // If the node exists within an iframe, get offsets for the virtual viewport so that
+ // points can be dragged to the extent of the global window, outside of the iframe
+ // window.
+ if (this.currentNode.ownerGlobal !== this.win) {
+ const win = this.win;
+ const nodeWin = this.currentNode.ownerGlobal;
+ // Get bounding box of iframe document relative to global document.
+ const bounds = nodeWin.document
+ .getBoxQuads({
+ relativeTo: win.document,
+ createFramesForSuppressedWhitespace: false,
+ })[0]
+ .getBounds();
+ xOffset = bounds.left - nodeWin.scrollX + win.scrollX;
+ yOffset = bounds.top - nodeWin.scrollY + win.scrollY;
+ }
+
+ const { pageXOffset, pageYOffset } = this.win;
+ const { clientHeight, clientWidth } = this.win.document.documentElement;
+ const left = pageXOffset + padding - xOffset;
+ const right = clientWidth + pageXOffset - padding - xOffset;
+ const top = pageYOffset + padding - yOffset;
+ const bottom = clientHeight + pageYOffset - padding - yOffset;
+ this.viewport = { left, right, top, bottom, padding };
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(event, id) {
+ // No event handling if the highlighter is hidden
+ if (this.areShapesHidden()) {
+ return;
+ }
+
+ let { target, type, pageX, pageY } = event;
+
+ // For events on highlighted nodes in an iframe, when the event takes place
+ // outside the iframe. Check if event target belongs to the iframe. If it doesn't,
+ // adjust pageX/pageY to be relative to the iframe rather than the parent.
+ const nodeDocument = this.currentNode.ownerDocument;
+ if (target !== nodeDocument && target.ownerDocument !== nodeDocument) {
+ const [xOffset, yOffset] = getFrameOffsets(
+ target.ownerGlobal,
+ this.currentNode
+ );
+ const zoom = getCurrentZoom(this.win);
+ // xOffset/yOffset are relative to the viewport, so first find the top/left
+ // edges of the viewport relative to the page.
+ const viewportLeft = pageX - event.clientX;
+ const viewportTop = pageY - event.clientY;
+ // Also adjust for scrolling in the iframe.
+ const { scrollTop, scrollLeft } = nodeDocument.documentElement;
+ pageX -= viewportLeft + xOffset / zoom - scrollLeft;
+ pageY -= viewportTop + yOffset / zoom - scrollTop;
+ }
+
+ switch (type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.destroy();
+ }
+
+ break;
+ case "mousedown":
+ if (this.transformMode) {
+ this._handleTransformClick(pageX, pageY);
+ } else if (this.shapeType === "polygon") {
+ this._handlePolygonClick(pageX, pageY);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleClick(pageX, pageY);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseClick(pageX, pageY);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetClick(pageX, pageY);
+ }
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Calculate constraints for a virtual viewport which ensures that a dragged
+ // marker remains visible even at the edges of the actual viewport.
+ this.setViewport(BASE_MARKER_SIZE);
+ break;
+ case "mouseup":
+ if (this[_dragging]) {
+ this[_dragging] = null;
+ this._handleMarkerHover(this.hoveredPoint);
+ }
+ break;
+ case "mousemove":
+ if (!this[_dragging]) {
+ this._handleMouseMoveNotDragging(pageX, pageY);
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Set constraints for mouse position to ensure dragged marker stays in viewport.
+ const { left, right, top, bottom } = this.viewport;
+ pageX = Math.min(Math.max(left, pageX), right);
+ pageY = Math.min(Math.max(top, pageY), bottom);
+
+ const { point } = this[_dragging];
+ if (this.transformMode) {
+ this._handleTransformMove(pageX, pageY);
+ } else if (this.shapeType === "polygon") {
+ this._handlePolygonMove(pageX, pageY);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleMove(point, pageX, pageY);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseMove(point, pageX, pageY);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetMove(point, pageX, pageY);
+ }
+ break;
+ case "dblclick":
+ if (this.shapeType === "polygon" && !this.transformMode) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const index = this.getPolygonPointAt(percentX, percentY);
+ if (index === -1) {
+ this.getPolygonClickedLine(percentX, percentY);
+ return;
+ }
+
+ this._deletePolygonPoint(index);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle a mouse click in transform mode.
+ * @param {Number} pageX the x coordinate of the mouse
+ * @param {Number} pageY the y coordinate of the mouse
+ */
+ _handleTransformClick(pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const type = this.getTransformPointAt(percentX, percentY);
+ if (!type) {
+ return;
+ }
+
+ if (this.shapeType === "polygon") {
+ this._handlePolygonTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetTransformClick(pageX, pageY, type);
+ }
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting a polygon.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handlePolygonTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const pointsInfo = this.origCoordUnits.map(([x, y], i) => {
+ const xComputed = (this.origCoordinates[i][0] / 100) * width;
+ const yComputed = (this.origCoordinates[i][1] / 100) * height;
+ const unitX = getUnit(x);
+ const unitY = getUnit(y);
+ const valueX = isUnitless(x) ? xComputed : parseFloat(x);
+ const valueY = isUnitless(y) ? yComputed : parseFloat(y);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+ return { unitX, unitY, valueX, valueY, ratioX, ratioY };
+ });
+ this[_dragging] = {
+ type,
+ pointsInfo,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ this._handleMarkerHover(this.hoveredPoint);
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting a circle.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleCircleTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const { cx, cy } = this.origCoordUnits;
+ const cxComputed = (this.origCoordinates.cx / 100) * width;
+ const cyComputed = (this.origCoordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ let { radius } = this.origCoordinates;
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+ radius = (radius / 100) * computedSize;
+ let valueRad = this.origCoordUnits.radius;
+ const unitRad = getUnit(valueRad);
+ valueRad = isUnitless(valueRad) ? radius : parseFloat(valueRad);
+ const ratioRad = this.getUnitToPixelRatio(unitRad, computedSize);
+
+ this[_dragging] = {
+ type,
+ unitX,
+ unitY,
+ unitRad,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRad,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting an ellipse.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleEllipseTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const { cx, cy } = this.origCoordUnits;
+ const cxComputed = (this.origCoordinates.cx / 100) * width;
+ const cyComputed = (this.origCoordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ let { rx, ry } = this.origCoordinates;
+ rx = (rx / 100) * width;
+ let valueRX = this.origCoordUnits.rx;
+ const unitRX = getUnit(valueRX);
+ valueRX = isUnitless(valueRX) ? rx : parseFloat(valueRX);
+ const ratioRX = valueRX / rx || 1;
+ ry = (ry / 100) * height;
+ let valueRY = this.origCoordUnits.ry;
+ const unitRY = getUnit(valueRY);
+ valueRY = isUnitless(valueRY) ? ry : parseFloat(valueRY);
+ const ratioRY = valueRY / ry || 1;
+
+ this[_dragging] = {
+ type,
+ unitX,
+ unitY,
+ unitRX,
+ unitRY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRX,
+ ratioRY,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting an inset.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleInsetTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const pointsInfo = {};
+ ["top", "right", "bottom", "left"].forEach(point => {
+ let value = this.origCoordUnits[point];
+ const size = point === "left" || point === "right" ? width : height;
+ const computedValue = (this.origCoordinates[point] / 100) * size;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? computedValue : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, size);
+
+ pointsInfo[point] = { value, unit, ratio };
+ });
+ this[_dragging] = {
+ type,
+ pointsInfo,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle mouse movement after a click on a handle in transform mode.
+ * @param {Number} pageX the x coordinate of the mouse
+ * @param {Number} pageY the y coordinate of the mouse
+ */
+ _handleTransformMove(pageX, pageY) {
+ const { type } = this[_dragging];
+ if (type === "translate") {
+ this._translateShape(pageX, pageY);
+ } else if (type.includes("scale")) {
+ this._scaleShape(pageX, pageY);
+ } else if (type === "rotate" && this.shapeType === "polygon") {
+ this._rotateShape(pageX, pageY);
+ }
+
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+
+ /**
+ * Translates a shape based on the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _translateShape(pageX, pageY) {
+ const { x, y, matrix } = this[_dragging];
+ const deltaX = pageX - x;
+ const deltaY = pageY - y;
+ this.transformMatrix = multiply(translate(deltaX, deltaY), matrix);
+
+ if (this.shapeType === "polygon") {
+ this._transformPolygon();
+ } else if (this.shapeType === "circle") {
+ this._transformCircle();
+ } else if (this.shapeType === "ellipse") {
+ this._transformEllipse();
+ } else if (this.shapeType === "inset") {
+ this._transformInset();
+ }
+ }
+
+ /**
+ * Scales a shape according to the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _scaleShape(pageX, pageY) {
+ /**
+ * To scale a shape:
+ * 1) Get the change of basis matrix corresponding to the current transformation
+ * matrix of the shape.
+ * 2) Convert the mouse x/y deltas to the "transformed" coordinate system, using
+ * the change of base matrix.
+ * 3) Calculate the proportion to which the shape should be scaled to, using the
+ * mouse x/y deltas and the width/height of the transformed shape.
+ * 4) Translate the shape such that the anchor (the point opposite to the one
+ * being dragged) is at the top left of the element.
+ * 5) Scale each point by multiplying by the scaling proportion.
+ * 6) Translate the shape back such that the anchor is in its original position.
+ */
+ const { type, x, y, matrix } = this[_dragging];
+ const { width, height } = this.currentDimensions;
+ // The point opposite to the one being dragged
+ const anchor = getAnchorPoint(type);
+
+ const { ne, nw, sw } = this[_dragging].transformedBB;
+ // u/v are the basis vectors of the transformed coordinate system.
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ // uLength/vLength represent the width/height of the shape in the
+ // transformed coordinate system.
+ const { basis, invertedBasis, uLength, vLength } = getBasis(u, v);
+
+ // How much points on each axis should be translated before scaling
+ const transX = (this[_dragging].transformedBB[anchor][0] / 100) * width;
+ const transY = (this[_dragging].transformedBB[anchor][1] / 100) * height;
+
+ // Distance from original click to current mouse position
+ const distanceX = pageX - x;
+ const distanceY = pageY - y;
+ // Convert from original coordinate system to transformed coordinate system
+ const tDistanceX =
+ invertedBasis[0] * distanceX + invertedBasis[1] * distanceY;
+ const tDistanceY =
+ invertedBasis[3] * distanceX + invertedBasis[4] * distanceY;
+
+ // Proportion of distance to bounding box width/height of shape
+ const proportionX = tDistanceX / uLength;
+ const proportionY = tDistanceY / vLength;
+ // proportionX is positive for size reductions dragging on w/nw/sw,
+ // negative for e/ne/se.
+ const scaleX = type.includes("w") ? 1 - proportionX : 1 + proportionX;
+ // proportionT is positive for size reductions dragging on n/nw/ne,
+ // negative for s/sw/se.
+ const scaleY = type.includes("n") ? 1 - proportionY : 1 + proportionY;
+ // Take the average of scaleX/scaleY for scaling on two axes
+ const scaleXY = (scaleX + scaleY) / 2;
+
+ const translateMatrix = translate(-transX, -transY);
+ let scaleMatrix = identity();
+ // The scale matrices are in the transformed coordinate system. We must convert
+ // them to the original coordinate system before applying it to the transformation
+ // matrix.
+ if (type === "scale-e" || type === "scale-w") {
+ scaleMatrix = changeMatrixBase(scale(scaleX, 1), invertedBasis, basis);
+ } else if (type === "scale-n" || type === "scale-s") {
+ scaleMatrix = changeMatrixBase(scale(1, scaleY), invertedBasis, basis);
+ } else {
+ scaleMatrix = changeMatrixBase(
+ scale(scaleXY, scaleXY),
+ invertedBasis,
+ basis
+ );
+ }
+ const translateBackMatrix = translate(transX, transY);
+ this.transformMatrix = multiply(
+ translateBackMatrix,
+ multiply(scaleMatrix, multiply(translateMatrix, matrix))
+ );
+
+ if (this.shapeType === "polygon") {
+ this._transformPolygon();
+ } else if (this.shapeType === "circle") {
+ this._transformCircle(transX);
+ } else if (this.shapeType === "ellipse") {
+ this._transformEllipse(transX, transY);
+ } else if (this.shapeType === "inset") {
+ this._transformInset();
+ }
+ }
+
+ /**
+ * Rotates a polygon based on the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _rotateShape(pageX, pageY) {
+ const { matrix } = this[_dragging];
+ const { center, ne, nw, sw } = this[_dragging].transformedBB;
+ const { width, height } = this.currentDimensions;
+ const centerX = (center[0] / 100) * width;
+ const centerY = (center[1] / 100) * height;
+ const { x: pageCenterX, y: pageCenterY } = this.convertPercentToPageCoords(
+ ...center
+ );
+
+ const dx = pageCenterX - pageX;
+ const dy = pageCenterY - pageY;
+
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ const { invertedBasis } = getBasis(u, v);
+
+ const tdx = invertedBasis[0] * dx + invertedBasis[1] * dy;
+ const tdy = invertedBasis[3] * dx + invertedBasis[4] * dy;
+ const angle = Math.atan2(tdx, tdy);
+ const translateMatrix = translate(-centerX, -centerY);
+ const rotateMatrix = rotate(angle);
+ const translateBackMatrix = translate(centerX, centerY);
+ this.transformMatrix = multiply(
+ translateBackMatrix,
+ multiply(rotateMatrix, multiply(translateMatrix, matrix))
+ );
+
+ this._transformPolygon();
+ }
+
+ /**
+ * Transform a polygon depending on the current transformation matrix.
+ */
+ _transformPolygon() {
+ const { pointsInfo } = this[_dragging];
+
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += pointsInfo
+ .map(point => {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY } = point;
+ const vector = [valueX / ratioX, valueY / ratioY];
+ let [newX, newY] = apply(this.transformMatrix, vector);
+ newX = round(newX * ratioX, unitX);
+ newY = round(newY * ratioY, unitY);
+
+ return `${newX}${unitX} ${newY}${unitY}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Transform a circle depending on the current transformation matrix.
+ * @param {Number} transX the number of pixels the shape is translated on the x axis
+ * before scaling
+ */
+ _transformCircle(transX = null) {
+ const { unitX, unitY, unitRad, valueX, valueY, ratioX, ratioY, ratioRad } =
+ this[_dragging];
+ let { radius } = this.coordUnits;
+
+ let [newCx, newCy] = apply(this.transformMatrix, [
+ valueX / ratioX,
+ valueY / ratioY,
+ ]);
+ if (transX !== null) {
+ // As part of scaling, the shape is translated to be tangent to the line y=0.
+ // To get the new radius, we translate the new cx back to that point and get
+ // the distance to the line y=0.
+ radius = round(Math.abs((newCx - transX) * ratioRad), unitRad);
+ radius = `${radius}${unitRad}`;
+ }
+
+ newCx = round(newCx * ratioX, unitX);
+ newCy = round(newCy * ratioY, unitY);
+ const circleDef =
+ `circle(${radius} at ${newCx}${unitX} ${newCy}${unitY})` +
+ ` ${this.geometryBox}`.trim();
+ this.emit("highlighter-event", { type: "shape-change", value: circleDef });
+ }
+
+ /**
+ * Transform an ellipse depending on the current transformation matrix.
+ * @param {Number} transX the number of pixels the shape is translated on the x axis
+ * before scaling
+ * @param {Number} transY the number of pixels the shape is translated on the y axis
+ * before scaling
+ */
+ _transformEllipse(transX = null, transY = null) {
+ const {
+ unitX,
+ unitY,
+ unitRX,
+ unitRY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRX,
+ ratioRY,
+ } = this[_dragging];
+ let { rx, ry } = this.coordUnits;
+
+ let [newCx, newCy] = apply(this.transformMatrix, [
+ valueX / ratioX,
+ valueY / ratioY,
+ ]);
+ if (transX !== null && transY !== null) {
+ // As part of scaling, the shape is translated to be tangent to the lines y=0 & x=0.
+ // To get the new radii, we translate the new center back to that point and get the
+ // distances to the line x=0 and y=0.
+ rx = round(Math.abs((newCx - transX) * ratioRX), unitRX);
+ rx = `${rx}${unitRX}`;
+ ry = round(Math.abs((newCy - transY) * ratioRY), unitRY);
+ ry = `${ry}${unitRY}`;
+ }
+
+ newCx = round(newCx * ratioX, unitX);
+ newCy = round(newCy * ratioY, unitY);
+
+ const centerStr = `${newCx}${unitX} ${newCy}${unitY}`;
+ const ellipseDef =
+ `ellipse(${rx} ${ry} at ${centerStr}) ${this.geometryBox}`.trim();
+ this.emit("highlighter-event", { type: "shape-change", value: ellipseDef });
+ }
+
+ /**
+ * Transform an inset depending on the current transformation matrix.
+ */
+ _transformInset() {
+ const { top, left, right, bottom } = this[_dragging].pointsInfo;
+ const { width, height } = this.currentDimensions;
+
+ const topLeft = [left.value / left.ratio, top.value / top.ratio];
+ let [newLeft, newTop] = apply(this.transformMatrix, topLeft);
+ newLeft = round(newLeft * left.ratio, left.unit);
+ newLeft = `${newLeft}${left.unit}`;
+ newTop = round(newTop * top.ratio, top.unit);
+ newTop = `${newTop}${top.unit}`;
+
+ // Right and bottom values are relative to the right and bottom edges of the
+ // element, so convert to the value relative to the left/top edges before scaling
+ // and convert back.
+ const bottomRight = [
+ width - right.value / right.ratio,
+ height - bottom.value / bottom.ratio,
+ ];
+ let [newRight, newBottom] = apply(this.transformMatrix, bottomRight);
+ newRight = round((width - newRight) * right.ratio, right.unit);
+ newRight = `${newRight}${right.unit}`;
+ newBottom = round((height - newBottom) * bottom.ratio, bottom.unit);
+ newBottom = `${newBottom}${bottom.unit}`;
+
+ let insetDef = this.insetRound
+ ? `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${this.insetRound})`
+ : `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`;
+ insetDef += this.geometryBox ? this.geometryBox : "";
+
+ this.emit("highlighter-event", { type: "shape-change", value: insetDef });
+ }
+
+ /**
+ * Handle a click when highlighting a polygon.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handlePolygonClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getPolygonPointAt(percentX, percentY);
+ if (point === -1) {
+ return;
+ }
+
+ const [x, y] = this.coordUnits[point];
+ const xComputed = (this.coordinates[point][0] / 100) * width;
+ const yComputed = (this.coordinates[point][1] / 100) * height;
+ const unitX = getUnit(x);
+ const unitY = getUnit(y);
+ const valueX = isUnitless(x) ? xComputed : parseFloat(x);
+ const valueY = isUnitless(y) ? yComputed : parseFloat(y);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this.setCursor("grabbing");
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ }
+
+ /**
+ * Update the dragged polygon point with the given x/y coords and update
+ * the element style.
+ * @param {Number} pageX the new x coordinate of the point
+ * @param {Number} pageY the new y coordinate of the point
+ */
+ _handlePolygonMove(pageX, pageY) {
+ const { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newX = round(valueX + deltaX, unitX);
+ const newY = round(valueY + deltaY, unitY);
+
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += this.coordUnits
+ .map((coords, i) => {
+ return i === point
+ ? `${newX}${unitX} ${newY}${unitY}`
+ : `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Add new point to the polygon defintion and update element style.
+ * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point.
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054
+ *
+ * @param {Number} after the index of the point that the new point should be added after
+ * @param {Number} x the x coordinate of the new point
+ * @param {Number} y the y coordinate of the new point
+ */
+ _addPolygonPoint(after, x, y) {
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += this.coordUnits
+ .map((coords, i) => {
+ return i === after
+ ? `${coords[0]} ${coords[1]}, ${x}% ${y}%`
+ : `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.hoveredPoint = after + 1;
+ this._emitHoverEvent(this.hoveredPoint);
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Remove point from polygon defintion and update the element style.
+ * @param {Number} point the index of the point to delete
+ */
+ _deletePolygonPoint(point) {
+ const coordinates = this.coordUnits.slice();
+ coordinates.splice(point, 1);
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += coordinates
+ .map((coords, i) => {
+ return `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.hoveredPoint = null;
+ this._emitHoverEvent(this.hoveredPoint);
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+ /**
+ * Handle a click when highlighting a circle.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleCircleClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getCirclePointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ if (point === "center") {
+ const { cx, cy } = this.coordUnits;
+ const cxComputed = (this.coordinates.cx / 100) * width;
+ const cyComputed = (this.coordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ } else if (point === "radius") {
+ let { radius } = this.coordinates;
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+ radius = (radius / 100) * computedSize;
+ let value = this.coordUnits.radius;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? radius : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, computedSize);
+
+ this[_dragging] = { point, value, origRadius: radius, unit, ratio };
+ }
+ }
+
+ /**
+ * Set the center/radius of the circle according to the mouse position and
+ * update the element style.
+ * @param {String} point either "center" or "radius"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ */
+ _handleCircleMove(point, pageX, pageY) {
+ const { radius, cx, cy } = this.coordUnits;
+
+ if (point === "center") {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newCx = `${round(valueX + deltaX, unitX)}${unitX}`;
+ const newCy = `${round(valueY + deltaY, unitY)}${unitY}`;
+ // if not defined by the user, geometryBox will be an empty string; trim() cleans up
+ const circleDef =
+ `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: circleDef,
+ });
+ } else if (point === "radius") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ // convert center point to px, then get distance between center and mouse.
+ const { x: pageCx, y: pageCy } = this.convertPercentToPageCoords(
+ this.coordinates.cx,
+ this.coordinates.cy
+ );
+ const newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY);
+
+ const delta = (newRadiusPx - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const position = cx !== "" ? ` at ${cx} ${cy}` : "";
+ const circleDef =
+ `circle(${newRadius}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: circleDef,
+ });
+ }
+ }
+
+ /**
+ * Handle a click when highlighting an ellipse.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleEllipseClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getEllipsePointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ if (point === "center") {
+ const { cx, cy } = this.coordUnits;
+ const cxComputed = (this.coordinates.cx / 100) * width;
+ const cyComputed = (this.coordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ } else if (point === "rx") {
+ let { rx } = this.coordinates;
+ rx = (rx / 100) * width;
+ let value = this.coordUnits.rx;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? rx : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, width);
+
+ this[_dragging] = { point, value, origRadius: rx, unit, ratio };
+ } else if (point === "ry") {
+ let { ry } = this.coordinates;
+ ry = (ry / 100) * height;
+ let value = this.coordUnits.ry;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? ry : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, height);
+
+ this[_dragging] = { point, value, origRadius: ry, unit, ratio };
+ }
+ }
+
+ /**
+ * Set center/rx/ry of the ellispe according to the mouse position and update the
+ * element style.
+ * @param {String} point "center", "rx", or "ry"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ */
+ _handleEllipseMove(point, pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const { rx, ry, cx, cy } = this.coordUnits;
+ const position = cx !== "" ? ` at ${cx} ${cy}` : "";
+
+ if (point === "center") {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newCx = `${round(valueX + deltaX, unitX)}${unitX}`;
+ const newCy = `${round(valueY + deltaY, unitY)}${unitY}`;
+ const ellipseDef =
+ `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ } else if (point === "rx") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ const newRadiusPercent = Math.abs(percentX - this.coordinates.cx);
+ const { width } = this.currentDimensions;
+ const delta = ((newRadiusPercent / 100) * width - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const ellipseDef =
+ `ellipse(${newRadius} ${ry}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ } else if (point === "ry") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ const newRadiusPercent = Math.abs(percentY - this.coordinates.cy);
+ const { height } = this.currentDimensions;
+ const delta = ((newRadiusPercent / 100) * height - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const ellipseDef =
+ `ellipse(${rx} ${newRadius}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ }
+ }
+
+ /**
+ * Handle a click when highlighting an inset.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleInsetClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getInsetPointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ let value = this.coordUnits[point];
+ const size = point === "left" || point === "right" ? width : height;
+ const computedValue = (this.coordinates[point] / 100) * size;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? computedValue : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, size);
+ const origValue = point === "left" || point === "right" ? pageX : pageY;
+
+ this[_dragging] = { point, value, origValue, unit, ratio };
+ }
+
+ /**
+ * Set the top/left/right/bottom of the inset shape according to the mouse position
+ * and update the element style.
+ * @param {String} point "top", "left", "right", or "bottom"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @memberof ShapesHighlighter
+ */
+ _handleInsetMove(point, pageX, pageY) {
+ let { top, left, right, bottom } = this.coordUnits;
+ const { value, origValue, unit, ratio } = this[_dragging];
+
+ if (point === "left") {
+ const delta = (pageX - origValue) * ratio;
+ left = `${round(value + delta, unit)}${unit}`;
+ } else if (point === "right") {
+ const delta = (pageX - origValue) * ratio;
+ right = `${round(value - delta, unit)}${unit}`;
+ } else if (point === "top") {
+ const delta = (pageY - origValue) * ratio;
+ top = `${round(value + delta, unit)}${unit}`;
+ } else if (point === "bottom") {
+ const delta = (pageY - origValue) * ratio;
+ bottom = `${round(value - delta, unit)}${unit}`;
+ }
+
+ let insetDef = this.insetRound
+ ? `inset(${top} ${right} ${bottom} ${left} round ${this.insetRound})`
+ : `inset(${top} ${right} ${bottom} ${left})`;
+
+ insetDef += this.geometryBox ? this.geometryBox : "";
+
+ this.emit("highlighter-event", { type: "shape-change", value: insetDef });
+ }
+
+ _handleMouseMoveNotDragging(pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ if (this.transformMode) {
+ const point = this.getTransformPointAt(percentX, percentY);
+ this.hoveredPoint = point;
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "polygon") {
+ const point = this.getPolygonPointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point !== -1 ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "circle") {
+ const point = this.getCirclePointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "ellipse") {
+ const point = this.getEllipsePointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "inset") {
+ const point = this.getInsetPointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ }
+ }
+
+ /**
+ * Change the appearance of the given marker when the mouse hovers over it.
+ * @param {String|Number} point if the shape is a polygon, the integer index of the
+ * point being hovered. Otherwise, a string identifying the point being hovered.
+ * Integers < 0 and falsey values excluding 0 indicate no point is being hovered.
+ */
+ _handleMarkerHover(point) {
+ // Hide hover marker for now, will be shown if point is a valid hover target
+ this.getElement("marker-hover").setAttribute("hidden", true);
+ // Catch all falsey values except when point === 0, as that's a valid point
+ if (!point && point !== 0) {
+ this.setCursor("auto");
+ return;
+ }
+ const hoverCursor = this[_dragging] ? "grabbing" : "grab";
+
+ if (this.transformMode) {
+ if (!point) {
+ this.setCursor("auto");
+ return;
+ }
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+
+ const points = [
+ {
+ pointName: "translate",
+ x: center[0],
+ y: center[1],
+ cursor: hoverCursor,
+ },
+ { pointName: "scale-se", x: se[0], y: se[1], anchor: "nw" },
+ { pointName: "scale-ne", x: ne[0], y: ne[1], anchor: "sw" },
+ { pointName: "scale-sw", x: sw[0], y: sw[1], anchor: "ne" },
+ { pointName: "scale-nw", x: nw[0], y: nw[1], anchor: "se" },
+ { pointName: "scale-n", x: n[0], y: n[1], anchor: "s" },
+ { pointName: "scale-s", x: s[0], y: s[1], anchor: "n" },
+ { pointName: "scale-e", x: e[0], y: e[1], anchor: "w" },
+ { pointName: "scale-w", x: w[0], y: w[1], anchor: "e" },
+ {
+ pointName: "rotate",
+ x: rotatePoint[0],
+ y: rotatePoint[1],
+ cursor: hoverCursor,
+ },
+ ];
+
+ for (const { pointName, x, y, cursor, anchor } of points) {
+ if (point === pointName) {
+ this._drawHoverMarker([[x, y]]);
+
+ // If the point is a scale handle, we will need to determine the direction
+ // of the resize cursor based on the position of the handle relative to its
+ // "anchor" (the handle opposite to it).
+ if (pointName.includes("scale")) {
+ const direction = this.getRoughDirection(pointName, anchor);
+ this.setCursor(`${direction}-resize`);
+ } else {
+ this.setCursor(cursor);
+ }
+ }
+ }
+ } else if (this.shapeType === "polygon") {
+ if (point === -1) {
+ this.setCursor("auto");
+ return;
+ }
+ this.setCursor(hoverCursor);
+ this._drawHoverMarker([this.coordinates[point]]);
+ } else if (this.shapeType === "circle") {
+ this.setCursor(hoverCursor);
+
+ const { cx, cy, rx } = this.coordinates;
+ if (point === "radius") {
+ this._drawHoverMarker([[cx + rx, cy]]);
+ } else if (point === "center") {
+ this._drawHoverMarker([[cx, cy]]);
+ }
+ } else if (this.shapeType === "ellipse") {
+ this.setCursor(hoverCursor);
+
+ if (point === "center") {
+ const { cx, cy } = this.coordinates;
+ this._drawHoverMarker([[cx, cy]]);
+ } else if (point === "rx") {
+ const { cx, cy, rx } = this.coordinates;
+ this._drawHoverMarker([[cx + rx, cy]]);
+ } else if (point === "ry") {
+ const { cx, cy, ry } = this.coordinates;
+ this._drawHoverMarker([[cx, cy + ry]]);
+ }
+ } else if (this.shapeType === "inset") {
+ this.setCursor(hoverCursor);
+
+ const { top, right, bottom, left } = this.coordinates;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+ const points = point.split(",");
+ const coords = points.map(side => {
+ if (side === "top") {
+ return [centerX, top];
+ } else if (side === "right") {
+ return [100 - right, centerY];
+ } else if (side === "bottom") {
+ return [centerX, 100 - bottom];
+ } else if (side === "left") {
+ return [left, centerY];
+ }
+ return null;
+ });
+
+ this._drawHoverMarker(coords);
+ }
+ }
+
+ _drawHoverMarker(points) {
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const path = points
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom);
+ })
+ .join(" ");
+
+ const markerHover = this.getElement("marker-hover");
+ markerHover.setAttribute("d", path);
+ markerHover.removeAttribute("hidden");
+ }
+
+ _emitHoverEvent(point) {
+ if (point === null || point === undefined) {
+ this.emit("highlighter-event", {
+ type: "shape-hover-off",
+ });
+ } else {
+ this.emit("highlighter-event", {
+ type: "shape-hover-on",
+ point: point.toString(),
+ });
+ }
+ }
+
+ /**
+ * Convert the given coordinates on the page to percentages relative to the current
+ * element.
+ * @param {Number} pageX the x coordinate on the page
+ * @param {Number} pageY the y coordinate on the page
+ * @returns {Object} object of form {percentX, percentY}, which are the x/y coords
+ * in percentages relative to the element.
+ */
+ convertPageCoordsToPercent(pageX, pageY) {
+ // If the current node is in an iframe, we get dimensions relative to the frame.
+ const dims = this.frameDimensions;
+ const { top, left, width, height } = dims;
+ pageX -= left;
+ pageY -= top;
+ const percentX = (pageX * 100) / width;
+ const percentY = (pageY * 100) / height;
+ return { percentX, percentY };
+ }
+
+ /**
+ * Convert the given x/y coordinates, in percentages relative to the current element,
+ * to pixel coordinates relative to the page
+ * @param {Number} x the x coordinate
+ * @param {Number} y the y coordinate
+ * @returns {Object} object of form {x, y}, which are the x/y coords in pixels
+ * relative to the page
+ *
+ * @memberof ShapesHighlighter
+ */
+ convertPercentToPageCoords(x, y) {
+ const dims = this.frameDimensions;
+ const { top, left, width, height } = dims;
+ x = (x * width) / 100;
+ y = (y * height) / 100;
+ x += left;
+ y += top;
+ return { x, y };
+ }
+
+ /**
+ * Get which transformation should be applied based on the mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @returns {String} a string describing the transformation that should be applied
+ * to the shape.
+ */
+ getTransformPointAt(pageX, pageY) {
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ const points = [
+ { pointName: "translate", x: center[0], y: center[1] },
+ { pointName: "scale-se", x: se[0], y: se[1] },
+ { pointName: "scale-ne", x: ne[0], y: ne[1] },
+ { pointName: "scale-sw", x: sw[0], y: sw[1] },
+ { pointName: "scale-nw", x: nw[0], y: nw[1] },
+ ];
+
+ if (this.shapeType === "polygon" || this.shapeType === "ellipse") {
+ points.push(
+ { pointName: "scale-n", x: n[0], y: n[1] },
+ { pointName: "scale-s", x: s[0], y: s[1] },
+ { pointName: "scale-e", x: e[0], y: e[1] },
+ { pointName: "scale-w", x: w[0], y: w[1] }
+ );
+ }
+
+ if (this.shapeType === "polygon") {
+ const x = rotatePoint[0];
+ const y = rotatePoint[1];
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return "rotate";
+ }
+ }
+
+ for (const { pointName, x, y } of points) {
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return pointName;
+ }
+ }
+
+ return "";
+ }
+
+ /**
+ * Get the id of the point on the polygon highlighter at the given coordinate.
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {Number} the index of the point that was clicked on in this.coordinates,
+ * or -1 if none of the points were clicked on.
+ */
+ getPolygonPointAt(pageX, pageY) {
+ const { coordinates } = this;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ for (const [index, coord] of coordinates.entries()) {
+ const [x, y] = coord;
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Check if the mouse clicked on a line of the polygon, and if so, add a point near
+ * the click.
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ */
+ getPolygonClickedLine(pageX, pageY) {
+ const { coordinates } = this;
+ const { width } = this.currentDimensions;
+ const clickWidth = (LINE_CLICK_WIDTH * 100) / width;
+
+ for (let i = 0; i < coordinates.length; i++) {
+ const [x1, y1] = coordinates[i];
+ const [x2, y2] =
+ i === coordinates.length - 1 ? coordinates[0] : coordinates[i + 1];
+ // Get the distance between clicked point and line drawn between points 1 and 2
+ // to check if the click was on the line between those two points.
+ const distance = distanceToLine(x1, y1, x2, y2, pageX, pageY);
+ if (
+ distance <= clickWidth &&
+ Math.min(x1, x2) - clickWidth <= pageX &&
+ pageX <= Math.max(x1, x2) + clickWidth &&
+ Math.min(y1, y2) - clickWidth <= pageY &&
+ pageY <= Math.max(y1, y2) + clickWidth
+ ) {
+ // Get the point on the line closest to the clicked point.
+ const [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY);
+ // Default unit for new points is percentages
+ this._addPolygonPoint(i, round(newX, "%"), round(newY, "%"));
+ return;
+ }
+ }
+ }
+
+ /**
+ * Check if the center point or radius of the circle highlighter is at given coords
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "center" if the center point was clicked, "radius" if the radius
+ * was clicked, "" if neither was clicked.
+ */
+ getCirclePointAt(pageX, pageY) {
+ const { cx, cy, rx, ry } = this.coordinates;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+ return "center";
+ }
+
+ const clickWidthX = (LINE_CLICK_WIDTH * 100) / width;
+ const clickWidthY = (LINE_CLICK_WIDTH * 100) / height;
+ if (
+ clickedOnEllipseEdge(
+ pageX,
+ pageY,
+ cx,
+ cy,
+ rx,
+ ry,
+ clickWidthX,
+ clickWidthY
+ ) ||
+ clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)
+ ) {
+ return "radius";
+ }
+
+ return "";
+ }
+
+ /**
+ * Check if the center or rx/ry points of the ellipse highlighter is at given point
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "center" if the center point was clicked, "rx" if the x-radius
+ * point was clicked, "ry" if the y-radius point was clicked,
+ * "" if none was clicked.
+ */
+ getEllipsePointAt(pageX, pageY) {
+ const { cx, cy, rx, ry } = this.coordinates;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+ return "center";
+ }
+
+ if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) {
+ return "rx";
+ }
+
+ if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) {
+ return "ry";
+ }
+
+ return "";
+ }
+
+ /**
+ * Check if the edges of the inset highlighter is at given coords
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "top", "left", "right", or "bottom" if any of those edges were
+ * clicked. "" if none were clicked.
+ */
+ // eslint-disable-next-line complexity
+ getInsetPointAt(pageX, pageY) {
+ const { top, left, right, bottom } = this.coordinates;
+ const zoom = getCurrentZoom(this.win);
+ const { width, height } = this.currentDimensions;
+ const clickWidthX = (LINE_CLICK_WIDTH * 100) / width;
+ const clickWidthY = (LINE_CLICK_WIDTH * 100) / height;
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+
+ if (
+ (pageX >= left - clickWidthX &&
+ pageX <= left + clickWidthX &&
+ pageY >= top &&
+ pageY <= 100 - bottom) ||
+ clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY)
+ ) {
+ return "left";
+ }
+
+ if (
+ (pageX >= 100 - right - clickWidthX &&
+ pageX <= 100 - right + clickWidthX &&
+ pageY >= top &&
+ pageY <= 100 - bottom) ||
+ clickedOnPoint(
+ pageX,
+ pageY,
+ 100 - right,
+ centerY,
+ clickRadiusX,
+ clickRadiusY
+ )
+ ) {
+ return "right";
+ }
+
+ if (
+ (pageY >= top - clickWidthY &&
+ pageY <= top + clickWidthY &&
+ pageX >= left &&
+ pageX <= 100 - right) ||
+ clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY)
+ ) {
+ return "top";
+ }
+
+ if (
+ (pageY >= 100 - bottom - clickWidthY &&
+ pageY <= 100 - bottom + clickWidthY &&
+ pageX >= left &&
+ pageX <= 100 - right) ||
+ clickedOnPoint(
+ pageX,
+ pageY,
+ centerX,
+ 100 - bottom,
+ clickRadiusX,
+ clickRadiusY
+ )
+ ) {
+ return "bottom";
+ }
+
+ return "";
+ }
+
+ /**
+ * Parses the CSS definition given and returns the shape type associated
+ * with the definition and the coordinates necessary to draw the shape.
+ * @param {String} definition the input CSS definition
+ * @returns {Object} null if the definition is not of a known shape type,
+ * or an object of the type { shapeType, coordinates }, where
+ * shapeType is the name of the shape and coordinates are an array
+ * or object of the coordinates needed to draw the shape.
+ */
+ _parseCSSShapeValue(definition) {
+ const shapeTypes = [
+ {
+ name: "polygon",
+ prefix: "polygon(",
+ coordParser: this.polygonPoints.bind(this),
+ },
+ {
+ name: "circle",
+ prefix: "circle(",
+ coordParser: this.circlePoints.bind(this),
+ },
+ {
+ name: "ellipse",
+ prefix: "ellipse(",
+ coordParser: this.ellipsePoints.bind(this),
+ },
+ {
+ name: "inset",
+ prefix: "inset(",
+ coordParser: this.insetPoints.bind(this),
+ },
+ ];
+ const geometryTypes = ["margin", "border", "padding", "content"];
+ // default to border for clip-path and offset-path, and margin for shape-outside
+ const defaultGeometryTypesByProperty = new Map([
+ ["clip-path", "border"],
+ ["offset-path", "border"],
+ ["shape-outside", "margin"],
+ ]);
+
+ let referenceBox = defaultGeometryTypesByProperty.get(this.property);
+ for (const geometry of geometryTypes) {
+ if (definition.includes(geometry)) {
+ referenceBox = geometry;
+ }
+ }
+ this.referenceBox = referenceBox;
+
+ this.useStrokeBox = definition.includes("stroke-box");
+ this.geometryBox = definition
+ .substring(definition.lastIndexOf(")") + 1)
+ .trim();
+
+ for (const { name, prefix, coordParser } of shapeTypes) {
+ if (definition.includes(prefix)) {
+ // the closing paren of the shape function is always the last one in definition.
+ definition = definition.substring(
+ prefix.length,
+ definition.lastIndexOf(")")
+ );
+ return {
+ shapeType: name,
+ coordinates: coordParser(definition),
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Parses the definition of the CSS polygon() function and returns its points,
+ * converted to percentages.
+ * @param {String} definition the arguments of the polygon() function
+ * @returns {Array} an array of the points of the polygon, with all values
+ * evaluated and converted to percentages
+ */
+ polygonPoints(definition) {
+ this.coordUnits = this.polygonRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+ const splitDef = definition.split(", ");
+ if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
+ splitDef.shift();
+ }
+ let minX = Number.MAX_SAFE_INTEGER;
+ let minY = Number.MAX_SAFE_INTEGER;
+ let maxX = Number.MIN_SAFE_INTEGER;
+ let maxY = Number.MIN_SAFE_INTEGER;
+ const coordinates = splitDef.map(coords => {
+ const [x, y] = splitCoords(coords).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+ if (x < minX) {
+ minX = x;
+ }
+ if (y < minY) {
+ minY = y;
+ }
+ if (x > maxX) {
+ maxX = x;
+ }
+ if (y > maxY) {
+ maxY = y;
+ }
+ return [x, y];
+ });
+ this.boundingBox = { minX, minY, maxX, maxY };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return coordinates;
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS polygon.
+ * @returns {Array} an array of the points of the polygon, with units preserved.
+ */
+ polygonRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(8, definition.lastIndexOf(")"));
+ const splitDef = definition.split(", ");
+ if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) {
+ this.fillRule = splitDef[0].trim();
+ splitDef.shift();
+ } else {
+ this.fillRule = "";
+ }
+ return splitDef.map(coords => {
+ return splitCoords(coords).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ });
+ });
+ }
+
+ /**
+ * Parses the definition of the CSS circle() function and returns the x/y radiuses and
+ * center coordinates, converted to percentages.
+ * @param {String} definition the arguments of the circle() function
+ * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
+ * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
+ * center of the circle. All values are evaluated and converted to percentages.
+ */
+ circlePoints(definition) {
+ this.coordUnits = this.circleRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+
+ const values = definition.split("at");
+ let radius = values[0] ? values[0].trim() : "closest-side";
+ const { width, height } = this.currentDimensions;
+ // This defaults to center if omitted.
+ const position = values[1] || "50% 50%";
+ const center = splitCoords(position).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+
+ // Percentage values for circle() are resolved from the
+ // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+
+ // Position coordinates for circle center in pixels.
+ const cxPx = (width * center[0]) / 100;
+ const cyPx = (height * center[1]) / 100;
+
+ if (radius === "closest-side") {
+ // radius is the distance from center to closest side of reference box
+ radius = Math.min(cxPx, cyPx, width - cxPx, height - cyPx);
+ radius = coordToPercent(`${radius}px`, computedSize);
+ } else if (radius === "farthest-side") {
+ // radius is the distance from center to farthest side of reference box
+ radius = Math.max(cxPx, cyPx, width - cxPx, height - cyPx);
+ radius = coordToPercent(`${radius}px`, computedSize);
+ } else if (radius.includes("calc(")) {
+ radius = evalCalcExpression(
+ radius.substring(5, radius.length - 1),
+ computedSize
+ );
+ } else {
+ radius = coordToPercent(radius, computedSize);
+ }
+
+ // Scale both radiusX and radiusY to match the radius computed
+ // using the above equation.
+ const ratioX = width / computedSize;
+ const ratioY = height / computedSize;
+ const radiusX = radius / ratioX;
+ const radiusY = radius / ratioY;
+
+ this.boundingBox = {
+ minX: center[0] - radiusX,
+ maxX: center[0] + radiusX,
+ minY: center[1] - radiusY,
+ maxY: center[1] + radiusY,
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS circle.
+ * @returns {Object} an object of the points of the circle (cx, cy, radius),
+ * with units preserved.
+ */
+ circleRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(7, definition.lastIndexOf(")"));
+
+ const values = definition.split("at");
+ const [cx = "", cy = ""] = values[1]
+ ? splitCoords(values[1]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ const radius = values[0] ? values[0].trim() : "closest-side";
+ return { cx, cy, radius };
+ }
+
+ /**
+ * Parses the computed style definition of the CSS ellipse() function and returns the
+ * x/y radii and center coordinates, converted to percentages.
+ * @param {String} definition the arguments of the ellipse() function
+ * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
+ * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
+ * center of the ellipse. All values are evaluated and converted to percentages
+ */
+ ellipsePoints(definition) {
+ this.coordUnits = this.ellipseRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+
+ const values = definition.split("at");
+ // This defaults to center if omitted.
+ const position = values[1] || "50% 50%";
+ const center = splitCoords(position).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+
+ let radii = values[0] ? values[0].trim() : "closest-side closest-side";
+ radii = splitCoords(radii).map((radius, i) => {
+ if (radius === "closest-side") {
+ // radius is the distance from center to closest x/y side of reference box
+ return i % 2 === 0
+ ? Math.min(center[0], 100 - center[0])
+ : Math.min(center[1], 100 - center[1]);
+ } else if (radius === "farthest-side") {
+ // radius is the distance from center to farthest x/y side of reference box
+ return i % 2 === 0
+ ? Math.max(center[0], 100 - center[0])
+ : Math.max(center[1], 100 - center[1]);
+ }
+ return this.convertCoordsToPercent(radius, i);
+ });
+
+ this.boundingBox = {
+ minX: center[0] - radii[0],
+ maxX: center[0] + radii[0],
+ minY: center[1] - radii[1],
+ maxY: center[1] + radii[1],
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS ellipse.
+ * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry),
+ * with units preserved.
+ */
+ ellipseRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(8, definition.lastIndexOf(")"));
+
+ const values = definition.split("at");
+ const [rx = "closest-side", ry = "closest-side"] = values[0]
+ ? splitCoords(values[0]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ const [cx = "", cy = ""] = values[1]
+ ? splitCoords(values[1]).map(coord => {
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ return { rx, ry, cx, cy };
+ }
+
+ /**
+ * Parses the definition of the CSS inset() function and returns the x/y offsets and
+ * width/height of the shape, converted to percentages. Border radiuses (given after
+ * "round" in the definition) are currently ignored.
+ * @param {String} definition the arguments of the inset() function
+ * @returns {Object} an object of the form { x, y, width, height }, which are the top/
+ * left positions and width/height of the shape.
+ */
+ insetPoints(definition) {
+ this.coordUnits = this.insetRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+ const values = definition.split(" round ");
+ const offsets = splitCoords(values[0]);
+
+ let top, left, right, bottom;
+ // The offsets, like margin/padding/border, are in order: top, right, bottom, left.
+ if (offsets.length === 1) {
+ top = left = right = bottom = offsets[0];
+ } else if (offsets.length === 2) {
+ top = bottom = offsets[0];
+ left = right = offsets[1];
+ } else if (offsets.length === 3) {
+ top = offsets[0];
+ left = right = offsets[1];
+ bottom = offsets[2];
+ } else if (offsets.length === 4) {
+ top = offsets[0];
+ right = offsets[1];
+ bottom = offsets[2];
+ left = offsets[3];
+ }
+
+ top = this.convertCoordsToPercentFromCurrentDimension(top, "height");
+ bottom = this.convertCoordsToPercentFromCurrentDimension(bottom, "height");
+ left = this.convertCoordsToPercentFromCurrentDimension(left, "width");
+ right = this.convertCoordsToPercentFromCurrentDimension(right, "width");
+
+ // maxX/maxY are found by subtracting the right/bottom edges from 100
+ // (the width/height of the element in %)
+ this.boundingBox = {
+ minX: left,
+ maxX: 100 - right,
+ minY: top,
+ maxY: 100 - bottom,
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { top, left, right, bottom };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS inset.
+ * @returns {Object} an object of the points of the inset (top, right, bottom, left),
+ * with units preserved.
+ */
+ insetRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(6, definition.lastIndexOf(")"));
+
+ const values = definition.split(" round ");
+ this.insetRound = values[1];
+ const offsets = splitCoords(values[0]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ });
+
+ let top,
+ left,
+ right,
+ bottom = 0;
+
+ if (offsets.length === 1) {
+ top = left = right = bottom = offsets[0];
+ } else if (offsets.length === 2) {
+ top = bottom = offsets[0];
+ left = right = offsets[1];
+ } else if (offsets.length === 3) {
+ top = offsets[0];
+ left = right = offsets[1];
+ bottom = offsets[2];
+ } else if (offsets.length === 4) {
+ top = offsets[0];
+ right = offsets[1];
+ bottom = offsets[2];
+ left = offsets[3];
+ }
+
+ return { top, left, right, bottom };
+ }
+
+ /**
+ * This uses the index to decide whether to use width or height for the
+ * computation. See `convertCoordsToPercentFromCurrentDimension()` if you
+ * need to specify width or height.
+ * @param {Number} coord a single coordinate
+ * @param {Number} i the index of its position in the function
+ * @returns {Number} the coordinate as a percentage value
+ */
+ convertCoordsToPercent(coord, i) {
+ const { width, height } = this.currentDimensions;
+ const size = i % 2 === 0 ? width : height;
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ }
+
+ /**
+ * Converts a value to percent based on the specified dimension.
+ * @param {Number} coord a single coordinate
+ * @param {Number} currentDimensionProperty the dimension ("width" or
+ * "height") to base the calculation off of
+ * @returns {Number} the coordinate as a percentage value
+ */
+ convertCoordsToPercentFromCurrentDimension(coord, currentDimensionProperty) {
+ const size = this.currentDimensions[currentDimensionProperty];
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+ super.destroy(this);
+ this.markup.destroy();
+ }
+
+ /**
+ * Get the element in the highlighter markup with the given id
+ * @param {String} id
+ * @returns {Object} the element with the given id
+ */
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Return whether all the elements used to draw shapes are hidden.
+ * @returns {Boolean}
+ */
+ areShapesHidden() {
+ return (
+ this.getElement("ellipse").hasAttribute("hidden") &&
+ this.getElement("polygon").hasAttribute("hidden") &&
+ this.getElement("rect").hasAttribute("hidden") &&
+ this.getElement("bounding-box").hasAttribute("hidden")
+ );
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ this.hoveredPoint = this.options.hoverPoint;
+ this.transformMode = this.options.transformMode;
+ this.coordinates = null;
+ this.coordUnits = null;
+ this.origBoundingBox = null;
+ this.origCoordUnits = null;
+ this.origCoordinates = null;
+ this.transformedBoundingBox = null;
+ if (this.transformMode) {
+ this.transformMatrix = identity();
+ }
+ if (this._hasMoved() && this.transformMode) {
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+ return this._update();
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's
+ * quads have changed. Override it so it also returns true if the element's shape has
+ * changed (which can happen when you change a CSS properties for instance).
+ */
+ _hasMoved() {
+ let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ if (hasMoved) {
+ this.origBoundingBox = null;
+ this.origCoordUnits = null;
+ this.origCoordinates = null;
+ if (this.transformMode) {
+ this.transformMatrix = identity();
+ }
+ }
+
+ const oldShapeCoordinates = JSON.stringify(this.coordinates);
+
+ // TODO: need other modes too.
+ if (this.options.mode.startsWith("css")) {
+ const property = shapeModeToCssPropertyName(this.options.mode);
+ // change camelCase to kebab-case
+ this.property = property.replace(/([a-z][A-Z])/g, g => {
+ return g[0] + "-" + g[1].toLowerCase();
+ });
+ const style = getComputedStyle(this.currentNode)[property];
+
+ if (!style || style === "none") {
+ this.coordinates = [];
+ this.shapeType = "none";
+ } else {
+ const { coordinates, shapeType } = this._parseCSSShapeValue(style);
+ this.coordinates = coordinates;
+ if (!this.origCoordinates) {
+ this.origCoordinates = coordinates;
+ }
+ this.shapeType = shapeType;
+ }
+ }
+
+ const newShapeCoordinates = JSON.stringify(this.coordinates);
+ hasMoved = hasMoved || oldShapeCoordinates !== newShapeCoordinates;
+ if (this.transformMode && hasMoved) {
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+
+ return hasMoved;
+ }
+
+ /**
+ * Hide all elements used to highlight CSS different shapes.
+ */
+ _hideShapes() {
+ this.getElement("ellipse").setAttribute("hidden", true);
+ this.getElement("polygon").setAttribute("hidden", true);
+ this.getElement("rect").setAttribute("hidden", true);
+ this.getElement("bounding-box").setAttribute("hidden", true);
+ this.getElement("markers").setAttribute("d", "");
+ this.getElement("markers-outline").setAttribute("d", "");
+ this.getElement("rotate-line").setAttribute("d", "");
+ this.getElement("quad").setAttribute("hidden", true);
+ this.getElement("clip-ellipse").setAttribute("hidden", true);
+ this.getElement("clip-polygon").setAttribute("hidden", true);
+ this.getElement("clip-rect").setAttribute("hidden", true);
+ this.getElement("dashed-polygon").setAttribute("hidden", true);
+ this.getElement("dashed-ellipse").setAttribute("hidden", true);
+ this.getElement("dashed-rect").setAttribute("hidden", true);
+ }
+
+ /**
+ * Update the highlighter for the current node. Called whenever the element's quads
+ * or CSS shape has changed.
+ * @returns {Boolean} whether the highlighter was successfully updated
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+ this.getElement("group").setAttribute("transform", "");
+ const root = this.getElement("root");
+ root.setAttribute("hidden", true);
+
+ const { top, left, width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+
+ // Size the SVG like the current node.
+ this.getElement("shape-container").setAttribute(
+ "style",
+ `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`
+ );
+
+ this._hideShapes();
+ this._updateShapes(width, height, zoom);
+
+ // For both shape-outside and clip-path the element's quads are displayed for the
+ // parts that overlap with the shape. The parts of the shape that extend past the
+ // element's quads are shown with a dashed line.
+ const quadRect = this.getElement("quad");
+ quadRect.removeAttribute("hidden");
+
+ this.getElement("polygon").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+ this.getElement("ellipse").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+ this.getElement("rect").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+
+ const { width: winWidth, height: winHeight } = this._winDimensions;
+ root.removeAttribute("hidden");
+ root.setAttribute(
+ "style",
+ `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden;`
+ );
+
+ this._handleMarkerHover(this.hoveredPoint);
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return true;
+ }
+
+ /**
+ * Update the SVGs to render the current CSS shape and add markers depending on shape
+ * type and transform mode.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateShapes(width, height, zoom) {
+ if (this.transformMode && this.shapeType !== "none") {
+ this._updateTransformMode(width, height, zoom);
+ } else if (this.shapeType === "polygon") {
+ this._updatePolygonShape(width, height, zoom);
+ // Draw markers for each of the polygon's points.
+ this._drawMarkers(this.coordinates, width, height, zoom);
+ } else if (this.shapeType === "circle") {
+ const { rx, cx, cy } = this.coordinates;
+ // Shape renders for "circle()" and "ellipse()" use the same SVG nodes.
+ this._updateEllipseShape(width, height, zoom);
+ // Draw markers for center and radius points.
+ this._drawMarkers(
+ [
+ [cx, cy],
+ [cx + rx, cy],
+ ],
+ width,
+ height,
+ zoom
+ );
+ } else if (this.shapeType === "ellipse") {
+ const { rx, ry, cx, cy } = this.coordinates;
+ this._updateEllipseShape(width, height, zoom);
+ // Draw markers for center, horizontal radius and vertical radius points.
+ this._drawMarkers(
+ [
+ [cx, cy],
+ [cx + rx, cy],
+ [cx, cy + ry],
+ ],
+ width,
+ height,
+ zoom
+ );
+ } else if (this.shapeType === "inset") {
+ const { top, left, right, bottom } = this.coordinates;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+ const markerCoords = [
+ [centerX, top],
+ [100 - right, centerY],
+ [centerX, 100 - bottom],
+ [left, centerY],
+ ];
+ this._updateInsetShape(width, height, zoom);
+ // Draw markers for each of the inset's sides.
+ this._drawMarkers(markerCoords, width, height, zoom);
+ }
+ }
+
+ /**
+ * Update the SVGs for transform mode to fit the new shape.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateTransformMode(width, height, zoom) {
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+ const boundingBox = this.getElement("bounding-box");
+ const path = `M${nw.join(" ")} L${ne.join(" ")} L${se.join(" ")} L${sw.join(
+ " "
+ )} Z`;
+ boundingBox.setAttribute("d", path);
+ boundingBox.removeAttribute("hidden");
+
+ const markerPoints = [center, nw, ne, se, sw];
+ if (this.shapeType === "polygon" || this.shapeType === "ellipse") {
+ markerPoints.push(n, s, w, e);
+ }
+
+ if (this.shapeType === "polygon") {
+ this._updatePolygonShape(width, height, zoom);
+ markerPoints.push(rotatePoint);
+ const rotateLine = `M ${center.join(" ")} L ${rotatePoint.join(" ")}`;
+ this.getElement("rotate-line").setAttribute("d", rotateLine);
+ } else if (this.shapeType === "circle" || this.shapeType === "ellipse") {
+ // Shape renders for "circle()" and "ellipse()" use the same SVG nodes.
+ this._updateEllipseShape(width, height, zoom);
+ } else if (this.shapeType === "inset") {
+ this._updateInsetShape(width, height, zoom);
+ }
+
+ this._drawMarkers(markerPoints, width, height, zoom);
+ }
+
+ /**
+ * Update the SVG polygon to fit the CSS polygon.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updatePolygonShape(width, height, zoom) {
+ // Draw and show the polygon.
+ const points = this.coordinates.map(point => point.join(",")).join(" ");
+
+ const polygonEl = this.getElement("polygon");
+ polygonEl.setAttribute("points", points);
+ polygonEl.removeAttribute("hidden");
+
+ const clipPolygon = this.getElement("clip-polygon");
+ clipPolygon.setAttribute("points", points);
+ clipPolygon.removeAttribute("hidden");
+
+ const dashedPolygon = this.getElement("dashed-polygon");
+ dashedPolygon.setAttribute("points", points);
+ dashedPolygon.removeAttribute("hidden");
+ }
+
+ /**
+ * Update the SVG ellipse to fit the CSS circle or ellipse.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateEllipseShape(width, height, zoom) {
+ const { rx, ry, cx, cy } = this.coordinates;
+ const ellipseEl = this.getElement("ellipse");
+ ellipseEl.setAttribute("rx", rx);
+ ellipseEl.setAttribute("ry", ry);
+ ellipseEl.setAttribute("cx", cx);
+ ellipseEl.setAttribute("cy", cy);
+ ellipseEl.removeAttribute("hidden");
+
+ const clipEllipse = this.getElement("clip-ellipse");
+ clipEllipse.setAttribute("rx", rx);
+ clipEllipse.setAttribute("ry", ry);
+ clipEllipse.setAttribute("cx", cx);
+ clipEllipse.setAttribute("cy", cy);
+ clipEllipse.removeAttribute("hidden");
+
+ const dashedEllipse = this.getElement("dashed-ellipse");
+ dashedEllipse.setAttribute("rx", rx);
+ dashedEllipse.setAttribute("ry", ry);
+ dashedEllipse.setAttribute("cx", cx);
+ dashedEllipse.setAttribute("cy", cy);
+ dashedEllipse.removeAttribute("hidden");
+ }
+
+ /**
+ * Update the SVG rect to fit the CSS inset.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateInsetShape(width, height, zoom) {
+ const { top, left, right, bottom } = this.coordinates;
+ const rectEl = this.getElement("rect");
+ rectEl.setAttribute("x", left);
+ rectEl.setAttribute("y", top);
+ rectEl.setAttribute("width", 100 - left - right);
+ rectEl.setAttribute("height", 100 - top - bottom);
+ rectEl.removeAttribute("hidden");
+
+ const clipRect = this.getElement("clip-rect");
+ clipRect.setAttribute("x", left);
+ clipRect.setAttribute("y", top);
+ clipRect.setAttribute("width", 100 - left - right);
+ clipRect.setAttribute("height", 100 - top - bottom);
+ clipRect.removeAttribute("hidden");
+
+ const dashedRect = this.getElement("dashed-rect");
+ dashedRect.setAttribute("x", left);
+ dashedRect.setAttribute("y", top);
+ dashedRect.setAttribute("width", 100 - left - right);
+ dashedRect.setAttribute("height", 100 - top - bottom);
+ dashedRect.removeAttribute("hidden");
+ }
+
+ /**
+ * Draw markers for the given coordinates.
+ * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...]
+ * @param {Number} width the width of the element markers are being drawn for
+ * @param {Number} height the height of the element markers are being drawn for
+ * @param {Number} zoom the zoom level of the window
+ */
+ _drawMarkers(coords, width, height, zoom) {
+ const markers = coords
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom);
+ })
+ .join(" ");
+ const outline = coords
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE + 2, x, y, width, height, zoom);
+ })
+ .join(" ");
+
+ this.getElement("markers").setAttribute("d", markers);
+ this.getElement("markers-outline").setAttribute("d", outline);
+ }
+
+ /**
+ * Calculate the bounding box of the shape after it is transformed according to
+ * the transformation matrix.
+ * @returns {Object} of form { nw, ne, sw, se, n, s, w, e, rotatePoint, center }.
+ * Each element in the object is an array of form [x,y], denoting the x/y
+ * coordinates of the given point.
+ */
+ calculateTransformedBoundingBox() {
+ const { minX, minY, maxX, maxY } = this.origBoundingBox;
+ const { width, height } = this.currentDimensions;
+ const toPixel = scale(width / 100, height / 100);
+ const toPercent = scale(100 / width, 100 / height);
+ const matrix = multiply(toPercent, multiply(this.transformMatrix, toPixel));
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+ const nw = apply(matrix, [minX, minY]);
+ const ne = apply(matrix, [maxX, minY]);
+ const sw = apply(matrix, [minX, maxY]);
+ const se = apply(matrix, [maxX, maxY]);
+ const n = apply(matrix, [centerX, minY]);
+ const s = apply(matrix, [centerX, maxY]);
+ const w = apply(matrix, [minX, centerY]);
+ const e = apply(matrix, [maxX, centerY]);
+ const center = apply(matrix, [centerX, centerY]);
+
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ const { basis, invertedBasis } = getBasis(u, v);
+ let rotatePointMatrix = changeMatrixBase(
+ translate(0, -ROTATE_LINE_LENGTH),
+ invertedBasis,
+ basis
+ );
+ rotatePointMatrix = multiply(
+ toPercent,
+ multiply(rotatePointMatrix, multiply(this.transformMatrix, toPixel))
+ );
+ const rotatePoint = apply(rotatePointMatrix, [centerX, centerY]);
+ return { nw, ne, sw, se, n, s, w, e, rotatePoint, center };
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._hideShapes();
+ this.getElement("markers").setAttribute("d", "");
+ this.getElement("root").setAttribute("style", "");
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ onPageHide({ target }) {
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Get the rough direction of the point relative to the anchor.
+ * If the handle is roughly horizontal relative to the anchor, return "ew".
+ * If the handle is roughly vertical relative to the anchor, return "ns"
+ * If the handle is roughly above/right or below/left, return "nesw"
+ * If the handle is roughly above/left or below/right, return "nwse"
+ * @param {String} pointName the name of the point being hovered
+ * @param {String} anchor the name of the anchor point
+ * @returns {String} The rough direction of the point relative to the anchor
+ */
+ getRoughDirection(pointName, anchor) {
+ const scalePoint = pointName.split("-")[1];
+ const anchorPos = this.transformedBoundingBox[anchor];
+ const scalePos = this.transformedBoundingBox[scalePoint];
+ const { minX, minY, maxX, maxY } = this.boundingBox;
+ const width = maxX - minX;
+ const height = maxY - minY;
+ const dx = (scalePos[0] - anchorPos[0]) / width;
+ const dy = (scalePos[1] - anchorPos[1]) / height;
+ if (dx >= -0.33 && dx <= 0.33) {
+ return "ns";
+ } else if (dy >= -0.33 && dy <= 0.33) {
+ return "ew";
+ } else if ((dx > 0.33 && dy < -0.33) || (dx < -0.33 && dy > 0.33)) {
+ return "nesw";
+ }
+ return "nwse";
+ }
+
+ /**
+ * Given a unit type, get the ratio by which to multiply a pixel value in order to
+ * convert pixels to that unit.
+ *
+ * Percentage units (%) are relative to a size. This must be provided when requesting
+ * a ratio for converting from pixels to percentages.
+ *
+ * @param {String} unit
+ * One of: %, em, rem, vw, vh
+ * @param {Number} size
+ * Size to which percentage values are relative to.
+ * @return {Number}
+ */
+ getUnitToPixelRatio(unit, size) {
+ let ratio;
+ const windowHeight = this.currentNode.ownerGlobal.innerHeight;
+ const windowWidth = this.currentNode.ownerGlobal.innerWidth;
+ switch (unit) {
+ case "%":
+ ratio = 100 / size;
+ break;
+ case "em":
+ ratio = 1 / parseFloat(getComputedStyle(this.currentNode).fontSize);
+ break;
+ case "rem":
+ const root = this.currentNode.ownerDocument.documentElement;
+ ratio = 1 / parseFloat(getComputedStyle(root).fontSize);
+ break;
+ case "vw":
+ ratio = 100 / windowWidth;
+ break;
+ case "vh":
+ ratio = 100 / windowHeight;
+ break;
+ case "vmin":
+ ratio = 100 / Math.min(windowHeight, windowWidth);
+ break;
+ case "vmax":
+ ratio = 100 / Math.max(windowHeight, windowWidth);
+ break;
+ default:
+ // If unit is not recognized, peg ratio 1:1 to pixels.
+ ratio = 1;
+ }
+
+ return ratio;
+ }
+}
+
+/**
+ * Get the "raw" (i.e. non-computed) shape definition on the given node.
+ * @param {Node} node the node to analyze
+ * @param {String} property the CSS property for which a value should be retrieved.
+ * @returns {String} the value of the given CSS property on the given node.
+ */
+function getDefinedShapeProperties(node, property) {
+ let prop = "";
+ if (!node) {
+ return prop;
+ }
+
+ const cssRules = getCSSStyleRules(node);
+ for (let i = 0; i < cssRules.length; i++) {
+ const rule = cssRules[i];
+ const value = rule.style.getPropertyValue(property);
+ if (value && value !== "auto") {
+ prop = value;
+ }
+ }
+
+ if (node.style) {
+ const value = node.style.getPropertyValue(property);
+ if (value && value !== "auto") {
+ prop = value;
+ }
+ }
+
+ return prop.trim();
+}
+
+/**
+ * Split coordinate pairs separated by a space and return an array.
+ * @param {String} coords the coordinate pair, where each coord is separated by a space.
+ * @returns {Array} a 2 element array containing the coordinates.
+ */
+function splitCoords(coords) {
+ // All coordinate pairs are of the form "x y" where x and y are values or
+ // calc() expressions. calc() expressions have spaces around operators, so
+ // replace those spaces with \u00a0 (non-breaking space) so they will not be
+ // split later.
+ return coords
+ .trim()
+ .replace(/ [\+\-\*\/] /g, match => {
+ return `\u00a0${match.trim()}\u00a0`;
+ })
+ .split(" ");
+}
+exports.splitCoords = splitCoords;
+
+/**
+ * Convert a coordinate to a percentage value.
+ * @param {String} coord a single coordinate
+ * @param {Number} size the size of the element (width or height) that the percentages
+ * are relative to
+ * @returns {Number} the coordinate as a percentage value
+ */
+function coordToPercent(coord, size) {
+ if (coord.includes("%")) {
+ // Just remove the % sign, nothing else to do, we're in a viewBox that's 100%
+ // worth.
+ return parseFloat(coord.replace("%", ""));
+ } else if (coord.includes("px")) {
+ // Convert the px value to a % value.
+ const px = parseFloat(coord.replace("px", ""));
+ return (px * 100) / size;
+ }
+
+ // Unit-less value, so 0.
+ return 0;
+}
+exports.coordToPercent = coordToPercent;
+
+/**
+ * Evaluates a CSS calc() expression (only handles addition)
+ * @param {String} expression the arguments to the calc() function
+ * @param {Number} size the size of the element (width or height) that percentage values
+ * are relative to
+ * @returns {Number} the result of the expression as a percentage value
+ */
+function evalCalcExpression(expression, size) {
+ // the calc() values returned by getComputedStyle only have addition, as it
+ // computes calc() expressions as much as possible without resolving percentages,
+ // leaving only addition.
+ const values = expression.split("+").map(v => v.trim());
+
+ return values.reduce((prev, curr) => {
+ return prev + coordToPercent(curr, size);
+ }, 0);
+}
+exports.evalCalcExpression = evalCalcExpression;
+
+/**
+ * Converts a shape mode to the proper CSS property name.
+ * @param {String} mode the mode of the CSS shape
+ * @returns the equivalent CSS property name
+ */
+const shapeModeToCssPropertyName = mode => {
+ const property = mode.substring(3);
+ return property.substring(0, 1).toLowerCase() + property.substring(1);
+};
+exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
+
+/**
+ * Get the SVG path definition for a circle with given attributes.
+ * @param {Number} size the radius of the circle in pixels
+ * @param {Number} cx the x coordinate of the centre of the circle
+ * @param {Number} cy the y coordinate of the centre of the circle
+ * @param {Number} width the width of the element the circle is being drawn for
+ * @param {Number} height the height of the element the circle is being drawn for
+ * @param {Number} zoom the zoom level of the window the circle is drawn in
+ * @returns {String} the definition of the circle in SVG path description format.
+ */
+const getCirclePath = (size, cx, cy, width, height, zoom) => {
+ // We use a viewBox of 100x100 for shape-container so it's easy to position things
+ // based on their percentage, but this makes it more difficult to create circles.
+ // Therefor, 100px is the base size of shape-container. In order to make the markers'
+ // size scale properly, we must adjust the radius based on zoom and the width/height of
+ // the element being highlighted, then calculate a radius for both x/y axes based
+ // on the aspect ratio of the element.
+ const radius = (size * (100 / Math.max(width, height))) / zoom;
+ const ratio = width / height;
+ const rx = ratio > 1 ? radius : radius / ratio;
+ const ry = ratio > 1 ? radius * ratio : radius;
+ // a circle is drawn as two arc lines, starting at the leftmost point of the circle.
+ return (
+ `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` +
+ `a${rx},${ry} 0 1,0 ${rx * -2},0`
+ );
+};
+exports.getCirclePath = getCirclePath;
+
+/**
+ * Calculates the object bounding box for a node given its stroke bounding box.
+ * @param {Number} top the y coord of the top edge of the stroke bounding box
+ * @param {Number} left the x coord of the left edge of the stroke bounding box
+ * @param {Number} width the width of the stroke bounding box
+ * @param {Number} height the height of the stroke bounding box
+ * @param {Object} node the node object
+ * @returns {Object} an object of the form { top, left, width, height }, which
+ * are the top/left/width/height of the object bounding box for the node.
+ */
+const getObjectBoundingBox = (top, left, width, height, node) => {
+ // See https://drafts.fxtf.org/css-masking-1/#stroke-bounding-box for details
+ // on this algorithm. Note that we intentionally do not check "stroke-linecap".
+ const strokeWidth = parseFloat(getComputedStyle(node).strokeWidth);
+ let delta = strokeWidth / 2;
+ const tagName = node.tagName;
+
+ if (
+ tagName !== "rect" &&
+ tagName !== "ellipse" &&
+ tagName !== "circle" &&
+ tagName !== "image"
+ ) {
+ if (getComputedStyle(node).strokeLinejoin === "miter") {
+ const miter = getComputedStyle(node).strokeMiterlimit;
+ if (miter < Math.SQRT2) {
+ delta *= Math.SQRT2;
+ } else {
+ delta *= miter;
+ }
+ } else {
+ delta *= Math.SQRT2;
+ }
+ }
+
+ return {
+ top: top + delta,
+ left: left + delta,
+ width: width - 2 * delta,
+ height: height - 2 * delta,
+ };
+};
+
+/**
+ * Get the unit (e.g. px, %, em) for the given point value.
+ * @param {any} point a point value for which a unit should be retrieved.
+ * @returns {String} the unit.
+ */
+const getUnit = point => {
+ // If the point has no unit, default to px.
+ if (isUnitless(point)) {
+ return "px";
+ }
+ const [unit] = point.match(/[^\d]+$/) || ["px"];
+ return unit;
+};
+exports.getUnit = getUnit;
+
+/**
+ * Check if the given point value has a unit.
+ * @param {any} point a point value.
+ * @returns {Boolean} whether the given value has a unit.
+ */
+const isUnitless = point => {
+ return (
+ !point ||
+ !point.match(/[^\d]+$/) ||
+ // If zero doesn't have a unit, its numeric and string forms should be equal.
+ (parseFloat(point) === 0 && parseFloat(point).toString() === point) ||
+ point.includes("(") ||
+ point === "center" ||
+ point === "closest-side" ||
+ point === "farthest-side"
+ );
+};
+
+/**
+ * Return the anchor corresponding to the given scale type.
+ * @param {String} type a scale type, of form "scale-[direction]"
+ * @returns {String} a string describing the anchor, one of the 8 cardinal directions.
+ */
+const getAnchorPoint = type => {
+ let anchor = type.split("-")[1];
+ if (anchor.includes("n")) {
+ anchor = anchor.replace("n", "s");
+ } else if (anchor.includes("s")) {
+ anchor = anchor.replace("s", "n");
+ }
+ if (anchor.includes("w")) {
+ anchor = anchor.replace("w", "e");
+ } else if (anchor.includes("e")) {
+ anchor = anchor.replace("e", "w");
+ }
+
+ if (anchor === "e" || anchor === "w") {
+ anchor = "n" + anchor;
+ } else if (anchor === "n" || anchor === "s") {
+ anchor = anchor + "w";
+ }
+
+ return anchor;
+};
+
+/**
+ * Get the decimal point precision for values depending on unit type.
+ * Only handle pixels and falsy values for now. Round them to the nearest integer value.
+ * All other unit types round to two decimal points.
+ *
+ * @param {String|undefined} unitType any one of the accepted CSS unit types for position.
+ * @return {Number} decimal precision when rounding a value
+ */
+function getDecimalPrecision(unitType) {
+ switch (unitType) {
+ case "px":
+ case "":
+ case undefined:
+ return 0;
+ default:
+ return 2;
+ }
+}
+exports.getDecimalPrecision = getDecimalPrecision;
+
+/**
+ * Round up a numeric value to a fixed number of decimals depending on CSS unit type.
+ * Used when generating output shape values when:
+ * - transforming shapes
+ * - inserting new points on a polygon.
+ *
+ * @param {Number} number
+ * Value to round up.
+ * @param {String} unitType
+ * CSS unit type, like "px", "%", "em", "vh", etc.
+ * @return {Number}
+ * Rounded value
+ */
+function round(number, unitType) {
+ return number.toFixed(getDecimalPrecision(unitType));
+}
+
+exports.ShapesHighlighter = ShapesHighlighter;
diff --git a/devtools/server/actors/highlighters/tabbing-order.js b/devtools/server/actors/highlighters/tabbing-order.js
new file mode 100644
index 0000000000..ab96d30fe6
--- /dev/null
+++ b/devtools/server/actors/highlighters/tabbing-order.js
@@ -0,0 +1,247 @@
+/* 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 lazy = {};
+loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+);
+loader.lazyRequireGetter(
+ this,
+ ["isFrameWithChildTarget", "isWindowIncluded"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "NodeTabbingOrderHighlighter",
+ "resource://devtools/server/actors/highlighters/node-tabbing-order.js",
+ true
+);
+
+const DEFAULT_FOCUS_FLAGS = Services.focus.FLAG_NOSCROLL;
+
+/**
+ * The TabbingOrderHighlighter uses focus manager to traverse all focusable
+ * nodes on the page and then uses the NodeTabbingOrderHighlighter to highlight
+ * these nodes.
+ */
+class TabbingOrderHighlighter {
+ constructor(highlighterEnv) {
+ this.highlighterEnv = highlighterEnv;
+ this._highlighters = new Map();
+
+ this.onMutation = this.onMutation.bind(this);
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that TabbingOrderHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get win() {
+ return this.highlighterEnv.window;
+ }
+
+ get focusedElement() {
+ return Services.focus.getFocusedElementForWindow(this.win, true, {});
+ }
+
+ set focusedElement(element) {
+ Services.focus.setFocus(element, DEFAULT_FOCUS_FLAGS);
+ }
+
+ moveFocus(startElement) {
+ return Services.focus.moveFocus(
+ this.win,
+ startElement.nodeType === Node.DOCUMENT_NODE
+ ? startElement.documentElement
+ : startElement,
+ Services.focus.MOVEFOCUS_FORWARD,
+ DEFAULT_FOCUS_FLAGS
+ );
+ }
+
+ /**
+ * Show NodeTabbingOrderHighlighter on each node that belongs to the keyboard
+ * tabbing order.
+ *
+ * @param {DOMNode} startElm
+ * Starting element to calculate tabbing order from.
+ *
+ * @param {JSON} options
+ * - options.index
+ * Start index for the tabbing order. Starting index will be 0 at
+ * the start of the tabbing order highlighting; in remote frames
+ * starting index will, typically, be greater than 0 (unless there
+ * was nothing to focus in the top level content document prior to
+ * the remote frame).
+ */
+ async show(startElm, { index }) {
+ const focusableElements = [];
+ const originalFocusedElement = this.focusedElement;
+ let currentFocusedElement = this.moveFocus(startElm);
+ while (
+ currentFocusedElement &&
+ isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
+ ) {
+ focusableElements.push(currentFocusedElement);
+ currentFocusedElement = this.moveFocus(currentFocusedElement);
+ }
+
+ // Allow to flush pending notifications to ensure the PresShell and frames
+ // are updated.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ let endElm = this.focusedElement;
+ if (
+ currentFocusedElement &&
+ !isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
+ ) {
+ endElm = null;
+ }
+
+ if (
+ !endElm &&
+ !!focusableElements.length &&
+ isFrameWithChildTarget(
+ this.highlighterEnv.targetActor,
+ focusableElements[focusableElements.length - 1]
+ )
+ ) {
+ endElm = focusableElements[focusableElements.length - 1];
+ }
+
+ if (originalFocusedElement && originalFocusedElement !== endElm) {
+ this.focusedElement = originalFocusedElement;
+ }
+
+ const highlighters = [];
+ for (let i = 0; i < focusableElements.length; i++) {
+ highlighters.push(
+ this._accumulateHighlighter(focusableElements[i], index++)
+ );
+ }
+ await Promise.all(highlighters);
+
+ this._trackMutations();
+
+ return {
+ contentDOMReference: endElm && lazy.ContentDOMReference.get(endElm),
+ index,
+ };
+ }
+
+ async _accumulateHighlighter(node, index) {
+ const highlighter = new NodeTabbingOrderHighlighter(this.highlighterEnv);
+ await highlighter.isReady;
+
+ highlighter.show(node, { index: index + 1 });
+ this._highlighters.set(node, highlighter);
+ }
+
+ hide() {
+ this._untrackMutations();
+ for (const highlighter of this._highlighters.values()) {
+ highlighter.destroy();
+ }
+
+ this._highlighters.clear();
+ }
+
+ /**
+ * Track mutations in the top level document subtree so that the appropriate
+ * NodeTabbingOrderHighlighter infobar's could be updated to reflect the
+ * attribute mutations on relevant nodes.
+ */
+ _trackMutations() {
+ const { win } = this;
+ this.currentMutationObserver = new win.MutationObserver(this.onMutation);
+ this.currentMutationObserver.observe(win.document.documentElement, {
+ subtree: true,
+ attributes: true,
+ });
+ }
+
+ _untrackMutations() {
+ if (!this.currentMutationObserver) {
+ return;
+ }
+
+ this.currentMutationObserver.disconnect();
+ this.currentMutationObserver = null;
+ }
+
+ onMutation(mutationList) {
+ for (const { target } of mutationList) {
+ const highlighter = this._highlighters.get(target);
+ if (highlighter) {
+ highlighter.update();
+ }
+ }
+ }
+
+ /**
+ * Update NodeTabbingOrderHighlighter focus styling for a node that,
+ * potentially, belongs to the tabbing order.
+ * @param {Object} options
+ * Options specifying the node and its focused state.
+ */
+ updateFocus({ node, focused }) {
+ const highlighter = this._highlighters.get(node);
+ if (!highlighter) {
+ return;
+ }
+
+ highlighter.updateFocus(focused);
+ }
+
+ destroy() {
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.hide();
+ this.highlighterEnv = null;
+ }
+
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide
+ // the highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.TabbingOrderHighlighter = TabbingOrderHighlighter;
diff --git a/devtools/server/actors/highlighters/utils/accessibility.js b/devtools/server/actors/highlighters/utils/accessibility.js
new file mode 100644
index 0000000000..23c4210a65
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -0,0 +1,774 @@
+/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ getCurrentZoom,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ truncateString,
+} = require("resource://devtools/shared/inspector/utils.js");
+
+const STRINGS_URI = "devtools/shared/locales/accessibility.properties";
+loader.lazyRequireGetter(
+ this,
+ "LocalizationHelper",
+ "resource://devtools/shared/l10n.js",
+ true
+);
+DevToolsUtils.defineLazyGetter(
+ this,
+ "L10N",
+ () => new LocalizationHelper(STRINGS_URI)
+);
+
+const {
+ accessibility: {
+ AUDIT_TYPE,
+ ISSUE_TYPE: {
+ [AUDIT_TYPE.KEYBOARD]: {
+ FOCUSABLE_NO_SEMANTICS,
+ FOCUSABLE_POSITIVE_TABINDEX,
+ INTERACTIVE_NO_ACTION,
+ INTERACTIVE_NOT_FOCUSABLE,
+ MOUSE_INTERACTIVE_ONLY,
+ NO_FOCUS_VISIBLE,
+ },
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ AREA_NO_NAME_FROM_ALT,
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ FRAME_NO_NAME,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+ },
+ },
+ SCORES,
+ },
+} = require("resource://devtools/shared/constants.js");
+
+// Max string length for truncating accessible name values.
+const MAX_STRING_LENGTH = 50;
+
+/**
+ * The AccessibleInfobar is a class responsible for creating the markup for the
+ * accessible highlighter. It is also reponsible for updating content within the
+ * infobar such as role and name values.
+ */
+class Infobar {
+ constructor(highlighter) {
+ this.highlighter = highlighter;
+ this.audit = new Audit(this);
+ }
+
+ get markup() {
+ return this.highlighter.markup;
+ }
+
+ get document() {
+ return this.highlighter.win.document;
+ }
+
+ get bounds() {
+ return this.highlighter._bounds;
+ }
+
+ get options() {
+ return this.highlighter.options;
+ }
+
+ get prefix() {
+ return this.highlighter.ID_CLASS_PREFIX;
+ }
+
+ get win() {
+ return this.highlighter.win;
+ }
+
+ /**
+ * Move the Infobar to the right place in the highlighter.
+ *
+ * @param {Element} container
+ * Container of infobar.
+ */
+ _moveInfobar(container) {
+ // Position the infobar using accessible's bounds
+ const { left: x, top: y, bottom, width } = this.bounds;
+ const infobarBounds = { x, y, bottom, width };
+
+ moveInfobar(container, infobarBounds, this.win);
+ }
+
+ /**
+ * Build markup for infobar.
+ *
+ * @param {Element} root
+ * Root element to build infobar with.
+ */
+ buildMarkup(root) {
+ const container = this.markup.createNode({
+ parent: root,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ "aria-hidden": "true",
+ hidden: "true",
+ },
+ prefix: this.prefix,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "infobar",
+ id: "infobar",
+ },
+ prefix: this.prefix,
+ });
+
+ const infobarText = this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ id: "infobar-text",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ class: "infobar-role",
+ id: "infobar-role",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ class: "infobar-name",
+ id: "infobar-name",
+ },
+ prefix: this.prefix,
+ });
+
+ this.audit.buildMarkup(infobarText);
+ }
+
+ /**
+ * Destroy the Infobar's highlighter.
+ */
+ destroy() {
+ this.highlighter = null;
+ this.audit.destroy();
+ this.audit = null;
+ }
+
+ /**
+ * Gets the element with the specified ID.
+ *
+ * @param {String} id
+ * Element ID.
+ * @return {Element} The element with specified ID.
+ */
+ getElement(id) {
+ return this.highlighter.getElement(id);
+ }
+
+ /**
+ * Gets the text content of element.
+ *
+ * @param {String} id
+ * Element ID to retrieve text content from.
+ * @return {String} The text content of the element.
+ */
+ getTextContent(id) {
+ const anonymousContent = this.markup.content;
+ return anonymousContent.root.getElementById(`${this.prefix}${id}`)
+ .textContent;
+ }
+
+ /**
+ * Hide the accessible infobar.
+ */
+ hide() {
+ const container = this.getElement("infobar-container");
+ container.setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the accessible infobar highlighter.
+ */
+ show() {
+ const container = this.getElement("infobar-container");
+
+ // Remove accessible's infobar "hidden" attribute. We do this first to get the
+ // computed styles of the infobar container.
+ container.removeAttribute("hidden");
+
+ // Update the infobar's position and content.
+ this.update(container);
+ }
+
+ /**
+ * Update content of the infobar.
+ */
+ update(container) {
+ const { audit, name, role } = this.options;
+
+ this.updateRole(role, this.getElement("infobar-role"));
+ this.updateName(name, this.getElement("infobar-name"));
+ this.audit.update(audit);
+
+ // Position the infobar.
+ this._moveInfobar(container);
+ }
+
+ /**
+ * Sets the text content of the specified element.
+ *
+ * @param {Element} el
+ * Element to set text content on.
+ * @param {String} text
+ * Text for content.
+ */
+ setTextContent(el, text) {
+ el.setTextContent(text);
+ }
+
+ /**
+ * Show the accessible's name message.
+ *
+ * @param {String} name
+ * Accessible's name value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateName(name, el) {
+ const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : "";
+ this.setTextContent(el, nameText);
+ }
+
+ /**
+ * Show the accessible's role.
+ *
+ * @param {String} role
+ * Accessible's role value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateRole(role, el) {
+ this.setTextContent(el, role);
+ }
+}
+
+/**
+ * Audit component used within the accessible highlighter infobar. This component is
+ * responsible for rendering and updating its containing AuditReport components that
+ * display various audit information such as contrast ratio score.
+ */
+class Audit {
+ constructor(infobar) {
+ this.infobar = infobar;
+
+ // A list of audit reports to be shown on the fly when highlighting an accessible
+ // object.
+ this.reports = {
+ [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this),
+ [AUDIT_TYPE.KEYBOARD]: new Keyboard(this),
+ [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this),
+ };
+ }
+
+ get prefix() {
+ return this.infobar.prefix;
+ }
+
+ get markup() {
+ return this.infobar.markup;
+ }
+
+ buildMarkup(root) {
+ const audit = this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "infobar-audit",
+ id: "infobar-audit",
+ },
+ prefix: this.prefix,
+ });
+
+ Object.values(this.reports).forEach(report => report.buildMarkup(audit));
+ }
+
+ update(audit = {}) {
+ const el = this.getElement("infobar-audit");
+ el.setAttribute("hidden", true);
+
+ let updated = false;
+ Object.values(this.reports).forEach(report => {
+ if (report.update(audit)) {
+ updated = true;
+ }
+ });
+
+ if (updated) {
+ el.removeAttribute("hidden");
+ }
+ }
+
+ getElement(id) {
+ return this.infobar.getElement(id);
+ }
+
+ setTextContent(el, text) {
+ return this.infobar.setTextContent(el, text);
+ }
+
+ destroy() {
+ this.infobar = null;
+ Object.values(this.reports).forEach(report => report.destroy());
+ this.reports = null;
+ }
+}
+
+/**
+ * A common interface between audit report components used to render accessibility audit
+ * information for the currently highlighted accessible object.
+ */
+class AuditReport {
+ constructor(audit) {
+ this.audit = audit;
+ }
+
+ get prefix() {
+ return this.audit.prefix;
+ }
+
+ get markup() {
+ return this.audit.markup;
+ }
+
+ getElement(id) {
+ return this.audit.getElement(id);
+ }
+
+ setTextContent(el, text) {
+ return this.audit.setTextContent(el, text);
+ }
+
+ destroy() {
+ this.audit = null;
+ }
+}
+
+/**
+ * Contrast ratio audit report that is used to display contrast ratio score as part of the
+ * inforbar,
+ */
+class ContrastRatio extends AuditReport {
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-label",
+ id: "contrast-ratio-label",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-error",
+ id: "contrast-ratio-error",
+ },
+ prefix: this.prefix,
+ text: L10N.getStr("accessibility.contrast.ratio.error"),
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio",
+ id: "contrast-ratio-min",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-separator",
+ id: "contrast-ratio-separator",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio",
+ id: "contrast-ratio-max",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) {
+ value = value.toFixed(2);
+ this.setTextContent(el, value);
+ el.classList.add(className);
+ el.setAttribute(
+ "style",
+ `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` +
+ `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});`
+ );
+ el.removeAttribute("hidden");
+ }
+
+ /**
+ * Update contrast ratio score infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the contrast ratio markup was updated correctly and infobar audit
+ * block should be visible.
+ */
+ update(audit) {
+ const els = {};
+ for (const key of ["label", "min", "max", "error", "separator"]) {
+ const el = (els[key] = this.getElement(`contrast-ratio-${key}`));
+ if (["min", "max"].includes(key)) {
+ Object.values(SCORES).forEach(className =>
+ el.classList.remove(className)
+ );
+ this.setTextContent(el, "");
+ }
+
+ el.setAttribute("hidden", true);
+ el.removeAttribute("style");
+ }
+
+ if (!audit) {
+ return false;
+ }
+
+ const contrastRatio = audit[AUDIT_TYPE.CONTRAST];
+ if (!contrastRatio) {
+ return false;
+ }
+
+ const { isLargeText, error } = contrastRatio;
+ this.setTextContent(
+ els.label,
+ L10N.getStr(
+ `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}`
+ )
+ );
+ els.label.removeAttribute("hidden");
+ if (error) {
+ els.error.removeAttribute("hidden");
+ return true;
+ }
+
+ if (contrastRatio.value) {
+ const { value, color, score, backgroundColor } = contrastRatio;
+ this._fillAndStyleContrastValue(els.min, {
+ value,
+ className: score,
+ color,
+ backgroundColor,
+ });
+ return true;
+ }
+
+ const {
+ min,
+ max,
+ color,
+ backgroundColorMin,
+ backgroundColorMax,
+ scoreMin,
+ scoreMax,
+ } = contrastRatio;
+ this._fillAndStyleContrastValue(els.min, {
+ value: min,
+ className: scoreMin,
+ color,
+ backgroundColor: backgroundColorMin,
+ });
+ els.separator.removeAttribute("hidden");
+ this._fillAndStyleContrastValue(els.max, {
+ value: max,
+ className: scoreMax,
+ color,
+ backgroundColor: backgroundColorMax,
+ });
+
+ return true;
+ }
+}
+
+/**
+ * Keyboard audit report that is used to display a problem with keyboard
+ * accessibility as part of the inforbar.
+ */
+class Keyboard extends AuditReport {
+ /**
+ * A map from keyboard issues to annotation component properties.
+ */
+ static get ISSUE_TO_INFOBAR_LABEL_MAP() {
+ return {
+ [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics",
+ [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex",
+ [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action",
+ [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable",
+ [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only",
+ [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible",
+ };
+ }
+
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "audit",
+ id: "keyboard",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Update keyboard audit infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the keyboard markup was updated correctly and infobar audit
+ * block should be visible.
+ */
+ update(audit) {
+ const el = this.getElement("keyboard");
+ el.setAttribute("hidden", true);
+ Object.values(SCORES).forEach(className => el.classList.remove(className));
+
+ if (!audit) {
+ return false;
+ }
+
+ const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD];
+ if (!keyboardAudit) {
+ return false;
+ }
+
+ const { issue, score } = keyboardAudit;
+ this.setTextContent(
+ el,
+ L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue])
+ );
+ el.classList.add(score);
+ el.removeAttribute("hidden");
+
+ return true;
+ }
+}
+
+/**
+ * Text label audit report that is used to display a problem with text alternatives
+ * as part of the inforbar.
+ */
+class TextLabel extends AuditReport {
+ /**
+ * A map from text label issues to annotation component properties.
+ */
+ static get ISSUE_TO_INFOBAR_LABEL_MAP() {
+ return {
+ [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area",
+ [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog",
+ [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title",
+ [EMBED_NO_NAME]: "accessibility.text.label.issue.embed",
+ [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure",
+ [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset",
+ [FORM_FIELDSET_NO_NAME_FROM_LEGEND]:
+ "accessibility.text.label.issue.fieldset.legend2",
+ [FORM_NO_NAME]: "accessibility.text.label.issue.form",
+ [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible",
+ [FORM_OPTGROUP_NO_NAME_FROM_LABEL]:
+ "accessibility.text.label.issue.optgroup.label2",
+ [FRAME_NO_NAME]: "accessibility.text.label.issue.frame",
+ [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content",
+ [HEADING_NO_NAME]: "accessibility.text.label.issue.heading",
+ [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe",
+ [IMAGE_NO_NAME]: "accessibility.text.label.issue.image",
+ [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive",
+ [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph",
+ [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar",
+ };
+ }
+
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "audit",
+ id: "text-label",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Update text label audit infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the text label markup was updated correctly and infobar
+ * audit block should be visible.
+ */
+ update(audit) {
+ const el = this.getElement("text-label");
+ el.setAttribute("hidden", true);
+ Object.values(SCORES).forEach(className => el.classList.remove(className));
+
+ if (!audit) {
+ return false;
+ }
+
+ const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL];
+ if (!textLabelAudit) {
+ return false;
+ }
+
+ const { issue, score } = textLabelAudit;
+ this.setTextContent(
+ el,
+ L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue])
+ );
+ el.classList.add(score);
+ el.removeAttribute("hidden");
+
+ return true;
+ }
+}
+
+/**
+ * A helper function that calculate accessible object bounds and positioning to
+ * be used for highlighting.
+ *
+ * @param {Object} win
+ * window that contains accessible object.
+ * @param {Object} options
+ * Object used for passing options:
+ * - {Number} x
+ * x coordinate of the top left corner of the accessible object
+ * - {Number} y
+ * y coordinate of the top left corner of the accessible object
+ * - {Number} w
+ * width of the the accessible object
+ * - {Number} h
+ * height of the the accessible object
+ * @return {Object|null} Returns, if available, positioning and bounds information for
+ * the accessible object.
+ */
+function getBounds(win, { x, y, w, h }) {
+ const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win;
+ const zoom = getCurrentZoom(win);
+ let left = x;
+ let right = x + w;
+ let top = y;
+ let bottom = y + h;
+
+ left -= mozInnerScreenX - scrollX;
+ right -= mozInnerScreenX - scrollX;
+ top -= mozInnerScreenY - scrollY;
+ bottom -= mozInnerScreenY - scrollY;
+
+ left *= zoom;
+ right *= zoom;
+ top *= zoom;
+ bottom *= zoom;
+
+ const width = right - left;
+ const height = bottom - top;
+
+ return { left, right, top, bottom, width, height };
+}
+
+/**
+ * A helper function that calculate accessible object bounds and positioning to
+ * be used for highlighting in browser toolbox.
+ *
+ * @param {Object} win
+ * window that contains accessible object.
+ * @param {Object} options
+ * Object used for passing options:
+ * - {Number} x
+ * x coordinate of the top left corner of the accessible object
+ * - {Number} y
+ * y coordinate of the top left corner of the accessible object
+ * - {Number} w
+ * width of the the accessible object
+ * - {Number} h
+ * height of the the accessible object
+ * - {Number} zoom
+ * zoom level of the accessible object's parent window
+ * @return {Object|null} Returns, if available, positioning and bounds information for
+ * the accessible object.
+ */
+function getBoundsXUL(win, { x, y, w, h, zoom }) {
+ const { mozInnerScreenX, mozInnerScreenY } = win;
+ let left = x;
+ let right = x + w;
+ let top = y;
+ let bottom = y + h;
+
+ left *= zoom;
+ right *= zoom;
+ top *= zoom;
+ bottom *= zoom;
+
+ left -= mozInnerScreenX;
+ right -= mozInnerScreenX;
+ top -= mozInnerScreenY;
+ bottom -= mozInnerScreenY;
+
+ const width = right - left;
+ const height = bottom - top;
+
+ return { left, right, top, bottom, width, height };
+}
+
+exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH;
+exports.getBounds = getBounds;
+exports.getBoundsXUL = getBoundsXUL;
+exports.Infobar = Infobar;
diff --git a/devtools/server/actors/highlighters/utils/canvas.js b/devtools/server/actors/highlighters/utils/canvas.js
new file mode 100644
index 0000000000..24285f02e0
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/canvas.js
@@ -0,0 +1,596 @@
+/* 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 {
+ apply,
+ getNodeTransformationMatrix,
+ getWritingModeMatrix,
+ identity,
+ isIdentity,
+ multiply,
+ scale,
+ translate,
+} = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ getCurrentZoom,
+ getViewportDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// A set of utility functions for highlighters that render their content to a <canvas>
+// element.
+
+// We create a <canvas> element that has always 4096x4096 physical pixels, to displays
+// our grid's overlay.
+// Then, we move the element around when needed, to give the perception that it always
+// covers the screen (See bug 1345434).
+//
+// This canvas size value is the safest we can use because most GPUs can handle it.
+// It's also far from the maximum canvas memory allocation limit (4096x4096x4 is
+// 67.108.864 bytes, where the limit is 500.000.000 bytes, see
+// gfx_max_alloc_size in modules/libpref/init/StaticPrefList.yaml.
+//
+// Note:
+// Once bug 1232491 lands, we could try to refactor this code to use the values from
+// the displayport API instead.
+//
+// Using a fixed value should also solve bug 1348293.
+const CANVAS_SIZE = 4096;
+
+// The default color used for the canvas' font, fill and stroke colors.
+const DEFAULT_COLOR = "#9400FF";
+
+/**
+ * Draws a rect to the context given and applies a transformation matrix if passed.
+ * The coordinates are the start and end points of the rectangle's diagonal.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * The transformation matrix to apply.
+ */
+function clearRect(ctx, x1, y1, x2, y2, matrix = identity()) {
+ const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix);
+
+ // We are creating a clipping path and want it removed after we clear it's
+ // contents so we need to save the context.
+ ctx.save();
+
+ // Create a path to be cleared.
+ ctx.beginPath();
+ ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y));
+ ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y));
+ ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y));
+ ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y));
+ ctx.closePath();
+
+ // Restrict future drawing to the inside of the path.
+ ctx.clip();
+
+ // Clear any transforms applied to the canvas so that clearRect() really does
+ // clear everything.
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+ // Clear the contents of our clipped path by attempting to clear the canvas.
+ ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
+
+ // Restore the context to the state it was before changing transforms and
+ // adding clipping paths.
+ ctx.restore();
+}
+
+/**
+ * Draws an arrow-bubble rectangle in the provided canvas context.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x
+ * The x-axis origin of the rectangle.
+ * @param {Number} y
+ * The y-axis origin of the rectangle.
+ * @param {Number} width
+ * The width of the rectangle.
+ * @param {Number} height
+ * The height of the rectangle.
+ * @param {Number} radius
+ * The radius of the rounding.
+ * @param {Number} margin
+ * The distance of the origin point from the pointer.
+ * @param {Number} arrowSize
+ * The size of the arrow.
+ * @param {String} alignment
+ * The alignment of the rectangle in relation to its position to the grid.
+ */
+function drawBubbleRect(
+ ctx,
+ x,
+ y,
+ width,
+ height,
+ radius,
+ margin,
+ arrowSize,
+ alignment
+) {
+ let angle = 0;
+
+ if (alignment === "bottom") {
+ angle = 180;
+ } else if (alignment === "right") {
+ angle = 90;
+ [width, height] = [height, width];
+ } else if (alignment === "left") {
+ [width, height] = [height, width];
+ angle = 270;
+ }
+
+ const originX = x;
+ const originY = y;
+
+ ctx.save();
+ ctx.translate(originX, originY);
+ ctx.rotate(angle * (Math.PI / 180));
+ ctx.translate(-originX, -originY);
+ ctx.translate(-width / 2, -height - arrowSize - margin);
+
+ // The contour of the bubble is drawn with a path. The canvas context will have taken
+ // care of transforming the coordinates before calling the function, so we just always
+ // draw with the arrow pointing down. The top edge has rounded corners too.
+ ctx.beginPath();
+ // Start at the top/left corner (below the rounded corner).
+ ctx.moveTo(x, y + radius);
+ // Go down.
+ ctx.lineTo(x, y + height);
+ // Go down and the right, to draw the first half of the arrow tip.
+ ctx.lineTo(x + width / 2, y + height + arrowSize);
+ // Go back up and to the right, to draw the second half of the arrow tip.
+ ctx.lineTo(x + width, y + height);
+ // Go up to just below the top/right rounded corner.
+ ctx.lineTo(x + width, y + radius);
+ // Draw the top/right rounded corner.
+ ctx.arcTo(x + width, y, x + width - radius, y, radius);
+ // Go to the left.
+ ctx.lineTo(x + radius, y);
+ // Draw the top/left rounded corner.
+ ctx.arcTo(x, y, x, y + radius, radius);
+
+ ctx.stroke();
+ ctx.fill();
+
+ ctx.restore();
+}
+
+/**
+ * Draws a line to the context given and applies a transformation matrix if passed.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis of the coordinate for the begin of the line.
+ * @param {Number} y1
+ * The y-axis of the coordinate for the begin of the line.
+ * @param {Number} x2
+ * The x-axis of the coordinate for the end of the line.
+ * @param {Number} y2
+ * The y-axis of the coordinate for the end of the line.
+ * @param {Object} [options]
+ * The options object.
+ * @param {Array} [options.matrix=identity()]
+ * The transformation matrix to apply.
+ * @param {Array} [options.extendToBoundaries]
+ * If set, the line will be extended to reach the boundaries specified.
+ */
+function drawLine(ctx, x1, y1, x2, y2, options) {
+ const matrix = options.matrix || identity();
+
+ const p1 = apply(matrix, [x1, y1]);
+ const p2 = apply(matrix, [x2, y2]);
+
+ x1 = p1[0];
+ y1 = p1[1];
+ x2 = p2[0];
+ y2 = p2[1];
+
+ if (options.extendToBoundaries) {
+ if (p1[1] === p2[1]) {
+ x1 = options.extendToBoundaries[0];
+ x2 = options.extendToBoundaries[2];
+ } else {
+ y1 = options.extendToBoundaries[1];
+ x1 = ((p2[0] - p1[0]) * (y1 - p1[1])) / (p2[1] - p1[1]) + p1[0];
+ y2 = options.extendToBoundaries[3];
+ x2 = ((p2[0] - p1[0]) * (y2 - p1[1])) / (p2[1] - p1[1]) + p1[0];
+ }
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(Math.round(x1), Math.round(y1));
+ ctx.lineTo(Math.round(x2), Math.round(y2));
+}
+
+/**
+ * Draws a rect to the context given and applies a transformation matrix if passed.
+ * The coordinates are the start and end points of the rectangle's diagonal.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * The transformation matrix to apply.
+ */
+function drawRect(ctx, x1, y1, x2, y2, matrix = identity()) {
+ const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix);
+
+ ctx.beginPath();
+ ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y));
+ ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y));
+ ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y));
+ ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y));
+ ctx.closePath();
+}
+
+/**
+ * Draws a rounded rectangle in the provided canvas context.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x
+ * The x-axis origin of the rectangle.
+ * @param {Number} y
+ * The y-axis origin of the rectangle.
+ * @param {Number} width
+ * The width of the rectangle.
+ * @param {Number} height
+ * The height of the rectangle.
+ * @param {Number} radius
+ * The radius of the rounding.
+ */
+function drawRoundedRect(ctx, x, y, width, height, radius) {
+ ctx.beginPath();
+ ctx.moveTo(x, y + radius);
+ ctx.lineTo(x, y + height - radius);
+ ctx.arcTo(x, y + height, x + radius, y + height, radius);
+ ctx.lineTo(x + width - radius, y + height);
+ ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius);
+ ctx.lineTo(x + width, y + radius);
+ ctx.arcTo(x + width, y, x + width - radius, y, radius);
+ ctx.lineTo(x + radius, y);
+ ctx.arcTo(x, y, x, y + radius, radius);
+ ctx.stroke();
+ ctx.fill();
+}
+
+/**
+ * Given an array of four points and returns a DOMRect-like object representing the
+ * boundaries defined by the four points.
+ *
+ * @param {Array} points
+ * An array with 4 pointer objects {x, y} representing the box quads.
+ * @return {Object} DOMRect-like object of the 4 points.
+ */
+function getBoundsFromPoints(points) {
+ const bounds = {};
+
+ bounds.left = Math.min(points[0].x, points[1].x, points[2].x, points[3].x);
+ bounds.right = Math.max(points[0].x, points[1].x, points[2].x, points[3].x);
+ bounds.top = Math.min(points[0].y, points[1].y, points[2].y, points[3].y);
+ bounds.bottom = Math.max(points[0].y, points[1].y, points[2].y, points[3].y);
+
+ bounds.x = bounds.left;
+ bounds.y = bounds.top;
+ bounds.width = bounds.right - bounds.left;
+ bounds.height = bounds.bottom - bounds.top;
+
+ return bounds;
+}
+
+/**
+ * Returns the current matrices for both canvas drawing and SVG taking into account the
+ * following transformations, in this order:
+ * 1. The scale given by the display pixel ratio.
+ * 2. The translation to the top left corner of the element.
+ * 3. The scale given by the current zoom.
+ * 4. The translation given by the top and left padding of the element.
+ * 5. Any CSS transformation applied directly to the element (only 2D
+ * transformation; the 3D transformation are flattened, see `dom-matrix-2d` module
+ * for further details.)
+ * 6. Rotate, translate, and reflect as needed to match the writing mode and text
+ * direction of the element.
+ *
+ * The transformations of the element's ancestors are not currently computed (see
+ * bug 1355675).
+ *
+ * @param {Element} element
+ * The current element.
+ * @param {Window} window
+ * The window object.
+ * @param {Object} [options.ignoreWritingModeAndTextDirection=false]
+ * Avoid transforming the current matrix to match the text direction
+ * and writing mode.
+ * @return {Object} An object with the following properties:
+ * - {Array} currentMatrix
+ * The current matrix.
+ * - {Boolean} hasNodeTransformations
+ * true if the node has transformed and false otherwise.
+ */
+function getCurrentMatrix(
+ element,
+ window,
+ { ignoreWritingModeAndTextDirection } = {}
+) {
+ const computedStyle = getComputedStyle(element);
+
+ const paddingTop = parseFloat(computedStyle.paddingTop);
+ const paddingRight = parseFloat(computedStyle.paddingRight);
+ const paddingBottom = parseFloat(computedStyle.paddingBottom);
+ const paddingLeft = parseFloat(computedStyle.paddingLeft);
+ const borderTop = parseFloat(computedStyle.borderTopWidth);
+ const borderRight = parseFloat(computedStyle.borderRightWidth);
+ const borderBottom = parseFloat(computedStyle.borderBottomWidth);
+ const borderLeft = parseFloat(computedStyle.borderLeftWidth);
+
+ const nodeMatrix = getNodeTransformationMatrix(
+ element,
+ window.document.documentElement
+ );
+
+ let currentMatrix = identity();
+ let hasNodeTransformations = false;
+
+ // Scale based on the device pixel ratio.
+ currentMatrix = multiply(currentMatrix, scale(window.devicePixelRatio));
+
+ // Apply the current node's transformation matrix, relative to the inspected window's
+ // root element, but only if it's not a identity matrix.
+ if (isIdentity(nodeMatrix)) {
+ hasNodeTransformations = false;
+ } else {
+ currentMatrix = multiply(currentMatrix, nodeMatrix);
+ hasNodeTransformations = true;
+ }
+
+ // Translate the origin based on the node's padding and border values.
+ currentMatrix = multiply(
+ currentMatrix,
+ translate(paddingLeft + borderLeft, paddingTop + borderTop)
+ );
+
+ // Adjust as needed to match the writing mode and text direction of the element.
+ const size = {
+ width:
+ element.offsetWidth -
+ borderLeft -
+ borderRight -
+ paddingLeft -
+ paddingRight,
+ height:
+ element.offsetHeight -
+ borderTop -
+ borderBottom -
+ paddingTop -
+ paddingBottom,
+ };
+
+ if (!ignoreWritingModeAndTextDirection) {
+ const writingModeMatrix = getWritingModeMatrix(size, computedStyle);
+ if (!isIdentity(writingModeMatrix)) {
+ currentMatrix = multiply(currentMatrix, writingModeMatrix);
+ }
+ }
+
+ return { currentMatrix, hasNodeTransformations };
+}
+
+/**
+ * Given an array of four points, returns a string represent a path description.
+ *
+ * @param {Array} points
+ * An array with 4 pointer objects {x, y} representing the box quads.
+ * @return {String} a Path Description that can be used in svg's <path> element.
+ */
+function getPathDescriptionFromPoints(points) {
+ return (
+ "M" +
+ points[0].x +
+ "," +
+ points[0].y +
+ " " +
+ "L" +
+ points[1].x +
+ "," +
+ points[1].y +
+ " " +
+ "L" +
+ points[2].x +
+ "," +
+ points[2].y +
+ " " +
+ "L" +
+ points[3].x +
+ "," +
+ points[3].y
+ );
+}
+
+/**
+ * Given the rectangle's diagonal start and end coordinates, returns an array containing
+ * the four coordinates of a rectangle. If a matrix is provided, applies the matrix
+ * function to each of the coordinates' value.
+ *
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * A transformation matrix to apply.
+ * @return {Array} the four coordinate points of the given rectangle transformed by the
+ * matrix given.
+ */
+function getPointsFromDiagonal(x1, y1, x2, y2, matrix = identity()) {
+ return [
+ [x1, y1],
+ [x2, y1],
+ [x2, y2],
+ [x1, y2],
+ ].map(point => {
+ const transformedPoint = apply(matrix, point);
+
+ return { x: transformedPoint[0], y: transformedPoint[1] };
+ });
+}
+
+/**
+ * Updates the <canvas> element's style in accordance with the current window's
+ * device pixel ratio, and the position calculated in `getCanvasPosition`. It also
+ * clears the drawing context. This is called on canvas update after a scroll event where
+ * `getCanvasPosition` updates the new canvasPosition.
+ *
+ * @param {Canvas} canvas
+ * The <canvas> element.
+ * @param {Object} canvasPosition
+ * A pointer object {x, y} representing the <canvas> position to the top left
+ * corner of the page.
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio.
+ * @param {Window} [options.zoomWindow]
+ * Optional window object used to calculate zoom (default = undefined).
+ */
+function updateCanvasElement(
+ canvas,
+ canvasPosition,
+ devicePixelRatio,
+ { zoomWindow } = {}
+) {
+ let { x, y } = canvasPosition;
+ const size = CANVAS_SIZE / devicePixelRatio;
+
+ if (zoomWindow) {
+ const zoom = getCurrentZoom(zoomWindow);
+ x *= zoom;
+ y *= zoom;
+ }
+
+ // Resize the canvas taking the dpr into account so as to have crisp lines, and
+ // translating it to give the perception that it always covers the viewport.
+ canvas.setAttribute(
+ "style",
+ `width: ${size}px; height: ${size}px; transform: translate(${x}px, ${y}px);`
+ );
+ canvas.getCanvasContext("2d").clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
+}
+
+/**
+ * Calculates and returns the <canvas>'s position in accordance with the page's scroll,
+ * document's size, canvas size, and viewport's size. This is called when a page's scroll
+ * is detected.
+ *
+ * @param {Object} canvasPosition
+ * A pointer object {x, y} representing the <canvas> position to the top left
+ * corner of the page.
+ * @param {Object} scrollPosition
+ * A pointer object {x, y} representing the window's pageXOffset and pageYOffset.
+ * @param {Window} window
+ * The window object.
+ * @param {Object} windowDimensions
+ * An object {width, height} representing the window's dimensions for the
+ * `window` given.
+ * @return {Boolean} true if the <canvas> position was updated and false otherwise.
+ */
+function updateCanvasPosition(
+ canvasPosition,
+ scrollPosition,
+ window,
+ windowDimensions
+) {
+ let { x: canvasX, y: canvasY } = canvasPosition;
+ const { x: scrollX, y: scrollY } = scrollPosition;
+ const cssCanvasSize = CANVAS_SIZE / window.devicePixelRatio;
+ const viewportSize = getViewportDimensions(window);
+ const { height, width } = windowDimensions;
+ const canvasWidth = cssCanvasSize;
+ const canvasHeight = cssCanvasSize;
+ let hasUpdated = false;
+
+ // Those values indicates the relative horizontal and vertical space the page can
+ // scroll before we have to reposition the <canvas>; they're 1/4 of the delta between
+ // the canvas' size and the viewport's size: that's because we want to consider both
+ // sides (top/bottom, left/right; so 1/2 for each side) and also we don't want to
+ // shown the edges of the canvas in case of fast scrolling (to avoid showing undraw
+ // areas, therefore another 1/2 here).
+ const bufferSizeX = (canvasWidth - viewportSize.width) >> 2;
+ const bufferSizeY = (canvasHeight - viewportSize.height) >> 2;
+
+ // Defines the boundaries for the canvas.
+ const leftBoundary = 0;
+ const rightBoundary = width - canvasWidth;
+ const topBoundary = 0;
+ const bottomBoundary = height - canvasHeight;
+
+ // Defines the thresholds that triggers the canvas' position to be updated.
+ const leftThreshold = scrollX - bufferSizeX;
+ const rightThreshold =
+ scrollX - canvasWidth + viewportSize.width + bufferSizeX;
+ const topThreshold = scrollY - bufferSizeY;
+ const bottomThreshold =
+ scrollY - canvasHeight + viewportSize.height + bufferSizeY;
+
+ if (canvasX < rightBoundary && canvasX < rightThreshold) {
+ canvasX = Math.min(leftThreshold, rightBoundary);
+ hasUpdated = true;
+ } else if (canvasX > leftBoundary && canvasX > leftThreshold) {
+ canvasX = Math.max(rightThreshold, leftBoundary);
+ hasUpdated = true;
+ }
+
+ if (canvasY < bottomBoundary && canvasY < bottomThreshold) {
+ canvasY = Math.min(topThreshold, bottomBoundary);
+ hasUpdated = true;
+ } else if (canvasY > topBoundary && canvasY > topThreshold) {
+ canvasY = Math.max(bottomThreshold, topBoundary);
+ hasUpdated = true;
+ }
+
+ // Update the canvas position with the calculated canvasX and canvasY positions.
+ canvasPosition.x = canvasX;
+ canvasPosition.y = canvasY;
+
+ return hasUpdated;
+}
+
+exports.CANVAS_SIZE = CANVAS_SIZE;
+exports.DEFAULT_COLOR = DEFAULT_COLOR;
+exports.clearRect = clearRect;
+exports.drawBubbleRect = drawBubbleRect;
+exports.drawLine = drawLine;
+exports.drawRect = drawRect;
+exports.drawRoundedRect = drawRoundedRect;
+exports.getBoundsFromPoints = getBoundsFromPoints;
+exports.getCurrentMatrix = getCurrentMatrix;
+exports.getPathDescriptionFromPoints = getPathDescriptionFromPoints;
+exports.getPointsFromDiagonal = getPointsFromDiagonal;
+exports.updateCanvasElement = updateCanvasElement;
+exports.updateCanvasPosition = updateCanvasPosition;
diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js
new file mode 100644
index 0000000000..a550ca0076
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -0,0 +1,787 @@
+/* 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 {
+ getCurrentZoom,
+ getWindowDimensions,
+ getViewportDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const lazyContainer = {};
+
+loader.lazyRequireGetter(
+ lazyContainer,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isDocumentReady",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+exports.getComputedStyle = node =>
+ lazyContainer.CssLogic.getComputedStyle(node);
+
+exports.getBindingElementAndPseudo = node =>
+ lazyContainer.CssLogic.getBindingElementAndPseudo(node);
+
+exports.hasPseudoClassLock = (...args) =>
+ InspectorUtils.hasPseudoClassLock(...args);
+
+exports.addPseudoClassLock = (...args) =>
+ InspectorUtils.addPseudoClassLock(...args);
+
+exports.removePseudoClassLock = (...args) =>
+ InspectorUtils.removePseudoClassLock(...args);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const STYLESHEET_URI =
+ "resource://devtools-highlighter-styles/highlighters.css";
+
+const _tokens = Symbol("classList/tokens");
+
+/**
+ * Shims the element's `classList` for anonymous content elements; used
+ * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
+ */
+function ClassList(className) {
+ const trimmed = (className || "").trim();
+ this[_tokens] = trimmed ? trimmed.split(/\s+/) : [];
+}
+
+ClassList.prototype = {
+ item(index) {
+ return this[_tokens][index];
+ },
+ contains(token) {
+ return this[_tokens].includes(token);
+ },
+ add(token) {
+ if (!this.contains(token)) {
+ this[_tokens].push(token);
+ }
+ EventEmitter.emit(this, "update");
+ },
+ remove(token) {
+ const index = this[_tokens].indexOf(token);
+
+ if (index > -1) {
+ this[_tokens].splice(index, 1);
+ }
+ EventEmitter.emit(this, "update");
+ },
+ toggle(token, force) {
+ // If force parameter undefined retain the toggle behavior
+ if (force === undefined) {
+ if (this.contains(token)) {
+ this.remove(token);
+ } else {
+ this.add(token);
+ }
+ } else if (force) {
+ // If force is true, enforce token addition
+ this.add(token);
+ } else {
+ // If force is falsy value, enforce token removal
+ this.remove(token);
+ }
+ },
+ get length() {
+ return this[_tokens].length;
+ },
+ *[Symbol.iterator]() {
+ for (let i = 0; i < this.tokens.length; i++) {
+ yield this[_tokens][i];
+ }
+ },
+ toString() {
+ return this[_tokens].join(" ");
+ },
+};
+
+/**
+ * Is this content window a XUL window?
+ * @param {Window} window
+ * @return {Boolean}
+ */
+function isXUL(window) {
+ return window.document.documentElement.namespaceURI === XUL_NS;
+}
+exports.isXUL = isXUL;
+
+/**
+ * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead
+ * object wrapper, is still attached to a document, and is of a given type.
+ * @param {DOMNode} node
+ * @param {Number} nodeType Optional, defaults to ELEMENT_NODE
+ * @return {Boolean}
+ */
+function isNodeValid(node, nodeType = Node.ELEMENT_NODE) {
+ // Is it still alive?
+ if (!node || Cu.isDeadWrapper(node)) {
+ return false;
+ }
+
+ // Is it of the right type?
+ if (node.nodeType !== nodeType) {
+ return false;
+ }
+
+ // Is its document accessible?
+ const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument;
+ if (!doc || !doc.defaultView) {
+ return false;
+ }
+
+ // Is the node connected to the document?
+ if (!node.isConnected) {
+ return false;
+ }
+
+ return true;
+}
+exports.isNodeValid = isNodeValid;
+
+/**
+ * Every highlighters should insert their markup content into the document's
+ * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
+ *
+ * Since this container gets cleared when the document navigates, highlighters
+ * should use this helper to have their markup content automatically re-inserted
+ * in the new document.
+ *
+ * Since the markup content is inserted in the canvasFrame using
+ * insertAnonymousContent, this means that it can be modified using the API
+ * described in AnonymousContent.webidl.
+ * To retrieve the AnonymousContent instance, use the content getter.
+ *
+ * @param {HighlighterEnv} highlighterEnv
+ * The environemnt which windows will be used to insert the node.
+ * @param {Function} nodeBuilder
+ * A function that, when executed, returns a DOM node to be inserted into
+ * the canvasFrame.
+ * @param {Object} options
+ * @param {Boolean} options.waitForDocumentToLoad
+ * Set to false to try to insert the anonymous content even if the document
+ * isn't loaded yet. Defaults to true.
+ */
+function CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ nodeBuilder,
+ { waitForDocumentToLoad = true } = {}
+) {
+ this.highlighterEnv = highlighterEnv;
+ this.nodeBuilder = nodeBuilder;
+ this.waitForDocumentToLoad = !!waitForDocumentToLoad;
+
+ this._onWindowReady = this._onWindowReady.bind(this);
+ this.highlighterEnv.on("window-ready", this._onWindowReady);
+
+ this.listeners = new Map();
+ this.elements = new Map();
+}
+
+CanvasFrameAnonymousContentHelper.prototype = {
+ initialize() {
+ // _insert will resolve this promise once the markup is displayed
+ const onInitialized = new Promise(resolve => {
+ this._initialized = resolve;
+ });
+ // Only try to create the highlighter when the document is loaded,
+ // otherwise, wait for the window-ready event to fire.
+ const doc = this.highlighterEnv.document;
+ if (
+ doc.documentElement &&
+ (!this.waitForDocumentToLoad ||
+ isDocumentReady(doc) ||
+ doc.readyState !== "uninitialized")
+ ) {
+ this._insert();
+ }
+
+ return onInitialized;
+ },
+
+ destroy() {
+ this._remove();
+
+ this.highlighterEnv.off("window-ready", this._onWindowReady);
+ this.highlighterEnv = this.nodeBuilder = this._content = null;
+ this.anonymousContentDocument = null;
+ this.anonymousContentWindow = null;
+ this.pageListenerTarget = null;
+
+ this._removeAllListeners();
+ this.elements.clear();
+ },
+
+ async _insert() {
+ if (this.waitForDocumentToLoad) {
+ await waitForContentLoaded(this.highlighterEnv.window);
+ }
+ if (!this.highlighterEnv) {
+ // CanvasFrameAnonymousContentHelper was already destroyed.
+ return;
+ }
+
+ // Highlighters are drawn inside the anonymous content of the
+ // highlighter environment document.
+ this.anonymousContentDocument = this.highlighterEnv.document;
+ this.anonymousContentWindow = this.highlighterEnv.window;
+ this.pageListenerTarget = this.highlighterEnv.pageListenerTarget;
+
+ // It was stated that hidden documents don't accept
+ // `insertAnonymousContent` calls yet. That doesn't seems the case anymore,
+ // at least on desktop. Therefore, removing the code that was dealing with
+ // that scenario, fixes when we're adding anonymous content in a tab that
+ // is not the active one (see bug 1260043 and bug 1260044)
+ try {
+ // If we didn't wait for the document to load, we want to force a layout update
+ // to ensure the anonymous content will be rendered (see Bug 1580394).
+ const forceSynchronousLayoutUpdate = !this.waitForDocumentToLoad;
+ this._content = this.anonymousContentDocument.insertAnonymousContent(
+ forceSynchronousLayoutUpdate
+ );
+ } catch (e) {
+ // If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means
+ // we don't have access to a `CustomContentContainer` yet (see bug 1365075).
+ // At this point, it could only happen on document's interactive state, and we
+ // need to wait until the `complete` state before inserting the anonymous content
+ // again.
+ if (
+ e.result === Cr.NS_ERROR_UNEXPECTED &&
+ this.anonymousContentDocument.readyState === "interactive"
+ ) {
+ // The next state change will be "complete" since the current is "interactive"
+ await new Promise(resolve => {
+ this.anonymousContentDocument.addEventListener(
+ "readystatechange",
+ resolve,
+ { once: true }
+ );
+ });
+ this._content = this.anonymousContentDocument.insertAnonymousContent();
+ } else {
+ throw e;
+ }
+ }
+
+ // Use createElementNS to make sure this is an HTML element.
+ // Document.createElement's behavior is different between SVG and HTML
+ // documents, see bug 1850007.
+ const link = this.anonymousContentDocument.createElementNS(
+ XHTML_NS,
+ "link"
+ );
+ link.href = STYLESHEET_URI;
+ link.rel = "stylesheet";
+ this._content.root.appendChild(link);
+ this._content.root.appendChild(this.nodeBuilder());
+
+ this._initialized();
+ },
+
+ _remove() {
+ try {
+ this.anonymousContentDocument.removeAnonymousContent(this._content);
+ } catch (e) {
+ // If the current window isn't the one the content was inserted into, this
+ // will fail, but that's fine.
+ }
+ },
+
+ /**
+ * The "window-ready" event can be triggered when:
+ * - a new window is created
+ * - a window is unfrozen from bfcache
+ * - when first attaching to a page
+ * - when swapping frame loaders (moving tabs, toggling RDM)
+ */
+ _onWindowReady({ isTopLevel }) {
+ if (isTopLevel) {
+ this._removeAllListeners();
+ this.elements.clear();
+ this._insert();
+ }
+ },
+
+ _getNodeById(id) {
+ return this.content?.root.getElementById(id);
+ },
+
+ getBoundingClientRect(id) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return null;
+ }
+ return node.getBoundingClientRect();
+ },
+
+ getComputedStylePropertyValue(id, property) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return null;
+ }
+ return this.anonymousContentWindow
+ .getComputedStyle(node)
+ .getPropertyValue(property);
+ },
+
+ getTextContentForElement(id) {
+ return this._getNodeById(id)?.textContent;
+ },
+
+ setTextContentForElement(id, text) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return;
+ }
+ node.textContent = text;
+ },
+
+ setAttributeForElement(id, name, value) {
+ this._getNodeById(id)?.setAttribute(name, value);
+ },
+
+ getAttributeForElement(id, name) {
+ return this._getNodeById(id)?.getAttribute(name);
+ },
+
+ removeAttributeForElement(id, name) {
+ this._getNodeById(id)?.removeAttribute(name);
+ },
+
+ hasAttributeForElement(id, name) {
+ return typeof this.getAttributeForElement(id, name) === "string";
+ },
+
+ getCanvasContext(id, type = "2d") {
+ return this._getNodeById(id)?.getContext(type);
+ },
+
+ /**
+ * Add an event listener to one of the elements inserted in the canvasFrame
+ * native anonymous container.
+ * Like other methods in this helper, this requires the ID of the element to
+ * be passed in.
+ *
+ * Note that if the content page navigates, the event listeners won't be
+ * added again.
+ *
+ * Also note that unlike traditional DOM events, the events handled by
+ * listeners added here will propagate through the document only through
+ * bubbling phase, so the useCapture parameter isn't supported.
+ * It is possible however to call e.stopPropagation() to stop the bubbling.
+ *
+ * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
+ * not leaking references to inserted elements to chrome JS code. That's
+ * because otherwise, chrome JS code could freely modify native anon elements
+ * inside the canvasFrame and probably change things that are assumed not to
+ * change by the C++ code managing this frame.
+ * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
+ * Unfortunately, the inserted nodes are still available via
+ * event.originalTarget, and that's what the event handler here uses to check
+ * that the event actually occured on the right element, but that also means
+ * consumers of this code would be able to access the inserted elements.
+ * Therefore, the originalTarget property will be nullified before the event
+ * is passed to your handler.
+ *
+ * IMPL DETAIL: A single event listener is added per event types only, at
+ * browser level and if the event originalTarget is found to have the provided
+ * ID, the callback is executed (and then IDs of parent nodes of the
+ * originalTarget are checked too).
+ *
+ * @param {String} id
+ * @param {String} type
+ * @param {Function} handler
+ */
+ addEventListenerForElement(id, type, handler) {
+ if (typeof id !== "string") {
+ throw new Error(
+ "Expected a string ID in addEventListenerForElement but" + " got: " + id
+ );
+ }
+
+ // If no one is listening for this type of event yet, add one listener.
+ if (!this.listeners.has(type)) {
+ const target = this.pageListenerTarget;
+ target.addEventListener(type, this, true);
+ // Each type entry in the map is a map of ids:handlers.
+ this.listeners.set(type, new Map());
+ }
+
+ const listeners = this.listeners.get(type);
+ listeners.set(id, handler);
+ },
+
+ /**
+ * Remove an event listener from one of the elements inserted in the
+ * canvasFrame native anonymous container.
+ * @param {String} id
+ * @param {String} type
+ */
+ removeEventListenerForElement(id, type) {
+ const listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+ listeners.delete(id);
+
+ // If no one is listening for event type anymore, remove the listener.
+ if (!this.listeners.has(type)) {
+ const target = this.pageListenerTarget;
+ target.removeEventListener(type, this, true);
+ }
+ },
+
+ handleEvent(event) {
+ const listeners = this.listeners.get(event.type);
+ if (!listeners) {
+ return;
+ }
+
+ // Hide the originalTarget property to avoid exposing references to native
+ // anonymous elements. See addEventListenerForElement's comment.
+ let isPropagationStopped = false;
+ const eventProxy = new Proxy(event, {
+ get: (obj, name) => {
+ if (name === "originalTarget") {
+ return null;
+ } else if (name === "stopPropagation") {
+ return () => {
+ isPropagationStopped = true;
+ };
+ }
+ return obj[name];
+ },
+ });
+
+ // Start at originalTarget, bubble through ancestors and call handlers when
+ // needed.
+ let node = event.originalTarget;
+ while (node) {
+ const handler = listeners.get(node.id);
+ if (handler) {
+ handler(eventProxy, node.id);
+ if (isPropagationStopped) {
+ break;
+ }
+ }
+ node = node.parentNode;
+ }
+ },
+
+ _removeAllListeners() {
+ if (this.pageListenerTarget) {
+ const target = this.pageListenerTarget;
+ for (const [type] of this.listeners) {
+ target.removeEventListener(type, this, true);
+ }
+ }
+ this.listeners.clear();
+ },
+
+ getElement(id) {
+ if (this.elements.has(id)) {
+ return this.elements.get(id);
+ }
+
+ const classList = new ClassList(this.getAttributeForElement(id, "class"));
+
+ EventEmitter.on(classList, "update", () => {
+ this.setAttributeForElement(id, "class", classList.toString());
+ });
+
+ const element = {
+ getTextContent: () => this.getTextContentForElement(id),
+ setTextContent: text => this.setTextContentForElement(id, text),
+ setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
+ getAttribute: name => this.getAttributeForElement(id, name),
+ removeAttribute: name => this.removeAttributeForElement(id, name),
+ hasAttribute: name => this.hasAttributeForElement(id, name),
+ getCanvasContext: type => this.getCanvasContext(id, type),
+ addEventListener: (type, handler) => {
+ return this.addEventListenerForElement(id, type, handler);
+ },
+ removeEventListener: (type, handler) => {
+ return this.removeEventListenerForElement(id, type, handler);
+ },
+ computedStyle: {
+ getPropertyValue: property =>
+ this.getComputedStylePropertyValue(id, property),
+ },
+ classList,
+ };
+
+ this.elements.set(id, element);
+
+ return element;
+ },
+
+ get content() {
+ if (!this._content || Cu.isDeadWrapper(this._content)) {
+ return null;
+ }
+ return this._content;
+ },
+
+ /**
+ * The canvasFrame anonymous content container gets zoomed in/out with the
+ * page. If this is unwanted, i.e. if you want the inserted element to remain
+ * unzoomed, then this method can be used.
+ *
+ * Consumers of the CanvasFrameAnonymousContentHelper should call this method,
+ * it isn't executed automatically. Typically, AutoRefreshHighlighter can call
+ * it when _update is executed.
+ *
+ * The matching element will be scaled down or up by 1/zoomLevel (using css
+ * transform) to cancel the current zoom. The element's width and height
+ * styles will also be set according to the scale. Finally, the element's
+ * position will be set as absolute.
+ *
+ * Note that if the matching element already has an inline style attribute, it
+ * *won't* be preserved.
+ *
+ * @param {DOMNode} node This node is used to determine which container window
+ * should be used to read the current zoom value.
+ * @param {String} id The ID of the root element inserted with this API.
+ */
+ scaleRootElement(node, id) {
+ const boundaryWindow = this.highlighterEnv.window;
+ const zoom = getCurrentZoom(node);
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ const root = this._getNodeById(id);
+ root.style.display = "none";
+ node.offsetWidth;
+
+ let { width, height } = getWindowDimensions(boundaryWindow);
+ let value = "";
+
+ if (zoom !== 1) {
+ value = `transform-origin:top left; transform:scale(${1 / zoom}); `;
+ width *= zoom;
+ height *= zoom;
+ }
+
+ value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden;`;
+ root.style = value;
+ },
+
+ /**
+ * Helper function that creates SVG DOM nodes.
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "box".
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ */
+ createSVGNode(options) {
+ if (!options.nodeType) {
+ options.nodeType = "box";
+ }
+
+ options.namespace = SVG_NS;
+
+ return this.createNode(options);
+ },
+
+ /**
+ * Helper function that creates DOM nodes.
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "div".
+ * - namespace: the namespace to use to create the node, defaults to XHTML namespace.
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ * - text: if provided, set the text content of the element.
+ */
+ createNode(options) {
+ const type = options.nodeType || "div";
+ const namespace = options.namespace || XHTML_NS;
+ const doc = this.anonymousContentDocument;
+
+ const node = doc.createElementNS(namespace, type);
+
+ for (const name in options.attributes || {}) {
+ let value = options.attributes[name];
+ if (options.prefix && (name === "class" || name === "id")) {
+ value = options.prefix + value;
+ }
+ node.setAttribute(name, value);
+ }
+
+ if (options.parent) {
+ options.parent.appendChild(node);
+ }
+
+ if (options.text) {
+ node.appendChild(doc.createTextNode(options.text));
+ }
+
+ return node;
+ },
+};
+exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;
+
+/**
+ * Wait for document readyness.
+ * @param {Object} iframeOrWindow
+ * IFrame or Window for which the content should be loaded.
+ */
+function waitForContentLoaded(iframeOrWindow) {
+ let loadEvent = "DOMContentLoaded";
+ // If we are waiting for an iframe to load and it is for a XUL window
+ // highlighter that is not browser toolbox, we must wait for IFRAME's "load".
+ if (
+ iframeOrWindow.contentWindow &&
+ iframeOrWindow.ownerGlobal !==
+ iframeOrWindow.contentWindow.browsingContext.topChromeWindow
+ ) {
+ loadEvent = "load";
+ }
+
+ const doc = iframeOrWindow.contentDocument || iframeOrWindow.document;
+ if (isDocumentReady(doc)) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ iframeOrWindow.addEventListener(loadEvent, resolve, { once: true });
+ });
+}
+
+/**
+ * Move the infobar to the right place in the highlighter. This helper method is utilized
+ * in both css-grid.js and box-model.js to help position the infobar in an appropriate
+ * space over the highlighted node element or grid area. The infobar is used to display
+ * relevant information about the highlighted item (ex, node or grid name and dimensions).
+ *
+ * This method will first try to position the infobar to top or bottom of the container
+ * such that it has enough space for the height of the infobar. Afterwards, it will try
+ * to horizontally center align with the container element if possible.
+ *
+ * @param {DOMNode} container
+ * The container element which will be used to position the infobar.
+ * @param {Object} bounds
+ * The content bounds of the container element.
+ * @param {Window} win
+ * The window object.
+ * @param {Object} [options={}]
+ * Advanced options for the infobar.
+ * @param {String} options.position
+ * Force the infobar to be displayed either on "top" or "bottom". Any other value
+ * will be ingnored.
+ */
+function moveInfobar(container, bounds, win, options = {}) {
+ const zoom = getCurrentZoom(win);
+ const viewport = getViewportDimensions(win);
+
+ const { computedStyle } = container;
+
+ const margin = 2;
+ const arrowSize = parseFloat(
+ computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
+ );
+ const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
+ const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
+ const containerHalfWidth = containerWidth / 2;
+
+ const viewportWidth = viewport.width * zoom;
+ const viewportHeight = viewport.height * zoom;
+ let { pageXOffset, pageYOffset } = win;
+
+ pageYOffset *= zoom;
+ pageXOffset *= zoom;
+
+ // Defines the boundaries for the infobar.
+ const topBoundary = margin;
+ const bottomBoundary = viewportHeight - containerHeight - margin - 1;
+ const leftBoundary = containerHalfWidth + margin;
+ const rightBoundary = viewportWidth - containerHalfWidth - margin;
+
+ // Set the default values.
+ let top = bounds.y - containerHeight - arrowSize;
+ const bottom = bounds.bottom + margin + arrowSize;
+ let left = bounds.x + bounds.width / 2;
+ let isOverlapTheNode = false;
+ let positionAttribute = "top";
+ let position = "absolute";
+
+ // Here we start the math.
+ // We basically want to position absolutely the infobar, except when is pointing to a
+ // node that is offscreen or partially offscreen, in a way that the infobar can't
+ // be placed neither on top nor on bottom.
+ // In such cases, the infobar will overlap the node, and to limit the latency given
+ // by APZ (See Bug 1312103) it will be positioned as "fixed".
+ // It's a sort of "position: sticky" (but positioned as absolute instead of relative).
+ const canBePlacedOnTop = top >= pageYOffset;
+ const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0;
+ const forcedOnTop = options.position === "top";
+ const forcedOnBottom = options.position === "bottom";
+
+ if (
+ (!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) ||
+ forcedOnBottom
+ ) {
+ top = bottom;
+ positionAttribute = "bottom";
+ }
+
+ const isOffscreenOnTop = top < topBoundary + pageYOffset;
+ const isOffscreenOnBottom = top > bottomBoundary + pageYOffset;
+ const isOffscreenOnLeft = left < leftBoundary + pageXOffset;
+ const isOffscreenOnRight = left > rightBoundary + pageXOffset;
+
+ if (isOffscreenOnTop) {
+ top = topBoundary;
+ isOverlapTheNode = true;
+ } else if (isOffscreenOnBottom) {
+ top = bottomBoundary;
+ isOverlapTheNode = true;
+ } else if (isOffscreenOnLeft || isOffscreenOnRight) {
+ isOverlapTheNode = true;
+ top -= pageYOffset;
+ }
+
+ if (isOverlapTheNode) {
+ left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary);
+
+ position = "fixed";
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ position = "absolute";
+ container.removeAttribute("hide-arrow");
+ }
+
+ // We need to scale the infobar Independently from the highlighter's container;
+ // otherwise the `position: fixed` won't work, since "any value other than `none` for
+ // the transform, results in the creation of both a stacking context and a containing
+ // block. The object acts as a containing block for fixed positioned descendants."
+ // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering)
+ // We also need to shift the infobar 50% to the left in order for it to appear centered
+ // on the element it points to.
+ container.setAttribute(
+ "style",
+ `
+ position:${position};
+ transform-origin: 0 0;
+ transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
+ );
+
+ container.setAttribute("position", positionAttribute);
+}
+exports.moveInfobar = moveInfobar;
diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build
new file mode 100644
index 0000000000..ab4f96912d
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules("accessibility.js", "canvas.js", "markup.js")
diff --git a/devtools/server/actors/highlighters/viewport-size.js b/devtools/server/actors/highlighters/viewport-size.js
new file mode 100644
index 0000000000..4c85a305ca
--- /dev/null
+++ b/devtools/server/actors/highlighters/viewport-size.js
@@ -0,0 +1,129 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+/**
+ * The ViewportSizeHighlighter is a class that displays the viewport
+ * width and height on a small overlay on the top right edge of the page
+ * while the rulers are turned on.
+ */
+class ViewportSizeHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this);
+ }
+
+ ID_CLASS_PREFIX = "viewport-size-highlighter-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "viewport-infobar-container",
+ id: "viewport-infobar-container",
+ position: "top",
+ },
+ prefix,
+ });
+
+ return container;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (event.target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ this.updateViewportInfobar();
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+
+ updateViewportInfobar() {
+ const { window } = this.env;
+ const { innerHeight, innerWidth } = window;
+ const infobarId = this.ID_CLASS_PREFIX + "viewport-infobar-container";
+ const textContent = innerWidth + "px \u00D7 " + innerHeight + "px";
+ this.markup.getElement(infobarId).setTextContent(textContent);
+ }
+
+ destroy() {
+ this.hide();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this);
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ this.markup.removeAttributeForElement(
+ this.ID_CLASS_PREFIX + "viewport-infobar-container",
+ "hidden"
+ );
+
+ this._update();
+
+ return true;
+ }
+
+ hide() {
+ this.markup.setAttributeForElement(
+ this.ID_CLASS_PREFIX + "viewport-infobar-container",
+ "hidden",
+ "true"
+ );
+
+ this._cancelUpdate();
+ }
+}
+exports.ViewportSizeHighlighter = ViewportSizeHighlighter;