diff options
Diffstat (limited to 'devtools/server/actors/highlighters/geometry-editor.js')
-rw-r--r-- | devtools/server/actors/highlighters/geometry-editor.js | 704 |
1 files changed, 704 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 000000000..35b33eec1 --- /dev/null +++ b/devtools/server/actors/highlighters/geometry-editor.js @@ -0,0 +1,704 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { CanvasFrameAnonymousContentHelper, getCSSStyleRules, getComputedStyle, + createSVGNode, createNode } = require("./utils/markup"); +const { setIgnoreLayoutChanges, getAdjustedQuads } = require("devtools/shared/layout/utils"); + +const GEOMETRY_LABEL_SIZE = 6; + +// List of all DOM Events subscribed directly to the document from the +// Geometry Editor highlighter +const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"]; + +const _dragging = Symbol("geometry/dragging"); + +/** + * Element geometry properties helper that gives names of position and size + * properties. + */ +var GeoProp = { + SIDES: ["top", "right", "bottom", "left"], + SIZES: ["width", "height"], + + allProps: function () { + return [...this.SIDES, ...this.SIZES]; + }, + + isSide: function (name) { + return this.SIDES.indexOf(name) !== -1; + }, + + isSize: function (name) { + return this.SIZES.indexOf(name) !== -1; + }, + + containsSide: function (names) { + return names.some(name => this.SIDES.indexOf(name) !== -1); + }, + + containsSize: function (names) { + return names.some(name => this.SIZES.indexOf(name) !== -1); + }, + + isHorizontal: function (name) { + return name === "left" || name === "right" || name === "width"; + }, + + isInverted: function (name) { + return name === "right" || name === "bottom"; + }, + + mainAxisStart: function (name) { + return this.isHorizontal(name) ? "left" : "top"; + }, + + crossAxisStart: function (name) { + return this.isHorizontal(name) ? "top" : "left"; + }, + + mainAxisSize: function (name) { + return this.isHorizontal(name) ? "width" : "height"; + }, + + crossAxisSize: function (name) { + return this.isHorizontal(name) ? "height" : "width"; + }, + + axis: function (name) { + return this.isHorizontal(name) ? "x" : "y"; + }, + + crossAxis: function (name) { + return this.isHorizontal(name) ? "y" : "x"; + } +}; + +/** + * Get the provided node's offsetParent dimensions. + * Returns an object with the {parent, dimension} properties. + * Note that the returned parent will be null if the offsetParent is the + * default, non-positioned, body or html node. + * + * node.offsetParent returns the nearest positioned ancestor but if it is + * non-positioned itself, we just return null to let consumers know the node is + * actually positioned relative to the viewport. + * + * @return {Object} + */ +function getOffsetParent(node) { + let win = node.ownerDocument.defaultView; + + let offsetParent = node.offsetParent; + if (offsetParent && + getComputedStyle(offsetParent).position === "static") { + offsetParent = null; + } + + let width, height; + if (!offsetParent) { + height = win.innerHeight; + width = win.innerWidth; + } else { + height = offsetParent.offsetHeight; + width = offsetParent.offsetWidth; + } + + return { + element: offsetParent, + dimension: {width, height} + }; +} + +/** + * Get the list of geometry properties that are actually set on the provided + * node. + * + * @param {nsIDOMNode} node The node to analyze. + * @return {Map} A map indexed by property name and where the value is an + * object having the cssRule property. + */ +function getDefinedGeometryProperties(node) { + let props = new Map(); + if (!node) { + return props; + } + + // Get the list of css rules applying to the current node. + let cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.Count(); i++) { + let rule = cssRules.GetElementAt(i); + for (let name of GeoProp.allProps()) { + let value = rule.style.getPropertyValue(name); + if (value && value !== "auto") { + // getCSSStyleRules returns rules ordered from least to most specific + // so just override any previous properties we have set. + props.set(name, { + cssRule: rule + }); + } + } + } + + // Go through the inline styles last, only if the node supports inline style + // (e.g. pseudo elements don't have a style property) + if (node.style) { + for (let name of GeoProp.allProps()) { + let value = node.style.getPropertyValue(name); + if (value && value !== "auto") { + props.set(name, { + // There's no cssRule to store here, so store the node instead since + // node.style exists. + cssRule: node + }); + } + } + } + + // Post-process the list for invalid properties. This is done after the fact + // because of cases like relative positioning with both top and bottom where + // only top will actually be used, but both exists in css rules and computed + // styles. + let { position } = getComputedStyle(node); + for (let [name] of props) { + // Top/left/bottom/right on static positioned elements have no effect. + if (position === "static" && GeoProp.SIDES.indexOf(name) !== -1) { + props.delete(name); + } + + // Bottom/right on relative positioned elements are only used if top/left + // are not defined. + let hasRightAndLeft = name === "right" && props.has("left"); + let hasBottomAndTop = name === "bottom" && props.has("top"); + if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) { + props.delete(name); + } + } + + return props; +} +exports.getDefinedGeometryProperties = getDefinedGeometryProperties; + +/** + * The GeometryEditor highlights an elements's top, left, bottom, right, width + * and height dimensions, when they are set. + * + * To determine if an element has a set size and position, the highlighter lists + * the CSS rules that apply to the element and checks for the top, left, bottom, + * right, width and height properties. + * The highlighter won't be shown if the element doesn't have any of these + * properties set, but will be shown when at least 1 property is defined. + * + * The highlighter displays lines and labels for each of the defined properties + * in and around the element (relative to the offset parent when one exists). + * The highlighter also highlights the element itself and its offset parent if + * there is one. + * + * Note that the class name contains the word Editor because the aim is for the + * handles to be draggable in content to make the geometry editable. + */ +function GeometryEditorHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + // The list of element geometry properties that can be set. + this.definedProperties = new Map(); + + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + let { pageListenerTarget } = this.highlighterEnv; + + // Register the geometry editor instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); + + // Register the mousedown event for each Geometry Editor's handler. + // Those events are automatically removed when the markup is destroyed. + let onMouseDown = this.handleEvent.bind(this); + + for (let side of GeoProp.SIDES) { + this.getElement("handler-" + side) + .addEventListener("mousedown", onMouseDown); + } +} + +GeometryEditorHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "GeometryEditorHighlighter", + + ID_CLASS_PREFIX: "geometry-editor-", + + _buildMarkup: function () { + let container = createNode(this.win, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: root, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Offset parent node highlighter. + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "offset-parent", + "id": "offset-parent", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Current node highlighter (margin box). + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "current-node", + "id": "current-node", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the 4 side arrows, handlers and labels. + for (let name of GeoProp.SIDES) { + createSVGNode(this.win, { + nodeType: "line", + parent: svg, + attributes: { + "class": "arrow " + name, + "id": "arrow-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "circle", + parent: svg, + attributes: { + "class": "handler-" + name, + "id": "handler-" + name, + "r": "4", + "data-side": name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Labels are positioned by using a translated <g>. This group contains + // a path and text that are themselves positioned using another translated + // <g>. This is so that the label arrow points at the 0,0 coordinates of + // parent <g>. + let labelG = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "id": "label-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let subG = createSVGNode(this.win, { + nodeType: "g", + parent: labelG, + attributes: { + "transform": GeoProp.isHorizontal(name) + ? "translate(-30 -30)" + : "translate(5 -10)" + } + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: subG, + attributes: { + "class": "label-bubble", + "d": GeoProp.isHorizontal(name) + ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z" + : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "text", + parent: subG, + attributes: { + "class": "label-text", + "id": "label-text-" + name, + "x": GeoProp.isHorizontal(name) ? "30" : "35", + "y": "10" + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + return container; + }, + + destroy: function () { + // Avoiding exceptions if `destroy` is called multiple times; and / or the + // highlighter environment was already destroyed. + if (!this.highlighterEnv) { + return; + } + + let { pageListenerTarget } = this.highlighterEnv; + + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this)); + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.markup.destroy(); + this.definedProperties.clear(); + this.definedProperties = null; + this.offsetParent = null; + }, + + handleEvent: function (event, id) { + // No event handling if the highlighter is hidden + if (this.getElement("root").hasAttribute("hidden")) { + return; + } + + const { type, pageX, pageY } = event; + + switch (type) { + case "pagehide": + this.destroy(); + break; + case "mousedown": + // The mousedown event is intended only for the handler + if (!id) { + return; + } + + let handlerSide = this.markup.getElement(id).getAttribute("data-side"); + + if (handlerSide) { + let side = handlerSide; + let sideProp = this.definedProperties.get(side); + + if (!sideProp) { + return; + } + + let value = sideProp.cssRule.style.getPropertyValue(side); + let computedValue = this.computedStyle.getPropertyValue(side); + + let [unit] = value.match(/[^\d]+$/) || [""]; + + value = parseFloat(value); + + let ratio = (value / parseFloat(computedValue)) || 1; + let dir = GeoProp.isInverted(side) ? -1 : 1; + + // Store all the initial values needed for drag & drop + this[_dragging] = { + side, + value, + unit, + x: pageX, + y: pageY, + inc: ratio * dir + }; + + this.getElement("handler-" + side).classList.add("dragging"); + } + + this.getElement("root").setAttribute("dragging", "true"); + break; + case "mouseup": + // If we're dragging, drop it. + if (this[_dragging]) { + let { side } = this[_dragging]; + this.getElement("root").removeAttribute("dragging"); + this.getElement("handler-" + side).classList.remove("dragging"); + this[_dragging] = null; + } + break; + case "mousemove": + if (!this[_dragging]) { + return; + } + + let { side, x, y, value, unit, inc } = this[_dragging]; + let sideProps = this.definedProperties.get(side); + + if (!sideProps) { + return; + } + + let delta = (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc; + + // The inline style has usually the priority over any other CSS rule + // set in stylesheets. However, if a rule has `!important` keyword, + // it will override the inline style too. To ensure Geometry Editor + // will always update the element, we have to add `!important` as + // well. + this.currentNode.style.setProperty( + side, (value + delta) + unit, "important"); + + break; + } + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + _show: function () { + this.computedStyle = getComputedStyle(this.currentNode); + let pos = this.computedStyle.position; + // XXX: sticky positioning is ignored for now. To be implemented next. + if (pos === "sticky") { + this.hide(); + return false; + } + + let hasUpdated = this._update(); + if (!hasUpdated) { + this.hide(); + return false; + } + + this.getElement("root").removeAttribute("hidden"); + + return true; + }, + + _update: function () { + // At each update, the position or/and size may have changed, so get the + // list of defined properties, and re-position the arrows and highlighters. + this.definedProperties = getDefinedGeometryProperties(this.currentNode); + + if (!this.definedProperties.size) { + console.warn("The element does not have editable geometry properties"); + return false; + } + + setIgnoreLayoutChanges(true); + + // Update the highlighters and arrows. + this.updateOffsetParent(); + this.updateCurrentNode(); + this.updateArrows(); + + // Avoid zooming the arrows when content is zoomed. + let node = this.currentNode; + this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root"); + + setIgnoreLayoutChanges(false, node.ownerDocument.documentElement); + return true; + }, + + /** + * Update the offset parent rectangle. + * There are 3 different cases covered here: + * - the node is absolutely/fixed positioned, and an offsetParent is defined + * (i.e. it's not just positioned in the viewport): the offsetParent node + * is highlighted (i.e. the rectangle is shown), + * - the node is relatively positioned: the rectangle is shown where the node + * would originally have been (because that's where the relative positioning + * is calculated from), + * - the node has no offset parent at all: the offsetParent rectangle is + * hidden. + */ + updateOffsetParent: function () { + // Get the offsetParent, if any. + this.offsetParent = getOffsetParent(this.currentNode); + // And the offsetParent quads. + this.parentQuads = getAdjustedQuads( + this.win, this.offsetParent.element, "padding"); + + let el = this.getElement("offset-parent"); + + let isPositioned = this.computedStyle.position === "absolute" || + this.computedStyle.position === "fixed"; + let isRelative = this.computedStyle.position === "relative"; + let isHighlighted = false; + + if (this.offsetParent.element && isPositioned) { + let {p1, p2, p3, p4} = this.parentQuads[0]; + let points = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + el.setAttribute("points", points); + isHighlighted = true; + } else if (isRelative) { + let xDelta = parseFloat(this.computedStyle.left); + let yDelta = parseFloat(this.computedStyle.top); + if (xDelta || yDelta) { + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let points = (p1.x - xDelta) + "," + (p1.y - yDelta) + " " + + (p2.x - xDelta) + "," + (p2.y - yDelta) + " " + + (p3.x - xDelta) + "," + (p3.y - yDelta) + " " + + (p4.x - xDelta) + "," + (p4.y - yDelta); + el.setAttribute("points", points); + isHighlighted = true; + } + } + + if (isHighlighted) { + el.removeAttribute("hidden"); + } else { + el.setAttribute("hidden", "true"); + } + }, + + updateCurrentNode: function () { + let box = this.getElement("current-node"); + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let attr = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + box.setAttribute("points", attr); + box.removeAttribute("hidden"); + }, + + _hide: function () { + setIgnoreLayoutChanges(true); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("current-node").setAttribute("hidden", "true"); + this.getElement("offset-parent").setAttribute("hidden", "true"); + this.hideArrows(); + + this.definedProperties.clear(); + + setIgnoreLayoutChanges(false, + this.currentNode.ownerDocument.documentElement); + }, + + hideArrows: function () { + for (let side of GeoProp.SIDES) { + this.getElement("arrow-" + side).setAttribute("hidden", "true"); + this.getElement("label-" + side).setAttribute("hidden", "true"); + this.getElement("handler-" + side).setAttribute("hidden", "true"); + } + }, + + updateArrows: function () { + this.hideArrows(); + + // Position arrows always end at the node's margin box. + let marginBox = this.currentQuads.margin[0].bounds; + + // Position the side arrows which need to be visible. + // Arrows always start at the offsetParent edge, and end at the middle + // position of the node's margin edge. + // Note that for relative positioning, the offsetParent is considered to be + // the node itself, where it would have been originally. + // +------------------+----------------+ + // | offsetparent | top | + // | or viewport | | + // | +--------+--------+ | + // | | node | | + // +---------+ +-------+ + // | left | | right | + // | +--------+--------+ | + // | | bottom | + // +------------------+----------------+ + let getSideArrowStartPos = side => { + // In case an offsetParent exists and is highlighted. + if (this.parentQuads && this.parentQuads.length) { + return this.parentQuads[0].bounds[side]; + } + + // In case of relative positioning. + if (this.computedStyle.position === "relative") { + if (GeoProp.isInverted(side)) { + return marginBox[side] + parseFloat(this.computedStyle[side]); + } + return marginBox[side] - parseFloat(this.computedStyle[side]); + } + + // In case the element is positioned in the viewport. + if (GeoProp.isInverted(side)) { + return this.offsetParent.dimension[GeoProp.mainAxisSize(side)]; + } + return -1 * this.currentNode.ownerDocument.defaultView["scroll" + + GeoProp.axis(side).toUpperCase()]; + }; + + for (let side of GeoProp.SIDES) { + let sideProp = this.definedProperties.get(side); + if (!sideProp) { + continue; + } + + let mainAxisStartPos = getSideArrowStartPos(side); + let mainAxisEndPos = marginBox[side]; + let crossAxisPos = marginBox[GeoProp.crossAxisStart(side)] + + marginBox[GeoProp.crossAxisSize(side)] / 2; + + this.updateArrow(side, mainAxisStartPos, mainAxisEndPos, crossAxisPos, + sideProp.cssRule.style.getPropertyValue(side)); + } + }, + + updateArrow: function (side, mainStart, mainEnd, crossPos, labelValue) { + let arrowEl = this.getElement("arrow-" + side); + let labelEl = this.getElement("label-" + side); + let labelTextEl = this.getElement("label-text-" + side); + let handlerEl = this.getElement("handler-" + side); + + // Position the arrow <line>. + arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos); + arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos); + arrowEl.removeAttribute("hidden"); + + handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd); + handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos); + handlerEl.removeAttribute("hidden"); + + // Position the label <text> in the middle of the arrow (making sure it's + // not hidden below the fold). + let capitalize = str => str[0].toUpperCase() + str.substring(1); + let winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))]; + let labelMain = mainStart + (mainEnd - mainStart) / 2; + if ((mainStart > 0 && mainStart < winMain) || + (mainEnd > 0 && mainEnd < winMain)) { + if (labelMain < GEOMETRY_LABEL_SIZE) { + labelMain = GEOMETRY_LABEL_SIZE; + } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) { + labelMain = winMain - GEOMETRY_LABEL_SIZE; + } + } + let labelCross = crossPos; + labelEl.setAttribute("transform", GeoProp.isHorizontal(side) + ? "translate(" + labelMain + " " + labelCross + ")" + : "translate(" + labelCross + " " + labelMain + ")"); + labelEl.removeAttribute("hidden"); + labelTextEl.setTextContent(labelValue); + } +}); +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; |