--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -9,25 +9,28 @@ const { CanvasFrameAnonymousContentHelpe
const { setIgnoreLayoutChanges, getCurrentZoom,
getAdjustedQuads, getFrameOffsets } = require("devtools/shared/layout/utils");
const { AutoRefreshHighlighter } = require("./auto-refresh");
const {
getDistance,
clickedOnEllipseEdge,
distanceToLine,
projection,
- clickedOnPoint
-} = require("devtools/server/actors/utils/shapes-geometry-utils");
+ clickedOnPoint,
+ roundTo
+} = require("devtools/server/actors/utils/shapes-utils");
const EventEmitter = require("devtools/shared/old-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 DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"];
+const UNITS = ["px", "%", "em", "rem", "in", "cm", "mm", "pt",
+ "pc", "vh", "vw", "vmin", "vmax"];
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.
*/
class ShapesHighlighter extends AutoRefreshHighlighter {
@@ -37,16 +40,17 @@ class ShapesHighlighter extends AutoRefr
this.ID_CLASS_PREFIX = "shapes-";
this.referenceBox = "border";
this.useStrokeBox = false;
this.geometryBox = "";
this.hoveredPoint = null;
this.fillRule = "";
+ this.numInsetPoints = 0;
this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
this._buildMarkup.bind(this));
this.onPageHide = this.onPageHide.bind(this);
let { pageListenerTarget } = this.highlighterEnv;
DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this));
pageListenerTarget.addEventListener("pagehide", this.onPageHide);
@@ -986,19 +990,22 @@ class ShapesHighlighter extends AutoRefr
* 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();
let splitDef = definition.split(", ");
- if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
+ if (splitDef[0].includes("nonzero") || splitDef[0].includes("evenodd")) {
splitDef.shift();
}
+ this.pixelCoords = splitDef.map(coords => {
+ return splitCoords(coords).map(this.convertCoordsToPixel.bind(this));
+ });
return splitDef.map(coords => {
return splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
});
}
/**
* Parse the raw (non-computed) definition of the CSS polygon.
* @returns {Array} an array of the points of the polygon, with units preserved.
@@ -1034,42 +1041,68 @@ class ShapesHighlighter extends AutoRefr
* center of the circle. All values are evaluated and converted to percentages.
*/
circlePoints(definition) {
this.coordUnits = this.circleRawPoints();
// The computed value of circle() always has the keyword "at".
let values = definition.split(" at ");
let radius = values[0];
let { width, height } = this.zoomAdjustedDimensions;
- let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
+ let splitCenter = splitCoords(values[1]);
+ let pxCenter = splitCenter.map(this.convertCoordsToPixel.bind(this));
+ let center = splitCenter.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).
let computedSize = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+ let pxRadius;
+ let ratioX = width / computedSize;
+ let ratioY = height / computedSize;
if (radius === "closest-side") {
// radius is the distance from center to closest side of reference box
- radius = Math.min(center[0], center[1], 100 - center[0], 100 - center[1]);
+ pxRadius = Math.min(pxCenter[0], pxCenter[1],
+ width - pxCenter[0], height - pxCenter[1]);
+ if (pxRadius === pxCenter[0] || pxRadius === width - pxCenter[0]) {
+ radius = pxRadius * 100 / width;
+ ratioX = 1;
+ ratioY = height / width;
+ } else {
+ radius = pxRadius * 100 / height;
+ ratioX = width / height;
+ ratioY = 1;
+ }
} else if (radius === "farthest-side") {
// radius is the distance from center to farthest side of reference box
- radius = Math.max(center[0], center[1], 100 - center[0], 100 - center[1]);
+ pxRadius = Math.max(pxCenter[0], pxCenter[1],
+ width - pxCenter[0], height - pxCenter[1]);
+ if (pxRadius === pxCenter[0] || pxRadius === width - pxCenter[0]) {
+ radius = pxRadius * 100 / width;
+ ratioX = 1;
+ ratioY = height / width;
+ } else {
+ radius = pxRadius * 100 / height;
+ ratioX = width / height;
+ ratioY = 1;
+ }
} else if (radius.includes("calc(")) {
radius = evalCalcExpression(radius.substring(5, radius.length - 1), computedSize);
+ pxRadius = radius / 100 * computedSize;
} else {
+ pxRadius = coordToPixel(radius, computedSize);
radius = coordToPercent(radius, computedSize);
}
// Scale both radiusX and radiusY to match the radius computed
// using the above equation.
- let ratioX = width / computedSize;
- let ratioY = height / computedSize;
let radiusX = radius / ratioX;
let radiusY = radius / ratioY;
// rx, ry, cx, ry
+ this.pixelCoords = { radius: pxRadius, cx: pxCenter[0], cy: pxCenter[1] };
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.
*/
@@ -1096,31 +1129,48 @@ class ShapesHighlighter extends AutoRefr
* @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();
let values = definition.split(" at ");
- let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
+ let splitCenter = splitCoords(values[1]);
+ let pxCenter = splitCenter.map(this.convertCoordsToPixel.bind(this));
+ let center = splitCenter.map(this.convertCoordsToPercent.bind(this));
let radii = splitCoords(values[0]).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);
});
+ let pxRadii = splitCoords(values[0]).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(pxCenter[0], 100 - pxCenter[0])
+ : Math.min(pxCenter[1], 100 - pxCenter[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(pxCenter[0], 100 - pxCenter[0])
+ : Math.max(pxCenter[1], 100 - pxCenter[1]);
+ }
+ return this.convertCoordsToPixel(radius, i);
+ });
+
+ this.pixelCoords = { rx: pxRadii[0], ry: pxRadii[1],
+ cx: pxCenter[0], cy: pxCenter[1] };
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.
*/
@@ -1150,37 +1200,51 @@ class ShapesHighlighter extends AutoRefr
* "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();
let values = definition.split(" round ");
- let offsets = splitCoords(values[0]).map(this.convertCoordsToPercent.bind(this));
+ let offsets = splitCoords(values[0]);
- let top, left = 0;
- let { width: right, height: bottom } = this.currentDimensions;
+ let top, left, right, bottom = 0;
+ let { width, height } = this.zoomAdjustedDimensions;
+ let pxTop, pxLeft, pxRight, pxBottom;
// The offsets, like margin/padding/border, are in order: top, right, bottom, left.
if (offsets.length === 1) {
- top = left = right = bottom = offsets[0];
+ top = bottom = coordToPercent(offsets[0], height);
+ left = right = coordToPercent(offsets[0], width);
+ pxTop = pxBottom = coordToPixel(offsets[0], height);
+ pxLeft = pxRight = coordToPixel(offsets[0], width);
} else if (offsets.length === 2) {
- top = bottom = offsets[0];
- left = right = offsets[1];
+ top = bottom = coordToPercent(offsets[0], height);
+ left = right = coordToPercent(offsets[1], width);
+ pxTop = pxBottom = coordToPixel(offsets[0], height);
+ pxLeft = pxRight = coordToPixel(offsets[1], width);
} else if (offsets.length === 3) {
- top = offsets[0];
- left = right = offsets[1];
- bottom = offsets[2];
+ top = coordToPercent(offsets[0], height);
+ left = right = coordToPercent(offsets[1], width);
+ bottom = coordToPercent(offsets[2], height);
+ pxTop = coordToPixel(offsets[0], height);
+ pxLeft = pxRight = coordToPixel(offsets[1], width);
+ pxBottom = coordToPixel(offsets[2], height);
} else if (offsets.length === 4) {
- top = offsets[0];
- right = offsets[1];
- bottom = offsets[2];
- left = offsets[3];
+ top = coordToPercent(offsets[0], height);
+ right = coordToPercent(offsets[1], width);
+ bottom = coordToPercent(offsets[2], height);
+ left = coordToPercent(offsets[3], width);
+ pxTop = coordToPixel(offsets[0], height);
+ pxRight = coordToPixel(offsets[1], width);
+ pxBottom = coordToPixel(offsets[2], height);
+ pxLeft = coordToPixel(offsets[3], width);
}
+ this.pixelCoords = { top: pxTop, left: pxLeft, right: pxRight, bottom: pxBottom };
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.
*/
@@ -1195,16 +1259,17 @@ class ShapesHighlighter extends AutoRefr
let values = definition.split(" round ");
this.insetRound = values[1];
let 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;
+ this.numInsetPoints = offsets.length;
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];
@@ -1224,16 +1289,25 @@ class ShapesHighlighter extends AutoRefr
let { width, height } = this.zoomAdjustedDimensions;
let size = i % 2 === 0 ? width : height;
if (coord.includes("calc(")) {
return evalCalcExpression(coord.substring(5, coord.length - 1), size);
}
return coordToPercent(coord, size);
}
+ convertCoordsToPixel(coord, i) {
+ let { width, height } = this.zoomAdjustedDimensions;
+ let size = i % 2 === 0 ? width : height;
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size) / 100 * size;
+ }
+ return coordToPixel(coord, size);
+ }
+
/**
* Destroy the nodes. Remove listeners.
*/
destroy() {
let { pageListenerTarget } = this.highlighterEnv;
if (pageListenerTarget) {
DOM_EVENTS.forEach(type => pageListenerTarget.removeEventListener(type, this));
}
@@ -1339,16 +1413,22 @@ class ShapesHighlighter extends AutoRefr
} else if (this.shapeType === "ellipse") {
this._updateEllipseShape(width, height, zoom);
} else if (this.shapeType === "inset") {
this._updateInsetShape(width, height, zoom);
}
this._handleMarkerHover(this.hoveredPoint);
+ if (this.options.convertPoint) {
+ let { convertPoint, convertPair, forward } = this.options;
+ this.handleUnitConversion(convertPoint, convertPair, forward);
+ this.options.convertPoint = null;
+ }
+
let { width: winWidth, height: winHeight } = this._winDimensions;
root.removeAttribute("hidden");
root.setAttribute("style",
`position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden`);
setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
return true;
@@ -1463,16 +1543,238 @@ class ShapesHighlighter extends AutoRefr
onPageHide({ target }) {
// If a page hide event is triggered for current window's highlighter, hide the
// highlighter.
if (target.defaultView === this.win) {
this.hide();
}
}
+
+ handleUnitConversion(point, pair, forward) {
+ if (this.shapeType === "polygon") {
+ this._handlePolygonConversion(point, pair, forward);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleConversion(point, pair, forward);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseConversion(point, pair, forward);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetConversion(point, forward);
+ }
+ }
+
+ _handlePolygonConversion(point, pair, forward) {
+ if (!pair) {
+ return;
+ }
+ let { width, height } = this.zoomAdjustedDimensions;
+ let pairIndex = (pair === "x") ? 0 : 1;
+ let size = (pair === "x") ? width : height;
+
+ let coord = this.pixelCoords[point][pairIndex];
+ let currCoord = this.coordUnits[point][pairIndex];
+ let converted = this.convertToNextUnit(coord, currCoord, forward, size);
+ this.coordUnits[point][pairIndex] = converted;
+
+ let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+ polygonDef += this.coordUnits.map((coords, i) => {
+ return coords.join(" ");
+ }).join(", ");
+ polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+ `polygon(${polygonDef})`;
+
+ this.currentNode.style.setProperty(this.property, polygonDef, "important");
+ }
+
+ _handleCircleConversion(point, pair, forward) {
+ if (point === "center") {
+ if (!pair) {
+ return;
+ }
+ let { cx, cy } = this.pixelCoords;
+ let { radius, cx: currCx, cy: currCy } = this.coordUnits;
+ let { width, height } = this.zoomAdjustedDimensions;
+
+ let coord = (pair === "x") ? cx : cy;
+ let currCoord = (pair === "x") ? currCx : currCy;
+ let size = (pair === "x") ? width : height;
+ let newCoord = this.convertToNextUnit(coord, currCoord, forward, size);
+ this.coordUnits[`c${pair}`] = newCoord;
+ if (pair === "x") {
+ currCx = newCoord;
+ } else {
+ currCy = newCoord;
+ }
+
+ let circleDef = (this.geometryBox) ?
+ `circle(${radius} at ${currCx} ${currCy}) ${this.geometryBox}` :
+ `circle(${radius} at ${currCx} ${currCy})`;
+ this.currentNode.style.setProperty(this.property, circleDef, "important");
+ } else if (point === "radius") {
+ let { radius } = this.pixelCoords;
+ let { radius: currRadius, cx, cy } = this.coordUnits;
+ let { width, height } = this.zoomAdjustedDimensions;
+ let size = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+ let newRadius = this.convertToNextUnit(radius, currRadius, forward, size);
+ this.coordUnits.radius = newRadius;
+
+ let circleDef = (this.geometryBox) ?
+ `circle(${newRadius} at ${cx} ${cy} ${this.geometryBox}` :
+ `circle(${newRadius} at ${cx} ${cy}`;
+ this.currentNode.style.setProperty(this.property, circleDef, "important");
+ }
+ }
+
+ _handleEllipseConversion(point, pair, forward) {
+ if (point === "center") {
+ if (!pair) {
+ return;
+ }
+ let { cx, cy } = this.pixelCoords;
+ let { rx, ry, cx: currCx, cy: currCy } = this.coordUnits;
+ let { width, height } = this.zoomAdjustedDimensions;
+
+ let coord = (pair === "x") ? cx : cy;
+ let currCoord = (pair === "x") ? currCx : currCy;
+ let size = (pair === "x") ? width : height;
+ let newCoord = this.convertToNextUnit(coord, currCoord, forward, size);
+ this.coordUnits[`c${pair}`] = newCoord;
+ if (pair === "x") {
+ currCx = newCoord;
+ } else {
+ currCy = newCoord;
+ }
+
+ let ellipseDef = (this.geometryBox) ?
+ `ellipse(${rx} ${ry} at ${currCx} ${currCy}) ${this.geometryBox}` :
+ `ellipse(${rx} ${ry} at ${currCx} ${currCy})`;
+ this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+ } else if (point === "rx") {
+ let { rx } = this.pixelCoords;
+ let { rx: currRx, ry, cx, cy } = this.coordUnits;
+ let { width } = this.zoomAdjustedDimensions;
+ let newRx = this.convertToNextUnit(rx, currRx, forward, width);
+ this.coordUnits.rx = newRx;
+ let ellipseDef = (this.geometryBox) ?
+ `ellipse(${newRx} ${ry} at ${cx} ${cy}) ${this.geometryBox}` :
+ `ellipse(${newRx} ${ry} at ${cx} ${cy})`;
+
+ this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+ } else if (point === "ry") {
+ let { ry } = this.pixelCoords;
+ let { rx, ry: currRy, cx, cy } = this.coordUnits;
+ let { height } = this.zoomAdjustedDimensions;
+ let newRy = this.convertToNextUnit(ry, currRy, forward, height);
+ this.coordUnits.ry = newRy;
+ let ellipseDef = (this.geometryBox) ?
+ `ellipse(${rx} ${newRy} at ${cx} ${cy}) ${this.geometryBox}` :
+ `ellipse(${rx} ${newRy} at ${cx} ${cy})`;
+
+ this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+ }
+ }
+
+ _handleInsetConversion(point, forward) {
+ let points = point.split(",");
+ let coordX = (points[0] === "left") ?
+ this.pixelCoords[points[0]] : this.pixelCoords.right;
+ let coordY = (points[0] === "bottom") ?
+ this.pixelCoords[points[0]] : this.pixelCoords.top;
+ let currCoordX = (points[0] === "left") ?
+ this.coordUnits[points[0]] : this.coordUnits.right;
+ let currCoordY = (points[0] === "bottom") ?
+ this.coordUnits[points[0]] : this.coordUnits.top;
+ let { width, height } = this.zoomAdjustedDimensions;
+ let convertedX = this.convertToNextUnit(coordX, currCoordX, forward, width);
+ let convertedY = this.convertToNextUnit(coordY, currCoordY, forward, height);
+
+ // If we are converting all 4 points at once, and convertedX and convertedY
+ // are different, that means we are converting to or from %, and the
+ // width/height are different. We shouldn't do this because we will get
+ // different values for top/bottom and left/right.
+ if (points.length === 4 && convertedX !== convertedY) {
+ // If we have converted to %, we skip it and convert to the next unit.
+ // If we have converted from %, we return and do not save the conversion.
+ if (getUnit(convertedX) === "%") {
+ convertedX = this.convertToNextUnit(coordX, convertedX, forward, width);
+ convertedY = this.convertToNextUnit(coordY, convertedY, forward, height);
+ } else {
+ return;
+ }
+ }
+
+ for (let insetPoint of points) {
+ if (insetPoint === "top" || insetPoint === "bottom") {
+ this.coordUnits[insetPoint] = convertedY;
+ } else {
+ this.coordUnits[insetPoint] = convertedX;
+ }
+ }
+
+ let { top, right, bottom, left } = this.coordUnits;
+ let definitions = [top, right, bottom, left];
+ definitions = definitions.slice(0, this.numInsetPoints);
+ let insetDef = (this.insetRound) ?
+ `inset(${definitions.join(" ")} round ${this.insetRound})` :
+ `inset(${definitions.join(" ")})`;
+
+ insetDef += (this.geometryBox) ? this.geometryBox : "";
+
+ this.currentNode.style.setProperty(this.property, insetDef, "important");
+ }
+
+ convertToNextUnit(coord, currCoord, forward, size) {
+ if (isUnitless(currCoord)) {
+ return currCoord;
+ }
+ let oldUnit = getUnit(currCoord);
+
+ let unitIndex = UNITS.indexOf(oldUnit);
+ let newUnit = (unitIndex === UNITS.length - 1) ? UNITS[0] : UNITS[unitIndex + 1];
+ if (!forward) {
+ newUnit = (unitIndex <= 0) ? UNITS[UNITS.length - 1] : UNITS[unitIndex - 1];
+ }
+ let newValue = this.convertFromPx(coord, newUnit, size);
+ return roundTo(newValue, -4) + newUnit;
+ }
+
+ convertFromPx(value, unit, size) {
+ switch (unit) {
+ case "px":
+ return value;
+ case "in":
+ return value / 96;
+ case "cm":
+ return value / 37.8;
+ case "mm":
+ return value / 3.78;
+ case "em":
+ return value / parseFloat(getComputedStyle(this.currentNode).fontSize);
+ case "rem":
+ let root = this.currentNode.ownerDocument.documentElement;
+ return value / parseFloat(getComputedStyle(root).fontSize);
+ case "pt":
+ return value * 0.75;
+ case "pc":
+ return value * 0.0625;
+ case "vh":
+ return value * 100 / this.win.innerHeight;
+ case "vw":
+ return value * 100 / this.win.innerWidth;
+ case "vmin":
+ let vmin = Math.min(this.win.innerHeight, this.win.innerWidth);
+ return value * 100 / vmin;
+ case "vmax":
+ let vmax = Math.max(this.win.innerHeight, this.win.innerWidth);
+ return value * 100 / vmax;
+ case "%":
+ return value * 100 / size;
+ }
+ return 0;
+ }
}
/**
* Get the "raw" (i.e. non-computed) shape definition on the given node.
* @param {nsIDOMNode} 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.
*/
@@ -1535,16 +1837,27 @@ function coordToPercent(coord, size) {
return px * 100 / size;
}
// Unit-less value, so 0.
return 0;
}
exports.coordToPercent = coordToPercent;
+function coordToPixel(coord, size) {
+ if (coord.includes("%")) {
+ let percent = parseFloat(coord);
+ return percent / 100 * size;
+ } else if (coord.includes("px")) {
+ return parseFloat(coord);
+ }
+
+ return 0;
+}
+
/**
* 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) {