diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/server/actors/highlighters | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/highlighters')
21 files changed, 13946 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/accessible.js b/devtools/server/actors/highlighters/accessible.js new file mode 100644 index 0000000000..07d99411cc --- /dev/null +++ b/devtools/server/actors/highlighters/accessible.js @@ -0,0 +1,387 @@ +/* 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, + isNodeValid, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { + TEXT_NODE, + DOCUMENT_NODE, +} = require("devtools/shared/dom-node-constants"); +const { + getCurrentZoom, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); + +loader.lazyRequireGetter( + this, + ["getBounds", "getBoundsXUL", "Infobar"], + "devtools/server/actors/highlighters/utils/accessibility", + true +); + +/** + * The AccessibleHighlighter draws the bounds of an accessible object. + * + * Usage example: + * + * let h = new AccessibleHighlighter(env); + * h.show(node, { x, y, w, h, [duration] }); + * h.hide(); + * h.destroy(); + * + * @param {Number} options.x + * X coordinate of the top left corner of the accessible object + * @param {Number} options.y + * Y coordinate of the top left corner of the accessible object + * @param {Number} options.w + * Width of the the accessible object + * @param {Number} options.h + * Height of the the accessible object + * @param {Number} options.duration + * Duration of time that the highlighter should be shown. + * @param {String|null} options.name + * Name of the the accessible object + * @param {String} options.role + * Role of the the accessible object + * + * Structure: + * <div class="highlighter-container" aria-hidden="true"> + * <div class="accessible-root"> + * <svg class="accessible-elements" hidden="true"> + * <path class="accessible-bounds" points="..." /> + * </svg> + * <div class="accessible-infobar-container"> + * <div class="accessible-infobar"> + * <div class="accessible-infobar-text"> + * <span class="accessible-infobar-role">Accessible Role</span> + * <span class="accessible-infobar-name">Accessible Name</span> + * </div> + * </div> + * </div> + * </div> + * </div> + */ +class AccessibleHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + this.ID_CLASS_PREFIX = "accessible-"; + this.accessibleInfobar = new Infobar(this); + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + this.pageListenerTarget = highlighterEnv.pageListenerTarget; + this.pageListenerTarget.addEventListener("pagehide", this.onPageHide); + } + + /** + * Static getter that indicates that AccessibleHighlighter supports + * highlighting in XUL windows. + */ + static get XULSupported() { + return true; + } + + /** + * Build highlighter markup. + * + * @return {Object} Container element for the highlighter markup. + */ + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + "aria-hidden": "true", + }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the SVG element. + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + width: "100%", + height: "100%", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: svg, + attributes: { + class: "bounds", + id: "bounds", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the accessible's infobar markup. + this.accessibleInfobar.buildMarkup(root); + + return container; + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + if (this._highlightTimer) { + clearTimeout(this._highlightTimer); + this._highlightTimer = null; + } + + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + this.pageListenerTarget = null; + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.accessibleInfobar.destroy(); + this.accessibleInfobar = null; + this.markup.destroy(); + } + + /** + * Find an element in highlighter markup. + * + * @param {String} id + * Highlighter markup elemet id attribute. + * @return {DOMNode} Element in the highlighter markup. + */ + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Check if node is a valid element, document or text node. + * + * @override AutoRefreshHighlighter.prototype._isNodeValid + * @param {DOMNode} node + * The node to highlight. + * @return {Boolean} whether or not node is valid. + */ + _isNodeValid(node) { + return ( + super._isNodeValid(node) || + isNodeValid(node, TEXT_NODE) || + isNodeValid(node, DOCUMENT_NODE) + ); + } + + /** + * Show the highlighter on a given accessible. + * + * @return {Boolean} True if accessible is highlighted, false otherwise. + */ + _show() { + if (this._highlightTimer) { + clearTimeout(this._highlightTimer); + this._highlightTimer = null; + } + + const { duration } = this.options; + const shown = this._update(); + if (shown) { + this.emit("highlighter-event", { options: this.options, type: "shown" }); + if (duration) { + this._highlightTimer = setTimeout(() => { + this.hide(); + }, duration); + } + } + + return shown; + } + + /** + * Update and show accessible bounds for a current accessible. + * + * @return {Boolean} True if accessible is highlighted, false otherwise. + */ + _update() { + let shown = false; + setIgnoreLayoutChanges(true); + + if (this._updateAccessibleBounds()) { + this._showAccessibleBounds(); + + this.accessibleInfobar.show(); + + shown = true; + } else { + // Nothing to highlight (0px rectangle like a <script> tag for instance) + this.hide(); + } + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + + return shown; + } + + /** + * Hide the highlighter. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideAccessibleBounds(); + this.accessibleInfobar.hide(); + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + /** + * Public API method to temporarily hide accessible bounds for things like + * color contrast calculation. + */ + hideAccessibleBounds() { + if (this.getElement("elements").hasAttribute("hidden")) { + return; + } + + this._hideAccessibleBounds(); + this._shouldRestoreBoundsVisibility = true; + } + + /** + * Public API method to show accessible bounds in case they were temporarily + * hidden. + */ + showAccessibleBounds() { + if (this._shouldRestoreBoundsVisibility) { + this._showAccessibleBounds(); + } + } + + /** + * Hide the accessible bounds container. + */ + _hideAccessibleBounds() { + this._shouldRestoreBoundsVisibility = null; + setIgnoreLayoutChanges(true); + this.getElement("elements").setAttribute("hidden", "true"); + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + /** + * Show the accessible bounds container. + */ + _showAccessibleBounds() { + this._shouldRestoreBoundsVisibility = null; + if (!this.currentNode || !this.highlighterEnv.window) { + return; + } + + setIgnoreLayoutChanges(true); + this.getElement("elements").removeAttribute("hidden"); + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + /** + * Get current accessible bounds. + * + * @return {Object|null} Returns, if available, positioning and bounds + * information for the accessible object. + */ + get _bounds() { + let { win, options } = this; + let getBoundsFn = getBounds; + if (this.options.isXUL) { + // Zoom level for the top level browser window does not change and only + // inner frames do. So we need to get the zoom level of the current node's + // parent window. + let zoom = getCurrentZoom(this.currentNode); + zoom *= zoom; + options = { ...options, zoom }; + getBoundsFn = getBoundsXUL; + win = this.win.parent.ownerGlobal; + } + + return getBoundsFn(win, options); + } + + /** + * Update accessible bounds for a current accessible. Re-draw highlighter + * markup. + * + * @return {Boolean} True if accessible is highlighted, false otherwise. + */ + _updateAccessibleBounds() { + const bounds = this._bounds; + if (!bounds) { + this._hide(); + return false; + } + + const boundsEl = this.getElement("bounds"); + const { left, right, top, bottom } = bounds; + const path = `M${left},${top} L${right},${top} L${right},${bottom} L${left},${bottom}`; + boundsEl.setAttribute("d", path); + + // Un-zoom the root wrapper if the page was zoomed. + const rootId = this.ID_CLASS_PREFIX + "elements"; + this.markup.scaleRootElement(this.currentNode, rootId); + + return true; + } + + /** + * Hide highlighter on page hide. + */ + onPageHide({ target }) { + // If a pagehide event is triggered for current window's highlighter, hide + // the highlighter. + if (target.defaultView === this.win) { + this.hide(); + } + } + + /** + * Hide highlighter on navigation. + */ + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +} + +exports.AccessibleHighlighter = AccessibleHighlighter; diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js new file mode 100644 index 0000000000..d927471b96 --- /dev/null +++ b/devtools/server/actors/highlighters/auto-refresh.js @@ -0,0 +1,318 @@ +/* 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("devtools/server/actors/highlighters/utils/markup"); +const { + getAdjustedQuads, + getWindowDimensions, +} = 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"]; +const QUADS_PROPS = ["p1", "p2", "p3", "p4"]; + +function arePointsDifferent(pointA, pointB) { + return ( + Math.abs(pointA.x - pointB.x) >= 0.5 || + Math.abs(pointA.y - pointB.y) >= 0.5 || + Math.abs(pointA.w - pointB.w) >= 0.5 + ); +} + +function areQuadsDifferent(oldQuads, newQuads) { + for (const region of BOX_MODEL_REGIONS) { + const { length } = oldQuads[region]; + + if (length !== newQuads[region].length) { + return true; + } + + for (let i = 0; i < length; i++) { + for (const prop of QUADS_PROPS) { + const oldPoint = oldQuads[region][i][prop]; + const newPoint = newQuads[region][i][prop]; + + if (arePointsDifferent(oldPoint, newPoint)) { + return true; + } + } + } + } + + return false; +} + +/** + * Base class for auto-refresh-on-change highlighters. Sub classes will have a + * chance to update whenever the current node's geometry changes. + * + * Sub classes must implement the following methods: + * _show: called when the highlighter should be shown, + * _hide: called when the highlighter should be hidden, + * _update: called while the highlighter is shown and the geometry of the + * current node changes. + * + * Sub classes will have access to the following properties: + * - this.currentNode: the node to be shown + * - this.currentQuads: all of the node's box model region quads + * - this.win: the current window + * + * Emits the following events: + * - shown + * - hidden + * - updated + */ +function AutoRefreshHighlighter(highlighterEnv) { + EventEmitter.decorate(this); + + this.highlighterEnv = highlighterEnv; + + this.currentNode = null; + this.currentQuads = {}; + + this._winDimensions = getWindowDimensions(this.win); + this._scroll = { x: this.win.pageXOffset, y: this.win.pageYOffset }; + + this.update = this.update.bind(this); +} + +AutoRefreshHighlighter.prototype = { + _ignoreZoom: false, + _ignoreScroll: false, + + /** + * Window corresponding to the current highlighterEnv. + */ + get win() { + if (!this.highlighterEnv) { + return null; + } + return this.highlighterEnv.window; + }, + + /* Window containing the target content. */ + get contentWindow() { + return this.win; + }, + + /** + * Show the highlighter on a given node + * @param {DOMNode} node + * @param {Object} options + * Object used for passing options + */ + show: function(node, options = {}) { + const isSameNode = node === this.currentNode; + const isSameOptions = this._isSameOptions(options); + + if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) { + return false; + } + + this.options = options; + + this._stopRefreshLoop(); + this.currentNode = node; + this._updateAdjustedQuads(); + this._startRefreshLoop(); + + const shown = this._show(); + if (shown) { + this.emit("shown"); + } + return shown; + }, + + /** + * Hide the highlighter + */ + hide: function() { + if (!this.currentNode || !this.highlighterEnv.window) { + return; + } + + this._hide(); + this._stopRefreshLoop(); + this.currentNode = null; + this.currentQuads = {}; + this.options = null; + + this.emit("hidden"); + }, + + /** + * Whether the current node is valid for this highlighter type. + * This is implemented by default to check if the node is an element node. Highlighter + * sub-classes should override this method if they want to highlight other node types. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid: 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; + } + + const keys = Object.keys(options); + + if (keys.length !== Object.keys(this.options).length) { + return false; + } + + for (const key of keys) { + if (this.options[key] !== options[key]) { + return false; + } + } + + return true; + }, + + /** + * Update the stored box quads by reading the current node's box quads. + */ + _updateAdjustedQuads: function() { + this.currentQuads = {}; + + for (const region of BOX_MODEL_REGIONS) { + this.currentQuads[region] = getAdjustedQuads( + this.contentWindow, + this.currentNode, + region, + { ignoreScroll: this._ignoreScroll, ignoreZoom: this._ignoreZoom } + ); + } + }, + + /** + * Update the knowledge we have of the current node's boxquads and return true + * if any of the points x/y or bounds have change since. + * @return {Boolean} + */ + _hasMoved: function() { + const oldQuads = this.currentQuads; + this._updateAdjustedQuads(); + + return areQuadsDifferent(oldQuads, this.currentQuads); + }, + + /** + * Update the knowledge we have of the current window's scrolling offset, both + * horizontal and vertical, and return `true` if they have changed since. + * @return {Boolean} + */ + _hasWindowScrolled: function() { + if (!this.win) { + return false; + } + + const { pageXOffset, pageYOffset } = this.win; + const hasChanged = + this._scroll.x !== pageXOffset || this._scroll.y !== pageYOffset; + + this._scroll = { x: pageXOffset, y: pageYOffset }; + + return hasChanged; + }, + + /** + * Update the knowledge we have of the current window's dimensions and return `true` + * if they have changed since. + * @return {Boolean} + */ + _haveWindowDimensionsChanged: function() { + const { width, height } = getWindowDimensions(this.win); + const haveChanged = + this._winDimensions.width !== width || + this._winDimensions.height !== height; + + this._winDimensions = { width, height }; + return haveChanged; + }, + + /** + * Update the highlighter if the node has moved since the last update. + */ + update: function() { + if ( + !this._isNodeValid(this.currentNode) || + (!this._hasMoved() && !this._haveWindowDimensionsChanged()) + ) { + // At this point we're not calling the `_update` method. However, if the window has + // scrolled, we want to invoke `_scrollUpdate`. + if (this._hasWindowScrolled()) { + this._scrollUpdate(); + } + + return; + } + + this._update(); + this.emit("updated"); + }, + + _show: 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 zoom or repaint + throw new Error("Custom highlighter class had to implement _update method"); + }, + + _scrollUpdate: function() { + // Can be implemented by sub classes + // When called, sub classes can upate the highlighter shown for + // this.currentNode + // This is called as a result of a page scroll + }, + + _hide: 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() { + const win = this.currentNode.ownerGlobal; + 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 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; diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js new file mode 100644 index 0000000000..bed5b106c5 --- /dev/null +++ b/devtools/server/actors/highlighters/css-grid.js @@ -0,0 +1,1958 @@ +/* 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 { + CANVAS_SIZE, + DEFAULT_COLOR, + drawBubbleRect, + drawLine, + drawRect, + drawRoundedRect, + getBoundsFromPoints, + getCurrentMatrix, + getPathDescriptionFromPoints, + getPointsFromDiagonal, + updateCanvasElement, + updateCanvasPosition, +} = require("devtools/server/actors/highlighters/utils/canvas"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, + moveInfobar, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { apply } = require("devtools/shared/layout/dom-matrix-2d"); +const { + getCurrentZoom, + getDisplayPixelRatio, + getWindowDimensions, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); +const { + stringifyGridFragments, +} = require("devtools/server/actors/utils/css-grid-utils"); +const { LocalizationHelper } = require("devtools/shared/l10n"); + +const STRINGS_URI = "devtools/shared/locales/highlighters.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const COLUMNS = "cols"; +const ROWS = "rows"; + +const GRID_FONT_SIZE = 10; +const GRID_FONT_FAMILY = "sans-serif"; +const GRID_AREA_NAME_FONT_SIZE = "20"; + +const GRID_LINES_PROPERTIES = { + edge: { + lineDash: [0, 0], + alpha: 1, + }, + explicit: { + lineDash: [5, 3], + alpha: 0.75, + }, + implicit: { + lineDash: [2, 2], + alpha: 0.5, + }, + areaEdge: { + lineDash: [0, 0], + alpha: 1, + lineWidth: 3, + }, +}; + +const GRID_GAP_PATTERN_WIDTH = 14; // px +const GRID_GAP_PATTERN_HEIGHT = 14; // px +const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; // px +const GRID_GAP_ALPHA = 0.5; + +// This is the minimum distance a line can be to the edge of the document under which we +// push the line number arrow to be inside the grid. This offset is enough to fit the +// entire arrow + a stacked arrow behind it. +const OFFSET_FROM_EDGE = 32; +// This is how much inside the grid we push the arrow. This a factor of the arrow size. +// The goal here is for a row and a column arrow that have both been pushed inside the +// grid, in a corner, not to overlap. +const FLIP_ARROW_INSIDE_FACTOR = 2.5; + +/** + * Given an `edge` of a box, return the name of the edge one move to the right. + */ +function rotateEdgeRight(edge) { + switch (edge) { + case "top": + return "right"; + case "right": + return "bottom"; + case "bottom": + return "left"; + case "left": + return "top"; + default: + return edge; + } +} + +/** + * Given an `edge` of a box, return the name of the edge one move to the left. + */ +function rotateEdgeLeft(edge) { + switch (edge) { + case "top": + return "left"; + case "right": + return "top"; + case "bottom": + return "right"; + case "left": + return "bottom"; + default: + return edge; + } +} + +/** + * Given an `edge` of a box, return the name of the opposite edge. + */ +function reflectEdge(edge) { + switch (edge) { + case "top": + return "bottom"; + case "right": + return "left"; + case "bottom": + return "top"; + case "left": + return "right"; + default: + return edge; + } +} + +/** + * Cached used by `CssGridHighlighter.getGridGapPattern`. + */ +const gCachedGridPattern = new Map(); + +/** + * The CssGridHighlighter is the class that overlays a visual grid on top of + * display:[inline-]grid elements. + * + * Usage example: + * let h = new CssGridHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * @param {String} options.color + * The color that should be used to draw the highlighter for this grid. + * @param {Number} options.globalAlpha + * The alpha (transparency) value that should be used to draw the highlighter for + * this grid. + * @param {Boolean} options.showAllGridAreas + * Shows all the grid area highlights for the current grid if isShown is + * true. + * @param {String} options.showGridArea + * Shows the grid area highlight for the given area name. + * @param {Boolean} options.showGridAreasOverlay + * Displays an overlay of all the grid areas for the current grid + * container if isShown is true. + * @param {Object} options.showGridCell + * An object containing the grid fragment index, row and column numbers + * to the corresponding grid cell to highlight for the current grid. + * @param {Number} options.showGridCell.gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} options.showGridCell.rowNumber + * Row number of the grid cell to highlight. + * @param {Number} options.showGridCell.columnNumber + * Column number of the grid cell to highlight. + * @param {Object} options.showGridLineNames + * An object containing the grid fragment index and line number to the + * corresponding grid line to highlight for the current grid. + * @param {Number} options.showGridLineNames.gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} options.showGridLineNames.lineNumber + * Line number of the grid line to highlight. + * @param {String} options.showGridLineNames.type + * The dimension type of the grid line. + * @param {Boolean} options.showGridLineNumbers + * Displays the grid line numbers on the grid lines if isShown is true. + * @param {Boolean} options.showInfiniteLines + * Displays an infinite line to represent the grid lines if isShown is + * true. + * @param {Number} options.zIndex + * The z-index to decide the displaying order. + * + * Structure: + * <div class="highlighter-container"> + * <canvas id="css-grid-canvas" class="css-grid-canvas"> + * <svg class="css-grid-elements" hidden="true"> + * <g class="css-grid-regions"> + * <path class="css-grid-areas" points="..." /> + * <path class="css-grid-cells" points="..." /> + * </g> + * </svg> + * <div class="css-grid-area-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-area-infobar-name">Grid Area Name</span> + * <span class="css-grid-area-infobar-dimensions">Grid Area Dimensions></span> + * </div> + * </div> + * </div> + * <div class="css-grid-cell-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-cell-infobar-position">Grid Cell Position</span> + * <span class="css-grid-cell-infobar-dimensions">Grid Cell Dimensions></span> + * </div> + * </div> + * <div class="css-grid-line-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-line-infobar-number">Grid Line Number</span> + * <span class="css-grid-line-infobar-names">Grid Line Names></span> + * </div> + * </div> + * </div> + * </div> + */ + +class CssGridHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "css-grid-"; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + + // Initialize the <canvas> position to the top left corner of the page. + this._canvasPosition = { + x: 0, + y: 0, + }; + + // Calling `updateCanvasPosition` anyway since the highlighter could be initialized + // on a page that has scrolled already. + updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // We use a <canvas> element so that we can draw an arbitrary number of lines + // which wouldn't be possible with HTML or SVG without having to insert and remove + // the whole markup on every update. + this.markup.createNode({ + parent: root, + nodeType: "canvas", + attributes: { + id: "canvas", + class: "canvas", + hidden: "true", + width: CANVAS_SIZE, + height: CANVAS_SIZE, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the SVG element. + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + width: "100%", + height: "100%", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const regions = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + attributes: { + class: "regions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: regions, + attributes: { + class: "areas", + id: "areas", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: regions, + attributes: { + class: "cells", + id: "cells", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid area infobar markup. + const areaInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "area-infobar-container", + id: "area-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const areaInfobar = this.markup.createNode({ + parent: areaInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const areaTextbox = this.markup.createNode({ + parent: areaInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: areaTextbox, + attributes: { + class: "area-infobar-name", + id: "area-infobar-name", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: areaTextbox, + attributes: { + class: "area-infobar-dimensions", + id: "area-infobar-dimensions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid cell infobar markup. + const cellInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "cell-infobar-container", + id: "cell-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const cellInfobar = this.markup.createNode({ + parent: cellInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const cellTextbox = this.markup.createNode({ + parent: cellInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: cellTextbox, + attributes: { + class: "cell-infobar-position", + id: "cell-infobar-position", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: cellTextbox, + attributes: { + class: "cell-infobar-dimensions", + id: "cell-infobar-dimensions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid line infobar markup. + const lineInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "line-infobar-container", + id: "line-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const lineInfobar = this.markup.createNode({ + parent: lineInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const lineTextbox = this.markup.createNode({ + parent: lineInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: lineTextbox, + attributes: { + class: "line-infobar-number", + id: "line-infobar-number", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: lineTextbox, + attributes: { + class: "line-infobar-names", + id: "line-infobar-names", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return container; + } + + clearCache() { + gCachedGridPattern.clear(); + } + + /** + * Clear the grid area highlights. + */ + clearGridAreas() { + const areas = this.getElement("areas"); + areas.setAttribute("d", ""); + } + + /** + * Clear the grid cell highlights. + */ + clearGridCell() { + const cells = this.getElement("cells"); + cells.setAttribute("d", ""); + } + + destroy() { + const { highlighterEnv } = this; + highlighterEnv.off("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + } + + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + this.clearCache(); + AutoRefreshHighlighter.prototype.destroy.call(this); + } + + get canvas() { + return this.getElement("canvas"); + } + + get color() { + return this.options.color || DEFAULT_COLOR; + } + + get ctx() { + return this.canvas.getCanvasContext("2d"); + } + + get globalAlpha() { + return this.options.globalAlpha || 1; + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + getFirstColLinePos(fragment) { + return fragment.cols.lines[0].start; + } + + getFirstRowLinePos(fragment) { + return fragment.rows.lines[0].start; + } + + /** + * Gets the grid gap pattern used to render the gap regions based on the device + * pixel ratio given. + * + * @param {Number} devicePixelRatio + * The device pixel ratio we want the pattern for. + * @param {Object} dimension + * Refers to the Map key for the grid dimension type which is either the + * constant COLUMNS or ROWS. + * @return {CanvasPattern} grid gap pattern. + */ + getGridGapPattern(devicePixelRatio, dimension) { + let gridPatternMap = null; + + if (gCachedGridPattern.has(devicePixelRatio)) { + gridPatternMap = gCachedGridPattern.get(devicePixelRatio); + } else { + gridPatternMap = new Map(); + } + + if (gridPatternMap.has(dimension)) { + return gridPatternMap.get(dimension); + } + + // Create the diagonal lines pattern for the rendering the grid gaps. + const canvas = this.markup.createNode({ nodeType: "canvas" }); + const width = (canvas.width = GRID_GAP_PATTERN_WIDTH * devicePixelRatio); + const height = (canvas.height = GRID_GAP_PATTERN_HEIGHT * devicePixelRatio); + + const ctx = canvas.getContext("2d"); + ctx.save(); + ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(0.5, 0.5); + + if (dimension === COLUMNS) { + ctx.moveTo(0, 0); + ctx.lineTo(width, height); + } else { + ctx.moveTo(width, 0); + ctx.lineTo(0, height); + } + + ctx.strokeStyle = this.color; + ctx.globalAlpha = GRID_GAP_ALPHA * this.globalAlpha; + ctx.stroke(); + ctx.restore(); + + const pattern = ctx.createPattern(canvas, "repeat"); + + gridPatternMap.set(dimension, pattern); + gCachedGridPattern.set(devicePixelRatio, gridPatternMap); + + return pattern; + } + + getLastColLinePos(fragment) { + return fragment.cols.lines[fragment.cols.lines.length - 1].start; + } + + /** + * Get the GridLine index of the last edge of the explicit grid for a grid dimension. + * + * @param {GridTracks} tracks + * The grid track of a given grid dimension. + * @return {Number} index of the last edge of the explicit grid for a grid dimension. + */ + getLastEdgeLineIndex(tracks) { + let trackIndex = tracks.length - 1; + + // Traverse the grid track backwards until we find an explicit track. + while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") { + trackIndex--; + } + + // The grid line index is the grid track index + 1. + return trackIndex + 1; + } + + getLastRowLinePos(fragment) { + return fragment.rows.lines[fragment.rows.lines.length - 1].start; + } + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the + * element's quads have changed. Override it so it also returns true if the + * element's grid has changed (which can happen when you change the + * grid-template-* CSS properties with the highlighter displayed). + */ + _hasMoved() { + const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + const oldGridData = stringifyGridFragments(this.gridData); + this.gridData = this.currentNode.getGridFragments(); + const newGridData = stringifyGridFragments(this.gridData); + + return hasMoved || oldGridData !== newGridData; + } + + /** + * Hide the highlighter, the canvas and the infobars. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideGrid(); + this._hideGridElements(); + this._hideGridAreaInfoBar(); + this._hideGridCellInfoBar(); + this._hideGridLineInfoBar(); + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + } + + _hideGrid() { + this.getElement("canvas").setAttribute("hidden", "true"); + } + + _hideGridAreaInfoBar() { + this.getElement("area-infobar-container").setAttribute("hidden", "true"); + } + + _hideGridCellInfoBar() { + this.getElement("cell-infobar-container").setAttribute("hidden", "true"); + } + + _hideGridElements() { + this.getElement("elements").setAttribute("hidden", "true"); + } + + _hideGridLineInfoBar() { + this.getElement("line-infobar-container").setAttribute("hidden", "true"); + } + + /** + * Checks if the current node has a CSS Grid layout. + * + * @return {Boolean} true if the current node has a CSS grid layout, false otherwise. + */ + isGrid() { + return this.currentNode.hasGridFragments(); + } + + /** + * Is a given grid fragment valid? i.e. does it actually have tracks? In some cases, we + * may have a fragment that defines column tracks but doesn't have any rows (or vice + * versa). In which case we do not want to draw anything for that fragment. + * + * @param {Object} fragment + * @return {Boolean} + */ + isValidFragment(fragment) { + return fragment.cols.tracks.length && fragment.rows.tracks.length; + } + + /** + * The <canvas>'s position needs to be updated if the page scrolls too much, in order + * to give the illusion that it always covers the viewport. + */ + _scrollUpdate() { + const hasUpdated = updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + + if (hasUpdated) { + this._update(); + } + } + + _show() { + if (!this.isGrid()) { + this.hide(); + return false; + } + + // The grid pattern cache should be cleared in case the color changed. + this.clearCache(); + + // Hide the canvas, grid element highlights and infobar. + this._hide(); + + return this._update(); + } + + _showGrid() { + this.getElement("canvas").removeAttribute("hidden"); + } + + _showGridAreaInfoBar() { + this.getElement("area-infobar-container").removeAttribute("hidden"); + } + + _showGridCellInfoBar() { + this.getElement("cell-infobar-container").removeAttribute("hidden"); + } + + _showGridElements() { + this.getElement("elements").removeAttribute("hidden"); + } + + _showGridLineInfoBar() { + this.getElement("line-infobar-container").removeAttribute("hidden"); + } + + /** + * Shows all the grid area highlights for the current grid. + */ + showAllGridAreas() { + this.renderGridArea(); + } + + /** + * Shows the grid area highlight for the given area name. + * + * @param {String} areaName + * Grid area name. + */ + showGridArea(areaName) { + this.renderGridArea(areaName); + } + + /** + * Shows the grid cell highlight for the given grid cell options. + * + * @param {Number} options.gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} options.rowNumber + * Row number of the grid cell to highlight. + * @param {Number} options.columnNumber + * Column number of the grid cell to highlight. + */ + showGridCell({ gridFragmentIndex, rowNumber, columnNumber }) { + this.renderGridCell(gridFragmentIndex, rowNumber, columnNumber); + } + + /** + * Shows the grid line highlight for the given grid line options. + * + * @param {Number} options.gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} options.lineNumber + * Line number of the grid line to highlight. + * @param {String} options.type + * The dimension type of the grid line. + */ + showGridLineNames({ gridFragmentIndex, lineNumber, type }) { + this.renderGridLineNames(gridFragmentIndex, lineNumber, type); + } + + /** + * If a page hide event is triggered for current window's highlighter, hide the + * highlighter. + */ + onPageHide({ target }) { + if (target.defaultView === this.win) { + this.hide(); + } + } + + /** + * Called when the page will-navigate. Used to hide the grid highlighter and clear + * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the + * next time. + */ + onWillNavigate({ isTopLevel }) { + this.clearCache(); + + if (isTopLevel) { + this.hide(); + } + } + + renderFragment(fragment) { + if (!this.isValidFragment(fragment)) { + return; + } + + this.renderLines( + fragment.cols, + COLUMNS, + this.getFirstRowLinePos(fragment), + this.getLastRowLinePos(fragment) + ); + this.renderLines( + fragment.rows, + ROWS, + this.getFirstColLinePos(fragment), + this.getLastColLinePos(fragment) + ); + + if (this.options.showGridAreasOverlay) { + this.renderGridAreaOverlay(); + } + + // Line numbers are rendered in a 2nd step to avoid overlapping with existing lines. + if (this.options.showGridLineNumbers) { + this.renderLineNumbers( + fragment.cols, + COLUMNS, + this.getFirstRowLinePos(fragment) + ); + this.renderLineNumbers( + fragment.rows, + ROWS, + this.getFirstColLinePos(fragment) + ); + this.renderNegativeLineNumbers( + fragment.cols, + COLUMNS, + this.getLastRowLinePos(fragment) + ); + this.renderNegativeLineNumbers( + fragment.rows, + ROWS, + this.getLastColLinePos(fragment) + ); + } + } + + /** + * Render the grid area highlight for the given area name or for all the grid areas. + * + * @param {String} areaName + * Name of the grid area to be highlighted. If no area name is provided, all + * the grid areas should be highlighted. + */ + renderGridArea(areaName) { + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const paths = []; + + for (let i = 0; i < this.gridData.length; i++) { + const fragment = this.gridData[i]; + + for (const area of fragment.areas) { + if (areaName && areaName != area.name) { + continue; + } + + const rowStart = fragment.rows.lines[area.rowStart - 1]; + const rowEnd = fragment.rows.lines[area.rowEnd - 1]; + const columnStart = fragment.cols.lines[area.columnStart - 1]; + const columnEnd = fragment.cols.lines[area.columnEnd - 1]; + + const x1 = columnStart.start + columnStart.breadth; + const y1 = rowStart.start + rowStart.breadth; + const x2 = columnEnd.start; + const y2 = rowEnd.start; + + const points = getPointsFromDiagonal( + x1, + y1, + x2, + y2, + this.currentMatrix + ); + + // Scale down by `devicePixelRatio` since SVG element already take them into + // account. + const svgPoints = points.map(point => ({ + x: Math.round(point.x / devicePixelRatio), + y: Math.round(point.y / devicePixelRatio), + })); + + // Scale down by `displayPixelRatio` since infobar's HTML elements already take it + // into account; and the zoom scaling is handled by `moveInfobar`. + const bounds = getBoundsFromPoints( + points.map(point => ({ + x: Math.round(point.x / displayPixelRatio), + y: Math.round(point.y / displayPixelRatio), + })) + ); + + paths.push(getPathDescriptionFromPoints(svgPoints)); + + // Update and show the info bar when only displaying a single grid area. + if (areaName) { + this._showGridAreaInfoBar(); + this._updateGridAreaInfobar(area, bounds); + } + } + } + + const areas = this.getElement("areas"); + areas.setAttribute("d", paths.join(" ")); + } + + /** + * Render grid area name on the containing grid area cell. + * + * @param {Object} fragment + * The grid fragment of the grid container. + * @param {Object} area + * The area overlay to render on the CSS highlighter canvas. + */ + renderGridAreaName(fragment, area) { + const { rowStart, rowEnd, columnStart, columnEnd } = area; + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const offset = (displayPixelRatio / 2) % 1; + let fontSize = GRID_AREA_NAME_FONT_SIZE * displayPixelRatio; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + this.ctx.save(); + this.ctx.translate(offset - canvasX, offset - canvasY); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + this.ctx.globalAlpha = this.globalAlpha; + this.ctx.strokeStyle = this.color; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + + // Draw the text for the grid area name. + for (let rowNumber = rowStart; rowNumber < rowEnd; rowNumber++) { + for ( + let columnNumber = columnStart; + columnNumber < columnEnd; + columnNumber++ + ) { + const row = fragment.rows.tracks[rowNumber - 1]; + const column = fragment.cols.tracks[columnNumber - 1]; + + // If the font size exceeds the bounds of the containing grid cell, size it its + // row or column dimension, whichever is smallest. + if ( + fontSize > column.breadth * displayPixelRatio || + fontSize > row.breadth * displayPixelRatio + ) { + fontSize = Math.min([column.breadth, row.breadth]); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + } + + const textWidth = this.ctx.measureText(area.name).width; + // The width of the character 'm' approximates the height of the text. + const textHeight = this.ctx.measureText("m").width; + // Padding in pixels for the line number text inside of the line number container. + const padding = 3 * displayPixelRatio; + + const boxWidth = textWidth + 2 * padding; + const boxHeight = textHeight + 2 * padding; + + let x = column.start + column.breadth / 2; + let y = row.start + row.breadth / 2; + + [x, y] = apply(this.currentMatrix, [x, y]); + + const rectXPos = x - boxWidth / 2; + const rectYPos = y - boxHeight / 2; + + // Draw a rounded rectangle with a border width of 1 pixel, + // a border color matching the grid color, and a white background. + this.ctx.lineWidth = 1 * displayPixelRatio; + this.ctx.strokeStyle = this.color; + this.ctx.fillStyle = "white"; + const radius = 2 * displayPixelRatio; + drawRoundedRect( + this.ctx, + rectXPos, + rectYPos, + boxWidth, + boxHeight, + radius + ); + + this.ctx.fillStyle = this.color; + this.ctx.fillText(area.name, x, y + padding); + } + } + + this.ctx.restore(); + } + + /** + * Renders the grid area overlay on the css grid highlighter canvas. + */ + renderGridAreaOverlay() { + const padding = 1; + + for (let i = 0; i < this.gridData.length; i++) { + const fragment = this.gridData[i]; + + for (const area of fragment.areas) { + const { rowStart, rowEnd, columnStart, columnEnd, type } = area; + + if (type === "implicit") { + continue; + } + + // Draw the line edges for the grid area. + const areaColStart = fragment.cols.lines[columnStart - 1]; + const areaColEnd = fragment.cols.lines[columnEnd - 1]; + + const areaRowStart = fragment.rows.lines[rowStart - 1]; + const areaRowEnd = fragment.rows.lines[rowEnd - 1]; + + const areaColStartLinePos = areaColStart.start + areaColStart.breadth; + const areaRowStartLinePos = areaRowStart.start + areaRowStart.breadth; + + this.renderLine( + areaColStartLinePos + padding, + areaRowStartLinePos, + areaRowEnd.start, + COLUMNS, + "areaEdge" + ); + this.renderLine( + areaColEnd.start - padding, + areaRowStartLinePos, + areaRowEnd.start, + COLUMNS, + "areaEdge" + ); + + this.renderLine( + areaRowStartLinePos + padding, + areaColStartLinePos, + areaColEnd.start, + ROWS, + "areaEdge" + ); + this.renderLine( + areaRowEnd.start - padding, + areaColStartLinePos, + areaColEnd.start, + ROWS, + "areaEdge" + ); + + this.renderGridAreaName(fragment, area); + } + } + } + + /** + * Render the grid cell highlight for the given grid fragment index, row and column + * number. + * + * @param {Number} gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} rowNumber + * Row number of the grid cell to highlight. + * @param {Number} columnNumber + * Column number of the grid cell to highlight. + */ + renderGridCell(gridFragmentIndex, rowNumber, columnNumber) { + const fragment = this.gridData[gridFragmentIndex]; + + if (!fragment) { + return; + } + + const row = fragment.rows.tracks[rowNumber - 1]; + const column = fragment.cols.tracks[columnNumber - 1]; + + if (!row || !column) { + return; + } + + const x1 = column.start; + const y1 = row.start; + const x2 = column.start + column.breadth; + const y2 = row.start + row.breadth; + + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const points = getPointsFromDiagonal(x1, y1, x2, y2, this.currentMatrix); + + // Scale down by `devicePixelRatio` since SVG element already take them into account. + const svgPoints = points.map(point => ({ + x: Math.round(point.x / devicePixelRatio), + y: Math.round(point.y / devicePixelRatio), + })); + + // Scale down by `displayPixelRatio` since infobar's HTML elements already take it + // into account, and the zoom scaling is handled by `moveInfobar`. + const bounds = getBoundsFromPoints( + points.map(point => ({ + x: Math.round(point.x / displayPixelRatio), + y: Math.round(point.y / displayPixelRatio), + })) + ); + + const cells = this.getElement("cells"); + cells.setAttribute("d", getPathDescriptionFromPoints(svgPoints)); + + this._showGridCellInfoBar(); + this._updateGridCellInfobar(rowNumber, columnNumber, bounds); + } + + /** + * Render the grid gap area on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridGap(linePos, startPos, endPos, breadth, dimensionType) { + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const offset = (displayPixelRatio / 2) % 1; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + breadth = Math.round(breadth); + + this.ctx.save(); + this.ctx.fillStyle = this.getGridGapPattern( + devicePixelRatio, + dimensionType + ); + this.ctx.translate(offset - canvasX, offset - canvasY); + + if (dimensionType === COLUMNS) { + if (isFinite(endPos)) { + endPos = Math.round(endPos); + } else { + endPos = this._winDimensions.height; + startPos = -endPos; + } + drawRect( + this.ctx, + linePos, + startPos, + linePos + breadth, + endPos, + this.currentMatrix + ); + } else { + if (isFinite(endPos)) { + endPos = Math.round(endPos); + } else { + endPos = this._winDimensions.width; + startPos = -endPos; + } + drawRect( + this.ctx, + startPos, + linePos, + endPos, + linePos + breadth, + this.currentMatrix + ); + } + + // Find current angle of grid by measuring the angle of two arbitrary points, + // then rotate canvas, so the hash pattern stays 45deg to the gridlines. + const p1 = apply(this.currentMatrix, [0, 0]); + const p2 = apply(this.currentMatrix, [1, 0]); + const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]); + this.ctx.rotate(angleRad); + + this.ctx.fill(); + this.ctx.restore(); + } + + /** + * Render the grid line name highlight for the given grid fragment index, lineNumber, + * and dimensionType. + * + * @param {Number} gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} lineNumber + * Line number of the grid line to highlight. + * @param {String} dimensionType + * The dimension type of the grid line. + */ + renderGridLineNames(gridFragmentIndex, lineNumber, dimensionType) { + const fragment = this.gridData[gridFragmentIndex]; + + if (!fragment || !lineNumber || !dimensionType) { + return; + } + + const { names } = fragment[dimensionType].lines[lineNumber - 1]; + let linePos; + + if (dimensionType === ROWS) { + linePos = fragment.rows.lines[lineNumber - 1]; + } else if (dimensionType === COLUMNS) { + linePos = fragment.cols.lines[lineNumber - 1]; + } + + if (!linePos) { + return; + } + + const currentZoom = getCurrentZoom(this.win); + const { bounds } = this.currentQuads.content[gridFragmentIndex]; + + const rowYPosition = fragment.rows.lines[0]; + const colXPosition = fragment.rows.lines[0]; + + const x = + dimensionType === COLUMNS + ? linePos.start + bounds.left / currentZoom + : colXPosition.start + bounds.left / currentZoom; + + const y = + dimensionType === ROWS + ? linePos.start + bounds.top / currentZoom + : rowYPosition.start + bounds.top / currentZoom; + + this._showGridLineInfoBar(); + this._updateGridLineInfobar(names.join(", "), lineNumber, x, y); + } + + /** + * Render the grid line number on the css grid highlighter canvas. + * + * @param {Number} lineNumber + * The grid line number. + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Boolean||undefined} isStackedLine + * Boolean indicating if the line is stacked. + */ + // eslint-disable-next-line complexity + renderGridLineNumber( + lineNumber, + linePos, + startPos, + breadth, + dimensionType, + isStackedLine + ) { + const displayPixelRatio = getDisplayPixelRatio(this.win); + const { devicePixelRatio } = this.win; + const offset = (displayPixelRatio / 2) % 1; + const fontSize = GRID_FONT_SIZE * devicePixelRatio; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + breadth = Math.round(breadth); + + if (linePos + breadth < 0) { + // Don't render the line number since the line is not visible on screen. + return; + } + + this.ctx.save(); + this.ctx.translate(offset - canvasX, offset - canvasY); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + + // For a general grid box, the height of the character "m" will be its minimum width + // and height. If line number's text width is greater, then use the grid box's text + // width instead. + const textHeight = this.ctx.measureText("m").width; + const textWidth = Math.max( + textHeight, + this.ctx.measureText(lineNumber).width + ); + + // Padding in pixels for the line number text inside of the line number container. + const padding = 3 * devicePixelRatio; + const offsetFromEdge = 2 * devicePixelRatio; + + let boxWidth = textWidth + 2 * padding; + let boxHeight = textHeight + 2 * padding; + + // Calculate the x & y coordinates for the line number container, so that its arrow + // tip is centered on the line (or the gap if there is one), and is offset by the + // calculated padding value from the grid container edge. + let x, y; + + if (dimensionType === COLUMNS) { + x = linePos + breadth / 2; + y = + lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge; + } else if (dimensionType === ROWS) { + y = linePos + breadth / 2; + x = + lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge; + } + + [x, y] = apply(this.currentMatrix, [x, y]); + + // Draw a bubble rectangular arrow with a border width of 2 pixels, a border color + // matching the grid color and a white background (the line number will be written in + // black). + this.ctx.lineWidth = 2 * displayPixelRatio; + this.ctx.strokeStyle = this.color; + this.ctx.fillStyle = "white"; + this.ctx.globalAlpha = this.globalAlpha; + + // See param definitions of drawBubbleRect. + const radius = 2 * displayPixelRatio; + const margin = 2 * displayPixelRatio; + const arrowSize = 8 * displayPixelRatio; + + const minBoxSize = arrowSize * 2 + padding; + boxWidth = Math.max(boxWidth, minBoxSize); + boxHeight = Math.max(boxHeight, minBoxSize); + + // Determine which edge of the box to aim the line number arrow at. + const boxEdge = this.getBoxEdge(dimensionType, lineNumber); + + let { width, height } = this._winDimensions; + width *= displayPixelRatio; + height *= displayPixelRatio; + + // Don't draw if the line is out of the viewport. + if ( + (dimensionType === ROWS && (y < 0 || y > height)) || + (dimensionType === COLUMNS && (x < 0 || x > width)) + ) { + this.ctx.restore(); + return; + } + + // If the arrow's edge (the one perpendicular to the line direction) is too close to + // the edge of the viewport. Push the arrow inside the grid. + const minOffsetFromEdge = OFFSET_FROM_EDGE * displayPixelRatio; + switch (boxEdge) { + case "left": + if (x < minOffsetFromEdge) { + x += FLIP_ARROW_INSIDE_FACTOR * boxWidth; + } + break; + case "right": + if (width - x < minOffsetFromEdge) { + x -= FLIP_ARROW_INSIDE_FACTOR * boxWidth; + } + break; + case "top": + if (y < minOffsetFromEdge) { + y += FLIP_ARROW_INSIDE_FACTOR * boxHeight; + } + break; + case "bottom": + if (height - y < minOffsetFromEdge) { + y -= FLIP_ARROW_INSIDE_FACTOR * boxHeight; + } + break; + } + + // Offset stacked line numbers by a quarter of the box's width/height, so a part of + // them remains visible behind the number that sits at the top of the stack. + if (isStackedLine) { + const xOffset = boxWidth / 4; + const yOffset = boxHeight / 4; + + if (lineNumber > 0) { + x -= xOffset; + y -= yOffset; + } else { + x += xOffset; + y += yOffset; + } + } + + // If one the edges of the arrow that's parallel to the line is too close to the edge + // of the viewport (and therefore partly hidden), grow the arrow's size in the + // opposite direction. + // The goal is for the part that's not hidden to be exactly the size of a normal + // arrow and for the arrow to keep pointing at the line (keep being centered on it). + let grewBox = false; + const boxWidthBeforeGrowth = boxWidth; + const boxHeightBeforeGrowth = boxHeight; + + if (dimensionType === ROWS && y <= boxHeight / 2) { + grewBox = true; + boxHeight = 2 * (boxHeight - y); + } else if (dimensionType === ROWS && y >= height - boxHeight / 2) { + grewBox = true; + boxHeight = 2 * (y - height + boxHeight); + } else if (dimensionType === COLUMNS && x <= boxWidth / 2) { + grewBox = true; + boxWidth = 2 * (boxWidth - x); + } else if (dimensionType === COLUMNS && x >= width - boxWidth / 2) { + grewBox = true; + boxWidth = 2 * (x - width + boxWidth); + } + + // Draw the arrow box itself + drawBubbleRect( + this.ctx, + x, + y, + boxWidth, + boxHeight, + radius, + margin, + arrowSize, + boxEdge + ); + + // Determine the text position for it to be centered nicely inside the arrow box. + switch (boxEdge) { + case "left": + x -= boxWidth + arrowSize + radius - boxWidth / 2; + break; + case "right": + x += boxWidth + arrowSize + radius - boxWidth / 2; + break; + case "top": + y -= boxHeight + arrowSize + radius - boxHeight / 2; + break; + case "bottom": + y += boxHeight + arrowSize + radius - boxHeight / 2; + break; + } + + // Do a second pass to adjust the position, along the other axis, if the box grew + // during the previous step, so the text is also centered on that axis. + if (grewBox) { + if (dimensionType === ROWS && y <= boxHeightBeforeGrowth / 2) { + y = boxHeightBeforeGrowth / 2; + } else if ( + dimensionType === ROWS && + y >= height - boxHeightBeforeGrowth / 2 + ) { + y = height - boxHeightBeforeGrowth / 2; + } else if (dimensionType === COLUMNS && x <= boxWidthBeforeGrowth / 2) { + x = boxWidthBeforeGrowth / 2; + } else if ( + dimensionType === COLUMNS && + x >= width - boxWidthBeforeGrowth / 2 + ) { + x = width - boxWidthBeforeGrowth / 2; + } + } + + // Write the line number inside of the rectangle. + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillStyle = "black"; + const numberText = isStackedLine ? "" : lineNumber; + this.ctx.fillText(numberText, x, y); + this.ctx.restore(); + } + + /** + * Determine which edge of a line number box to aim the line number arrow at. + * + * @param {String} dimensionType + * The grid line dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} lineNumber + * The grid line number. + * @return {String} The edge of the box: top, right, bottom or left. + */ + getBoxEdge(dimensionType, lineNumber) { + let boxEdge; + + if (dimensionType === COLUMNS) { + boxEdge = lineNumber > 0 ? "top" : "bottom"; + } else if (dimensionType === ROWS) { + boxEdge = lineNumber > 0 ? "left" : "right"; + } + + // Rotate box edge as needed for writing mode and text direction. + const { direction, writingMode } = getComputedStyle(this.currentNode); + + switch (writingMode) { + case "horizontal-tb": + // This is the initial value. No further adjustment needed. + break; + case "vertical-rl": + boxEdge = rotateEdgeRight(boxEdge); + break; + case "vertical-lr": + if (dimensionType === COLUMNS) { + boxEdge = rotateEdgeLeft(boxEdge); + } else { + boxEdge = rotateEdgeRight(boxEdge); + } + break; + case "sideways-rl": + boxEdge = rotateEdgeRight(boxEdge); + break; + case "sideways-lr": + boxEdge = rotateEdgeLeft(boxEdge); + break; + default: + console.error(`Unexpected writing-mode: ${writingMode}`); + } + + switch (direction) { + case "ltr": + // This is the initial value. No further adjustment needed. + break; + case "rtl": + if (dimensionType === ROWS) { + boxEdge = reflectEdge(boxEdge); + } + break; + default: + console.error(`Unexpected direction: ${direction}`); + } + + return boxEdge; + } + + /** + * Render the grid line on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {String} lineType + * The grid line type - "edge", "explicit", or "implicit". + */ + renderLine(linePos, startPos, endPos, dimensionType, lineType) { + const { devicePixelRatio } = this.win; + const lineWidth = getDisplayPixelRatio(this.win); + const offset = (lineWidth / 2) % 1; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + endPos = Math.round(endPos); + + this.ctx.save(); + this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash); + this.ctx.translate(offset - canvasX, offset - canvasY); + + const lineOptions = { + matrix: this.currentMatrix, + }; + + if (this.options.showInfiniteLines) { + lineOptions.extendToBoundaries = [ + canvasX, + canvasY, + canvasX + CANVAS_SIZE, + canvasY + CANVAS_SIZE, + ]; + } + + if (dimensionType === COLUMNS) { + drawLine(this.ctx, linePos, startPos, linePos, endPos, lineOptions); + } else { + drawLine(this.ctx, startPos, linePos, endPos, linePos, lineOptions); + } + + this.ctx.strokeStyle = this.color; + this.ctx.globalAlpha = + GRID_LINES_PROPERTIES[lineType].alpha * this.globalAlpha; + + if (GRID_LINES_PROPERTIES[lineType].lineWidth) { + this.ctx.lineWidth = + GRID_LINES_PROPERTIES[lineType].lineWidth * devicePixelRatio; + } else { + this.ctx.lineWidth = lineWidth; + } + + this.ctx.stroke(); + this.ctx.restore(); + } + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {Object} quad.bounds + * The content bounds of the box model region quads. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + * @param {Number} endPos + * The end position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderLines(gridDimension, dimensionType, startPos, endPos) { + const { lines, tracks } = gridDimension; + const lastEdgeLineIndex = this.getLastEdgeLineIndex(tracks); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const linePos = line.start; + + if (i == 0 || i == lastEdgeLineIndex) { + this.renderLine(linePos, startPos, endPos, dimensionType, "edge"); + } else { + this.renderLine( + linePos, + startPos, + endPos, + dimensionType, + tracks[i - 1].type + ); + } + + // Render a second line to illustrate the gutter for non-zero breadth. + if (line.breadth > 0) { + this.renderGridGap( + linePos, + startPos, + endPos, + line.breadth, + dimensionType + ); + this.renderLine( + linePos + line.breadth, + startPos, + endPos, + dimensionType, + tracks[i].type + ); + } + } + } + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderLineNumbers(gridDimension, dimensionType, startPos) { + const { lines, tracks } = gridDimension; + + for (let i = 0, line; (line = lines[i++]); ) { + // If you place something using negative numbers, you can trigger some implicit + // grid creation above and to the left of the explicit grid (assuming a + // horizontal-tb writing mode). + // + // The first explicit grid line gets the number of 1, and any implicit grid lines + // before 1 get negative numbers. Since here we're rendering only the positive line + // numbers, we have to skip any implicit grid lines before the first one that is + // explicit. The API returns a 0 as the line's number for these implicit lines that + // occurs before the first explicit line. + if (line.number === 0) { + continue; + } + + // Check for overlapping lines by measuring the track width between them. + // We render a second box beneath the last overlapping + // line number to indicate there are lines beneath it. + const gridTrack = tracks[i - 1]; + + if (gridTrack) { + const { breadth } = gridTrack; + + if (breadth === 0) { + this.renderGridLineNumber( + line.number, + line.start, + startPos, + line.breadth, + dimensionType, + true + ); + continue; + } + } + + this.renderGridLineNumber( + line.number, + line.start, + startPos, + line.breadth, + dimensionType + ); + } + } + + /** + * Render the negative grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderNegativeLineNumbers(gridDimension, dimensionType, startPos) { + const { lines, tracks } = gridDimension; + + for (let i = 0, line; (line = lines[i++]); ) { + const linePos = line.start; + const negativeLineNumber = line.negativeNumber; + + // Don't render any negative line number greater than -1. + if (negativeLineNumber == 0) { + break; + } + + // Check for overlapping lines by measuring the track width between them. + // We render a second box beneath the last overlapping + // line number to indicate there are lines beneath it. + const gridTrack = tracks[i - 1]; + if (gridTrack) { + const { breadth } = gridTrack; + + // Ensure "-1" is always visible, since it is always the largest number. + if (breadth === 0 && negativeLineNumber != -1) { + this.renderGridLineNumber( + negativeLineNumber, + linePos, + startPos, + line.breadth, + dimensionType, + true + ); + continue; + } + } + + this.renderGridLineNumber( + negativeLineNumber, + linePos, + startPos, + line.breadth, + dimensionType + ); + } + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). Should be called whenever node's geometry + * or grid changes. + */ + _update() { + setIgnoreLayoutChanges(true); + + // Set z-index. + this.markup.content.setStyle("z-index", this.options.zIndex); + + const root = this.getElement("root"); + const cells = this.getElement("cells"); + const areas = this.getElement("areas"); + + // Set the grid cells and areas fill to the current grid colour. + cells.setAttribute("style", `fill: ${this.color}`); + areas.setAttribute("style", `fill: ${this.color}`); + + // Hide the root element and force the reflow in order to get the proper window's + // dimensions without increasing them. + root.setAttribute("style", "display: none"); + this.win.document.documentElement.offsetWidth; + this._winDimensions = getWindowDimensions(this.win); + const { width, height } = this._winDimensions; + + // Updates the <canvas> element's position and size. + // It also clear the <canvas>'s drawing context. + updateCanvasElement( + this.canvas, + this._canvasPosition, + this.win.devicePixelRatio + ); + + // Clear the grid area highlights. + this.clearGridAreas(); + this.clearGridCell(); + + // Update the current matrix used in our canvas' rendering. + const { currentMatrix, hasNodeTransformations } = getCurrentMatrix( + this.currentNode, + this.win + ); + this.currentMatrix = currentMatrix; + this.hasNodeTransformations = hasNodeTransformations; + + // Start drawing the grid fragments. + for (let i = 0; i < this.gridData.length; i++) { + this.renderFragment(this.gridData[i]); + } + + // Display the grid area highlights if needed. + if (this.options.showAllGridAreas) { + this.showAllGridAreas(); + } else if (this.options.showGridArea) { + this.showGridArea(this.options.showGridArea); + } + + // Display the grid cell highlights if needed. + if (this.options.showGridCell) { + this.showGridCell(this.options.showGridCell); + } + + // Display the grid line names if needed. + if (this.options.showGridLineNames) { + this.showGridLineNames(this.options.showGridLineNames); + } + + this._showGrid(); + this._showGridElements(); + + root.setAttribute( + "style", + `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden` + ); + + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + return true; + } + + /** + * Update the grid information displayed in the grid area info bar. + * + * @param {GridArea} area + * The grid area object. + * @param {Object} bounds + * A DOMRect-like object represent the grid area rectangle. + */ + _updateGridAreaInfobar(area, bounds) { + const { width, height } = bounds; + const dim = + parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + + this.getElement("area-infobar-name").setTextContent(area.name); + this.getElement("area-infobar-dimensions").setTextContent(dim); + + const container = this.getElement("area-infobar-container"); + moveInfobar(container, bounds, this.win, { + position: "bottom", + hideIfOffscreen: true, + }); + } + + /** + * Update the grid information displayed in the grid cell info bar. + * + * @param {Number} rowNumber + * The grid cell's row number. + * @param {Number} columnNumber + * The grid cell's column number. + * @param {Object} bounds + * A DOMRect-like object represent the grid cell rectangle. + */ + _updateGridCellInfobar(rowNumber, columnNumber, bounds) { + const { width, height } = bounds; + const dim = + parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + const position = L10N.getFormatStr( + "grid.rowColumnPositions", + rowNumber, + columnNumber + ); + + this.getElement("cell-infobar-position").setTextContent(position); + this.getElement("cell-infobar-dimensions").setTextContent(dim); + + const container = this.getElement("cell-infobar-container"); + moveInfobar(container, bounds, this.win, { + position: "top", + hideIfOffscreen: true, + }); + } + + /** + * Update the grid information displayed in the grid line info bar. + * + * @param {String} gridLineNames + * Comma-separated string of names for the grid line. + * @param {Number} gridLineNumber + * The grid line number. + * @param {Number} x + * The x-coordinate of the grid line. + * @param {Number} y + * The y-coordinate of the grid line. + */ + _updateGridLineInfobar(gridLineNames, gridLineNumber, x, y) { + this.getElement("line-infobar-number").setTextContent(gridLineNumber); + this.getElement("line-infobar-names").setTextContent(gridLineNames); + + const container = this.getElement("line-infobar-container"); + moveInfobar( + container, + getBoundsFromPoints([ + { x, y }, + { x, y }, + { x, y }, + { x, y }, + ]), + this.win + ); + } +} + +exports.CssGridHighlighter = CssGridHighlighter; diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js new file mode 100644 index 0000000000..b55c2b229f --- /dev/null +++ b/devtools/server/actors/highlighters/css-transform.js @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + AutoRefreshHighlighter, +} = require("devtools/server/actors/highlighters/auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, +} = require("devtools/server/actors/highlighters/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. + */ +class CssTransformHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "css-transform-"; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + // The root wrapper is used to unzoom the highlighter when needed. + const rootWrapper = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: rootWrapper, + attributes: { + id: "elements", + hidden: "true", + width: "100%", + height: "100%", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Add a marker tag to the svg root for the arrow tip + this.markerId = "arrow-marker-" + MARKER_COUNTER; + MARKER_COUNTER++; + const marker = this.markup.createSVGNode({ + nodeType: "marker", + parent: svg, + attributes: { + id: this.markerId, + markerWidth: "10", + markerHeight: "5", + orient: "auto", + markerUnits: "strokeWidth", + refX: "10", + refY: "5", + viewBox: "0 0 10 10", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createSVGNode({ + nodeType: "path", + parent: marker, + attributes: { + d: "M 0 0 L 10 5 L 0 10 z", + fill: "#08C", + }, + }); + + const shapesGroup = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + }); + + // Create the 2 polygons (transformed and untransformed) + this.markup.createSVGNode({ + nodeType: "polygon", + parent: shapesGroup, + attributes: { + id: "untransformed", + class: "untransformed", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createSVGNode({ + nodeType: "polygon", + parent: shapesGroup, + attributes: { + id: "transformed", + class: "transformed", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Create the arrows + for (const nb of ["1", "2", "3", "4"]) { + this.markup.createSVGNode({ + nodeType: "line", + parent: shapesGroup, + attributes: { + id: "line" + nb, + class: "line", + "marker-end": "url(#" + this.markerId + ")", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + return container; + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + AutoRefreshHighlighter.prototype.destroy.call(this); + this.markup.destroy(); + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Show the highlighter on a given node + */ + _show() { + if (!this._isTransformed(this.currentNode)) { + this.hide(); + return false; + } + + return this._update(); + } + + /** + * Checks if the supplied node is transformed and not inline + */ + _isTransformed(node) { + const style = getComputedStyle(node); + return style && style.transform !== "none" && style.display !== "inline"; + } + + _setPolygonPoints(quad, id) { + const points = []; + for (const point of ["p1", "p2", "p3", "p4"]) { + points.push(quad[point].x + "," + quad[point].y); + } + this.getElement(id).setAttribute("points", points.join(" ")); + } + + _setLinePoints(p1, p2, id) { + const line = this.getElement(id); + line.setAttribute("x1", p1.x); + line.setAttribute("y1", p1.y); + line.setAttribute("x2", p2.x); + line.setAttribute("y2", p2.y); + + const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + if (dist < ARROW_LINE_MIN_DISTANCE) { + line.removeAttribute("marker-end"); + } else { + line.setAttribute("marker-end", "url(#" + this.markerId + ")"); + } + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update() { + setIgnoreLayoutChanges(true); + + // Getting the points for the transformed shape + const quads = this.currentQuads.border; + if ( + !quads.length || + quads[0].bounds.width <= 0 || + quads[0].bounds.height <= 0 + ) { + this._hideShapes(); + return false; + } + + const [quad] = quads; + + // Getting the points for the untransformed shape + const untransformedQuad = getNodeBounds(this.win, this.currentNode); + + this._setPolygonPoints(quad, "transformed"); + this._setPolygonPoints(untransformedQuad, "untransformed"); + for (const nb of ["1", "2", "3", "4"]) { + this._setLinePoints( + untransformedQuad["p" + nb], + quad["p" + nb], + "line" + nb + ); + } + + // Adapt to the current zoom + this.markup.scaleRootElement( + this.currentNode, + this.ID_CLASS_PREFIX + "root" + ); + + this._showShapes(); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + return true; + } + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideShapes(); + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + _hideShapes() { + this.getElement("elements").setAttribute("hidden", "true"); + } + + _showShapes() { + this.getElement("elements").removeAttribute("hidden"); + } +} + +exports.CssTransformHighlighter = CssTransformHighlighter; diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js new file mode 100644 index 0000000000..564f85394e --- /dev/null +++ b/devtools/server/actors/highlighters/eye-dropper.js @@ -0,0 +1,581 @@ +/* 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, +} = require("devtools/server/actors/highlighters/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-shared/locale/eyedropper.properties" + ) +); + +const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom"; +const FORMAT_PREF = "devtools.defaultColorUnit"; +// Width of the canvas. +const MAGNIFIER_WIDTH = 96; +// Height of the canvas. +const MAGNIFIER_HEIGHT = 96; +// Start position, when the tool is first shown. This should match the top/left position +// defined in CSS. +const DEFAULT_START_POS_X = 100; +const DEFAULT_START_POS_Y = 100; +// How long to wait before closing after copy. +const CLOSE_DELAY = 750; + +/** + * The EyeDropper 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) + ); + this.isReady = this.markup.initialize(); + + // Get a couple of settings from prefs. + this.format = Services.prefs.getCharPref(FORMAT_PREF); + this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF); +} + +EyeDropper.prototype = { + ID_CLASS_PREFIX: "eye-dropper-", + + get win() { + return this.highlighterEnv.window; + }, + + _buildMarkup() { + // Highlighter main container. + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + // Wrapper element. + const wrapper = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // The magnifier canvas element. + this.markup.createNode({ + parent: wrapper, + nodeType: "canvas", + attributes: { + id: "canvas", + class: "canvas", + width: MAGNIFIER_WIDTH, + height: MAGNIFIER_HEIGHT, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // The color label element. + const colorLabelContainer = this.markup.createNode({ + parent: wrapper, + attributes: { class: "color-container" }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "div", + parent: colorLabelContainer, + attributes: { id: "color-preview", class: "color-preview" }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "div", + parent: colorLabelContainer, + attributes: { id: "color-value", class: "color-value" }, + prefix: this.ID_CLASS_PREFIX, + }); + + return container; + }, + + destroy() { + this.hide(); + this.markup.destroy(); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Show the eye-dropper highlighter. + * @param {DOMNode} node The node which document the highlighter should be inserted in. + * @param {Object} options The options object may contain the following properties: + * - {Boolean} copyOnSelect Whether selecting a color should copy it to the clipboard. + */ + 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. + const { 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(); + + // Make sure we receive mouse events when the debugger has paused execution + // in the page. + this.win.document.setSuppressedEventListener(this); + + return true; + }, + + /** + * Hide the eye-dropper highlighter. + */ + hide() { + if (this.highlighterEnv.isXUL) { + return; + } + + this.pageImage = null; + + const { pageListenerTarget } = this.highlighterEnv; + + if (pageListenerTarget) { + 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"); + + this.win.document.setSuppressedEventListener(null); + }, + + prepareImageCapture() { + // Get the image data from the content window. + const 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() { + const pos = this.centerCell * this.cellSize + this.cellSize / 2; + const rgb = this.ctx.getImageData(pos, pos, 1, 1).data; + return rgb; + }, + + draw() { + // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove. + if (!this.pageImage) { + return; + } + + const { width, height, x, y } = this.magnifiedArea; + + const zoomedWidth = width / this.eyeDropperZoomLevel; + const zoomedHeight = height / this.eyeDropperZoomLevel; + + const sx = x - zoomedWidth / 2; + const sy = y - zoomedHeight / 2; + const sw = zoomedWidth; + const sh = zoomedHeight; + + this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height); + + // Draw the grid on top, but only at 3x or more, otherwise it's too busy. + if (this.eyeDropperZoomLevel > 2) { + this.drawGrid(); + } + + this.drawCrosshair(); + + // Update the color preview and value. + const rgb = this.centerColor; + this.getElement("color-preview").setAttribute( + "style", + `background-color:${toColorString(rgb, "rgb")};` + ); + this.getElement("color-value").setTextContent( + toColorString(rgb, this.format) + ); + }, + + /** + * Draw a grid on the canvas representing pixel boundaries. + */ + drawGrid() { + const { width, height } = this.magnifiedArea; + + this.ctx.lineWidth = 1; + this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)"; + + for (let i = 0; i < width; i += this.cellSize) { + this.ctx.beginPath(); + this.ctx.moveTo(i - 0.5, 0); + this.ctx.lineTo(i - 0.5, height); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(0, i - 0.5); + this.ctx.lineTo(width, i - 0.5); + this.ctx.stroke(); + } + }, + + /** + * Draw a box on the canvas to highlight the center cell. + */ + drawCrosshair() { + const pos = this.centerCell * this.cellSize; + + this.ctx.lineWidth = 1; + this.ctx.lineJoin = "miter"; + this.ctx.strokeStyle = "rgba(0, 0, 0, 1)"; + this.ctx.strokeRect( + pos - 1.5, + pos - 1.5, + this.cellSize + 2, + this.cellSize + 2 + ); + + this.ctx.strokeStyle = "rgba(255, 255, 255, 1)"; + this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize); + }, + + handleEvent(e) { + switch (e.type) { + case "mousemove": + // We might be getting an event from a child frame, so account for the offset. + const [xOffset, yOffset] = getFrameOffsets(this.win, e.target); + const x = xOffset + e.pageX - this.win.scrollX; + const y = yOffset + e.pageY - this.win.scrollY; + // Update the zoom area. + this.magnifiedArea.x = x * this.pageZoom; + this.magnifiedArea.y = y * this.pageZoom; + // Redraw the portion of the screenshot that is now under the mouse. + this.draw(); + // And move the eye-dropper's UI so it follows the mouse. + this.moveTo(x, y); + break; + // Note: when events are suppressed we will only get mousedown/mouseup and + // not any click events. + case "click": + case "mouseup": + this.selectColor(); + break; + case "keydown": + this.handleKeyDown(e); + break; + case "DOMMouseScroll": + // Prevent scrolling. That's because we only took a screenshot of the viewport, so + // scrolling out of the viewport wouldn't draw the expected things. In the future + // we can take the screenshot again on scroll, but for now it doesn't seem + // important. + e.preventDefault(); + break; + case "FullZoomChange": + this.hide(); + this.show(); + break; + } + }, + + moveTo(x, y) { + const root = this.getElement("root"); + root.setAttribute("style", `top:${y}px;left:${x}px;`); + + // Move the label container to the top if the magnifier is close to the bottom edge. + if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) { + root.setAttribute("top", ""); + } else { + root.removeAttribute("top"); + } + + // Also offset the label container to the right or left if the magnifier is close to + // the edge. + root.removeAttribute("left"); + root.removeAttribute("right"); + if (x <= MAGNIFIER_WIDTH) { + root.setAttribute("right", ""); + } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) { + root.setAttribute("left", ""); + } + }, + + /** + * Select the current color that's being previewed. Depending on the current options, + * selecting might mean copying to the clipboard and closing the + */ + selectColor() { + let onColorSelected = Promise.resolve(); + if (this.options.copyOnSelect) { + onColorSelected = this.copyColor(); + } + + this.emit("selected", toColorString(this.centerColor, this.format)); + onColorSelected.then(() => this.hide(), console.error); + }, + + /** + * Handler for the keydown event. Either select the color or move the panel in a + * direction depending on the key pressed. + */ + handleKeyDown(e) { + // Bail out early if any unsupported modifier is used, so that we let + // keyboard shortcuts through. + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + if (e.keyCode === e.DOM_VK_RETURN) { + this.selectColor(); + e.preventDefault(); + return; + } + + if (e.keyCode === e.DOM_VK_ESCAPE) { + this.emit("canceled"); + this.hide(); + e.preventDefault(); + return; + } + + let offsetX = 0; + let offsetY = 0; + let modifier = 1; + + if (e.keyCode === e.DOM_VK_LEFT) { + offsetX = -1; + } else if (e.keyCode === e.DOM_VK_RIGHT) { + offsetX = 1; + } else if (e.keyCode === e.DOM_VK_UP) { + offsetY = -1; + } else if (e.keyCode === e.DOM_VK_DOWN) { + offsetY = 1; + } + + if (e.shiftKey) { + modifier = 10; + } + + offsetY *= modifier; + offsetX *= modifier; + + if (offsetX !== 0 || offsetY !== 0) { + this.magnifiedArea.x = cap( + this.magnifiedArea.x + offsetX, + 0, + this.win.innerWidth * this.pageZoom + ); + this.magnifiedArea.y = cap( + this.magnifiedArea.y + offsetY, + 0, + this.win.innerHeight * this.pageZoom + ); + + this.draw(); + + this.moveTo( + this.magnifiedArea.x / this.pageZoom, + this.magnifiedArea.y / this.pageZoom + ); + + e.preventDefault(); + } + }, + + /** + * Copy the currently inspected color to the clipboard. + * @return {Promise} Resolves when the copy has been done (after a delay that is used to + * let users know that something was copied). + */ + copyColor() { + // Copy to the clipboard. + const color = toColorString(this.centerColor, this.format); + clipboardHelper.copyString(color); + + // Provide some feedback. + this.getElement("color-value").setTextContent( + "✓ " + l10n.GetStringFromName("colorValue.copied") + ); + + // Hide the tool after a delay. + clearTimeout(this._copyTimeout); + return new Promise(resolve => { + this._copyTimeout = setTimeout(resolve, CLOSE_DELAY); + }); + }, +}; + +exports.EyeDropper = EyeDropper; + +/** + * Draw the visible portion of the window on a canvas and get the resulting ImageData. + * @param {Window} win + * @return {ImageData} The image data for the window. + */ +function getWindowAsImageData(win) { + const canvas = win.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + const scale = getCurrentZoom(win); + const width = win.innerWidth; + const height = win.innerHeight; + canvas.width = width * scale; + canvas.height = height * scale; + canvas.mozOpaque = true; + + const ctx = canvas.getContext("2d"); + + ctx.scale(scale, scale); + ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff"); + + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * Get a formatted CSS color string from a color value. + * @param {array} rgb Rgb values of a color to format. + * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name". + * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)". + */ +function toColorString(rgb, format) { + const [r, g, b] = rgb; + + switch (format) { + case "hex": + return hexString(rgb); + case "rgb": + return "rgb(" + r + ", " + g + ", " + b + ")"; + case "hsl": + const [h, s, l] = rgbToHsl(rgb); + return "hsl(" + h + ", " + s + "%, " + l + "%)"; + case "name": + const str = rgbToColorName(r, g, b) || hexString(rgb); + return str; + default: + return hexString(rgb); + } +} + +/** + * Produce a hex-formatted color string from rgb values. + * @param {array} rgb Rgb values of color to stringify. + * @return {string} Hex formatted string for color, e.g. "#FFEE00". + */ +function hexString([r, g, b]) { + const val = (1 << 24) + (r << 16) + (g << 8) + (b << 0); + return "#" + val.toString(16).substr(-6); +} + +function cap(value, min, max) { + return Math.max(min, Math.min(value, max)); +} diff --git a/devtools/server/actors/highlighters/flexbox.js b/devtools/server/actors/highlighters/flexbox.js new file mode 100644 index 0000000000..2b4e751a06 --- /dev/null +++ b/devtools/server/actors/highlighters/flexbox.js @@ -0,0 +1,1037 @@ +/* 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 { apply } = require("devtools/shared/layout/dom-matrix-2d"); +const { + CANVAS_SIZE, + DEFAULT_COLOR, + clearRect, + drawLine, + drawRect, + getCurrentMatrix, + updateCanvasElement, + updateCanvasPosition, +} = require("devtools/server/actors/highlighters/utils/canvas"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { + getAbsoluteScrollOffsetsForNode, + getCurrentZoom, + getDisplayPixelRatio, + getUntransformedQuad, + getWindowDimensions, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); + +const FLEXBOX_LINES_PROPERTIES = { + edge: { + lineDash: [5, 3], + }, + item: { + lineDash: [0, 0], + }, + alignItems: { + lineDash: [0, 0], + }, +}; + +const FLEXBOX_CONTAINER_PATTERN_LINE_DASH = [5, 3]; // px +const FLEXBOX_CONTAINER_PATTERN_WIDTH = 14; // px +const FLEXBOX_CONTAINER_PATTERN_HEIGHT = 14; // px +const FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH = 7; // px +const FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT = 7; // px + +/** + * Cached used by `FlexboxHighlighter.getFlexContainerPattern`. + */ +const gCachedFlexboxPattern = new Map(); + +const FLEXBOX = "flexbox"; +const JUSTIFY_CONTENT = "justify-content"; + +/** + * The FlexboxHighlighter is the class that overlays a visual canvas on top of + * display: [inline-]flex elements. + * + * @param {String} options.color + * The color that should be used to draw the highlighter for this flexbox. + * Structure: + * <div class="highlighter-container"> + * <div id="flexbox-root" class="flexbox-root"> + * <canvas id="flexbox-canvas" + * class="flexbox-canvas" + * width="4096" + * height="4096" + * hidden="true"> + * </canvas> + * </div> + * </div> + */ +class FlexboxHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "flexbox-"; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + + // Initialize the <canvas> position to the top left corner of the page + this._canvasPosition = { + x: 0, + y: 0, + }; + + this._ignoreZoom = true; + + // Calling `updateCanvasPosition` anyway since the highlighter could be initialized + // on a page that has scrolled already. + updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // We use a <canvas> element because there is an arbitrary number of items and texts + // to draw which wouldn't be possible with HTML or SVG without having to insert and + // remove the whole markup on every update. + this.markup.createNode({ + parent: root, + nodeType: "canvas", + attributes: { + id: "canvas", + class: "canvas", + hidden: "true", + width: CANVAS_SIZE, + height: CANVAS_SIZE, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return container; + } + + clearCache() { + gCachedFlexboxPattern.clear(); + } + + destroy() { + const { highlighterEnv } = this; + highlighterEnv.off("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + } + + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + this.clearCache(); + + this.axes = null; + this.crossAxisDirection = null; + this.flexData = null; + this.mainAxisDirection = null; + this.transform = null; + + AutoRefreshHighlighter.prototype.destroy.call(this); + } + + /** + * Draw the justify content for a given flex item (left, top, right, bottom) position. + */ + drawJustifyContent(left, top, right, bottom) { + const { devicePixelRatio } = this.win; + this.ctx.fillStyle = this.getJustifyContentPattern(devicePixelRatio); + drawRect(this.ctx, left, top, right, bottom, this.currentMatrix); + this.ctx.fill(); + } + + get canvas() { + return this.getElement("canvas"); + } + + get color() { + return this.options.color || DEFAULT_COLOR; + } + + get container() { + return this.currentNode; + } + + get ctx() { + return this.canvas.getCanvasContext("2d"); + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Gets the flexbox container pattern used to render the container regions. + * + * @param {Number} devicePixelRatio + * The device pixel ratio we want the pattern for. + * @return {CanvasPattern} flex container pattern. + */ + getFlexContainerPattern(devicePixelRatio) { + let flexboxPatternMap = null; + + if (gCachedFlexboxPattern.has(devicePixelRatio)) { + flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio); + } else { + flexboxPatternMap = new Map(); + } + + if (gCachedFlexboxPattern.has(FLEXBOX)) { + return gCachedFlexboxPattern.get(FLEXBOX); + } + + // Create the diagonal lines pattern for the rendering the flexbox gaps. + const canvas = this.markup.createNode({ nodeType: "canvas" }); + const width = (canvas.width = + FLEXBOX_CONTAINER_PATTERN_WIDTH * devicePixelRatio); + const height = (canvas.height = + FLEXBOX_CONTAINER_PATTERN_HEIGHT * devicePixelRatio); + + const ctx = canvas.getContext("2d"); + ctx.save(); + ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(0.5, 0.5); + + ctx.moveTo(0, 0); + ctx.lineTo(width, height); + + ctx.strokeStyle = this.color; + ctx.stroke(); + ctx.restore(); + + const pattern = ctx.createPattern(canvas, "repeat"); + flexboxPatternMap.set(FLEXBOX, pattern); + gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap); + + return pattern; + } + + /** + * Gets the flexbox justify content pattern used to render the justify content regions. + * + * @param {Number} devicePixelRatio + * The device pixel ratio we want the pattern for. + * @return {CanvasPattern} flex justify content pattern. + */ + getJustifyContentPattern(devicePixelRatio) { + let flexboxPatternMap = null; + + if (gCachedFlexboxPattern.has(devicePixelRatio)) { + flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio); + } else { + flexboxPatternMap = new Map(); + } + + if (flexboxPatternMap.has(JUSTIFY_CONTENT)) { + return flexboxPatternMap.get(JUSTIFY_CONTENT); + } + + // Create the inversed diagonal lines pattern + // for the rendering the justify content gaps. + const canvas = this.markup.createNode({ nodeType: "canvas" }); + const zoom = getCurrentZoom(this.win); + const width = (canvas.width = + FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH * devicePixelRatio * zoom); + const height = (canvas.height = + FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT * devicePixelRatio * zoom); + + const ctx = canvas.getContext("2d"); + ctx.save(); + ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(0.5, 0.5); + + ctx.moveTo(0, height); + ctx.lineTo(width, 0); + + ctx.strokeStyle = this.color; + ctx.stroke(); + ctx.restore(); + + const pattern = ctx.createPattern(canvas, "repeat"); + flexboxPatternMap.set(JUSTIFY_CONTENT, pattern); + gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap); + + return pattern; + } + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the + * element's quads have changed. Override it so it also returns true if the + * flex container and its flex items have changed. + */ + _hasMoved() { + const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + if (!this.computedStyle) { + this.computedStyle = getComputedStyle(this.container); + } + + const flex = this.container.getAsFlexContainer(); + + const oldCrossAxisDirection = this.crossAxisDirection; + this.crossAxisDirection = flex ? flex.crossAxisDirection : null; + const newCrossAxisDirection = this.crossAxisDirection; + + const oldMainAxisDirection = this.mainAxisDirection; + this.mainAxisDirection = flex ? flex.mainAxisDirection : null; + const newMainAxisDirection = this.mainAxisDirection; + + // Concatenate the axes to simplify conditionals. + this.axes = `${this.mainAxisDirection} ${this.crossAxisDirection}`; + + const oldFlexData = this.flexData; + this.flexData = getFlexData(this.container); + const hasFlexDataChanged = compareFlexData(oldFlexData, this.flexData); + + const oldAlignItems = this.alignItemsValue; + this.alignItemsValue = this.computedStyle.alignItems; + const newAlignItems = this.alignItemsValue; + + const oldFlexDirection = this.flexDirection; + this.flexDirection = this.computedStyle.flexDirection; + const newFlexDirection = this.flexDirection; + + const oldFlexWrap = this.flexWrap; + this.flexWrap = this.computedStyle.flexWrap; + const newFlexWrap = this.flexWrap; + + const oldJustifyContent = this.justifyContentValue; + this.justifyContentValue = this.computedStyle.justifyContent; + const newJustifyContent = this.justifyContentValue; + + const oldTransform = this.transformValue; + this.transformValue = this.computedStyle.transform; + const newTransform = this.transformValue; + + return ( + hasMoved || + hasFlexDataChanged || + oldAlignItems !== newAlignItems || + oldFlexDirection !== newFlexDirection || + oldFlexWrap !== newFlexWrap || + oldJustifyContent !== newJustifyContent || + oldCrossAxisDirection !== newCrossAxisDirection || + oldMainAxisDirection !== newMainAxisDirection || + oldTransform !== newTransform + ); + } + + _hide() { + this.alignItemsValue = null; + this.computedStyle = null; + this.flexData = null; + this.flexDirection = null; + this.flexWrap = null; + this.justifyContentValue = null; + + setIgnoreLayoutChanges(true); + this._hideFlexbox(); + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + } + + _hideFlexbox() { + this.getElement("canvas").setAttribute("hidden", "true"); + } + + /** + * The <canvas>'s position needs to be updated if the page scrolls too much, in order + * to give the illusion that it always covers the viewport. + */ + _scrollUpdate() { + const hasUpdated = updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + + if (hasUpdated) { + this._update(); + } + } + + _show() { + this._hide(); + return this._update(); + } + + _showFlexbox() { + this.getElement("canvas").removeAttribute("hidden"); + } + + /** + * If a page hide event is triggered for current window's highlighter, hide the + * highlighter. + */ + onPageHide({ target }) { + if (target.defaultView === this.win) { + this.hide(); + } + } + + /** + * Called when the page will-navigate. Used to hide the flexbox highlighter and clear + * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the + * next time. + */ + onWillNavigate({ isTopLevel }) { + this.clearCache(); + + if (isTopLevel) { + this.hide(); + } + } + + renderFlexContainer() { + if (!this.currentQuads.content || !this.currentQuads.content[0]) { + return; + } + + const { devicePixelRatio } = this.win; + const containerQuad = getUntransformedQuad(this.container, "content"); + const { width, height } = containerQuad.getBounds(); + + this.setupCanvas({ + lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash, + lineWidthMultiplier: 2, + }); + + this.ctx.fillStyle = this.getFlexContainerPattern(devicePixelRatio); + + drawRect(this.ctx, 0, 0, width, height, this.currentMatrix); + + // Find current angle of outer flex element by measuring the angle of two arbitrary + // points, then rotate canvas, so the hash pattern stays 45deg to the boundary. + const p1 = apply(this.currentMatrix, [0, 0]); + const p2 = apply(this.currentMatrix, [1, 0]); + const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]); + this.ctx.rotate(angleRad); + + this.ctx.fill(); + this.ctx.stroke(); + this.ctx.restore(); + } + + renderFlexItems() { + if ( + !this.flexData || + !this.currentQuads.content || + !this.currentQuads.content[0] + ) { + return; + } + + this.setupCanvas({ + lineDash: FLEXBOX_LINES_PROPERTIES.item.lineDash, + }); + + for (const flexLine of this.flexData.lines) { + for (const flexItem of flexLine.items) { + const { left, top, right, bottom } = flexItem.rect; + + clearRect(this.ctx, left, top, right, bottom, this.currentMatrix); + drawRect(this.ctx, left, top, right, bottom, this.currentMatrix); + this.ctx.stroke(); + } + } + + this.ctx.restore(); + } + + renderFlexLines() { + if ( + !this.flexData || + !this.currentQuads.content || + !this.currentQuads.content[0] + ) { + return; + } + + const lineWidth = getDisplayPixelRatio(this.win); + const options = { matrix: this.currentMatrix }; + const { + width: containerWidth, + height: containerHeight, + } = getUntransformedQuad(this.container, "content").getBounds(); + + this.setupCanvas({ + useContainerScrollOffsets: true, + }); + + for (const flexLine of this.flexData.lines) { + const { crossStart, crossSize } = flexLine; + + switch (this.axes) { + case "horizontal-lr vertical-tb": + case "horizontal-lr vertical-bt": + case "horizontal-rl vertical-tb": + case "horizontal-rl vertical-bt": + clearRect( + this.ctx, + 0, + crossStart, + containerWidth, + crossStart + crossSize, + this.currentMatrix + ); + + // Avoid drawing the start flex line when they overlap with the flex container. + if (crossStart != 0) { + drawLine( + this.ctx, + 0, + crossStart, + containerWidth, + crossStart, + options + ); + this.ctx.stroke(); + } + + // Avoid drawing the end flex line when they overlap with the flex container. + if (crossStart + crossSize < containerHeight - lineWidth * 2) { + drawLine( + this.ctx, + 0, + crossStart + crossSize, + containerWidth, + crossStart + crossSize, + options + ); + this.ctx.stroke(); + } + break; + case "vertical-tb horizontal-lr": + case "vertical-bt horizontal-rl": + clearRect( + this.ctx, + crossStart, + 0, + crossStart + crossSize, + containerHeight, + this.currentMatrix + ); + + // Avoid drawing the start flex line when they overlap with the flex container. + if (crossStart != 0) { + drawLine( + this.ctx, + crossStart, + 0, + crossStart, + containerHeight, + options + ); + this.ctx.stroke(); + } + + // Avoid drawing the end flex line when they overlap with the flex container. + if (crossStart + crossSize < containerWidth - lineWidth * 2) { + drawLine( + this.ctx, + crossStart + crossSize, + 0, + crossStart + crossSize, + containerHeight, + options + ); + this.ctx.stroke(); + } + break; + case "vertical-bt horizontal-lr": + case "vertical-tb horizontal-rl": + clearRect( + this.ctx, + containerWidth - crossStart, + 0, + containerWidth - crossStart - crossSize, + containerHeight, + this.currentMatrix + ); + + // Avoid drawing the start flex line when they overlap with the flex container. + if (crossStart != 0) { + drawLine( + this.ctx, + containerWidth - crossStart, + 0, + containerWidth - crossStart, + containerHeight, + options + ); + this.ctx.stroke(); + } + + // Avoid drawing the end flex line when they overlap with the flex container. + if (crossStart + crossSize < containerWidth - lineWidth * 2) { + drawLine( + this.ctx, + containerWidth - crossStart - crossSize, + 0, + containerWidth - crossStart - crossSize, + containerHeight, + options + ); + this.ctx.stroke(); + } + break; + } + } + + this.ctx.restore(); + } + + /** + * Clear the whole alignment container along the main axis for each flex item. + */ + // eslint-disable-next-line complexity + renderJustifyContent() { + if ( + !this.flexData || + !this.currentQuads.content || + !this.currentQuads.content[0] + ) { + return; + } + + const { + width: containerWidth, + height: containerHeight, + } = getUntransformedQuad(this.container, "content").getBounds(); + + this.setupCanvas({ + lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash, + offset: (getDisplayPixelRatio(this.win) / 2) % 1, + skipLineAndStroke: true, + useContainerScrollOffsets: true, + }); + + for (const flexLine of this.flexData.lines) { + const { crossStart, crossSize } = flexLine; + let mainStart = 0; + + // In these two situations mainStart goes from right to left so set it's + // value as appropriate. + if ( + this.axes === "horizontal-lr vertical-bt" || + this.axes === "horizontal-rl vertical-tb" + ) { + mainStart = containerWidth; + } + + for (const flexItem of flexLine.items) { + const { left, top, right, bottom } = flexItem.rect; + + switch (this.axes) { + case "horizontal-lr vertical-tb": + case "horizontal-rl vertical-bt": + this.drawJustifyContent( + mainStart, + crossStart, + left, + crossStart + crossSize + ); + mainStart = right; + break; + case "horizontal-lr vertical-bt": + case "horizontal-rl vertical-tb": + this.drawJustifyContent( + right, + crossStart, + mainStart, + crossStart + crossSize + ); + mainStart = left; + break; + case "vertical-tb horizontal-lr": + case "vertical-bt horizontal-rl": + this.drawJustifyContent( + crossStart, + mainStart, + crossStart + crossSize, + top + ); + mainStart = bottom; + break; + case "vertical-bt horizontal-lr": + case "vertical-tb horizontal-rl": + this.drawJustifyContent( + containerWidth - crossStart - crossSize, + mainStart, + containerWidth - crossStart, + top + ); + mainStart = bottom; + break; + } + } + + // Draw the last justify-content area after the last flex item. + switch (this.axes) { + case "horizontal-lr vertical-tb": + case "horizontal-rl vertical-bt": + this.drawJustifyContent( + mainStart, + crossStart, + containerWidth, + crossStart + crossSize + ); + break; + case "horizontal-lr vertical-bt": + case "horizontal-rl vertical-tb": + this.drawJustifyContent( + 0, + crossStart, + mainStart, + crossStart + crossSize + ); + break; + case "vertical-tb horizontal-lr": + case "vertical-bt horizontal-rl": + this.drawJustifyContent( + crossStart, + mainStart, + crossStart + crossSize, + containerHeight + ); + break; + case "vertical-bt horizontal-lr": + case "vertical-tb horizontal-rl": + this.drawJustifyContent( + containerWidth - crossStart - crossSize, + mainStart, + containerWidth - crossStart, + containerHeight + ); + break; + } + } + + this.ctx.restore(); + } + + /** + * Set up the canvas with the given options prior to drawing. + * + * @param {String} [options.lineDash = null] + * An Array of numbers that specify distances to alternately draw a + * line and a gap (in coordinate space units). If the number of + * elements in the array is odd, the elements of the array get copied + * and concatenated. For example, [5, 15, 25] will become + * [5, 15, 25, 5, 15, 25]. If the array is empty, the line dash list is + * cleared and line strokes return to being solid. + * + * We use the following constants here: + * FLEXBOX_LINES_PROPERTIES.edge.lineDash, + * FLEXBOX_LINES_PROPERTIES.item.lineDash + * FLEXBOX_LINES_PROPERTIES.alignItems.lineDash + * @param {Number} [options.lineWidthMultiplier = 1] + * The width of the line. + * @param {Number} [options.offset = `(displayPixelRatio / 2) % 1`] + * The single line width used to obtain a crisp line. + * @param {Boolean} [options.skipLineAndStroke = false] + * Skip the setting of lineWidth and strokeStyle. + * @param {Boolean} [options.useContainerScrollOffsets = false] + * Take the flexbox container's scroll and zoom offsets into account. + * This is needed for drawing flex lines and justify content when the + * flexbox container itself is display:scroll. + */ + setupCanvas({ + lineDash = null, + lineWidthMultiplier = 1, + offset = (getDisplayPixelRatio(this.win) / 2) % 1, + skipLineAndStroke = false, + useContainerScrollOffsets = false, + }) { + const { devicePixelRatio } = this.win; + const lineWidth = getDisplayPixelRatio(this.win); + const zoom = getCurrentZoom(this.win); + const style = getComputedStyle(this.container); + const position = style.position; + let offsetX = this._canvasPosition.x; + let offsetY = this._canvasPosition.y; + + if (useContainerScrollOffsets) { + offsetX += this.container.scrollLeft / zoom; + offsetY += this.container.scrollTop / zoom; + } + + // If the flexbox container is position:fixed we need to subtract the scroll + // positions of all ancestral elements. + if (position === "fixed") { + const { scrollLeft, scrollTop } = getAbsoluteScrollOffsetsForNode( + this.container + ); + offsetX -= scrollLeft / zoom; + offsetY -= scrollTop / zoom; + } + + const canvasX = Math.round(offsetX * devicePixelRatio * zoom); + const canvasY = Math.round(offsetY * devicePixelRatio * zoom); + + this.ctx.save(); + this.ctx.translate(offset - canvasX, offset - canvasY); + + if (lineDash) { + this.ctx.setLineDash(lineDash); + } + + if (!skipLineAndStroke) { + this.ctx.lineWidth = lineWidth * lineWidthMultiplier; + this.ctx.strokeStyle = this.color; + } + } + + _update() { + setIgnoreLayoutChanges(true); + + const root = this.getElement("root"); + + // Hide the root element and force the reflow in order to get the proper window's + // dimensions without increasing them. + root.setAttribute("style", "display: none"); + this.win.document.documentElement.offsetWidth; + this._winDimensions = getWindowDimensions(this.win); + const { width, height } = this._winDimensions; + + // Updates the <canvas> element's position and size. + // It also clear the <canvas>'s drawing context. + updateCanvasElement( + this.canvas, + this._canvasPosition, + this.win.devicePixelRatio, + { + zoomWindow: this.win, + } + ); + + // Update the current matrix used in our canvas' rendering + const { currentMatrix, hasNodeTransformations } = getCurrentMatrix( + this.container, + this.win, + { + ignoreWritingModeAndTextDirection: true, + } + ); + this.currentMatrix = currentMatrix; + this.hasNodeTransformations = hasNodeTransformations; + + if (this.prevColor != this.color) { + this.clearCache(); + } + this.renderFlexContainer(); + this.renderFlexLines(); + this.renderJustifyContent(); + this.renderFlexItems(); + this._showFlexbox(); + this.prevColor = this.color; + + root.setAttribute( + "style", + `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden` + ); + + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + return true; + } +} + +/** + * Returns an object representation of the Flex data object and its array of FlexLine + * and FlexItem objects along with the DOMRects of the flex items. + * + * @param {DOMNode} container + * The flex container. + * @return {Object|null} representation of the Flex data object. + */ +function getFlexData(container) { + const flex = container.getAsFlexContainer(); + + if (!flex) { + return null; + } + + return { + lines: flex.getLines().map(line => { + return { + crossSize: line.crossSize, + crossStart: line.crossStart, + firstBaselineOffset: line.firstBaselineOffset, + growthState: line.growthState, + lastBaselineOffset: line.lastBaselineOffset, + items: line.getItems().map(item => { + return { + crossMaxSize: item.crossMaxSize, + crossMinSize: item.crossMinSize, + mainBaseSize: item.mainBaseSize, + mainDeltaSize: item.mainDeltaSize, + mainMaxSize: item.mainMaxSize, + mainMinSize: item.mainMinSize, + node: item.node, + rect: getRectFromFlexItemValues(item, container), + }; + }), + }; + }), + }; +} + +/** + * Given a FlexItemValues, return a DOMRect representing the flex item taking + * into account its flex container's border and padding. + * + * @param {FlexItemValues} item + * The FlexItemValues for which we need the DOMRect. + * @param {DOMNode} + * Flex container containing the flex item. + * @return {DOMRect} representing the flex item. + */ +function getRectFromFlexItemValues(item, container) { + const rect = item.frameRect; + const domRect = new DOMRect(rect.x, rect.y, rect.width, rect.height); + const win = container.ownerGlobal; + const style = win.getComputedStyle(container); + const borderLeftWidth = parseInt(style.borderLeftWidth, 10) || 0; + const borderTopWidth = parseInt(style.borderTopWidth, 10) || 0; + const paddingLeft = parseInt(style.paddingLeft, 10) || 0; + const paddingTop = parseInt(style.paddingTop, 10) || 0; + const scrollX = container.scrollLeft || 0; + const scrollY = container.scrollTop || 0; + + domRect.x -= paddingLeft + scrollX; + domRect.y -= paddingTop + scrollY; + + if (style.overflow === "visible" || style.overflow === "clip") { + domRect.x -= borderLeftWidth; + domRect.y -= borderTopWidth; + } + + return domRect; +} + +/** + * Returns whether or not the flex data has changed. + * + * @param {Flex} oldFlexData + * The old Flex data object. + * @param {Flex} newFlexData + * The new Flex data object. + * @return {Boolean} true if the flex data has changed and false otherwise. + */ +// eslint-disable-next-line complexity +function compareFlexData(oldFlexData, newFlexData) { + if (!oldFlexData || !newFlexData) { + return true; + } + + const oldLines = oldFlexData.lines; + const newLines = newFlexData.lines; + + if (oldLines.length !== newLines.length) { + return true; + } + + for (let i = 0; i < oldLines.length; i++) { + const oldLine = oldLines[i]; + const newLine = newLines[i]; + + if ( + oldLine.crossSize !== newLine.crossSize || + oldLine.crossStart !== newLine.crossStart || + oldLine.firstBaselineOffset !== newLine.firstBaselineOffset || + oldLine.growthState !== newLine.growthState || + oldLine.lastBaselineOffset !== newLine.lastBaselineOffset + ) { + return true; + } + + const oldItems = oldLine.items; + const newItems = newLine.items; + + if (oldItems.length !== newItems.length) { + return true; + } + + for (let j = 0; j < oldItems.length; j++) { + const oldItem = oldItems[j]; + const newItem = newItems[j]; + + if ( + oldItem.crossMaxSize !== newItem.crossMaxSize || + oldItem.crossMinSize !== newItem.crossMinSize || + oldItem.mainBaseSize !== newItem.mainBaseSize || + oldItem.mainDeltaSize !== newItem.mainDeltaSize || + oldItem.mainMaxSize !== newItem.mainMaxSize || + oldItem.mainMinSize !== newItem.mainMinSize + ) { + return true; + } + + const oldItemRect = oldItem.rect; + const newItemRect = newItem.rect; + + // We are using DOMRects so we only need to compare x, y, width and + // height (left, top, right and bottom are calculated from these values). + if ( + oldItemRect.x !== newItemRect.x || + oldItemRect.y !== newItemRect.y || + oldItemRect.width !== newItemRect.width || + oldItemRect.height !== newItemRect.height + ) { + return true; + } + } + } + + return false; +} + +exports.FlexboxHighlighter = FlexboxHighlighter; diff --git a/devtools/server/actors/highlighters/fonts.js b/devtools/server/actors/highlighters/fonts.js new file mode 100644 index 0000000000..3106e1b1fa --- /dev/null +++ b/devtools/server/actors/highlighters/fonts.js @@ -0,0 +1,122 @@ +/* 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 InspectorUtils = require("InspectorUtils"); +loader.lazyRequireGetter( + this, + "loadSheet", + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + "removeSheet", + "devtools/shared/layout/utils", + true +); + +// How many text runs are we highlighting at a time. There may be many text runs, and we +// want to prevent performance problems. +const MAX_TEXT_RANGES = 100; + +// This stylesheet is inserted into the page to customize the color of the selected text +// runs. +// Note that this color is defined as --highlighter-content-color in the highlighters.css +// file, and corresponds to the box-model content color. We want to give it an opacity of +// 0.6 here. +const STYLESHEET_URI = + "data:text/css," + + encodeURIComponent( + "::selection{background-color:hsl(197,71%,73%,.6)!important;}" + ); + +/** + * This highlighter highlights runs of text in the page that have been rendered given a + * certain font. The highlighting is done with window selection ranges, so no extra + * markup is being inserted into the content page. + */ +class FontsHighlighter { + constructor(highlighterEnv) { + this.env = highlighterEnv; + } + + destroy() { + this.hide(); + this.env = this.currentNode = null; + } + + get currentNodeDocument() { + if (!this.currentNode) { + return this.env.document; + } + + if (this.currentNode.nodeType === this.currentNode.DOCUMENT_NODE) { + return this.currentNode; + } + + return this.currentNode.ownerDocument; + } + + /** + * Show the highlighter for a given node. + * @param {DOMNode} node The node in which we want to search for text runs. + * @param {Object} options A bunch of options that can be set: + * - {String} name The actual font name to look for in the node. + * - {String} CSSFamilyName The CSS font-family name given to this font. + */ + show(node, options) { + this.currentNode = node; + const doc = this.currentNodeDocument; + + // Get all of the fonts used to render content inside the node. + const searchRange = doc.createRange(); + searchRange.selectNodeContents(node); + + const fonts = InspectorUtils.getUsedFontFaces(searchRange, MAX_TEXT_RANGES); + + // Find the ones we want, based on the provided option. + const matchingFonts = fonts.filter( + f => f.CSSFamilyName === options.CSSFamilyName && f.name === options.name + ); + if (!matchingFonts.length) { + return; + } + + // Load the stylesheet that will customize the color of the highlighter (using a + // ::selection rule). + loadSheet(this.env.window, STYLESHEET_URI); + + // Create a multi-selection in the page to highlight the text runs. + const selection = doc.defaultView.getSelection(); + selection.removeAllRanges(); + + for (const matchingFont of matchingFonts) { + for (const range of matchingFont.ranges) { + selection.addRange(range); + } + } + } + + hide() { + // No node was highlighted before, don't need to continue any further. + if (!this.currentNode) { + return; + } + + try { + removeSheet(this.env.window, STYLESHEET_URI); + } catch (e) { + // Silently fail here as we might not have inserted the stylesheet at all. + } + + // Simply remove all current ranges in the seletion. + const doc = this.currentNodeDocument; + const selection = doc.defaultView.getSelection(); + selection.removeAllRanges(); + } +} + +exports.FontsHighlighter = FontsHighlighter; diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js new file mode 100644 index 0000000000..95eac514ca --- /dev/null +++ b/devtools/server/actors/highlighters/geometry-editor.js @@ -0,0 +1,796 @@ +/* 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, + getComputedStyle, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { + setIgnoreLayoutChanges, + getAdjustedQuads, +} = require("devtools/shared/layout/utils"); +const { getCSSStyleRules } = require("devtools/shared/inspector/css-logic"); + +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.includes(name); + }, + + isSize: function(name) { + return this.SIZES.includes(name); + }, + + containsSide: function(names) { + return names.some(name => this.SIDES.includes(name)); + }, + + containsSize: function(names) { + return names.some(name => this.SIZES.includes(name)); + }, + + 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) { + const win = node.ownerGlobal; + + let offsetParent = node.offsetParent; + if (offsetParent && getComputedStyle(offsetParent).position === "static") { + offsetParent = null; + } + + let width, height; + if (!offsetParent) { + height = win.innerHeight; + width = win.innerWidth; + } else { + height = offsetParent.offsetHeight; + width = offsetParent.offsetWidth; + } + + return { + element: offsetParent, + dimension: { width, height }, + }; +} + +/** + * Get the list of geometry properties that are actually set on the provided + * node. + * + * @param {Node} node The node to analyze. + * @return {Map} A map indexed by property name and where the value is an + * object having the cssRule property. + */ +function getDefinedGeometryProperties(node) { + const props = new Map(); + if (!node) { + return props; + } + + // Get the list of css rules applying to the current node. + const cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.length; i++) { + const rule = cssRules[i]; + for (const name of GeoProp.allProps()) { + const value = rule.style.getPropertyValue(name); + if (value && value !== "auto") { + // getCSSStyleRules returns rules ordered from least to most specific + // so just override any previous properties we have set. + props.set(name, { + cssRule: rule, + }); + } + } + } + + // Go through the inline styles last, only if the node supports inline style + // (e.g. pseudo elements don't have a style property) + if (node.style) { + for (const name of GeoProp.allProps()) { + const value = node.style.getPropertyValue(name); + if (value && value !== "auto") { + props.set(name, { + // There's no cssRule to store here, so store the node instead since + // node.style exists. + cssRule: node, + }); + } + } + } + + // Post-process the list for invalid properties. This is done after the fact + // because of cases like relative positioning with both top and bottom where + // only top will actually be used, but both exists in css rules and computed + // styles. + const { position } = getComputedStyle(node); + for (const [name] of props) { + // Top/left/bottom/right on static positioned elements have no effect. + if (position === "static" && GeoProp.SIDES.includes(name)) { + props.delete(name); + } + + // Bottom/right on relative positioned elements are only used if top/left + // are not defined. + const hasRightAndLeft = name === "right" && props.has("left"); + const hasBottomAndTop = name === "bottom" && props.has("top"); + if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) { + props.delete(name); + } + } + + return props; +} +exports.getDefinedGeometryProperties = getDefinedGeometryProperties; + +/** + * The GeometryEditor highlights an elements's top, left, bottom, right, width + * and height dimensions, when they are set. + * + * To determine if an element has a set size and position, the highlighter lists + * the CSS rules that apply to the element and checks for the top, left, bottom, + * right, width and height properties. + * The highlighter won't be shown if the element doesn't have any of these + * properties set, but will be shown when at least 1 property is defined. + * + * The highlighter displays lines and labels for each of the defined properties + * in and around the element (relative to the offset parent when one exists). + * The highlighter also highlights the element itself and its offset parent if + * there is one. + * + * Note that the class name contains the word Editor because the aim is for the + * handles to be draggable in content to make the geometry editable. + */ +class GeometryEditorHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "geometry-editor-"; + + // The list of element geometry properties that can be set. + this.definedProperties = new Map(); + + this.markup = new CanvasFrameAnonymousContentHelper( + highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.initialize(); + + const { pageListenerTarget } = this.highlighterEnv; + + // Register the geometry editor instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); + + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + } + + async initialize() { + await this.markup.initialize(); + // Register the mousedown event for each Geometry Editor's handler. + // Those events are automatically removed when the markup is destroyed. + const onMouseDown = this.handleEvent.bind(this); + + for (const side of GeoProp.SIDES) { + this.getElement("handler-" + side).addEventListener( + "mousedown", + onMouseDown + ); + } + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + width: "100%", + height: "100%", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Offset parent node highlighter. + this.markup.createSVGNode({ + nodeType: "polygon", + parent: svg, + attributes: { + class: "offset-parent", + id: "offset-parent", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Current node highlighter (margin box). + this.markup.createSVGNode({ + nodeType: "polygon", + parent: svg, + attributes: { + class: "current-node", + id: "current-node", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the 4 side arrows, handlers and labels. + for (const name of GeoProp.SIDES) { + this.markup.createSVGNode({ + nodeType: "line", + parent: svg, + attributes: { + class: "arrow " + name, + id: "arrow-" + name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "circle", + parent: svg, + attributes: { + class: "handler-" + name, + id: "handler-" + name, + r: "4", + "data-side": name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Labels are positioned by using a translated <g>. This group contains + // a path and text that are themselves positioned using another translated + // <g>. This is so that the label arrow points at the 0,0 coordinates of + // parent <g>. + const labelG = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + attributes: { + id: "label-" + name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const subG = this.markup.createSVGNode({ + nodeType: "g", + parent: labelG, + attributes: { + transform: GeoProp.isHorizontal(name) + ? "translate(-30 -30)" + : "translate(5 -10)", + }, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: subG, + attributes: { + class: "label-bubble", + d: GeoProp.isHorizontal(name) + ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z" + : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "text", + parent: subG, + attributes: { + class: "label-text", + id: "label-text-" + name, + x: GeoProp.isHorizontal(name) ? "30" : "35", + y: "10", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + return container; + } + + destroy() { + // Avoiding exceptions if `destroy` is called multiple times; and / or the + // highlighter environment was already destroyed. + if (!this.highlighterEnv) { + return; + } + + const { pageListenerTarget } = this.highlighterEnv; + + if (pageListenerTarget) { + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this) + ); + } + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.markup.destroy(); + this.definedProperties.clear(); + this.definedProperties = null; + this.offsetParent = null; + } + + handleEvent(event, id) { + // No event handling if the highlighter is hidden + if (this.getElement("root").hasAttribute("hidden")) { + return; + } + + const { target, type, pageX, pageY } = event; + + switch (type) { + case "pagehide": + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.win) { + this.destroy(); + } + + break; + case "mousedown": + // The mousedown event is intended only for the handler + if (!id) { + return; + } + + const handlerSide = this.markup + .getElement(id) + .getAttribute("data-side"); + + if (handlerSide) { + const side = handlerSide; + const sideProp = this.definedProperties.get(side); + + if (!sideProp) { + return; + } + + let value = sideProp.cssRule.style.getPropertyValue(side); + const computedValue = this.computedStyle.getPropertyValue(side); + + const [unit] = value.match(/[^\d]+$/) || [""]; + + value = parseFloat(value); + + const ratio = value / parseFloat(computedValue) || 1; + const dir = GeoProp.isInverted(side) ? -1 : 1; + + // Store all the initial values needed for drag & drop + this[_dragging] = { + side, + value, + unit, + x: pageX, + y: pageY, + inc: ratio * dir, + }; + + this.getElement("handler-" + side).classList.add("dragging"); + } + + this.getElement("root").setAttribute("dragging", "true"); + break; + case "mouseup": + // If we're dragging, drop it. + if (this[_dragging]) { + const { side } = this[_dragging]; + this.getElement("root").removeAttribute("dragging"); + this.getElement("handler-" + side).classList.remove("dragging"); + this[_dragging] = null; + } + break; + case "mousemove": + if (!this[_dragging]) { + return; + } + + const { side, x, y, value, unit, inc } = this[_dragging]; + const sideProps = this.definedProperties.get(side); + + if (!sideProps) { + return; + } + + const delta = + (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc; + + // The inline style has usually the priority over any other CSS rule + // set in stylesheets. However, if a rule has `!important` keyword, + // it will override the inline style too. To ensure Geometry Editor + // will always update the element, we have to add `!important` as + // well. + this.currentNode.style.setProperty( + side, + value + delta + unit, + "important" + ); + + break; + } + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + _show() { + this.computedStyle = getComputedStyle(this.currentNode); + const pos = this.computedStyle.position; + // XXX: sticky positioning is ignored for now. To be implemented next. + if (pos === "sticky") { + this.hide(); + return false; + } + + const hasUpdated = this._update(); + if (!hasUpdated) { + this.hide(); + return false; + } + + this.getElement("root").removeAttribute("hidden"); + + return true; + } + + _update() { + // At each update, the position or/and size may have changed, so get the + // list of defined properties, and re-position the arrows and highlighters. + this.definedProperties = getDefinedGeometryProperties(this.currentNode); + + if (!this.definedProperties.size) { + console.warn("The element does not have editable geometry properties"); + return false; + } + + setIgnoreLayoutChanges(true); + + // Update the highlighters and arrows. + this.updateOffsetParent(); + this.updateCurrentNode(); + this.updateArrows(); + + // Avoid zooming the arrows when content is zoomed. + const node = this.currentNode; + this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root"); + + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + return true; + } + + /** + * Update the offset parent rectangle. + * There are 3 different cases covered here: + * - the node is absolutely/fixed positioned, and an offsetParent is defined + * (i.e. it's not just positioned in the viewport): the offsetParent node + * is highlighted (i.e. the rectangle is shown), + * - the node is relatively positioned: the rectangle is shown where the node + * would originally have been (because that's where the relative positioning + * is calculated from), + * - the node has no offset parent at all: the offsetParent rectangle is + * hidden. + */ + updateOffsetParent() { + // Get the offsetParent, if any. + this.offsetParent = getOffsetParent(this.currentNode); + // And the offsetParent quads. + this.parentQuads = getAdjustedQuads( + this.win, + this.offsetParent.element, + "padding" + ); + + const el = this.getElement("offset-parent"); + + const isPositioned = + this.computedStyle.position === "absolute" || + this.computedStyle.position === "fixed"; + const isRelative = this.computedStyle.position === "relative"; + let isHighlighted = false; + + if (this.offsetParent.element && isPositioned) { + const { p1, p2, p3, p4 } = this.parentQuads[0]; + const points = + p1.x + + "," + + p1.y + + " " + + p2.x + + "," + + p2.y + + " " + + p3.x + + "," + + p3.y + + " " + + p4.x + + "," + + p4.y; + el.setAttribute("points", points); + isHighlighted = true; + } else if (isRelative) { + const xDelta = parseFloat(this.computedStyle.left); + const yDelta = parseFloat(this.computedStyle.top); + if (xDelta || yDelta) { + const { p1, p2, p3, p4 } = this.currentQuads.margin[0]; + const points = + p1.x - + xDelta + + "," + + (p1.y - yDelta) + + " " + + (p2.x - xDelta) + + "," + + (p2.y - yDelta) + + " " + + (p3.x - xDelta) + + "," + + (p3.y - yDelta) + + " " + + (p4.x - xDelta) + + "," + + (p4.y - yDelta); + el.setAttribute("points", points); + isHighlighted = true; + } + } + + if (isHighlighted) { + el.removeAttribute("hidden"); + } else { + el.setAttribute("hidden", "true"); + } + } + + updateCurrentNode() { + const box = this.getElement("current-node"); + const { p1, p2, p3, p4 } = this.currentQuads.margin[0]; + const attr = + p1.x + + "," + + p1.y + + " " + + p2.x + + "," + + p2.y + + " " + + p3.x + + "," + + p3.y + + " " + + p4.x + + "," + + p4.y; + box.setAttribute("points", attr); + box.removeAttribute("hidden"); + } + + _hide() { + setIgnoreLayoutChanges(true); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("current-node").setAttribute("hidden", "true"); + this.getElement("offset-parent").setAttribute("hidden", "true"); + this.hideArrows(); + + this.definedProperties.clear(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + } + + hideArrows() { + for (const side of GeoProp.SIDES) { + this.getElement("arrow-" + side).setAttribute("hidden", "true"); + this.getElement("label-" + side).setAttribute("hidden", "true"); + this.getElement("handler-" + side).setAttribute("hidden", "true"); + } + } + + updateArrows() { + this.hideArrows(); + + // Position arrows always end at the node's margin box. + const marginBox = this.currentQuads.margin[0].bounds; + + // Position the side arrows which need to be visible. + // Arrows always start at the offsetParent edge, and end at the middle + // position of the node's margin edge. + // Note that for relative positioning, the offsetParent is considered to be + // the node itself, where it would have been originally. + // +------------------+----------------+ + // | offsetparent | top | + // | or viewport | | + // | +--------+--------+ | + // | | node | | + // +---------+ +-------+ + // | left | | right | + // | +--------+--------+ | + // | | bottom | + // +------------------+----------------+ + const getSideArrowStartPos = side => { + // In case 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.ownerGlobal[ + "scroll" + GeoProp.axis(side).toUpperCase() + ] + ); + }; + + for (const side of GeoProp.SIDES) { + const sideProp = this.definedProperties.get(side); + if (!sideProp) { + continue; + } + + const mainAxisStartPos = getSideArrowStartPos(side); + const mainAxisEndPos = marginBox[side]; + const crossAxisPos = + marginBox[GeoProp.crossAxisStart(side)] + + marginBox[GeoProp.crossAxisSize(side)] / 2; + + this.updateArrow( + side, + mainAxisStartPos, + mainAxisEndPos, + crossAxisPos, + sideProp.cssRule.style.getPropertyValue(side) + ); + } + } + + updateArrow(side, mainStart, mainEnd, crossPos, labelValue) { + const arrowEl = this.getElement("arrow-" + side); + const labelEl = this.getElement("label-" + side); + const labelTextEl = this.getElement("label-text-" + side); + const handlerEl = this.getElement("handler-" + side); + + // Position the arrow <line>. + arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos); + arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos); + arrowEl.removeAttribute("hidden"); + + handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd); + handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos); + handlerEl.removeAttribute("hidden"); + + // Position the label <text> in the middle of the arrow (making sure it's + // not hidden below the fold). + const capitalize = str => str[0].toUpperCase() + str.substring(1); + const winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))]; + let labelMain = mainStart + (mainEnd - mainStart) / 2; + if ( + (mainStart > 0 && mainStart < winMain) || + (mainEnd > 0 && mainEnd < winMain) + ) { + if (labelMain < GEOMETRY_LABEL_SIZE) { + labelMain = GEOMETRY_LABEL_SIZE; + } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) { + labelMain = winMain - GEOMETRY_LABEL_SIZE; + } + } + const labelCross = crossPos; + labelEl.setAttribute( + "transform", + GeoProp.isHorizontal(side) + ? "translate(" + labelMain + " " + labelCross + ")" + : "translate(" + labelCross + " " + labelMain + ")" + ); + labelEl.removeAttribute("hidden"); + labelTextEl.setTextContent(labelValue); + } + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +} + +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js new file mode 100644 index 0000000000..c9de633a83 --- /dev/null +++ b/devtools/server/actors/highlighters/measuring-tool.js @@ -0,0 +1,763 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + getCurrentZoom, + getWindowDimensions, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, +} = require("devtools/server/actors/highlighters/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; + +// List of all DOM Events subscribed directly to the document from the +// Measuring Tool highlighter +const DOM_EVENTS = [ + "mousedown", + "mousemove", + "mouseup", + "mouseleave", + "scroll", + "pagehide", +]; + +const SIDES = ["top", "right", "bottom", "left"]; +const HANDLERS = [...SIDES, "topleft", "topright", "bottomleft", "bottomright"]; +const HANDLER_SIZE = 6; + +/** + * 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.isReady = this.markup.initialize(); + + this.coords = { + x: 0, + y: 0, + }; + + const { pageListenerTarget } = highlighterEnv; + + // Register the measuring tool instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); +} + +MeasuringToolHighlighter.prototype = { + ID_CLASS_PREFIX: "measuring-tool-", + + _buildMarkup() { + const prefix = this.ID_CLASS_PREFIX; + + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + hidden: "true", + }, + prefix, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + class: "elements", + width: "100%", + height: "100%", + }, + prefix, + }); + + for (const side of SIDES) { + this.markup.createSVGNode({ + nodeType: "line", + parent: svg, + attributes: { + class: `guide-${side}`, + id: `guide-${side}`, + hidden: "true", + }, + prefix, + }); + } + + this.markup.createNode({ + nodeType: "label", + attributes: { + id: "label-size", + class: "label-size", + hidden: "true", + }, + parent: root, + prefix, + }); + + this.markup.createNode({ + nodeType: "label", + attributes: { + id: "label-position", + class: "label-position", + hidden: "true", + }, + parent: root, + prefix, + }); + + // Creating a <g> element in order to group all the paths below, that + // together represent the measuring tool; so that would be easier move them + // around + const g = this.markup.createSVGNode({ + nodeType: "g", + attributes: { + id: "tool", + }, + parent: svg, + prefix, + }); + + this.markup.createSVGNode({ + nodeType: "path", + attributes: { + id: "box-path", + class: "box-path", + }, + parent: g, + prefix, + }); + + this.markup.createSVGNode({ + nodeType: "path", + attributes: { + id: "diagonal-path", + class: "diagonal-path", + }, + parent: g, + prefix, + }); + + for (const handler of HANDLERS) { + this.markup.createSVGNode({ + nodeType: "circle", + parent: g, + attributes: { + class: `handler-${handler}`, + id: `handler-${handler}`, + r: HANDLER_SIZE, + hidden: "true", + }, + prefix, + }); + } + + return container; + }, + + _update() { + const { window } = this.env; + + setIgnoreLayoutChanges(true); + + const zoom = getCurrentZoom(window); + + const { width, height } = getWindowDimensions(window); + + const { coords } = this; + + const isZoomChanged = zoom !== coords.zoom; + + if (isZoomChanged) { + coords.zoom = zoom; + this.updateLabel(); + } + + const 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, window.document.documentElement); + + this._rafID = window.requestAnimationFrame(() => this._update()); + }, + + _cancelUpdate() { + if (this._rafID) { + this.env.window.cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + }, + + destroy() { + this.hide(); + + this._cancelUpdate(); + + const { pageListenerTarget } = this.env; + + if (pageListenerTarget) { + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this) + ); + } + + this.markup.destroy(); + + EventEmitter.emit(this, "destroy"); + }, + + show() { + setIgnoreLayoutChanges(true); + + this.getElement("root").removeAttribute("hidden"); + + this._update(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hide() { + setIgnoreLayoutChanges(true); + + this.hideLabel("size"); + this.hideLabel("position"); + + this.getElement("root").setAttribute("hidden", "true"); + + this._cancelUpdate(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + setSize(w, h) { + this.setCoords(undefined, undefined, w, h); + }, + + setCoords(x, y, w, h) { + const { 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._dragging) { + this.updatePaths(); + this.updateHandlers(); + } + + this.updateLabel(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + updatePaths() { + const { x, y, w, h } = this.coords; + const dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`; + + // Adding correction to the line path, otherwise some pixels are drawn + // outside the main rectangle area. + const x1 = w > 0 ? 0.5 : 0; + const y1 = w < 0 && h < 0 ? -0.5 : 0; + const w1 = w + (h < 0 && w < 0 ? 0.5 : 0); + const h1 = h + (h > 0 && w > 0 ? -0.5 : 0); + + const linedir = `M${x1} ${y1} L${w1} ${h1}`; + + this.getElement("box-path").setAttribute("d", dir); + this.getElement("diagonal-path").setAttribute("d", linedir); + this.getElement("tool").setAttribute("transform", `translate(${x},${y})`); + }, + + updateLabel(type) { + type = type || (this._dragging ? "size" : "position"); + + const isSizeLabel = type === "size"; + + const label = this.getElement(`label-${type}`); + + let origin = "top left"; + + const { innerWidth, innerHeight, scrollX, scrollY } = this.env.window; + let { x, y, w, h, zoom } = this.coords; + const scale = 1 / zoom; + + w = w || 0; + h = h || 0; + x = x || 0; + y = y || 0; + if (type === "size") { + x += w; + y += h; + } + + let labelMargin, labelHeight, labelWidth; + + if (isSizeLabel) { + labelMargin = LABEL_SIZE_MARGIN; + labelWidth = LABEL_SIZE_WIDTH; + labelHeight = LABEL_SIZE_HEIGHT; + + const d = Math.hypot(w, h).toFixed(2); + + label.setTextContent(`W: ${Math.abs(w)} px + H: ${Math.abs(h)} px + ↘: ${d}px`); + } else { + labelMargin = LABEL_POS_MARGIN; + labelWidth = LABEL_POS_WIDTH; + labelHeight = LABEL_POS_HEIGHT; + + label.setTextContent(`${x} + ${y}`); + } + + // Size used to position properly the label + const labelBoxWidth = (labelWidth + labelMargin) * scale; + const labelBoxHeight = (labelHeight + labelMargin) * scale; + + const isGoingLeft = w < scrollX; + const isSizeGoingLeft = isSizeLabel && isGoingLeft; + const isExceedingLeftMargin = x - labelBoxWidth < scrollX; + const isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX; + const isExceedingTopMargin = y - labelBoxHeight < scrollY; + const isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY; + + if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) { + x -= labelBoxWidth; + origin = "top right"; + } else { + x += labelMargin * scale; + } + + if (isSizeLabel) { + y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight; + } else { + y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale; + } + + label.setAttribute( + "style", + ` + width: ${labelWidth}px; + height: ${labelHeight}px; + transform-origin: ${origin}; + transform: translate(${x}px,${y}px) scale(${scale}) + ` + ); + + if (!isSizeLabel) { + const labelSize = this.getElement("label-size"); + const style = labelSize.getAttribute("style"); + + if (style) { + labelSize.setAttribute( + "style", + style.replace(/scale[^)]+\)/, `scale(${scale})`) + ); + } + } + }, + + updateViewport() { + const { devicePixelRatio } = this.env.window; + const { documentWidth, documentHeight, zoom } = this.coords; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + const pixelRatio = devicePixelRatio / zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + const minWidth = 1 / pixelRatio; + const strokeWidth = minWidth / zoom; + + this.getElement("root").setAttribute( + "style", + `stroke-width:${strokeWidth}; + width:${documentWidth}px; + height:${documentHeight}px;` + ); + }, + + updateGuides() { + const { x, y, w, h } = this.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%"); + }, + + setHandlerPosition(handler, x, y) { + const handlerElement = this.getElement(`handler-${handler}`); + handlerElement.setAttribute("cx", x); + handlerElement.setAttribute("cy", y); + }, + + updateHandlers() { + const { w, h } = this.coords; + + this.setHandlerPosition("top", w / 2, 0); + this.setHandlerPosition("topright", w, 0); + this.setHandlerPosition("right", w, h / 2); + this.setHandlerPosition("bottomright", w, h); + this.setHandlerPosition("bottom", w / 2, h); + this.setHandlerPosition("bottomleft", 0, h); + this.setHandlerPosition("left", 0, h / 2); + this.setHandlerPosition("topleft", 0, 0); + }, + + showLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).removeAttribute("hidden"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hideLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).setAttribute("hidden", "true"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + showGuides() { + const prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (const side of SIDES) { + this.markup.removeAttributeForElement(`${prefix + side}`, "hidden"); + } + }, + + hideGuides() { + const prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (const side of SIDES) { + this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true"); + } + }, + + showHandler(id) { + const prefix = this.ID_CLASS_PREFIX + "handler-"; + this.markup.removeAttributeForElement(prefix + id, "hidden"); + }, + + showHandlers() { + const prefix = this.ID_CLASS_PREFIX + "handler-"; + + for (const handler of HANDLERS) { + this.markup.removeAttributeForElement(prefix + handler, "hidden"); + } + }, + + hideAll() { + this.hideLabel("position"); + this.hideLabel("size"); + this.hideGuides(); + this.hideHandlers(); + }, + + showGuidesAndHandlers() { + // Shows the guides and handlers only if an actual area is selected + if (this.coords.w !== 0 && this.coords.h !== 0) { + this.updateGuides(); + this.showGuides(); + this.updateHandlers(); + this.showHandlers(); + } + }, + + hideHandlers() { + const prefix = this.ID_CLASS_PREFIX + "handler-"; + + for (const handler of HANDLERS) { + this.markup.setAttributeForElement(prefix + handler, "hidden", "true"); + } + }, + + handleEvent(event) { + const { target, type } = event; + + switch (type) { + case "mousedown": + if (event.button || this._dragging) { + return; + } + + const isHandler = event.originalTarget.id.includes("handler"); + if (isHandler) { + this.handleResizingMouseDownEvent(event); + } else { + this.handleMouseDownEvent(event); + } + break; + case "mousemove": + if (this._dragging && this._dragging.handler) { + this.handleResizingMouseMoveEvent(event); + } else { + this.handleMouseMoveEvent(event); + } + break; + case "mouseup": + if (this._dragging) { + if (this._dragging.handler) { + this.handleResizingMouseUpEvent(); + } else { + this.handleMouseUpEvent(); + } + } + break; + case "mouseleave": { + if (!this._dragging) { + this.hideLabel("position"); + } + break; + } + case "scroll": { + this.hideLabel("position"); + break; + } + case "pagehide": { + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.env.window) { + this.destroy(); + } + break; + } + } + }, + + handleMouseDownEvent(event) { + const { pageX, pageY } = event; + const { window } = this.env; + const elementId = `${this.ID_CLASS_PREFIX}tool`; + + setIgnoreLayoutChanges(true); + + this.markup.getElement(elementId).classList.add("dragging"); + + this.hideAll(); + + setIgnoreLayoutChanges(false, window.document.documentElement); + + // Store all the initial values needed for drag & drop + this._dragging = { + handler: null, + x: pageX, + y: pageY, + }; + + this.setCoords(pageX, pageY, 0, 0); + }, + + handleMouseMoveEvent(event) { + const { pageX, pageY } = event; + const { coords } = this; + let { x, y, w, h } = coords; + let labelType; + + if (this._dragging) { + w = pageX - coords.x; + h = pageY - coords.y; + + this.setCoords(x, y, w, h); + + labelType = "size"; + } else { + labelType = "position"; + + this.setCoords(pageX, pageY); + } + + this.showLabel(labelType); + }, + + handleMouseUpEvent() { + setIgnoreLayoutChanges(true); + + this.getElement("tool").classList.remove("dragging"); + + this.showGuidesAndHandlers(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + this._dragging = null; + }, + + handleResizingMouseDownEvent(event) { + const { originalTarget, pageX, pageY } = event; + const { window } = this.env; + const prefix = this.ID_CLASS_PREFIX + "handler-"; + const handler = originalTarget.id.replace(prefix, ""); + + setIgnoreLayoutChanges(true); + + this.markup.getElement(originalTarget.id).classList.add("dragging"); + + this.hideAll(); + this.showHandler(handler); + + // Set coordinates to the current measurement area's position + const [, x, y] = this.getElement("tool") + .getAttribute("transform") + .match(/(\d+),(\d+)/); + this.setCoords(Number(x), Number(y)); + + setIgnoreLayoutChanges(false, window.document.documentElement); + + // Store all the initial values needed for drag & drop + this._dragging = { + handler, + x: pageX, + y: pageY, + }; + }, + + handleResizingMouseMoveEvent(event) { + const { pageX, pageY } = event; + const { coords } = this; + let { x, y, w, h } = coords; + + const { handler } = this._dragging; + + switch (handler) { + case "top": + y = pageY; + h = coords.y + coords.h - pageY; + break; + case "topright": + y = pageY; + w = pageX - coords.x; + h = coords.y + coords.h - pageY; + break; + case "right": + w = pageX - coords.x; + break; + case "bottomright": + w = pageX - coords.x; + h = pageY - coords.y; + break; + case "bottom": + h = pageY - coords.y; + break; + case "bottomleft": + x = pageX; + w = coords.x + coords.w - pageX; + h = pageY - coords.y; + break; + case "left": + x = pageX; + w = coords.x + coords.w - pageX; + break; + case "topleft": + x = pageX; + y = pageY; + w = coords.x + coords.w - pageX; + h = coords.y + coords.h - pageY; + break; + } + + this.setCoords(x, y, w, h); + + // Changes the resizing cursors in case the measuring box is mirrored + const isMirrored = + (coords.w < 0 || coords.h < 0) && !(coords.w < 0 && coords.h < 0); + this.getElement("tool").classList.toggle("mirrored", isMirrored); + + this.showLabel("size"); + }, + + handleResizingMouseUpEvent() { + const { handler } = this._dragging; + + setIgnoreLayoutChanges(true); + + this.getElement(`handler-${handler}`).classList.remove("dragging"); + this.showHandlers(); + + this.showGuidesAndHandlers(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + this._dragging = null; + }, +}; +exports.MeasuringToolHighlighter = MeasuringToolHighlighter; diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build new file mode 100644 index 0000000000..5423477f73 --- /dev/null +++ b/devtools/server/actors/highlighters/moz.build @@ -0,0 +1,28 @@ +# -*- 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( + "accessible.js", + "auto-refresh.js", + "box-model.js", + "css-grid.js", + "css-transform.js", + "eye-dropper.js", + "flexbox.js", + "fonts.js", + "geometry-editor.js", + "measuring-tool.js", + "node-tabbing-order.js", + "paused-debugger.js", + "rulers.js", + "selector.js", + "shapes.js", + "tabbing-order.js", +) diff --git a/devtools/server/actors/highlighters/node-tabbing-order.js b/devtools/server/actors/highlighters/node-tabbing-order.js new file mode 100644 index 0000000000..1e0e145ef1 --- /dev/null +++ b/devtools/server/actors/highlighters/node-tabbing-order.js @@ -0,0 +1,399 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + ["setIgnoreLayoutChanges", "getCurrentZoom"], + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + "AutoRefreshHighlighter", + "devtools/server/actors/highlighters/auto-refresh", + true +); +loader.lazyRequireGetter( + this, + ["CanvasFrameAnonymousContentHelper"], + "devtools/server/actors/highlighters/utils/markup", + true +); + +/** + * The NodeTabbingOrderHighlighter draws an outline around a node (based on its + * border bounds). + * + * Usage example: + * + * const h = new NodeTabbingOrderHighlighter(env); + * await h.isReady(); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * @param {Number} options.index + * Tabbing index value to be displayed in the highlighter info bar. + */ +class NodeTabbingOrderHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this._doNotStartRefreshLoop = true; + this.ID_CLASS_PREFIX = "tabbing-order-"; + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + } + + _buildMarkup() { + const root = this.markup.createNode({ + attributes: { + id: "root", + class: "root highlighter-container tabbing-order", + "aria-hidden": "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const container = this.markup.createNode({ + parent: root, + attributes: { + id: "container", + width: "100%", + height: "100%", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Building the SVG element + this.markup.createNode({ + parent: container, + attributes: { + class: "bounds", + id: "bounds", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Building the nodeinfo bar markup + + const infobarContainer = this.markup.createNode({ + parent: root, + attributes: { + class: "infobar-container", + id: "infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const infobar = this.markup.createNode({ + parent: infobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createNode({ + parent: infobar, + attributes: { + class: "infobar-text", + id: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return root; + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + this.markup.destroy(); + + AutoRefreshHighlighter.prototype.destroy.call(this); + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Update focused styling for a node tabbing index highlight. + * + * @param {Boolean} focused + * Indicates if the highlighted node needs to be focused. + */ + updateFocus(focused) { + const root = this.getElement("root"); + root.classList.toggle("focused", focused); + } + + /** + * Show the highlighter on a given node + */ + _show() { + return this._update(); + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update() { + let shown = false; + setIgnoreLayoutChanges(true); + + if (this._updateTabbingOrder()) { + this._showInfobar(); + this._showTabbingOrder(); + shown = true; + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } else { + // Nothing to highlight (0px rectangle like a <script> tag for instance) + this._hide(); + } + + return shown; + } + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + + this._hideTabbingOrder(); + this._hideInfobar(); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + /** + * Hide the infobar + */ + _hideInfobar() { + this.getElement("infobar-container").setAttribute("hidden", "true"); + } + + /** + * Show the infobar + */ + _showInfobar() { + if (!this.currentNode) { + return; + } + + this.getElement("infobar-container").removeAttribute("hidden"); + this.getElement("infobar-text").setTextContent(this.options.index); + const bounds = this._getBounds(); + const container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + } + + /** + * Hide the tabbing order highlighter + */ + _hideTabbingOrder() { + this.getElement("container").setAttribute("hidden", "true"); + } + + /** + * Show the tabbing order highlighter + */ + _showTabbingOrder() { + this.getElement("container").removeAttribute("hidden"); + } + + /** + * Calculate border bounds based on the quads returned by getAdjustedQuads. + * @return {Object} A bounds object {bottom,height,left,right,top,width,x,y} + */ + _getBorderBounds() { + const quads = this.currentQuads.border; + if (!quads || !quads.length) { + return null; + } + + const bounds = { + bottom: -Infinity, + height: 0, + left: Infinity, + right: -Infinity, + top: Infinity, + width: 0, + x: 0, + y: 0, + }; + + for (const q of quads) { + bounds.bottom = Math.max(bounds.bottom, q.bounds.bottom); + bounds.top = Math.min(bounds.top, q.bounds.top); + bounds.left = Math.min(bounds.left, q.bounds.left); + bounds.right = Math.max(bounds.right, q.bounds.right); + } + bounds.x = bounds.left; + bounds.y = bounds.top; + bounds.width = bounds.right - bounds.left; + bounds.height = bounds.bottom - bounds.top; + + return bounds; + } + + /** + * Update the tabbing order index as per the current node. + * + * @return {boolean} + * True if the current node has a tabbing order index to be + * highlighted + */ + _updateTabbingOrder() { + if (!this._nodeNeedsHighlighting()) { + this._hideTabbingOrder(); + return false; + } + + const boundsEl = this.getElement("bounds"); + const { left, top, width, height } = this._getBounds(); + boundsEl.setAttribute( + "style", + `top: ${top}px; left: ${left}px; width: ${width}px; height: ${height}px;` + ); + + // Un-zoom the root wrapper if the page was zoomed. + const rootId = this.ID_CLASS_PREFIX + "container"; + this.markup.scaleRootElement(this.currentNode, rootId); + + return true; + } + + /** + * Can the current node be highlighted? Does it have quads. + * @return {Boolean} + */ + _nodeNeedsHighlighting() { + return ( + this.currentQuads.margin.length || + this.currentQuads.border.length || + this.currentQuads.padding.length || + this.currentQuads.content.length + ); + } + + _getBounds() { + const borderBounds = this._getBorderBounds(); + let bounds = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + + if (!borderBounds) { + // Invisible element such as a script tag. + return bounds; + } + + const { bottom, height, left, right, top, width, x, y } = borderBounds; + if (width > 0 || height > 0) { + bounds = { bottom, height, left, right, top, width, x, y }; + } + + return bounds; + } +} + +/** + * Move the infobar to the right place in the highlighter. The infobar is used + * to display element's tabbing order index. + * + * @param {DOMNode} container + * The container element which will be used to position the infobar. + * @param {Object} bounds + * The content bounds of the container element. + * @param {Window} win + * The window object. + */ +function moveInfobar(container, bounds, win) { + const zoom = getCurrentZoom(win); + const { computedStyle } = container; + const margin = 2; + const arrowSize = + parseFloat( + computedStyle.getPropertyValue("--highlighter-bubble-arrow-size") + ) - 2; + const containerHeight = parseFloat(computedStyle.getPropertyValue("height")); + const containerWidth = parseFloat(computedStyle.getPropertyValue("width")); + + const topBoundary = margin; + const bottomBoundary = + win.document.scrollingElement.scrollHeight - containerHeight - margin - 1; + const leftBoundary = containerWidth / 2 + margin; + + let top = bounds.y - containerHeight - arrowSize; + let left = bounds.x + bounds.width / 2; + const bottom = bounds.bottom + arrowSize; + let positionAttribute = "top"; + + const canBePlacedOnTop = top >= topBoundary; + const canBePlacedOnBottom = bottomBoundary - bottom > 0; + + if (!canBePlacedOnTop && canBePlacedOnBottom) { + top = bottom; + positionAttribute = "bottom"; + } + + let hideArrow = false; + if (top < topBoundary) { + hideArrow = true; + top = topBoundary; + } else if (top > bottomBoundary) { + hideArrow = true; + top = bottomBoundary; + } + + if (left < leftBoundary) { + hideArrow = true; + left = leftBoundary; + } + + if (hideArrow) { + container.setAttribute("hide-arrow", "true"); + } else { + container.removeAttribute("hide-arrow"); + } + + container.setAttribute( + "style", + ` + position: absolute; + transform-origin: 0 0; + transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)` + ); + + container.setAttribute("position", positionAttribute); +} + +exports.NodeTabbingOrderHighlighter = NodeTabbingOrderHighlighter; diff --git a/devtools/server/actors/highlighters/paused-debugger.js b/devtools/server/actors/highlighters/paused-debugger.js new file mode 100644 index 0000000000..b281d6cb88 --- /dev/null +++ b/devtools/server/actors/highlighters/paused-debugger.js @@ -0,0 +1,255 @@ +/* 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("devtools/server/actors/highlighters/utils/markup"); + +loader.lazyGetter(this, "L10N", () => { + const { LocalizationHelper } = require("devtools/shared/l10n"); + const STRINGS_URI = "devtools/client/locales/debugger.properties"; + return new LocalizationHelper(STRINGS_URI); +}); + +/** + * The PausedDebuggerOverlay is a class that displays a semi-transparent mask on top of + * the whole page and a toolbar at the top of the page. + * This is used to signal to users that script execution is current paused. + * The toolbar is used to display the reason for the pause in script execution as well as + * buttons to resume or step through the program. + */ +function PausedDebuggerOverlay(highlighterEnv, options = {}) { + this.env = highlighterEnv; + this.resume = options.resume; + this.stepOver = options.stepOver; + + this.lastTarget = null; + + this.markup = new CanvasFrameAnonymousContentHelper( + highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); +} + +PausedDebuggerOverlay.prototype = { + ID_CLASS_PREFIX: "paused-dbg-", + + _buildMarkup() { + const prefix = this.ID_CLASS_PREFIX; + + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + // Wrapper element. + const wrapper = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + hidden: "true", + overlay: "true", + }, + prefix, + }); + + const toolbar = this.markup.createNode({ + parent: wrapper, + attributes: { + id: "toolbar", + class: "toolbar", + }, + prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: toolbar, + attributes: { + id: "reason", + class: "reason", + }, + prefix, + }); + + this.markup.createNode({ + parent: toolbar, + attributes: { + id: "divider", + class: "divider", + }, + prefix, + }); + + const stepWrapper = this.markup.createNode({ + parent: toolbar, + attributes: { + id: "step-button-wrapper", + class: "step-button-wrapper", + }, + prefix, + }); + + this.markup.createNode({ + nodeType: "button", + parent: stepWrapper, + attributes: { + id: "step-button", + class: "step-button", + }, + prefix, + }); + + const resumeWrapper = this.markup.createNode({ + parent: toolbar, + attributes: { + id: "resume-button-wrapper", + class: "resume-button-wrapper", + }, + prefix, + }); + + this.markup.createNode({ + nodeType: "button", + parent: resumeWrapper, + attributes: { + id: "resume-button", + class: "resume-button", + }, + prefix, + }); + + return container; + }, + + destroy() { + this.hide(); + this.markup.destroy(); + this.env = null; + this.lastTarget = null; + }, + + onClick(target) { + const { id } = target; + if (!id) { + return; + } + + if (id.includes("paused-dbg-step-button")) { + this.stepOver(); + } else if (id.includes("paused-dbg-resume-button")) { + this.resume(); + } + }, + + onMouseMove(target) { + // Not an element we care about + if (!target || !target.id) { + return; + } + + // If the user didn't change targets, do nothing + if (this.lastTarget && this.lastTarget.id === target.id) { + return; + } + + if ( + target.id.includes("step-button") || + target.id.includes("resume-button") + ) { + // The hover should be applied to the wrapper (icon's parent node) + const newTarget = target.parentNode.id.includes("wrapper") + ? target.parentNode + : target; + + // Remove the hover class if the user has changed buttons + if (this.lastTarget && this.lastTarget != newTarget) { + this.lastTarget.classList.remove("hover"); + } + newTarget.classList.add("hover"); + this.lastTarget = newTarget; + } else if (this.lastTarget) { + // Remove the hover class if the user isn't on a button + this.lastTarget.classList.remove("hover"); + } + }, + + handleEvent(e) { + switch (e.type) { + case "mousedown": + this.onClick(e.target); + break; + case "DOMMouseScroll": + // Prevent scrolling. That's because we only took a screenshot of the viewport, so + // scrolling out of the viewport wouldn't draw the expected things. In the future + // we can take the screenshot again on scroll, but for now it doesn't seem + // important. + e.preventDefault(); + break; + + case "mousemove": + this.onMouseMove(e.target); + break; + } + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + show(reason) { + if (this.env.isXUL || !reason) { + return false; + } + + try { + reason = L10N.getStr(`whyPaused.${reason}`); + } catch (e) { + // This is a temporary workaround (See Bug 1591025). + // This actors relies on a client side properties file. This file will not + // be available when debugging Firefox for Android / Gecko View. + // The highlighter also shows buttons that use client only images and are + // therefore invisible when remote debugging a mobile Firefox. + return false; + } + + // Only track mouse movement when the the overlay is shown + // Prevents mouse tracking when the user isn't paused + const { pageListenerTarget } = this.env; + pageListenerTarget.addEventListener("mousemove", this); + + // Show the highlighter's root element. + const root = this.getElement("root"); + root.removeAttribute("hidden"); + root.setAttribute("overlay", "true"); + + // Set the text to appear in the toolbar. + const toolbar = this.getElement("toolbar"); + this.getElement("reason").setTextContent(reason); + toolbar.removeAttribute("hidden"); + + // When the debugger pauses execution in a page, events will not be delivered + // to any handlers added to elements on that page. So here we use the + // document's setSuppressedEventListener interface to still be able to act on mouse + // events (they'll be handled by the `handleEvent` method) + this.env.window.document.setSuppressedEventListener(this); + return true; + }, + + hide() { + if (this.env.isXUL) { + return; + } + + const { pageListenerTarget } = this.env; + pageListenerTarget.removeEventListener("mousemove", this); + + // Hide the overlay. + this.getElement("root").setAttribute("hidden", "true"); + }, +}; +exports.PausedDebuggerOverlay = PausedDebuggerOverlay; diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js new file mode 100644 index 0000000000..fc5c66c6b7 --- /dev/null +++ b/devtools/server/actors/highlighters/rulers.js @@ -0,0 +1,341 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + getCurrentZoom, + setIgnoreLayoutChanges, +} = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, +} = require("devtools/server/actors/highlighters/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) + ); + this.isReady = this.markup.initialize(); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("scroll", this); + pageListenerTarget.addEventListener("pagehide", this); +} + +RulersHighlighter.prototype = { + ID_CLASS_PREFIX: "rulers-highlighter-", + + _buildMarkup: function() { + const prefix = this.ID_CLASS_PREFIX; + + const createRuler = (axis, size) => { + let width, height; + let isHorizontal = true; + + if (axis === "x") { + width = size; + height = 16; + } else if (axis === "y") { + width = 16; + height = size; + isHorizontal = false; + } else { + throw new Error( + `Invalid type of axis given; expected "x" or "y" but got "${axis}"` + ); + } + + const g = this.markup.createSVGNode({ + nodeType: "g", + attributes: { + id: `${axis}-axis`, + }, + parent: svg, + prefix, + }); + + this.markup.createSVGNode({ + nodeType: "rect", + attributes: { + y: isHorizontal ? 0 : 16, + width, + height, + }, + parent: g, + }); + + const gRule = this.markup.createSVGNode({ + nodeType: "g", + attributes: { + id: `${axis}-axis-ruler`, + }, + parent: g, + prefix, + }); + + const pathGraduations = this.markup.createSVGNode({ + nodeType: "path", + attributes: { + class: "ruler-graduations", + width, + height, + }, + parent: gRule, + prefix, + }); + + const pathMarkers = this.markup.createSVGNode({ + nodeType: "path", + attributes: { + class: "ruler-markers", + width, + height, + }, + parent: gRule, + prefix, + }); + + const gText = this.markup.createSVGNode({ + nodeType: "g", + attributes: { + id: `${axis}-axis-text`, + class: (isHorizontal ? "horizontal" : "vertical") + "-labels", + }, + parent: g, + prefix, + }); + + let dGraduations = ""; + let dMarkers = ""; + let graduationLength; + + for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) { + if (i === 0) { + continue; + } + + graduationLength = i % 2 === 0 ? 6 : 4; + + if (i % RULERS_TEXT_STEP === 0) { + graduationLength = 8; + this.markup.createSVGNode({ + nodeType: "text", + parent: gText, + attributes: { + x: isHorizontal ? 2 + i : -i - 1, + y: 5, + }, + }).textContent = i; + } + + if (isHorizontal) { + if (i % RULERS_MARKER_STEP === 0) { + dMarkers += `M${i} 0 L${i} ${graduationLength}`; + } else { + dGraduations += `M${i} 0 L${i} ${graduationLength} `; + } + } else if (i % 50 === 0) { + dMarkers += `M0 ${i} L${graduationLength} ${i}`; + } else { + dGraduations += `M0 ${i} L${graduationLength} ${i}`; + } + } + + pathGraduations.setAttribute("d", dGraduations); + pathMarkers.setAttribute("d", dMarkers); + + return g; + }; + + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + class: "elements", + width: "100%", + height: "100%", + hidden: "true", + }, + prefix, + }); + + createRuler("x", RULERS_MAX_X_AXIS); + createRuler("y", RULERS_MAX_Y_AXIS); + + this.markup.createNode({ + parent: container, + attributes: { + class: "viewport-infobar-container", + id: "viewport-infobar-container", + position: "top", + }, + prefix, + }); + + return container; + }, + + handleEvent: function(event) { + switch (event.type) { + case "scroll": + this._onScroll(event); + break; + case "pagehide": + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (event.target.defaultView === this.env.window) { + this.destroy(); + } + break; + } + }, + + _onScroll: function(event) { + const prefix = this.ID_CLASS_PREFIX; + const { scrollX, scrollY } = event.view; + + this.markup + .getElement(`${prefix}x-axis-ruler`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup + .getElement(`${prefix}x-axis-text`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup + .getElement(`${prefix}y-axis-ruler`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + this.markup + .getElement(`${prefix}y-axis-text`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + }, + + _update: function() { + const { window } = this.env; + + setIgnoreLayoutChanges(true); + + const zoom = getCurrentZoom(window); + const isZoomChanged = zoom !== this._zoom; + + if (isZoomChanged) { + this._zoom = zoom; + this.updateViewport(); + } + + this.updateViewportInfobar(); + + 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() { + const { devicePixelRatio } = this.env.window; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + const pixelRatio = devicePixelRatio / this._zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + const minWidth = 1 / pixelRatio; + const strokeWidth = Math.min(minWidth, minWidth / this._zoom); + + this.markup + .getElement(this.ID_CLASS_PREFIX + "root") + .setAttribute("style", `stroke-width:${strokeWidth};`); + }, + + updateViewportInfobar: function() { + const { window } = this.env; + const { innerHeight, innerWidth } = window; + const infobarId = this.ID_CLASS_PREFIX + "viewport-infobar-container"; + const textContent = innerWidth + "px \u00D7 " + innerHeight + "px"; + this.markup.getElement(infobarId).setTextContent(textContent); + }, + + destroy: function() { + this.hide(); + + const { pageListenerTarget } = this.env; + + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("scroll", this); + pageListenerTarget.removeEventListener("pagehide", this); + } + + this.markup.destroy(); + + EventEmitter.emit(this, "destroy"); + }, + + show: function() { + this.markup.removeAttributeForElement( + this.ID_CLASS_PREFIX + "elements", + "hidden" + ); + this.markup.removeAttributeForElement( + this.ID_CLASS_PREFIX + "viewport-infobar-container", + "hidden" + ); + + this._update(); + + return true; + }, + + hide: function() { + this.markup.setAttributeForElement( + this.ID_CLASS_PREFIX + "elements", + "hidden", + "true" + ); + this.markup.setAttributeForElement( + this.ID_CLASS_PREFIX + "viewport-infobar-container", + "hidden", + "true" + ); + + this._cancelUpdate(); + }, +}; +exports.RulersHighlighter = RulersHighlighter; diff --git a/devtools/server/actors/highlighters/selector.js b/devtools/server/actors/highlighters/selector.js new file mode 100644 index 0000000000..c09b963320 --- /dev/null +++ b/devtools/server/actors/highlighters/selector.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + isNodeValid, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { + BoxModelHighlighter, +} = require("devtools/server/actors/highlighters/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 = { + /** + * Show a BoxModelHighlighter on each node that matches a given selector. + * + * @param {DOMNode} node + * A context node used to get the document element on which to run + * querySelectorAll(). This node will not be highlighted. + * @param {Object} options + * Configuration options for SelectorHighlighter. + * All of the options for BoxModelHighlighter.show() are also valid here. + * @param {String} options.selector + * Required. CSS selector used with querySelectorAll() to find matching elements. + */ + show: async 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. + } + + // Prevent passing the `selector` option to BoxModelHighlighter + delete options.selector; + + const promises = []; + for (let i = 0; i < Math.min(nodes.length, MAX_HIGHLIGHTED_ELEMENTS); i++) { + promises.push(this._showHighlighter(nodes[i], options)); + } + + await Promise.all(promises); + return true; + }, + + /** + * Create an instance of BoxModelHighlighter, wait for it to be ready + * (see CanvasFrameAnonymousContentHelper.initialize()), + * then show the highlighter on the given node with the given configuration options. + * + * @param {DOMNode} node + * Node to be highlighted + * @param {Object} options + * Configuration options for the BoxModelHighlighter + * @return {Promise} Promise that resolves when the BoxModelHighlighter is ready + */ + _showHighlighter: async function(node, options) { + const highlighter = new BoxModelHighlighter(this.highlighterEnv); + await highlighter.isReady; + + highlighter.show(node, options); + this._highlighters.push(highlighter); + }, + + hide: function() { + for (const 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/shapes.js b/devtools/server/actors/highlighters/shapes.js new file mode 100644 index 0000000000..bad75f5ca8 --- /dev/null +++ b/devtools/server/actors/highlighters/shapes.js @@ -0,0 +1,3259 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { + setIgnoreLayoutChanges, + getCurrentZoom, + getAdjustedQuads, + getFrameOffsets, +} = require("devtools/shared/layout/utils"); +const { + AutoRefreshHighlighter, +} = require("devtools/server/actors/highlighters/auto-refresh"); +const { + getDistance, + clickedOnEllipseEdge, + distanceToLine, + projection, + clickedOnPoint, +} = require("devtools/server/actors/utils/shapes-utils"); +const { + identity, + apply, + translate, + multiply, + scale, + rotate, + changeMatrixBase, + getBasis, +} = require("devtools/shared/layout/dom-matrix-2d"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { getCSSStyleRules } = require("devtools/shared/inspector/css-logic"); + +const BASE_MARKER_SIZE = 5; +// the width of the area around highlighter lines that can be clicked, in px +const LINE_CLICK_WIDTH = 5; +const ROTATE_LINE_LENGTH = 50; +const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"]; +const _dragging = Symbol("shapes/dragging"); + +/** + * The ShapesHighlighter draws an outline shapes in the page. + * The idea is to have something that is able to wrap complex shapes for css properties + * such as shape-outside/inside, clip-path but also SVG elements. + * + * Notes on shape transformation: + * + * When using transform mode to translate, scale, and rotate shapes, a transformation + * matrix keeps track of the transformations done to the original shape. When the + * highlighter is toggled on/off or between transform mode and point editing mode, + * the transformations applied to the shape become permanent. + * + * While transformations are being performed on a shape, there is an "original" and + * a "transformed" coordinate system. This is used when scaling or rotating a rotated + * shape. + * + * The "original" coordinate system is the one where (0,0) is at the top left corner + * of the page, the x axis is horizontal, and the y axis is vertical. + * + * The "transformed" coordinate system is the one where (0,0) is at the top left + * corner of the current shape. The x axis follows the north edge of the shape + * (from the northwest corner to the northeast corner) and the y axis follows + * the west edge of the shape (from the northwest corner to the southwest corner). + * + * Because of rotation, the "north" and "west" edges might not actually be at the + * top and left of the transformed shape. Imagine that the compass directions are + * also rotated along with the shape. + * + * A refresher for coordinates and change of basis that may be helpful: + * https://www.math.ubc.ca/~behrend/math221/Coords.pdf + * + * @param {String} options.hoverPoint + * The point to highlight. + * @param {Boolean} options.transformMode + * Whether to show the highlighter in transforms mode. + * @param {} options.mode + */ +class ShapesHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + EventEmitter.decorate(this); + + this.ID_CLASS_PREFIX = "shapes-"; + + this.referenceBox = "border"; + this.useStrokeBox = false; + this.geometryBox = ""; + this.hoveredPoint = null; + this.fillRule = ""; + this.numInsetPoints = 0; + this.transformMode = false; + this.viewport = {}; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + this.onPageHide = this.onPageHide.bind(this); + + const { pageListenerTarget } = this.highlighterEnv; + DOM_EVENTS.forEach(event => + pageListenerTarget.addEventListener(event, this) + ); + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + // The root wrapper is used to unzoom the highlighter when needed. + const rootWrapper = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const mainSvg = this.markup.createSVGNode({ + nodeType: "svg", + parent: rootWrapper, + attributes: { + id: "shape-container", + class: "shape-container", + viewBox: "0 0 100 100", + preserveAspectRatio: "none", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // This clipPath and its children make sure the element quad outline + // is only shown when the shape extends past the element quads. + const clipSvg = this.markup.createSVGNode({ + nodeType: "clipPath", + parent: mainSvg, + attributes: { + id: "clip-path", + class: "clip-path", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "polygon", + parent: clipSvg, + attributes: { + id: "clip-polygon", + class: "clip-polygon", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "ellipse", + parent: clipSvg, + attributes: { + id: "clip-ellipse", + class: "clip-ellipse", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "rect", + parent: clipSvg, + attributes: { + id: "clip-rect", + class: "clip-rect", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Rectangle that displays the element quads. Only shown for shape-outside. + // Only the parts of the rectangle's outline that overlap with the shape is shown. + this.markup.createSVGNode({ + nodeType: "rect", + parent: mainSvg, + attributes: { + id: "quad", + class: "quad", + hidden: "true", + "clip-path": "url(#shapes-clip-path)", + x: 0, + y: 0, + width: 100, + height: 100, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // clipPath that corresponds to the element's quads. Only applied for shape-outside. + // This ensures only the parts of the shape that are within the element's quads are + // outlined by a solid line. + const shapeClipSvg = this.markup.createSVGNode({ + nodeType: "clipPath", + parent: mainSvg, + attributes: { + id: "quad-clip-path", + class: "quad-clip-path", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "rect", + parent: shapeClipSvg, + attributes: { + id: "quad-clip", + class: "quad-clip", + x: -1, + y: -1, + width: 102, + height: 102, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const mainGroup = this.markup.createSVGNode({ + nodeType: "g", + parent: mainSvg, + attributes: { + id: "group", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Append a polygon for polygon shapes. + this.markup.createSVGNode({ + nodeType: "polygon", + parent: mainGroup, + attributes: { + id: "polygon", + class: "polygon", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Append an ellipse for circle/ellipse shapes. + this.markup.createSVGNode({ + nodeType: "ellipse", + parent: mainGroup, + attributes: { + id: "ellipse", + class: "ellipse", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Append a rect for inset(). + this.markup.createSVGNode({ + nodeType: "rect", + parent: mainGroup, + attributes: { + id: "rect", + class: "rect", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Dashed versions of each shape. Only shown for the parts of the shape + // that extends past the element's quads. + this.markup.createSVGNode({ + nodeType: "polygon", + parent: mainGroup, + attributes: { + id: "dashed-polygon", + class: "polygon", + hidden: "true", + "stroke-dasharray": "5, 5", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "ellipse", + parent: mainGroup, + attributes: { + id: "dashed-ellipse", + class: "ellipse", + hidden: "true", + "stroke-dasharray": "5, 5", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "rect", + parent: mainGroup, + attributes: { + id: "dashed-rect", + class: "rect", + hidden: "true", + "stroke-dasharray": "5, 5", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: mainGroup, + attributes: { + id: "bounding-box", + class: "bounding-box", + "stroke-dasharray": "5, 5", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: mainGroup, + attributes: { + id: "rotate-line", + class: "rotate-line", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Append a path to display the markers for the shape. + this.markup.createSVGNode({ + nodeType: "path", + parent: mainGroup, + attributes: { + id: "markers-outline", + class: "markers-outline", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: mainGroup, + attributes: { + id: "markers", + class: "markers", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: mainGroup, + attributes: { + id: "marker-hover", + class: "marker-hover", + hidden: true, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return container; + } + + get currentDimensions() { + let dims = this.currentQuads[this.referenceBox][0].bounds; + const zoom = getCurrentZoom(this.win); + + // If an SVG element has a stroke, currentQuads will return the stroke bounding box. + // However, clip-path always uses the object bounding box unless "stroke-box" is + // specified. So, we must calculate the object bounding box if there is a stroke + // and "stroke-box" is not specified. stroke only applies to SVG elements, so use + // getBBox, which only exists for SVG, to check if currentNode is an SVG element. + if ( + this.currentNode.getBBox && + getComputedStyle(this.currentNode).stroke !== "none" && + !this.useStrokeBox + ) { + dims = getObjectBoundingBox( + dims.top, + dims.left, + dims.width, + dims.height, + this.currentNode + ); + } + + return { + top: dims.top / zoom, + left: dims.left / zoom, + width: dims.width / zoom, + height: dims.height / zoom, + }; + } + + get frameDimensions() { + // In an iframe, we get the node's quads relative to the frame, instead of the parent + // document. + let dims = + this.highlighterEnv.window.document === this.currentNode.ownerDocument + ? this.currentQuads[this.referenceBox][0].bounds + : getAdjustedQuads( + this.currentNode.ownerGlobal, + this.currentNode, + this.referenceBox + )[0].bounds; + const zoom = getCurrentZoom(this.win); + + // If an SVG element has a stroke, currentQuads will return the stroke bounding box. + // However, clip-path always uses the object bounding box unless "stroke-box" is + // specified. So, we must calculate the object bounding box if there is a stroke + // and "stroke-box" is not specified. stroke only applies to SVG elements, so use + // getBBox, which only exists for SVG, to check if currentNode is an SVG element. + if ( + this.currentNode.getBBox && + getComputedStyle(this.currentNode).stroke !== "none" && + !this.useStrokeBox + ) { + dims = getObjectBoundingBox( + dims.top, + dims.left, + dims.width, + dims.height, + this.currentNode + ); + } + + return { + top: dims.top / zoom, + left: dims.left / zoom, + width: dims.width / zoom, + height: dims.height / zoom, + }; + } + + /** + * Changes the appearance of the mouse cursor on the highlighter. + * + * Because we can't attach event handlers to individual elements in the + * highlighter, we determine if the mouse is hovering over a point by seeing if + * it's within 5 pixels of it. This creates a square hitbox that doesn't match + * perfectly with the circular markers. So if we were to use the :hover + * pseudo-class to apply changes to the mouse cursor, the cursor change would not + * always accurately reflect whether you can interact with the point. This is + * also the reason we have the hidden marker-hover element instead of using CSS + * to fill in the marker. + * + * In addition, the cursor CSS property is applied to .shapes-root because if + * it were attached to .shapes-marker, the cursor change no longer applies if + * you are for example resizing the shape and your mouse goes off the point. + * Also, if you are dragging a polygon point, the marker plays catch up to your + * mouse position, resulting in an undesirable visual effect where the cursor + * rapidly flickers between "grab" and "auto". + * + * @param {String} cursorType the name of the cursor to display + */ + setCursor(cursorType) { + const container = this.getElement("root"); + let style = container.getAttribute("style"); + // remove existing cursor definitions in the style + style = style.replace(/cursor:.*?;/g, ""); + style = style.replace(/pointer-events:.*?;/g, ""); + const pointerEvents = cursorType === "auto" ? "none" : "auto"; + container.setAttribute( + "style", + `${style}pointer-events:${pointerEvents};cursor:${cursorType};` + ); + } + + /** + * Set the absolute pixel offsets which define the current viewport in relation to + * the full page size. + * + * If a padding value is given, inset the viewport by this value. This is used to define + * a virtual viewport which ensures some element remains visible even when at the edges + * of the actual viewport. + * + * @param {Number} padding + * Optional. Amount by which to inset the viewport in all directions. + */ + setViewport(padding = 0) { + let xOffset = 0; + let yOffset = 0; + + // If the node exists within an iframe, get offsets for the virtual viewport so that + // points can be dragged to the extent of the global window, outside of the iframe + // window. + if (this.currentNode.ownerGlobal !== this.win) { + const win = this.win; + const nodeWin = this.currentNode.ownerGlobal; + // Get bounding box of iframe document relative to global document. + const bounds = nodeWin.document + .getBoxQuads({ + relativeTo: win.document, + createFramesForSuppressedWhitespace: false, + })[0] + .getBounds(); + xOffset = bounds.left - nodeWin.scrollX + win.scrollX; + yOffset = bounds.top - nodeWin.scrollY + win.scrollY; + } + + const { pageXOffset, pageYOffset } = this.win; + const { clientHeight, clientWidth } = this.win.document.documentElement; + const left = pageXOffset + padding - xOffset; + const right = clientWidth + pageXOffset - padding - xOffset; + const top = pageYOffset + padding - yOffset; + const bottom = clientHeight + pageYOffset - padding - yOffset; + this.viewport = { left, right, top, bottom, padding }; + } + + // eslint-disable-next-line complexity + handleEvent(event, id) { + // No event handling if the highlighter is hidden + if (this.areShapesHidden()) { + return; + } + + let { target, type, pageX, pageY } = event; + + // For events on highlighted nodes in an iframe, when the event takes place + // outside the iframe. Check if event target belongs to the iframe. If it doesn't, + // adjust pageX/pageY to be relative to the iframe rather than the parent. + const nodeDocument = this.currentNode.ownerDocument; + if (target !== nodeDocument && target.ownerDocument !== nodeDocument) { + const [xOffset, yOffset] = getFrameOffsets( + target.ownerGlobal, + this.currentNode + ); + const zoom = getCurrentZoom(this.win); + // xOffset/yOffset are relative to the viewport, so first find the top/left + // edges of the viewport relative to the page. + const viewportLeft = pageX - event.clientX; + const viewportTop = pageY - event.clientY; + // Also adjust for scrolling in the iframe. + const { scrollTop, scrollLeft } = nodeDocument.documentElement; + pageX -= viewportLeft + xOffset / zoom - scrollLeft; + pageY -= viewportTop + yOffset / zoom - scrollTop; + } + + switch (type) { + case "pagehide": + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.win) { + this.destroy(); + } + + break; + case "mousedown": + if (this.transformMode) { + this._handleTransformClick(pageX, pageY); + } else if (this.shapeType === "polygon") { + this._handlePolygonClick(pageX, pageY); + } else if (this.shapeType === "circle") { + this._handleCircleClick(pageX, pageY); + } else if (this.shapeType === "ellipse") { + this._handleEllipseClick(pageX, pageY); + } else if (this.shapeType === "inset") { + this._handleInsetClick(pageX, pageY); + } + event.stopPropagation(); + event.preventDefault(); + + // Calculate constraints for a virtual viewport which ensures that a dragged + // marker remains visible even at the edges of the actual viewport. + this.setViewport(BASE_MARKER_SIZE); + break; + case "mouseup": + if (this[_dragging]) { + this[_dragging] = null; + this._handleMarkerHover(this.hoveredPoint); + } + break; + case "mousemove": + if (!this[_dragging]) { + this._handleMouseMoveNotDragging(pageX, pageY); + return; + } + event.stopPropagation(); + event.preventDefault(); + + // Set constraints for mouse position to ensure dragged marker stays in viewport. + const { left, right, top, bottom } = this.viewport; + pageX = Math.min(Math.max(left, pageX), right); + pageY = Math.min(Math.max(top, pageY), bottom); + + const { point } = this[_dragging]; + if (this.transformMode) { + this._handleTransformMove(pageX, pageY); + } else if (this.shapeType === "polygon") { + this._handlePolygonMove(pageX, pageY); + } else if (this.shapeType === "circle") { + this._handleCircleMove(point, pageX, pageY); + } else if (this.shapeType === "ellipse") { + this._handleEllipseMove(point, pageX, pageY); + } else if (this.shapeType === "inset") { + this._handleInsetMove(point, pageX, pageY); + } + break; + case "dblclick": + if (this.shapeType === "polygon" && !this.transformMode) { + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const index = this.getPolygonPointAt(percentX, percentY); + if (index === -1) { + this.getPolygonClickedLine(percentX, percentY); + return; + } + + this._deletePolygonPoint(index); + } + break; + } + } + + /** + * Handle a mouse click in transform mode. + * @param {Number} pageX the x coordinate of the mouse + * @param {Number} pageY the y coordinate of the mouse + */ + _handleTransformClick(pageX, pageY) { + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const type = this.getTransformPointAt(percentX, percentY); + if (!type) { + return; + } + + if (this.shapeType === "polygon") { + this._handlePolygonTransformClick(pageX, pageY, type); + } else if (this.shapeType === "circle") { + this._handleCircleTransformClick(pageX, pageY, type); + } else if (this.shapeType === "ellipse") { + this._handleEllipseTransformClick(pageX, pageY, type); + } else if (this.shapeType === "inset") { + this._handleInsetTransformClick(pageX, pageY, type); + } + } + + /** + * Handle a click in transform mode while highlighting a polygon. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + * @param {String} type the type of transform handle that was clicked. + */ + _handlePolygonTransformClick(pageX, pageY, type) { + const { width, height } = this.currentDimensions; + const pointsInfo = this.origCoordUnits.map(([x, y], i) => { + const xComputed = (this.origCoordinates[i][0] / 100) * width; + const yComputed = (this.origCoordinates[i][1] / 100) * height; + const unitX = getUnit(x); + const unitY = getUnit(y); + const valueX = isUnitless(x) ? xComputed : parseFloat(x); + const valueY = isUnitless(y) ? yComputed : parseFloat(y); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + return { unitX, unitY, valueX, valueY, ratioX, ratioY }; + }); + this[_dragging] = { + type, + pointsInfo, + x: pageX, + y: pageY, + bb: this.boundingBox, + matrix: this.transformMatrix, + transformedBB: this.transformedBoundingBox, + }; + this._handleMarkerHover(this.hoveredPoint); + } + + /** + * Handle a click in transform mode while highlighting a circle. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + * @param {String} type the type of transform handle that was clicked. + */ + _handleCircleTransformClick(pageX, pageY, type) { + const { width, height } = this.currentDimensions; + const { cx, cy } = this.origCoordUnits; + const cxComputed = (this.origCoordinates.cx / 100) * width; + const cyComputed = (this.origCoordinates.cy / 100) * height; + const unitX = getUnit(cx); + const unitY = getUnit(cy); + const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); + const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + + let { radius } = this.origCoordinates; + const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); + radius = (radius / 100) * computedSize; + let valueRad = this.origCoordUnits.radius; + const unitRad = getUnit(valueRad); + valueRad = isUnitless(valueRad) ? radius : parseFloat(valueRad); + const ratioRad = this.getUnitToPixelRatio(unitRad, computedSize); + + this[_dragging] = { + type, + unitX, + unitY, + unitRad, + valueX, + valueY, + ratioX, + ratioY, + ratioRad, + x: pageX, + y: pageY, + bb: this.boundingBox, + matrix: this.transformMatrix, + transformedBB: this.transformedBoundingBox, + }; + } + + /** + * Handle a click in transform mode while highlighting an ellipse. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + * @param {String} type the type of transform handle that was clicked. + */ + _handleEllipseTransformClick(pageX, pageY, type) { + const { width, height } = this.currentDimensions; + const { cx, cy } = this.origCoordUnits; + const cxComputed = (this.origCoordinates.cx / 100) * width; + const cyComputed = (this.origCoordinates.cy / 100) * height; + const unitX = getUnit(cx); + const unitY = getUnit(cy); + const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); + const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + + let { rx, ry } = this.origCoordinates; + rx = (rx / 100) * width; + let valueRX = this.origCoordUnits.rx; + const unitRX = getUnit(valueRX); + valueRX = isUnitless(valueRX) ? rx : parseFloat(valueRX); + const ratioRX = valueRX / rx || 1; + ry = (ry / 100) * height; + let valueRY = this.origCoordUnits.ry; + const unitRY = getUnit(valueRY); + valueRY = isUnitless(valueRY) ? ry : parseFloat(valueRY); + const ratioRY = valueRY / ry || 1; + + this[_dragging] = { + type, + unitX, + unitY, + unitRX, + unitRY, + valueX, + valueY, + ratioX, + ratioY, + ratioRX, + ratioRY, + x: pageX, + y: pageY, + bb: this.boundingBox, + matrix: this.transformMatrix, + transformedBB: this.transformedBoundingBox, + }; + } + + /** + * Handle a click in transform mode while highlighting an inset. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + * @param {String} type the type of transform handle that was clicked. + */ + _handleInsetTransformClick(pageX, pageY, type) { + const { width, height } = this.currentDimensions; + const pointsInfo = {}; + ["top", "right", "bottom", "left"].forEach(point => { + let value = this.origCoordUnits[point]; + const size = point === "left" || point === "right" ? width : height; + const computedValue = (this.origCoordinates[point] / 100) * size; + const unit = getUnit(value); + value = isUnitless(value) ? computedValue : parseFloat(value); + const ratio = this.getUnitToPixelRatio(unit, size); + + pointsInfo[point] = { value, unit, ratio }; + }); + this[_dragging] = { + type, + pointsInfo, + x: pageX, + y: pageY, + bb: this.boundingBox, + matrix: this.transformMatrix, + transformedBB: this.transformedBoundingBox, + }; + } + + /** + * Handle mouse movement after a click on a handle in transform mode. + * @param {Number} pageX the x coordinate of the mouse + * @param {Number} pageY the y coordinate of the mouse + */ + _handleTransformMove(pageX, pageY) { + const { type } = this[_dragging]; + if (type === "translate") { + this._translateShape(pageX, pageY); + } else if (type.includes("scale")) { + this._scaleShape(pageX, pageY); + } else if (type === "rotate" && this.shapeType === "polygon") { + this._rotateShape(pageX, pageY); + } + + this.transformedBoundingBox = this.calculateTransformedBoundingBox(); + } + + /** + * Translates a shape based on the current mouse position. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + */ + _translateShape(pageX, pageY) { + const { x, y, matrix } = this[_dragging]; + const deltaX = pageX - x; + const deltaY = pageY - y; + this.transformMatrix = multiply(translate(deltaX, deltaY), matrix); + + if (this.shapeType === "polygon") { + this._transformPolygon(); + } else if (this.shapeType === "circle") { + this._transformCircle(); + } else if (this.shapeType === "ellipse") { + this._transformEllipse(); + } else if (this.shapeType === "inset") { + this._transformInset(); + } + } + + /** + * Scales a shape according to the current mouse position. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + */ + _scaleShape(pageX, pageY) { + /** + * To scale a shape: + * 1) Get the change of basis matrix corresponding to the current transformation + * matrix of the shape. + * 2) Convert the mouse x/y deltas to the "transformed" coordinate system, using + * the change of base matrix. + * 3) Calculate the proportion to which the shape should be scaled to, using the + * mouse x/y deltas and the width/height of the transformed shape. + * 4) Translate the shape such that the anchor (the point opposite to the one + * being dragged) is at the top left of the element. + * 5) Scale each point by multiplying by the scaling proportion. + * 6) Translate the shape back such that the anchor is in its original position. + */ + const { type, x, y, matrix } = this[_dragging]; + const { width, height } = this.currentDimensions; + // The point opposite to the one being dragged + const anchor = getAnchorPoint(type); + + const { ne, nw, sw } = this[_dragging].transformedBB; + // u/v are the basis vectors of the transformed coordinate system. + const u = [ + ((ne[0] - nw[0]) / 100) * width, + ((ne[1] - nw[1]) / 100) * height, + ]; + const v = [ + ((sw[0] - nw[0]) / 100) * width, + ((sw[1] - nw[1]) / 100) * height, + ]; + // uLength/vLength represent the width/height of the shape in the + // transformed coordinate system. + const { basis, invertedBasis, uLength, vLength } = getBasis(u, v); + + // How much points on each axis should be translated before scaling + const transX = (this[_dragging].transformedBB[anchor][0] / 100) * width; + const transY = (this[_dragging].transformedBB[anchor][1] / 100) * height; + + // Distance from original click to current mouse position + const distanceX = pageX - x; + const distanceY = pageY - y; + // Convert from original coordinate system to transformed coordinate system + const tDistanceX = + invertedBasis[0] * distanceX + invertedBasis[1] * distanceY; + const tDistanceY = + invertedBasis[3] * distanceX + invertedBasis[4] * distanceY; + + // Proportion of distance to bounding box width/height of shape + const proportionX = tDistanceX / uLength; + const proportionY = tDistanceY / vLength; + // proportionX is positive for size reductions dragging on w/nw/sw, + // negative for e/ne/se. + const scaleX = type.includes("w") ? 1 - proportionX : 1 + proportionX; + // proportionT is positive for size reductions dragging on n/nw/ne, + // negative for s/sw/se. + const scaleY = type.includes("n") ? 1 - proportionY : 1 + proportionY; + // Take the average of scaleX/scaleY for scaling on two axes + const scaleXY = (scaleX + scaleY) / 2; + + const translateMatrix = translate(-transX, -transY); + let scaleMatrix = identity(); + // The scale matrices are in the transformed coordinate system. We must convert + // them to the original coordinate system before applying it to the transformation + // matrix. + if (type === "scale-e" || type === "scale-w") { + scaleMatrix = changeMatrixBase(scale(scaleX, 1), invertedBasis, basis); + } else if (type === "scale-n" || type === "scale-s") { + scaleMatrix = changeMatrixBase(scale(1, scaleY), invertedBasis, basis); + } else { + scaleMatrix = changeMatrixBase( + scale(scaleXY, scaleXY), + invertedBasis, + basis + ); + } + const translateBackMatrix = translate(transX, transY); + this.transformMatrix = multiply( + translateBackMatrix, + multiply(scaleMatrix, multiply(translateMatrix, matrix)) + ); + + if (this.shapeType === "polygon") { + this._transformPolygon(); + } else if (this.shapeType === "circle") { + this._transformCircle(transX); + } else if (this.shapeType === "ellipse") { + this._transformEllipse(transX, transY); + } else if (this.shapeType === "inset") { + this._transformInset(); + } + } + + /** + * Rotates a polygon based on the current mouse position. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + */ + _rotateShape(pageX, pageY) { + const { matrix } = this[_dragging]; + const { center, ne, nw, sw } = this[_dragging].transformedBB; + const { width, height } = this.currentDimensions; + const centerX = (center[0] / 100) * width; + const centerY = (center[1] / 100) * height; + const { x: pageCenterX, y: pageCenterY } = this.convertPercentToPageCoords( + ...center + ); + + const dx = pageCenterX - pageX; + const dy = pageCenterY - pageY; + + const u = [ + ((ne[0] - nw[0]) / 100) * width, + ((ne[1] - nw[1]) / 100) * height, + ]; + const v = [ + ((sw[0] - nw[0]) / 100) * width, + ((sw[1] - nw[1]) / 100) * height, + ]; + const { invertedBasis } = getBasis(u, v); + + const tdx = invertedBasis[0] * dx + invertedBasis[1] * dy; + const tdy = invertedBasis[3] * dx + invertedBasis[4] * dy; + const angle = Math.atan2(tdx, tdy); + const translateMatrix = translate(-centerX, -centerY); + const rotateMatrix = rotate(angle); + const translateBackMatrix = translate(centerX, centerY); + this.transformMatrix = multiply( + translateBackMatrix, + multiply(rotateMatrix, multiply(translateMatrix, matrix)) + ); + + this._transformPolygon(); + } + + /** + * Transform a polygon depending on the current transformation matrix. + */ + _transformPolygon() { + const { pointsInfo } = this[_dragging]; + + let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; + polygonDef += pointsInfo + .map(point => { + const { unitX, unitY, valueX, valueY, ratioX, ratioY } = point; + const vector = [valueX / ratioX, valueY / ratioY]; + let [newX, newY] = apply(this.transformMatrix, vector); + newX = round(newX * ratioX, unitX); + newY = round(newY * ratioY, unitY); + + return `${newX}${unitX} ${newY}${unitY}`; + }) + .join(", "); + polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); + } + + /** + * Transform a circle depending on the current transformation matrix. + * @param {Number} transX the number of pixels the shape is translated on the x axis + * before scaling + */ + _transformCircle(transX = null) { + const { + unitX, + unitY, + unitRad, + valueX, + valueY, + ratioX, + ratioY, + ratioRad, + } = this[_dragging]; + let { radius } = this.coordUnits; + + let [newCx, newCy] = apply(this.transformMatrix, [ + valueX / ratioX, + valueY / ratioY, + ]); + if (transX !== null) { + // As part of scaling, the shape is translated to be tangent to the line y=0. + // To get the new radius, we translate the new cx back to that point and get + // the distance to the line y=0. + radius = round(Math.abs((newCx - transX) * ratioRad), unitRad); + radius = `${radius}${unitRad}`; + } + + newCx = round(newCx * ratioX, unitX); + newCy = round(newCy * ratioY, unitY); + const circleDef = + `circle(${radius} at ${newCx}${unitX} ${newCy}${unitY})` + + ` ${this.geometryBox}`.trim(); + this.emit("highlighter-event", { type: "shape-change", value: circleDef }); + } + + /** + * Transform an ellipse depending on the current transformation matrix. + * @param {Number} transX the number of pixels the shape is translated on the x axis + * before scaling + * @param {Number} transY the number of pixels the shape is translated on the y axis + * before scaling + */ + _transformEllipse(transX = null, transY = null) { + const { + unitX, + unitY, + unitRX, + unitRY, + valueX, + valueY, + ratioX, + ratioY, + ratioRX, + ratioRY, + } = this[_dragging]; + let { rx, ry } = this.coordUnits; + + let [newCx, newCy] = apply(this.transformMatrix, [ + valueX / ratioX, + valueY / ratioY, + ]); + if (transX !== null && transY !== null) { + // As part of scaling, the shape is translated to be tangent to the lines y=0 & x=0. + // To get the new radii, we translate the new center back to that point and get the + // distances to the line x=0 and y=0. + rx = round(Math.abs((newCx - transX) * ratioRX), unitRX); + rx = `${rx}${unitRX}`; + ry = round(Math.abs((newCy - transY) * ratioRY), unitRY); + ry = `${ry}${unitRY}`; + } + + newCx = round(newCx * ratioX, unitX); + newCy = round(newCy * ratioY, unitY); + + const centerStr = `${newCx}${unitX} ${newCy}${unitY}`; + const ellipseDef = `ellipse(${rx} ${ry} at ${centerStr}) ${this.geometryBox}`.trim(); + this.emit("highlighter-event", { type: "shape-change", value: ellipseDef }); + } + + /** + * Transform an inset depending on the current transformation matrix. + */ + _transformInset() { + const { top, left, right, bottom } = this[_dragging].pointsInfo; + const { width, height } = this.currentDimensions; + + const topLeft = [left.value / left.ratio, top.value / top.ratio]; + let [newLeft, newTop] = apply(this.transformMatrix, topLeft); + newLeft = round(newLeft * left.ratio, left.unit); + newLeft = `${newLeft}${left.unit}`; + newTop = round(newTop * top.ratio, top.unit); + newTop = `${newTop}${top.unit}`; + + // Right and bottom values are relative to the right and bottom edges of the + // element, so convert to the value relative to the left/top edges before scaling + // and convert back. + const bottomRight = [ + width - right.value / right.ratio, + height - bottom.value / bottom.ratio, + ]; + let [newRight, newBottom] = apply(this.transformMatrix, bottomRight); + newRight = round((width - newRight) * right.ratio, right.unit); + newRight = `${newRight}${right.unit}`; + newBottom = round((height - newBottom) * bottom.ratio, bottom.unit); + newBottom = `${newBottom}${bottom.unit}`; + + let insetDef = this.insetRound + ? `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${this.insetRound})` + : `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`; + insetDef += this.geometryBox ? this.geometryBox : ""; + + this.emit("highlighter-event", { type: "shape-change", value: insetDef }); + } + + /** + * Handle a click when highlighting a polygon. + * @param {Number} pageX the x coordinate of the click + * @param {Number} pageY the y coordinate of the click + */ + _handlePolygonClick(pageX, pageY) { + const { width, height } = this.currentDimensions; + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const point = this.getPolygonPointAt(percentX, percentY); + if (point === -1) { + return; + } + + const [x, y] = this.coordUnits[point]; + const xComputed = (this.coordinates[point][0] / 100) * width; + const yComputed = (this.coordinates[point][1] / 100) * height; + const unitX = getUnit(x); + const unitY = getUnit(y); + const valueX = isUnitless(x) ? xComputed : parseFloat(x); + const valueY = isUnitless(y) ? yComputed : parseFloat(y); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + + this.setCursor("grabbing"); + this[_dragging] = { + point, + unitX, + unitY, + valueX, + valueY, + ratioX, + ratioY, + x: pageX, + y: pageY, + }; + } + + /** + * Update the dragged polygon point with the given x/y coords and update + * the element style. + * @param {Number} pageX the new x coordinate of the point + * @param {Number} pageY the new y coordinate of the point + */ + _handlePolygonMove(pageX, pageY) { + const { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[ + _dragging + ]; + const deltaX = (pageX - x) * ratioX; + const deltaY = (pageY - y) * ratioY; + const newX = round(valueX + deltaX, unitX); + const newY = round(valueY + deltaY, unitY); + + let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; + polygonDef += this.coordUnits + .map((coords, i) => { + return i === point + ? `${newX}${unitX} ${newY}${unitY}` + : `${coords[0]} ${coords[1]}`; + }) + .join(", "); + polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); + } + + /** + * Add new point to the polygon defintion and update element style. + * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point. + * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054 + * + * @param {Number} after the index of the point that the new point should be added after + * @param {Number} x the x coordinate of the new point + * @param {Number} y the y coordinate of the new point + */ + _addPolygonPoint(after, x, y) { + let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; + polygonDef += this.coordUnits + .map((coords, i) => { + return i === after + ? `${coords[0]} ${coords[1]}, ${x}% ${y}%` + : `${coords[0]} ${coords[1]}`; + }) + .join(", "); + polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); + + this.hoveredPoint = after + 1; + this._emitHoverEvent(this.hoveredPoint); + this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); + } + + /** + * Remove point from polygon defintion and update the element style. + * @param {Number} point the index of the point to delete + */ + _deletePolygonPoint(point) { + const coordinates = this.coordUnits.slice(); + coordinates.splice(point, 1); + let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; + polygonDef += coordinates + .map((coords, i) => { + return `${coords[0]} ${coords[1]}`; + }) + .join(", "); + polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); + + this.hoveredPoint = null; + this._emitHoverEvent(this.hoveredPoint); + this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); + } + /** + * Handle a click when highlighting a circle. + * @param {Number} pageX the x coordinate of the click + * @param {Number} pageY the y coordinate of the click + */ + _handleCircleClick(pageX, pageY) { + const { width, height } = this.currentDimensions; + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const point = this.getCirclePointAt(percentX, percentY); + if (!point) { + return; + } + + this.setCursor("grabbing"); + if (point === "center") { + const { cx, cy } = this.coordUnits; + const cxComputed = (this.coordinates.cx / 100) * width; + const cyComputed = (this.coordinates.cy / 100) * height; + const unitX = getUnit(cx); + const unitY = getUnit(cy); + const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); + const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + + this[_dragging] = { + point, + unitX, + unitY, + valueX, + valueY, + ratioX, + ratioY, + x: pageX, + y: pageY, + }; + } else if (point === "radius") { + let { radius } = this.coordinates; + const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); + radius = (radius / 100) * computedSize; + let value = this.coordUnits.radius; + const unit = getUnit(value); + value = isUnitless(value) ? radius : parseFloat(value); + const ratio = this.getUnitToPixelRatio(unit, computedSize); + + this[_dragging] = { point, value, origRadius: radius, unit, ratio }; + } + } + + /** + * Set the center/radius of the circle according to the mouse position and + * update the element style. + * @param {String} point either "center" or "radius" + * @param {Number} pageX the x coordinate of the mouse position, in terms of % + * relative to the element + * @param {Number} pageY the y coordinate of the mouse position, in terms of % + * relative to the element + */ + _handleCircleMove(point, pageX, pageY) { + const { radius, cx, cy } = this.coordUnits; + + if (point === "center") { + const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[ + _dragging + ]; + const deltaX = (pageX - x) * ratioX; + const deltaY = (pageY - y) * ratioY; + const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; + const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; + // if not defined by the user, geometryBox will be an empty string; trim() cleans up + const circleDef = `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { + type: "shape-change", + value: circleDef, + }); + } else if (point === "radius") { + const { value, unit, origRadius, ratio } = this[_dragging]; + // convert center point to px, then get distance between center and mouse. + const { x: pageCx, y: pageCy } = this.convertPercentToPageCoords( + this.coordinates.cx, + this.coordinates.cy + ); + const newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY); + + const delta = (newRadiusPx - origRadius) * ratio; + const newRadius = `${round(value + delta, unit)}${unit}`; + + const circleDef = `circle(${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { + type: "shape-change", + value: circleDef, + }); + } + } + + /** + * Handle a click when highlighting an ellipse. + * @param {Number} pageX the x coordinate of the click + * @param {Number} pageY the y coordinate of the click + */ + _handleEllipseClick(pageX, pageY) { + const { width, height } = this.currentDimensions; + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const point = this.getEllipsePointAt(percentX, percentY); + if (!point) { + return; + } + + this.setCursor("grabbing"); + if (point === "center") { + const { cx, cy } = this.coordUnits; + const cxComputed = (this.coordinates.cx / 100) * width; + const cyComputed = (this.coordinates.cy / 100) * height; + const unitX = getUnit(cx); + const unitY = getUnit(cy); + const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); + const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); + + const ratioX = this.getUnitToPixelRatio(unitX, width); + const ratioY = this.getUnitToPixelRatio(unitY, height); + + this[_dragging] = { + point, + unitX, + unitY, + valueX, + valueY, + ratioX, + ratioY, + x: pageX, + y: pageY, + }; + } else if (point === "rx") { + let { rx } = this.coordinates; + rx = (rx / 100) * width; + let value = this.coordUnits.rx; + const unit = getUnit(value); + value = isUnitless(value) ? rx : parseFloat(value); + const ratio = this.getUnitToPixelRatio(unit, width); + + this[_dragging] = { point, value, origRadius: rx, unit, ratio }; + } else if (point === "ry") { + let { ry } = this.coordinates; + ry = (ry / 100) * height; + let value = this.coordUnits.ry; + const unit = getUnit(value); + value = isUnitless(value) ? ry : parseFloat(value); + const ratio = this.getUnitToPixelRatio(unit, height); + + this[_dragging] = { point, value, origRadius: ry, unit, ratio }; + } + } + + /** + * Set center/rx/ry of the ellispe according to the mouse position and update the + * element style. + * @param {String} point "center", "rx", or "ry" + * @param {Number} pageX the x coordinate of the mouse position, in terms of % + * relative to the element + * @param {Number} pageY the y coordinate of the mouse position, in terms of % + * relative to the element + */ + _handleEllipseMove(point, pageX, pageY) { + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const { rx, ry, cx, cy } = this.coordUnits; + + if (point === "center") { + const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[ + _dragging + ]; + const deltaX = (pageX - x) * ratioX; + const deltaY = (pageY - y) * ratioY; + const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; + const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; + const ellipseDef = `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { + type: "shape-change", + value: ellipseDef, + }); + } else if (point === "rx") { + const { value, unit, origRadius, ratio } = this[_dragging]; + const newRadiusPercent = Math.abs(percentX - this.coordinates.cx); + const { width } = this.currentDimensions; + const delta = ((newRadiusPercent / 100) * width - origRadius) * ratio; + const newRadius = `${round(value + delta, unit)}${unit}`; + + const ellipseDef = `ellipse(${newRadius} ${ry} at ${cx} ${cy}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { + type: "shape-change", + value: ellipseDef, + }); + } else if (point === "ry") { + const { value, unit, origRadius, ratio } = this[_dragging]; + const newRadiusPercent = Math.abs(percentY - this.coordinates.cy); + const { height } = this.currentDimensions; + const delta = ((newRadiusPercent / 100) * height - origRadius) * ratio; + const newRadius = `${round(value + delta, unit)}${unit}`; + + const ellipseDef = `ellipse(${rx} ${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim(); + + this.emit("highlighter-event", { + type: "shape-change", + value: ellipseDef, + }); + } + } + + /** + * Handle a click when highlighting an inset. + * @param {Number} pageX the x coordinate of the click + * @param {Number} pageY the y coordinate of the click + */ + _handleInsetClick(pageX, pageY) { + const { width, height } = this.currentDimensions; + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + const point = this.getInsetPointAt(percentX, percentY); + if (!point) { + return; + } + + this.setCursor("grabbing"); + let value = this.coordUnits[point]; + const size = point === "left" || point === "right" ? width : height; + const computedValue = (this.coordinates[point] / 100) * size; + const unit = getUnit(value); + value = isUnitless(value) ? computedValue : parseFloat(value); + const ratio = this.getUnitToPixelRatio(unit, size); + const origValue = point === "left" || point === "right" ? pageX : pageY; + + this[_dragging] = { point, value, origValue, unit, ratio }; + } + + /** + * Set the top/left/right/bottom of the inset shape according to the mouse position + * and update the element style. + * @param {String} point "top", "left", "right", or "bottom" + * @param {Number} pageX the x coordinate of the mouse position, in terms of % + * relative to the element + * @param {Number} pageY the y coordinate of the mouse position, in terms of % + * relative to the element + * @memberof ShapesHighlighter + */ + _handleInsetMove(point, pageX, pageY) { + let { top, left, right, bottom } = this.coordUnits; + const { value, origValue, unit, ratio } = this[_dragging]; + + if (point === "left") { + const delta = (pageX - origValue) * ratio; + left = `${round(value + delta, unit)}${unit}`; + } else if (point === "right") { + const delta = (pageX - origValue) * ratio; + right = `${round(value - delta, unit)}${unit}`; + } else if (point === "top") { + const delta = (pageY - origValue) * ratio; + top = `${round(value + delta, unit)}${unit}`; + } else if (point === "bottom") { + const delta = (pageY - origValue) * ratio; + bottom = `${round(value - delta, unit)}${unit}`; + } + + let insetDef = this.insetRound + ? `inset(${top} ${right} ${bottom} ${left} round ${this.insetRound})` + : `inset(${top} ${right} ${bottom} ${left})`; + + insetDef += this.geometryBox ? this.geometryBox : ""; + + this.emit("highlighter-event", { type: "shape-change", value: insetDef }); + } + + _handleMouseMoveNotDragging(pageX, pageY) { + const { percentX, percentY } = this.convertPageCoordsToPercent( + pageX, + pageY + ); + if (this.transformMode) { + const point = this.getTransformPointAt(percentX, percentY); + this.hoveredPoint = point; + this._handleMarkerHover(point); + } else if (this.shapeType === "polygon") { + const point = this.getPolygonPointAt(percentX, percentY); + const oldHoveredPoint = this.hoveredPoint; + this.hoveredPoint = point !== -1 ? point : null; + if (this.hoveredPoint !== oldHoveredPoint) { + this._emitHoverEvent(this.hoveredPoint); + } + this._handleMarkerHover(point); + } else if (this.shapeType === "circle") { + const point = this.getCirclePointAt(percentX, percentY); + const oldHoveredPoint = this.hoveredPoint; + this.hoveredPoint = point ? point : null; + if (this.hoveredPoint !== oldHoveredPoint) { + this._emitHoverEvent(this.hoveredPoint); + } + this._handleMarkerHover(point); + } else if (this.shapeType === "ellipse") { + const point = this.getEllipsePointAt(percentX, percentY); + const oldHoveredPoint = this.hoveredPoint; + this.hoveredPoint = point ? point : null; + if (this.hoveredPoint !== oldHoveredPoint) { + this._emitHoverEvent(this.hoveredPoint); + } + this._handleMarkerHover(point); + } else if (this.shapeType === "inset") { + const point = this.getInsetPointAt(percentX, percentY); + const oldHoveredPoint = this.hoveredPoint; + this.hoveredPoint = point ? point : null; + if (this.hoveredPoint !== oldHoveredPoint) { + this._emitHoverEvent(this.hoveredPoint); + } + this._handleMarkerHover(point); + } + } + + /** + * Change the appearance of the given marker when the mouse hovers over it. + * @param {String|Number} point if the shape is a polygon, the integer index of the + * point being hovered. Otherwise, a string identifying the point being hovered. + * Integers < 0 and falsey values excluding 0 indicate no point is being hovered. + */ + _handleMarkerHover(point) { + // Hide hover marker for now, will be shown if point is a valid hover target + this.getElement("marker-hover").setAttribute("hidden", true); + // Catch all falsey values except when point === 0, as that's a valid point + if (!point && point !== 0) { + this.setCursor("auto"); + return; + } + const hoverCursor = this[_dragging] ? "grabbing" : "grab"; + + if (this.transformMode) { + if (!point) { + this.setCursor("auto"); + return; + } + const { + nw, + ne, + sw, + se, + n, + w, + s, + e, + rotatePoint, + center, + } = this.transformedBoundingBox; + + const points = [ + { + pointName: "translate", + x: center[0], + y: center[1], + cursor: hoverCursor, + }, + { pointName: "scale-se", x: se[0], y: se[1], anchor: "nw" }, + { pointName: "scale-ne", x: ne[0], y: ne[1], anchor: "sw" }, + { pointName: "scale-sw", x: sw[0], y: sw[1], anchor: "ne" }, + { pointName: "scale-nw", x: nw[0], y: nw[1], anchor: "se" }, + { pointName: "scale-n", x: n[0], y: n[1], anchor: "s" }, + { pointName: "scale-s", x: s[0], y: s[1], anchor: "n" }, + { pointName: "scale-e", x: e[0], y: e[1], anchor: "w" }, + { pointName: "scale-w", x: w[0], y: w[1], anchor: "e" }, + { + pointName: "rotate", + x: rotatePoint[0], + y: rotatePoint[1], + cursor: hoverCursor, + }, + ]; + + for (const { pointName, x, y, cursor, anchor } of points) { + if (point === pointName) { + this._drawHoverMarker([[x, y]]); + + // If the point is a scale handle, we will need to determine the direction + // of the resize cursor based on the position of the handle relative to its + // "anchor" (the handle opposite to it). + if (pointName.includes("scale")) { + const direction = this.getRoughDirection(pointName, anchor); + this.setCursor(`${direction}-resize`); + } else { + this.setCursor(cursor); + } + } + } + } else if (this.shapeType === "polygon") { + if (point === -1) { + this.setCursor("auto"); + return; + } + this.setCursor(hoverCursor); + this._drawHoverMarker([this.coordinates[point]]); + } else if (this.shapeType === "circle") { + this.setCursor(hoverCursor); + + const { cx, cy, rx } = this.coordinates; + if (point === "radius") { + this._drawHoverMarker([[cx + rx, cy]]); + } else if (point === "center") { + this._drawHoverMarker([[cx, cy]]); + } + } else if (this.shapeType === "ellipse") { + this.setCursor(hoverCursor); + + if (point === "center") { + const { cx, cy } = this.coordinates; + this._drawHoverMarker([[cx, cy]]); + } else if (point === "rx") { + const { cx, cy, rx } = this.coordinates; + this._drawHoverMarker([[cx + rx, cy]]); + } else if (point === "ry") { + const { cx, cy, ry } = this.coordinates; + this._drawHoverMarker([[cx, cy + ry]]); + } + } else if (this.shapeType === "inset") { + this.setCursor(hoverCursor); + + const { top, right, bottom, left } = this.coordinates; + const centerX = (left + (100 - right)) / 2; + const centerY = (top + (100 - bottom)) / 2; + const points = point.split(","); + const coords = points.map(side => { + if (side === "top") { + return [centerX, top]; + } else if (side === "right") { + return [100 - right, centerY]; + } else if (side === "bottom") { + return [centerX, 100 - bottom]; + } else if (side === "left") { + return [left, centerY]; + } + return null; + }); + + this._drawHoverMarker(coords); + } + } + + _drawHoverMarker(points) { + const { width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + const path = points + .map(([x, y]) => { + return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); + }) + .join(" "); + + const markerHover = this.getElement("marker-hover"); + markerHover.setAttribute("d", path); + markerHover.removeAttribute("hidden"); + } + + _emitHoverEvent(point) { + if (point === null || point === undefined) { + this.emit("highlighter-event", { + type: "shape-hover-off", + }); + } else { + this.emit("highlighter-event", { + type: "shape-hover-on", + point: point.toString(), + }); + } + } + + /** + * Convert the given coordinates on the page to percentages relative to the current + * element. + * @param {Number} pageX the x coordinate on the page + * @param {Number} pageY the y coordinate on the page + * @returns {Object} object of form {percentX, percentY}, which are the x/y coords + * in percentages relative to the element. + */ + convertPageCoordsToPercent(pageX, pageY) { + // If the current node is in an iframe, we get dimensions relative to the frame. + const dims = this.frameDimensions; + const { top, left, width, height } = dims; + pageX -= left; + pageY -= top; + const percentX = (pageX * 100) / width; + const percentY = (pageY * 100) / height; + return { percentX, percentY }; + } + + /** + * Convert the given x/y coordinates, in percentages relative to the current element, + * to pixel coordinates relative to the page + * @param {Number} x the x coordinate + * @param {Number} y the y coordinate + * @returns {Object} object of form {x, y}, which are the x/y coords in pixels + * relative to the page + * + * @memberof ShapesHighlighter + */ + convertPercentToPageCoords(x, y) { + const dims = this.frameDimensions; + const { top, left, width, height } = dims; + x = (x * width) / 100; + y = (y * height) / 100; + x += left; + y += top; + return { x, y }; + } + + /** + * Get which transformation should be applied based on the mouse position. + * @param {Number} pageX the x coordinate of the mouse. + * @param {Number} pageY the y coordinate of the mouse. + * @returns {String} a string describing the transformation that should be applied + * to the shape. + */ + getTransformPointAt(pageX, pageY) { + const { + nw, + ne, + sw, + se, + n, + w, + s, + e, + rotatePoint, + center, + } = this.transformedBoundingBox; + const { width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; + const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; + + const points = [ + { pointName: "translate", x: center[0], y: center[1] }, + { pointName: "scale-se", x: se[0], y: se[1] }, + { pointName: "scale-ne", x: ne[0], y: ne[1] }, + { pointName: "scale-sw", x: sw[0], y: sw[1] }, + { pointName: "scale-nw", x: nw[0], y: nw[1] }, + ]; + + if (this.shapeType === "polygon" || this.shapeType === "ellipse") { + points.push( + { pointName: "scale-n", x: n[0], y: n[1] }, + { pointName: "scale-s", x: s[0], y: s[1] }, + { pointName: "scale-e", x: e[0], y: e[1] }, + { pointName: "scale-w", x: w[0], y: w[1] } + ); + } + + if (this.shapeType === "polygon") { + const x = rotatePoint[0]; + const y = rotatePoint[1]; + if ( + pageX >= x - clickRadiusX && + pageX <= x + clickRadiusX && + pageY >= y - clickRadiusY && + pageY <= y + clickRadiusY + ) { + return "rotate"; + } + } + + for (const { pointName, x, y } of points) { + if ( + pageX >= x - clickRadiusX && + pageX <= x + clickRadiusX && + pageY >= y - clickRadiusY && + pageY <= y + clickRadiusY + ) { + return pointName; + } + } + + return ""; + } + + /** + * Get the id of the point on the polygon highlighter at the given coordinate. + * @param {Number} pageX the x coordinate on the page, in % relative to the element + * @param {Number} pageY the y coordinate on the page, in % relative to the element + * @returns {Number} the index of the point that was clicked on in this.coordinates, + * or -1 if none of the points were clicked on. + */ + getPolygonPointAt(pageX, pageY) { + const { coordinates } = this; + const { width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; + const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; + + for (const [index, coord] of coordinates.entries()) { + const [x, y] = coord; + if ( + pageX >= x - clickRadiusX && + pageX <= x + clickRadiusX && + pageY >= y - clickRadiusY && + pageY <= y + clickRadiusY + ) { + return index; + } + } + + return -1; + } + + /** + * Check if the mouse clicked on a line of the polygon, and if so, add a point near + * the click. + * @param {Number} pageX the x coordinate on the page, in % relative to the element + * @param {Number} pageY the y coordinate on the page, in % relative to the element + */ + getPolygonClickedLine(pageX, pageY) { + const { coordinates } = this; + const { width } = this.currentDimensions; + const clickWidth = (LINE_CLICK_WIDTH * 100) / width; + + for (let i = 0; i < coordinates.length; i++) { + const [x1, y1] = coordinates[i]; + const [x2, y2] = + i === coordinates.length - 1 ? coordinates[0] : coordinates[i + 1]; + // Get the distance between clicked point and line drawn between points 1 and 2 + // to check if the click was on the line between those two points. + const distance = distanceToLine(x1, y1, x2, y2, pageX, pageY); + if ( + distance <= clickWidth && + Math.min(x1, x2) - clickWidth <= pageX && + pageX <= Math.max(x1, x2) + clickWidth && + Math.min(y1, y2) - clickWidth <= pageY && + pageY <= Math.max(y1, y2) + clickWidth + ) { + // Get the point on the line closest to the clicked point. + const [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY); + // Default unit for new points is percentages + this._addPolygonPoint(i, round(newX, "%"), round(newY, "%")); + return; + } + } + } + + /** + * Check if the center point or radius of the circle highlighter is at given coords + * @param {Number} pageX the x coordinate on the page, in % relative to the element + * @param {Number} pageY the y coordinate on the page, in % relative to the element + * @returns {String} "center" if the center point was clicked, "radius" if the radius + * was clicked, "" if neither was clicked. + */ + getCirclePointAt(pageX, pageY) { + const { cx, cy, rx, ry } = this.coordinates; + const { width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; + const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; + + if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { + return "center"; + } + + const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; + const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; + if ( + clickedOnEllipseEdge( + pageX, + pageY, + cx, + cy, + rx, + ry, + clickWidthX, + clickWidthY + ) || + clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY) + ) { + return "radius"; + } + + return ""; + } + + /** + * Check if the center or rx/ry points of the ellipse highlighter is at given point + * @param {Number} pageX the x coordinate on the page, in % relative to the element + * @param {Number} pageY the y coordinate on the page, in % relative to the element + * @returns {String} "center" if the center point was clicked, "rx" if the x-radius + * point was clicked, "ry" if the y-radius point was clicked, + * "" if none was clicked. + */ + getEllipsePointAt(pageX, pageY) { + const { cx, cy, rx, ry } = this.coordinates; + const { width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; + const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; + + if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { + return "center"; + } + + if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) { + return "rx"; + } + + if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) { + return "ry"; + } + + return ""; + } + + /** + * Check if the edges of the inset highlighter is at given coords + * @param {Number} pageX the x coordinate on the page, in % relative to the element + * @param {Number} pageY the y coordinate on the page, in % relative to the element + * @returns {String} "top", "left", "right", or "bottom" if any of those edges were + * clicked. "" if none were clicked. + */ + // eslint-disable-next-line complexity + getInsetPointAt(pageX, pageY) { + const { top, left, right, bottom } = this.coordinates; + const zoom = getCurrentZoom(this.win); + const { width, height } = this.currentDimensions; + const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; + const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; + const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; + const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; + const centerX = (left + (100 - right)) / 2; + const centerY = (top + (100 - bottom)) / 2; + + if ( + (pageX >= left - clickWidthX && + pageX <= left + clickWidthX && + pageY >= top && + pageY <= 100 - bottom) || + clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY) + ) { + return "left"; + } + + if ( + (pageX >= 100 - right - clickWidthX && + pageX <= 100 - right + clickWidthX && + pageY >= top && + pageY <= 100 - bottom) || + clickedOnPoint( + pageX, + pageY, + 100 - right, + centerY, + clickRadiusX, + clickRadiusY + ) + ) { + return "right"; + } + + if ( + (pageY >= top - clickWidthY && + pageY <= top + clickWidthY && + pageX >= left && + pageX <= 100 - right) || + clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY) + ) { + return "top"; + } + + if ( + (pageY >= 100 - bottom - clickWidthY && + pageY <= 100 - bottom + clickWidthY && + pageX >= left && + pageX <= 100 - right) || + clickedOnPoint( + pageX, + pageY, + centerX, + 100 - bottom, + clickRadiusX, + clickRadiusY + ) + ) { + return "bottom"; + } + + return ""; + } + + /** + * Parses the CSS definition given and returns the shape type associated + * with the definition and the coordinates necessary to draw the shape. + * @param {String} definition the input CSS definition + * @returns {Object} null if the definition is not of a known shape type, + * or an object of the type { shapeType, coordinates }, where + * shapeType is the name of the shape and coordinates are an array + * or object of the coordinates needed to draw the shape. + */ + _parseCSSShapeValue(definition) { + const shapeTypes = [ + { + name: "polygon", + prefix: "polygon(", + coordParser: this.polygonPoints.bind(this), + }, + { + name: "circle", + prefix: "circle(", + coordParser: this.circlePoints.bind(this), + }, + { + name: "ellipse", + prefix: "ellipse(", + coordParser: this.ellipsePoints.bind(this), + }, + { + name: "inset", + prefix: "inset(", + coordParser: this.insetPoints.bind(this), + }, + ]; + const geometryTypes = ["margin", "border", "padding", "content"]; + + // default to border for clip-path, and margin for shape-outside + let referenceBox = this.property === "clip-path" ? "border" : "margin"; + for (const geometry of geometryTypes) { + if (definition.includes(geometry)) { + referenceBox = geometry; + } + } + this.referenceBox = referenceBox; + + this.useStrokeBox = definition.includes("stroke-box"); + this.geometryBox = definition + .substring(definition.lastIndexOf(")") + 1) + .trim(); + + for (const { name, prefix, coordParser } of shapeTypes) { + if (definition.includes(prefix)) { + // the closing paren of the shape function is always the last one in definition. + definition = definition.substring( + prefix.length, + definition.lastIndexOf(")") + ); + return { + shapeType: name, + coordinates: coordParser(definition), + }; + } + } + + return null; + } + + /** + * Parses the definition of the CSS polygon() function and returns its points, + * converted to percentages. + * @param {String} definition the arguments of the polygon() function + * @returns {Array} an array of the points of the polygon, with all values + * evaluated and converted to percentages + */ + polygonPoints(definition) { + this.coordUnits = this.polygonRawPoints(); + if (!this.origCoordUnits) { + this.origCoordUnits = this.coordUnits; + } + const splitDef = definition.split(", "); + if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") { + splitDef.shift(); + } + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + let maxX = Number.MIN_SAFE_INTEGER; + let maxY = Number.MIN_SAFE_INTEGER; + const coordinates = splitDef.map(coords => { + const [x, y] = splitCoords(coords).map( + this.convertCoordsToPercent.bind(this) + ); + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (x > maxX) { + maxX = x; + } + if (y > maxY) { + maxY = y; + } + return [x, y]; + }); + this.boundingBox = { minX, minY, maxX, maxY }; + if (!this.origBoundingBox) { + this.origBoundingBox = this.boundingBox; + } + return coordinates; + } + + /** + * Parse the raw (non-computed) definition of the CSS polygon. + * @returns {Array} an array of the points of the polygon, with units preserved. + */ + polygonRawPoints() { + let definition = getDefinedShapeProperties(this.currentNode, this.property); + if (definition === this.rawDefinition && this.coordUnits) { + return this.coordUnits; + } + this.rawDefinition = definition; + definition = definition.substring(8, definition.lastIndexOf(")")); + const splitDef = definition.split(", "); + if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) { + this.fillRule = splitDef[0].trim(); + splitDef.shift(); + } else { + this.fillRule = ""; + } + return splitDef.map(coords => { + return splitCoords(coords).map(coord => { + // Undo the insertion of that was done in splitCoords. + return coord.replace(/\u00a0/g, " "); + }); + }); + } + + /** + * Parses the definition of the CSS circle() function and returns the x/y radiuses and + * center coordinates, converted to percentages. + * @param {String} definition the arguments of the circle() function + * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the + * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the + * center of the circle. All values are evaluated and converted to percentages. + */ + circlePoints(definition) { + this.coordUnits = this.circleRawPoints(); + if (!this.origCoordUnits) { + this.origCoordUnits = this.coordUnits; + } + + const values = definition.split("at"); + let radius = values[0] ? values[0].trim() : "closest-side"; + const { width, height } = this.currentDimensions; + const center = splitCoords(values[1]).map( + this.convertCoordsToPercent.bind(this) + ); + + // Percentage values for circle() are resolved from the + // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2). + const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); + + // Position coordinates for circle center in pixels. + const cxPx = (width * center[0]) / 100; + const cyPx = (height * center[1]) / 100; + + if (radius === "closest-side") { + // radius is the distance from center to closest side of reference box + radius = Math.min(cxPx, cyPx, width - cxPx, height - cyPx); + radius = coordToPercent(`${radius}px`, computedSize); + } else if (radius === "farthest-side") { + // radius is the distance from center to farthest side of reference box + radius = Math.max(cxPx, cyPx, width - cxPx, height - cyPx); + radius = coordToPercent(`${radius}px`, computedSize); + } else if (radius.includes("calc(")) { + radius = evalCalcExpression( + radius.substring(5, radius.length - 1), + computedSize + ); + } else { + radius = coordToPercent(radius, computedSize); + } + + // Scale both radiusX and radiusY to match the radius computed + // using the above equation. + const ratioX = width / computedSize; + const ratioY = height / computedSize; + const radiusX = radius / ratioX; + const radiusY = radius / ratioY; + + this.boundingBox = { + minX: center[0] - radiusX, + maxX: center[0] + radiusX, + minY: center[1] - radiusY, + maxY: center[1] + radiusY, + }; + if (!this.origBoundingBox) { + this.origBoundingBox = this.boundingBox; + } + return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] }; + } + + /** + * Parse the raw (non-computed) definition of the CSS circle. + * @returns {Object} an object of the points of the circle (cx, cy, radius), + * with units preserved. + */ + circleRawPoints() { + let definition = getDefinedShapeProperties(this.currentNode, this.property); + if (definition === this.rawDefinition && this.coordUnits) { + return this.coordUnits; + } + this.rawDefinition = definition; + definition = definition.substring(7, definition.lastIndexOf(")")); + + const values = definition.split("at"); + const [cx = "", cy = ""] = values[1] + ? splitCoords(values[1]).map(coord => { + // Undo the insertion of that was done in splitCoords. + return coord.replace(/\u00a0/g, " "); + }) + : []; + const radius = values[0] ? values[0].trim() : "closest-side"; + return { cx, cy, radius }; + } + + /** + * Parses the computed style definition of the CSS ellipse() function and returns the + * x/y radii and center coordinates, converted to percentages. + * @param {String} definition the arguments of the ellipse() function + * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the + * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the + * center of the ellipse. All values are evaluated and converted to percentages + */ + ellipsePoints(definition) { + this.coordUnits = this.ellipseRawPoints(); + if (!this.origCoordUnits) { + this.origCoordUnits = this.coordUnits; + } + + const values = definition.split("at"); + const center = splitCoords(values[1]).map( + this.convertCoordsToPercent.bind(this) + ); + + let radii = values[0] ? values[0].trim() : "closest-side closest-side"; + radii = splitCoords(radii).map((radius, i) => { + if (radius === "closest-side") { + // radius is the distance from center to closest x/y side of reference box + return i % 2 === 0 + ? Math.min(center[0], 100 - center[0]) + : Math.min(center[1], 100 - center[1]); + } else if (radius === "farthest-side") { + // radius is the distance from center to farthest x/y side of reference box + return i % 2 === 0 + ? Math.max(center[0], 100 - center[0]) + : Math.max(center[1], 100 - center[1]); + } + return this.convertCoordsToPercent(radius, i); + }); + + this.boundingBox = { + minX: center[0] - radii[0], + maxX: center[0] + radii[0], + minY: center[1] - radii[1], + maxY: center[1] + radii[1], + }; + if (!this.origBoundingBox) { + this.origBoundingBox = this.boundingBox; + } + return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] }; + } + + /** + * Parse the raw (non-computed) definition of the CSS ellipse. + * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry), + * with units preserved. + */ + ellipseRawPoints() { + let definition = getDefinedShapeProperties(this.currentNode, this.property); + if (definition === this.rawDefinition && this.coordUnits) { + return this.coordUnits; + } + this.rawDefinition = definition; + definition = definition.substring(8, definition.lastIndexOf(")")); + + const values = definition.split("at"); + const [rx = "closest-side", ry = "closest-side"] = values[0] + ? splitCoords(values[0]).map(coord => { + // Undo the insertion of that was done in splitCoords. + return coord.replace(/\u00a0/g, " "); + }) + : []; + const [cx = "", cy = ""] = values[1] + ? splitCoords(values[1]).map(coord => { + return coord.replace(/\u00a0/g, " "); + }) + : []; + return { rx, ry, cx, cy }; + } + + /** + * Parses the definition of the CSS inset() function and returns the x/y offsets and + * width/height of the shape, converted to percentages. Border radiuses (given after + * "round" in the definition) are currently ignored. + * @param {String} definition the arguments of the inset() function + * @returns {Object} an object of the form { x, y, width, height }, which are the top/ + * left positions and width/height of the shape. + */ + insetPoints(definition) { + this.coordUnits = this.insetRawPoints(); + if (!this.origCoordUnits) { + this.origCoordUnits = this.coordUnits; + } + const values = definition.split(" round "); + const offsets = splitCoords(values[0]).map( + this.convertCoordsToPercent.bind(this) + ); + + let top, left, right, bottom; + // The offsets, like margin/padding/border, are in order: top, right, bottom, left. + if (offsets.length === 1) { + top = left = right = bottom = offsets[0]; + } else if (offsets.length === 2) { + top = bottom = offsets[0]; + left = right = offsets[1]; + } else if (offsets.length === 3) { + top = offsets[0]; + left = right = offsets[1]; + bottom = offsets[2]; + } else if (offsets.length === 4) { + top = offsets[0]; + right = offsets[1]; + bottom = offsets[2]; + left = offsets[3]; + } + + // maxX/maxY are found by subtracting the right/bottom edges from 100 + // (the width/height of the element in %) + this.boundingBox = { + minX: left, + maxX: 100 - right, + minY: top, + maxY: 100 - bottom, + }; + if (!this.origBoundingBox) { + this.origBoundingBox = this.boundingBox; + } + return { top, left, right, bottom }; + } + + /** + * Parse the raw (non-computed) definition of the CSS inset. + * @returns {Object} an object of the points of the inset (top, right, bottom, left), + * with units preserved. + */ + insetRawPoints() { + let definition = getDefinedShapeProperties(this.currentNode, this.property); + if (definition === this.rawDefinition && this.coordUnits) { + return this.coordUnits; + } + this.rawDefinition = definition; + definition = definition.substring(6, definition.lastIndexOf(")")); + + const values = definition.split(" round "); + this.insetRound = values[1]; + const offsets = splitCoords(values[0]).map(coord => { + // Undo the insertion of that was done in splitCoords. + return coord.replace(/\u00a0/g, " "); + }); + + let top, + left, + right, + bottom = 0; + + if (offsets.length === 1) { + top = left = right = bottom = offsets[0]; + } else if (offsets.length === 2) { + top = bottom = offsets[0]; + left = right = offsets[1]; + } else if (offsets.length === 3) { + top = offsets[0]; + left = right = offsets[1]; + bottom = offsets[2]; + } else if (offsets.length === 4) { + top = offsets[0]; + right = offsets[1]; + bottom = offsets[2]; + left = offsets[3]; + } + + return { top, left, right, bottom }; + } + + convertCoordsToPercent(coord, i) { + const { width, height } = this.currentDimensions; + const size = i % 2 === 0 ? width : height; + if (coord.includes("calc(")) { + return evalCalcExpression(coord.substring(5, coord.length - 1), size); + } + return coordToPercent(coord, size); + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + const { pageListenerTarget } = this.highlighterEnv; + if (pageListenerTarget) { + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this) + ); + } + super.destroy(this); + this.markup.destroy(); + } + + /** + * Get the element in the highlighter markup with the given id + * @param {String} id + * @returns {Object} the element with the given id + */ + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Return whether all the elements used to draw shapes are hidden. + * @returns {Boolean} + */ + areShapesHidden() { + return ( + this.getElement("ellipse").hasAttribute("hidden") && + this.getElement("polygon").hasAttribute("hidden") && + this.getElement("rect").hasAttribute("hidden") && + this.getElement("bounding-box").hasAttribute("hidden") + ); + } + + /** + * Show the highlighter on a given node + */ + _show() { + this.hoveredPoint = this.options.hoverPoint; + this.transformMode = this.options.transformMode; + this.coordinates = null; + this.coordUnits = null; + this.origBoundingBox = null; + this.origCoordUnits = null; + this.origCoordinates = null; + this.transformedBoundingBox = null; + if (this.transformMode) { + this.transformMatrix = identity(); + } + if (this._hasMoved() && this.transformMode) { + this.transformedBoundingBox = this.calculateTransformedBoundingBox(); + } + return this._update(); + } + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's + * quads have changed. Override it so it also returns true if the element's shape has + * changed (which can happen when you change a CSS properties for instance). + */ + _hasMoved() { + let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + if (hasMoved) { + this.origBoundingBox = null; + this.origCoordUnits = null; + this.origCoordinates = null; + if (this.transformMode) { + this.transformMatrix = identity(); + } + } + + const oldShapeCoordinates = JSON.stringify(this.coordinates); + + // TODO: need other modes too. + if (this.options.mode.startsWith("css")) { + const property = shapeModeToCssPropertyName(this.options.mode); + // change camelCase to kebab-case + this.property = property.replace(/([a-z][A-Z])/g, g => { + return g[0] + "-" + g[1].toLowerCase(); + }); + const style = getComputedStyle(this.currentNode)[property]; + + if (!style || style === "none") { + this.coordinates = []; + this.shapeType = "none"; + } else { + const { coordinates, shapeType } = this._parseCSSShapeValue(style); + this.coordinates = coordinates; + if (!this.origCoordinates) { + this.origCoordinates = coordinates; + } + this.shapeType = shapeType; + } + } + + const newShapeCoordinates = JSON.stringify(this.coordinates); + hasMoved = hasMoved || oldShapeCoordinates !== newShapeCoordinates; + if (this.transformMode && hasMoved) { + this.transformedBoundingBox = this.calculateTransformedBoundingBox(); + } + + return hasMoved; + } + + /** + * Hide all elements used to highlight CSS different shapes. + */ + _hideShapes() { + this.getElement("ellipse").setAttribute("hidden", true); + this.getElement("polygon").setAttribute("hidden", true); + this.getElement("rect").setAttribute("hidden", true); + this.getElement("bounding-box").setAttribute("hidden", true); + this.getElement("markers").setAttribute("d", ""); + this.getElement("markers-outline").setAttribute("d", ""); + this.getElement("rotate-line").setAttribute("d", ""); + this.getElement("quad").setAttribute("hidden", true); + this.getElement("clip-ellipse").setAttribute("hidden", true); + this.getElement("clip-polygon").setAttribute("hidden", true); + this.getElement("clip-rect").setAttribute("hidden", true); + this.getElement("dashed-polygon").setAttribute("hidden", true); + this.getElement("dashed-ellipse").setAttribute("hidden", true); + this.getElement("dashed-rect").setAttribute("hidden", true); + } + + /** + * Update the highlighter for the current node. Called whenever the element's quads + * or CSS shape has changed. + * @returns {Boolean} whether the highlighter was successfully updated + */ + _update() { + setIgnoreLayoutChanges(true); + this.getElement("group").setAttribute("transform", ""); + const root = this.getElement("root"); + root.setAttribute("hidden", true); + + const { top, left, width, height } = this.currentDimensions; + const zoom = getCurrentZoom(this.win); + + // Size the SVG like the current node. + this.getElement("shape-container").setAttribute( + "style", + `top:${top}px;left:${left}px;width:${width}px;height:${height}px;` + ); + + this._hideShapes(); + this._updateShapes(width, height, zoom); + + // For both shape-outside and clip-path the element's quads are displayed for the + // parts that overlap with the shape. The parts of the shape that extend past the + // element's quads are shown with a dashed line. + const quadRect = this.getElement("quad"); + quadRect.removeAttribute("hidden"); + + this.getElement("polygon").setAttribute( + "clip-path", + "url(#shapes-quad-clip-path)" + ); + this.getElement("ellipse").setAttribute( + "clip-path", + "url(#shapes-quad-clip-path)" + ); + this.getElement("rect").setAttribute( + "clip-path", + "url(#shapes-quad-clip-path)" + ); + + const { width: winWidth, height: winHeight } = this._winDimensions; + root.removeAttribute("hidden"); + root.setAttribute( + "style", + `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden;` + ); + + this._handleMarkerHover(this.hoveredPoint); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + + return true; + } + + /** + * Update the SVGs to render the current CSS shape and add markers depending on shape + * type and transform mode. + * @param {Number} width the width of the element quads + * @param {Number} height the height of the element quads + * @param {Number} zoom the zoom level of the window + */ + _updateShapes(width, height, zoom) { + if (this.transformMode && this.shapeType !== "none") { + this._updateTransformMode(width, height, zoom); + } else if (this.shapeType === "polygon") { + this._updatePolygonShape(width, height, zoom); + // Draw markers for each of the polygon's points. + this._drawMarkers(this.coordinates, width, height, zoom); + } else if (this.shapeType === "circle") { + const { rx, cx, cy } = this.coordinates; + // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. + this._updateEllipseShape(width, height, zoom); + // Draw markers for center and radius points. + this._drawMarkers( + [ + [cx, cy], + [cx + rx, cy], + ], + width, + height, + zoom + ); + } else if (this.shapeType === "ellipse") { + const { rx, ry, cx, cy } = this.coordinates; + this._updateEllipseShape(width, height, zoom); + // Draw markers for center, horizontal radius and vertical radius points. + this._drawMarkers( + [ + [cx, cy], + [cx + rx, cy], + [cx, cy + ry], + ], + width, + height, + zoom + ); + } else if (this.shapeType === "inset") { + const { top, left, right, bottom } = this.coordinates; + const centerX = (left + (100 - right)) / 2; + const centerY = (top + (100 - bottom)) / 2; + const markerCoords = [ + [centerX, top], + [100 - right, centerY], + [centerX, 100 - bottom], + [left, centerY], + ]; + this._updateInsetShape(width, height, zoom); + // Draw markers for each of the inset's sides. + this._drawMarkers(markerCoords, width, height, zoom); + } + } + + /** + * Update the SVGs for transform mode to fit the new shape. + * @param {Number} width the width of the element quads + * @param {Number} height the height of the element quads + * @param {Number} zoom the zoom level of the window + */ + _updateTransformMode(width, height, zoom) { + const { + nw, + ne, + sw, + se, + n, + w, + s, + e, + rotatePoint, + center, + } = this.transformedBoundingBox; + const boundingBox = this.getElement("bounding-box"); + const path = `M${nw.join(" ")} L${ne.join(" ")} L${se.join(" ")} L${sw.join( + " " + )} Z`; + boundingBox.setAttribute("d", path); + boundingBox.removeAttribute("hidden"); + + const markerPoints = [center, nw, ne, se, sw]; + if (this.shapeType === "polygon" || this.shapeType === "ellipse") { + markerPoints.push(n, s, w, e); + } + + if (this.shapeType === "polygon") { + this._updatePolygonShape(width, height, zoom); + markerPoints.push(rotatePoint); + const rotateLine = `M ${center.join(" ")} L ${rotatePoint.join(" ")}`; + this.getElement("rotate-line").setAttribute("d", rotateLine); + } else if (this.shapeType === "circle" || this.shapeType === "ellipse") { + // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. + this._updateEllipseShape(width, height, zoom); + } else if (this.shapeType === "inset") { + this._updateInsetShape(width, height, zoom); + } + + this._drawMarkers(markerPoints, width, height, zoom); + } + + /** + * Update the SVG polygon to fit the CSS polygon. + * @param {Number} width the width of the element quads + * @param {Number} height the height of the element quads + * @param {Number} zoom the zoom level of the window + */ + _updatePolygonShape(width, height, zoom) { + // Draw and show the polygon. + const points = this.coordinates.map(point => point.join(",")).join(" "); + + const polygonEl = this.getElement("polygon"); + polygonEl.setAttribute("points", points); + polygonEl.removeAttribute("hidden"); + + const clipPolygon = this.getElement("clip-polygon"); + clipPolygon.setAttribute("points", points); + clipPolygon.removeAttribute("hidden"); + + const dashedPolygon = this.getElement("dashed-polygon"); + dashedPolygon.setAttribute("points", points); + dashedPolygon.removeAttribute("hidden"); + } + + /** + * Update the SVG ellipse to fit the CSS circle or ellipse. + * @param {Number} width the width of the element quads + * @param {Number} height the height of the element quads + * @param {Number} zoom the zoom level of the window + */ + _updateEllipseShape(width, height, zoom) { + const { rx, ry, cx, cy } = this.coordinates; + const ellipseEl = this.getElement("ellipse"); + ellipseEl.setAttribute("rx", rx); + ellipseEl.setAttribute("ry", ry); + ellipseEl.setAttribute("cx", cx); + ellipseEl.setAttribute("cy", cy); + ellipseEl.removeAttribute("hidden"); + + const clipEllipse = this.getElement("clip-ellipse"); + clipEllipse.setAttribute("rx", rx); + clipEllipse.setAttribute("ry", ry); + clipEllipse.setAttribute("cx", cx); + clipEllipse.setAttribute("cy", cy); + clipEllipse.removeAttribute("hidden"); + + const dashedEllipse = this.getElement("dashed-ellipse"); + dashedEllipse.setAttribute("rx", rx); + dashedEllipse.setAttribute("ry", ry); + dashedEllipse.setAttribute("cx", cx); + dashedEllipse.setAttribute("cy", cy); + dashedEllipse.removeAttribute("hidden"); + } + + /** + * Update the SVG rect to fit the CSS inset. + * @param {Number} width the width of the element quads + * @param {Number} height the height of the element quads + * @param {Number} zoom the zoom level of the window + */ + _updateInsetShape(width, height, zoom) { + const { top, left, right, bottom } = this.coordinates; + const rectEl = this.getElement("rect"); + rectEl.setAttribute("x", left); + rectEl.setAttribute("y", top); + rectEl.setAttribute("width", 100 - left - right); + rectEl.setAttribute("height", 100 - top - bottom); + rectEl.removeAttribute("hidden"); + + const clipRect = this.getElement("clip-rect"); + clipRect.setAttribute("x", left); + clipRect.setAttribute("y", top); + clipRect.setAttribute("width", 100 - left - right); + clipRect.setAttribute("height", 100 - top - bottom); + clipRect.removeAttribute("hidden"); + + const dashedRect = this.getElement("dashed-rect"); + dashedRect.setAttribute("x", left); + dashedRect.setAttribute("y", top); + dashedRect.setAttribute("width", 100 - left - right); + dashedRect.setAttribute("height", 100 - top - bottom); + dashedRect.removeAttribute("hidden"); + } + + /** + * Draw markers for the given coordinates. + * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...] + * @param {Number} width the width of the element markers are being drawn for + * @param {Number} height the height of the element markers are being drawn for + * @param {Number} zoom the zoom level of the window + */ + _drawMarkers(coords, width, height, zoom) { + const markers = coords + .map(([x, y]) => { + return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); + }) + .join(" "); + const outline = coords + .map(([x, y]) => { + return getCirclePath(BASE_MARKER_SIZE + 2, x, y, width, height, zoom); + }) + .join(" "); + + this.getElement("markers").setAttribute("d", markers); + this.getElement("markers-outline").setAttribute("d", outline); + } + + /** + * Calculate the bounding box of the shape after it is transformed according to + * the transformation matrix. + * @returns {Object} of form { nw, ne, sw, se, n, s, w, e, rotatePoint, center }. + * Each element in the object is an array of form [x,y], denoting the x/y + * coordinates of the given point. + */ + calculateTransformedBoundingBox() { + const { minX, minY, maxX, maxY } = this.origBoundingBox; + const { width, height } = this.currentDimensions; + const toPixel = scale(width / 100, height / 100); + const toPercent = scale(100 / width, 100 / height); + const matrix = multiply(toPercent, multiply(this.transformMatrix, toPixel)); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const nw = apply(matrix, [minX, minY]); + const ne = apply(matrix, [maxX, minY]); + const sw = apply(matrix, [minX, maxY]); + const se = apply(matrix, [maxX, maxY]); + const n = apply(matrix, [centerX, minY]); + const s = apply(matrix, [centerX, maxY]); + const w = apply(matrix, [minX, centerY]); + const e = apply(matrix, [maxX, centerY]); + const center = apply(matrix, [centerX, centerY]); + + const u = [ + ((ne[0] - nw[0]) / 100) * width, + ((ne[1] - nw[1]) / 100) * height, + ]; + const v = [ + ((sw[0] - nw[0]) / 100) * width, + ((sw[1] - nw[1]) / 100) * height, + ]; + const { basis, invertedBasis } = getBasis(u, v); + let rotatePointMatrix = changeMatrixBase( + translate(0, -ROTATE_LINE_LENGTH), + invertedBasis, + basis + ); + rotatePointMatrix = multiply( + toPercent, + multiply(rotatePointMatrix, multiply(this.transformMatrix, toPixel)) + ); + const rotatePoint = apply(rotatePointMatrix, [centerX, centerY]); + return { nw, ne, sw, se, n, s, w, e, rotatePoint, center }; + } + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + + this._hideShapes(); + this.getElement("markers").setAttribute("d", ""); + this.getElement("root").setAttribute("style", ""); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + onPageHide({ target }) { + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.win) { + this.hide(); + } + } + + /** + * Get the rough direction of the point relative to the anchor. + * If the handle is roughly horizontal relative to the anchor, return "ew". + * If the handle is roughly vertical relative to the anchor, return "ns" + * If the handle is roughly above/right or below/left, return "nesw" + * If the handle is roughly above/left or below/right, return "nwse" + * @param {String} pointName the name of the point being hovered + * @param {String} anchor the name of the anchor point + * @returns {String} The rough direction of the point relative to the anchor + */ + getRoughDirection(pointName, anchor) { + const scalePoint = pointName.split("-")[1]; + const anchorPos = this.transformedBoundingBox[anchor]; + const scalePos = this.transformedBoundingBox[scalePoint]; + const { minX, minY, maxX, maxY } = this.boundingBox; + const width = maxX - minX; + const height = maxY - minY; + const dx = (scalePos[0] - anchorPos[0]) / width; + const dy = (scalePos[1] - anchorPos[1]) / height; + if (dx >= -0.33 && dx <= 0.33) { + return "ns"; + } else if (dy >= -0.33 && dy <= 0.33) { + return "ew"; + } else if ((dx > 0.33 && dy < -0.33) || (dx < -0.33 && dy > 0.33)) { + return "nesw"; + } + return "nwse"; + } + + /** + * Given a unit type, get the ratio by which to multiply a pixel value in order to + * convert pixels to that unit. + * + * Percentage units (%) are relative to a size. This must be provided when requesting + * a ratio for converting from pixels to percentages. + * + * @param {String} unit + * One of: %, em, rem, vw, vh + * @param {Number} size + * Size to which percentage values are relative to. + * @return {Number} + */ + getUnitToPixelRatio(unit, size) { + let ratio; + const windowHeight = this.currentNode.ownerGlobal.innerHeight; + const windowWidth = this.currentNode.ownerGlobal.innerWidth; + switch (unit) { + case "%": + ratio = 100 / size; + break; + case "em": + ratio = 1 / parseFloat(getComputedStyle(this.currentNode).fontSize); + break; + case "rem": + const root = this.currentNode.ownerDocument.documentElement; + ratio = 1 / parseFloat(getComputedStyle(root).fontSize); + break; + case "vw": + ratio = 100 / windowWidth; + break; + case "vh": + ratio = 100 / windowHeight; + break; + case "vmin": + ratio = 100 / Math.min(windowHeight, windowWidth); + break; + case "vmax": + ratio = 100 / Math.max(windowHeight, windowWidth); + break; + default: + // If unit is not recognized, peg ratio 1:1 to pixels. + ratio = 1; + } + + return ratio; + } +} + +/** + * Get the "raw" (i.e. non-computed) shape definition on the given node. + * @param {Node} node the node to analyze + * @param {String} property the CSS property for which a value should be retrieved. + * @returns {String} the value of the given CSS property on the given node. + */ +function getDefinedShapeProperties(node, property) { + let prop = ""; + if (!node) { + return prop; + } + + const cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.length; i++) { + const rule = cssRules[i]; + const value = rule.style.getPropertyValue(property); + if (value && value !== "auto") { + prop = value; + } + } + + if (node.style) { + const value = node.style.getPropertyValue(property); + if (value && value !== "auto") { + prop = value; + } + } + + return prop.trim(); +} + +/** + * Split coordinate pairs separated by a space and return an array. + * @param {String} coords the coordinate pair, where each coord is separated by a space. + * @returns {Array} a 2 element array containing the coordinates. + */ +function splitCoords(coords) { + // All coordinate pairs are of the form "x y" where x and y are values or + // calc() expressions. calc() expressions have spaces around operators, so + // replace those spaces with \u00a0 (non-breaking space) so they will not be + // split later. + return coords + .trim() + .replace(/ [\+\-\*\/] /g, match => { + return `\u00a0${match.trim()}\u00a0`; + }) + .split(" "); +} +exports.splitCoords = splitCoords; + +/** + * Convert a coordinate to a percentage value. + * @param {String} coord a single coordinate + * @param {Number} size the size of the element (width or height) that the percentages + * are relative to + * @returns {Number} the coordinate as a percentage value + */ +function coordToPercent(coord, size) { + if (coord.includes("%")) { + // Just remove the % sign, nothing else to do, we're in a viewBox that's 100% + // worth. + return parseFloat(coord.replace("%", "")); + } else if (coord.includes("px")) { + // Convert the px value to a % value. + const px = parseFloat(coord.replace("px", "")); + return (px * 100) / size; + } + + // Unit-less value, so 0. + return 0; +} +exports.coordToPercent = coordToPercent; + +/** + * Evaluates a CSS calc() expression (only handles addition) + * @param {String} expression the arguments to the calc() function + * @param {Number} size the size of the element (width or height) that percentage values + * are relative to + * @returns {Number} the result of the expression as a percentage value + */ +function evalCalcExpression(expression, size) { + // the calc() values returned by getComputedStyle only have addition, as it + // computes calc() expressions as much as possible without resolving percentages, + // leaving only addition. + const values = expression.split("+").map(v => v.trim()); + + return values.reduce((prev, curr) => { + return prev + coordToPercent(curr, size); + }, 0); +} +exports.evalCalcExpression = evalCalcExpression; + +/** + * Converts a shape mode to the proper CSS property name. + * @param {String} mode the mode of the CSS shape + * @returns the equivalent CSS property name + */ +const shapeModeToCssPropertyName = mode => { + const property = mode.substring(3); + return property.substring(0, 1).toLowerCase() + property.substring(1); +}; +exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName; + +/** + * Get the SVG path definition for a circle with given attributes. + * @param {Number} size the radius of the circle in pixels + * @param {Number} cx the x coordinate of the centre of the circle + * @param {Number} cy the y coordinate of the centre of the circle + * @param {Number} width the width of the element the circle is being drawn for + * @param {Number} height the height of the element the circle is being drawn for + * @param {Number} zoom the zoom level of the window the circle is drawn in + * @returns {String} the definition of the circle in SVG path description format. + */ +const getCirclePath = (size, cx, cy, width, height, zoom) => { + // We use a viewBox of 100x100 for shape-container so it's easy to position things + // based on their percentage, but this makes it more difficult to create circles. + // Therefor, 100px is the base size of shape-container. In order to make the markers' + // size scale properly, we must adjust the radius based on zoom and the width/height of + // the element being highlighted, then calculate a radius for both x/y axes based + // on the aspect ratio of the element. + const radius = (size * (100 / Math.max(width, height))) / zoom; + const ratio = width / height; + const rx = ratio > 1 ? radius : radius / ratio; + const ry = ratio > 1 ? radius * ratio : radius; + // a circle is drawn as two arc lines, starting at the leftmost point of the circle. + return ( + `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` + + `a${rx},${ry} 0 1,0 ${rx * -2},0` + ); +}; +exports.getCirclePath = getCirclePath; + +/** + * Calculates the object bounding box for a node given its stroke bounding box. + * @param {Number} top the y coord of the top edge of the stroke bounding box + * @param {Number} left the x coord of the left edge of the stroke bounding box + * @param {Number} width the width of the stroke bounding box + * @param {Number} height the height of the stroke bounding box + * @param {Object} node the node object + * @returns {Object} an object of the form { top, left, width, height }, which + * are the top/left/width/height of the object bounding box for the node. + */ +const getObjectBoundingBox = (top, left, width, height, node) => { + // See https://drafts.fxtf.org/css-masking-1/#stroke-bounding-box for details + // on this algorithm. Note that we intentionally do not check "stroke-linecap". + const strokeWidth = parseFloat(getComputedStyle(node).strokeWidth); + let delta = strokeWidth / 2; + const tagName = node.tagName; + + if ( + tagName !== "rect" && + tagName !== "ellipse" && + tagName !== "circle" && + tagName !== "image" + ) { + if (getComputedStyle(node).strokeLinejoin === "miter") { + const miter = getComputedStyle(node).strokeMiterlimit; + if (miter < Math.SQRT2) { + delta *= Math.SQRT2; + } else { + delta *= miter; + } + } else { + delta *= Math.SQRT2; + } + } + + return { + top: top + delta, + left: left + delta, + width: width - 2 * delta, + height: height - 2 * delta, + }; +}; + +/** + * Get the unit (e.g. px, %, em) for the given point value. + * @param {any} point a point value for which a unit should be retrieved. + * @returns {String} the unit. + */ +const getUnit = point => { + // If the point has no unit, default to px. + if (isUnitless(point)) { + return "px"; + } + const [unit] = point.match(/[^\d]+$/) || ["px"]; + return unit; +}; +exports.getUnit = getUnit; + +/** + * Check if the given point value has a unit. + * @param {any} point a point value. + * @returns {Boolean} whether the given value has a unit. + */ +const isUnitless = point => { + return ( + !point || + !point.match(/[^\d]+$/) || + // If zero doesn't have a unit, its numeric and string forms should be equal. + (parseFloat(point) === 0 && parseFloat(point).toString() === point) || + point.includes("(") || + point === "center" || + point === "closest-side" || + point === "farthest-side" + ); +}; + +/** + * Return the anchor corresponding to the given scale type. + * @param {String} type a scale type, of form "scale-[direction]" + * @returns {String} a string describing the anchor, one of the 8 cardinal directions. + */ +const getAnchorPoint = type => { + let anchor = type.split("-")[1]; + if (anchor.includes("n")) { + anchor = anchor.replace("n", "s"); + } else if (anchor.includes("s")) { + anchor = anchor.replace("s", "n"); + } + if (anchor.includes("w")) { + anchor = anchor.replace("w", "e"); + } else if (anchor.includes("e")) { + anchor = anchor.replace("e", "w"); + } + + if (anchor === "e" || anchor === "w") { + anchor = "n" + anchor; + } else if (anchor === "n" || anchor === "s") { + anchor = anchor + "w"; + } + + return anchor; +}; + +/** + * Get the decimal point precision for values depending on unit type. + * Only handle pixels and falsy values for now. Round them to the nearest integer value. + * All other unit types round to two decimal points. + * + * @param {String|undefined} unitType any one of the accepted CSS unit types for position. + * @return {Number} decimal precision when rounding a value + */ +function getDecimalPrecision(unitType) { + switch (unitType) { + case "px": + case "": + case undefined: + return 0; + default: + return 2; + } +} +exports.getDecimalPrecision = getDecimalPrecision; + +/** + * Round up a numeric value to a fixed number of decimals depending on CSS unit type. + * Used when generating output shape values when: + * - transforming shapes + * - inserting new points on a polygon. + * + * @param {Number} number + * Value to round up. + * @param {String} unitType + * CSS unit type, like "px", "%", "em", "vh", etc. + * @return {Number} + * Rounded value + */ +function round(number, unitType) { + return number.toFixed(getDecimalPrecision(unitType)); +} + +exports.ShapesHighlighter = ShapesHighlighter; diff --git a/devtools/server/actors/highlighters/tabbing-order.js b/devtools/server/actors/highlighters/tabbing-order.js new file mode 100644 index 0000000000..891325f669 --- /dev/null +++ b/devtools/server/actors/highlighters/tabbing-order.js @@ -0,0 +1,237 @@ +/* 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"); +loader.lazyRequireGetter( + this, + "ContentDOMReference", + "resource://gre/modules/ContentDOMReference.jsm", + true +); +loader.lazyRequireGetter( + this, + ["isRemoteFrame", "isWindowIncluded"], + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + "NodeTabbingOrderHighlighter", + "devtools/server/actors/highlighters/node-tabbing-order", + true +); + +const DEFAULT_FOCUS_FLAGS = Services.focus.FLAG_NOSCROLL; + +/** + * The TabbingOrderHighlighter uses focus manager to traverse all focusable + * nodes on the page and then uses the NodeTabbingOrderHighlighter to highlight + * these nodes. + */ +class TabbingOrderHighlighter { + constructor(highlighterEnv) { + this.highlighterEnv = highlighterEnv; + this._highlighters = new Map(); + + this.onMutation = this.onMutation.bind(this); + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + } + + /** + * Static getter that indicates that TabbingOrderHighlighter supports + * highlighting in XUL windows. + */ + static get XULSupported() { + return true; + } + + get win() { + return this.highlighterEnv.window; + } + + get focusedElement() { + return Services.focus.getFocusedElementForWindow(this.win, true, {}); + } + + set focusedElement(element) { + Services.focus.setFocus(element, DEFAULT_FOCUS_FLAGS); + } + + moveFocus(startElement) { + return Services.focus.moveFocus( + this.win, + startElement.nodeType === Node.DOCUMENT_NODE + ? startElement.documentElement + : startElement, + Services.focus.MOVEFOCUS_FORWARD, + DEFAULT_FOCUS_FLAGS + ); + } + + /** + * Show NodeTabbingOrderHighlighter on each node that belongs to the keyboard + * tabbing order. + * + * @param {DOMNode} startElm + * Starting element to calculate tabbing order from. + * + * @param {JSON} options + * - options.index + * Start index for the tabbing order. Starting index will be 0 at + * the start of the tabbing order highlighting; in remote frames + * starting index will, typically, be greater than 0 (unless there + * was nothing to focus in the top level content document prior to + * the remote frame). + */ + async show(startElm, { index }) { + const focusableElements = []; + const originalFocusedElement = this.focusedElement; + let currentFocusedElement = this.moveFocus(startElm); + while ( + currentFocusedElement && + isWindowIncluded(this.win, currentFocusedElement.ownerGlobal) + ) { + focusableElements.push(currentFocusedElement); + currentFocusedElement = this.moveFocus(currentFocusedElement); + } + + // Allow to flush pending notifications to ensure the PresShell and frames + // are updated. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + let endElm = this.focusedElement; + if ( + currentFocusedElement && + !isWindowIncluded(this.win, currentFocusedElement.ownerGlobal) + ) { + endElm = null; + } + + if ( + !endElm && + focusableElements.length > 0 && + isRemoteFrame(focusableElements[focusableElements.length - 1]) + ) { + endElm = focusableElements[focusableElements.length - 1]; + } + + if (originalFocusedElement && originalFocusedElement !== endElm) { + this.focusedElement = originalFocusedElement; + } + + const highlighters = []; + for (let i = 0; i < focusableElements.length; i++) { + highlighters.push( + this._accumulateHighlighter(focusableElements[i], index++) + ); + } + await Promise.all(highlighters); + + this._trackMutations(); + + return { + contentDOMReference: endElm && ContentDOMReference.get(endElm), + index, + }; + } + + async _accumulateHighlighter(node, index) { + const highlighter = new NodeTabbingOrderHighlighter(this.highlighterEnv); + await highlighter.isReady; + + highlighter.show(node, { index: index + 1 }); + this._highlighters.set(node, highlighter); + } + + hide() { + this._untrackMutations(); + for (const highlighter of this._highlighters.values()) { + highlighter.destroy(); + } + + this._highlighters.clear(); + } + + /** + * Track mutations in the top level document subtree so that the appropriate + * NodeTabbingOrderHighlighter infobar's could be updated to reflect the + * attribute mutations on relevant nodes. + */ + _trackMutations() { + const { win } = this; + this.currentMutationObserver = new win.MutationObserver(this.onMutation); + this.currentMutationObserver.observe(win.document.documentElement, { + subtree: true, + attributes: true, + }); + } + + _untrackMutations() { + if (!this.currentMutationObserver) { + return; + } + + this.currentMutationObserver.disconnect(); + this.currentMutationObserver = null; + } + + onMutation(mutationList) { + for (const { target } of mutationList) { + const highlighter = this._highlighters.get(target); + if (highlighter) { + highlighter.update(); + } + } + } + + /** + * Update NodeTabbingOrderHighlighter focus styling for a node that, + * potentially, belongs to the tabbing order. + * @param {Object} options + * Options specifying the node and its focused state. + */ + updateFocus({ node, focused }) { + const highlighter = this._highlighters.get(node); + if (!highlighter) { + return; + } + + highlighter.updateFocus(focused); + } + + destroy() { + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = this.highlighterEnv; + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + } + + this.hide(); + this.highlighterEnv = null; + } + + onPageHide({ target }) { + // If a pagehide event is triggered for current window's highlighter, hide + // the highlighter. + if (target.defaultView === this.win) { + this.hide(); + } + } + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +} + +exports.TabbingOrderHighlighter = TabbingOrderHighlighter; diff --git a/devtools/server/actors/highlighters/utils/accessibility.js b/devtools/server/actors/highlighters/utils/accessibility.js new file mode 100644 index 0000000000..18e4549a5f --- /dev/null +++ b/devtools/server/actors/highlighters/utils/accessibility.js @@ -0,0 +1,769 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { getCurrentZoom } = require("devtools/shared/layout/utils"); +const { + moveInfobar, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { truncateString } = require("devtools/shared/inspector/utils"); + +const STRINGS_URI = "devtools/shared/locales/accessibility.properties"; +loader.lazyRequireGetter( + this, + "LocalizationHelper", + "devtools/shared/l10n", + true +); +DevToolsUtils.defineLazyGetter( + this, + "L10N", + () => new LocalizationHelper(STRINGS_URI) +); + +const { + accessibility: { + AUDIT_TYPE, + ISSUE_TYPE: { + [AUDIT_TYPE.KEYBOARD]: { + FOCUSABLE_NO_SEMANTICS, + FOCUSABLE_POSITIVE_TABINDEX, + INTERACTIVE_NO_ACTION, + INTERACTIVE_NOT_FOCUSABLE, + MOUSE_INTERACTIVE_ONLY, + NO_FOCUS_VISIBLE, + }, + [AUDIT_TYPE.TEXT_LABEL]: { + AREA_NO_NAME_FROM_ALT, + DIALOG_NO_NAME, + DOCUMENT_NO_TITLE, + EMBED_NO_NAME, + FIGURE_NO_NAME, + FORM_FIELDSET_NO_NAME, + FORM_FIELDSET_NO_NAME_FROM_LEGEND, + FORM_NO_NAME, + FORM_NO_VISIBLE_NAME, + FORM_OPTGROUP_NO_NAME_FROM_LABEL, + FRAME_NO_NAME, + HEADING_NO_CONTENT, + HEADING_NO_NAME, + IFRAME_NO_NAME_FROM_TITLE, + IMAGE_NO_NAME, + INTERACTIVE_NO_NAME, + MATHML_GLYPH_NO_NAME, + TOOLBAR_NO_NAME, + }, + }, + SCORES, + }, +} = require("devtools/shared/constants"); + +// Max string length for truncating accessible name values. +const MAX_STRING_LENGTH = 50; + +/** + * The AccessibleInfobar is a class responsible for creating the markup for the + * accessible highlighter. It is also reponsible for updating content within the + * infobar such as role and name values. + */ +class Infobar { + constructor(highlighter) { + this.highlighter = highlighter; + this.audit = new Audit(this); + } + + get markup() { + return this.highlighter.markup; + } + + get document() { + return this.highlighter.win.document; + } + + get bounds() { + return this.highlighter._bounds; + } + + get options() { + return this.highlighter.options; + } + + get prefix() { + return this.highlighter.ID_CLASS_PREFIX; + } + + get win() { + return this.highlighter.win; + } + + /** + * Move the Infobar to the right place in the highlighter. + * + * @param {Element} container + * Container of infobar. + */ + _moveInfobar(container) { + // Position the infobar using accessible's bounds + const { left: x, top: y, bottom, width } = this.bounds; + const infobarBounds = { x, y, bottom, width }; + + moveInfobar(container, infobarBounds, this.win); + } + + /** + * Build markup for infobar. + * + * @param {Element} root + * Root element to build infobar with. + */ + buildMarkup(root) { + const container = this.markup.createNode({ + parent: root, + attributes: { + class: "infobar-container", + id: "infobar-container", + "aria-hidden": "true", + hidden: "true", + }, + prefix: this.prefix, + }); + + const infobar = this.markup.createNode({ + parent: container, + attributes: { + class: "infobar", + id: "infobar", + }, + prefix: this.prefix, + }); + + const infobarText = this.markup.createNode({ + parent: infobar, + attributes: { + class: "infobar-text", + id: "infobar-text", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: infobarText, + attributes: { + class: "infobar-role", + id: "infobar-role", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: infobarText, + attributes: { + class: "infobar-name", + id: "infobar-name", + }, + prefix: this.prefix, + }); + + this.audit.buildMarkup(infobarText); + } + + /** + * Destroy the Infobar's highlighter. + */ + destroy() { + this.highlighter = null; + this.audit.destroy(); + this.audit = null; + } + + /** + * Gets the element with the specified ID. + * + * @param {String} id + * Element ID. + * @return {Element} The element with specified ID. + */ + getElement(id) { + return this.highlighter.getElement(id); + } + + /** + * Gets the text content of element. + * + * @param {String} id + * Element ID to retrieve text content from. + * @return {String} The text content of the element. + */ + getTextContent(id) { + const anonymousContent = this.markup.content; + return anonymousContent.getTextContentForElement(`${this.prefix}${id}`); + } + + /** + * Hide the accessible infobar. + */ + hide() { + const container = this.getElement("infobar-container"); + container.setAttribute("hidden", "true"); + } + + /** + * Show the accessible infobar highlighter. + */ + show() { + const container = this.getElement("infobar-container"); + + // Remove accessible's infobar "hidden" attribute. We do this first to get the + // computed styles of the infobar container. + container.removeAttribute("hidden"); + + // Update the infobar's position and content. + this.update(container); + } + + /** + * Update content of the infobar. + */ + update(container) { + const { audit, name, role } = this.options; + + this.updateRole(role, this.getElement("infobar-role")); + this.updateName(name, this.getElement("infobar-name")); + this.audit.update(audit); + + // Position the infobar. + this._moveInfobar(container); + } + + /** + * Sets the text content of the specified element. + * + * @param {Element} el + * Element to set text content on. + * @param {String} text + * Text for content. + */ + setTextContent(el, text) { + el.setTextContent(text); + } + + /** + * Show the accessible's name message. + * + * @param {String} name + * Accessible's name value. + * @param {Element} el + * Element to set text content on. + */ + updateName(name, el) { + const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : ""; + this.setTextContent(el, nameText); + } + + /** + * Show the accessible's role. + * + * @param {String} role + * Accessible's role value. + * @param {Element} el + * Element to set text content on. + */ + updateRole(role, el) { + this.setTextContent(el, role); + } +} + +/** + * Audit component used within the accessible highlighter infobar. This component is + * responsible for rendering and updating its containing AuditReport components that + * display various audit information such as contrast ratio score. + */ +class Audit { + constructor(infobar) { + this.infobar = infobar; + + // A list of audit reports to be shown on the fly when highlighting an accessible + // object. + this.reports = { + [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this), + [AUDIT_TYPE.KEYBOARD]: new Keyboard(this), + [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this), + }; + } + + get prefix() { + return this.infobar.prefix; + } + + get markup() { + return this.infobar.markup; + } + + buildMarkup(root) { + const audit = this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "infobar-audit", + id: "infobar-audit", + }, + prefix: this.prefix, + }); + + Object.values(this.reports).forEach(report => report.buildMarkup(audit)); + } + + update(audit = {}) { + const el = this.getElement("infobar-audit"); + el.setAttribute("hidden", true); + + let updated = false; + Object.values(this.reports).forEach(report => { + if (report.update(audit)) { + updated = true; + } + }); + + if (updated) { + el.removeAttribute("hidden"); + } + } + + getElement(id) { + return this.infobar.getElement(id); + } + + setTextContent(el, text) { + return this.infobar.setTextContent(el, text); + } + + destroy() { + this.infobar = null; + Object.values(this.reports).forEach(report => report.destroy()); + this.reports = null; + } +} + +/** + * A common interface between audit report components used to render accessibility audit + * information for the currently highlighted accessible object. + */ +class AuditReport { + constructor(audit) { + this.audit = audit; + } + + get prefix() { + return this.audit.prefix; + } + + get markup() { + return this.audit.markup; + } + + getElement(id) { + return this.audit.getElement(id); + } + + setTextContent(el, text) { + return this.audit.setTextContent(el, text); + } + + destroy() { + this.audit = null; + } +} + +/** + * Contrast ratio audit report that is used to display contrast ratio score as part of the + * inforbar, + */ +class ContrastRatio extends AuditReport { + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-label", + id: "contrast-ratio-label", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-error", + id: "contrast-ratio-error", + }, + prefix: this.prefix, + text: L10N.getStr("accessibility.contrast.ratio.error"), + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio", + id: "contrast-ratio-min", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-separator", + id: "contrast-ratio-separator", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio", + id: "contrast-ratio-max", + }, + prefix: this.prefix, + }); + } + + _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) { + value = value.toFixed(2); + this.setTextContent(el, value); + el.classList.add(className); + el.setAttribute( + "style", + `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` + + `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});` + ); + el.removeAttribute("hidden"); + } + + /** + * Update contrast ratio score infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the contrast ratio markup was updated correctly and infobar audit + * block should be visible. + */ + update(audit) { + const els = {}; + for (const key of ["label", "min", "max", "error", "separator"]) { + const el = (els[key] = this.getElement(`contrast-ratio-${key}`)); + if (["min", "max"].includes(key)) { + Object.values(SCORES).forEach(className => + el.classList.remove(className) + ); + this.setTextContent(el, ""); + } + + el.setAttribute("hidden", true); + el.removeAttribute("style"); + } + + if (!audit) { + return false; + } + + const contrastRatio = audit[AUDIT_TYPE.CONTRAST]; + if (!contrastRatio) { + return false; + } + + const { isLargeText, error } = contrastRatio; + this.setTextContent( + els.label, + L10N.getStr( + `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}` + ) + ); + els.label.removeAttribute("hidden"); + if (error) { + els.error.removeAttribute("hidden"); + return true; + } + + if (contrastRatio.value) { + const { value, color, score, backgroundColor } = contrastRatio; + this._fillAndStyleContrastValue(els.min, { + value, + className: score, + color, + backgroundColor, + }); + return true; + } + + const { + min, + max, + color, + backgroundColorMin, + backgroundColorMax, + scoreMin, + scoreMax, + } = contrastRatio; + this._fillAndStyleContrastValue(els.min, { + value: min, + className: scoreMin, + color, + backgroundColor: backgroundColorMin, + }); + els.separator.removeAttribute("hidden"); + this._fillAndStyleContrastValue(els.max, { + value: max, + className: scoreMax, + color, + backgroundColor: backgroundColorMax, + }); + + return true; + } +} + +/** + * Keyboard audit report that is used to display a problem with keyboard + * accessibility as part of the inforbar. + */ +class Keyboard extends AuditReport { + /** + * A map from keyboard issues to annotation component properties. + */ + static get ISSUE_TO_INFOBAR_LABEL_MAP() { + return { + [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics", + [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex", + [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action", + [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable", + [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only", + [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible", + }; + } + + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "audit", + id: "keyboard", + }, + prefix: this.prefix, + }); + } + + /** + * Update keyboard audit infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the keyboard markup was updated correctly and infobar audit + * block should be visible. + */ + update(audit) { + const el = this.getElement("keyboard"); + el.setAttribute("hidden", true); + Object.values(SCORES).forEach(className => el.classList.remove(className)); + + if (!audit) { + return false; + } + + const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD]; + if (!keyboardAudit) { + return false; + } + + const { issue, score } = keyboardAudit; + this.setTextContent( + el, + L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) + ); + el.classList.add(score); + el.removeAttribute("hidden"); + + return true; + } +} + +/** + * Text label audit report that is used to display a problem with text alternatives + * as part of the inforbar. + */ +class TextLabel extends AuditReport { + /** + * A map from text label issues to annotation component properties. + */ + static get ISSUE_TO_INFOBAR_LABEL_MAP() { + return { + [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area", + [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog", + [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title", + [EMBED_NO_NAME]: "accessibility.text.label.issue.embed", + [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure", + [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset", + [FORM_FIELDSET_NO_NAME_FROM_LEGEND]: + "accessibility.text.label.issue.fieldset.legend2", + [FORM_NO_NAME]: "accessibility.text.label.issue.form", + [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible", + [FORM_OPTGROUP_NO_NAME_FROM_LABEL]: + "accessibility.text.label.issue.optgroup.label2", + [FRAME_NO_NAME]: "accessibility.text.label.issue.frame", + [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content", + [HEADING_NO_NAME]: "accessibility.text.label.issue.heading", + [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe", + [IMAGE_NO_NAME]: "accessibility.text.label.issue.image", + [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive", + [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph", + [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar", + }; + } + + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "audit", + id: "text-label", + }, + prefix: this.prefix, + }); + } + + /** + * Update text label audit infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the text label markup was updated correctly and infobar + * audit block should be visible. + */ + update(audit) { + const el = this.getElement("text-label"); + el.setAttribute("hidden", true); + Object.values(SCORES).forEach(className => el.classList.remove(className)); + + if (!audit) { + return false; + } + + const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL]; + if (!textLabelAudit) { + return false; + } + + const { issue, score } = textLabelAudit; + this.setTextContent( + el, + L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) + ); + el.classList.add(score); + el.removeAttribute("hidden"); + + return true; + } +} + +/** + * A helper function that calculate accessible object bounds and positioning to + * be used for highlighting. + * + * @param {Object} win + * window that contains accessible object. + * @param {Object} options + * Object used for passing options: + * - {Number} x + * x coordinate of the top left corner of the accessible object + * - {Number} y + * y coordinate of the top left corner of the accessible object + * - {Number} w + * width of the the accessible object + * - {Number} h + * height of the the accessible object + * @return {Object|null} Returns, if available, positioning and bounds information for + * the accessible object. + */ +function getBounds(win, { x, y, w, h }) { + const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win; + const zoom = getCurrentZoom(win); + let left = x; + let right = x + w; + let top = y; + let bottom = y + h; + + left -= mozInnerScreenX - scrollX; + right -= mozInnerScreenX - scrollX; + top -= mozInnerScreenY - scrollY; + bottom -= mozInnerScreenY - scrollY; + + left *= zoom; + right *= zoom; + top *= zoom; + bottom *= zoom; + + const width = right - left; + const height = bottom - top; + + return { left, right, top, bottom, width, height }; +} + +/** + * A helper function that calculate accessible object bounds and positioning to + * be used for highlighting in browser toolbox. + * + * @param {Object} win + * window that contains accessible object. + * @param {Object} options + * Object used for passing options: + * - {Number} x + * x coordinate of the top left corner of the accessible object + * - {Number} y + * y coordinate of the top left corner of the accessible object + * - {Number} w + * width of the the accessible object + * - {Number} h + * height of the the accessible object + * - {Number} zoom + * zoom level of the accessible object's parent window + * @return {Object|null} Returns, if available, positioning and bounds information for + * the accessible object. + */ +function getBoundsXUL(win, { x, y, w, h, zoom }) { + const { mozInnerScreenX, mozInnerScreenY } = win; + let left = x; + let right = x + w; + let top = y; + let bottom = y + h; + + left *= zoom; + right *= zoom; + top *= zoom; + bottom *= zoom; + + left -= mozInnerScreenX; + right -= mozInnerScreenX; + top -= mozInnerScreenY; + bottom -= mozInnerScreenY; + + const width = right - left; + const height = bottom - top; + + return { left, right, top, bottom, width, height }; +} + +exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH; +exports.getBounds = getBounds; +exports.getBoundsXUL = getBoundsXUL; +exports.Infobar = Infobar; diff --git a/devtools/server/actors/highlighters/utils/canvas.js b/devtools/server/actors/highlighters/utils/canvas.js new file mode 100644 index 0000000000..06f143d04d --- /dev/null +++ b/devtools/server/actors/highlighters/utils/canvas.js @@ -0,0 +1,596 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + apply, + getNodeTransformationMatrix, + getWritingModeMatrix, + identity, + isIdentity, + multiply, + scale, + translate, +} = require("devtools/shared/layout/dom-matrix-2d"); +const { + getCurrentZoom, + getViewportDimensions, +} = require("devtools/shared/layout/utils"); +const { + getComputedStyle, +} = require("devtools/server/actors/highlighters/utils/markup"); + +// A set of utility functions for highlighters that render their content to a <canvas> +// element. + +// We create a <canvas> element that has always 4096x4096 physical pixels, to displays +// our grid's overlay. +// Then, we move the element around when needed, to give the perception that it always +// covers the screen (See bug 1345434). +// +// This canvas size value is the safest we can use because most GPUs can handle it. +// It's also far from the maximum canvas memory allocation limit (4096x4096x4 is +// 67.108.864 bytes, where the limit is 500.000.000 bytes, see +// gfx_max_alloc_size in modules/libpref/init/StaticPrefList.yaml. +// +// Note: +// Once bug 1232491 lands, we could try to refactor this code to use the values from +// the displayport API instead. +// +// Using a fixed value should also solve bug 1348293. +const CANVAS_SIZE = 4096; + +// The default color used for the canvas' font, fill and stroke colors. +const DEFAULT_COLOR = "#9400FF"; + +/** + * Draws a rect to the context given and applies a transformation matrix if passed. + * The coordinates are the start and end points of the rectangle's diagonal. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * The transformation matrix to apply. + */ +function clearRect(ctx, x1, y1, x2, y2, matrix = identity()) { + const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix); + + // We are creating a clipping path and want it removed after we clear it's + // contents so we need to save the context. + ctx.save(); + + // Create a path to be cleared. + ctx.beginPath(); + ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y)); + ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y)); + ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y)); + ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y)); + ctx.closePath(); + + // Restrict future drawing to the inside of the path. + ctx.clip(); + + // Clear any transforms applied to the canvas so that clearRect() really does + // clear everything. + ctx.setTransform(1, 0, 0, 1, 0, 0); + + // Clear the contents of our clipped path by attempting to clear the canvas. + ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + + // Restore the context to the state it was before changing transforms and + // adding clipping paths. + ctx.restore(); +} + +/** + * Draws an arrow-bubble rectangle in the provided canvas context. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x + * The x-axis origin of the rectangle. + * @param {Number} y + * The y-axis origin of the rectangle. + * @param {Number} width + * The width of the rectangle. + * @param {Number} height + * The height of the rectangle. + * @param {Number} radius + * The radius of the rounding. + * @param {Number} margin + * The distance of the origin point from the pointer. + * @param {Number} arrowSize + * The size of the arrow. + * @param {String} alignment + * The alignment of the rectangle in relation to its position to the grid. + */ +function drawBubbleRect( + ctx, + x, + y, + width, + height, + radius, + margin, + arrowSize, + alignment +) { + let angle = 0; + + if (alignment === "bottom") { + angle = 180; + } else if (alignment === "right") { + angle = 90; + [width, height] = [height, width]; + } else if (alignment === "left") { + [width, height] = [height, width]; + angle = 270; + } + + const originX = x; + const originY = y; + + ctx.save(); + ctx.translate(originX, originY); + ctx.rotate(angle * (Math.PI / 180)); + ctx.translate(-originX, -originY); + ctx.translate(-width / 2, -height - arrowSize - margin); + + // The contour of the bubble is drawn with a path. The canvas context will have taken + // care of transforming the coordinates before calling the function, so we just always + // draw with the arrow pointing down. The top edge has rounded corners too. + ctx.beginPath(); + // Start at the top/left corner (below the rounded corner). + ctx.moveTo(x, y + radius); + // Go down. + ctx.lineTo(x, y + height); + // Go down and the right, to draw the first half of the arrow tip. + ctx.lineTo(x + width / 2, y + height + arrowSize); + // Go back up and to the right, to draw the second half of the arrow tip. + ctx.lineTo(x + width, y + height); + // Go up to just below the top/right rounded corner. + ctx.lineTo(x + width, y + radius); + // Draw the top/right rounded corner. + ctx.arcTo(x + width, y, x + width - radius, y, radius); + // Go to the left. + ctx.lineTo(x + radius, y); + // Draw the top/left rounded corner. + ctx.arcTo(x, y, x, y + radius, radius); + + ctx.stroke(); + ctx.fill(); + + ctx.restore(); +} + +/** + * Draws a line to the context given and applies a transformation matrix if passed. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis of the coordinate for the begin of the line. + * @param {Number} y1 + * The y-axis of the coordinate for the begin of the line. + * @param {Number} x2 + * The x-axis of the coordinate for the end of the line. + * @param {Number} y2 + * The y-axis of the coordinate for the end of the line. + * @param {Object} [options] + * The options object. + * @param {Array} [options.matrix=identity()] + * The transformation matrix to apply. + * @param {Array} [options.extendToBoundaries] + * If set, the line will be extended to reach the boundaries specified. + */ +function drawLine(ctx, x1, y1, x2, y2, options) { + const matrix = options.matrix || identity(); + + const p1 = apply(matrix, [x1, y1]); + const p2 = apply(matrix, [x2, y2]); + + x1 = p1[0]; + y1 = p1[1]; + x2 = p2[0]; + y2 = p2[1]; + + if (options.extendToBoundaries) { + if (p1[1] === p2[1]) { + x1 = options.extendToBoundaries[0]; + x2 = options.extendToBoundaries[2]; + } else { + y1 = options.extendToBoundaries[1]; + x1 = ((p2[0] - p1[0]) * (y1 - p1[1])) / (p2[1] - p1[1]) + p1[0]; + y2 = options.extendToBoundaries[3]; + x2 = ((p2[0] - p1[0]) * (y2 - p1[1])) / (p2[1] - p1[1]) + p1[0]; + } + } + + ctx.beginPath(); + ctx.moveTo(Math.round(x1), Math.round(y1)); + ctx.lineTo(Math.round(x2), Math.round(y2)); +} + +/** + * Draws a rect to the context given and applies a transformation matrix if passed. + * The coordinates are the start and end points of the rectangle's diagonal. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * The transformation matrix to apply. + */ +function drawRect(ctx, x1, y1, x2, y2, matrix = identity()) { + const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix); + + ctx.beginPath(); + ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y)); + ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y)); + ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y)); + ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y)); + ctx.closePath(); +} + +/** + * Draws a rounded rectangle in the provided canvas context. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x + * The x-axis origin of the rectangle. + * @param {Number} y + * The y-axis origin of the rectangle. + * @param {Number} width + * The width of the rectangle. + * @param {Number} height + * The height of the rectangle. + * @param {Number} radius + * The radius of the rounding. + */ +function drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y + height - radius); + ctx.arcTo(x, y + height, x + radius, y + height, radius); + ctx.lineTo(x + width - radius, y + height); + ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius); + ctx.lineTo(x + width, y + radius); + ctx.arcTo(x + width, y, x + width - radius, y, radius); + ctx.lineTo(x + radius, y); + ctx.arcTo(x, y, x, y + radius, radius); + ctx.stroke(); + ctx.fill(); +} + +/** + * Given an array of four points and returns a DOMRect-like object representing the + * boundaries defined by the four points. + * + * @param {Array} points + * An array with 4 pointer objects {x, y} representing the box quads. + * @return {Object} DOMRect-like object of the 4 points. + */ +function getBoundsFromPoints(points) { + const bounds = {}; + + bounds.left = Math.min(points[0].x, points[1].x, points[2].x, points[3].x); + bounds.right = Math.max(points[0].x, points[1].x, points[2].x, points[3].x); + bounds.top = Math.min(points[0].y, points[1].y, points[2].y, points[3].y); + bounds.bottom = Math.max(points[0].y, points[1].y, points[2].y, points[3].y); + + bounds.x = bounds.left; + bounds.y = bounds.top; + bounds.width = bounds.right - bounds.left; + bounds.height = bounds.bottom - bounds.top; + + return bounds; +} + +/** + * Returns the current matrices for both canvas drawing and SVG taking into account the + * following transformations, in this order: + * 1. The scale given by the display pixel ratio. + * 2. The translation to the top left corner of the element. + * 3. The scale given by the current zoom. + * 4. The translation given by the top and left padding of the element. + * 5. Any CSS transformation applied directly to the element (only 2D + * transformation; the 3D transformation are flattened, see `dom-matrix-2d` module + * for further details.) + * 6. Rotate, translate, and reflect as needed to match the writing mode and text + * direction of the element. + * + * The transformations of the element's ancestors are not currently computed (see + * bug 1355675). + * + * @param {Element} element + * The current element. + * @param {Window} window + * The window object. + * @param {Object} [options.ignoreWritingModeAndTextDirection=false] + * Avoid transforming the current matrix to match the text direction + * and writing mode. + * @return {Object} An object with the following properties: + * - {Array} currentMatrix + * The current matrix. + * - {Boolean} hasNodeTransformations + * true if the node has transformed and false otherwise. + */ +function getCurrentMatrix( + element, + window, + { ignoreWritingModeAndTextDirection } = {} +) { + const computedStyle = getComputedStyle(element); + + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingRight = parseFloat(computedStyle.paddingRight); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const borderTop = parseFloat(computedStyle.borderTopWidth); + const borderRight = parseFloat(computedStyle.borderRightWidth); + const borderBottom = parseFloat(computedStyle.borderBottomWidth); + const borderLeft = parseFloat(computedStyle.borderLeftWidth); + + const nodeMatrix = getNodeTransformationMatrix( + element, + window.document.documentElement + ); + + let currentMatrix = identity(); + let hasNodeTransformations = false; + + // Scale based on the device pixel ratio. + currentMatrix = multiply(currentMatrix, scale(window.devicePixelRatio)); + + // Apply the current node's transformation matrix, relative to the inspected window's + // root element, but only if it's not a identity matrix. + if (isIdentity(nodeMatrix)) { + hasNodeTransformations = false; + } else { + currentMatrix = multiply(currentMatrix, nodeMatrix); + hasNodeTransformations = true; + } + + // Translate the origin based on the node's padding and border values. + currentMatrix = multiply( + currentMatrix, + translate(paddingLeft + borderLeft, paddingTop + borderTop) + ); + + // Adjust as needed to match the writing mode and text direction of the element. + const size = { + width: + element.offsetWidth - + borderLeft - + borderRight - + paddingLeft - + paddingRight, + height: + element.offsetHeight - + borderTop - + borderBottom - + paddingTop - + paddingBottom, + }; + + if (!ignoreWritingModeAndTextDirection) { + const writingModeMatrix = getWritingModeMatrix(size, computedStyle); + if (!isIdentity(writingModeMatrix)) { + currentMatrix = multiply(currentMatrix, writingModeMatrix); + } + } + + return { currentMatrix, hasNodeTransformations }; +} + +/** + * Given an array of four points, returns a string represent a path description. + * + * @param {Array} points + * An array with 4 pointer objects {x, y} representing the box quads. + * @return {String} a Path Description that can be used in svg's <path> element. + */ +function getPathDescriptionFromPoints(points) { + return ( + "M" + + points[0].x + + "," + + points[0].y + + " " + + "L" + + points[1].x + + "," + + points[1].y + + " " + + "L" + + points[2].x + + "," + + points[2].y + + " " + + "L" + + points[3].x + + "," + + points[3].y + ); +} + +/** + * Given the rectangle's diagonal start and end coordinates, returns an array containing + * the four coordinates of a rectangle. If a matrix is provided, applies the matrix + * function to each of the coordinates' value. + * + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * A transformation matrix to apply. + * @return {Array} the four coordinate points of the given rectangle transformed by the + * matrix given. + */ +function getPointsFromDiagonal(x1, y1, x2, y2, matrix = identity()) { + return [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ].map(point => { + const transformedPoint = apply(matrix, point); + + return { x: transformedPoint[0], y: transformedPoint[1] }; + }); +} + +/** + * Updates the <canvas> element's style in accordance with the current window's + * device pixel ratio, and the position calculated in `getCanvasPosition`. It also + * clears the drawing context. This is called on canvas update after a scroll event where + * `getCanvasPosition` updates the new canvasPosition. + * + * @param {Canvas} canvas + * The <canvas> element. + * @param {Object} canvasPosition + * A pointer object {x, y} representing the <canvas> position to the top left + * corner of the page. + * @param {Number} devicePixelRatio + * The device pixel ratio. + * @param {Window} [options.zoomWindow] + * Optional window object used to calculate zoom (default = undefined). + */ +function updateCanvasElement( + canvas, + canvasPosition, + devicePixelRatio, + { zoomWindow } = {} +) { + let { x, y } = canvasPosition; + const size = CANVAS_SIZE / devicePixelRatio; + + if (zoomWindow) { + const zoom = getCurrentZoom(zoomWindow); + x *= zoom; + y *= zoom; + } + + // Resize the canvas taking the dpr into account so as to have crisp lines, and + // translating it to give the perception that it always covers the viewport. + canvas.setAttribute( + "style", + `width: ${size}px; height: ${size}px; transform: translate(${x}px, ${y}px);` + ); + canvas.getCanvasContext("2d").clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); +} + +/** + * Calculates and returns the <canvas>'s position in accordance with the page's scroll, + * document's size, canvas size, and viewport's size. This is called when a page's scroll + * is detected. + * + * @param {Object} canvasPosition + * A pointer object {x, y} representing the <canvas> position to the top left + * corner of the page. + * @param {Object} scrollPosition + * A pointer object {x, y} representing the window's pageXOffset and pageYOffset. + * @param {Window} window + * The window object. + * @param {Object} windowDimensions + * An object {width, height} representing the window's dimensions for the + * `window` given. + * @return {Boolean} true if the <canvas> position was updated and false otherwise. + */ +function updateCanvasPosition( + canvasPosition, + scrollPosition, + window, + windowDimensions +) { + let { x: canvasX, y: canvasY } = canvasPosition; + const { x: scrollX, y: scrollY } = scrollPosition; + const cssCanvasSize = CANVAS_SIZE / window.devicePixelRatio; + const viewportSize = getViewportDimensions(window); + const { height, width } = windowDimensions; + const canvasWidth = cssCanvasSize; + const canvasHeight = cssCanvasSize; + let hasUpdated = false; + + // Those values indicates the relative horizontal and vertical space the page can + // scroll before we have to reposition the <canvas>; they're 1/4 of the delta between + // the canvas' size and the viewport's size: that's because we want to consider both + // sides (top/bottom, left/right; so 1/2 for each side) and also we don't want to + // shown the edges of the canvas in case of fast scrolling (to avoid showing undraw + // areas, therefore another 1/2 here). + const bufferSizeX = (canvasWidth - viewportSize.width) >> 2; + const bufferSizeY = (canvasHeight - viewportSize.height) >> 2; + + // Defines the boundaries for the canvas. + const leftBoundary = 0; + const rightBoundary = width - canvasWidth; + const topBoundary = 0; + const bottomBoundary = height - canvasHeight; + + // Defines the thresholds that triggers the canvas' position to be updated. + const leftThreshold = scrollX - bufferSizeX; + const rightThreshold = + scrollX - canvasWidth + viewportSize.width + bufferSizeX; + const topThreshold = scrollY - bufferSizeY; + const bottomThreshold = + scrollY - canvasHeight + viewportSize.height + bufferSizeY; + + if (canvasX < rightBoundary && canvasX < rightThreshold) { + canvasX = Math.min(leftThreshold, rightBoundary); + hasUpdated = true; + } else if (canvasX > leftBoundary && canvasX > leftThreshold) { + canvasX = Math.max(rightThreshold, leftBoundary); + hasUpdated = true; + } + + if (canvasY < bottomBoundary && canvasY < bottomThreshold) { + canvasY = Math.min(topThreshold, bottomBoundary); + hasUpdated = true; + } else if (canvasY > topBoundary && canvasY > topThreshold) { + canvasY = Math.max(bottomThreshold, topBoundary); + hasUpdated = true; + } + + // Update the canvas position with the calculated canvasX and canvasY positions. + canvasPosition.x = canvasX; + canvasPosition.y = canvasY; + + return hasUpdated; +} + +exports.CANVAS_SIZE = CANVAS_SIZE; +exports.DEFAULT_COLOR = DEFAULT_COLOR; +exports.clearRect = clearRect; +exports.drawBubbleRect = drawBubbleRect; +exports.drawLine = drawLine; +exports.drawRect = drawRect; +exports.drawRoundedRect = drawRoundedRect; +exports.getBoundsFromPoints = getBoundsFromPoints; +exports.getCurrentMatrix = getCurrentMatrix; +exports.getPathDescriptionFromPoints = getPathDescriptionFromPoints; +exports.getPointsFromDiagonal = getPointsFromDiagonal; +exports.updateCanvasElement = updateCanvasElement; +exports.updateCanvasPosition = updateCanvasPosition; diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js new file mode 100644 index 0000000000..c3e75a1ee2 --- /dev/null +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -0,0 +1,856 @@ +/* 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, Cr } = require("chrome"); +const { + getCurrentZoom, + getWindowDimensions, + getViewportDimensions, + loadSheet, + removeSheet, +} = require("devtools/shared/layout/utils"); +const EventEmitter = require("devtools/shared/event-emitter"); +const InspectorUtils = require("InspectorUtils"); + +const lazyContainer = {}; + +loader.lazyRequireGetter( + lazyContainer, + "CssLogic", + "devtools/server/actors/inspector/css-logic", + true +); +loader.lazyRequireGetter( + this, + "isDocumentReady", + "devtools/server/actors/inspector/utils", + true +); + +exports.getComputedStyle = node => + lazyContainer.CssLogic.getComputedStyle(node); + +exports.getBindingElementAndPseudo = node => + lazyContainer.CssLogic.getBindingElementAndPseudo(node); + +exports.hasPseudoClassLock = (...args) => + InspectorUtils.hasPseudoClassLock(...args); + +exports.addPseudoClassLock = (...args) => + InspectorUtils.addPseudoClassLock(...args); + +exports.removePseudoClassLock = (...args) => + InspectorUtils.removePseudoClassLock(...args); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +// Highlighter in parent process will create an iframe relative to its target +// window. We need to make sure that the iframe is styled correctly. Note: +// this styles are taken from browser/base/content/browser.css +// iframe.devtools-highlighter-renderer rules. +const XUL_HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8, +:root > iframe.devtools-highlighter-renderer { + border: none; + pointer-events: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2; +}`; + +const STYLESHEET_URI = + "resource://devtools/server/actors/" + "highlighters.css"; + +const _tokens = Symbol("classList/tokens"); + +/** + * Shims the element's `classList` for anonymous content elements; used + * internally by `CanvasFrameAnonymousContentHelper.getElement()` method. + */ +function ClassList(className) { + const trimmed = (className || "").trim(); + this[_tokens] = trimmed ? trimmed.split(/\s+/) : []; +} + +ClassList.prototype = { + item(index) { + return this[_tokens][index]; + }, + contains(token) { + return this[_tokens].includes(token); + }, + add(token) { + if (!this.contains(token)) { + this[_tokens].push(token); + } + EventEmitter.emit(this, "update"); + }, + remove(token) { + const index = this[_tokens].indexOf(token); + + if (index > -1) { + this[_tokens].splice(index, 1); + } + EventEmitter.emit(this, "update"); + }, + toggle(token, force) { + // If force parameter undefined retain the toggle behavior + if (force === undefined) { + if (this.contains(token)) { + this.remove(token); + } else { + this.add(token); + } + } else if (force) { + // If force is true, enforce token addition + this.add(token); + } else { + // If force is falsy value, enforce token removal + this.remove(token); + } + }, + get length() { + return this[_tokens].length; + }, + [Symbol.iterator]: 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) { + // XXX: We temporarily return true for HTML documents if the document disables + // scroll frames since the regular highlighter is broken in this case. This + // should be removed when bug 1594587 is fixed. + return ( + window.document.documentElement.namespaceURI === XUL_NS || + (window.isChromeWindow && + window.document.documentElement.getAttribute("scrolling") === "false") + ); +} +exports.isXUL = isXUL; + +/** + * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead + * object wrapper, is still attached to a document, and is of a given type. + * @param {DOMNode} node + * @param {Number} nodeType Optional, defaults to ELEMENT_NODE + * @return {Boolean} + */ +function isNodeValid(node, nodeType = Node.ELEMENT_NODE) { + // Is it still alive? + if (!node || Cu.isDeadWrapper(node)) { + return false; + } + + // Is it of the right type? + if (node.nodeType !== nodeType) { + return false; + } + + // Is its document accessible? + const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument; + if (!doc || !doc.defaultView) { + return false; + } + + // Is the node connected to the document? + if (!node.isConnected) { + return false; + } + + return true; +} +exports.isNodeValid = isNodeValid; + +/** + * Every highlighters should insert their markup content into the document's + * canvasFrame anonymous content container (see dom/webidl/Document.webidl). + * + * Since this container gets cleared when the document navigates, highlighters + * should use this helper to have their markup content automatically re-inserted + * in the new document. + * + * Since the markup content is inserted in the canvasFrame using + * insertAnonymousContent, this means that it can be modified using the API + * described in AnonymousContent.webidl. + * To retrieve the AnonymousContent instance, use the content getter. + * + * @param {HighlighterEnv} highlighterEnv + * The environemnt which windows will be used to insert the node. + * @param {Function} nodeBuilder + * A function that, when executed, returns a DOM node to be inserted into + * the canvasFrame. + */ +function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) { + this.highlighterEnv = highlighterEnv; + this.nodeBuilder = nodeBuilder; + + this._onWindowReady = this._onWindowReady.bind(this); + this.highlighterEnv.on("window-ready", this._onWindowReady); + + this.listeners = new Map(); + this.elements = new Map(); +} + +CanvasFrameAnonymousContentHelper.prototype = { + initialize() { + // _insert will resolve this promise once the markup is displayed + const onInitialized = new Promise(resolve => { + this._initialized = resolve; + }); + // Only try to create the highlighter when the document is loaded, + // otherwise, wait for the window-ready event to fire. + const doc = this.highlighterEnv.document; + if ( + doc.documentElement && + (isDocumentReady(doc) || doc.readyState !== "uninitialized") + ) { + this._insert(); + } + + return onInitialized; + }, + + destroy() { + this._remove(); + if (this._iframe) { + // If iframe is used, remove one ref count from its numberOfHighlighters + // data attribute. + const numberOfHighlighters = + parseInt(this._iframe.dataset.numberOfHighlighters, 10) - 1; + this._iframe.dataset.numberOfHighlighters = numberOfHighlighters; + // If we reached 0, we can now remove the iframe and its styling from + // target window. + if (numberOfHighlighters === 0) { + this._iframe.remove(); + removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + } + this._iframe = null; + } + + this.highlighterEnv.off("window-ready", this._onWindowReady); + this.highlighterEnv = this.nodeBuilder = this._content = null; + this.anonymousContentDocument = null; + this.anonymousContentWindow = null; + this.pageListenerTarget = null; + + this._removeAllListeners(); + this.elements.clear(); + }, + + async _insert() { + await waitForContentLoaded(this.highlighterEnv.window); + if (!this.highlighterEnv) { + // CanvasFrameAnonymousContentHelper was already destroyed. + return; + } + if (isXUL(this.highlighterEnv.window)) { + // In order to use anonymous content, we need to create and use an IFRAME + // inside a XUL document first and use its window/document the same way we + // would normally use highlighter environment's window/document. See + // TODO: bug 1594587 for more details. + // + // Note: xul:window is not necessarily the top chrome window (as it's the + // case with about:devtools-toolbox). We need to ensure that we use the + // top chrome window to look up or create the iframe. + if (!this._iframe) { + const { documentElement } = this.highlighterEnv.window.document; + this._iframe = documentElement.querySelector( + ":scope > .devtools-highlighter-renderer" + ); + if (this._iframe) { + // If iframe is used and already exists, add one ref count to its + // numberOfHighlighters data attribute. + const numberOfHighlighters = + parseInt(this._iframe.dataset.numberOfHighlighters, 10) + 1; + this._iframe.dataset.numberOfHighlighters = numberOfHighlighters; + } else { + this._iframe = this.highlighterEnv.window.document.createElement( + "iframe" + ); + this._iframe.classList.add("devtools-highlighter-renderer"); + // If iframe is used for the first time, add ref count of one to its + // numberOfHighlighters data attribute. + this._iframe.dataset.numberOfHighlighters = 1; + documentElement.append(this._iframe); + loadSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + } + } + + await waitForContentLoaded(this._iframe); + if (!this.highlighterEnv) { + // CanvasFrameAnonymousContentHelper was already destroyed. + return; + } + + // If it's a XUL window anonymous content will be inserted inside a newly + // created IFRAME in the chrome window. + this.anonymousContentDocument = this._iframe.contentDocument; + this.anonymousContentWindow = this._iframe.contentWindow; + this.pageListenerTarget = this._iframe.contentWindow; + } else { + // Regular highlighters are drawn inside the anonymous content of the + // highlighter environment document. + this.anonymousContentDocument = this.highlighterEnv.document; + this.anonymousContentWindow = this.highlighterEnv.window; + this.pageListenerTarget = this.highlighterEnv.pageListenerTarget; + } + + // For now highlighters.css is injected in content as a ua sheet because + // we no longer support scoped style sheets (see bug 1345702). + // If it did, highlighters.css would be injected as an anonymous content + // node using CanvasFrameAnonymousContentHelper instead. + loadSheet(this.anonymousContentWindow, STYLESHEET_URI); + + const 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) + try { + this._content = this.anonymousContentDocument.insertAnonymousContent( + node + ); + } catch (e) { + // If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means + // we don't have access to a `CustomContentContainer` yet (see bug 1365075). + // At this point, it could only happen on document's interactive state, and we + // need to wait until the `complete` state before inserting the anonymous content + // again. + if ( + e.result === Cr.NS_ERROR_UNEXPECTED && + this.anonymousContentDocument.readyState === "interactive" + ) { + // The next state change will be "complete" since the current is "interactive" + await new Promise(resolve => { + this.anonymousContentDocument.addEventListener( + "readystatechange", + resolve, + { once: true } + ); + }); + this._content = this.anonymousContentDocument.insertAnonymousContent( + node + ); + } else { + throw e; + } + } + + this._initialized(); + }, + + _remove() { + try { + this.anonymousContentDocument.removeAnonymousContent(this._content); + } catch (e) { + // If the current window isn't the one the content was inserted into, this + // will fail, but that's fine. + } + }, + + /** + * The "window-ready" event can be triggered when: + * - a new window is created + * - a window is unfrozen from bfcache + * - when first attaching to a page + * - when swapping frame loaders (moving tabs, toggling RDM) + */ + _onWindowReady({ isTopLevel }) { + if (isTopLevel) { + this._removeAllListeners(); + this.elements.clear(); + if (this._iframe) { + // When we are switching top level targets, we can remove the iframe and + // its styling as well, since it will be re-created for the new top + // level target document. + this._iframe.remove(); + removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + this._iframe = null; + } + + this._insert(); + } + }, + + getComputedStylePropertyValue(id, property) { + return ( + this.content && this.content.getComputedStylePropertyValue(id, property) + ); + }, + + getTextContentForElement(id) { + return this.content && this.content.getTextContentForElement(id); + }, + + setTextContentForElement(id, text) { + if (this.content) { + this.content.setTextContentForElement(id, text); + } + }, + + setAttributeForElement(id, name, value) { + if (this.content) { + this.content.setAttributeForElement(id, name, value); + } + }, + + getAttributeForElement(id, name) { + return this.content && this.content.getAttributeForElement(id, name); + }, + + removeAttributeForElement(id, name) { + if (this.content) { + this.content.removeAttributeForElement(id, name); + } + }, + + hasAttributeForElement(id, name) { + return typeof this.getAttributeForElement(id, name) === "string"; + }, + + getCanvasContext(id, type = "2d") { + return this.content && this.content.getCanvasContext(id, type); + }, + + /** + * Add an event listener to one of the elements inserted in the canvasFrame + * native anonymous container. + * Like other methods in this helper, this requires the ID of the element to + * be passed in. + * + * Note that if the content page navigates, the event listeners won't be + * added again. + * + * Also note that unlike traditional DOM events, the events handled by + * listeners added here will propagate through the document only through + * bubbling phase, so the useCapture parameter isn't supported. + * It is possible however to call e.stopPropagation() to stop the bubbling. + * + * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of + * not leaking references to inserted elements to chrome JS code. That's + * because otherwise, chrome JS code could freely modify native anon elements + * inside the canvasFrame and probably change things that are assumed not to + * change by the C++ code managing this frame. + * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API + * Unfortunately, the inserted nodes are still available via + * event.originalTarget, and that's what the event handler here uses to check + * that the event actually occured on the right element, but that also means + * consumers of this code would be able to access the inserted elements. + * Therefore, the originalTarget property will be nullified before the event + * is passed to your handler. + * + * IMPL DETAIL: A single event listener is added per event types only, at + * browser level and if the event originalTarget is found to have the provided + * ID, the callback is executed (and then IDs of parent nodes of the + * originalTarget are checked too). + * + * @param {String} id + * @param {String} type + * @param {Function} handler + */ + addEventListenerForElement(id, type, handler) { + if (typeof id !== "string") { + throw new Error( + "Expected a string ID in addEventListenerForElement but" + " got: " + id + ); + } + + // If no one is listening for this type of event yet, add one listener. + if (!this.listeners.has(type)) { + const target = this.pageListenerTarget; + target.addEventListener(type, this, true); + // Each type entry in the map is a map of ids:handlers. + this.listeners.set(type, new Map()); + } + + const listeners = this.listeners.get(type); + listeners.set(id, handler); + }, + + /** + * Remove an event listener from one of the elements inserted in the + * canvasFrame native anonymous container. + * @param {String} id + * @param {String} type + */ + removeEventListenerForElement(id, type) { + const listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(id); + + // If no one is listening for event type anymore, remove the listener. + if (!this.listeners.has(type)) { + const target = this.pageListenerTarget; + target.removeEventListener(type, this, true); + } + }, + + handleEvent(event) { + const listeners = this.listeners.get(event.type); + if (!listeners) { + return; + } + + // Hide the originalTarget property to avoid exposing references to native + // anonymous elements. See addEventListenerForElement's comment. + let isPropagationStopped = false; + const eventProxy = new Proxy(event, { + get: (obj, name) => { + if (name === "originalTarget") { + return null; + } else if (name === "stopPropagation") { + return () => { + isPropagationStopped = true; + }; + } + return obj[name]; + }, + }); + + // Start at originalTarget, bubble through ancestors and call handlers when + // needed. + let node = event.originalTarget; + while (node) { + const handler = listeners.get(node.id); + if (handler) { + handler(eventProxy, node.id); + if (isPropagationStopped) { + break; + } + } + node = node.parentNode; + } + }, + + _removeAllListeners() { + if (this.pageListenerTarget) { + const target = this.pageListenerTarget; + for (const [type] of this.listeners) { + target.removeEventListener(type, this, true); + } + } + this.listeners.clear(); + }, + + getElement(id) { + if (this.elements.has(id)) { + return this.elements.get(id); + } + + const classList = new ClassList(this.getAttributeForElement(id, "class")); + + EventEmitter.on(classList, "update", () => { + this.setAttributeForElement(id, "class", classList.toString()); + }); + + const element = { + getTextContent: () => this.getTextContentForElement(id), + setTextContent: text => this.setTextContentForElement(id, text), + setAttribute: (name, val) => this.setAttributeForElement(id, name, val), + getAttribute: name => this.getAttributeForElement(id, name), + removeAttribute: name => this.removeAttributeForElement(id, name), + hasAttribute: name => this.hasAttributeForElement(id, name), + getCanvasContext: type => this.getCanvasContext(id, type), + addEventListener: (type, handler) => { + return this.addEventListenerForElement(id, type, handler); + }, + removeEventListener: (type, handler) => { + return this.removeEventListenerForElement(id, type, handler); + }, + computedStyle: { + getPropertyValue: property => + this.getComputedStylePropertyValue(id, property), + }, + classList, + }; + + this.elements.set(id, element); + + return element; + }, + + get content() { + if (!this._content || Cu.isDeadWrapper(this._content)) { + return null; + } + return this._content; + }, + + /** + * The canvasFrame anonymous content container gets zoomed in/out with the + * page. If this is unwanted, i.e. if you want the inserted element to remain + * unzoomed, then this method can be used. + * + * Consumers of the CanvasFrameAnonymousContentHelper should call this method, + * it isn't executed automatically. Typically, AutoRefreshHighlighter can call + * it when _update is executed. + * + * The matching element will be scaled down or up by 1/zoomLevel (using css + * transform) to cancel the current zoom. The element's width and height + * styles will also be set according to the scale. Finally, the element's + * position will be set as absolute. + * + * Note that if the matching element already has an inline style attribute, it + * *won't* be preserved. + * + * @param {DOMNode} node This node is used to determine which container window + * should be used to read the current zoom value. + * @param {String} id The ID of the root element inserted with this API. + */ + scaleRootElement(node, id) { + const boundaryWindow = this.highlighterEnv.window; + const zoom = getCurrentZoom(node); + // Hide the root element and force the reflow in order to get the proper window's + // dimensions without increasing them. + this.setAttributeForElement(id, "style", "display: none"); + node.offsetWidth; + + let { width, height } = getWindowDimensions(boundaryWindow); + let value = ""; + + if (zoom !== 1) { + value = `transform-origin:top left; transform:scale(${1 / zoom}); `; + width *= zoom; + height *= zoom; + } + + value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden`; + + this.setAttributeForElement(id, "style", value); + }, + + /** + * Helper function that creates SVG DOM nodes. + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "box". + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + */ + createSVGNode(options) { + if (!options.nodeType) { + options.nodeType = "box"; + } + + options.namespace = SVG_NS; + + return this.createNode(options); + }, + + /** + * Helper function that creates DOM nodes. + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "div". + * - namespace: the namespace to use to create the node, defaults to XHTML namespace. + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + * - text: if provided, set the text content of the element. + */ + createNode(options) { + const type = options.nodeType || "div"; + const namespace = options.namespace || XHTML_NS; + const doc = this.anonymousContentDocument; + + const node = doc.createElementNS(namespace, type); + + for (const name in options.attributes || {}) { + let value = options.attributes[name]; + if (options.prefix && (name === "class" || name === "id")) { + value = options.prefix + value; + } + node.setAttribute(name, value); + } + + if (options.parent) { + options.parent.appendChild(node); + } + + if (options.text) { + node.appendChild(doc.createTextNode(options.text)); + } + + return node; + }, +}; +exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper; + +/** + * Wait for document readyness. + * @param {Object} iframeOrWindow + * IFrame or Window for which the content should be loaded. + */ +function waitForContentLoaded(iframeOrWindow) { + let loadEvent = "DOMContentLoaded"; + // If we are waiting for an iframe to load and it is for a XUL window + // highlighter that is not browser toolbox, we must wait for IFRAME's "load". + if ( + iframeOrWindow.contentWindow && + iframeOrWindow.ownerGlobal !== + iframeOrWindow.contentWindow.browsingContext.topChromeWindow + ) { + loadEvent = "load"; + } + + const doc = iframeOrWindow.contentDocument || iframeOrWindow.document; + if (isDocumentReady(doc)) { + return Promise.resolve(); + } + + return new Promise(resolve => { + iframeOrWindow.addEventListener(loadEvent, resolve, { once: true }); + }); +} + +/** + * Move the infobar to the right place in the highlighter. This helper method is utilized + * in both css-grid.js and box-model.js to help position the infobar in an appropriate + * space over the highlighted node element or grid area. The infobar is used to display + * relevant information about the highlighted item (ex, node or grid name and dimensions). + * + * This method will first try to position the infobar to top or bottom of the container + * such that it has enough space for the height of the infobar. Afterwards, it will try + * to horizontally center align with the container element if possible. + * + * @param {DOMNode} container + * The container element which will be used to position the infobar. + * @param {Object} bounds + * The content bounds of the container element. + * @param {Window} win + * The window object. + * @param {Object} [options={}] + * Advanced options for the infobar. + * @param {String} options.position + * Force the infobar to be displayed either on "top" or "bottom". Any other value + * will be ingnored. + * @param {Boolean} options.hideIfOffscreen + * If set to `true`, hides the infobar if it's offscreen, instead of automatically + * reposition it. + */ +function moveInfobar(container, bounds, win, options = {}) { + const zoom = getCurrentZoom(win); + const viewport = getViewportDimensions(win); + + const { computedStyle } = container; + + const margin = 2; + const arrowSize = parseFloat( + computedStyle.getPropertyValue("--highlighter-bubble-arrow-size") + ); + const containerHeight = parseFloat(computedStyle.getPropertyValue("height")); + const containerWidth = parseFloat(computedStyle.getPropertyValue("width")); + const containerHalfWidth = containerWidth / 2; + + const viewportWidth = viewport.width * zoom; + const viewportHeight = viewport.height * zoom; + let { pageXOffset, pageYOffset } = win; + + pageYOffset *= zoom; + pageXOffset *= zoom; + + // Defines the boundaries for the infobar. + const topBoundary = margin; + const bottomBoundary = viewportHeight - containerHeight - margin - 1; + const leftBoundary = containerHalfWidth + margin; + const rightBoundary = viewportWidth - containerHalfWidth - margin; + + // Set the default values. + let top = bounds.y - containerHeight - arrowSize; + const bottom = bounds.bottom + margin + arrowSize; + let left = bounds.x + bounds.width / 2; + let isOverlapTheNode = false; + let positionAttribute = "top"; + let position = "absolute"; + + // Here we start the math. + // We basically want to position absolutely the infobar, except when is pointing to a + // node that is offscreen or partially offscreen, in a way that the infobar can't + // be placed neither on top nor on bottom. + // In such cases, the infobar will overlap the node, and to limit the latency given + // by APZ (See Bug 1312103) it will be positioned as "fixed". + // It's a sort of "position: sticky" (but positioned as absolute instead of relative). + const canBePlacedOnTop = top >= pageYOffset; + const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0; + const forcedOnTop = options.position === "top"; + const forcedOnBottom = options.position === "bottom"; + + if ( + (!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) || + forcedOnBottom + ) { + top = bottom; + positionAttribute = "bottom"; + } + + const isOffscreenOnTop = top < topBoundary + pageYOffset; + const isOffscreenOnBottom = top > bottomBoundary + pageYOffset; + const isOffscreenOnLeft = left < leftBoundary + pageXOffset; + const isOffscreenOnRight = left > rightBoundary + pageXOffset; + + if (isOffscreenOnTop) { + top = topBoundary; + isOverlapTheNode = true; + } else if (isOffscreenOnBottom) { + top = bottomBoundary; + isOverlapTheNode = true; + } else if (isOffscreenOnLeft || isOffscreenOnRight) { + isOverlapTheNode = true; + top -= pageYOffset; + } + + if (isOverlapTheNode && options.hideIfOffscreen) { + container.setAttribute("hidden", "true"); + return; + } else if (isOverlapTheNode) { + left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary); + + position = "fixed"; + container.setAttribute("hide-arrow", "true"); + } else { + position = "absolute"; + container.removeAttribute("hide-arrow"); + } + + // We need to scale the infobar Independently from the highlighter's container; + // otherwise the `position: fixed` won't work, since "any value other than `none` for + // the transform, results in the creation of both a stacking context and a containing + // block. The object acts as a containing block for fixed positioned descendants." + // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering) + // We also need to shift the infobar 50% to the left in order for it to appear centered + // on the element it points to. + container.setAttribute( + "style", + ` + position:${position}; + transform-origin: 0 0; + transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)` + ); + + container.setAttribute("position", positionAttribute); +} +exports.moveInfobar = moveInfobar; diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build new file mode 100644 index 0000000000..ab4f96912d --- /dev/null +++ b/devtools/server/actors/highlighters/utils/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules("accessibility.js", "canvas.js", "markup.js") |