summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/highlighters')
-rw-r--r--devtools/server/actors/highlighters/auto-refresh.js215
-rw-r--r--devtools/server/actors/highlighters/box-model.js712
-rw-r--r--devtools/server/actors/highlighters/css-grid.js737
-rw-r--r--devtools/server/actors/highlighters/css-transform.js243
-rw-r--r--devtools/server/actors/highlighters/eye-dropper.js534
-rw-r--r--devtools/server/actors/highlighters/geometry-editor.js704
-rw-r--r--devtools/server/actors/highlighters/measuring-tool.js563
-rw-r--r--devtools/server/actors/highlighters/moz.build23
-rw-r--r--devtools/server/actors/highlighters/rect.js102
-rw-r--r--devtools/server/actors/highlighters/rulers.js294
-rw-r--r--devtools/server/actors/highlighters/selector.js83
-rw-r--r--devtools/server/actors/highlighters/simple-outline.js67
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js609
-rw-r--r--devtools/server/actors/highlighters/utils/moz.build9
14 files changed, 4895 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js
new file mode 100644
index 000000000..31f89de20
--- /dev/null
+++ b/devtools/server/actors/highlighters/auto-refresh.js
@@ -0,0 +1,215 @@
+/* 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 { Cu } = require("chrome");
+const EventEmitter = require("devtools/shared/event-emitter");
+const { isNodeValid } = require("./utils/markup");
+const { getAdjustedQuads } = require("devtools/shared/layout/utils");
+
+// 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"];
+
+/**
+ * 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
+ */
+function AutoRefreshHighlighter(highlighterEnv) {
+ EventEmitter.decorate(this);
+
+ this.highlighterEnv = highlighterEnv;
+
+ this.currentNode = null;
+ this.currentQuads = {};
+
+ this.update = this.update.bind(this);
+}
+
+AutoRefreshHighlighter.prototype = {
+ /**
+ * Window corresponding to the current highlighterEnv
+ */
+ get win() {
+ if (!this.highlighterEnv) {
+ return null;
+ }
+ return this.highlighterEnv.window;
+ },
+
+ /**
+ * Show the highlighter on a given node
+ * @param {DOMNode} node
+ * @param {Object} options
+ * Object used for passing options
+ */
+ show: function (node, options = {}) {
+ let isSameNode = node === this.currentNode;
+ let isSameOptions = this._isSameOptions(options);
+
+ if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) {
+ return false;
+ }
+
+ this.options = options;
+
+ this._stopRefreshLoop();
+ this.currentNode = node;
+ this._updateAdjustedQuads();
+ this._startRefreshLoop();
+
+ let shown = this._show();
+ if (shown) {
+ this.emit("shown");
+ }
+ return shown;
+ },
+
+ /**
+ * Hide the highlighter
+ */
+ hide: function () {
+ if (!this._isNodeValid(this.currentNode)) {
+ 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: function (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: function (options) {
+ if (!this.options) {
+ return false;
+ }
+
+ let keys = Object.keys(options);
+
+ if (keys.length !== Object.keys(this.options).length) {
+ return false;
+ }
+
+ for (let 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: function () {
+ for (let region of BOX_MODEL_REGIONS) {
+ this.currentQuads[region] = getAdjustedQuads(
+ this.win,
+ this.currentNode, region);
+ }
+ },
+
+ /**
+ * 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: function () {
+ let oldQuads = JSON.stringify(this.currentQuads);
+ this._updateAdjustedQuads();
+ let newQuads = JSON.stringify(this.currentQuads);
+ return oldQuads !== newQuads;
+ },
+
+ /**
+ * Update the highlighter if the node has moved since the last update.
+ */
+ update: function () {
+ if (!this._isNodeValid(this.currentNode) || !this._hasMoved()) {
+ return;
+ }
+
+ this._update();
+ this.emit("updated");
+ },
+
+ _show: function () {
+ // 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: function () {
+ // 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 scroll, zoom or repaint
+ throw new Error("Custom highlighter class had to implement _update method");
+ },
+
+ _hide: function () {
+ // 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: function () {
+ let win = this.currentNode.ownerDocument.defaultView;
+ this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this));
+ this.rafWin = win;
+ this.update();
+ },
+
+ _stopRefreshLoop: function () {
+ if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) {
+ this.rafWin.cancelAnimationFrame(this.rafID);
+ }
+ this.rafID = this.rafWin = null;
+ },
+
+ destroy: function () {
+ this.hide();
+
+ 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 000000000..35f201a04
--- /dev/null
+++ b/devtools/server/actors/highlighters/box-model.js
@@ -0,0 +1,712 @@
+ /* 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 { extend } = require("sdk/core/heritage");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+const {
+ CanvasFrameAnonymousContentHelper,
+ createNode,
+ createSVGNode,
+ getBindingElementAndPseudo,
+ hasPseudoClassLock,
+ isNodeValid,
+ moveInfobar,
+} = require("./utils/markup");
+const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const inspector = require("devtools/server/actors/inspector");
+const nodeConstants = require("devtools/shared/dom-node-constants");
+
+// 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;
+// FIXME: add ":visited" and ":link" after bug 713106 is fixed
+const PSEUDO_CLASSES = [":hover", ":active", ":focus"];
+
+/**
+ * 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();
+ *
+ * Available options:
+ * - region {String}
+ * "content", "padding", "border" or "margin"
+ * This specifies the region that the guides should outline.
+ * Defaults to "content"
+ * - hideGuides {Boolean}
+ * Defaults to false
+ * - hideInfoBar {Boolean}
+ * Defaults to false
+ * - showOnly {String}
+ * "content", "padding", "border" or "margin"
+ * If set, only this region will be highlighted. Use with onlyRegionArea to
+ * only highlight the area of the region.
+ * - onlyRegionArea {Boolean}
+ * 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">
+ * <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>
+ * </div>
+ * </div>
+ * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/>
+ * </div>
+ * </div>
+ * </div>
+ */
+function BoxModelHighlighter(highlighterEnv) {
+ AutoRefreshHighlighter.call(this, highlighterEnv);
+
+ this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ /**
+ * Optionally customize each region's fill color by adding an entry to the
+ * regionFill property: `highlighter.regionFill.margin = "red";
+ */
+ this.regionFill = {};
+
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+}
+
+BoxModelHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
+ typeName: "BoxModelHighlighter",
+
+ ID_CLASS_PREFIX: "box-model-",
+
+ _buildMarkup: function () {
+ let doc = this.win.document;
+
+ let highlighterContainer = doc.createElement("div");
+ highlighterContainer.className = "highlighter-container box-model";
+
+ // Build the root wrapper, used to adapt to the page zoom.
+ let rootWrapper = createNode(this.win, {
+ parent: highlighterContainer,
+ attributes: {
+ "id": "root",
+ "class": "root"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Building the SVG element with its polygons and lines
+
+ let svg = createSVGNode(this.win, {
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ "id": "elements",
+ "width": "100%",
+ "height": "100%",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let regions = createSVGNode(this.win, {
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ "class": "regions"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ for (let region of BOX_MODEL_REGIONS) {
+ createSVGNode(this.win, {
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ "class": region,
+ "id": region
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ }
+
+ for (let side of BOX_MODEL_SIDES) {
+ createSVGNode(this.win, {
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ "class": "guide-" + side,
+ "id": "guide-" + side,
+ "stroke-width": GUIDE_STROKE_WIDTH
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ }
+
+ // Building the nodeinfo bar markup
+
+ let infobarContainer = createNode(this.win, {
+ parent: rootWrapper,
+ attributes: {
+ "class": "infobar-container",
+ "id": "infobar-container",
+ "position": "top",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let infobar = createNode(this.win, {
+ parent: infobarContainer,
+ attributes: {
+ "class": "infobar"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let texthbox = createNode(this.win, {
+ parent: infobar,
+ attributes: {
+ "class": "infobar-text"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ "class": "infobar-tagname",
+ "id": "infobar-tagname"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ "class": "infobar-id",
+ "id": "infobar-id"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ "class": "infobar-classes",
+ "id": "infobar-classes"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ "class": "infobar-pseudo-classes",
+ "id": "infobar-pseudo-classes"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ "class": "infobar-dimensions",
+ "id": "infobar-dimensions"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ return highlighterContainer;
+ },
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy: function () {
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+ this.markup.destroy();
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ },
+
+ getElement: function (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: function (node) {
+ return node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE));
+ },
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show: function () {
+ if (BOX_MODEL_REGIONS.indexOf(this.options.region) == -1) {
+ this.options.region = "content";
+ }
+
+ let shown = this._update();
+ this._trackMutations();
+ this.emit("ready");
+ return shown;
+ },
+
+ /**
+ * Track the current node markup mutations so that the node info bar can be
+ * updated to reflects the node's attributes
+ */
+ _trackMutations: function () {
+ if (isNodeValid(this.currentNode)) {
+ let win = this.currentNode.ownerDocument.defaultView;
+ this.currentNodeObserver = new win.MutationObserver(this.update);
+ this.currentNodeObserver.observe(this.currentNode, {attributes: true});
+ }
+ },
+
+ _untrackMutations: function () {
+ 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: function () {
+ 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 && (
+ this.currentNode.nodeType === this.currentNode.ELEMENT_NODE ||
+ this.currentNode.nodeType === this.currentNode.TEXT_NODE)) {
+ this._showInfobar();
+ } else {
+ this._hideInfobar();
+ }
+ 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;
+ },
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide: function () {
+ setIgnoreLayoutChanges(true);
+
+ this._untrackMutations();
+ this._hideBoxModel();
+ this._hideInfobar();
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+ },
+
+ /**
+ * Hide the infobar
+ */
+ _hideInfobar: function () {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ },
+
+ /**
+ * Show the infobar
+ */
+ _showInfobar: function () {
+ this.getElement("infobar-container").removeAttribute("hidden");
+ this._updateInfobar();
+ },
+
+ /**
+ * Hide the box model
+ */
+ _hideBoxModel: function () {
+ this.getElement("elements").setAttribute("hidden", "true");
+ },
+
+ /**
+ * Show the box model
+ */
+ _showBoxModel: function () {
+ 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: function (region) {
+ let quads = this.currentQuads[region];
+ if (!quads.length) {
+ return null;
+ }
+
+ let 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 (let 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: function () {
+ let 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++) {
+ let boxType = BOX_MODEL_REGIONS[i];
+ let nextBoxType = BOX_MODEL_REGIONS[i + 1];
+ let box = this.getElement(boxType);
+
+ if (this.regionFill[boxType]) {
+ box.setAttribute("style", "fill:" + this.regionFill[boxType]);
+ } else {
+ box.setAttribute("style", "");
+ }
+
+ // Highlight all quads for this region by setting the "d" attribute of the
+ // corresponding <path>.
+ let path = [];
+ for (let j = 0; j < this.currentQuads[boxType].length; j++) {
+ let boxQuad = this.currentQuads[boxType][j];
+ let 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.
+ let rootId = this.ID_CLASS_PREFIX + "root";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ },
+
+ _getBoxPathCoordinates: function (boxQuad, nextBoxQuad) {
+ let {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;
+ } else {
+ // Otherwise, just draw the region itself, not a filled rectangle.
+ let {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: function () {
+ return this.currentQuads.margin.length ||
+ this.currentQuads.border.length ||
+ this.currentQuads.padding.length ||
+ this.currentQuads.content.length;
+ },
+
+ _getOuterBounds: function () {
+ for (let region of ["margin", "border", "padding", "content"]) {
+ let quad = this._getOuterQuad(region);
+
+ if (!quad) {
+ // Invisible element such as a script tag.
+ break;
+ }
+
+ let {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: function (region) {
+ let {p1, p2, p3, p4} = this._getOuterQuad(region);
+
+ let allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b);
+ let allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b);
+ let toShowX = [];
+ let toShowY = [];
+
+ for (let arr of [allX, allY]) {
+ for (let i = 0; i < arr.length; i++) {
+ let 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", toShowY[0]);
+ this._updateGuide("right", toShowX[1]);
+ this._updateGuide("bottom", toShowY[1]);
+ this._updateGuide("left", toShowX[0]);
+ },
+
+ _hideGuides: function () {
+ for (let 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: function (side, point = -1) {
+ let guide = this.getElement("guide-" + side);
+
+ if (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: function () {
+ if (!this.currentNode) {
+ return;
+ }
+
+ let {bindingElement: node, pseudo} =
+ getBindingElementAndPseudo(this.currentNode);
+
+ // Update the tag, id, classes, pseudo-classes and dimensions
+ let displayName = inspector.getNodeDisplayName(node);
+
+ let id = node.id ? "#" + node.id : "";
+
+ let classList = (node.classList || []).length
+ ? "." + [...node.classList].join(".")
+ : "";
+
+ let pseudos = this._getPseudoClasses(node).join("");
+ if (pseudo) {
+ // Display :after as ::after
+ pseudos += ":" + pseudo;
+ }
+
+ let rect = this._getOuterQuad("border").bounds;
+ let dim = parseFloat(rect.width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(rect.height.toPrecision(6));
+
+ 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._moveInfobar();
+ },
+
+ _getPseudoClasses: function (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: function () {
+ let bounds = this._getOuterBounds();
+ let container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ },
+
+ onWillNavigate: function ({ 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 000000000..0ed1ee961
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-grid.js
@@ -0,0 +1,737 @@
+/* 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 Services = require("Services");
+const { extend } = require("sdk/core/heritage");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+const {
+ CanvasFrameAnonymousContentHelper,
+ createNode,
+ createSVGNode,
+ moveInfobar,
+} = require("./utils/markup");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges
+} = require("devtools/shared/layout/utils");
+const { stringifyGridFragments } = require("devtools/server/actors/utils/css-grid-utils");
+
+const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
+const ROWS = "rows";
+const COLUMNS = "cols";
+const GRID_LINES_PROPERTIES = {
+ "edge": {
+ lineDash: [0, 0],
+ strokeStyle: "#4B0082"
+ },
+ "explicit": {
+ lineDash: [5, 3],
+ strokeStyle: "#8A2BE2"
+ },
+ "implicit": {
+ lineDash: [2, 2],
+ strokeStyle: "#9370DB"
+ }
+};
+
+// px
+const GRID_GAP_PATTERN_WIDTH = 14;
+const GRID_GAP_PATTERN_HEIGHT = 14;
+const GRID_GAP_PATTERN_LINE_DASH = [5, 3];
+const GRID_GAP_PATTERN_STROKE_STYLE = "#9370DB";
+
+/**
+ * Cached used by `CssGridHighlighter.getGridGapPattern`.
+ */
+const gCachedGridPattern = new WeakMap();
+// WeakMap key for the Row grid pattern.
+const ROW_KEY = {};
+// WeakMap key for the Column grid pattern.
+const COLUMN_KEY = {};
+
+/**
+ * The CssGridHighlighter is the class that overlays a visual grid on top of
+ * display:grid elements.
+ *
+ * Usage example:
+ * let h = new CssGridHighlighter(env);
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * Available Options:
+ * - showGridArea(areaName)
+ * @param {String} areaName
+ * Shows the grid area highlight for the given area name.
+ * - showAllGridAreas
+ * Shows all the grid area highlights for the current grid.
+ * - showGridLineNumbers(isShown)
+ * @param {Boolean}
+ * Displays the grid line numbers on the grid lines if isShown is true.
+ * - showInfiniteLines(isShown)
+ * @param {Boolean} isShown
+ * Displays an infinite line to represent the grid lines if isShown is true.
+ *
+ * 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="..." />
+ * </g>
+ * </svg>
+ * <div class="css-grid-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-infobar-areaname">Grid Area Name</span>
+ * <span class="css-grid-infobar-dimensions"Grid Area Dimensions></span>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ */
+function CssGridHighlighter(highlighterEnv) {
+ AutoRefreshHighlighter.call(this, highlighterEnv);
+
+ this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ this.onNavigate = this.onNavigate.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("navigate", this.onNavigate);
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+}
+
+CssGridHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
+ typeName: "CssGridHighlighter",
+
+ ID_CLASS_PREFIX: "css-grid-",
+
+ _buildMarkup() {
+ let container = createNode(this.win, {
+ attributes: {
+ "class": "highlighter-container"
+ }
+ });
+
+ // 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.
+ createNode(this.win, {
+ parent: container,
+ nodeType: "canvas",
+ attributes: {
+ "id": "canvas",
+ "class": "canvas",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Build the SVG element
+ let svg = createSVGNode(this.win, {
+ nodeType: "svg",
+ parent: container,
+ attributes: {
+ "id": "elements",
+ "width": "100%",
+ "height": "100%",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let regions = createSVGNode(this.win, {
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ "class": "regions"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ createSVGNode(this.win, {
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ "class": "areas",
+ "id": "areas"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Building the grid infobar markup
+ let infobarContainer = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "class": "infobar-container",
+ "id": "infobar-container",
+ "position": "top",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let infobar = createNode(this.win, {
+ parent: infobarContainer,
+ attributes: {
+ "class": "infobar"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let textbox = createNode(this.win, {
+ parent: infobar,
+ attributes: {
+ "class": "infobar-text"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: textbox,
+ attributes: {
+ "class": "infobar-areaname",
+ "id": "infobar-areaname"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "span",
+ parent: textbox,
+ attributes: {
+ "class": "infobar-dimensions",
+ "id": "infobar-dimensions"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ return container;
+ },
+
+ destroy() {
+ this.highlighterEnv.off("navigate", this.onNavigate);
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+ this.markup.destroy();
+
+ // Clear the pattern cache to avoid dead object exceptions (Bug 1342051).
+ gCachedGridPattern.delete(ROW_KEY);
+ gCachedGridPattern.delete(COLUMN_KEY);
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ },
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ },
+
+ get ctx() {
+ return this.canvas.getCanvasContext("2d");
+ },
+
+ get canvas() {
+ return this.getElement("canvas");
+ },
+
+ /**
+ * Gets the grid gap pattern used to render the gap regions.
+ *
+ * @param {Object} dimension
+ * Refers to the WeakMap key for the grid dimension type which is either the
+ * constant COLUMN or ROW.
+ * @return {CanvasPattern} grid gap pattern.
+ */
+ getGridGapPattern(dimension) {
+ if (gCachedGridPattern.has(dimension)) {
+ return gCachedGridPattern.get(dimension);
+ }
+
+ // Create the diagonal lines pattern for the rendering the grid gaps.
+ let canvas = createNode(this.win, { nodeType: "canvas" });
+ canvas.width = GRID_GAP_PATTERN_WIDTH;
+ canvas.height = GRID_GAP_PATTERN_HEIGHT;
+
+ let ctx = canvas.getContext("2d");
+ ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(.5, .5);
+
+ if (dimension === COLUMN_KEY) {
+ ctx.moveTo(0, 0);
+ ctx.lineTo(GRID_GAP_PATTERN_WIDTH, GRID_GAP_PATTERN_HEIGHT);
+ } else {
+ ctx.moveTo(GRID_GAP_PATTERN_WIDTH, 0);
+ ctx.lineTo(0, GRID_GAP_PATTERN_HEIGHT);
+ }
+
+ ctx.strokeStyle = GRID_GAP_PATTERN_STROKE_STYLE;
+ ctx.stroke();
+
+ let pattern = ctx.createPattern(canvas, "repeat");
+ gCachedGridPattern.set(dimension, pattern);
+ return pattern;
+ },
+
+ /**
+ * Called when the page navigates. Used to clear the cached gap patterns and avoid
+ * using DeadWrapper objects as gap patterns the next time.
+ */
+ onNavigate() {
+ gCachedGridPattern.delete(ROW_KEY);
+ gCachedGridPattern.delete(COLUMN_KEY);
+ },
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ },
+
+ _show() {
+ if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) && !this.isGrid()) {
+ this.hide();
+ return false;
+ }
+
+ return this._update();
+ },
+
+ /**
+ * Shows the grid area highlight for the given area name.
+ *
+ * @param {String} areaName
+ * Grid area name.
+ */
+ showGridArea(areaName) {
+ this.renderGridArea(areaName);
+ this._showGridArea();
+ },
+
+ /**
+ * Shows all the grid area highlights for the current grid.
+ */
+ showAllGridAreas() {
+ this.renderGridArea();
+ this._showGridArea();
+ },
+
+ /**
+ * Clear the grid area highlights.
+ */
+ clearGridAreas() {
+ let box = this.getElement("areas");
+ box.setAttribute("d", "");
+ },
+
+ /**
+ * 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.getGridFragments().length > 0;
+ },
+
+ /**
+ * 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).
+ */
+ _hasMoved() {
+ let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ let oldGridData = stringifyGridFragments(this.gridData);
+ this.gridData = this.currentNode.getGridFragments();
+ let newGridData = stringifyGridFragments(this.gridData);
+
+ return hasMoved || oldGridData !== newGridData;
+ },
+
+ /**
+ * 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);
+
+ // Clear the canvas the grid area highlights.
+ this.clearCanvas();
+ this.clearGridAreas();
+
+ // Start drawing the grid fragments.
+ for (let i = 0; i < this.gridData.length; i++) {
+ let fragment = this.gridData[i];
+ let quad = this.currentQuads.content[i];
+ this.renderFragment(fragment, quad);
+ }
+
+ // Display the grid area highlights if needed.
+ if (this.options.showAllGridAreas) {
+ this.showAllGridAreas();
+ } else if (this.options.showGridArea) {
+ this.showGridArea(this.options.showGridArea);
+ }
+
+ this._showGrid();
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+ return true;
+ },
+
+ /**
+ * Update the grid information displayed in the grid info bar.
+ *
+ * @param {GridArea} area
+ * The grid area object.
+ * @param {Number} x1
+ * The first x-coordinate of the grid area rectangle.
+ * @param {Number} x2
+ * The second x-coordinate of the grid area rectangle.
+ * @param {Number} y1
+ * The first y-coordinate of the grid area rectangle.
+ * @param {Number} y2
+ * The second y-coordinate of the grid area rectangle.
+ */
+ _updateInfobar(area, x1, x2, y1, y2) {
+ let width = x2 - x1;
+ let height = y2 - y1;
+ let dim = parseFloat(width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(height.toPrecision(6));
+
+ this.getElement("infobar-areaname").setTextContent(area.name);
+ this.getElement("infobar-dimensions").setTextContent(dim);
+
+ this._moveInfobar(x1, x2, y1, y2);
+ },
+
+ /**
+ * Move the grid infobar to the right place in the highlighter.
+ *
+ * @param {Number} x1
+ * The first x-coordinate of the grid area rectangle.
+ * @param {Number} x2
+ * The second x-coordinate of the grid area rectangle.
+ * @param {Number} y1
+ * The first y-coordinate of the grid area rectangle.
+ * @param {Number} y2
+ * The second y-coordinate of the grid area rectangle.
+ */
+ _moveInfobar(x1, x2, y1, y2) {
+ let bounds = {
+ bottom: y2,
+ height: y2 - y1,
+ left: x1,
+ right: x2,
+ top: y1,
+ width: x2 - x1,
+ x: x1,
+ y: y1,
+ };
+ let container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ },
+
+ clearCanvas() {
+ let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2));
+ let width = this.win.innerWidth;
+ let height = this.win.innerHeight;
+
+ // Resize the canvas taking the dpr into account so as to have crisp lines.
+ this.canvas.setAttribute("width", width * ratio);
+ this.canvas.setAttribute("height", height * ratio);
+ this.canvas.setAttribute("style", `width:${width}px;height:${height}px`);
+ this.ctx.scale(ratio, ratio);
+
+ this.ctx.clearRect(0, 0, width, height);
+ },
+
+ getFirstRowLinePos(fragment) {
+ return fragment.rows.lines[0].start;
+ },
+
+ getLastRowLinePos(fragment) {
+ return fragment.rows.lines[fragment.rows.lines.length - 1].start;
+ },
+
+ getFirstColLinePos(fragment) {
+ return fragment.cols.lines[0].start;
+ },
+
+ 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;
+ },
+
+ renderFragment(fragment, quad) {
+ this.renderLines(fragment.cols, quad, COLUMNS, "left", "top", "height",
+ this.getFirstRowLinePos(fragment),
+ this.getLastRowLinePos(fragment));
+ this.renderLines(fragment.rows, quad, ROWS, "top", "left", "width",
+ this.getFirstColLinePos(fragment),
+ this.getLastColLinePos(fragment));
+ },
+
+ /**
+ * 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 {String} mainSide
+ * The main side of the given grid dimension - "top" for rows and
+ * "left" for columns.
+ * @param {String} crossSide
+ * The cross side of the given grid dimension - "left" for rows and
+ * "top" for columns.
+ * @param {String} mainSize
+ * The main size of the given grid dimension - "width" for rows and
+ * "height" for columns.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid dimension.
+ * @param {Number} endPos
+ * The end position of the cross side of the grid dimension.
+ */
+ renderLines(gridDimension, {bounds}, dimensionType, mainSide, crossSide,
+ mainSize, startPos, endPos) {
+ let lineStartPos = (bounds[crossSide] / getCurrentZoom(this.win)) + startPos;
+ let lineEndPos = (bounds[crossSide] / getCurrentZoom(this.win)) + endPos;
+
+ if (this.options.showInfiniteLines) {
+ lineStartPos = 0;
+ lineEndPos = parseInt(this.canvas.getAttribute(mainSize), 10);
+ }
+
+ let lastEdgeLineIndex = this.getLastEdgeLineIndex(gridDimension.tracks);
+
+ for (let i = 0; i < gridDimension.lines.length; i++) {
+ let line = gridDimension.lines[i];
+ let linePos = (bounds[mainSide] / getCurrentZoom(this.win)) + line.start;
+
+ if (this.options.showGridLineNumbers) {
+ this.renderGridLineNumber(line.number, linePos, lineStartPos, dimensionType);
+ }
+
+ if (i == 0 || i == lastEdgeLineIndex) {
+ this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge");
+ } else {
+ this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType,
+ gridDimension.tracks[i - 1].type);
+ }
+
+ // Render a second line to illustrate the gutter for non-zero breadth.
+ if (line.breadth > 0) {
+ this.renderGridGap(linePos, lineStartPos, lineEndPos, line.breadth,
+ dimensionType);
+ this.renderLine(linePos + line.breadth, lineStartPos, lineEndPos, dimensionType,
+ gridDimension.tracks[i].type);
+ }
+ }
+ },
+
+ /**
+ * 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 {[type]} lineType
+ * The grid line type - "edge", "explicit", or "implicit".
+ */
+ renderLine(linePos, startPos, endPos, dimensionType, lineType) {
+ this.ctx.save();
+ this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash);
+ this.ctx.beginPath();
+ this.ctx.translate(.5, .5);
+
+ if (dimensionType === COLUMNS) {
+ this.ctx.moveTo(linePos, startPos);
+ this.ctx.lineTo(linePos, endPos);
+ } else {
+ this.ctx.moveTo(startPos, linePos);
+ this.ctx.lineTo(endPos, linePos);
+ }
+
+ this.ctx.strokeStyle = GRID_LINES_PROPERTIES[lineType].strokeStyle;
+ this.ctx.stroke();
+ this.ctx.restore();
+ },
+
+ /**
+ * 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 {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ */
+ renderGridLineNumber(lineNumber, linePos, startPos, dimensionType) {
+ this.ctx.save();
+
+ if (dimensionType === COLUMNS) {
+ this.ctx.fillText(lineNumber, linePos, startPos);
+ } else {
+ let textWidth = this.ctx.measureText(lineNumber).width;
+ this.ctx.fillText(lineNumber, startPos - textWidth, linePos);
+ }
+
+ this.ctx.restore();
+ },
+
+ /**
+ * 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) {
+ this.ctx.save();
+
+ if (dimensionType === COLUMNS) {
+ this.ctx.fillStyle = this.getGridGapPattern(COLUMN_KEY);
+ this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos);
+ } else {
+ this.ctx.fillStyle = this.getGridGapPattern(ROW_KEY);
+ this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth);
+ }
+
+ this.ctx.restore();
+ },
+
+ /**
+ * 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) {
+ let paths = [];
+ let currentZoom = getCurrentZoom(this.win);
+
+ for (let i = 0; i < this.gridData.length; i++) {
+ let fragment = this.gridData[i];
+ let {bounds} = this.currentQuads.content[i];
+
+ for (let area of fragment.areas) {
+ if (areaName && areaName != area.name) {
+ continue;
+ }
+
+ let rowStart = fragment.rows.lines[area.rowStart - 1];
+ let rowEnd = fragment.rows.lines[area.rowEnd - 1];
+ let columnStart = fragment.cols.lines[area.columnStart - 1];
+ let columnEnd = fragment.cols.lines[area.columnEnd - 1];
+
+ let x1 = columnStart.start + columnStart.breadth +
+ (bounds.left / currentZoom);
+ let x2 = columnEnd.start + (bounds.left / currentZoom);
+ let y1 = rowStart.start + rowStart.breadth +
+ (bounds.top / currentZoom);
+ let y2 = rowEnd.start + (bounds.top / currentZoom);
+
+ let path = "M" + x1 + "," + y1 + " " +
+ "L" + x2 + "," + y1 + " " +
+ "L" + x2 + "," + y2 + " " +
+ "L" + x1 + "," + y2;
+ paths.push(path);
+
+ // Update and show the info bar when only displaying a single grid area.
+ if (areaName) {
+ this._updateInfobar(area, x1, x2, y1, y2);
+ this._showInfoBar();
+ }
+ }
+ }
+
+ let box = this.getElement("areas");
+ box.setAttribute("d", paths.join(" "));
+ },
+
+ /**
+ * Hide the highlighter, the canvas and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideGrid();
+ this._hideGridArea();
+ this._hideInfoBar();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+ },
+
+ _hideGrid() {
+ this.getElement("canvas").setAttribute("hidden", "true");
+ },
+
+ _showGrid() {
+ this.getElement("canvas").removeAttribute("hidden");
+ },
+
+ _hideGridArea() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ },
+
+ _showGridArea() {
+ this.getElement("elements").removeAttribute("hidden");
+ },
+
+ _hideInfoBar() {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ },
+
+ _showInfoBar() {
+ this.getElement("infobar-container").removeAttribute("hidden");
+ },
+
+});
+
+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 000000000..83a2c7e59
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-transform.js
@@ -0,0 +1,243 @@
+/* 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 { extend } = require("sdk/core/heritage");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+const {
+ CanvasFrameAnonymousContentHelper, getComputedStyle,
+ createSVGNode, createNode } = require("./utils/markup");
+const { setIgnoreLayoutChanges,
+ getNodeBounds } = require("devtools/shared/layout/utils");
+
+// 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.
+ */
+function CssTransformHighlighter(highlighterEnv) {
+ AutoRefreshHighlighter.call(this, highlighterEnv);
+
+ this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+ this._buildMarkup.bind(this));
+}
+
+CssTransformHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
+ typeName: "CssTransformHighlighter",
+
+ ID_CLASS_PREFIX: "css-transform-",
+
+ _buildMarkup: function () {
+ let container = createNode(this.win, {
+ attributes: {
+ "class": "highlighter-container"
+ }
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ let rootWrapper = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let svg = createSVGNode(this.win, {
+ 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++;
+ let marker = createSVGNode(this.win, {
+ 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
+ });
+ createSVGNode(this.win, {
+ nodeType: "path",
+ parent: marker,
+ attributes: {
+ "d": "M 0 0 L 10 5 L 0 10 z",
+ "fill": "#08C"
+ }
+ });
+
+ let shapesGroup = createSVGNode(this.win, {
+ nodeType: "g",
+ parent: svg
+ });
+
+ // Create the 2 polygons (transformed and untransformed)
+ createSVGNode(this.win, {
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ "id": "untransformed",
+ "class": "untransformed"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createSVGNode(this.win, {
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ "id": "transformed",
+ "class": "transformed"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Create the arrows
+ for (let nb of ["1", "2", "3", "4"]) {
+ createSVGNode(this.win, {
+ 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: function () {
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ this.markup.destroy();
+ },
+
+ getElement: function (id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ },
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show: function () {
+ if (!this._isTransformed(this.currentNode)) {
+ this.hide();
+ return false;
+ }
+
+ return this._update();
+ },
+
+ /**
+ * Checks if the supplied node is transformed and not inline
+ */
+ _isTransformed: function (node) {
+ let style = getComputedStyle(node);
+ return style && (style.transform !== "none" && style.display !== "inline");
+ },
+
+ _setPolygonPoints: function (quad, id) {
+ let points = [];
+ for (let point of ["p1", "p2", "p3", "p4"]) {
+ points.push(quad[point].x + "," + quad[point].y);
+ }
+ this.getElement(id).setAttribute("points", points.join(" "));
+ },
+
+ _setLinePoints: function (p1, p2, id) {
+ let line = this.getElement(id);
+ line.setAttribute("x1", p1.x);
+ line.setAttribute("y1", p1.y);
+ line.setAttribute("x2", p2.x);
+ line.setAttribute("y2", p2.y);
+
+ let 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: function () {
+ setIgnoreLayoutChanges(true);
+
+ // Getting the points for the transformed shape
+ let quads = this.currentQuads.border;
+ if (!quads.length ||
+ quads[0].bounds.width <= 0 || quads[0].bounds.height <= 0) {
+ this._hideShapes();
+ return false;
+ }
+
+ let [quad] = quads;
+
+ // Getting the points for the untransformed shape
+ let untransformedQuad = getNodeBounds(this.win, this.currentNode);
+
+ this._setPolygonPoints(quad, "transformed");
+ this._setPolygonPoints(untransformedQuad, "untransformed");
+ for (let 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: function () {
+ setIgnoreLayoutChanges(true);
+ this._hideShapes();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+ },
+
+ _hideShapes: function () {
+ this.getElement("elements").setAttribute("hidden", "true");
+ },
+
+ _showShapes: function () {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+});
+exports.CssTransformHighlighter = CssTransformHighlighter;
diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js
new file mode 100644
index 000000000..a90ec22bd
--- /dev/null
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -0,0 +1,534 @@
+/* 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 {Ci, Cc} = require("chrome");
+const {CanvasFrameAnonymousContentHelper, createNode} = require("./utils/markup");
+const Services = require("Services");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {rgbToHsl, rgbToColorName} = require("devtools/shared/css/color").colorUtils;
+const {getCurrentZoom, getFrameOffsets} = require("devtools/shared/layout/utils");
+
+loader.lazyGetter(this, "clipboardHelper",
+ () => Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper));
+loader.lazyGetter(this, "l10n",
+ () => Services.strings.createBundle("chrome://devtools/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 is the class that draws the gradient line and
+ * color stops as an overlay on top of a linear-gradient background-image.
+ */
+function EyeDropper(highlighterEnv) {
+ EventEmitter.decorate(this);
+
+ this.highlighterEnv = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ // Get a couple of settings from prefs.
+ this.format = Services.prefs.getCharPref(FORMAT_PREF);
+ this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF);
+}
+
+EyeDropper.prototype = {
+ typeName: "EyeDropper",
+
+ ID_CLASS_PREFIX: "eye-dropper-",
+
+ get win() {
+ return this.highlighterEnv.window;
+ },
+
+ _buildMarkup() {
+ // Highlighter main container.
+ let container = createNode(this.win, {
+ attributes: {"class": "highlighter-container"}
+ });
+
+ // Wrapper element.
+ let wrapper = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // The magnifier canvas element.
+ createNode(this.win, {
+ parent: wrapper,
+ nodeType: "canvas",
+ attributes: {
+ "id": "canvas",
+ "class": "canvas",
+ "width": MAGNIFIER_WIDTH,
+ "height": MAGNIFIER_HEIGHT
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // The color label element.
+ let colorLabelContainer = createNode(this.win, {
+ parent: wrapper,
+ attributes: {"class": "color-container"},
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: {"id": "color-preview", "class": "color-preview"},
+ prefix: this.ID_CLASS_PREFIX
+ });
+ createNode(this.win, {
+ 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.
+ */
+ 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();
+
+ // Start listening for user events.
+ let {pageListenerTarget} = this.highlighterEnv;
+ pageListenerTarget.addEventListener("mousemove", this);
+ pageListenerTarget.addEventListener("click", this, true);
+ pageListenerTarget.addEventListener("keydown", this);
+ pageListenerTarget.addEventListener("DOMMouseScroll", this);
+ pageListenerTarget.addEventListener("FullZoomChange", this);
+
+ // 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();
+
+ return true;
+ },
+
+ /**
+ * Hide the eye-dropper highlighter.
+ */
+ hide() {
+ if (this.highlighterEnv.isXUL) {
+ return;
+ }
+
+ this.pageImage = null;
+
+ let {pageListenerTarget} = this.highlighterEnv;
+ pageListenerTarget.removeEventListener("mousemove", this);
+ pageListenerTarget.removeEventListener("click", this, true);
+ pageListenerTarget.removeEventListener("keydown", this);
+ pageListenerTarget.removeEventListener("DOMMouseScroll", this);
+ pageListenerTarget.removeEventListener("FullZoomChange", this);
+
+ this.getElement("root").setAttribute("hidden", "true");
+ this.getElement("root").removeAttribute("drawn");
+
+ this.emit("hidden");
+ },
+
+ prepareImageCapture() {
+ // Get the image data from the content window.
+ let imageData = getWindowAsImageData(this.win);
+
+ // We need to transform 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.
+ this.win.createImageBitmap(imageData).then(image => {
+ 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() {
+ let pos = (this.centerCell * this.cellSize) + (this.cellSize / 2);
+ let 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;
+ }
+
+ let {width, height, x, y} = this.magnifiedArea;
+
+ let zoomedWidth = width / this.eyeDropperZoomLevel;
+ let zoomedHeight = height / this.eyeDropperZoomLevel;
+
+ let sx = x - (zoomedWidth / 2);
+ let sy = y - (zoomedHeight / 2);
+ let sw = zoomedWidth;
+ let 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.
+ let 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() {
+ let {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 - .5, 0);
+ this.ctx.lineTo(i - .5, height);
+ this.ctx.stroke();
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, i - .5);
+ this.ctx.lineTo(width, i - .5);
+ this.ctx.stroke();
+ }
+ },
+
+ /**
+ * Draw a box on the canvas to highlight the center cell.
+ */
+ drawCrosshair() {
+ let 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.
+ let [xOffset, yOffset] = getFrameOffsets(this.win, e.target);
+ let x = xOffset + e.pageX - this.win.scrollX;
+ let 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;
+ case "click":
+ 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) {
+ let 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(), e => console.error(e));
+ },
+
+ /**
+ * 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.
+ let 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) {
+ let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+ let scale = getCurrentZoom(win);
+ let width = win.innerWidth;
+ let height = win.innerHeight;
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+ canvas.mozOpaque = true;
+
+ let 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) {
+ let [r, g, b] = rgb;
+
+ switch (format) {
+ case "hex":
+ return hexString(rgb);
+ case "rgb":
+ return "rgb(" + r + ", " + g + ", " + b + ")";
+ case "hsl":
+ let [h, s, l] = rgbToHsl(rgb);
+ return "hsl(" + h + ", " + s + "%, " + l + "%)";
+ case "name":
+ let str;
+ try {
+ str = rgbToColorName(r, g, b);
+ } catch (e) {
+ str = 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]) {
+ let val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
+ return "#" + val.toString(16).substr(-6).toUpperCase();
+}
+
+function cap(value, min, max) {
+ return Math.max(min, Math.min(value, max));
+}
diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js
new file mode 100644
index 000000000..35b33eec1
--- /dev/null
+++ b/devtools/server/actors/highlighters/geometry-editor.js
@@ -0,0 +1,704 @@
+/* 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 { extend } = require("sdk/core/heritage");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+const { CanvasFrameAnonymousContentHelper, getCSSStyleRules, getComputedStyle,
+ createSVGNode, createNode } = require("./utils/markup");
+const { setIgnoreLayoutChanges, getAdjustedQuads } = require("devtools/shared/layout/utils");
+
+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: function () {
+ return [...this.SIDES, ...this.SIZES];
+ },
+
+ isSide: function (name) {
+ return this.SIDES.indexOf(name) !== -1;
+ },
+
+ isSize: function (name) {
+ return this.SIZES.indexOf(name) !== -1;
+ },
+
+ containsSide: function (names) {
+ return names.some(name => this.SIDES.indexOf(name) !== -1);
+ },
+
+ containsSize: function (names) {
+ return names.some(name => this.SIZES.indexOf(name) !== -1);
+ },
+
+ isHorizontal: function (name) {
+ return name === "left" || name === "right" || name === "width";
+ },
+
+ isInverted: function (name) {
+ return name === "right" || name === "bottom";
+ },
+
+ mainAxisStart: function (name) {
+ return this.isHorizontal(name) ? "left" : "top";
+ },
+
+ crossAxisStart: function (name) {
+ return this.isHorizontal(name) ? "top" : "left";
+ },
+
+ mainAxisSize: function (name) {
+ return this.isHorizontal(name) ? "width" : "height";
+ },
+
+ crossAxisSize: function (name) {
+ return this.isHorizontal(name) ? "height" : "width";
+ },
+
+ axis: function (name) {
+ return this.isHorizontal(name) ? "x" : "y";
+ },
+
+ crossAxis: function (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) {
+ let win = node.ownerDocument.defaultView;
+
+ 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 {nsIDOMNode} 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) {
+ let props = new Map();
+ if (!node) {
+ return props;
+ }
+
+ // Get the list of css rules applying to the current node.
+ let cssRules = getCSSStyleRules(node);
+ for (let i = 0; i < cssRules.Count(); i++) {
+ let rule = cssRules.GetElementAt(i);
+ for (let name of GeoProp.allProps()) {
+ let 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 (let name of GeoProp.allProps()) {
+ let 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.
+ let { position } = getComputedStyle(node);
+ for (let [name] of props) {
+ // Top/left/bottom/right on static positioned elements have no effect.
+ if (position === "static" && GeoProp.SIDES.indexOf(name) !== -1) {
+ props.delete(name);
+ }
+
+ // Bottom/right on relative positioned elements are only used if top/left
+ // are not defined.
+ let hasRightAndLeft = name === "right" && props.has("left");
+ let 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.
+ */
+function GeometryEditorHighlighter(highlighterEnv) {
+ AutoRefreshHighlighter.call(this, highlighterEnv);
+
+ // The list of element geometry properties that can be set.
+ this.definedProperties = new Map();
+
+ this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ let { pageListenerTarget } = this.highlighterEnv;
+
+ // Register the geometry editor instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+
+ // Register the mousedown event for each Geometry Editor's handler.
+ // Those events are automatically removed when the markup is destroyed.
+ let onMouseDown = this.handleEvent.bind(this);
+
+ for (let side of GeoProp.SIDES) {
+ this.getElement("handler-" + side)
+ .addEventListener("mousedown", onMouseDown);
+ }
+}
+
+GeometryEditorHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
+ typeName: "GeometryEditorHighlighter",
+
+ ID_CLASS_PREFIX: "geometry-editor-",
+
+ _buildMarkup: function () {
+ let container = createNode(this.win, {
+ attributes: {"class": "highlighter-container"}
+ });
+
+ let root = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let svg = createSVGNode(this.win, {
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ "id": "elements",
+ "width": "100%",
+ "height": "100%"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Offset parent node highlighter.
+ createSVGNode(this.win, {
+ nodeType: "polygon",
+ parent: svg,
+ attributes: {
+ "class": "offset-parent",
+ "id": "offset-parent",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Current node highlighter (margin box).
+ createSVGNode(this.win, {
+ 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 (let name of GeoProp.SIDES) {
+ createSVGNode(this.win, {
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ "class": "arrow " + name,
+ "id": "arrow-" + name,
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ createSVGNode(this.win, {
+ 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>.
+ let labelG = createSVGNode(this.win, {
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ "id": "label-" + name,
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let subG = createSVGNode(this.win, {
+ nodeType: "g",
+ parent: labelG,
+ attributes: {
+ "transform": GeoProp.isHorizontal(name)
+ ? "translate(-30 -30)"
+ : "translate(5 -10)"
+ }
+ });
+
+ createSVGNode(this.win, {
+ 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
+ });
+
+ createSVGNode(this.win, {
+ 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: function () {
+ // Avoiding exceptions if `destroy` is called multiple times; and / or the
+ // highlighter environment was already destroyed.
+ if (!this.highlighterEnv) {
+ return;
+ }
+
+ let { pageListenerTarget } = this.highlighterEnv;
+
+ 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: function (event, id) {
+ // No event handling if the highlighter is hidden
+ if (this.getElement("root").hasAttribute("hidden")) {
+ return;
+ }
+
+ const { type, pageX, pageY } = event;
+
+ switch (type) {
+ case "pagehide":
+ this.destroy();
+ break;
+ case "mousedown":
+ // The mousedown event is intended only for the handler
+ if (!id) {
+ return;
+ }
+
+ let handlerSide = this.markup.getElement(id).getAttribute("data-side");
+
+ if (handlerSide) {
+ let side = handlerSide;
+ let sideProp = this.definedProperties.get(side);
+
+ if (!sideProp) {
+ return;
+ }
+
+ let value = sideProp.cssRule.style.getPropertyValue(side);
+ let computedValue = this.computedStyle.getPropertyValue(side);
+
+ let [unit] = value.match(/[^\d]+$/) || [""];
+
+ value = parseFloat(value);
+
+ let ratio = (value / parseFloat(computedValue)) || 1;
+ let 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]) {
+ let { 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;
+ }
+
+ let { side, x, y, value, unit, inc } = this[_dragging];
+ let sideProps = this.definedProperties.get(side);
+
+ if (!sideProps) {
+ return;
+ }
+
+ let 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: function (id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ },
+
+ _show: function () {
+ this.computedStyle = getComputedStyle(this.currentNode);
+ let pos = this.computedStyle.position;
+ // XXX: sticky positioning is ignored for now. To be implemented next.
+ if (pos === "sticky") {
+ this.hide();
+ return false;
+ }
+
+ let hasUpdated = this._update();
+ if (!hasUpdated) {
+ this.hide();
+ return false;
+ }
+
+ this.getElement("root").removeAttribute("hidden");
+
+ return true;
+ },
+
+ _update: function () {
+ // 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);
+
+ 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.
+ let node = this.currentNode;
+ this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root");
+
+ setIgnoreLayoutChanges(false, node.ownerDocument.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: function () {
+ // Get the offsetParent, if any.
+ this.offsetParent = getOffsetParent(this.currentNode);
+ // And the offsetParent quads.
+ this.parentQuads = getAdjustedQuads(
+ this.win, this.offsetParent.element, "padding");
+
+ let el = this.getElement("offset-parent");
+
+ let isPositioned = this.computedStyle.position === "absolute" ||
+ this.computedStyle.position === "fixed";
+ let isRelative = this.computedStyle.position === "relative";
+ let isHighlighted = false;
+
+ if (this.offsetParent.element && isPositioned) {
+ let {p1, p2, p3, p4} = this.parentQuads[0];
+ let 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) {
+ let xDelta = parseFloat(this.computedStyle.left);
+ let yDelta = parseFloat(this.computedStyle.top);
+ if (xDelta || yDelta) {
+ let {p1, p2, p3, p4} = this.currentQuads.margin[0];
+ let 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: function () {
+ let box = this.getElement("current-node");
+ let {p1, p2, p3, p4} = this.currentQuads.margin[0];
+ let 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: function () {
+ 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.currentNode.ownerDocument.documentElement);
+ },
+
+ hideArrows: function () {
+ for (let 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: function () {
+ this.hideArrows();
+
+ // Position arrows always end at the node's margin box.
+ let 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 |
+ // +------------------+----------------+
+ let getSideArrowStartPos = side => {
+ // In case an offsetParent exists and is highlighted.
+ if (this.parentQuads && this.parentQuads.length) {
+ return this.parentQuads[0].bounds[side];
+ }
+
+ // In case of relative positioning.
+ if (this.computedStyle.position === "relative") {
+ if (GeoProp.isInverted(side)) {
+ return marginBox[side] + parseFloat(this.computedStyle[side]);
+ }
+ return marginBox[side] - parseFloat(this.computedStyle[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.ownerDocument.defaultView["scroll" +
+ GeoProp.axis(side).toUpperCase()];
+ };
+
+ for (let side of GeoProp.SIDES) {
+ let sideProp = this.definedProperties.get(side);
+ if (!sideProp) {
+ continue;
+ }
+
+ let mainAxisStartPos = getSideArrowStartPos(side);
+ let mainAxisEndPos = marginBox[side];
+ let crossAxisPos = marginBox[GeoProp.crossAxisStart(side)] +
+ marginBox[GeoProp.crossAxisSize(side)] / 2;
+
+ this.updateArrow(side, mainAxisStartPos, mainAxisEndPos, crossAxisPos,
+ sideProp.cssRule.style.getPropertyValue(side));
+ }
+ },
+
+ updateArrow: function (side, mainStart, mainEnd, crossPos, labelValue) {
+ let arrowEl = this.getElement("arrow-" + side);
+ let labelEl = this.getElement("label-" + side);
+ let labelTextEl = this.getElement("label-text-" + side);
+ let 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).
+ let capitalize = str => str[0].toUpperCase() + str.substring(1);
+ let 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;
+ }
+ }
+ let labelCross = crossPos;
+ labelEl.setAttribute("transform", GeoProp.isHorizontal(side)
+ ? "translate(" + labelMain + " " + labelCross + ")"
+ : "translate(" + labelCross + " " + labelMain + ")");
+ labelEl.removeAttribute("hidden");
+ labelTextEl.setTextContent(labelValue);
+ }
+});
+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 000000000..e1e1de94f
--- /dev/null
+++ b/devtools/server/actors/highlighters/measuring-tool.js
@@ -0,0 +1,563 @@
+/* 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 events = require("sdk/event/core");
+const { getCurrentZoom,
+ setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const {
+ CanvasFrameAnonymousContentHelper,
+ createSVGNode, createNode } = require("./utils/markup");
+
+// 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 SIDES = ["top", "right", "bottom", "left"];
+
+/**
+ * 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.
+ */
+function MeasuringToolHighlighter(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ this.coords = {
+ x: 0,
+ y: 0
+ };
+
+ let { pageListenerTarget } = highlighterEnv;
+
+ pageListenerTarget.addEventListener("mousedown", this);
+ pageListenerTarget.addEventListener("mousemove", this);
+ pageListenerTarget.addEventListener("mouseleave", this);
+ pageListenerTarget.addEventListener("scroll", this);
+ pageListenerTarget.addEventListener("pagehide", this);
+}
+
+MeasuringToolHighlighter.prototype = {
+ typeName: "MeasuringToolHighlighter",
+
+ ID_CLASS_PREFIX: "measuring-tool-highlighter-",
+
+ _buildMarkup() {
+ let prefix = this.ID_CLASS_PREFIX;
+ let { window } = this.env;
+
+ let container = createNode(window, {
+ attributes: {"class": "highlighter-container"}
+ });
+
+ let root = createNode(window, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root",
+ },
+ prefix
+ });
+
+ let svg = createSVGNode(window, {
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ "class": "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true"
+ },
+ prefix
+ });
+
+ createNode(window, {
+ nodeType: "label",
+ attributes: {
+ id: "label-size",
+ "class": "label-size",
+ "hidden": "true"
+ },
+ parent: root,
+ prefix
+ });
+
+ createNode(window, {
+ 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
+ let g = createSVGNode(window, {
+ nodeType: "g",
+ attributes: {
+ id: "tool",
+ },
+ parent: svg,
+ prefix
+ });
+
+ createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ id: "box-path"
+ },
+ parent: g,
+ prefix
+ });
+
+ createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ id: "diagonal-path"
+ },
+ parent: g,
+ prefix
+ });
+
+ for (let side of SIDES) {
+ createSVGNode(window, {
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ "class": `guide-${side}`,
+ id: `guide-${side}`,
+ hidden: "true"
+ },
+ prefix
+ });
+ }
+
+ return container;
+ },
+
+ _update() {
+ let { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ let zoom = getCurrentZoom(window);
+
+ let { documentElement } = window.document;
+
+ let width = Math.max(documentElement.clientWidth,
+ documentElement.scrollWidth,
+ documentElement.offsetWidth);
+
+ let height = Math.max(documentElement.clientHeight,
+ documentElement.scrollHeight,
+ documentElement.offsetHeight);
+
+ let { body } = window.document;
+
+ // get the size of the content document despite the compatMode
+ if (body) {
+ width = Math.max(width, body.scrollWidth, body.offsetWidth);
+ height = Math.max(height, body.scrollHeight, body.offsetHeight);
+ }
+
+ let { coords } = this;
+
+ let isZoomChanged = zoom !== coords.zoom;
+
+ if (isZoomChanged) {
+ coords.zoom = zoom;
+ this.updateLabel();
+ }
+
+ let isDocumentSizeChanged = width !== coords.documentWidth ||
+ height !== coords.documentHeight;
+
+ if (isDocumentSizeChanged) {
+ coords.documentWidth = width;
+ coords.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, 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();
+
+ let { pageListenerTarget } = this.env;
+
+ pageListenerTarget.removeEventListener("mousedown", this);
+ pageListenerTarget.removeEventListener("mousemove", this);
+ pageListenerTarget.removeEventListener("mouseup", this);
+ pageListenerTarget.removeEventListener("scroll", this);
+ pageListenerTarget.removeEventListener("pagehide", this);
+ pageListenerTarget.removeEventListener("mouseleave", this);
+
+ this.markup.destroy();
+
+ events.emit(this, "destroy");
+ },
+
+ show() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("elements").removeAttribute("hidden");
+
+ this._update();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.hideLabel("size");
+ this.hideLabel("position");
+
+ this.getElement("elements").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.setCoords(undefined, undefined, w, h);
+ },
+
+ setCoords(x, y, w, h) {
+ let { coords } = this;
+
+ if (typeof x !== "undefined") {
+ coords.x = x;
+ }
+
+ if (typeof y !== "undefined") {
+ coords.y = y;
+ }
+
+ if (typeof w !== "undefined") {
+ coords.w = w;
+ }
+
+ if (typeof h !== "undefined") {
+ coords.h = h;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ if (this._isDragging) {
+ this.updatePaths();
+ }
+
+ this.updateLabel();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ updatePaths() {
+ let { x, y, w, h } = this.coords;
+ let 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.
+ let x1 = w > 0 ? 0.5 : 0;
+ let y1 = w < 0 && h < 0 ? -0.5 : 0;
+ let w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
+ let h1 = h + (h > 0 && w > 0 ? -0.5 : 0);
+
+ let 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._isDragging ? "size" : "position";
+
+ let isSizeLabel = type === "size";
+
+ let label = this.getElement(`label-${type}`);
+
+ let origin = "top left";
+
+ let { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
+ let { x, y, w, h, zoom } = this.coords;
+ let scale = 1 / zoom;
+
+ w = w || 0;
+ h = h || 0;
+ x = (x || 0) + w;
+ y = (y || 0) + h;
+
+ let labelMargin, labelHeight, labelWidth;
+
+ if (isSizeLabel) {
+ labelMargin = LABEL_SIZE_MARGIN;
+ labelWidth = LABEL_SIZE_WIDTH;
+ labelHeight = LABEL_SIZE_HEIGHT;
+
+ let 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(`${x}
+ ${y}`);
+ }
+
+ // Size used to position properly the label
+ let labelBoxWidth = (labelWidth + labelMargin) * scale;
+ let labelBoxHeight = (labelHeight + labelMargin) * scale;
+
+ let isGoingLeft = w < scrollX;
+ let isSizeGoingLeft = isSizeLabel && isGoingLeft;
+ let isExceedingLeftMargin = x - labelBoxWidth < scrollX;
+ let isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
+ let isExceedingTopMargin = y - labelBoxHeight < scrollY;
+ let 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) {
+ let labelSize = this.getElement("label-size");
+ let style = labelSize.getAttribute("style");
+
+ if (style) {
+ labelSize.setAttribute("style",
+ style.replace(/scale[^)]+\)/, `scale(${scale})`));
+ }
+ }
+ },
+
+ updateViewport() {
+ let { scrollX, scrollY, devicePixelRatio } = this.env.window;
+ let { documentWidth, documentHeight, zoom } = this.coords;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ let 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.
+ let minWidth = 1 / pixelRatio;
+ let strokeWidth = Math.min(minWidth, minWidth / zoom);
+
+ this.getElement("root").setAttribute("style",
+ `stroke-width:${strokeWidth};
+ width:${documentWidth}px;
+ height:${documentHeight}px;
+ transform: translate(${-scrollX}px,${-scrollY}px)`);
+ },
+
+ updateGuides() {
+ let { x, y, w, h } = this.coords;
+
+ 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%");
+ },
+
+ 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() {
+ let prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (let side of SIDES) {
+ this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
+ }
+ },
+
+ hideGuides() {
+ let prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (let side of SIDES) {
+ this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
+ }
+ },
+
+ handleEvent(event) {
+ let scrollX, scrollY, innerWidth, innerHeight;
+ let x, y;
+
+ let { pageListenerTarget } = this.env;
+
+ switch (event.type) {
+ case "mousedown":
+ if (event.button) {
+ return;
+ }
+
+ this._isDragging = true;
+
+ let { window } = this.env;
+
+ ({ scrollX, scrollY } = window);
+ x = event.clientX + scrollX;
+ y = event.clientY + scrollY;
+
+ pageListenerTarget.addEventListener("mouseup", this);
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").setAttribute("class", "dragging");
+
+ this.hideLabel("size");
+ this.hideLabel("position");
+
+ this.hideGuides();
+ this.setCoords(x, y, 0, 0);
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ break;
+ case "mouseup":
+ this._isDragging = false;
+
+ pageListenerTarget.removeEventListener("mouseup", this);
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").removeAttribute("class", "");
+
+ // Shows the guides only if an actual area is selected
+ if (this.coords.w !== 0 && this.coords.h !== 0) {
+ this.updateGuides();
+ this.showGuides();
+ }
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+
+ break;
+ case "mousemove":
+ ({ scrollX, scrollY, innerWidth, innerHeight } = this.env.window);
+ x = event.clientX + scrollX;
+ y = event.clientY + scrollY;
+
+ let { coords } = this;
+
+ x = Math.min(innerWidth + scrollX - 1, Math.max(0 + scrollX, x));
+ y = Math.min(innerHeight + scrollY, Math.max(1 + scrollY, y));
+
+ this.setSize(x - coords.x, y - coords.y);
+
+ let type = this._isDragging ? "size" : "position";
+
+ this.showLabel(type);
+ break;
+ case "mouseleave":
+ if (!this._isDragging) {
+ this.hideLabel("position");
+ }
+ break;
+ case "scroll":
+ setIgnoreLayoutChanges(true);
+ this.updateViewport();
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+
+ break;
+ case "pagehide":
+ this.destroy();
+ break;
+ }
+ }
+};
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build
new file mode 100644
index 000000000..317d0832c
--- /dev/null
+++ b/devtools/server/actors/highlighters/moz.build
@@ -0,0 +1,23 @@
+# -*- 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',
+]
+
+DevToolsModules(
+ 'auto-refresh.js',
+ 'box-model.js',
+ 'css-grid.js',
+ 'css-transform.js',
+ 'eye-dropper.js',
+ 'geometry-editor.js',
+ 'measuring-tool.js',
+ 'rect.js',
+ 'rulers.js',
+ 'selector.js',
+ 'simple-outline.js'
+)
diff --git a/devtools/server/actors/highlighters/rect.js b/devtools/server/actors/highlighters/rect.js
new file mode 100644
index 000000000..69ff09880
--- /dev/null
+++ b/devtools/server/actors/highlighters/rect.js
@@ -0,0 +1,102 @@
+/* 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("./utils/markup");
+const { getAdjustedQuads } = require("devtools/shared/layout/utils");
+/**
+ * The RectHighlighter is a class that draws a rectangle highlighter at specific
+ * coordinates.
+ * It does *not* highlight DOM nodes, but rects.
+ * It also does *not* update dynamically, it only highlights a rect and remains
+ * there as long as it is shown.
+ */
+function RectHighlighter(highlighterEnv) {
+ this.win = highlighterEnv.window;
+ this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
+ this._buildMarkup.bind(this));
+}
+
+RectHighlighter.prototype = {
+ typeName: "RectHighlighter",
+
+ _buildMarkup: function () {
+ let doc = this.win.document;
+
+ let container = doc.createElement("div");
+ container.className = "highlighter-container";
+ container.innerHTML = "<div id=\"highlighted-rect\" " +
+ "class=\"highlighted-rect\" hidden=\"true\">";
+
+ return container;
+ },
+
+ destroy: function () {
+ this.win = null;
+ this.markup.destroy();
+ },
+
+ getElement: function (id) {
+ return this.markup.getElement(id);
+ },
+
+ _hasValidOptions: function (options) {
+ let isValidNb = n => typeof n === "number" && n >= 0 && isFinite(n);
+ return options && options.rect &&
+ isValidNb(options.rect.x) &&
+ isValidNb(options.rect.y) &&
+ options.rect.width && isValidNb(options.rect.width) &&
+ options.rect.height && isValidNb(options.rect.height);
+ },
+
+ /**
+ * @param {DOMNode} node The highlighter rect is relatively positioned to the
+ * viewport this node is in. Using the provided node, the highligther will get
+ * the parent documentElement and use it as context to position the
+ * highlighter correctly.
+ * @param {Object} options Accepts the following options:
+ * - rect: mandatory object that should have the x, y, width, height
+ * properties
+ * - fill: optional fill color for the rect
+ */
+ show: function (node, options) {
+ if (!this._hasValidOptions(options) || !node || !node.ownerDocument) {
+ this.hide();
+ return false;
+ }
+
+ let contextNode = node.ownerDocument.documentElement;
+
+ // Caculate the absolute rect based on the context node's adjusted quads.
+ let quads = getAdjustedQuads(this.win, contextNode);
+ if (!quads.length) {
+ this.hide();
+ return false;
+ }
+
+ let {bounds} = quads[0];
+ let x = "left:" + (bounds.x + options.rect.x) + "px;";
+ let y = "top:" + (bounds.y + options.rect.y) + "px;";
+ let width = "width:" + options.rect.width + "px;";
+ let height = "height:" + options.rect.height + "px;";
+
+ let style = x + y + width + height;
+ if (options.fill) {
+ style += "background:" + options.fill + ";";
+ }
+
+ // Set the coordinates of the highlighter and show it
+ let rect = this.getElement("highlighted-rect");
+ rect.setAttribute("style", style);
+ rect.removeAttribute("hidden");
+
+ return true;
+ },
+
+ hide: function () {
+ this.getElement("highlighted-rect").setAttribute("hidden", "true");
+ }
+};
+exports.RectHighlighter = RectHighlighter;
diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js
new file mode 100644
index 000000000..01e082e67
--- /dev/null
+++ b/devtools/server/actors/highlighters/rulers.js
@@ -0,0 +1,294 @@
+/* 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 events = require("sdk/event/core");
+const { getCurrentZoom,
+ setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const {
+ CanvasFrameAnonymousContentHelper,
+ createSVGNode, createNode } = require("./utils/markup");
+
+// 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
+ */
+function RulersHighlighter(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ let { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("scroll", this);
+ pageListenerTarget.addEventListener("pagehide", this);
+}
+
+RulersHighlighter.prototype = {
+ typeName: "RulersHighlighter",
+
+ ID_CLASS_PREFIX: "rulers-highlighter-",
+
+ _buildMarkup: function () {
+ let { window } = this.env;
+ let prefix = this.ID_CLASS_PREFIX;
+
+ function 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}"`);
+ }
+
+ let g = createSVGNode(window, {
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis`
+ },
+ parent: svg,
+ prefix
+ });
+
+ createSVGNode(window, {
+ nodeType: "rect",
+ attributes: {
+ y: isHorizontal ? 0 : 16,
+ width,
+ height
+ },
+ parent: g
+ });
+
+ let gRule = createSVGNode(window, {
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis-ruler`
+ },
+ parent: g,
+ prefix
+ });
+
+ let pathGraduations = createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ "class": "ruler-graduations",
+ width,
+ height
+ },
+ parent: gRule,
+ prefix
+ });
+
+ let pathMarkers = createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ "class": "ruler-markers",
+ width,
+ height
+ },
+ parent: gRule,
+ prefix
+ });
+
+ let gText = createSVGNode(window, {
+ 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;
+ createSVGNode(window, {
+ 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;
+ }
+
+ let container = createNode(window, {
+ attributes: {"class": "highlighter-container"}
+ });
+
+ let root = createNode(window, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root"
+ },
+ prefix
+ });
+
+ let svg = createSVGNode(window, {
+ 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: function (event) {
+ switch (event.type) {
+ case "scroll":
+ this._onScroll(event);
+ break;
+ case "pagehide":
+ this.destroy();
+ break;
+ }
+ },
+
+ _onScroll: function (event) {
+ let prefix = this.ID_CLASS_PREFIX;
+ let { 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: function () {
+ let { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ let zoom = getCurrentZoom(window);
+ let isZoomChanged = zoom !== this._zoom;
+
+ if (isZoomChanged) {
+ this._zoom = zoom;
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ },
+
+ _cancelUpdate: function () {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ },
+ updateViewport: function () {
+ let { 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`
+ let 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.
+ let minWidth = 1 / pixelRatio;
+ let strokeWidth = Math.min(minWidth, minWidth / this._zoom);
+
+ this.markup.getElement(this.ID_CLASS_PREFIX + "root").setAttribute("style",
+ `stroke-width:${strokeWidth};`);
+ },
+
+ destroy: function () {
+ this.hide();
+
+ let { pageListenerTarget } = this.env;
+ pageListenerTarget.removeEventListener("scroll", this);
+ pageListenerTarget.removeEventListener("pagehide", this);
+
+ this.markup.destroy();
+
+ events.emit(this, "destroy");
+ },
+
+ show: function () {
+ this.markup.removeAttributeForElement(this.ID_CLASS_PREFIX + "elements",
+ "hidden");
+
+ this._update();
+
+ return true;
+ },
+
+ hide: function () {
+ 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 000000000..557a6d541
--- /dev/null
+++ b/devtools/server/actors/highlighters/selector.js
@@ -0,0 +1,83 @@
+/* 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("./utils/markup");
+const { BoxModelHighlighter } = require("./box-model");
+
+// 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
+ */
+function SelectorHighlighter(highlighterEnv) {
+ this.highlighterEnv = highlighterEnv;
+ this._highlighters = [];
+}
+
+SelectorHighlighter.prototype = {
+ typeName: "SelectorHighlighter",
+
+ /**
+ * Show BoxModelHighlighter on each node that matches that provided selector.
+ * @param {DOMNode} node A context node that is used to get the document on
+ * which querySelectorAll should be executed. This node will NOT be
+ * highlighted.
+ * @param {Object} options Should at least contain the 'selector' option, a
+ * string that will be used in querySelectorAll. On top of this, all of the
+ * valid options to BoxModelHighlighter.show are also valid here.
+ */
+ show: function (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.
+ }
+
+ delete options.selector;
+
+ let i = 0;
+ for (let matchingNode of nodes) {
+ if (i >= MAX_HIGHLIGHTED_ELEMENTS) {
+ break;
+ }
+
+ let highlighter = new BoxModelHighlighter(this.highlighterEnv);
+ if (options.fill) {
+ highlighter.regionFill[options.region || "border"] = options.fill;
+ }
+ highlighter.show(matchingNode, options);
+ this._highlighters.push(highlighter);
+ i++;
+ }
+
+ return true;
+ },
+
+ hide: function () {
+ for (let highlighter of this._highlighters) {
+ highlighter.destroy();
+ }
+ this._highlighters = [];
+ },
+
+ destroy: function () {
+ this.hide();
+ this.highlighterEnv = null;
+ }
+};
+exports.SelectorHighlighter = SelectorHighlighter;
diff --git a/devtools/server/actors/highlighters/simple-outline.js b/devtools/server/actors/highlighters/simple-outline.js
new file mode 100644
index 000000000..dae20f2d9
--- /dev/null
+++ b/devtools/server/actors/highlighters/simple-outline.js
@@ -0,0 +1,67 @@
+/* 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 {
+ installHelperSheet,
+ isNodeValid,
+ addPseudoClassLock,
+ removePseudoClassLock
+} = require("./utils/markup");
+
+// SimpleOutlineHighlighter's stylesheet
+const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted";
+const SIMPLE_OUTLINE_SHEET = `.__fx-devtools-hide-shortcut__ {
+ visibility: hidden !important
+ }
+ ${HIGHLIGHTED_PSEUDO_CLASS} {
+ outline: 2px dashed #F06!important;
+ outline-offset: -2px!important
+ }`;
+/**
+ * The SimpleOutlineHighlighter is a class that has the same API than the
+ * BoxModelHighlighter, but adds a pseudo-class on the target element itself
+ * to draw a simple css outline around the element.
+ * It is used by the HighlighterActor when canvasframe-based highlighters can't
+ * be used. This is the case for XUL windows.
+ */
+function SimpleOutlineHighlighter(highlighterEnv) {
+ this.chromeDoc = highlighterEnv.document;
+}
+
+SimpleOutlineHighlighter.prototype = {
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy: function () {
+ this.hide();
+ this.chromeDoc = null;
+ },
+
+ /**
+ * Show the highlighter on a given node
+ * @param {DOMNode} node
+ */
+ show: function (node) {
+ if (isNodeValid(node) && (!this.currentNode || node !== this.currentNode)) {
+ this.hide();
+ this.currentNode = node;
+ installHelperSheet(node.ownerDocument.defaultView, SIMPLE_OUTLINE_SHEET);
+ addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
+ }
+ return true;
+ },
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ hide: function () {
+ if (this.currentNode) {
+ removePseudoClassLock(this.currentNode, HIGHLIGHTED_PSEUDO_CLASS);
+ this.currentNode = null;
+ }
+ }
+};
+exports.SimpleOutlineHighlighter = SimpleOutlineHighlighter;
diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js
new file mode 100644
index 000000000..8750014bc
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -0,0 +1,609 @@
+/* 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 { Cc, Ci, Cu } = require("chrome");
+const { getCurrentZoom,
+ getRootBindingParent } = require("devtools/shared/layout/utils");
+const { on, emit } = require("sdk/event/core");
+
+const lazyContainer = {};
+
+loader.lazyRequireGetter(lazyContainer, "CssLogic",
+ "devtools/server/css-logic", true);
+exports.getComputedStyle = (node) =>
+ lazyContainer.CssLogic.getComputedStyle(node);
+
+exports.getBindingElementAndPseudo = (node) =>
+ lazyContainer.CssLogic.getBindingElementAndPseudo(node);
+
+loader.lazyGetter(lazyContainer, "DOMUtils", () =>
+ Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils));
+exports.hasPseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.hasPseudoClassLock(...args);
+
+exports.addPseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.addPseudoClassLock(...args);
+
+exports.removePseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.removePseudoClassLock(...args);
+
+exports.getCSSStyleRules = (...args) =>
+ lazyContainer.DOMUtils.getCSSStyleRules(...args);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const STYLESHEET_URI = "resource://devtools/server/actors/" +
+ "highlighters.css";
+// How high is the infobar (px).
+const INFOBAR_HEIGHT = 34;
+// What's the size of the infobar arrow (px).
+const INFOBAR_ARROW_SIZE = 9;
+
+const _tokens = Symbol("classList/tokens");
+
+/**
+ * Shims the element's `classList` for anonymous content elements; used
+ * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
+ */
+function ClassList(className) {
+ let 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);
+ }
+ emit(this, "update");
+ },
+ remove(token) {
+ let index = this[_tokens].indexOf(token);
+
+ if (index > -1) {
+ this[_tokens].splice(index, 1);
+ }
+ emit(this, "update");
+ },
+ toggle(token) {
+ if (this.contains(token)) {
+ this.remove(token);
+ } else {
+ this.add(token);
+ }
+ },
+ get length() {
+ return this[_tokens].length;
+ },
+ [Symbol.iterator]: function* () {
+ 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;
+
+/**
+ * Inject a helper stylesheet in the window.
+ */
+var installedHelperSheets = new WeakMap();
+
+function installHelperSheet(win, source, type = "agent") {
+ if (installedHelperSheets.has(win.document)) {
+ return;
+ }
+ let {Style} = require("sdk/stylesheet/style");
+ let {attach} = require("sdk/content/mod");
+ let style = Style({source, type});
+ attach(style, win);
+ installedHelperSheets.set(win.document, style);
+}
+exports.installHelperSheet = installHelperSheet;
+
+/**
+ * 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 = Ci.nsIDOMNode.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?
+ let doc = node.ownerDocument;
+ if (!doc || !doc.defaultView) {
+ return false;
+ }
+
+ // Is the node connected to the document? Using getBindingParent adds
+ // support for anonymous elements generated by a node in the document.
+ let bindingParent = getRootBindingParent(node);
+ if (!doc.documentElement.contains(bindingParent)) {
+ return false;
+ }
+
+ return true;
+}
+exports.isNodeValid = isNodeValid;
+
+/**
+ * Helper function that creates SVG DOM nodes.
+ * @param {Window} This window's document will be used to create the element
+ * @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.
+ */
+function createSVGNode(win, options) {
+ if (!options.nodeType) {
+ options.nodeType = "box";
+ }
+ options.namespace = SVG_NS;
+ return createNode(win, options);
+}
+exports.createSVGNode = createSVGNode;
+
+/**
+ * Helper function that creates DOM nodes.
+ * @param {Window} This window's document will be used to create the element
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "div".
+ * - namespace: if passed, doc.createElementNS will be used instead of
+ * doc.creatElement.
+ * - 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.
+ */
+function createNode(win, options) {
+ let type = options.nodeType || "div";
+
+ let node;
+ if (options.namespace) {
+ node = win.document.createElementNS(options.namespace, type);
+ } else {
+ node = win.document.createElement(type);
+ }
+
+ for (let 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);
+ }
+
+ return node;
+}
+exports.createNode = createNode;
+
+/**
+ * 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.
+ */
+function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) {
+ this.highlighterEnv = highlighterEnv;
+ this.nodeBuilder = nodeBuilder;
+ this.anonymousContentDocument = this.highlighterEnv.document;
+ // XXX the next line is a wallpaper for bug 1123362.
+ this.anonymousContentGlobal = Cu.getGlobalForObject(
+ this.anonymousContentDocument);
+
+ // Only try to create the highlighter when the document is loaded,
+ // otherwise, wait for the navigate event to fire.
+ let doc = this.highlighterEnv.document;
+ if (doc.documentElement && doc.readyState != "uninitialized") {
+ this._insert();
+ }
+
+ this._onNavigate = this._onNavigate.bind(this);
+ this.highlighterEnv.on("navigate", this._onNavigate);
+
+ this.listeners = new Map();
+}
+
+CanvasFrameAnonymousContentHelper.prototype = {
+ destroy: function () {
+ try {
+ let doc = this.anonymousContentDocument;
+ doc.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.
+ }
+ this.highlighterEnv.off("navigate", this._onNavigate);
+ this.highlighterEnv = this.nodeBuilder = this._content = null;
+ this.anonymousContentDocument = null;
+ this.anonymousContentGlobal = null;
+
+ this._removeAllListeners();
+ },
+
+ _insert: function () {
+ let doc = this.highlighterEnv.document;
+ // Insert the content node only if the document:
+ // * is loaded (navigate event will fire once it is),
+ // * still exists,
+ // * isn't in XUL.
+ if (doc.readyState == "uninitialized" ||
+ !doc.documentElement ||
+ isXUL(this.highlighterEnv.window)) {
+ return;
+ }
+
+ // For now highlighters.css is injected in content as a ua sheet because
+ // <style scoped> doesn't work inside anonymous content (see bug 1086532).
+ // If it did, highlighters.css would be injected as an anonymous content
+ // node using CanvasFrameAnonymousContentHelper instead.
+ installHelperSheet(this.highlighterEnv.window,
+ "@import url('" + STYLESHEET_URI + "');");
+ let node = this.nodeBuilder();
+
+ // 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)
+ this._content = doc.insertAnonymousContent(node);
+ },
+
+ _onNavigate: function (e, {isTopLevel}) {
+ if (isTopLevel) {
+ this._removeAllListeners();
+ this._insert();
+ this.anonymousContentDocument = this.highlighterEnv.document;
+ }
+ },
+
+ getTextContentForElement: function (id) {
+ if (!this.content) {
+ return null;
+ }
+ return this.content.getTextContentForElement(id);
+ },
+
+ setTextContentForElement: function (id, text) {
+ if (this.content) {
+ this.content.setTextContentForElement(id, text);
+ }
+ },
+
+ setAttributeForElement: function (id, name, value) {
+ if (this.content) {
+ this.content.setAttributeForElement(id, name, value);
+ }
+ },
+
+ getAttributeForElement: function (id, name) {
+ if (!this.content) {
+ return null;
+ }
+ return this.content.getAttributeForElement(id, name);
+ },
+
+ removeAttributeForElement: function (id, name) {
+ if (this.content) {
+ this.content.removeAttributeForElement(id, name);
+ }
+ },
+
+ hasAttributeForElement: function (id, name) {
+ return typeof this.getAttributeForElement(id, name) === "string";
+ },
+
+ getCanvasContext: function (id, type = "2d") {
+ return this.content ? this.content.getCanvasContext(id, type) : null;
+ },
+
+ /**
+ * 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: function (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)) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ target.addEventListener(type, this, true);
+ // Each type entry in the map is a map of ids:handlers.
+ this.listeners.set(type, new Map());
+ }
+
+ let 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: function (id, type) {
+ let 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)) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ target.removeEventListener(type, this, true);
+ }
+ },
+
+ handleEvent: function (event) {
+ let 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;
+ let 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) {
+ let handler = listeners.get(node.id);
+ if (handler) {
+ handler(eventProxy, node.id);
+ if (isPropagationStopped) {
+ break;
+ }
+ }
+ node = node.parentNode;
+ }
+ },
+
+ _removeAllListeners: function () {
+ if (this.highlighterEnv) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ for (let [type] of this.listeners) {
+ target.removeEventListener(type, this, true);
+ }
+ }
+ this.listeners.clear();
+ },
+
+ getElement: function (id) {
+ let classList = new ClassList(this.getAttributeForElement(id, "class"));
+
+ on(classList, "update", () => {
+ this.setAttributeForElement(id, "class", classList.toString());
+ });
+
+ return {
+ 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);
+ },
+ classList
+ };
+ },
+
+ 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: function (node, id) {
+ let zoom = getCurrentZoom(node);
+ let value = "position:absolute;width:100%;height:100%;";
+
+ if (zoom !== 1) {
+ value = "position:absolute;";
+ value += "transform-origin:top left;transform:scale(" + (1 / zoom) + ");";
+ value += "width:" + (100 * zoom) + "%;height:" + (100 * zoom) + "%;";
+ }
+
+ this.setAttributeForElement(id, "style", value);
+ }
+};
+exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;
+
+/**
+ * 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.
+ */
+function moveInfobar(container, bounds, win) {
+ let winHeight = win.innerHeight * getCurrentZoom(win);
+ let winWidth = win.innerWidth * getCurrentZoom(win);
+ let winScrollY = win.scrollY;
+
+ // Ensure that containerBottom and containerTop are at least zero to avoid
+ // showing tooltips outside the viewport.
+ let containerBottom = Math.max(0, bounds.bottom) + INFOBAR_ARROW_SIZE;
+ let containerTop = Math.min(winHeight, bounds.top);
+
+ // Can the bar be above the node?
+ let top;
+ if (containerTop < INFOBAR_HEIGHT) {
+ // No. Can we move the bar under the node?
+ if (containerBottom + INFOBAR_HEIGHT > winHeight) {
+ // No. Let's move it inside. Can we show it at the top of the element?
+ if (containerTop < winScrollY) {
+ // No. Window is scrolled past the top of the element.
+ top = 0;
+ } else {
+ // Yes. Show it at the top of the element
+ top = containerTop;
+ }
+ container.setAttribute("position", "overlap");
+ } else {
+ // Yes. Let's move it under the node.
+ top = containerBottom;
+ container.setAttribute("position", "bottom");
+ }
+ } else {
+ // Yes. Let's move it on top of the node.
+ top = containerTop - INFOBAR_HEIGHT;
+ container.setAttribute("position", "top");
+ }
+
+ // Align the bar with the box's center if possible.
+ let left = bounds.right - bounds.width / 2;
+ // Make sure the while infobar is visible.
+ let buffer = 100;
+ if (left < buffer) {
+ left = buffer;
+ container.setAttribute("hide-arrow", "true");
+ } else if (left > winWidth - buffer) {
+ left = winWidth - buffer;
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ container.removeAttribute("hide-arrow");
+ }
+
+ let style = "top:" + top + "px;left:" + left + "px;";
+ container.setAttribute("style", style);
+}
+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 000000000..4bb429bc3
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/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(
+ 'markup.js'
+)