diff options
Diffstat (limited to 'devtools/server/actors/highlighters')
-rw-r--r-- | devtools/server/actors/highlighters/auto-refresh.js | 215 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/box-model.js | 712 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/css-grid.js | 737 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/css-transform.js | 243 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/eye-dropper.js | 534 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/geometry-editor.js | 704 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/measuring-tool.js | 563 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/moz.build | 23 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/rect.js | 102 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/rulers.js | 294 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/selector.js | 83 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/simple-outline.js | 67 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/utils/markup.js | 609 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/utils/moz.build | 9 |
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' +) |