--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -10,16 +10,17 @@ support-files =
doc_inspector_delete-selected-node-01.html
doc_inspector_delete-selected-node-02.html
doc_inspector_embed.html
doc_inspector_gcli-inspect-command.html
doc_inspector_highlight_after_transition.html
doc_inspector_highlighter-comments.html
doc_inspector_highlighter-geometry_01.html
doc_inspector_highlighter-geometry_02.html
+ doc_inspector_highlighter_cssshapes.html
doc_inspector_highlighter_csstransform.html
doc_inspector_highlighter_dom.html
doc_inspector_highlighter_inline.html
doc_inspector_highlighter.html
doc_inspector_highlighter_rect.html
doc_inspector_highlighter_rect_iframe.html
doc_inspector_highlighter_xbl.xul
doc_inspector_infobar_01.html
@@ -69,16 +70,18 @@ skip-if = os == "mac" # Full keyboard na
[browser_inspector_highlighter-03.js]
[browser_inspector_highlighter-04.js]
[browser_inspector_highlighter-05.js]
[browser_inspector_highlighter-by-type.js]
[browser_inspector_highlighter-cancel.js]
[browser_inspector_highlighter-comments.js]
[browser_inspector_highlighter-cssgrid_01.js]
[browser_inspector_highlighter-cssgrid_02.js]
+[browser_inspector_highlighter-cssshape_01.js]
+[browser_inspector_highlighter-cssshape_02.js]
[browser_inspector_highlighter-csstransform_01.js]
[browser_inspector_highlighter-csstransform_02.js]
[browser_inspector_highlighter-embed.js]
[browser_inspector_highlighter-eyedropper-clipboard.js]
subsuite = clipboard
skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
[browser_inspector_highlighter-eyedropper-csp.js]
[browser_inspector_highlighter-eyedropper-events.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_01.js
@@ -0,0 +1,60 @@
+/* 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";
+
+// Test the creation of the CSS shapes highlighter.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+
+add_task(function* () {
+ let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
+ let front = inspector.inspector;
+ let highlighter = yield front.getHighlighterByType(HIGHLIGHTER_TYPE);
+
+ yield isHiddenByDefault(testActor, highlighter);
+ yield isVisibleWhenShown(testActor, inspector, highlighter);
+
+ yield highlighter.finalize();
+});
+
+function* isHiddenByDefault(testActor, highlighterFront) {
+ info("Checking that highlighter is hidden by default");
+
+ let polygonHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-polygon", "hidden", highlighterFront);
+ let ellipseHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "hidden", highlighterFront);
+ ok(polygonHidden && ellipseHidden, "The highlighter is hidden by default");
+}
+
+function* isVisibleWhenShown(testActor, inspector, highlighterFront) {
+ info("Asking to show the highlighter on the polygon node");
+
+ let polygonNode = yield getNodeFront("#polygon", inspector);
+ yield highlighterFront.show(polygonNode, {mode: "cssClipPath"});
+
+ let polygonHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-polygon", "hidden", highlighterFront);
+ ok(!polygonHidden, "The polygon highlighter is visible");
+
+ info("Asking to show the highlighter on the circle node");
+ let circleNode = yield getNodeFront("#circle", inspector);
+ yield highlighterFront.show(circleNode, {mode: "cssClipPath"});
+
+ let ellipseHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "hidden", highlighterFront);
+ polygonHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-polygon", "hidden", highlighterFront);
+ ok(!ellipseHidden, "The circle highlighter is visible");
+ ok(polygonHidden, "The polygon highlighter is no longer visible");
+
+ info("Hiding the highlighter");
+ yield highlighterFront.hide();
+
+ polygonHidden = yield testActor.getHighlighterNodeAttribute(
+ "shapes-polygon", "hidden", highlighterFront);
+ ok(polygonHidden, "The highlighter is hidden");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
@@ -0,0 +1,55 @@
+/* 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";
+
+// Make sure that the CSS shapes highlighters have the correct attributes.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+
+add_task(function* () {
+ let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
+ let front = inspector.inspector;
+ let highlighter = yield front.getHighlighterByType(HIGHLIGHTER_TYPE);
+
+ yield polygonHasCorrectAttrs(testActor, inspector, highlighter);
+ yield circleHasCorrectAttrs(testActor, inspector, highlighter);
+
+ yield highlighter.finalize();
+});
+
+function* polygonHasCorrectAttrs(testActor, inspector, highlighterFront) {
+ info("Checking polygon highlighter has correct points");
+
+ let polygonNode = yield getNodeFront("#polygon", inspector);
+ yield highlighterFront.show(polygonNode, {mode: "cssClipPath"});
+
+ let points = yield testActor.getHighlighterNodeAttribute(
+ "shapes-polygon", "points", highlighterFront);
+ let realPoints = "0,0 12.5,50 25,0 37.5,50 50,0 62.5,50 " +
+ "75,0 87.5,50 100,0 90,100 50,60 10,100";
+ is(points, realPoints, "Polygon highlighter has correct points");
+}
+
+function* circleHasCorrectAttrs(testActor, inspector, highlighterFront) {
+ info("Checking circle highlighter has correct attributes");
+
+ let circleNode = yield getNodeFront("#circle", inspector);
+ yield highlighterFront.show(circleNode, {mode: "cssClipPath"});
+
+ let rx = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "rx", highlighterFront);
+ let ry = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "ry", highlighterFront);
+ let cx = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "cx", highlighterFront);
+ let cy = yield testActor.getHighlighterNodeAttribute(
+ "shapes-ellipse", "cy", highlighterFront);
+
+ is(rx, 25, "Circle highlighter has correct rx");
+ is(ry, 25, "Circle highlighter has correct ry");
+ is(cx, 30, "Circle highlighter has correct cx");
+ is(cy, 40, "Circle highlighter has correct cy");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
@@ -0,0 +1,42 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<meta charset="utf-8">
+<style>
+ html, body {
+ height: 100%;
+ margin: 0;
+ }
+ .wrapper {
+ width: 800px;
+ height: 800px;
+ background: #f06;
+ }
+ #polygon {
+ clip-path: polygon(0 0,
+ 100px 50%,
+ 200px 0,
+ 300px 50%,
+ 400px 0,
+ 500px 50%,
+ 600px 0,
+ 700px 50%,
+ 800px 0,
+ 90% 100%,
+ 50% 60%,
+ 10% 100%);
+ }
+ #circle {
+ clip-path: circle(25% at 30% 40%);
+ }
+ #ellipse {
+ clip-path: ellipse(40% 30% at 25% 75%);
+ }
+ #inset {
+ clip-path: inset(200px 100px 30% 15%);
+ }
+</style>
+<div class="wrapper" id="polygon"></div>
+<div class="wrapper" id="circle"></div>
+<div class="wrapper" id="ellipse"></div>
+<div class="wrapper" id="inset"></div>
\ No newline at end of file
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -587,8 +587,32 @@
border-radius: 2px;
box-shadow: var(--toolbar-box-shadow);
background-color: var(--toolbar-background);
border: 1px solid var(--toolbar-border);
font: var(--highlighter-font-family);
font-size: var(--highlighter-font-size);
}
+
+/* Shapes highlighter */
+
+:-moz-native-anonymous .shapes-shape-container,
+:-moz-native-anonymous .shapes-markers-container {
+ position: absolute;
+}
+
+:-moz-native-anonymous .shapes-markers-container {
+ width: 10px;
+ height: 10px;
+ transform: translate(-5px, -5px);
+ background: transparent;
+ border-radius: 50%;
+ color: var(--highlighter-bubble-background-color);
+}
+
+:-moz-native-anonymous .shapes-polygon,
+:-moz-native-anonymous .shapes-ellipse {
+ fill: transparent;
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: crispEdges;
+ vector-effect: non-scaling-stroke;
+}
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -34,22 +34,22 @@ const highlighterTypes = new Map();
* Returns `true` if a highlighter for the given `typeName` is registered,
* `false` otherwise.
*/
const isTypeRegistered = (typeName) => highlighterTypes.has(typeName);
exports.isTypeRegistered = isTypeRegistered;
/**
* Registers a given constructor as highlighter, for the `typeName` given.
- * If no `typeName` is provided, is looking for a `typeName` property in
- * the prototype's constructor.
+ * If no `typeName` is provided, the `typeName` property on the constructor's prototype
+ * is used, if one is found, otherwise the name of the constructor function is used.
*/
-const register = (constructor, typeName = constructor.prototype.typeName) => {
+const register = (constructor, typeName) => {
if (!typeName) {
- throw Error("No type's name found, or provided.");
+ typeName = constructor.prototype.typeName || constructor.name;
}
if (highlighterTypes.has(typeName)) {
throw Error(`${typeName} is already registered.`);
}
highlighterTypes.set(typeName, constructor);
};
@@ -723,8 +723,12 @@ exports.MeasuringToolHighlighter = Measu
const { EyeDropper } = require("./highlighters/eye-dropper");
register(EyeDropper);
exports.EyeDropper = EyeDropper;
const { PausedDebuggerOverlay } = require("./highlighters/paused-debugger");
register(PausedDebuggerOverlay);
exports.PausedDebuggerOverlay = PausedDebuggerOverlay;
+
+const { ShapesHighlighter } = require("./highlighters/shapes");
+register(ShapesHighlighter);
+exports.ShapesHighlighter = ShapesHighlighter;
--- a/devtools/server/actors/highlighters/moz.build
+++ b/devtools/server/actors/highlighters/moz.build
@@ -14,10 +14,11 @@ DevToolsModules(
'css-grid.js',
'css-transform.js',
'eye-dropper.js',
'geometry-editor.js',
'measuring-tool.js',
'paused-debugger.js',
'rulers.js',
'selector.js',
+ 'shapes.js',
'simple-outline.js'
)
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -0,0 +1,415 @@
+/* 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,
+ createSVGNode, createNode, getComputedStyle } = require("./utils/markup");
+const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+
+// We use this as an offset to avoid the marker itself from being on top of its shadow.
+const MARKER_SIZE = 10;
+
+/**
+ * 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 {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "shapes-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+ this._buildMarkup.bind(this));
+ }
+
+ _buildMarkup() {
+ let container = createNode(this.win, {
+ attributes: {
+ "class": "highlighter-container"
+ }
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ let rootWrapper = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ let mainSvg = createSVGNode(this.win, {
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ "id": "shape-container",
+ "class": "shape-container",
+ "viewBox": "0 0 100 100",
+ "preserveAspectRatio": "none"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // We also need a separate element to draw the shapes' points markers. We can't use
+ // the SVG because it is scaled.
+ createNode(this.win, {
+ nodeType: "div",
+ parent: rootWrapper,
+ attributes: {
+ "id": "markers-container",
+ "class": "markers-container"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Append a polygon for polygon shapes.
+ createSVGNode(this.win, {
+ nodeType: "polygon",
+ parent: mainSvg,
+ attributes: {
+ "id": "polygon",
+ "class": "polygon",
+ "hidden": "true"
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // Append an ellipse for circle/ellipse shapes.
+ createSVGNode(this.win, {
+ nodeType: "ellipse",
+ parent: mainSvg,
+ attributes: {
+ "id": "ellipse",
+ "class": "ellipse",
+ "hidden": true
+ },
+ prefix: this.ID_CLASS_PREFIX
+ });
+
+ // TODO: Append different SVG objects for different shapes.
+
+ return container;
+ }
+
+ /**
+ * 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 types = [{
+ name: "polygon",
+ prefix: "polygon(",
+ coordParser: this.polygonPoints.bind(this)
+ }, {
+ name: "circle",
+ prefix: "circle(",
+ coordParser: this.circlePoints.bind(this)
+ }];
+
+ for (let { name, prefix, coordParser } of types) {
+ if (definition.includes(prefix)) {
+ definition = definition.substring(prefix.length, definition.length - 1);
+ 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) {
+ return definition.split(",").map(coords => {
+ return splitCoords(coords).map((coord, i) => {
+ let size = i % 2 === 0 ? this.currentQuads.border[0].bounds.width
+ : this.currentQuads.border[0].bounds.height;
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ });
+ });
+ }
+
+ /**
+ * 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) {
+ // The computed value of circle() always has the keyword "at".
+ let values = definition.split(" at ");
+ let radius = values[0];
+ let elemWidth = this.currentQuads.border[0].bounds.width;
+ let elemHeight = this.currentQuads.border[0].bounds.height;
+ let center = splitCoords(values[1]).map((coord, i) => {
+ let size = i % 2 === 0 ? elemWidth : elemHeight;
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ });
+
+ 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]);
+ } 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]);
+ } else {
+ // radius is a % or px value
+ radius = coordToPercent(radius, Math.max(elemWidth, elemHeight));
+ }
+
+ // Percentage values for circle() are resolved from the
+ // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
+ // Scale both radiusX and radiusY to match the radius computed
+ // using the above equation.
+ let computedSize = Math.sqrt((elemWidth ** 2) + (elemHeight ** 2)) / Math.sqrt(2);
+ let ratioX = elemWidth / computedSize;
+ let ratioY = elemHeight / computedSize;
+ let radiusX = radius / ratioX;
+ let radiusY = radius / ratioY;
+
+ // rx, ry, cx, ry
+ return { rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ AutoRefreshHighlighter.prototype.destroy.call(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);
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ 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);
+
+ let oldShapeCoordinates = JSON.stringify(this.coordinates);
+
+ // TODO: need other modes too.
+ if (this.options.mode.startsWith("css")) {
+ let property = shapeModeToCssPropertyName(this.options.mode);
+
+ let { coordinates, shapeType } =
+ this._parseCSSShapeValue(getComputedStyle(this.currentNode)[property]);
+ this.coordinates = coordinates;
+ this.shapeType = shapeType;
+ }
+
+ let newShapeCoordinates = JSON.stringify(this.coordinates);
+
+ return hasMoved || oldShapeCoordinates !== newShapeCoordinates;
+ }
+
+ /**
+ * Hide all elements used to highlight CSS different shapes.
+ */
+ _hideShapes() {
+ this.getElement("ellipse").setAttribute("hidden", true);
+ this.getElement("polygon").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);
+
+ let { top, left, width, height } = this.currentQuads.border[0].bounds;
+
+ // 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.getElement("markers-container").setAttribute("style", "");
+
+ if (this.shapeType === "polygon") {
+ this._updatePolygonShape(top, left, width, height);
+ } else if (this.shapeType === "circle") {
+ this._updateCircleShape(top, left, width, height);
+ }
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+
+ return true;
+ }
+
+ /**
+ * Update the SVG polygon to fit the CSS polygon.
+ * @param {Number} top the top bound of the element quads
+ * @param {Number} left the left bound of the element quads
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ */
+ _updatePolygonShape(top, left, width, height) {
+ // Draw and show the polygon.
+ let points = this.coordinates.map(point => point.join(",")).join(" ");
+
+ let polygonEl = this.getElement("polygon");
+ polygonEl.setAttribute("points", points);
+ polygonEl.removeAttribute("hidden");
+
+ // Draw the points themselves, using the markers-container and multiple box-shadows.
+ let shadows = this.coordinates.map(([x, y]) => {
+ return `${MARKER_SIZE + x * width / 100}px ${MARKER_SIZE + y * height / 100}px 0 0`;
+ }).join(", ");
+
+ this.getElement("markers-container").setAttribute("style",
+ `top:${top - MARKER_SIZE}px;left:${left - MARKER_SIZE}px;box-shadow:${shadows};`);
+ }
+
+ /**
+ * Update the SVG ellipse to fit the CSS circle.
+ * @param {Number} top the top bound of the element quads
+ * @param {Number} left the left bound of the element quads
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ */
+ _updateCircleShape(top, left, width, height) {
+ let { rx, ry, cx, cy } = this.coordinates;
+ let ellipseEl = this.getElement("ellipse");
+ ellipseEl.setAttribute("rx", rx);
+ ellipseEl.setAttribute("ry", ry);
+ ellipseEl.setAttribute("cx", cx);
+ ellipseEl.setAttribute("cy", cy);
+ ellipseEl.removeAttribute("hidden");
+
+ let shadows = `${MARKER_SIZE + cx * width / 100}px
+ ${MARKER_SIZE + cy * height / 100}px 0 0,
+ ${MARKER_SIZE + (cx + rx) * width / 100}px
+ ${MARKER_SIZE + cy * height / 100}px 0 0`;
+
+ this.getElement("markers-container").setAttribute("style",
+ `top:${top - MARKER_SIZE}px;left:${left - MARKER_SIZE}px;box-shadow:${shadows};`);
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._hideShapes();
+ this.getElement("markers-container").setAttribute("style", "");
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+ }
+}
+
+/**
+ * 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 " + " in them, so replace
+ // those with "+" before splitting with " " to get the proper coord pair.
+ return coords.trim().replace(/ \+ /g, "+").split(" ");
+}
+
+/**
+ * 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.
+ let px = parseFloat(coord.replace("px", ""));
+ return px * 100 / size;
+ }
+
+ // Unit-less value, so 0.
+ 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) {
+ // the calc() values returned by getComputedStyle only have addition, as it
+ // computes calc() expressions as much as possible without resolving percentages,
+ // leaving only addition.
+ let values = expression.split("+").map(v => v.trim());
+
+ return values.reduce((prev, curr) => {
+ return prev + coordToPercent(curr, size);
+ }, 0);
+}
+
+/**
+ * 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 => {
+ let property = mode.substring(3);
+ return property.substring(0, 1).toLowerCase() + property.substring(1);
+};
+
+exports.ShapesHighlighter = ShapesHighlighter;
+
+// Export helper functions so they can be tested
+exports.splitCoords = splitCoords;
+exports.coordToPercent = coordToPercent;
+exports.evalCalcExpression = evalCalcExpression;
+exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_shapes_highlighter_helpers.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test the helper functions of the shapes highlighter.
+ */
+
+"use strict";
+
+const {
+ splitCoords,
+ coordToPercent,
+ evalCalcExpression,
+ shapeModeToCssPropertyName
+} = require("devtools/server/actors/highlighters/shapes");
+
+function run_test() {
+ test_split_coords();
+ test_coord_to_percent();
+ test_eval_calc_expression();
+ test_shape_mode_to_css_property_name();
+ run_next_test();
+}
+
+function test_split_coords() {
+ const tests = [{
+ desc: "splitCoords for basic coordinate pair",
+ expr: "30% 20%",
+ expected: ["30%", "20%"]
+ }, {
+ desc: "splitCoords for coord pair with calc()",
+ expr: "calc(50px + 20%) 30%",
+ expected: ["calc(50px+20%)", "30%"]
+ }];
+
+ for (let { desc, expr, expected } of tests) {
+ deepEqual(splitCoords(expr), expected, desc);
+ }
+}
+
+function test_coord_to_percent() {
+ const size = 1000;
+ const tests = [{
+ desc: "coordToPercent for percent value",
+ expr: "50%",
+ expected: 50
+ }, {
+ desc: "coordToPercent for px value",
+ expr: "500px",
+ expected: 50
+ }, {
+ desc: "coordToPercent for zero value",
+ expr: "0",
+ expected: 0
+ }];
+
+ for (let { desc, expr, expected } of tests) {
+ equal(coordToPercent(expr, size), expected, desc);
+ }
+}
+
+function test_eval_calc_expression() {
+ const size = 1000;
+ const tests = [{
+ desc: "evalCalcExpression with one value",
+ expr: "50%",
+ expected: 50
+ }, {
+ desc: "evalCalcExpression with percent and px values",
+ expr: "50% + 100px",
+ expected: 60
+ }, {
+ desc: "evalCalcExpression with a zero value",
+ expr: "0 + 100px",
+ expected: 10
+ }, {
+ desc: "evalCalcExpression with a negative value",
+ expr: "-200px+50%",
+ expected: 30
+ }];
+
+ for (let { desc, expr, expected } of tests) {
+ equal(evalCalcExpression(expr, size), expected, desc);
+ }
+}
+
+function test_shape_mode_to_css_property_name() {
+ const tests = [{
+ desc: "shapeModeToCssPropertyName for clip-path",
+ expr: "cssClipPath",
+ expected: "clipPath"
+ }, {
+ desc: "shapeModeToCssPropertyName for shape-outside",
+ expr: "cssShapeOutside",
+ expected: "shapeOutside"
+ }];
+
+ for (let { desc, expr, expected } of tests) {
+ equal(shapeModeToCssPropertyName(expr), expected, desc);
+ }
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -226,8 +226,9 @@ support-files = xpcshell_debugging_scrip
[test_setBreakpoint-on-line.js]
[test_setBreakpoint-on-line-in-gcd-script.js]
[test_setBreakpoint-on-line-with-multiple-offsets.js]
[test_setBreakpoint-on-line-with-multiple-statements.js]
[test_setBreakpoint-on-line-with-no-offsets.js]
[test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js]
[test_safe-getter.js]
[test_client_close.js]
+[test_shapes_highlighter_helpers.js]