diff options
Diffstat (limited to 'devtools/server/actors/highlighters/geometry-editor.js')
-rw-r--r-- | devtools/server/actors/highlighters/geometry-editor.js | 798 |
1 files changed, 798 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js new file mode 100644 index 0000000000..d7e56204e5 --- /dev/null +++ b/devtools/server/actors/highlighters/geometry-editor.js @@ -0,0 +1,798 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + AutoRefreshHighlighter, +} = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, +} = require("resource://devtools/server/actors/highlighters/utils/markup.js"); +const { + setIgnoreLayoutChanges, + getAdjustedQuads, +} = require("resource://devtools/shared/layout/utils.js"); +const { + getCSSStyleRules, +} = require("resource://devtools/shared/inspector/css-logic.js"); + +const GEOMETRY_LABEL_SIZE = 6; + +// List of all DOM Events subscribed directly to the document from the +// Geometry Editor highlighter +const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"]; + +const _dragging = Symbol("geometry/dragging"); + +/** + * Element geometry properties helper that gives names of position and size + * properties. + */ +var GeoProp = { + SIDES: ["top", "right", "bottom", "left"], + SIZES: ["width", "height"], + + allProps() { + return [...this.SIDES, ...this.SIZES]; + }, + + isSide(name) { + return this.SIDES.includes(name); + }, + + isSize(name) { + return this.SIZES.includes(name); + }, + + containsSide(names) { + return names.some(name => this.SIDES.includes(name)); + }, + + containsSize(names) { + return names.some(name => this.SIZES.includes(name)); + }, + + isHorizontal(name) { + return name === "left" || name === "right" || name === "width"; + }, + + isInverted(name) { + return name === "right" || name === "bottom"; + }, + + mainAxisStart(name) { + return this.isHorizontal(name) ? "left" : "top"; + }, + + crossAxisStart(name) { + return this.isHorizontal(name) ? "top" : "left"; + }, + + mainAxisSize(name) { + return this.isHorizontal(name) ? "width" : "height"; + }, + + crossAxisSize(name) { + return this.isHorizontal(name) ? "height" : "width"; + }, + + axis(name) { + return this.isHorizontal(name) ? "x" : "y"; + }, + + crossAxis(name) { + return this.isHorizontal(name) ? "y" : "x"; + }, +}; + +/** + * Get the provided node's offsetParent dimensions. + * Returns an object with the {parent, dimension} properties. + * Note that the returned parent will be null if the offsetParent is the + * default, non-positioned, body or html node. + * + * node.offsetParent returns the nearest positioned ancestor but if it is + * non-positioned itself, we just return null to let consumers know the node is + * actually positioned relative to the viewport. + * + * @return {Object} + */ +function getOffsetParent(node) { + const win = node.ownerGlobal; + + let offsetParent = node.offsetParent; + if (offsetParent && getComputedStyle(offsetParent).position === "static") { + offsetParent = null; + } + + let width, height; + if (!offsetParent) { + height = win.innerHeight; + width = win.innerWidth; + } else { + height = offsetParent.offsetHeight; + width = offsetParent.offsetWidth; + } + + return { + element: offsetParent, + dimension: { width, height }, + }; +} + +/** + * Get the list of geometry properties that are actually set on the provided + * node. + * + * @param {Node} node The node to analyze. + * @return {Map} A map indexed by property name and where the value is an + * object having the cssRule property. + */ +function getDefinedGeometryProperties(node) { + const props = new Map(); + if (!node) { + return props; + } + + // Get the list of css rules applying to the current node. + const cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.length; i++) { + const rule = cssRules[i]; + for (const name of GeoProp.allProps()) { + const value = rule.style.getPropertyValue(name); + if (value && value !== "auto") { + // getCSSStyleRules returns rules ordered from least to most specific + // so just override any previous properties we have set. + props.set(name, { + cssRule: rule, + }); + } + } + } + + // Go through the inline styles last, only if the node supports inline style + // (e.g. pseudo elements don't have a style property) + if (node.style) { + for (const name of GeoProp.allProps()) { + const value = node.style.getPropertyValue(name); + if (value && value !== "auto") { + props.set(name, { + // There's no cssRule to store here, so store the node instead since + // node.style exists. + cssRule: node, + }); + } + } + } + + // Post-process the list for invalid properties. This is done after the fact + // because of cases like relative positioning with both top and bottom where + // only top will actually be used, but both exists in css rules and computed + // styles. + const { position } = getComputedStyle(node); + for (const [name] of props) { + // Top/left/bottom/right on static positioned elements have no effect. + if (position === "static" && GeoProp.SIDES.includes(name)) { + props.delete(name); + } + + // Bottom/right on relative positioned elements are only used if top/left + // are not defined. + const hasRightAndLeft = name === "right" && props.has("left"); + const hasBottomAndTop = name === "bottom" && props.has("top"); + if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) { + props.delete(name); + } + } + + return props; +} +exports.getDefinedGeometryProperties = getDefinedGeometryProperties; + +/** + * The GeometryEditor highlights an elements's top, left, bottom, right, width + * and height dimensions, when they are set. + * + * To determine if an element has a set size and position, the highlighter lists + * the CSS rules that apply to the element and checks for the top, left, bottom, + * right, width and height properties. + * The highlighter won't be shown if the element doesn't have any of these + * properties set, but will be shown when at least 1 property is defined. + * + * The highlighter displays lines and labels for each of the defined properties + * in and around the element (relative to the offset parent when one exists). + * The highlighter also highlights the element itself and its offset parent if + * there is one. + * + * Note that the class name contains the word Editor because the aim is for the + * handles to be draggable in content to make the geometry editable. + */ +class GeometryEditorHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "geometry-editor-"; + + // The list of element geometry properties that can be set. + this.definedProperties = new Map(); + + this.markup = new CanvasFrameAnonymousContentHelper( + highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.initialize(); + + const { pageListenerTarget } = this.highlighterEnv; + + // Register the geometry editor instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); + + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + } + + async initialize() { + await this.markup.initialize(); + // Register the mousedown event for each Geometry Editor's handler. + // Those events are automatically removed when the markup is destroyed. + const onMouseDown = this.handleEvent.bind(this); + + for (const side of GeoProp.SIDES) { + this.getElement("handler-" + side).addEventListener( + "mousedown", + onMouseDown + ); + } + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { class: "highlighter-container" }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + width: "100%", + height: "100%", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Offset parent node highlighter. + this.markup.createSVGNode({ + nodeType: "polygon", + parent: svg, + attributes: { + class: "offset-parent", + id: "offset-parent", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Current node highlighter (margin box). + this.markup.createSVGNode({ + nodeType: "polygon", + parent: svg, + attributes: { + class: "current-node", + id: "current-node", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the 4 side arrows, handlers and labels. + for (const name of GeoProp.SIDES) { + this.markup.createSVGNode({ + nodeType: "line", + parent: svg, + attributes: { + class: "arrow " + name, + id: "arrow-" + name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "circle", + parent: svg, + attributes: { + class: "handler-" + name, + id: "handler-" + name, + r: "4", + "data-side": name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Labels are positioned by using a translated <g>. This group contains + // a path and text that are themselves positioned using another translated + // <g>. This is so that the label arrow points at the 0,0 coordinates of + // parent <g>. + const labelG = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + attributes: { + id: "label-" + name, + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const subG = this.markup.createSVGNode({ + nodeType: "g", + parent: labelG, + attributes: { + transform: GeoProp.isHorizontal(name) + ? "translate(-30 -30)" + : "translate(5 -10)", + }, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: subG, + attributes: { + class: "label-bubble", + d: GeoProp.isHorizontal(name) + ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z" + : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "text", + parent: subG, + attributes: { + class: "label-text", + id: "label-text-" + name, + x: GeoProp.isHorizontal(name) ? "30" : "35", + y: "10", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + return container; + } + + destroy() { + // Avoiding exceptions if `destroy` is called multiple times; and / or the + // highlighter environment was already destroyed. + if (!this.highlighterEnv) { + return; + } + + const { pageListenerTarget } = this.highlighterEnv; + + if (pageListenerTarget) { + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this) + ); + } + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.markup.destroy(); + this.definedProperties.clear(); + this.definedProperties = null; + this.offsetParent = null; + } + + handleEvent(event, id) { + // No event handling if the highlighter is hidden + if (this.getElement("root").hasAttribute("hidden")) { + return; + } + + const { target, type, pageX, pageY } = event; + + switch (type) { + case "pagehide": + // If a page hide event is triggered for current window's highlighter, hide the + // highlighter. + if (target.defaultView === this.win) { + this.destroy(); + } + + break; + case "mousedown": + // The mousedown event is intended only for the handler + if (!id) { + return; + } + + const handlerSide = this.markup + .getElement(id) + .getAttribute("data-side"); + + if (handlerSide) { + const side = handlerSide; + const sideProp = this.definedProperties.get(side); + + if (!sideProp) { + return; + } + + let value = sideProp.cssRule.style.getPropertyValue(side); + const computedValue = this.computedStyle.getPropertyValue(side); + + const [unit] = value.match(/[^\d]+$/) || [""]; + + value = parseFloat(value); + + const ratio = value / parseFloat(computedValue) || 1; + const dir = GeoProp.isInverted(side) ? -1 : 1; + + // Store all the initial values needed for drag & drop + this[_dragging] = { + side, + value, + unit, + x: pageX, + y: pageY, + inc: ratio * dir, + }; + + this.getElement("handler-" + side).classList.add("dragging"); + } + + this.getElement("root").setAttribute("dragging", "true"); + break; + case "mouseup": + // If we're dragging, drop it. + if (this[_dragging]) { + const { side } = this[_dragging]; + this.getElement("root").removeAttribute("dragging"); + this.getElement("handler-" + side).classList.remove("dragging"); + this[_dragging] = null; + } + break; + case "mousemove": + if (!this[_dragging]) { + return; + } + + const { side, x, y, value, unit, inc } = this[_dragging]; + const sideProps = this.definedProperties.get(side); + + if (!sideProps) { + return; + } + + const delta = + (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc; + + // The inline style has usually the priority over any other CSS rule + // set in stylesheets. However, if a rule has `!important` keyword, + // it will override the inline style too. To ensure Geometry Editor + // will always update the element, we have to add `!important` as + // well. + this.currentNode.style.setProperty( + side, + value + delta + unit, + "important" + ); + + break; + } + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + _show() { + this.computedStyle = getComputedStyle(this.currentNode); + const pos = this.computedStyle.position; + // XXX: sticky positioning is ignored for now. To be implemented next. + if (pos === "sticky") { + this.hide(); + return false; + } + + const hasUpdated = this._update(); + if (!hasUpdated) { + this.hide(); + return false; + } + + this.getElement("root").removeAttribute("hidden"); + + return true; + } + + _update() { + // At each update, the position or/and size may have changed, so get the + // list of defined properties, and re-position the arrows and highlighters. + this.definedProperties = getDefinedGeometryProperties(this.currentNode); + + 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; |