diff options
Diffstat (limited to 'devtools/server/actors/highlighters/box-model.js')
-rw-r--r-- | devtools/server/actors/highlighters/box-model.js | 875 |
1 files changed, 875 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/box-model.js b/devtools/server/actors/highlighters/box-model.js new file mode 100644 index 0000000000..055672d4a0 --- /dev/null +++ b/devtools/server/actors/highlighters/box-model.js @@ -0,0 +1,875 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + AutoRefreshHighlighter, +} = require("devtools/server/actors/highlighters/auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + getBindingElementAndPseudo, + hasPseudoClassLock, + isNodeValid, + moveInfobar, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { PSEUDO_CLASSES } = require("devtools/shared/css/constants"); +const { + getCurrentZoom, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); +const { + getNodeDisplayName, + getNodeGridFlexType, +} = require("devtools/server/actors/inspector/utils"); +const nodeConstants = require("devtools/shared/dom-node-constants"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const STRINGS_URI = "devtools/shared/locales/highlighters.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +// Note that the order of items in this array is important because it is used +// for drawing the BoxModelHighlighter's path elements correctly. +const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; +const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"]; +// Width of boxmodelhighlighter guides +const GUIDE_STROKE_WIDTH = 1; + +/** + * The BoxModelHighlighter draws the box model regions on top of a node. + * If the node is a block box, then each region will be displayed as 1 polygon. + * If the node is an inline box though, each region may be represented by 1 or + * more polygons, depending on how many line boxes the inline element has. + * + * Usage example: + * + * let h = new BoxModelHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * @param {String} options.region + * Specifies the region that the guides should outline: + * "content" (default), "padding", "border" or "margin". + * @param {Boolean} options.hideGuides + * Defaults to false + * @param {Boolean} options.hideInfoBar + * Defaults to false + * @param {String} options.showOnly + * If set, only this region will be highlighted. Use with onlyRegionArea + * to only highlight the area of the region: + * "content", "padding", "border" or "margin" + * @param {Boolean} options.onlyRegionArea + * This can be set to true to make each region's box only highlight the + * area of the corresponding region rather than the area of nested + * regions too. This is useful when used with showOnly. + * + * Structure: + * <div class="highlighter-container" aria-hidden="true"> + * <div class="box-model-root"> + * <svg class="box-model-elements" hidden="true"> + * <g class="box-model-regions"> + * <path class="box-model-margin" points="..." /> + * <path class="box-model-border" points="..." /> + * <path class="box-model-padding" points="..." /> + * <path class="box-model-content" points="..." /> + * </g> + * <line class="box-model-guide-top" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-right" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-bottom" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-left" x1="..." y1="..." x2="..." y2="..." /> + * </svg> + * <div class="box-model-infobar-container"> + * <div class="box-model-infobar-arrow highlighter-infobar-arrow-top" /> + * <div class="box-model-infobar"> + * <div class="box-model-infobar-text" align="center"> + * <span class="box-model-infobar-tagname">Node name</span> + * <span class="box-model-infobar-id">Node id</span> + * <span class="box-model-infobar-classes">.someClass</span> + * <span class="box-model-infobar-pseudo-classes">:hover</span> + * <span class="box-model-infobar-grid-type">Grid Type</span> + * <span class="box-model-infobar-flex-type">Flex Type</span> + * </div> + * </div> + * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/> + * </div> + * </div> + * </div> + */ +class BoxModelHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "box-model-"; + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + } + + /** + * Static getter that indicates that BoxModelHighlighter supports + * highlighting in XUL windows. + */ + static get XULSupported() { + return true; + } + + _buildMarkup() { + const highlighterContainer = this.markup.anonymousContentDocument.createElement( + "div" + ); + highlighterContainer.className = "highlighter-container box-model"; + // We need a better solution for how to handle the highlighter from the + // accessibility standpoint. For now, in order to avoid displaying it in the + // accessibility tree lets hide it altogether. See bug 1598667 for more + // context. + highlighterContainer.setAttribute("aria-hidden", "true"); + + // Build the root wrapper, used to adapt to the page zoom. + const rootWrapper = this.markup.createNode({ + parent: highlighterContainer, + attributes: { + id: "root", + class: "root", + role: "presentation", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Building the SVG element with its polygons and lines + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: rootWrapper, + attributes: { + id: "elements", + width: "100%", + height: "100%", + hidden: "true", + role: "presentation", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const regions = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + attributes: { + class: "regions", + role: "presentation", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + for (const region of BOX_MODEL_REGIONS) { + this.markup.createSVGNode({ + nodeType: "path", + parent: regions, + attributes: { + class: region, + id: region, + role: "presentation", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + for (const side of BOX_MODEL_SIDES) { + this.markup.createSVGNode({ + nodeType: "line", + parent: svg, + attributes: { + class: "guide-" + side, + id: "guide-" + side, + "stroke-width": GUIDE_STROKE_WIDTH, + role: "presentation", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + // Building the nodeinfo bar markup + + const infobarContainer = this.markup.createNode({ + parent: rootWrapper, + attributes: { + class: "infobar-container", + id: "infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const infobar = this.markup.createNode({ + parent: infobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const texthbox = this.markup.createNode({ + parent: infobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-tagname", + id: "infobar-tagname", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-id", + id: "infobar-id", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-classes", + id: "infobar-classes", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-pseudo-classes", + id: "infobar-pseudo-classes", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-dimensions", + id: "infobar-dimensions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-grid-type", + id: "infobar-grid-type", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createNode({ + nodeType: "span", + parent: texthbox, + attributes: { + class: "infobar-flex-type", + id: "infobar-flex-type", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return highlighterContainer; + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = this.highlighterEnv; + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + } + + this.markup.destroy(); + + AutoRefreshHighlighter.prototype.destroy.call(this); + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Override the AutoRefreshHighlighter's _isNodeValid method to also return true for + * text nodes since these can also be highlighted. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid(node) { + return ( + node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE)) + ); + } + + /** + * Show the highlighter on a given node + */ + _show() { + if (!BOX_MODEL_REGIONS.includes(this.options.region)) { + this.options.region = "content"; + } + + const shown = this._update(); + this._trackMutations(); + return shown; + } + + /** + * Track the current node markup mutations so that the node info bar can be + * updated to reflects the node's attributes + */ + _trackMutations() { + if (isNodeValid(this.currentNode)) { + const win = this.currentNode.ownerGlobal; + this.currentNodeObserver = new win.MutationObserver(this.update); + this.currentNodeObserver.observe(this.currentNode, { attributes: true }); + } + } + + _untrackMutations() { + if (isNodeValid(this.currentNode) && this.currentNodeObserver) { + this.currentNodeObserver.disconnect(); + this.currentNodeObserver = null; + } + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update() { + const node = this.currentNode; + let shown = false; + setIgnoreLayoutChanges(true); + + if (this._updateBoxModel()) { + // Show the infobar only if configured to do so and the node is an element or a text + // node. + if ( + !this.options.hideInfoBar && + (node.nodeType === node.ELEMENT_NODE || + node.nodeType === node.TEXT_NODE) + ) { + this._showInfobar(); + } else { + this._hideInfobar(); + } + this._showBoxModel(); + shown = true; + } else { + // Nothing to highlight (0px rectangle like a <script> tag for instance) + this._hide(); + } + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + + return shown; + } + + _scrollUpdate() { + this._moveInfobar(); + } + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + + this._untrackMutations(); + this._hideBoxModel(); + this._hideInfobar(); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + /** + * Hide the infobar + */ + _hideInfobar() { + this.getElement("infobar-container").setAttribute("hidden", "true"); + } + + /** + * Show the infobar + */ + _showInfobar() { + this.getElement("infobar-container").removeAttribute("hidden"); + this._updateInfobar(); + } + + /** + * Hide the box model + */ + _hideBoxModel() { + this.getElement("elements").setAttribute("hidden", "true"); + } + + /** + * Show the box model + */ + _showBoxModel() { + this.getElement("elements").removeAttribute("hidden"); + } + + /** + * Calculate an outer quad based on the quads returned by getAdjustedQuads. + * The BoxModelHighlighter may highlight more than one boxes, so in this case + * create a new quad that "contains" all of these quads. + * This is useful to position the guides and infobar. + * This may happen if the BoxModelHighlighter is used to highlight an inline + * element that spans line breaks. + * @param {String} region The box-model region to get the outer quad for. + * @return {Object} A quad-like object {p1,p2,p3,p4,bounds} + */ + _getOuterQuad(region) { + const quads = this.currentQuads[region]; + if (!quads || !quads.length) { + return null; + } + + const quad = { + p1: { x: Infinity, y: Infinity }, + p2: { x: -Infinity, y: Infinity }, + p3: { x: -Infinity, y: -Infinity }, + p4: { x: Infinity, y: -Infinity }, + bounds: { + bottom: -Infinity, + height: 0, + left: Infinity, + right: -Infinity, + top: Infinity, + width: 0, + x: 0, + y: 0, + }, + }; + + for (const q of quads) { + quad.p1.x = Math.min(quad.p1.x, q.p1.x); + quad.p1.y = Math.min(quad.p1.y, q.p1.y); + quad.p2.x = Math.max(quad.p2.x, q.p2.x); + quad.p2.y = Math.min(quad.p2.y, q.p2.y); + quad.p3.x = Math.max(quad.p3.x, q.p3.x); + quad.p3.y = Math.max(quad.p3.y, q.p3.y); + quad.p4.x = Math.min(quad.p4.x, q.p4.x); + quad.p4.y = Math.max(quad.p4.y, q.p4.y); + + quad.bounds.bottom = Math.max(quad.bounds.bottom, q.bounds.bottom); + quad.bounds.top = Math.min(quad.bounds.top, q.bounds.top); + quad.bounds.left = Math.min(quad.bounds.left, q.bounds.left); + quad.bounds.right = Math.max(quad.bounds.right, q.bounds.right); + } + quad.bounds.x = quad.bounds.left; + quad.bounds.y = quad.bounds.top; + quad.bounds.width = quad.bounds.right - quad.bounds.left; + quad.bounds.height = quad.bounds.bottom - quad.bounds.top; + + return quad; + } + + /** + * Update the box model as per the current node. + * + * @return {boolean} + * True if the current node has a box model to be highlighted + */ + _updateBoxModel() { + const options = this.options; + options.region = options.region || "content"; + + if (!this._nodeNeedsHighlighting()) { + this._hideBoxModel(); + return false; + } + + for (let i = 0; i < BOX_MODEL_REGIONS.length; i++) { + const boxType = BOX_MODEL_REGIONS[i]; + const nextBoxType = BOX_MODEL_REGIONS[i + 1]; + const box = this.getElement(boxType); + + // Highlight all quads for this region by setting the "d" attribute of the + // corresponding <path>. + const path = []; + for (let j = 0; j < this.currentQuads[boxType].length; j++) { + const boxQuad = this.currentQuads[boxType][j]; + const nextBoxQuad = this.currentQuads[nextBoxType] + ? this.currentQuads[nextBoxType][j] + : null; + path.push(this._getBoxPathCoordinates(boxQuad, nextBoxQuad)); + } + + box.setAttribute("d", path.join(" ")); + box.removeAttribute("faded"); + + // If showOnly is defined, either hide the other regions, or fade them out + // if onlyRegionArea is set too. + if (options.showOnly && options.showOnly !== boxType) { + if (options.onlyRegionArea) { + box.setAttribute("faded", "true"); + } else { + box.removeAttribute("d"); + } + } + + if (boxType === options.region && !options.hideGuides) { + this._showGuides(boxType); + } else if (options.hideGuides) { + this._hideGuides(); + } + } + + // Un-zoom the root wrapper if the page was zoomed. + const rootId = this.ID_CLASS_PREFIX + "elements"; + this.markup.scaleRootElement(this.currentNode, rootId); + + return true; + } + + _getBoxPathCoordinates(boxQuad, nextBoxQuad) { + const { p1, p2, p3, p4 } = boxQuad; + + let path; + if (!nextBoxQuad || !this.options.onlyRegionArea) { + // If this is the content box (inner-most box) or if we're not being asked + // to highlight only region areas, then draw a simple rectangle. + path = + "M" + + p1.x + + "," + + p1.y + + " " + + "L" + + p2.x + + "," + + p2.y + + " " + + "L" + + p3.x + + "," + + p3.y + + " " + + "L" + + p4.x + + "," + + p4.y; + } else { + // Otherwise, just draw the region itself, not a filled rectangle. + const { p1: np1, p2: np2, p3: np3, p4: np4 } = nextBoxQuad; + path = + "M" + + p1.x + + "," + + p1.y + + " " + + "L" + + p2.x + + "," + + p2.y + + " " + + "L" + + p3.x + + "," + + p3.y + + " " + + "L" + + p4.x + + "," + + p4.y + + " " + + "L" + + p1.x + + "," + + p1.y + + " " + + "L" + + np1.x + + "," + + np1.y + + " " + + "L" + + np4.x + + "," + + np4.y + + " " + + "L" + + np3.x + + "," + + np3.y + + " " + + "L" + + np2.x + + "," + + np2.y + + " " + + "L" + + np1.x + + "," + + np1.y; + } + + return path; + } + + /** + * Can the current node be highlighted? Does it have quads. + * @return {Boolean} + */ + _nodeNeedsHighlighting() { + return ( + this.currentQuads.margin.length || + this.currentQuads.border.length || + this.currentQuads.padding.length || + this.currentQuads.content.length + ); + } + + _getOuterBounds() { + for (const region of ["margin", "border", "padding", "content"]) { + const quad = this._getOuterQuad(region); + + if (!quad) { + // Invisible element such as a script tag. + break; + } + + const { bottom, height, left, right, top, width, x, y } = quad.bounds; + + if (width > 0 || height > 0) { + return { bottom, height, left, right, top, width, x, y }; + } + } + + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + } + + /** + * We only want to show guides for horizontal and vertical edges as this helps + * to line them up. This method finds these edges and displays a guide there. + * @param {String} region The region around which the guides should be shown. + */ + _showGuides(region) { + const quad = this._getOuterQuad(region); + + if (!quad) { + // Invisible element such as a script tag. + return; + } + + const { p1, p2, p3, p4 } = quad; + + const allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b); + const allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b); + const toShowX = []; + const toShowY = []; + + for (const arr of [allX, allY]) { + for (let i = 0; i < arr.length; i++) { + const val = arr[i]; + + if (i !== arr.lastIndexOf(val)) { + if (arr === allX) { + toShowX.push(val); + } else { + toShowY.push(val); + } + arr.splice(arr.lastIndexOf(val), 1); + } + } + } + + // Move guide into place or hide it if no valid co-ordinate was found. + this._updateGuide("top", Math.round(toShowY[0])); + this._updateGuide("right", Math.round(toShowX[1]) - 1); + this._updateGuide("bottom", Math.round(toShowY[1] - 1)); + this._updateGuide("left", Math.round(toShowX[0])); + } + + _hideGuides() { + for (const side of BOX_MODEL_SIDES) { + this.getElement("guide-" + side).setAttribute("hidden", "true"); + } + } + + /** + * Move a guide to the appropriate position and display it. If no point is + * passed then the guide is hidden. + * + * @param {String} side + * The guide to update + * @param {Integer} point + * x or y co-ordinate. If this is undefined we hide the guide. + */ + _updateGuide(side, point = -1) { + const 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() { + if (!this.currentNode) { + return; + } + + const { bindingElement: node, pseudo } = getBindingElementAndPseudo( + this.currentNode + ); + + // Update the tag, id, classes, pseudo-classes and dimensions + const displayName = getNodeDisplayName(node); + + const id = node.id ? "#" + node.id : ""; + + const classList = (node.classList || []).length + ? "." + [...node.classList].join(".") + : ""; + + let pseudos = this._getPseudoClasses(node).join(""); + if (pseudo) { + // Display :after as ::after + pseudos += ":" + pseudo; + } + + // We want to display the original `width` and `height`, instead of the ones affected + // by any zoom. Since the infobar can be displayed also for text nodes, we can't + // access the computed style for that, and this is why we recalculate them here. + const zoom = getCurrentZoom(this.win); + const quad = this._getOuterQuad("border"); + + if (!quad) { + return; + } + + const { width, height } = quad.bounds; + const dim = + parseFloat((width / zoom).toPrecision(6)) + + " \u00D7 " + + parseFloat((height / zoom).toPrecision(6)); + + const { grid: gridType, flex: flexType } = getNodeGridFlexType(node); + const gridLayoutTextType = this._getLayoutTextType("gridType", gridType); + const flexLayoutTextType = this._getLayoutTextType("flexType", flexType); + + this.getElement("infobar-tagname").setTextContent(displayName); + this.getElement("infobar-id").setTextContent(id); + this.getElement("infobar-classes").setTextContent(classList); + this.getElement("infobar-pseudo-classes").setTextContent(pseudos); + this.getElement("infobar-dimensions").setTextContent(dim); + this.getElement("infobar-grid-type").setTextContent(gridLayoutTextType); + this.getElement("infobar-flex-type").setTextContent(flexLayoutTextType); + + this._moveInfobar(); + } + + _getLayoutTextType(layoutTypeKey, { isContainer, isItem }) { + if (!isContainer && !isItem) { + return ""; + } + if (isContainer && !isItem) { + return L10N.getStr(`${layoutTypeKey}.container`); + } + if (!isContainer && isItem) { + return L10N.getStr(`${layoutTypeKey}.item`); + } + return L10N.getStr(`${layoutTypeKey}.dual`); + } + + _getPseudoClasses(node) { + if (node.nodeType !== nodeConstants.ELEMENT_NODE) { + // hasPseudoClassLock can only be used on Elements. + return []; + } + + return PSEUDO_CLASSES.filter(pseudo => hasPseudoClassLock(node, pseudo)); + } + + /** + * Move the Infobar to the right place in the highlighter. + */ + _moveInfobar() { + const bounds = this._getOuterBounds(); + const container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + } + + onPageHide({ target }) { + // If a pagehide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.win) { + this.hide(); + } + } + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +} + +exports.BoxModelHighlighter = BoxModelHighlighter; |