summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets')
-rw-r--r--devtools/client/shared/widgets/Chart.js532
-rw-r--r--devtools/client/shared/widgets/CubicBezierPresets.js64
-rw-r--r--devtools/client/shared/widgets/CubicBezierWidget.js986
-rw-r--r--devtools/client/shared/widgets/FilterWidget.js1131
-rw-r--r--devtools/client/shared/widgets/LinearEasingFunctionWidget.js731
-rw-r--r--devtools/client/shared/widgets/ShapesInContextEditor.js347
-rw-r--r--devtools/client/shared/widgets/Spectrum.js783
-rw-r--r--devtools/client/shared/widgets/TableWidget.js2031
-rw-r--r--devtools/client/shared/widgets/TreeWidget.js643
-rw-r--r--devtools/client/shared/widgets/cubic-bezier.css216
-rw-r--r--devtools/client/shared/widgets/filter-widget.css242
-rw-r--r--devtools/client/shared/widgets/linear-widget.css61
-rw-r--r--devtools/client/shared/widgets/moz.build22
-rw-r--r--devtools/client/shared/widgets/spectrum.css331
-rw-r--r--devtools/client/shared/widgets/tooltip/EventTooltipHelper.js384
-rw-r--r--devtools/client/shared/widgets/tooltip/HTMLTooltip.js1061
-rw-r--r--devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js145
-rw-r--r--devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js69
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js270
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js357
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js95
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js117
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js97
-rw-r--r--devtools/client/shared/widgets/tooltip/TooltipToggle.js203
-rw-r--r--devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js31
-rw-r--r--devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js292
-rw-r--r--devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js145
-rw-r--r--devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js127
-rw-r--r--devtools/client/shared/widgets/tooltip/moz.build22
-rw-r--r--devtools/client/shared/widgets/view-helpers.js430
-rw-r--r--devtools/client/shared/widgets/widgets.css79
31 files changed, 12044 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/Chart.js b/devtools/client/shared/widgets/Chart.js
new file mode 100644
index 0000000000..574fadbadf
--- /dev/null
+++ b/devtools/client/shared/widgets/Chart.js
@@ -0,0 +1,532 @@
+/* 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 NET_STRINGS_URI = "devtools/client/locales/netmonitor.properties";
+const SVG_NS = "http://www.w3.org/2000/svg";
+const PI = Math.PI;
+const TAU = PI * 2;
+const EPSILON = 0.0000001;
+const NAMED_SLICE_MIN_ANGLE = TAU / 8;
+const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9;
+const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20;
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(NET_STRINGS_URI);
+
+/**
+ * A factory for creating charts.
+ * Example usage: let myChart = Chart.Pie(document, { ... });
+ */
+var Chart = {
+ Pie: createPieChart,
+ Table: createTableChart,
+ PieTable: createPieTableChart,
+};
+
+/**
+ * A simple pie chart proxy for the underlying view.
+ * Each item in the `slices` property represents a [data, node] pair containing
+ * the data used to create the slice and the Node displaying it.
+ *
+ * @param Node node
+ * The node representing the view for this chart.
+ */
+function PieChart(node) {
+ this.node = node;
+ this.slices = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple table chart proxy for the underlying view.
+ * Each item in the `rows` property represents a [data, node] pair containing
+ * the data used to create the row and the Node displaying it.
+ *
+ * @param Node node
+ * The node representing the view for this chart.
+ */
+function TableChart(node) {
+ this.node = node;
+ this.rows = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple pie+table chart proxy for the underlying view.
+ *
+ * @param Node node
+ * The node representing the view for this chart.
+ * @param PieChart pie
+ * The pie chart proxy.
+ * @param TableChart table
+ * The table chart proxy.
+ */
+function PieTableChart(node, pie, table) {
+ this.node = node;
+ this.pie = pie;
+ this.table = table;
+ EventEmitter.decorate(this);
+}
+
+/**
+ * Creates the DOM for a pie+table chart.
+ *
+ * @param Document document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the table chart's (description)/local
+ * - diameter: the diameter of the pie chart, in pixels
+ * - data: an array of items used to display each slice in the pie
+ * and each row in the table;
+ * @see `createPieChart` and `createTableChart` for details.
+ * - strings: @see `createTableChart` for details.
+ * - totals: @see `createTableChart` for details.
+ * - sorted: a flag specifying if the `data` should be sorted
+ * ascending by `size`.
+ * @return PieTableChart
+ * A pie+table chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a slice or a row
+ * - "mouseout", when the mouse leaves a slice or a row
+ * - "click", when the mouse enters a slice or a row
+ */
+function createPieTableChart(
+ document,
+ { title, diameter, data, strings, totals, sorted, header }
+) {
+ if (data && sorted) {
+ data = data.slice().sort((a, b) => +(a.size < b.size));
+ }
+
+ const pie = Chart.Pie(document, {
+ width: diameter,
+ data,
+ });
+
+ const table = Chart.Table(document, {
+ title,
+ data,
+ strings,
+ totals,
+ header,
+ });
+
+ const container = document.createElement("div");
+ container.className = "pie-table-chart-container";
+ container.appendChild(pie.node);
+ container.appendChild(table.node);
+
+ const proxy = new PieTableChart(container, pie, table);
+
+ pie.on("click", item => {
+ proxy.emit("click", item);
+ });
+
+ table.on("click", item => {
+ proxy.emit("click", item);
+ });
+
+ pie.on("mouseover", item => {
+ proxy.emit("mouseover", item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).setAttribute("focused", "");
+ }
+ });
+
+ pie.on("mouseout", item => {
+ proxy.emit("mouseout", item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).removeAttribute("focused");
+ }
+ });
+
+ table.on("mouseover", item => {
+ proxy.emit("mouseover", item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).setAttribute("focused", "");
+ }
+ });
+
+ table.on("mouseout", item => {
+ proxy.emit("mouseout", item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).removeAttribute("focused");
+ }
+ });
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a pie chart based on the specified properties.
+ *
+ * @param Document document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - data: an array of items used to display each slice; all the items
+ * should be objects containing a `size` and a `label` property.
+ * e.g: [{
+ * size: 1,
+ * label: "foo"
+ * }, {
+ * size: 2,
+ * label: "bar"
+ * }];
+ * - width: the width of the chart, in pixels
+ * - height: optional, the height of the chart, in pixels.
+ * - centerX: optional, the X-axis center of the chart, in pixels.
+ * - centerY: optional, the Y-axis center of the chart, in pixels.
+ * - radius: optional, the radius of the chart, in pixels.
+ * @return PieChart
+ * A pie chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a slice
+ * - "mouseout", when the mouse leaves a slice
+ * - "click", when the mouse clicks a slice
+ */
+function createPieChart(
+ document,
+ { data, width, height, centerX, centerY, radius }
+) {
+ height = height || width;
+ centerX = centerX || width / 2;
+ centerY = centerY || height / 2;
+ radius = radius || (width + height) / 4;
+ let isPlaceholder = false;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data) {
+ data = loadingPieChartData();
+ isPlaceholder = true;
+ }
+ if (!data.length) {
+ data = emptyPieChartData();
+ isPlaceholder = true;
+ }
+
+ const container = document.createElementNS(SVG_NS, "svg");
+ container.setAttribute(
+ "class",
+ "generic-chart-container pie-chart-container"
+ );
+
+ container.setAttribute("width", width);
+ container.setAttribute("height", height);
+ container.setAttribute("viewBox", "0 0 " + width + " " + height);
+ container.setAttribute("slices", data.length);
+ container.setAttribute("placeholder", isPlaceholder);
+ container.setAttribute("role", "group");
+ container.setAttribute("aria-label", L10N.getStr("pieChart.ariaLabel"));
+
+ const slicesGroup = document.createElementNS(SVG_NS, "g");
+ slicesGroup.setAttribute("role", "list");
+ container.append(slicesGroup);
+
+ const proxy = new PieChart(container);
+
+ const total = data.reduce((acc, e) => acc + e.size, 0);
+ const angles = data.map(e => (e.size / total) * (TAU - EPSILON));
+ const largest = data.reduce((a, b) => (a.size > b.size ? a : b));
+ const smallest = data.reduce((a, b) => (a.size < b.size ? a : b));
+
+ const textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO;
+ const translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO;
+ let startAngle = TAU;
+ let endAngle = 0;
+ let midAngle = 0;
+ radius -= translateDistance;
+
+ for (let i = data.length - 1; i >= 0; i--) {
+ const sliceInfo = data[i];
+ const sliceAngle = angles[i];
+
+ const sliceNode = document.createElementNS(SVG_NS, "g");
+ sliceNode.setAttribute("role", "listitem");
+ slicesGroup.append(sliceNode);
+
+ const interactiveNodeId = `${sliceInfo.label}-slice`;
+ const textNodeId = `${sliceInfo.label}-slice-label`;
+
+ // The only way to make this keyboard accessible is to have a link
+ const interactiveNode = document.createElementNS(SVG_NS, "a");
+ interactiveNode.setAttribute("id", interactiveNodeId);
+ interactiveNode.setAttribute("xlink:href", `#${interactiveNodeId}`);
+ interactiveNode.setAttribute("tabindex", `0`);
+ interactiveNode.setAttribute("role", `button`);
+ interactiveNode.classList.add("pie-chart-slice-container");
+ if (!isPlaceholder) {
+ interactiveNode.setAttribute(
+ "aria-label",
+ L10N.getFormatStr(
+ "pieChart.sliceAriaLabel",
+ sliceInfo.label,
+ new Intl.NumberFormat(undefined, {
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 2,
+ }).format((sliceInfo.size / total) * 100)
+ )
+ );
+ }
+
+ sliceNode.append(interactiveNode);
+
+ endAngle = startAngle - sliceAngle;
+ midAngle = (startAngle + endAngle) / 2;
+
+ const x1 = centerX + radius * Math.sin(startAngle);
+ const y1 = centerY - radius * Math.cos(startAngle);
+ const x2 = centerX + radius * Math.sin(endAngle);
+ const y2 = centerY - radius * Math.cos(endAngle);
+ const largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0;
+
+ const pathNode = document.createElementNS(SVG_NS, "path");
+ pathNode.classList.add("pie-chart-slice");
+ pathNode.setAttribute("data-statistic-name", sliceInfo.label);
+ pathNode.setAttribute(
+ "d",
+ " M " +
+ centerX +
+ "," +
+ centerY +
+ " L " +
+ x2 +
+ "," +
+ y2 +
+ " A " +
+ radius +
+ "," +
+ radius +
+ " 0 " +
+ largeArcFlag +
+ " 1 " +
+ x1 +
+ "," +
+ y1 +
+ " Z"
+ );
+
+ if (sliceInfo == largest) {
+ pathNode.setAttribute("largest", "");
+ }
+ if (sliceInfo == smallest) {
+ pathNode.setAttribute("smallest", "");
+ }
+
+ const hoverX = translateDistance * Math.sin(midAngle);
+ const hoverY = -translateDistance * Math.cos(midAngle);
+ const hoverTransform =
+ "transform: translate(" + hoverX + "px, " + hoverY + "px)";
+ pathNode.setAttribute("style", data.length > 1 ? hoverTransform : "");
+
+ proxy.slices.set(sliceInfo, pathNode);
+ delegate(
+ proxy,
+ ["click", "mouseover", "mouseout", "focus"],
+ interactiveNode,
+ sliceInfo
+ );
+ interactiveNode.appendChild(pathNode);
+
+ const textX = centerX + textDistance * Math.sin(midAngle);
+ const textY = centerY - textDistance * Math.cos(midAngle);
+
+ // Don't add the label if the slice isn't large enough so it doesn't look cramped.
+ if (sliceAngle >= NAMED_SLICE_MIN_ANGLE) {
+ const label = document.createElementNS(SVG_NS, "text");
+ label.appendChild(document.createTextNode(sliceInfo.label));
+ label.setAttribute("id", textNodeId);
+ // A label is already set on `interactiveNode`, so hide this from the accessibility tree
+ // to avoid duplicating text.
+ label.setAttribute("aria-hidden", "true");
+ label.setAttribute("class", "pie-chart-label");
+ label.setAttribute("style", data.length > 1 ? hoverTransform : "");
+ label.setAttribute("x", data.length > 1 ? textX : centerX);
+ label.setAttribute("y", data.length > 1 ? textY : centerY);
+ interactiveNode.append(label);
+ }
+
+ startAngle = endAngle;
+ }
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a table chart based on the specified properties.
+ *
+ * @param Document document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the chart's (description)/local
+ * - data: an array of items used to display each row; all the items
+ * should be objects representing columns, for which the
+ * properties' values will be displayed in each cell of a row.
+ * e.g: [{
+ * label1: 1,
+ * label2: 3,
+ * label3: "foo"
+ * }, {
+ * label1: 4,
+ * label2: 6,
+ * label3: "bar
+ * }];
+ * - strings: an object specifying for which rows in the `data` array
+ * their cell values should be stringified and localized
+ * based on a predicate function;
+ * e.g: {
+ * label1: value => l10n.getFormatStr("...", value)
+ * }
+ * - totals: an object specifying for which rows in the `data` array
+ * the sum of their cells is to be displayed in the chart;
+ * e.g: {
+ * label1: total => l10n.getFormatStr("...", total), // 5
+ * label2: total => l10n.getFormatStr("...", total), // 9
+ * }
+ * - header: an object specifying strings to use for table column
+ * headers
+ * e.g. {
+ * label1: l10n.getStr(...),
+ * label2: l10n.getStr(...),
+ * }
+ * @return TableChart
+ * A table chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a row
+ * - "mouseout", when the mouse leaves a row
+ * - "click", when the mouse clicks a row
+ */
+function createTableChart(document, { title, data, strings, totals, header }) {
+ strings = strings || {};
+ totals = totals || {};
+ header = header || {};
+ let isPlaceholder = false;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data) {
+ data = loadingTableChartData();
+ isPlaceholder = true;
+ }
+ if (!data.length) {
+ data = emptyTableChartData();
+ isPlaceholder = true;
+ }
+
+ const container = document.createElement("div");
+ container.className = "generic-chart-container table-chart-container";
+ container.setAttribute("placeholder", isPlaceholder);
+
+ const proxy = new TableChart(container);
+
+ const titleNode = document.createElement("span");
+ titleNode.className = "plain table-chart-title";
+ titleNode.textContent = title;
+ container.appendChild(titleNode);
+
+ const tableNode = document.createElement("table");
+ tableNode.className = "plain table-chart-grid";
+ container.appendChild(tableNode);
+
+ const headerNode = document.createElement("thead");
+ headerNode.className = "table-chart-row";
+
+ const bodyNode = document.createElement("tbody");
+
+ const headerBoxNode = document.createElement("tr");
+ headerBoxNode.className = "table-chart-row-box";
+ headerNode.appendChild(headerBoxNode);
+
+ for (const [key, value] of Object.entries(header)) {
+ const headerLabelNode = document.createElement("th");
+ headerLabelNode.className = "plain table-chart-row-label";
+ headerLabelNode.setAttribute("name", key);
+ headerLabelNode.textContent = value;
+ if (key == "count") {
+ headerLabelNode.classList.add("offscreen");
+ }
+ headerBoxNode.appendChild(headerLabelNode);
+ }
+
+ tableNode.append(headerNode, bodyNode);
+
+ for (const rowInfo of data) {
+ const rowNode = document.createElement("tr");
+ rowNode.className = "table-chart-row";
+ rowNode.setAttribute("data-statistic-name", rowInfo.label);
+
+ for (const [key, value] of Object.entries(rowInfo)) {
+ // Don't render the "cached" column. We only have it in here so it can be displayed
+ // in the `totals` section.
+ if (key == "cached") {
+ continue;
+ }
+ const index = data.indexOf(rowInfo);
+ const stringified = strings[key] ? strings[key](value, index) : value;
+ const labelNode = document.createElement("td");
+ labelNode.className = "plain table-chart-row-label";
+ labelNode.setAttribute("name", key);
+ labelNode.textContent = stringified;
+ rowNode.appendChild(labelNode);
+ }
+
+ proxy.rows.set(rowInfo, rowNode);
+ delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo);
+ bodyNode.appendChild(rowNode);
+ }
+
+ const totalsNode = document.createElement("div");
+ totalsNode.className = "table-chart-totals";
+
+ for (const [key, value] of Object.entries(totals)) {
+ const total = data.reduce((acc, e) => acc + e[key], 0);
+ const stringified = value ? value(total || 0) : total;
+ const labelNode = document.createElement("span");
+ labelNode.className = "plain table-chart-summary-label";
+ labelNode.setAttribute("name", key);
+ labelNode.textContent = stringified;
+ totalsNode.appendChild(labelNode);
+ }
+
+ container.appendChild(totalsNode);
+
+ return proxy;
+}
+
+function loadingPieChartData() {
+ return [{ size: 1, label: L10N.getStr("pieChart.loading") }];
+}
+
+function emptyPieChartData() {
+ return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }];
+}
+
+function loadingTableChartData() {
+ return [{ size: "", label: L10N.getStr("tableChart.loading") }];
+}
+
+function emptyTableChartData() {
+ return [{ size: "", label: L10N.getStr("tableChart.unavailable") }];
+}
+
+/**
+ * Delegates DOM events emitted by a Node to an EventEmitter proxy.
+ *
+ * @param EventEmitter emitter
+ * The event emitter proxy instance.
+ * @param array events
+ * An array of events, e.g. ["mouseover", "mouseout"].
+ * @param Node node
+ * The element firing the DOM events.
+ * @param any args
+ * The arguments passed when emitting events through the proxy.
+ */
+function delegate(emitter, events, node, args) {
+ for (const event of events) {
+ node.addEventListener(event, emitter.emit.bind(emitter, event, args));
+ }
+}
+
+exports.Chart = Chart;
diff --git a/devtools/client/shared/widgets/CubicBezierPresets.js b/devtools/client/shared/widgets/CubicBezierPresets.js
new file mode 100644
index 0000000000..7422843d88
--- /dev/null
+++ b/devtools/client/shared/widgets/CubicBezierPresets.js
@@ -0,0 +1,64 @@
+/**
+ * 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/.
+ */
+
+// Set of preset definitions for use with CubicBezierWidget
+// Credit: http://easings.net
+
+"use strict";
+
+const PREDEFINED = {
+ ease: [0.25, 0.1, 0.25, 1],
+ linear: [0, 0, 1, 1],
+ "ease-in": [0.42, 0, 1, 1],
+ "ease-out": [0, 0, 0.58, 1],
+ "ease-in-out": [0.42, 0, 0.58, 1],
+};
+
+const PRESETS = {
+ "ease-in": {
+ "ease-in-linear": [0, 0, 1, 1],
+ "ease-in-ease-in": [0.42, 0, 1, 1],
+ "ease-in-sine": [0.47, 0, 0.74, 0.71],
+ "ease-in-quadratic": [0.55, 0.09, 0.68, 0.53],
+ "ease-in-cubic": [0.55, 0.06, 0.68, 0.19],
+ "ease-in-quartic": [0.9, 0.03, 0.69, 0.22],
+ "ease-in-quintic": [0.76, 0.05, 0.86, 0.06],
+ "ease-in-exponential": [0.95, 0.05, 0.8, 0.04],
+ "ease-in-circular": [0.6, 0.04, 0.98, 0.34],
+ "ease-in-backward": [0.6, -0.28, 0.74, 0.05],
+ },
+ "ease-out": {
+ "ease-out-linear": [0, 0, 1, 1],
+ "ease-out-ease-out": [0, 0, 0.58, 1],
+ "ease-out-sine": [0.39, 0.58, 0.57, 1],
+ "ease-out-quadratic": [0.25, 0.46, 0.45, 0.94],
+ "ease-out-cubic": [0.22, 0.61, 0.36, 1],
+ "ease-out-quartic": [0.17, 0.84, 0.44, 1],
+ "ease-out-quintic": [0.23, 1, 0.32, 1],
+ "ease-out-exponential": [0.19, 1, 0.22, 1],
+ "ease-out-circular": [0.08, 0.82, 0.17, 1],
+ "ease-out-backward": [0.18, 0.89, 0.32, 1.28],
+ },
+ "ease-in-out": {
+ "ease-in-out-linear": [0, 0, 1, 1],
+ "ease-in-out-ease": [0.25, 0.1, 0.25, 1],
+ "ease-in-out-ease-in-out": [0.42, 0, 0.58, 1],
+ "ease-in-out-sine": [0.45, 0.05, 0.55, 0.95],
+ "ease-in-out-quadratic": [0.46, 0.03, 0.52, 0.96],
+ "ease-in-out-cubic": [0.65, 0.05, 0.36, 1],
+ "ease-in-out-quartic": [0.77, 0, 0.18, 1],
+ "ease-in-out-quintic": [0.86, 0, 0.07, 1],
+ "ease-in-out-exponential": [1, 0, 0, 1],
+ "ease-in-out-circular": [0.79, 0.14, 0.15, 0.86],
+ "ease-in-out-backward": [0.68, -0.55, 0.27, 1.55],
+ },
+};
+
+const DEFAULT_PRESET_CATEGORY = Object.keys(PRESETS)[0];
+
+exports.PRESETS = PRESETS;
+exports.PREDEFINED = PREDEFINED;
+exports.DEFAULT_PRESET_CATEGORY = DEFAULT_PRESET_CATEGORY;
diff --git a/devtools/client/shared/widgets/CubicBezierWidget.js b/devtools/client/shared/widgets/CubicBezierWidget.js
new file mode 100644
index 0000000000..39407d4711
--- /dev/null
+++ b/devtools/client/shared/widgets/CubicBezierWidget.js
@@ -0,0 +1,986 @@
+/**
+ * Copyright (c) 2013 Lea Verou. All rights reserved.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+// Based on www.cubic-bezier.com by Lea Verou
+// See https://github.com/LeaVerou/cubic-bezier
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ PREDEFINED,
+ PRESETS,
+ DEFAULT_PRESET_CATEGORY,
+} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js");
+const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * CubicBezier data structure helper
+ * Accepts an array of coordinates and exposes a few useful getters
+ * @param {Array} coordinates i.e. [.42, 0, .58, 1]
+ */
+function CubicBezier(coordinates) {
+ if (!coordinates) {
+ throw new Error("No offsets were defined");
+ }
+
+ this.coordinates = coordinates.map(n => +n);
+
+ for (let i = 4; i--; ) {
+ const xy = this.coordinates[i];
+ if (isNaN(xy) || (!(i % 2) && (xy < 0 || xy > 1))) {
+ throw new Error(`Wrong coordinate at ${i}(${xy})`);
+ }
+ }
+
+ this.coordinates.toString = function () {
+ return (
+ this.map(n => {
+ return (Math.round(n * 100) / 100 + "").replace(/^0\./, ".");
+ }) + ""
+ );
+ };
+}
+
+exports.CubicBezier = CubicBezier;
+
+CubicBezier.prototype = {
+ get P1() {
+ return this.coordinates.slice(0, 2);
+ },
+
+ get P2() {
+ return this.coordinates.slice(2);
+ },
+
+ toString() {
+ // Check first if current coords are one of css predefined functions
+ const predefName = Object.keys(PREDEFINED).find(key =>
+ coordsAreEqual(PREDEFINED[key], this.coordinates)
+ );
+
+ return predefName || "cubic-bezier(" + this.coordinates + ")";
+ },
+};
+
+/**
+ * Bezier curve canvas plotting class
+ * @param {DOMNode} canvas
+ * @param {CubicBezier} bezier
+ * @param {Array} padding Amount of horizontal,vertical padding around the graph
+ */
+function BezierCanvas(canvas, bezier, padding) {
+ this.canvas = canvas;
+ this.bezier = bezier;
+ this.padding = getPadding(padding);
+
+ // Convert to a cartesian coordinate system with axes from 0 to 1
+ this.ctx = this.canvas.getContext("2d");
+ const p = this.padding;
+
+ this.ctx.scale(
+ canvas.width * (1 - p[1] - p[3]),
+ -canvas.height * (1 - p[0] - p[2])
+ );
+ this.ctx.translate(p[3] / (1 - p[1] - p[3]), -1 - p[0] / (1 - p[0] - p[2]));
+}
+
+exports.BezierCanvas = BezierCanvas;
+
+BezierCanvas.prototype = {
+ /**
+ * Get P1 and P2 current top/left offsets so they can be positioned
+ * @return {Array} Returns an array of 2 {top:String,left:String} objects
+ */
+ get offsets() {
+ const p = this.padding,
+ w = this.canvas.width,
+ h = this.canvas.height;
+
+ return [
+ {
+ left:
+ w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + "px",
+ top:
+ h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0]) +
+ "px",
+ },
+ {
+ left:
+ w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + "px",
+ top:
+ h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) +
+ "px",
+ },
+ ];
+ },
+
+ /**
+ * Convert an element's left/top offsets into coordinates
+ */
+ offsetsToCoordinates(element) {
+ const w = this.canvas.width,
+ h = this.canvas.height;
+
+ // Convert padding percentage to actual padding
+ const p = this.padding.map((a, i) => a * (i % 2 ? w : h));
+
+ return [
+ (parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]),
+ (h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2]),
+ ];
+ },
+
+ /**
+ * Draw the cubic bezier curve for the current coordinates
+ */
+ plot(settings = {}) {
+ const xy = this.bezier.coordinates;
+
+ const defaultSettings = {
+ handleColor: "#666",
+ handleThickness: 0.008,
+ bezierColor: "#4C9ED9",
+ bezierThickness: 0.015,
+ drawHandles: true,
+ };
+
+ for (const setting in settings) {
+ defaultSettings[setting] = settings[setting];
+ }
+
+ // Clear the canvas –making sure to clear the
+ // whole area by resetting the transform first.
+ this.ctx.save();
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ this.ctx.restore();
+
+ if (defaultSettings.drawHandles) {
+ // Draw control handles
+ this.ctx.beginPath();
+ this.ctx.fillStyle = defaultSettings.handleColor;
+ this.ctx.lineWidth = defaultSettings.handleThickness;
+ this.ctx.strokeStyle = defaultSettings.handleColor;
+
+ this.ctx.moveTo(0, 0);
+ this.ctx.lineTo(xy[0], xy[1]);
+ this.ctx.moveTo(1, 1);
+ this.ctx.lineTo(xy[2], xy[3]);
+
+ this.ctx.stroke();
+ this.ctx.closePath();
+
+ const circle = (ctx, cx, cy, r) => {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, 2 * Math.PI, !1);
+ ctx.closePath();
+ };
+
+ circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness);
+ this.ctx.fill();
+ circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness);
+ this.ctx.fill();
+ }
+
+ // Draw bezier curve
+ this.ctx.beginPath();
+ this.ctx.lineWidth = defaultSettings.bezierThickness;
+ this.ctx.strokeStyle = defaultSettings.bezierColor;
+ this.ctx.moveTo(0, 0);
+ this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1);
+ this.ctx.stroke();
+ this.ctx.closePath();
+ },
+};
+
+/**
+ * Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and
+ * adds the control points and user interaction
+ * @param {DOMNode} parent The container where the graph should be created
+ * @param {Array} coordinates Coordinates of the curve to be drawn
+ *
+ * Emits "updated" events whenever the curve is changed. Along with the event is
+ * sent a CubicBezier object
+ */
+function CubicBezierWidget(
+ parent,
+ coordinates = PRESETS["ease-in"]["ease-in-sine"]
+) {
+ EventEmitter.decorate(this);
+
+ this.parent = parent;
+ const { curve, p1, p2 } = this._initMarkup();
+
+ this.curveBoundingBox = curve.getBoundingClientRect();
+ this.curve = curve;
+ this.p1 = p1;
+ this.p2 = p2;
+
+ // Create and plot the bezier curve
+ this.bezierCanvas = new BezierCanvas(
+ this.curve,
+ new CubicBezier(coordinates),
+ [0.3, 0]
+ );
+ this.bezierCanvas.plot();
+
+ // Place the control points
+ const offsets = this.bezierCanvas.offsets;
+ this.p1.style.left = offsets[0].left;
+ this.p1.style.top = offsets[0].top;
+ this.p2.style.left = offsets[1].left;
+ this.p2.style.top = offsets[1].top;
+
+ this._onPointMouseDown = this._onPointMouseDown.bind(this);
+ this._onPointKeyDown = this._onPointKeyDown.bind(this);
+ this._onCurveClick = this._onCurveClick.bind(this);
+ this._onNewCoordinates = this._onNewCoordinates.bind(this);
+ this.onPrefersReducedMotionChange =
+ this.onPrefersReducedMotionChange.bind(this);
+
+ // Add preset preview menu
+ this.presets = new CubicBezierPresetWidget(parent);
+
+ // Add the timing function previewer
+ // if prefers-reduced-motion is not set
+ this.reducedMotion = parent.ownerGlobal.matchMedia(
+ "(prefers-reduced-motion)"
+ );
+ if (!this.reducedMotion.matches) {
+ this.timingPreview = new TimingFunctionPreviewWidget(parent);
+ }
+
+ // add event listener to change prefers-reduced-motion
+ // of the timing function preview during runtime
+ this.reducedMotion.addEventListener(
+ "change",
+ this.onPrefersReducedMotionChange
+ );
+
+ this._initEvents();
+}
+
+exports.CubicBezierWidget = CubicBezierWidget;
+
+CubicBezierWidget.prototype = {
+ _initMarkup() {
+ const doc = this.parent.ownerDocument;
+
+ const wrap = doc.createElementNS(XHTML_NS, "div");
+ wrap.className = "display-wrap";
+
+ const plane = doc.createElementNS(XHTML_NS, "div");
+ plane.className = "coordinate-plane";
+
+ const p1 = doc.createElementNS(XHTML_NS, "button");
+ p1.className = "control-point";
+ plane.appendChild(p1);
+
+ const p2 = doc.createElementNS(XHTML_NS, "button");
+ p2.className = "control-point";
+ plane.appendChild(p2);
+
+ const curve = doc.createElementNS(XHTML_NS, "canvas");
+ curve.setAttribute("width", 150);
+ curve.setAttribute("height", 370);
+ curve.className = "curve";
+
+ plane.appendChild(curve);
+ wrap.appendChild(plane);
+
+ this.parent.appendChild(wrap);
+
+ return {
+ p1,
+ p2,
+ curve,
+ };
+ },
+
+ onPrefersReducedMotionChange(event) {
+ // if prefers-reduced-motion is enabled destroy timing function preview
+ // else create it if it does not exist
+ if (event.matches) {
+ if (this.timingPreview) {
+ this.timingPreview.destroy();
+ }
+ this.timingPreview = undefined;
+ } else if (!this.timingPreview) {
+ this.timingPreview = new TimingFunctionPreviewWidget(this.parent);
+ }
+ },
+
+ _removeMarkup() {
+ this.parent.querySelector(".display-wrap").remove();
+ },
+
+ _initEvents() {
+ this.p1.addEventListener("mousedown", this._onPointMouseDown);
+ this.p2.addEventListener("mousedown", this._onPointMouseDown);
+
+ this.p1.addEventListener("keydown", this._onPointKeyDown);
+ this.p2.addEventListener("keydown", this._onPointKeyDown);
+
+ this.curve.addEventListener("click", this._onCurveClick);
+
+ this.presets.on("new-coordinates", this._onNewCoordinates);
+ },
+
+ _removeEvents() {
+ this.p1.removeEventListener("mousedown", this._onPointMouseDown);
+ this.p2.removeEventListener("mousedown", this._onPointMouseDown);
+
+ this.p1.removeEventListener("keydown", this._onPointKeyDown);
+ this.p2.removeEventListener("keydown", this._onPointKeyDown);
+
+ this.curve.removeEventListener("click", this._onCurveClick);
+
+ this.presets.off("new-coordinates", this._onNewCoordinates);
+ },
+
+ _onPointMouseDown(event) {
+ // Updating the boundingbox in case it has changed
+ this.curveBoundingBox = this.curve.getBoundingClientRect();
+
+ const point = event.target;
+ const doc = point.ownerDocument;
+ const self = this;
+
+ doc.onmousemove = function drag(e) {
+ let x = e.pageX;
+ const y = e.pageY;
+ const left = self.curveBoundingBox.left;
+ const top = self.curveBoundingBox.top;
+
+ if (x === 0 && y == 0) {
+ return;
+ }
+
+ // Constrain x
+ x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width);
+
+ point.style.left = x - left + "px";
+ point.style.top = y - top + "px";
+
+ self._updateFromPoints();
+ };
+
+ doc.onmouseup = function () {
+ point.focus();
+ doc.onmousemove = doc.onmouseup = null;
+ };
+ },
+
+ _onPointKeyDown(event) {
+ const point = event.target;
+ const code = event.keyCode;
+
+ if (code >= 37 && code <= 40) {
+ event.preventDefault();
+
+ // Arrow keys pressed
+ const left = parseInt(point.style.left, 10);
+ const top = parseInt(point.style.top, 10);
+ const offset = 3 * (event.shiftKey ? 10 : 1);
+
+ switch (code) {
+ case 37:
+ point.style.left = left - offset + "px";
+ break;
+ case 38:
+ point.style.top = top - offset + "px";
+ break;
+ case 39:
+ point.style.left = left + offset + "px";
+ break;
+ case 40:
+ point.style.top = top + offset + "px";
+ break;
+ }
+
+ this._updateFromPoints();
+ }
+ },
+
+ _onCurveClick(event) {
+ this.curveBoundingBox = this.curve.getBoundingClientRect();
+
+ const left = this.curveBoundingBox.left;
+ const top = this.curveBoundingBox.top;
+ const x = event.pageX - left;
+ const y = event.pageY - top;
+
+ // Find which point is closer
+ const distP1 = distance(
+ x,
+ y,
+ parseInt(this.p1.style.left, 10),
+ parseInt(this.p1.style.top, 10)
+ );
+ const distP2 = distance(
+ x,
+ y,
+ parseInt(this.p2.style.left, 10),
+ parseInt(this.p2.style.top, 10)
+ );
+
+ const point = distP1 < distP2 ? this.p1 : this.p2;
+ point.style.left = x + "px";
+ point.style.top = y + "px";
+
+ this._updateFromPoints();
+ },
+
+ _onNewCoordinates(coordinates) {
+ this.coordinates = coordinates;
+ },
+
+ /**
+ * Get the current point coordinates and redraw the curve to match
+ */
+ _updateFromPoints() {
+ // Get the new coordinates from the point's offsets
+ let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1);
+ coordinates = coordinates.concat(
+ this.bezierCanvas.offsetsToCoordinates(this.p2)
+ );
+
+ this.presets.refreshMenu(coordinates);
+ this._redraw(coordinates);
+ },
+
+ /**
+ * Redraw the curve
+ * @param {Array} coordinates The array of control point coordinates
+ */
+ _redraw(coordinates) {
+ // Provide a new CubicBezier to the canvas and plot the curve
+ this.bezierCanvas.bezier = new CubicBezier(coordinates);
+ this.bezierCanvas.plot();
+ this.emit("updated", this.bezierCanvas.bezier);
+
+ if (this.timingPreview) {
+ this.timingPreview.preview(this.bezierCanvas.bezier.toString());
+ }
+ },
+
+ /**
+ * Set new coordinates for the control points and redraw the curve
+ * @param {Array} coordinates
+ */
+ set coordinates(coordinates) {
+ this._redraw(coordinates);
+
+ // Move the points
+ const offsets = this.bezierCanvas.offsets;
+ this.p1.style.left = offsets[0].left;
+ this.p1.style.top = offsets[0].top;
+ this.p2.style.left = offsets[1].left;
+ this.p2.style.top = offsets[1].top;
+ },
+
+ /**
+ * Set new coordinates for the control point and redraw the curve
+ * @param {String} value A string value. E.g. "linear",
+ * "cubic-bezier(0,0,1,1)"
+ */
+ set cssCubicBezierValue(value) {
+ if (!value) {
+ return;
+ }
+
+ value = value.trim();
+
+ // Try with one of the predefined values
+ const coordinates = parseTimingFunction(value);
+
+ this.presets.refreshMenu(coordinates);
+ this.coordinates = coordinates;
+ },
+
+ destroy() {
+ this._removeEvents();
+ this._removeMarkup();
+
+ // remove prefers-reduced-motion event listener
+ this.reducedMotion.removeEventListener(
+ "change",
+ this.onPrefersReducedMotionChange
+ );
+ this.reducedMotion = null;
+
+ if (this.timingPreview) {
+ this.timingPreview.destroy();
+ this.timingPreview = null;
+ }
+ this.presets.destroy();
+
+ this.curve = this.p1 = this.p2 = null;
+ },
+};
+
+/**
+ * CubicBezierPreset widget.
+ * Builds a menu of presets from CubicBezierPresets
+ * @param {DOMNode} parent The container where the preset panel should be
+ * created
+ *
+ * Emits "new-coordinate" event along with the coordinates
+ * whenever a preset is selected.
+ */
+function CubicBezierPresetWidget(parent) {
+ this.parent = parent;
+
+ const { presetPane, presets, categories } = this._initMarkup();
+ this.presetPane = presetPane;
+ this.presets = presets;
+ this.categories = categories;
+
+ this._activeCategory = null;
+ this._activePresetList = null;
+ this._activePreset = null;
+
+ this._onCategoryClick = this._onCategoryClick.bind(this);
+ this._onPresetClick = this._onPresetClick.bind(this);
+
+ EventEmitter.decorate(this);
+ this._initEvents();
+}
+
+exports.CubicBezierPresetWidget = CubicBezierPresetWidget;
+
+CubicBezierPresetWidget.prototype = {
+ /*
+ * Constructs a list of all preset categories and a list
+ * of presets for each category.
+ *
+ * High level markup:
+ * div .preset-pane
+ * div .preset-categories
+ * div .category
+ * div .category
+ * ...
+ * div .preset-container
+ * div .presetList
+ * div .preset
+ * ...
+ * div .presetList
+ * div .preset
+ * ...
+ */
+ _initMarkup() {
+ const doc = this.parent.ownerDocument;
+
+ const presetPane = doc.createElementNS(XHTML_NS, "div");
+ presetPane.className = "preset-pane";
+
+ const categoryList = doc.createElementNS(XHTML_NS, "div");
+ categoryList.id = "preset-categories";
+
+ const presetContainer = doc.createElementNS(XHTML_NS, "div");
+ presetContainer.id = "preset-container";
+
+ Object.keys(PRESETS).forEach(categoryLabel => {
+ const category = this._createCategory(categoryLabel);
+ categoryList.appendChild(category);
+
+ const presetList = this._createPresetList(categoryLabel);
+ presetContainer.appendChild(presetList);
+ });
+
+ presetPane.appendChild(categoryList);
+ presetPane.appendChild(presetContainer);
+
+ this.parent.appendChild(presetPane);
+
+ const allCategories = presetPane.querySelectorAll(".category");
+ const allPresets = presetPane.querySelectorAll(".preset");
+
+ return {
+ presetPane,
+ presets: allPresets,
+ categories: allCategories,
+ };
+ },
+
+ _createCategory(categoryLabel) {
+ const doc = this.parent.ownerDocument;
+
+ const category = doc.createElementNS(XHTML_NS, "div");
+ category.id = categoryLabel;
+ category.classList.add("category");
+
+ const categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel);
+ category.textContent = categoryDisplayLabel;
+ category.setAttribute("title", categoryDisplayLabel);
+
+ return category;
+ },
+
+ _normalizeCategoryLabel(categoryLabel) {
+ return categoryLabel.replace("/-/g", " ");
+ },
+
+ _createPresetList(categoryLabel) {
+ const doc = this.parent.ownerDocument;
+
+ const presetList = doc.createElementNS(XHTML_NS, "div");
+ presetList.id = "preset-category-" + categoryLabel;
+ presetList.classList.add("preset-list");
+
+ Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
+ const preset = this._createPreset(categoryLabel, presetLabel);
+ presetList.appendChild(preset);
+ });
+
+ return presetList;
+ },
+
+ _createPreset(categoryLabel, presetLabel) {
+ const doc = this.parent.ownerDocument;
+
+ const preset = doc.createElementNS(XHTML_NS, "div");
+ preset.classList.add("preset");
+ preset.id = presetLabel;
+ preset.coordinates = PRESETS[categoryLabel][presetLabel];
+ // Create preset preview
+ const curve = doc.createElementNS(XHTML_NS, "canvas");
+ const bezier = new CubicBezier(preset.coordinates);
+ curve.setAttribute("height", 50);
+ curve.setAttribute("width", 50);
+ preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]);
+ preset.bezierCanvas.plot({
+ drawHandles: false,
+ bezierThickness: 0.025,
+ });
+ preset.appendChild(curve);
+
+ // Create preset label
+ const presetLabelElem = doc.createElementNS(XHTML_NS, "p");
+ const presetDisplayLabel = this._normalizePresetLabel(
+ categoryLabel,
+ presetLabel
+ );
+ presetLabelElem.textContent = presetDisplayLabel;
+ preset.appendChild(presetLabelElem);
+ preset.setAttribute("title", presetDisplayLabel);
+
+ return preset;
+ },
+
+ _normalizePresetLabel(categoryLabel, presetLabel) {
+ return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " ");
+ },
+
+ _initEvents() {
+ for (const category of this.categories) {
+ category.addEventListener("click", this._onCategoryClick);
+ }
+
+ for (const preset of this.presets) {
+ preset.addEventListener("click", this._onPresetClick);
+ }
+ },
+
+ _removeEvents() {
+ for (const category of this.categories) {
+ category.removeEventListener("click", this._onCategoryClick);
+ }
+
+ for (const preset of this.presets) {
+ preset.removeEventListener("click", this._onPresetClick);
+ }
+ },
+
+ _onPresetClick(event) {
+ this.emit("new-coordinates", event.currentTarget.coordinates);
+ this.activePreset = event.currentTarget;
+ },
+
+ _onCategoryClick(event) {
+ this.activeCategory = event.target;
+ },
+
+ _setActivePresetList(presetListId) {
+ const presetList = this.presetPane.querySelector("#" + presetListId);
+ swapClassName("active-preset-list", this._activePresetList, presetList);
+ this._activePresetList = presetList;
+ },
+
+ set activeCategory(category) {
+ swapClassName("active-category", this._activeCategory, category);
+ this._activeCategory = category;
+ this._setActivePresetList("preset-category-" + category.id);
+ },
+
+ get activeCategory() {
+ return this._activeCategory;
+ },
+
+ set activePreset(preset) {
+ swapClassName("active-preset", this._activePreset, preset);
+ this._activePreset = preset;
+ },
+
+ get activePreset() {
+ return this._activePreset;
+ },
+
+ /**
+ * Called by CubicBezierWidget onload and when
+ * the curve is modified via the canvas.
+ * Attempts to match the new user setting with an
+ * existing preset.
+ * @param {Array} coordinates new coords [i, j, k, l]
+ */
+ refreshMenu(coordinates) {
+ // If we cannot find a matching preset, keep
+ // menu on last known preset category.
+ let category = this._activeCategory;
+
+ // If we cannot find a matching preset
+ // deselect any selected preset.
+ let preset = null;
+
+ // If a category has never been viewed before
+ // show the default category.
+ if (!category) {
+ category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY);
+ }
+
+ // If the new coordinates do match a preset,
+ // set its category and preset button as active.
+ Object.keys(PRESETS).forEach(categoryLabel => {
+ Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
+ if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) {
+ category = this.parent.querySelector("#" + categoryLabel);
+ preset = this.parent.querySelector("#" + presetLabel);
+ }
+ });
+ });
+
+ this.activeCategory = category;
+ this.activePreset = preset;
+ },
+
+ destroy() {
+ this._removeEvents();
+ this.parent.querySelector(".preset-pane").remove();
+ },
+};
+
+/**
+ * The TimingFunctionPreviewWidget animates a dot on a scale with a given
+ * timing-function
+ * @param {DOMNode} parent The container where this widget should go
+ */
+function TimingFunctionPreviewWidget(parent) {
+ this.previousValue = null;
+
+ this.parent = parent;
+ this._initMarkup();
+}
+
+TimingFunctionPreviewWidget.prototype = {
+ PREVIEW_DURATION: 1000,
+
+ _initMarkup() {
+ const doc = this.parent.ownerDocument;
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "timing-function-preview";
+
+ this.dot = doc.createElementNS(XHTML_NS, "div");
+ this.dot.className = "dot";
+ container.appendChild(this.dot);
+
+ const scale = doc.createElementNS(XHTML_NS, "div");
+ scale.className = "scale";
+ container.appendChild(scale);
+
+ this.parent.appendChild(container);
+ },
+
+ destroy() {
+ this.dot.getAnimations().forEach(anim => anim.cancel());
+ this.parent.querySelector(".timing-function-preview").remove();
+ this.parent = this.dot = null;
+ },
+
+ /**
+ * Preview a new timing function. The current preview will only be stopped if
+ * the supplied function value is different from the previous one. If the
+ * supplied function is invalid, the preview will stop.
+ * @param {String} value
+ */
+ preview(value) {
+ // Don't restart the preview animation if the value is the same
+ if (value === this.previousValue) {
+ return;
+ }
+
+ if (parseTimingFunction(value)) {
+ this.restartAnimation(value);
+ }
+
+ this.previousValue = value;
+ },
+
+ /**
+ * Re-start the preview animation from the beginning.
+ * @param {String} timingFunction The value for the timing-function.
+ */
+ restartAnimation(timingFunction) {
+ // Cancel the previous animation if there was any.
+ this.dot.getAnimations().forEach(anim => anim.cancel());
+
+ // And start the new one.
+ // The animation consists of a few keyframes that move the dot to the right of the
+ // container, and then move it back to the left.
+ // It also contains some pause where the dot is semi transparent, before it moves to
+ // the right, and once again, before it comes back to the left.
+ // The timing function passed to this function is applied to the keyframes that
+ // actually move the dot. This way it can be previewed in both direction, instead of
+ // being spread over the whole animation.
+ this.dot.animate(
+ [
+ { left: "-7px", opacity: 0.5, offset: 0 },
+ { left: "-7px", opacity: 0.5, offset: 0.19 },
+ { left: "-7px", opacity: 1, offset: 0.2, easing: timingFunction },
+ { left: "143px", opacity: 1, offset: 0.5 },
+ { left: "143px", opacity: 0.5, offset: 0.51 },
+ { left: "143px", opacity: 0.5, offset: 0.7 },
+ { left: "143px", opacity: 1, offset: 0.71, easing: timingFunction },
+ { left: "-7px", opacity: 1, offset: 1 },
+ ],
+ {
+ duration: this.PREVIEW_DURATION * 2,
+ iterations: Infinity,
+ }
+ );
+ },
+};
+
+// Helpers
+
+function getPadding(padding) {
+ const p = typeof padding === "number" ? [padding] : padding;
+
+ if (p.length === 1) {
+ p[1] = p[0];
+ }
+
+ if (p.length === 2) {
+ p[2] = p[0];
+ }
+
+ if (p.length === 3) {
+ p[3] = p[1];
+ }
+
+ return p;
+}
+
+function distance(x1, y1, x2, y2) {
+ return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
+}
+
+/**
+ * Parse a string to see whether it is a valid timing function.
+ * If it is, return the coordinates as an array.
+ * Otherwise, return undefined.
+ * @param {String} value
+ * @return {Array} of coordinates, or undefined
+ */
+function parseTimingFunction(value) {
+ if (value in PREDEFINED) {
+ return PREDEFINED[value];
+ }
+
+ const tokenStream = getCSSLexer(value);
+ const getNextToken = () => {
+ while (true) {
+ const token = tokenStream.nextToken();
+ if (
+ !token ||
+ (token.tokenType !== "whitespace" && token.tokenType !== "comment")
+ ) {
+ return token;
+ }
+ }
+ };
+
+ let token = getNextToken();
+ if (token.tokenType !== "function" || token.text !== "cubic-bezier") {
+ return undefined;
+ }
+
+ const result = [];
+ for (let i = 0; i < 4; ++i) {
+ token = getNextToken();
+ if (!token || token.tokenType !== "number") {
+ return undefined;
+ }
+ result.push(token.number);
+
+ token = getNextToken();
+ if (
+ !token ||
+ token.tokenType !== "symbol" ||
+ token.text !== (i == 3 ? ")" : ",")
+ ) {
+ return undefined;
+ }
+ }
+
+ return result;
+}
+
+exports.parseTimingFunction = parseTimingFunction;
+
+/**
+ * Removes a class from a node and adds it to another.
+ * @param {String} className the class to swap
+ * @param {DOMNode} from the node to remove the class from
+ * @param {DOMNode} to the node to add the class to
+ */
+function swapClassName(className, from, to) {
+ if (from !== null) {
+ from.classList.remove(className);
+ }
+
+ if (to !== null) {
+ to.classList.add(className);
+ }
+}
+
+/**
+ * Compares two arrays of coordinates [i, j, k, l]
+ * @param {Array} c1 first coordinate array to compare
+ * @param {Array} c2 second coordinate array to compare
+ * @return {Boolean}
+ */
+function coordsAreEqual(c1, c2) {
+ return c1.reduce((prev, curr, index) => prev && curr === c2[index], true);
+}
diff --git a/devtools/client/shared/widgets/FilterWidget.js b/devtools/client/shared/widgets/FilterWidget.js
new file mode 100644
index 0000000000..bb23bdfeca
--- /dev/null
+++ b/devtools/client/shared/widgets/FilterWidget.js
@@ -0,0 +1,1131 @@
+/* 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";
+
+/**
+ * This is a CSS Filter Editor widget used
+ * for Rule View's filter swatches
+ */
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const STRINGS_URI = "devtools/client/locales/filterwidget.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+const {
+ cssTokenizer,
+} = require("resource://devtools/shared/css/parsing-utils.js");
+
+const asyncStorage = require("resource://devtools/shared/async-storage.js");
+
+const DEFAULT_FILTER_TYPE = "length";
+const UNIT_MAPPING = {
+ percentage: "%",
+ length: "px",
+ angle: "deg",
+ string: "",
+};
+
+const FAST_VALUE_MULTIPLIER = 10;
+const SLOW_VALUE_MULTIPLIER = 0.1;
+const DEFAULT_VALUE_MULTIPLIER = 1;
+
+const LIST_PADDING = 7;
+const LIST_ITEM_HEIGHT = 32;
+
+const filterList = [
+ {
+ name: "blur",
+ range: [0, Infinity],
+ type: "length",
+ },
+ {
+ name: "brightness",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "contrast",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "drop-shadow",
+ placeholder: L10N.getStr("dropShadowPlaceholder"),
+ type: "string",
+ },
+ {
+ name: "grayscale",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "hue-rotate",
+ range: [0, Infinity],
+ type: "angle",
+ },
+ {
+ name: "invert",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "opacity",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "saturate",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "sepia",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "url",
+ placeholder: "example.svg#c1",
+ type: "string",
+ },
+];
+
+// Valid values that shouldn't be parsed for filters.
+const SPECIAL_VALUES = new Set(["none", "unset", "initial", "inherit"]);
+
+/**
+ * A CSS Filter editor widget used to add/remove/modify
+ * filters.
+ *
+ * Normally, it takes a CSS filter value as input, parses it
+ * and creates the required elements / bindings.
+ *
+ * You can, however, use add/remove/update methods manually.
+ * See each method's comments for more details
+ *
+ * @param {Node} el
+ * The widget container.
+ * @param {String} value
+ * CSS filter value
+ */
+function CSSFilterEditorWidget(el, value = "") {
+ this.doc = el.ownerDocument;
+ this.win = this.doc.defaultView;
+ this.el = el;
+ this._cssIsValid = (name, val) => {
+ return this.win.CSS.supports(name, val);
+ };
+
+ this._addButtonClick = this._addButtonClick.bind(this);
+ this._removeButtonClick = this._removeButtonClick.bind(this);
+ this._mouseMove = this._mouseMove.bind(this);
+ this._mouseUp = this._mouseUp.bind(this);
+ this._mouseDown = this._mouseDown.bind(this);
+ this._keyDown = this._keyDown.bind(this);
+ this._input = this._input.bind(this);
+ this._presetClick = this._presetClick.bind(this);
+ this._savePreset = this._savePreset.bind(this);
+ this._togglePresets = this._togglePresets.bind(this);
+ this._resetFocus = this._resetFocus.bind(this);
+
+ // Passed to asyncStorage, requires binding
+ this.renderPresets = this.renderPresets.bind(this);
+
+ this._initMarkup();
+ this._buildFilterItemMarkup();
+ this._buildPresetItemMarkup();
+ this._addEventListeners();
+
+ EventEmitter.decorate(this);
+
+ this.filters = [];
+ this.setCssValue(value);
+ this.renderPresets();
+}
+
+exports.CSSFilterEditorWidget = CSSFilterEditorWidget;
+
+CSSFilterEditorWidget.prototype = {
+ _initMarkup() {
+ // The following structure is created:
+ // <div class="filters-list">
+ // <div id="filters"></div>
+ // <div class="footer">
+ // <select value="">
+ // <option value="">${filterListSelectPlaceholder}</option>
+ // </select>
+ // <button id="add-filter" class="add">${addNewFilterButton}</button>
+ // <button id="toggle-presets">${presetsToggleButton}</button>
+ // </div>
+ // </div>
+ // <div class="presets-list">
+ // <div id="presets"></div>
+ // <div class="footer">
+ // <input value="" class="devtools-textinput"
+ // placeholder="${newPresetPlaceholder}"></input>
+ // <button class="add">${savePresetButton}</button>
+ // </div>
+ // </div>
+ const content = this.doc.createDocumentFragment();
+
+ const filterListWrapper = this.doc.createElementNS(XHTML_NS, "div");
+ filterListWrapper.classList.add("filters-list");
+ content.appendChild(filterListWrapper);
+
+ this.filterList = this.doc.createElementNS(XHTML_NS, "div");
+ this.filterList.setAttribute("id", "filters");
+ filterListWrapper.appendChild(this.filterList);
+
+ const filterListFooter = this.doc.createElementNS(XHTML_NS, "div");
+ filterListFooter.classList.add("footer");
+ filterListWrapper.appendChild(filterListFooter);
+
+ this.filterSelect = this.doc.createElementNS(XHTML_NS, "select");
+ this.filterSelect.setAttribute("value", "");
+ filterListFooter.appendChild(this.filterSelect);
+
+ const filterListPlaceholder = this.doc.createElementNS(XHTML_NS, "option");
+ filterListPlaceholder.setAttribute("value", "");
+ filterListPlaceholder.textContent = L10N.getStr(
+ "filterListSelectPlaceholder"
+ );
+ this.filterSelect.appendChild(filterListPlaceholder);
+
+ const addFilter = this.doc.createElementNS(XHTML_NS, "button");
+ addFilter.setAttribute("id", "add-filter");
+ addFilter.classList.add("add");
+ addFilter.textContent = L10N.getStr("addNewFilterButton");
+ filterListFooter.appendChild(addFilter);
+
+ this.togglePresets = this.doc.createElementNS(XHTML_NS, "button");
+ this.togglePresets.setAttribute("id", "toggle-presets");
+ this.togglePresets.textContent = L10N.getStr("presetsToggleButton");
+ filterListFooter.appendChild(this.togglePresets);
+
+ const presetListWrapper = this.doc.createElementNS(XHTML_NS, "div");
+ presetListWrapper.classList.add("presets-list");
+ content.appendChild(presetListWrapper);
+
+ this.presetList = this.doc.createElementNS(XHTML_NS, "div");
+ this.presetList.setAttribute("id", "presets");
+ presetListWrapper.appendChild(this.presetList);
+
+ const presetListFooter = this.doc.createElementNS(XHTML_NS, "div");
+ presetListFooter.classList.add("footer");
+ presetListWrapper.appendChild(presetListFooter);
+
+ this.addPresetInput = this.doc.createElementNS(XHTML_NS, "input");
+ this.addPresetInput.setAttribute("value", "");
+ this.addPresetInput.classList.add("devtools-textinput");
+ this.addPresetInput.setAttribute(
+ "placeholder",
+ L10N.getStr("newPresetPlaceholder")
+ );
+ presetListFooter.appendChild(this.addPresetInput);
+
+ this.addPresetButton = this.doc.createElementNS(XHTML_NS, "button");
+ this.addPresetButton.classList.add("add");
+ this.addPresetButton.textContent = L10N.getStr("savePresetButton");
+ presetListFooter.appendChild(this.addPresetButton);
+
+ this.el.appendChild(content);
+
+ this._populateFilterSelect();
+ },
+
+ _destroyMarkup() {
+ this._filterItemMarkup.remove();
+ this.el.remove();
+ this.el = this.filterList = this._filterItemMarkup = null;
+ this.presetList = this.togglePresets = this.filterSelect = null;
+ this.addPresetButton = null;
+ },
+
+ destroy() {
+ this._removeEventListeners();
+ this._destroyMarkup();
+ },
+
+ /**
+ * Creates <option> elements for each filter definition
+ * in filterList
+ */
+ _populateFilterSelect() {
+ const select = this.filterSelect;
+ filterList.forEach(filter => {
+ const option = this.doc.createElementNS(XHTML_NS, "option");
+ option.textContent = option.value = filter.name;
+ select.appendChild(option);
+ });
+ },
+
+ /**
+ * Creates a template for filter elements which is cloned and used in render
+ */
+ _buildFilterItemMarkup() {
+ const base = this.doc.createElementNS(XHTML_NS, "div");
+ base.className = "filter";
+
+ const name = this.doc.createElementNS(XHTML_NS, "div");
+ name.className = "filter-name";
+
+ const value = this.doc.createElementNS(XHTML_NS, "div");
+ value.className = "filter-value";
+
+ const drag = this.doc.createElementNS(XHTML_NS, "i");
+ drag.title = L10N.getStr("dragHandleTooltipText");
+
+ const label = this.doc.createElementNS(XHTML_NS, "label");
+
+ name.appendChild(drag);
+ name.appendChild(label);
+
+ const unitPreview = this.doc.createElementNS(XHTML_NS, "span");
+ const input = this.doc.createElementNS(XHTML_NS, "input");
+ input.classList.add("devtools-textinput");
+
+ value.appendChild(input);
+ value.appendChild(unitPreview);
+
+ const removeButton = this.doc.createElementNS(XHTML_NS, "button");
+ removeButton.className = "remove-button";
+
+ base.appendChild(name);
+ base.appendChild(value);
+ base.appendChild(removeButton);
+
+ this._filterItemMarkup = base;
+ },
+
+ _buildPresetItemMarkup() {
+ const base = this.doc.createElementNS(XHTML_NS, "div");
+ base.classList.add("preset");
+
+ const name = this.doc.createElementNS(XHTML_NS, "label");
+ base.appendChild(name);
+
+ const value = this.doc.createElementNS(XHTML_NS, "span");
+ base.appendChild(value);
+
+ const removeButton = this.doc.createElementNS(XHTML_NS, "button");
+ removeButton.classList.add("remove-button");
+
+ base.appendChild(removeButton);
+
+ this._presetItemMarkup = base;
+ },
+
+ _addEventListeners() {
+ this.addButton = this.el.querySelector("#add-filter");
+ this.addButton.addEventListener("click", this._addButtonClick);
+ this.filterList.addEventListener("click", this._removeButtonClick);
+ this.filterList.addEventListener("mousedown", this._mouseDown);
+ this.filterList.addEventListener("keydown", this._keyDown);
+ this.el.addEventListener("mousedown", this._resetFocus);
+
+ this.presetList.addEventListener("click", this._presetClick);
+ this.togglePresets.addEventListener("click", this._togglePresets);
+ this.addPresetButton.addEventListener("click", this._savePreset);
+
+ // These events are event delegators for
+ // drag-drop re-ordering and label-dragging
+ this.win.addEventListener("mousemove", this._mouseMove);
+ this.win.addEventListener("mouseup", this._mouseUp);
+
+ // Used to workaround float-precision problems
+ this.filterList.addEventListener("input", this._input);
+ },
+
+ _removeEventListeners() {
+ this.addButton.removeEventListener("click", this._addButtonClick);
+ this.filterList.removeEventListener("click", this._removeButtonClick);
+ this.filterList.removeEventListener("mousedown", this._mouseDown);
+ this.filterList.removeEventListener("keydown", this._keyDown);
+ this.el.removeEventListener("mousedown", this._resetFocus);
+
+ this.presetList.removeEventListener("click", this._presetClick);
+ this.togglePresets.removeEventListener("click", this._togglePresets);
+ this.addPresetButton.removeEventListener("click", this._savePreset);
+
+ // These events are used for drag drop re-ordering
+ this.win.removeEventListener("mousemove", this._mouseMove);
+ this.win.removeEventListener("mouseup", this._mouseUp);
+
+ // Used to workaround float-precision problems
+ this.filterList.removeEventListener("input", this._input);
+ },
+
+ _getFilterElementIndex(el) {
+ return [...this.filterList.children].indexOf(el);
+ },
+
+ _keyDown(e) {
+ if (
+ e.target.tagName.toLowerCase() !== "input" ||
+ (e.keyCode !== 40 && e.keyCode !== 38)
+ ) {
+ return;
+ }
+ const input = e.target;
+
+ const direction = e.keyCode === 40 ? -1 : 1;
+
+ let multiplier = DEFAULT_VALUE_MULTIPLIER;
+ if (e.altKey) {
+ multiplier = SLOW_VALUE_MULTIPLIER;
+ } else if (e.shiftKey) {
+ multiplier = FAST_VALUE_MULTIPLIER;
+ }
+
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ const filter = this.filters[index];
+
+ // Filters that have units are number-type filters. For them,
+ // the value can be incremented/decremented simply.
+ // For other types of filters (e.g. drop-shadow) we need to check
+ // if the keydown happened close to a number first.
+ if (filter.unit) {
+ const startValue = parseFloat(e.target.value);
+ let value = startValue + direction * multiplier;
+
+ const [min, max] = this._definition(filter.name).range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+
+ input.value = fixFloat(value);
+
+ this.updateValueAt(index, value);
+ } else {
+ let selectionStart = input.selectionStart;
+ const num = getNeighbourNumber(input.value, selectionStart);
+ if (!num) {
+ return;
+ }
+
+ let { start, end, value } = num;
+
+ const split = input.value.split("");
+ let computed = fixFloat(value + direction * multiplier);
+ const dotIndex = computed.indexOf(".0");
+ if (dotIndex > -1) {
+ computed = computed.slice(0, -2);
+
+ selectionStart =
+ selectionStart > start + dotIndex ? start + dotIndex : selectionStart;
+ }
+ split.splice(start, end - start, computed);
+
+ value = split.join("");
+ input.value = value;
+ this.updateValueAt(index, value);
+ input.setSelectionRange(selectionStart, selectionStart);
+ }
+ e.preventDefault();
+ },
+
+ _input(e) {
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ const filter = this.filters[index];
+ const def = this._definition(filter.name);
+
+ if (def.type !== "string") {
+ e.target.value = fixFloat(e.target.value);
+ }
+ this.updateValueAt(index, e.target.value);
+ },
+
+ _mouseDown(e) {
+ const filterEl = e.target.closest(".filter");
+
+ // re-ordering drag handle
+ if (e.target.tagName.toLowerCase() === "i") {
+ this.isReorderingFilter = true;
+ filterEl.startingY = e.pageY;
+ filterEl.classList.add("dragging");
+
+ this.el.classList.add("dragging");
+ // label-dragging
+ } else if (e.target.classList.contains("devtools-draglabel")) {
+ const label = e.target;
+ const input = filterEl.querySelector("input");
+ const index = this._getFilterElementIndex(filterEl);
+
+ this._dragging = {
+ index,
+ label,
+ input,
+ startX: e.pageX,
+ };
+
+ this.isDraggingLabel = true;
+ }
+ },
+
+ _addButtonClick() {
+ const select = this.filterSelect;
+ if (!select.value) {
+ return;
+ }
+
+ const key = select.value;
+ this.add(key, null);
+
+ this.render();
+ },
+
+ _removeButtonClick(e) {
+ const isRemoveButton = e.target.classList.contains("remove-button");
+ if (!isRemoveButton) {
+ return;
+ }
+
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ this.removeAt(index);
+ },
+
+ _mouseMove(e) {
+ if (this.isReorderingFilter) {
+ this._dragFilterElement(e);
+ } else if (this.isDraggingLabel) {
+ this._dragLabel(e);
+ }
+ },
+
+ _dragFilterElement(e) {
+ const rect = this.filterList.getBoundingClientRect();
+ const top = e.pageY - LIST_PADDING;
+ const bottom = e.pageY + LIST_PADDING;
+ // don't allow dragging over top/bottom of list
+ if (top < rect.top || bottom > rect.bottom) {
+ return;
+ }
+
+ const filterEl = this.filterList.querySelector(".dragging");
+
+ const delta = e.pageY - filterEl.startingY;
+ filterEl.style.top = delta + "px";
+
+ // change is the number of _steps_ taken from initial position
+ // i.e. how many elements we have passed
+ let change = delta / LIST_ITEM_HEIGHT;
+ if (change > 0) {
+ change = Math.floor(change);
+ } else if (change < 0) {
+ change = Math.ceil(change);
+ }
+
+ const children = this.filterList.children;
+ const index = [...children].indexOf(filterEl);
+ const destination = index + change;
+
+ // If we're moving out, or there's no change at all, stop and return
+ if (destination >= children.length || destination < 0 || change === 0) {
+ return;
+ }
+
+ // Re-order filter objects
+ swapArrayIndices(this.filters, index, destination);
+
+ // Re-order the dragging element in markup
+ const target =
+ change > 0 ? children[destination + 1] : children[destination];
+ if (target) {
+ this.filterList.insertBefore(filterEl, target);
+ } else {
+ this.filterList.appendChild(filterEl);
+ }
+
+ filterEl.removeAttribute("style");
+
+ const currentPosition = change * LIST_ITEM_HEIGHT;
+ filterEl.startingY = e.pageY + currentPosition - delta;
+ },
+
+ _dragLabel(e) {
+ const dragging = this._dragging;
+
+ const input = dragging.input;
+
+ let multiplier = DEFAULT_VALUE_MULTIPLIER;
+
+ if (e.altKey) {
+ multiplier = SLOW_VALUE_MULTIPLIER;
+ } else if (e.shiftKey) {
+ multiplier = FAST_VALUE_MULTIPLIER;
+ }
+
+ dragging.lastX = e.pageX;
+ const delta = e.pageX - dragging.startX;
+ const startValue = parseFloat(input.value);
+ let value = startValue + delta * multiplier;
+
+ const filter = this.filters[dragging.index];
+ const [min, max] = this._definition(filter.name).range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+
+ input.value = fixFloat(value);
+
+ dragging.startX = e.pageX;
+
+ this.updateValueAt(dragging.index, value);
+ },
+
+ _mouseUp() {
+ // Label-dragging is disabled on mouseup
+ this._dragging = null;
+ this.isDraggingLabel = false;
+
+ // Filter drag/drop needs more cleaning
+ if (!this.isReorderingFilter) {
+ return;
+ }
+ const filterEl = this.filterList.querySelector(".dragging");
+
+ this.isReorderingFilter = false;
+ filterEl.classList.remove("dragging");
+ this.el.classList.remove("dragging");
+ filterEl.removeAttribute("style");
+
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ _presetClick(e) {
+ const el = e.target;
+ const preset = el.closest(".preset");
+ if (!preset) {
+ return;
+ }
+
+ const id = +preset.dataset.id;
+
+ this.getPresets().then(presets => {
+ if (el.classList.contains("remove-button")) {
+ // If the click happened on the remove button.
+ presets.splice(id, 1);
+ this.setPresets(presets).then(this.renderPresets, console.error);
+ } else {
+ // Or if the click happened on a preset.
+ const p = presets[id];
+
+ this.setCssValue(p.value);
+ this.addPresetInput.value = p.name;
+ }
+ }, console.error);
+ },
+
+ _togglePresets() {
+ this.el.classList.toggle("show-presets");
+ this.emit("render");
+ },
+
+ _savePreset(e) {
+ e.preventDefault();
+
+ const name = this.addPresetInput.value;
+ const value = this.getCssValue();
+
+ if (!name || !value || SPECIAL_VALUES.has(value)) {
+ this.emit("preset-save-error");
+ return;
+ }
+
+ this.getPresets().then(presets => {
+ const index = presets.findIndex(preset => preset.name === name);
+
+ if (index > -1) {
+ presets[index].value = value;
+ } else {
+ presets.push({ name, value });
+ }
+
+ this.setPresets(presets).then(this.renderPresets, console.error);
+ }, console.error);
+ },
+
+ /**
+ * Workaround needed to reset the focus when using a HTML select inside a XUL panel.
+ * See Bug 1294366.
+ */
+ _resetFocus() {
+ this.filterSelect.ownerDocument.defaultView.focus();
+ },
+
+ /**
+ * Clears the list and renders filters, binding required events.
+ * There are some delegated events bound in _addEventListeners method
+ */
+ render() {
+ if (!this.filters.length) {
+ // eslint-disable-next-line no-unsanitized/property
+ this.filterList.innerHTML = `<p> ${L10N.getStr("emptyFilterList")} <br />
+ ${L10N.getStr("addUsingList")} </p>`;
+ this.emit("render");
+ return;
+ }
+
+ this.filterList.innerHTML = "";
+
+ const base = this._filterItemMarkup;
+
+ for (const filter of this.filters) {
+ const def = this._definition(filter.name);
+
+ const el = base.cloneNode(true);
+
+ const [name, value] = el.children;
+ const label = name.children[1];
+ const [input, unitPreview] = value.children;
+
+ let min, max;
+ if (def.range) {
+ [min, max] = def.range;
+ }
+
+ label.textContent = filter.name;
+ input.value = filter.value;
+
+ switch (def.type) {
+ case "percentage":
+ case "angle":
+ case "length":
+ input.type = "number";
+ input.min = min;
+ if (max !== Infinity) {
+ input.max = max;
+ }
+ input.step = "0.1";
+ break;
+ case "string":
+ input.type = "text";
+ input.placeholder = def.placeholder;
+ break;
+ }
+
+ // use photoshop-style label-dragging
+ // and show filters' unit next to their <input>
+ if (def.type !== "string") {
+ unitPreview.textContent = filter.unit;
+
+ label.classList.add("devtools-draglabel");
+ label.title = L10N.getStr("labelDragTooltipText");
+ } else {
+ // string-type filters have no unit
+ unitPreview.remove();
+ }
+
+ this.filterList.appendChild(el);
+ }
+
+ const lastInput = this.filterList.querySelector(
+ ".filter:last-of-type input"
+ );
+ if (lastInput) {
+ lastInput.focus();
+ if (lastInput.type === "text") {
+ // move cursor to end of input
+ const end = lastInput.value.length;
+ lastInput.setSelectionRange(end, end);
+ }
+ }
+
+ this.emit("render");
+ },
+
+ renderPresets() {
+ this.getPresets().then(presets => {
+ // getPresets is async and the widget may be destroyed in between.
+ if (!this.presetList) {
+ return;
+ }
+
+ if (!presets || !presets.length) {
+ // eslint-disable-next-line no-unsanitized/property
+ this.presetList.innerHTML = `<p>${L10N.getStr("emptyPresetList")}</p>`;
+ this.emit("render");
+ return;
+ }
+ const base = this._presetItemMarkup;
+
+ this.presetList.innerHTML = "";
+
+ for (const [index, preset] of presets.entries()) {
+ const el = base.cloneNode(true);
+
+ const [label, span] = el.children;
+
+ el.dataset.id = index;
+
+ label.textContent = preset.name;
+ span.textContent = preset.value;
+
+ this.presetList.appendChild(el);
+ }
+
+ this.emit("render");
+ });
+ },
+
+ /**
+ * returns definition of a filter as defined in filterList
+ *
+ * @param {String} name
+ * filter name (e.g. blur)
+ * @return {Object}
+ * filter's definition
+ */
+ _definition(name) {
+ name = name.toLowerCase();
+ return filterList.find(a => a.name === name);
+ },
+
+ /**
+ * Parses the CSS value specified, updating widget's filters
+ *
+ * @param {String} cssValue
+ * css value to be parsed
+ */
+ setCssValue(cssValue) {
+ if (!cssValue) {
+ throw new Error("Missing CSS filter value in setCssValue");
+ }
+
+ this.filters = [];
+
+ if (SPECIAL_VALUES.has(cssValue)) {
+ this._specialValue = cssValue;
+ this.emit("updated", this.getCssValue());
+ this.render();
+ return;
+ }
+
+ for (let { name, value, quote } of tokenizeFilterValue(cssValue)) {
+ // If the specified value is invalid, replace it with the
+ // default.
+ if (name !== "url") {
+ if (!this._cssIsValid("filter", name + "(" + value + ")")) {
+ value = null;
+ }
+ }
+
+ this.add(name, value, quote, true);
+ }
+
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ /**
+ * Creates a new [name] filter record with value
+ *
+ * @param {String} name
+ * filter name (e.g. blur)
+ * @param {String} value
+ * value of the filter (e.g. 30px, 20%)
+ * If this is |null|, then a default value may be supplied.
+ * @param {String} quote
+ * For a url filter, the quoting style. This can be a
+ * single quote, a double quote, or empty.
+ * @return {Number}
+ * The index of the new filter in the current list of filters
+ * @param {Boolean}
+ * By default, adding a new filter emits an "updated" event, but if
+ * you're calling add in a loop and wait to emit a single event after
+ * the loop yourself, set this parameter to true.
+ */
+ add(name, value, quote, noEvent) {
+ const def = this._definition(name);
+ if (!def) {
+ return false;
+ }
+
+ if (value === null) {
+ // UNIT_MAPPING[string] is an empty string (falsy), so
+ // using || doesn't work here
+ const unitLabel =
+ typeof UNIT_MAPPING[def.type] === "undefined"
+ ? UNIT_MAPPING[DEFAULT_FILTER_TYPE]
+ : UNIT_MAPPING[def.type];
+
+ // string-type filters have no default value but a placeholder instead
+ if (!unitLabel) {
+ value = "";
+ } else {
+ value = def.range[0] + unitLabel;
+ }
+
+ if (name === "url") {
+ // Default quote.
+ quote = '"';
+ }
+ }
+
+ let unit = def.type === "string" ? "" : (/[a-zA-Z%]+/.exec(value) || [])[0];
+
+ if (def.type !== "string") {
+ value = parseFloat(value);
+
+ // You can omit percentage values' and use a value between 0..1
+ if (def.type === "percentage" && !unit) {
+ value = value * 100;
+ unit = "%";
+ }
+
+ const [min, max] = def.range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+ }
+
+ const index = this.filters.push({ value, unit, name, quote }) - 1;
+ if (!noEvent) {
+ this.emit("updated", this.getCssValue());
+ }
+
+ return index;
+ },
+
+ /**
+ * returns value + unit of the specified filter
+ *
+ * @param {Number} index
+ * filter index
+ * @return {String}
+ * css value of filter
+ */
+ getValueAt(index) {
+ const filter = this.filters[index];
+ if (!filter) {
+ return null;
+ }
+
+ // Just return the value+unit for non-url functions.
+ if (filter.name !== "url") {
+ return filter.value + filter.unit;
+ }
+
+ // url values need to be quoted and escaped.
+ if (filter.quote === "'") {
+ return "'" + filter.value.replace(/\'/g, "\\'") + "'";
+ } else if (filter.quote === '"') {
+ return '"' + filter.value.replace(/\"/g, '\\"') + '"';
+ }
+
+ // Unquoted. This approach might change the original input -- for
+ // example the original might be over-quoted. But, this is
+ // correct and probably good enough.
+ return filter.value.replace(/[\\ \t()"']/g, "\\$&");
+ },
+
+ removeAt(index) {
+ if (!this.filters[index]) {
+ return;
+ }
+
+ this.filters.splice(index, 1);
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ /**
+ * Generates CSS filter value for filters of the widget
+ *
+ * @return {String}
+ * css value of filters
+ */
+ getCssValue() {
+ return (
+ this.filters
+ .map((filter, i) => {
+ return `${filter.name}(${this.getValueAt(i)})`;
+ })
+ .join(" ") ||
+ this._specialValue ||
+ "none"
+ );
+ },
+
+ /**
+ * Updates specified filter's value
+ *
+ * @param {Number} index
+ * The index of the filter in the current list of filters
+ * @param {number/string} value
+ * value to set, string for string-typed filters
+ * number for the rest (unit automatically determined)
+ */
+ updateValueAt(index, value) {
+ const filter = this.filters[index];
+ if (!filter) {
+ return;
+ }
+
+ const def = this._definition(filter.name);
+
+ if (def.type !== "string") {
+ const [min, max] = def.range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+ }
+
+ filter.value = filter.unit ? fixFloat(value, true) : value;
+
+ this.emit("updated", this.getCssValue());
+ },
+
+ getPresets() {
+ return asyncStorage.getItem("cssFilterPresets").then(presets => {
+ if (!presets) {
+ return [];
+ }
+
+ return presets;
+ }, console.error);
+ },
+
+ setPresets(presets) {
+ return asyncStorage
+ .setItem("cssFilterPresets", presets)
+ .catch(console.error);
+ },
+};
+
+// Fixes JavaScript's float precision
+function fixFloat(a, number) {
+ const fixed = parseFloat(a).toFixed(1);
+ return number ? parseFloat(fixed) : fixed;
+}
+
+/**
+ * Used to swap two filters' indexes
+ * after drag/drop re-ordering
+ *
+ * @param {Array} array
+ * the array to swap elements of
+ * @param {Number} a
+ * index of first element
+ * @param {Number} b
+ * index of second element
+ */
+function swapArrayIndices(array, a, b) {
+ array[a] = array.splice(b, 1, array[a])[0];
+}
+
+/**
+ * Tokenizes a CSS Filter value and returns an array of {name, value} pairs.
+ *
+ * @param {String} css CSS Filter value to be parsed
+ * @return {Array} An array of {name, value} pairs
+ */
+function tokenizeFilterValue(css) {
+ const filters = [];
+ let depth = 0;
+
+ if (SPECIAL_VALUES.has(css)) {
+ return filters;
+ }
+
+ let state = "initial";
+ let name;
+ let contents;
+ for (const token of cssTokenizer(css)) {
+ switch (state) {
+ case "initial":
+ if (token.tokenType === "function") {
+ name = token.text;
+ contents = "";
+ state = "function";
+ depth = 1;
+ } else if (token.tokenType === "url" || token.tokenType === "bad_url") {
+ // Extract the quoting style from the url.
+ const originalText = css.substring(
+ token.startOffset,
+ token.endOffset
+ );
+ const [, quote] = /^url\([ \t\r\n\f]*(["']?)/i.exec(originalText);
+
+ filters.push({ name: "url", value: token.text.trim(), quote });
+ // Leave state as "initial" because the URL token includes
+ // the trailing close paren.
+ }
+ break;
+
+ case "function":
+ if (token.tokenType === "symbol" && token.text === ")") {
+ --depth;
+ if (depth === 0) {
+ filters.push({ name, value: contents.trim() });
+ state = "initial";
+ break;
+ }
+ }
+ contents += css.substring(token.startOffset, token.endOffset);
+ if (
+ token.tokenType === "function" ||
+ (token.tokenType === "symbol" && token.text === "(")
+ ) {
+ ++depth;
+ }
+ break;
+ }
+ }
+
+ return filters;
+}
+
+/**
+ * Finds neighbour number characters of an index in a string
+ * the numbers may be floats (containing dots)
+ * It's assumed that the value given to this function is a valid number
+ *
+ * @param {String} string
+ * The string containing numbers
+ * @param {Number} index
+ * The index to look for neighbours for
+ * @return {Object}
+ * returns null if no number is found
+ * value: The number found
+ * start: The number's starting index
+ * end: The number's ending index
+ */
+function getNeighbourNumber(string, index) {
+ if (!/\d/.test(string)) {
+ return null;
+ }
+
+ let left = /-?[0-9.]*$/.exec(string.slice(0, index));
+ let right = /-?[0-9.]*/.exec(string.slice(index));
+
+ left = left ? left[0] : "";
+ right = right ? right[0] : "";
+
+ if (!right && !left) {
+ return null;
+ }
+
+ return {
+ value: fixFloat(left + right, true),
+ start: index - left.length,
+ end: index + right.length,
+ };
+}
diff --git a/devtools/client/shared/widgets/LinearEasingFunctionWidget.js b/devtools/client/shared/widgets/LinearEasingFunctionWidget.js
new file mode 100644
index 0000000000..e6d2e604df
--- /dev/null
+++ b/devtools/client/shared/widgets/LinearEasingFunctionWidget.js
@@ -0,0 +1,731 @@
+/* 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";
+
+/**
+ * This is a chart-like editor for linear() easing function, used in the Rules View.
+ */
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const { getCSSLexer } = require("devtools/shared/css/lexer");
+const { throttle } = require("devtools/shared/throttle");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+const numberFormatter = new Intl.NumberFormat("en", {
+ maximumFractionDigits: 3,
+});
+const percentFormatter = new Intl.NumberFormat("en", {
+ maximumFractionDigits: 2,
+ style: "percent",
+});
+
+/**
+ * Easing function widget. Draw the lines and control points in an svg.
+ *
+ * XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
+ * but this will be hard to have proper visual representation to handle those cases, so we
+ * only handle points inside [0,0] [1,1] to represent most common use cases (even though
+ * the line will properly link points outside of this range)
+ *
+ *
+ * @emits "updated" events whenever the line is changed, with the updated property value.
+ */
+class LinearEasingFunctionWidget extends EventEmitter {
+ /**
+ * @param {DOMNode} parent The container where the widget should be created
+ */
+ constructor(parent) {
+ super();
+
+ this.parent = parent;
+ this.#initMarkup();
+
+ this.#svgEl.addEventListener("mousedown", this.#onMouseDown.bind(this), {
+ signal: this.#abortController.signal,
+ });
+ this.#svgEl.addEventListener("dblclick", this.#onDoubleClick.bind(this), {
+ signal: this.#abortController.signal,
+ });
+
+ // Add the timing function previewer
+ // if prefers-reduced-motion is not set
+ this.#reducedMotion = parent.ownerGlobal.matchMedia(
+ "(prefers-reduced-motion)"
+ );
+ if (!this.#reducedMotion.matches) {
+ this.#timingPreview = new TimingFunctionPreviewWidget(this.#wrapperEl);
+ }
+
+ // add event listener to change prefers-reduced-motion
+ // of the timing function preview during runtime
+ this.#reducedMotion.addEventListener(
+ "change",
+ event => {
+ // if prefers-reduced-motion is enabled destroy timing function preview
+ // else create it if it does not exist
+ if (event.matches) {
+ if (this.#timingPreview) {
+ this.#timingPreview.destroy();
+ }
+ this.#timingPreview = undefined;
+ } else if (!this.#timingPreview) {
+ this.#timingPreview = new TimingFunctionPreviewWidget(
+ this.#wrapperEl
+ );
+ }
+ },
+ { signal: this.#abortController.signal }
+ );
+ }
+
+ static CONTROL_POINTS_CLASSNAME = "control-point";
+
+ // Handles event listener that are enabled for the whole widget lifetime
+ #abortController = new AbortController();
+
+ // Array<Object>: Object has `input` (plotted on x axis) and `output` (plotted on y axis) properties
+ #functionPoints;
+
+ // MediaQueryList
+ #reducedMotion;
+
+ // TimingFunctionPreviewWidget
+ #timingPreview;
+
+ // current dragged element. null if there's no dragging happening
+ #draggedEl = null;
+
+ // handles event listeners added when user starts dragging an element
+ #dragAbortController;
+
+ // element references
+ #wrapperEl;
+ #svgEl;
+ #linearLineEl;
+ #controlPointGroupEl;
+
+ /**
+ * Creates the markup of the widget
+ */
+ #initMarkup() {
+ const doc = this.parent.ownerDocument;
+
+ const wrap = doc.createElementNS(XHTML_NS, "div");
+ wrap.className = "display-wrap";
+ this.#wrapperEl = wrap;
+
+ const svg = doc.createElementNS(SVG_NS, "svg");
+ svg.classList.add("chart");
+
+ // Add some "padding" to the viewBox so circles near the edges are not clipped.
+ const padding = 0.1;
+ const length = 1 + padding * 2;
+ // XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
+ // but this will be hard to have proper visual representation for all cases, so we
+ // set the viewBox is basically starting at 0,0 and has a size of 1 (if we don't take the
+ // padding into account), to represent most common use cases.
+ svg.setAttribute(
+ "viewBox",
+ `${0 - padding} ${0 - padding} ${length} ${length}`
+ );
+
+ // Create a background grid
+ const chartGrid = doc.createElementNS(SVG_NS, "g");
+ chartGrid.setAttribute("stroke-width", "0.005");
+ chartGrid.classList.add("chart-grid");
+ for (let i = 0; i <= 10; i++) {
+ const value = i / 10;
+ const hLine = doc.createElementNS(SVG_NS, "line");
+ hLine.setAttribute("x1", 0);
+ hLine.setAttribute("y1", value);
+ hLine.setAttribute("x2", 1);
+ hLine.setAttribute("y2", value);
+ const vLine = doc.createElementNS(SVG_NS, "line");
+ vLine.setAttribute("x1", value);
+ vLine.setAttribute("y1", 0);
+ vLine.setAttribute("x2", value);
+ vLine.setAttribute("y2", 1);
+ chartGrid.append(hLine, vLine);
+ }
+
+ // Create the actual graph line
+ const linearLine = doc.createElementNS(SVG_NS, "polyline");
+ linearLine.classList.add("chart-linear");
+ linearLine.setAttribute("fill", "none");
+ linearLine.setAttribute("stroke", "context-stroke black");
+ linearLine.setAttribute("stroke-width", "0.01");
+
+ // And a group for all the control points
+ const controlPointGroup = doc.createElementNS(SVG_NS, "g");
+ controlPointGroup.classList.add("control-points-group");
+
+ this.#linearLineEl = linearLine;
+ this.#svgEl = svg;
+ this.#controlPointGroupEl = controlPointGroup;
+
+ svg.append(chartGrid, linearLine, controlPointGroup);
+ wrap.append(svg);
+ this.parent.append(wrap);
+ }
+
+ /**
+ * Remove widget markup, called on destroy
+ */
+ #removeMarkup() {
+ this.#wrapperEl.remove();
+ }
+
+ /**
+ * Handle mousedown event on the svg
+ *
+ * @param {MouseEvent} event
+ */
+ #onMouseDown(event) {
+ if (
+ !event.target.classList.contains(
+ LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
+ )
+ ) {
+ return;
+ }
+
+ this.#draggedEl = event.target;
+ this.#draggedEl.setPointerCapture(event.pointerId);
+
+ this.#dragAbortController = new AbortController();
+ this.#draggedEl.addEventListener(
+ "mousemove",
+ this.#onMouseMove.bind(this),
+ { signal: this.#dragAbortController.signal }
+ );
+ this.#draggedEl.addEventListener("mouseup", this.#onMouseUp.bind(this), {
+ signal: this.#dragAbortController.signal,
+ });
+ }
+
+ /**
+ * Handle mousemove event on a control point. Only active when there's a control point
+ * being dragged.
+ *
+ * @param {MouseEvent} event
+ */
+ #onMouseMove = throttle(event => {
+ if (!this.#draggedEl) {
+ return;
+ }
+
+ const { x, y } = this.#getPositionInSvgFromEvent(event);
+
+ // XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
+ // but this will be hard to have proper visual representation for all cases, so we
+ // clamp x and y between 0 and 1 as it's more likely the range that will be used.
+ let cx = clamp(0, 1, x);
+ let cy = clamp(0, 1, y);
+
+ if (this.#draggedEl.previousSibling) {
+ // We don't allow moving the point before the previous point
+ cx = Math.max(
+ cx,
+ parseFloat(this.#draggedEl.previousSibling.getAttribute("cx"))
+ );
+ }
+ if (this.#draggedEl.nextSibling) {
+ // We don't allow moving the point after the next point
+ cx = Math.min(
+ cx,
+ parseFloat(this.#draggedEl.nextSibling.getAttribute("cx"))
+ );
+ }
+
+ // Enable "Snap to grid" when the user holds the shift key
+ if (event.shiftKey) {
+ cx = Math.round(cx * 10) / 10;
+ cy = Math.round(cy * 10) / 10;
+ }
+
+ this.#draggedEl.setAttribute("cx", cx);
+ this.#draggedEl.setAttribute("cy", cy);
+
+ this.#updateFunctionPointsFromControlPoints();
+ this.#redrawLineFromFunctionPoints();
+ this.emit("updated", this.getCssLinearValue());
+ }, 20);
+
+ /**
+ * Handle mouseup event on a control point. Only active when there's a control point
+ * being dragged.
+ *
+ * @param {MouseEvent} event
+ */
+ #onMouseUp(event) {
+ this.#draggedEl.releasePointerCapture(event.pointerId);
+ this.#draggedEl = null;
+ this.#dragAbortController.abort();
+ this.#dragAbortController = null;
+ }
+
+ /**
+ * Handle dblclick event on the svg.
+ * If the target is a control point, this will remove it, otherwise this will add
+ * a new control point at the clicked position.
+ *
+ * @param {MouseEvent} event
+ */
+ #onDoubleClick(event) {
+ const existingPoints = Array.from(
+ this.#controlPointGroupEl.querySelectorAll(
+ `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
+ )
+ );
+
+ if (
+ event.target.classList.contains(
+ LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
+ )
+ ) {
+ // The function is only valid when it has at least 2 points, so don't allow to
+ // produce invalid value.
+ if (existingPoints.length <= 2) {
+ return;
+ }
+
+ event.target.remove();
+ this.#updateFunctionPointsFromControlPoints();
+ this.#redrawFromFunctionPoints();
+ } else {
+ let { x, y } = this.#getPositionInSvgFromEvent(event);
+
+ // Enable "Snap to grid" when the user holds the shift key
+ if (event.shiftKey) {
+ x = clamp(0, 1, Math.round(x * 10) / 10);
+ y = clamp(0, 1, Math.round(y * 10) / 10);
+ }
+
+ // Add a control point at specified x and y in svg coords
+ // We need to loop through existing control points to insert it at the correct index.
+ const nextSibling = existingPoints.find(
+ el => parseFloat(el.getAttribute("cx")) >= x
+ );
+
+ this.#controlPointGroupEl.insertBefore(
+ this.#createSvgControlPointEl(x, y),
+ nextSibling
+ );
+ this.#updateFunctionPointsFromControlPoints();
+ this.#redrawLineFromFunctionPoints();
+ }
+ }
+
+ /**
+ * Update this.#functionPoints based on the control points in the svg
+ */
+ #updateFunctionPointsFromControlPoints() {
+ // We ensure to order the control points based on their x position within the group,
+ // so here, we can iterate through them without any need to sort them.
+ this.#functionPoints = Array.from(
+ this.#controlPointGroupEl.querySelectorAll(
+ `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
+ )
+ ).map(el => {
+ const input = parseFloat(el.getAttribute("cx"));
+ // Since svg coords start from the top-left corner, we need to translate cy
+ // to have the actual value we want for the function.
+ const output = 1 - parseFloat(el.getAttribute("cy"));
+
+ return {
+ input,
+ output,
+ };
+ });
+ }
+
+ /**
+ * Redraw the control points and the linear() line in the svg,
+ * based on the value of this.functionPoints.
+ */
+ #redrawFromFunctionPoints() {
+ // Remove previous control points
+ this.#controlPointGroupEl
+ .querySelectorAll(
+ `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
+ )
+ .forEach(el => el.remove());
+
+ if (this.#functionPoints) {
+ // Add controls for each function points
+ this.#functionPoints.forEach(({ input, output }) => {
+ this.#controlPointGroupEl.append(
+ // Since svg coords start from the top-left corner, we need to translate output
+ // to properly place it on the graph.
+ this.#createSvgControlPointEl(input, 1 - output)
+ );
+ });
+ }
+
+ this.#redrawLineFromFunctionPoints();
+ }
+
+ /**
+ * Redraw linear() line in the svg based on the value of this.functionPoints.
+ */
+ #redrawLineFromFunctionPoints() {
+ // Set the line points
+ this.#linearLineEl.setAttribute(
+ "points",
+ (this.#functionPoints || [])
+ .map(
+ ({ input, output }) =>
+ // Since svg coords start from the top-left corner, we need to translate output
+ // to properly place it on the graph.
+ `${input},${1 - output}`
+ )
+ .join(" ")
+ );
+
+ const cssLinearValue = this.getCssLinearValue();
+ if (this.#timingPreview) {
+ this.#timingPreview.preview(cssLinearValue);
+ }
+
+ this.emit("updated", cssLinearValue);
+ }
+
+ /**
+ * Create a control points for the svg line.
+ *
+ * @param {Number} cx
+ * @param {Number} cy
+ * @returns {SVGCircleElement}
+ */
+ #createSvgControlPointEl(cx, cy) {
+ const controlEl = this.parent.ownerDocument.createElementNS(
+ SVG_NS,
+ "circle"
+ );
+ controlEl.classList.add("control-point");
+ controlEl.setAttribute("cx", cx);
+ controlEl.setAttribute("cy", cy);
+ controlEl.setAttribute("r", 0.025);
+ controlEl.setAttribute("fill", "context-fill");
+ controlEl.setAttribute("stroke-width", 0);
+ return controlEl;
+ }
+
+ /**
+ * Return the position in the SVG viewbox from mouse event.
+ *
+ * @param {MouseEvent} event
+ * @returns {Object} An object with x and y properties
+ */
+ #getPositionInSvgFromEvent(event) {
+ const position = this.#svgEl.createSVGPoint();
+ position.x = event.clientX;
+ position.y = event.clientY;
+
+ const matrix = this.#svgEl.getScreenCTM();
+ const inverseSvgMatrix = matrix.inverse();
+ const transformedPosition = position.matrixTransform(inverseSvgMatrix);
+
+ return { x: transformedPosition.x, y: transformedPosition.y };
+ }
+
+ /**
+ * Provide the value of the linear() function we want to visualize here.
+ * Called from the tooltip with the value of the function in the rule view.
+ *
+ * @param {String} linearFunctionValue: e.g. `linear(0, 0.5, 1)`.
+ */
+ setCssLinearValue(linearFunctionValue) {
+ if (!linearFunctionValue) {
+ return;
+ }
+
+ // Parse the string to extract all the points
+ const points = parseTimingFunction(linearFunctionValue);
+ this.#functionPoints = points;
+
+ // And draw the line and points
+ this.#redrawFromFunctionPoints();
+ }
+
+ /**
+ * Return the value of the linear() function based on the state of the graph.
+ * The resulting value is what we emit in the "updated" event.
+ *
+ * @return {String|null} e.g. `linear(0 0%, 0.5 50%, 1 100%)`.
+ */
+ getCssLinearValue() {
+ if (!this.#functionPoints) {
+ return null;
+ }
+
+ return `linear(${this.#functionPoints
+ .map(
+ ({ input, output }) =>
+ `${numberFormatter.format(output)} ${percentFormatter.format(input)}`
+ )
+ .join(", ")})`;
+ }
+
+ destroy() {
+ this.#abortController.abort();
+ this.#dragAbortController?.abort();
+ this.#removeMarkup();
+ this.#reducedMotion = null;
+
+ if (this.#timingPreview) {
+ this.#timingPreview.destroy();
+ this.#timingPreview = null;
+ }
+ }
+}
+
+exports.LinearEasingFunctionWidget = LinearEasingFunctionWidget;
+
+/**
+ * The TimingFunctionPreviewWidget animates a dot on a scale with a given
+ * timing-function
+ */
+class TimingFunctionPreviewWidget {
+ /**
+ * @param {DOMNode} parent The container where this widget should go
+ */
+ constructor(parent) {
+ this.#initMarkup(parent);
+ }
+
+ #PREVIEW_DURATION = 1000;
+ #dotEl;
+ #previousValue;
+
+ #initMarkup(parent) {
+ const doc = parent.ownerDocument;
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "timing-function-preview";
+
+ this.#dotEl = doc.createElementNS(XHTML_NS, "div");
+ this.#dotEl.className = "dot";
+ container.appendChild(this.#dotEl);
+ parent.appendChild(container);
+ }
+
+ destroy() {
+ this.#dotEl.getAnimations().forEach(anim => anim.cancel());
+ this.#dotEl.parentElement.remove();
+ }
+
+ /**
+ * Preview a new timing function. The current preview will only be stopped if
+ * the supplied function value is different from the previous one. If the
+ * supplied function is invalid, the preview will stop.
+ * @param {Array} value
+ */
+ preview(timingFunction) {
+ if (this.#previousValue == timingFunction) {
+ return;
+ }
+ this.#restartAnimation(timingFunction);
+ this.#previousValue = timingFunction;
+ }
+
+ /**
+ * Re-start the preview animation from the beginning.
+ * @param {Array} points
+ */
+ #restartAnimation = throttle(timingFunction => {
+ // Cancel the previous animation if there was any.
+ this.#dotEl.getAnimations().forEach(anim => anim.cancel());
+
+ // And start the new one.
+ // The animation consists of a few keyframes that move the dot to the right of the
+ // container, and then move it back to the left.
+ // It also contains some pause where the dot is semi transparent, before it moves to
+ // the right, and once again, before it comes back to the left.
+ // The timing function passed to this function is applied to the keyframes that
+ // actually move the dot. This way it can be previewed in both direction, instead of
+ // being spread over the whole animation.
+ this.#dotEl.animate(
+ [
+ { translate: "0%", opacity: 0.5, offset: 0 },
+ { translate: "0%", opacity: 0.5, offset: 0.19 },
+ { translate: "0%", opacity: 1, offset: 0.2, easing: timingFunction },
+ { translate: "100%", opacity: 1, offset: 0.5 },
+ { translate: "100%", opacity: 0.5, offset: 0.51 },
+ { translate: "100%", opacity: 0.5, offset: 0.7 },
+ { translate: "100%", opacity: 1, offset: 0.71, easing: timingFunction },
+ { translate: "0%", opacity: 1, offset: 1 },
+ ],
+ {
+ duration: this.#PREVIEW_DURATION * 2,
+ iterations: Infinity,
+ }
+ );
+ }, 250);
+}
+
+/**
+ * Parse a linear() string to collect the different values.
+ * https://drafts.csswg.org/css-easing-2/#the-linear-easing-function
+ *
+ * @param {String} value
+ * @return {Array<Object>|undefined} returns undefined if value isn't a valid linear() value.
+ * the items of the array are objects with {Number} `input`
+ * and {Number} `output` properties.
+ */
+function parseTimingFunction(value) {
+ value = value.trim();
+ const tokenStream = getCSSLexer(value);
+ const getNextToken = () => {
+ while (true) {
+ const token = tokenStream.nextToken();
+ if (
+ !token ||
+ (token.tokenType !== "whitespace" && token.tokenType !== "comment")
+ ) {
+ return token;
+ }
+ }
+ };
+
+ let token = getNextToken();
+ if (!token || token.tokenType !== "function" || token.text !== "linear") {
+ return undefined;
+ }
+
+ // Let's follow the spec parsing algorithm https://drafts.csswg.org/css-easing-2/#linear-easing-function-parsing
+ const points = [];
+ let largestInput = -Infinity;
+
+ while ((token = getNextToken())) {
+ if (token.text === ")") {
+ break;
+ }
+
+ if (token.tokenType === "number") {
+ // [parsing step 4.1]
+ const point = { input: null, output: token.number };
+ // [parsing step 4.2]
+ points.push(point);
+
+ // get nextToken to see if there's a linear stop length
+ token = getNextToken();
+ // [parsing step 4.3]
+ if (token && token.tokenType === "percentage") {
+ // [parsing step 4.3.1]
+ point.input = Math.max(token.number, largestInput);
+ // [parsing step 4.3.2]
+ largestInput = point.input;
+
+ // get nextToken to see if there's a second linear stop length
+ token = getNextToken();
+
+ // [parsing step 4.3.3]
+ if (token && token.tokenType === "percentage") {
+ // [parsing step 4.3.3.1]
+ const extraPoint = { input: null, output: point.output };
+ // [parsing step 4.3.3.2]
+ points.push(extraPoint);
+
+ // [parsing step 4.3.3.3]
+ extraPoint.input = Math.max(token.number, largestInput);
+ // [parsing step 4.3.3.4]
+ largestInput = extraPoint.input;
+ }
+ } else if (points.length == 1) {
+ // [parsing step 4.4]
+ // [parsing step 4.4.1]
+ point.input = 0;
+ // [parsing step 4.4.2]
+ largestInput = 0;
+ }
+ }
+ }
+
+ if (points.length < 2) {
+ return undefined;
+ }
+
+ // [parsing step 4.5]
+ if (points.at(-1).input === null) {
+ points.at(-1).input = Math.max(largestInput, 1);
+ }
+
+ // [parsing step 5]
+
+ // We want to retrieve ranges ("runs" in the spec) of items with null inputs so we
+ // can compute their input using linear interpolation.
+ const nullInputPoints = [];
+ points.forEach((point, index, array) => {
+ if (point.input == null) {
+ // since the first point is guaranteed to have an non-null input, and given that
+ // we iterate through the points in regular order, we are guaranteed to find a previous
+ // non null point.
+ const previousNonNull = array.findLast(
+ (item, i) => i < index && item.input !== null
+ ).input;
+ // since the last point is guaranteed to have an non-null input, and given that
+ // we iterate through the points in regular order, we are guaranteed to find a next
+ // non null point.
+ const nextNonNull = array.find(
+ (item, i) => i > index && item.input !== null
+ ).input;
+
+ if (nullInputPoints.at(-1)?.indexes?.at(-1) == index - 1) {
+ nullInputPoints.at(-1).indexes.push(index);
+ } else {
+ nullInputPoints.push({
+ indexes: [index],
+ previousNonNull,
+ nextNonNull,
+ });
+ }
+ }
+ });
+
+ // For each range of consecutive null-input indexes
+ nullInputPoints.forEach(({ indexes, previousNonNull, nextNonNull }) => {
+ // For each null-input points, compute their input by linearly interpolating between
+ // the closest previous and next points that have a non-null input.
+ indexes.forEach((index, i) => {
+ points[index].input = lerp(
+ previousNonNull,
+ nextNonNull,
+ (i + 1) / (indexes.length + 1)
+ );
+ });
+ });
+
+ return points;
+}
+
+/**
+ * Linearly interpolate between 2 numbers.
+ *
+ * @param {Number} x
+ * @param {Number} y
+ * @param {Number} a
+ * A value of 0 returns x, and 1 returns y
+ * @return {Number}
+ */
+function lerp(x, y, a) {
+ return x * (1 - a) + y * a;
+}
+
+/**
+ * Clamp value in a range, meaning the result won't be smaller than min
+ * and no bigger than max.
+ *
+ * @param {Number} min
+ * @param {Number} max
+ * @param {Number} value
+ * @returns {Number}
+ */
+function clamp(min, max, value) {
+ return Math.max(min, Math.min(value, max));
+}
+
+exports.parseTimingFunction = parseTimingFunction;
diff --git a/devtools/client/shared/widgets/ShapesInContextEditor.js b/devtools/client/shared/widgets/ShapesInContextEditor.js
new file mode 100644
index 0000000000..1d794bd81f
--- /dev/null
+++ b/devtools/client/shared/widgets/ShapesInContextEditor.js
@@ -0,0 +1,347 @@
+/* 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("resource://devtools/shared/event-emitter.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+
+/**
+ * The ShapesInContextEditor:
+ * - communicates with the ShapesHighlighter actor from the server;
+ * - listens to events for shape change and hover point coming from the shape-highlighter;
+ * - writes shape value changes to the CSS declaration it was triggered from;
+ * - synchronises highlighting coordinate points on mouse over between the shapes
+ * highlighter and the shape value shown in the Rule view.
+ *
+ * It is instantiated once in HighlightersOverlay by calls to .getInContextEditor().
+ */
+class ShapesInContextEditor {
+ constructor(highlighter, inspector, state) {
+ EventEmitter.decorate(this);
+
+ this.inspector = inspector;
+ this.highlighter = highlighter;
+ // Refence to the NodeFront currently being highlighted.
+ this.highlighterTargetNode = null;
+ this.highligherEventHandlers = {};
+ this.highligherEventHandlers["shape-change"] = this.onShapeChange;
+ this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover;
+ this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover;
+ // Mode for shapes highlighter: shape-outside or clip-path. Used to discern
+ // when toggling the highlighter on the same node for different CSS properties.
+ this.mode = null;
+ // Reference to Rule view used to listen for changes
+ this.ruleView = this.inspector.getPanel("ruleview").view;
+ // Reference of |state| from HighlightersOverlay.
+ this.state = state;
+ // Reference to DOM node of the toggle icon for shapes highlighter.
+ this.swatch = null;
+
+ // Commit triggers expensive DOM changes in TextPropertyEditor.update()
+ // so we debounce it.
+ this.commit = debounce(this.commit, 200, this);
+ this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
+ this.onNodeFrontChanged = this.onNodeFrontChanged.bind(this);
+ this.onShapeValueUpdated = this.onShapeValueUpdated.bind(this);
+ this.onRuleViewChanged = this.onRuleViewChanged.bind(this);
+
+ this.highlighter.on("highlighter-event", this.onHighlighterEvent);
+ this.ruleView.on("ruleview-changed", this.onRuleViewChanged);
+ }
+
+ /**
+ * Get the reference to the TextProperty where shape changes should be written.
+ *
+ * We can't rely on the TextProperty to be consistent while changing the value of an
+ * inline style because the fix for Bug 1467076 forces a full rebuild of TextProperties
+ * for the inline style's mock-CSS Rule in the Rule view.
+ *
+ * On |toggle()|, we store the target TextProperty index, property name and parent rule.
+ * Here, we use that index and property name to attempt to re-identify the correct
+ * TextProperty in the rule.
+ *
+ * @return {TextProperty|null}
+ */
+ get textProperty() {
+ if (!this.rule || !this.rule.textProps) {
+ return null;
+ }
+
+ const textProp = this.rule.textProps[this.textPropIndex];
+ return textProp && textProp.name === this.textPropName ? textProp : null;
+ }
+
+ /**
+ * Called when the element style changes from the Rule view.
+ * If the TextProperty we're acting on isn't enabled anymore or overridden,
+ * turn off the shapes highlighter.
+ */
+ async onRuleViewChanged() {
+ if (
+ this.textProperty &&
+ (!this.textProperty.enabled || this.textProperty.overridden)
+ ) {
+ await this.hide();
+ }
+ }
+
+ /**
+ * Toggle the shapes highlighter for the given element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ */
+ async toggle(node, options, prop) {
+ // Same target node, same mode -> hide and exit OR switch to toggle transform mode.
+ if (node == this.highlighterTargetNode && this.mode === options.mode) {
+ if (!options.transformMode) {
+ await this.hide();
+ return;
+ }
+
+ options.transformMode = !this.state.shapes.options.transformMode;
+ }
+
+ // Same target node, dfferent modes -> toggle between shape-outside and clip-path.
+ // Hide highlighter for previous property, but continue and show for other property.
+ if (node == this.highlighterTargetNode && this.mode !== options.mode) {
+ await this.hide();
+ }
+
+ // Save the target TextProperty's parent rule, index and property name for later
+ // re-identification of the TextProperty. @see |get textProperty()|.
+ this.rule = prop.rule;
+ this.textPropIndex = this.rule.textProps.indexOf(prop);
+ this.textPropName = prop.name;
+
+ this.findSwatch();
+ await this.show(node, options);
+ }
+
+ /**
+ * Show the shapes highlighter for the given element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ */
+ async show(node, options) {
+ const isShown = await this.highlighter.show(node, options);
+ if (!isShown) {
+ return;
+ }
+
+ this.inspector.selection.on("detached-front", this.onNodeFrontChanged);
+ this.inspector.selection.on("new-node-front", this.onNodeFrontChanged);
+ this.ruleView.on("property-value-updated", this.onShapeValueUpdated);
+ this.highlighterTargetNode = node;
+ this.mode = options.mode;
+ this.emit("show", { node, options });
+ }
+
+ /**
+ * Hide the shapes highlighter.
+ */
+ async hide() {
+ try {
+ await this.highlighter.hide();
+ } catch (err) {
+ // silent error
+ }
+
+ // Stop if the panel has been destroyed during the call to hide.
+ if (this.destroyed) {
+ return;
+ }
+
+ if (this.swatch) {
+ this.swatch.classList.remove("active");
+ }
+ this.swatch = null;
+ this.rule = null;
+ this.textPropIndex = -1;
+ this.textPropName = null;
+
+ this.emit("hide", { node: this.highlighterTargetNode });
+ this.inspector.selection.off("detached-front", this.onNodeFrontChanged);
+ this.inspector.selection.off("new-node-front", this.onNodeFrontChanged);
+ this.ruleView.off("property-value-updated", this.onShapeValueUpdated);
+ this.highlighterTargetNode = null;
+ }
+
+ /**
+ * Identify the swatch (aka toggle icon) DOM node from the TextPropertyEditor of the
+ * TextProperty we're working with. Whenever the TextPropertyEditor is updated (i.e.
+ * when committing the shape value to the Rule view), it rebuilds its DOM and the old
+ * swatch reference becomes invalid. Call this method to identify the current swatch.
+ */
+ findSwatch() {
+ if (!this.textProperty) {
+ return;
+ }
+
+ const valueSpan = this.textProperty.editor.valueSpan;
+ this.swatch = valueSpan.querySelector(".ruleview-shapeswatch");
+ if (this.swatch) {
+ this.swatch.classList.add("active");
+ }
+ }
+
+ /**
+ * Handle events emitted by the highlighter.
+ * Find any callback assigned to the event type and call it with the given data object.
+ *
+ * @param {Object} data
+ * The data object sent in the event.
+ */
+ onHighlighterEvent(data) {
+ const handler = this.highligherEventHandlers[data.type];
+ if (!handler || typeof handler !== "function") {
+ return;
+ }
+ handler.call(this, data);
+ this.inspector.highlighters.emit("highlighter-event-handled");
+ }
+
+ /**
+ * Clean up when node selection changes because Rule view and TextPropertyEditor
+ * instances are not automatically destroyed when selection changes.
+ */
+ async onNodeFrontChanged() {
+ try {
+ await this.hide();
+ } catch (err) {
+ // Silent error.
+ }
+ }
+
+ /**
+ * Handler for "shape-change" event from the shapes highlighter.
+ *
+ * @param {Object} data
+ * Data associated with the "shape-change" event.
+ * Contains:
+ * - {String} value: the new shape value.
+ * - {String} type: the event type ("shape-change").
+ */
+ onShapeChange(data) {
+ this.preview(data.value);
+ this.commit(data.value);
+ }
+
+ /**
+ * Handler for "shape-hover-on" and "shape-hover-off" events from the shapes highlighter.
+ * Called when the mouse moves over or off of a coordinate point inside the shapes
+ * highlighter. Marks/unmarks the corresponding coordinate node in the shape value
+ * from the Rule view.
+ *
+ * @param {Object} data
+ * Data associated with the "shape-hover" event.
+ * Contains:
+ * - {String|null} point: coordinate to highlight or null if nothing to highlight
+ * - {String} type: the event type ("shape-hover-on" or "shape-hover-on").
+ */
+ onShapeHover(data) {
+ const shapeValueEl = this.swatch && this.swatch.nextSibling;
+ if (!shapeValueEl) {
+ return;
+ }
+
+ const pointSelector = ".ruleview-shape-point";
+ // First, unmark all highlighted coordinate nodes from Rule view
+ for (const node of shapeValueEl.querySelectorAll(
+ `${pointSelector}.active`
+ )) {
+ node.classList.remove("active");
+ }
+
+ // Exit if there's no coordinate to highlight.
+ if (typeof data.point !== "string") {
+ return;
+ }
+
+ const point = data.point.includes(",")
+ ? data.point.split(",")[0]
+ : data.point;
+
+ /**
+ * Build selector for coordinate nodes in shape value that must be highlighted.
+ * Coordinate values for inset() use class names instead of data attributes because
+ * a single node may represent multiple coordinates in shorthand notation.
+ * Example: inset(50px); The node wrapping 50px represents all four inset coordinates.
+ */
+ const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
+ const selector = INSET_POINT_TYPES.includes(point)
+ ? `${pointSelector}.${point}`
+ : `${pointSelector}[data-point='${point}']`;
+
+ for (const node of shapeValueEl.querySelectorAll(selector)) {
+ node.classList.add("active");
+ }
+ }
+
+ /**
+ * Handler for "property-value-updated" event triggered by the Rule view.
+ * Called after the shape value has been written to the element's style and the Rule
+ * view updated. Emits an event on HighlightersOverlay that is expected by
+ * tests in order to check if the shape value has been correctly applied.
+ */
+ async onShapeValueUpdated() {
+ if (this.textProperty) {
+ // When TextPropertyEditor updates, it replaces the previous swatch DOM node.
+ // Find and store the new one.
+ this.findSwatch();
+ this.inspector.highlighters.emit("shapes-highlighter-changes-applied");
+ } else {
+ await this.hide();
+ }
+ }
+
+ /**
+ * Preview a shape value on the element without committing the changes to the Rule view.
+ *
+ * @param {String} value
+ * The shape value to set the current property to
+ */
+ preview(value) {
+ if (!this.textProperty) {
+ return;
+ }
+ // Update the element's style to see live results.
+ this.textProperty.rule.previewPropertyValue(this.textProperty, value);
+ // Update the text of CSS value in the Rule view. This makes it inert.
+ // When commit() is called, the value is reparsed and its DOM structure rebuilt.
+ this.swatch.nextSibling.textContent = value;
+ }
+
+ /**
+ * Commit a shape value change which triggers an expensive operation that rebuilds
+ * part of the DOM of the TextPropertyEditor. Called in a debounced manner; see
+ * constructor.
+ *
+ * @param {String} value
+ * The shape value for the current property
+ */
+ commit(value) {
+ if (!this.textProperty) {
+ return;
+ }
+
+ this.textProperty.setValue(value);
+ }
+
+ destroy() {
+ this.highlighter.off("highlighter-event", this.onHighlighterEvent);
+ this.ruleView.off("ruleview-changed", this.onRuleViewChanged);
+ this.highligherEventHandlers = {};
+
+ this.destroyed = true;
+ }
+}
+
+module.exports = ShapesInContextEditor;
diff --git a/devtools/client/shared/widgets/Spectrum.js b/devtools/client/shared/widgets/Spectrum.js
new file mode 100644
index 0000000000..cdf5f2df6b
--- /dev/null
+++ b/devtools/client/shared/widgets/Spectrum.js
@@ -0,0 +1,783 @@
+/* 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("resource://devtools/shared/event-emitter.js");
+const {
+ MultiLocalizationHelper,
+} = require("resource://devtools/shared/l10n.js");
+
+loader.lazyRequireGetter(
+ this,
+ "colorUtils",
+ "resource://devtools/shared/css/color.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "labColors",
+ "resource://devtools/shared/css/color-db.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getTextProperties", "getContrastRatioAgainstBackground"],
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+
+const L10N = new MultiLocalizationHelper(
+ "devtools/client/locales/accessibility.properties",
+ "devtools/client/locales/inspector.properties"
+);
+const ARROW_KEYS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"];
+const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const SLIDER = {
+ hue: {
+ MIN: "0",
+ MAX: "128",
+ STEP: "1",
+ },
+ alpha: {
+ MIN: "0",
+ MAX: "1",
+ STEP: "0.01",
+ },
+};
+
+/**
+ * Spectrum creates a color picker widget in any container you give it.
+ *
+ * Simple usage example:
+ *
+ * const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
+ * let s = new Spectrum(containerElement, [255, 126, 255, 1]);
+ * s.on("changed", (rgba, color) => {
+ * console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " +
+ * rgba[3] + ")");
+ * });
+ * s.show();
+ * s.destroy();
+ *
+ * Note that the color picker is hidden by default and you need to call show to
+ * make it appear. This 2 stages initialization helps in cases you are creating
+ * the color picker in a parent element that hasn't been appended anywhere yet
+ * or that is hidden. Calling show() when the parent element is appended and
+ * visible will allow spectrum to correctly initialize its various parts.
+ *
+ * Fires the following events:
+ * - changed : When the user changes the current color
+ */
+class Spectrum {
+ constructor(parentEl, rgb) {
+ EventEmitter.decorate(this);
+
+ this.document = parentEl.ownerDocument;
+ this.element = parentEl.ownerDocument.createElementNS(XHTML_NS, "div");
+ this.parentEl = parentEl;
+
+ this.element.className = "spectrum-container";
+ // eslint-disable-next-line no-unsanitized/property
+ this.element.innerHTML = `
+ <section class="spectrum-color-picker">
+ <div class="spectrum-color spectrum-box"
+ tabindex="0"
+ role="slider"
+ title="${L10N.getStr("colorPickerTooltip.spectrumDraggerTitle")}"
+ aria-describedby="spectrum-dragger">
+ <div class="spectrum-sat">
+ <div class="spectrum-val">
+ <div class="spectrum-dragger" id="spectrum-dragger"></div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="spectrum-controls">
+ <div class="spectrum-color-preview"></div>
+ <div class="spectrum-slider-container">
+ <div class="spectrum-hue spectrum-box"></div>
+ <div class="spectrum-alpha spectrum-checker spectrum-box"></div>
+ </div>
+ </section>
+ <section class="spectrum-color-contrast accessibility-color-contrast">
+ <div class="contrast-ratio-header-and-single-ratio">
+ <span class="contrast-ratio-label" role="presentation"></span>
+ <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ </div>
+ <div class="contrast-ratio-range">
+ <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ <span class="accessibility-color-contrast-separator"></span>
+ <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ </div>
+ </section>
+ `;
+
+ this.onElementClick = this.onElementClick.bind(this);
+ this.element.addEventListener("click", this.onElementClick);
+
+ this.parentEl.appendChild(this.element);
+
+ // Color spectrum dragger.
+ this.dragger = this.element.querySelector(".spectrum-color");
+ this.dragHelper = this.element.querySelector(".spectrum-dragger");
+ draggable(this.dragger, this.dragHelper, this.onDraggerMove.bind(this));
+
+ // Here we define the components for the "controls" section of the color picker.
+ this.controls = this.element.querySelector(".spectrum-controls");
+ this.colorPreview = this.element.querySelector(".spectrum-color-preview");
+
+ // Create the eyedropper.
+ const eyedropper = this.document.createElementNS(XHTML_NS, "button");
+ eyedropper.id = "eyedropper-button";
+ eyedropper.className = "devtools-button";
+ eyedropper.style.pointerEvents = "auto";
+ eyedropper.setAttribute(
+ "aria-label",
+ L10N.getStr("colorPickerTooltip.eyedropperTitle")
+ );
+ this.controls.insertBefore(eyedropper, this.colorPreview);
+
+ // Hue slider and alpha slider
+ this.hueSlider = this.createSlider("hue", this.onHueSliderMove.bind(this));
+ this.hueSlider.setAttribute("aria-describedby", this.dragHelper.id);
+ this.alphaSlider = this.createSlider(
+ "alpha",
+ this.onAlphaSliderMove.bind(this)
+ );
+
+ // Color contrast
+ this.spectrumContrast = this.element.querySelector(
+ ".spectrum-color-contrast"
+ );
+ this.contrastLabel = this.element.querySelector(".contrast-ratio-label");
+ [this.contrastValue, this.contrastValueMin, this.contrastValueMax] =
+ this.element.querySelectorAll(".accessibility-contrast-value");
+
+ // Create the learn more info button
+ const learnMore = this.document.createElementNS(XHTML_NS, "button");
+ learnMore.id = "learn-more-button";
+ learnMore.className = "learn-more";
+ learnMore.title = L10N.getStr("accessibility.learnMore");
+ this.element
+ .querySelector(".contrast-ratio-header-and-single-ratio")
+ .appendChild(learnMore);
+
+ if (rgb) {
+ this.rgb = rgb;
+ this.updateUI();
+ }
+ }
+
+ set textProps(style) {
+ this._textProps = style
+ ? {
+ fontSize: style["font-size"].value,
+ fontWeight: style["font-weight"].value,
+ opacity: style.opacity.value,
+ }
+ : null;
+ }
+
+ set rgb(color) {
+ this.hsv = rgbToHsv(color[0], color[1], color[2], color[3]);
+ }
+
+ set backgroundColorData(colorData) {
+ this._backgroundColorData = colorData;
+ }
+
+ get backgroundColorData() {
+ return this._backgroundColorData;
+ }
+
+ get textProps() {
+ return this._textProps;
+ }
+
+ get rgb() {
+ const rgb = hsvToRgb(this.hsv[0], this.hsv[1], this.hsv[2], this.hsv[3]);
+ return [
+ Math.round(rgb[0]),
+ Math.round(rgb[1]),
+ Math.round(rgb[2]),
+ Math.round(rgb[3] * 100) / 100,
+ ];
+ }
+
+ /**
+ * Map current rgb to the closest color available in the database by
+ * calculating the delta-E between each available color and the current rgb
+ *
+ * @return {String}
+ * Color name or closest color name
+ */
+ get colorName() {
+ const labColorEntries = Object.entries(labColors);
+
+ const deltaEs = labColorEntries.map(color =>
+ colorUtils.calculateDeltaE(color[1], colorUtils.rgbToLab(this.rgb))
+ );
+
+ // Get the color name for the one that has the lowest delta-E
+ const minDeltaE = Math.min(...deltaEs);
+ const colorName = labColorEntries[deltaEs.indexOf(minDeltaE)][0];
+ return minDeltaE === 0
+ ? colorName
+ : L10N.getFormatStr("colorPickerTooltip.colorNameTitle", colorName);
+ }
+
+ get rgbNoSatVal() {
+ const rgb = hsvToRgb(this.hsv[0], 1, 1);
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
+ }
+
+ get rgbCssString() {
+ const rgb = this.rgb;
+ return (
+ "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")"
+ );
+ }
+
+ show() {
+ this.dragWidth = this.dragger.offsetWidth;
+ this.dragHeight = this.dragger.offsetHeight;
+ this.dragHelperHeight = this.dragHelper.offsetHeight;
+
+ this.updateUI();
+ }
+
+ onElementClick(e) {
+ e.stopPropagation();
+ }
+
+ onHueSliderMove() {
+ this.hsv[0] = this.hueSlider.value / this.hueSlider.max;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onDraggerMove(dragX, dragY) {
+ this.hsv[1] = dragX / this.dragWidth;
+ this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onAlphaSliderMove() {
+ this.hsv[3] = this.alphaSlider.value / this.alphaSlider.max;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onChange() {
+ this.emit("changed", this.rgb, this.rgbCssString);
+ }
+
+ /**
+ * Creates and initializes a slider element, attaches it to its parent container
+ * based on the slider type and returns it
+ *
+ * @param {String} sliderType
+ * The type of the slider (i.e. alpha or hue)
+ * @param {Function} onSliderMove
+ * The function to tie the slider to on input
+ * @return {DOMNode}
+ * Newly created slider
+ */
+ createSlider(sliderType, onSliderMove) {
+ const container = this.element.querySelector(`.spectrum-${sliderType}`);
+
+ const slider = this.document.createElementNS(XHTML_NS, "input");
+ slider.className = `spectrum-${sliderType}-input`;
+ slider.type = "range";
+ slider.min = SLIDER[sliderType].MIN;
+ slider.max = SLIDER[sliderType].MAX;
+ slider.step = SLIDER[sliderType].STEP;
+ slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
+ slider.addEventListener("input", onSliderMove);
+
+ container.appendChild(slider);
+ return slider;
+ }
+
+ /**
+ * Updates the contrast label with appropriate content (i.e. large text indicator
+ * if the contrast is calculated for large text, or a base label otherwise)
+ *
+ * @param {Boolean} isLargeText
+ * True if contrast is calculated for large text.
+ */
+ updateContrastLabel(isLargeText) {
+ if (!isLargeText) {
+ this.contrastLabel.textContent = L10N.getStr(
+ "accessibility.contrast.ratio.label"
+ );
+ return;
+ }
+
+ // Clear previously appended children before appending any new children
+ while (this.contrastLabel.firstChild) {
+ this.contrastLabel.firstChild.remove();
+ }
+
+ const largeTextStr = L10N.getStr("accessibility.contrast.large.text");
+ const contrastLabelStr = L10N.getFormatStr(
+ "colorPickerTooltip.contrast.large.title",
+ largeTextStr
+ );
+
+ // Build an array of children nodes for the contrast label element
+ const contents = contrastLabelStr
+ .split(new RegExp(largeTextStr), 2)
+ .map(content => this.document.createTextNode(content));
+ const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span");
+ largeTextIndicator.className = "accessibility-color-contrast-large-text";
+ largeTextIndicator.textContent = largeTextStr;
+ largeTextIndicator.title = L10N.getStr(
+ "accessibility.contrast.large.title"
+ );
+ contents.splice(1, 0, largeTextIndicator);
+
+ // Append children to contrast label
+ for (const content of contents) {
+ this.contrastLabel.appendChild(content);
+ }
+ }
+
+ /**
+ * Updates a contrast value element with the given score, value and swatches.
+ *
+ * @param {DOMNode} el
+ * Contrast value element to update.
+ * @param {String} score
+ * Contrast ratio score.
+ * @param {Number} value
+ * Contrast ratio value.
+ * @param {Array} backgroundColor
+ * RGBA color array for the background color to show in the swatch.
+ */
+ updateContrastValueEl(el, score, value, backgroundColor) {
+ el.classList.toggle(score, true);
+ el.textContent = value.toFixed(2);
+ el.title = L10N.getFormatStr(
+ `accessibility.contrast.annotation.${score}`,
+ L10N.getFormatStr(
+ "colorPickerTooltip.contrastAgainstBgTitle",
+ `rgba(${backgroundColor})`
+ )
+ );
+ el.parentElement.style.setProperty(
+ "--accessibility-contrast-color",
+ this.rgbCssString
+ );
+ el.parentElement.style.setProperty(
+ "--accessibility-contrast-bg",
+ `rgba(${backgroundColor})`
+ );
+ }
+
+ updateAlphaSlider() {
+ // Set alpha slider background
+ const rgb = this.rgb;
+
+ const rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
+ const rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
+ const alphaGradient =
+ "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
+ this.alphaSlider.style.background = alphaGradient;
+ }
+
+ updateColorPreview() {
+ // Overlay the rgba color over a checkered image background.
+ this.colorPreview.style.setProperty("--overlay-color", this.rgbCssString);
+
+ // We should be able to distinguish the color preview on high luminance rgba values.
+ // Give the color preview a light grey border if the luminance of the current rgba
+ // tuple is great.
+ const colorLuminance = colorUtils.calculateLuminance(this.rgb);
+ this.colorPreview.classList.toggle("high-luminance", colorLuminance > 0.85);
+
+ // Set title on color preview for better UX
+ this.colorPreview.title = this.colorName;
+ }
+
+ updateDragger() {
+ // Set dragger background color
+ const flatColor =
+ "rgb(" +
+ this.rgbNoSatVal[0] +
+ ", " +
+ this.rgbNoSatVal[1] +
+ ", " +
+ this.rgbNoSatVal[2] +
+ ")";
+ this.dragger.style.backgroundColor = flatColor;
+
+ // Set dragger aria attributes
+ this.dragger.setAttribute("aria-valuetext", this.rgbCssString);
+ }
+
+ updateHueSlider() {
+ // Set hue slider aria attributes
+ this.hueSlider.setAttribute("aria-valuetext", this.rgbCssString);
+ }
+
+ updateHelperLocations() {
+ const h = this.hsv[0];
+ const s = this.hsv[1];
+ const v = this.hsv[2];
+
+ // Placing the color dragger
+ let dragX = s * this.dragWidth;
+ let dragY = this.dragHeight - v * this.dragHeight;
+ const helperDim = this.dragHelperHeight / 2;
+
+ dragX = Math.max(
+ -helperDim,
+ Math.min(this.dragWidth - helperDim, dragX - helperDim)
+ );
+ dragY = Math.max(
+ -helperDim,
+ Math.min(this.dragHeight - helperDim, dragY - helperDim)
+ );
+
+ this.dragHelper.style.top = dragY + "px";
+ this.dragHelper.style.left = dragX + "px";
+
+ // Placing the hue slider
+ this.hueSlider.value = h * this.hueSlider.max;
+
+ // Placing the alpha slider
+ this.alphaSlider.value = this.hsv[3] * this.alphaSlider.max;
+ }
+
+ /* Calculates the contrast ratio for the currently selected
+ * color against a single or range of background colors and displays contrast ratio section
+ * components depending on the contrast ratio calculated.
+ *
+ * Contrast ratio components include:
+ * - contrastLargeTextIndicator: Hidden by default, shown when text has large font
+ * size if there is no error in calculation.
+ * - contrastValue(s): Set to calculated value(s), score(s) and text color on
+ * background swatches. Set to error text
+ * if there is an error in calculation.
+ */
+ updateContrast() {
+ // Remove additional classes on spectrum contrast, leaving behind only base classes
+ this.spectrumContrast.classList.toggle("visible", false);
+ this.spectrumContrast.classList.toggle("range", false);
+ this.spectrumContrast.classList.toggle("error", false);
+ // Assign only base class to all contrastValues, removing any score class
+ this.contrastValue.className =
+ this.contrastValueMin.className =
+ this.contrastValueMax.className =
+ "accessibility-contrast-value";
+
+ if (!this.contrastEnabled) {
+ return;
+ }
+
+ const isRange = this.backgroundColorData.min !== undefined;
+ this.spectrumContrast.classList.toggle("visible", true);
+ this.spectrumContrast.classList.toggle("range", isRange);
+
+ const colorContrast = getContrastRatio(
+ {
+ ...this.textProps,
+ color: this.rgbCssString,
+ },
+ this.backgroundColorData
+ );
+
+ const {
+ value,
+ min,
+ max,
+ score,
+ scoreMin,
+ scoreMax,
+ backgroundColor,
+ backgroundColorMin,
+ backgroundColorMax,
+ isLargeText,
+ error,
+ } = colorContrast;
+
+ if (error) {
+ this.updateContrastLabel(false);
+ this.spectrumContrast.classList.toggle("error", true);
+
+ // If current background color is a range, show the error text in the contrast range
+ // span. Otherwise, show it in the single contrast span.
+ const contrastValEl = isRange
+ ? this.contrastValueMin
+ : this.contrastValue;
+ contrastValEl.textContent = L10N.getStr("accessibility.contrast.error");
+ contrastValEl.title = L10N.getStr(
+ "accessibility.contrast.annotation.transparent.error"
+ );
+
+ return;
+ }
+
+ this.updateContrastLabel(isLargeText);
+ if (!isRange) {
+ this.updateContrastValueEl(
+ this.contrastValue,
+ score,
+ value,
+ backgroundColor
+ );
+
+ return;
+ }
+
+ this.updateContrastValueEl(
+ this.contrastValueMin,
+ scoreMin,
+ min,
+ backgroundColorMin
+ );
+ this.updateContrastValueEl(
+ this.contrastValueMax,
+ scoreMax,
+ max,
+ backgroundColorMax
+ );
+ }
+
+ updateUI() {
+ this.updateHelperLocations();
+
+ this.updateColorPreview();
+ this.updateDragger();
+ this.updateHueSlider();
+ this.updateAlphaSlider();
+ this.updateContrast();
+ }
+
+ destroy() {
+ this.element.removeEventListener("click", this.onElementClick);
+ this.hueSlider.removeEventListener("input", this.onHueSliderMove);
+ this.alphaSlider.removeEventListener("input", this.onAlphaSliderMove);
+
+ this.parentEl.removeChild(this.element);
+
+ this.dragger = this.dragHelper = null;
+ this.alphaSlider = null;
+ this.hueSlider = null;
+ this.colorPreview = null;
+ this.element = null;
+ this.parentEl = null;
+ this.spectrumContrast = null;
+ this.contrastValue = this.contrastValueMin = this.contrastValueMax = null;
+ this.contrastLabel = null;
+ }
+}
+
+function hsvToRgb(h, s, v, a) {
+ let r, g, b;
+
+ const i = Math.floor(h * 6);
+ const f = h * 6 - i;
+ const p = v * (1 - s);
+ const q = v * (1 - f * s);
+ const t = v * (1 - (1 - f) * s);
+
+ switch (i % 6) {
+ case 0:
+ r = v;
+ g = t;
+ b = p;
+ break;
+ case 1:
+ r = q;
+ g = v;
+ b = p;
+ break;
+ case 2:
+ r = p;
+ g = v;
+ b = t;
+ break;
+ case 3:
+ r = p;
+ g = q;
+ b = v;
+ break;
+ case 4:
+ r = t;
+ g = p;
+ b = v;
+ break;
+ case 5:
+ r = v;
+ g = p;
+ b = q;
+ break;
+ }
+
+ return [r * 255, g * 255, b * 255, a];
+}
+
+function rgbToHsv(r, g, b, a) {
+ r = r / 255;
+ g = g / 255;
+ b = b / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+
+ const v = max;
+ const d = max - min;
+ const s = max == 0 ? 0 : d / max;
+
+ let h;
+ if (max == min) {
+ // achromatic
+ h = 0;
+ } else {
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+ h /= 6;
+ }
+ return [h, s, v, a];
+}
+
+function draggable(element, dragHelper, onmove) {
+ onmove = onmove || function () {};
+
+ const doc = element.ownerDocument;
+ let dragging = false;
+ let offset = {};
+ let maxHeight = 0;
+ let maxWidth = 0;
+
+ function setDraggerDimensionsAndOffset() {
+ maxHeight = element.offsetHeight;
+ maxWidth = element.offsetWidth;
+ offset = element.getBoundingClientRect();
+ }
+
+ function prevent(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ function move(e) {
+ if (dragging) {
+ if (e.buttons === 0) {
+ // The button is no longer pressed but we did not get a mouseup event.
+ stop();
+ return;
+ }
+ const pageX = e.pageX;
+ const pageY = e.pageY;
+
+ const dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
+ const dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+ }
+
+ function start(e) {
+ const rightClick = e.which === 3;
+
+ if (!rightClick && !dragging) {
+ dragging = true;
+ setDraggerDimensionsAndOffset();
+
+ move(e);
+
+ doc.addEventListener("selectstart", prevent);
+ doc.addEventListener("dragstart", prevent);
+ doc.addEventListener("mousemove", move);
+ doc.addEventListener("mouseup", stop);
+
+ prevent(e);
+ }
+ }
+
+ function stop() {
+ if (dragging) {
+ doc.removeEventListener("selectstart", prevent);
+ doc.removeEventListener("dragstart", prevent);
+ doc.removeEventListener("mousemove", move);
+ doc.removeEventListener("mouseup", stop);
+ }
+ dragging = false;
+ }
+
+ function onKeydown(e) {
+ const { key } = e;
+
+ if (!ARROW_KEYS.includes(key)) {
+ return;
+ }
+
+ setDraggerDimensionsAndOffset();
+ const { offsetHeight, offsetTop, offsetLeft } = dragHelper;
+ let dragX = offsetLeft + offsetHeight / 2;
+ let dragY = offsetTop + offsetHeight / 2;
+
+ if (key === ArrowLeft && dragX > 0) {
+ dragX -= 1;
+ } else if (key === ArrowRight && dragX < maxWidth) {
+ dragX += 1;
+ } else if (key === ArrowUp && dragY > 0) {
+ dragY -= 1;
+ } else if (key === ArrowDown && dragY < maxHeight) {
+ dragY += 1;
+ }
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+
+ element.addEventListener("mousedown", start);
+ element.addEventListener("keydown", onKeydown);
+}
+
+/**
+ * Calculates the contrast ratio for a DOM node's computed style against
+ * a given background.
+ *
+ * @param {Object} computedStyle
+ * The computed style for which we want to calculate the contrast ratio.
+ * @param {Object} backgroundColor
+ * Object with one or more of the following properties: value, min, max
+ * @return {Object}
+ * An object that may contain one or more of the following fields: error,
+ * isLargeText, value, score for contrast.
+ */
+function getContrastRatio(computedStyle, backgroundColor) {
+ const props = getTextProperties(computedStyle);
+
+ if (!props) {
+ return {
+ error: true,
+ };
+ }
+
+ return getContrastRatioAgainstBackground(backgroundColor, props);
+}
+
+module.exports = Spectrum;
diff --git a/devtools/client/shared/widgets/TableWidget.js b/devtools/client/shared/widgets/TableWidget.js
new file mode 100644
index 0000000000..d37559b587
--- /dev/null
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -0,0 +1,2031 @@
+/* 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("resource://devtools/shared/event-emitter.js");
+loader.lazyRequireGetter(
+ this,
+ ["clearNamedTimeout", "setNamedTimeout"],
+ "resource://devtools/client/shared/widgets/view-helpers.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "naturalSortCaseInsensitive",
+ "resource://devtools/shared/natural-sort.js",
+ true
+);
+loader.lazyGetter(this, "standardSessionString", () => {
+ const l10n = new Localization(["devtools/client/storage.ftl"], true);
+ return l10n.formatValueSync("storage-expires-session");
+});
+
+const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const AFTER_SCROLL_DELAY = 100;
+
+// Different types of events emitted by the Various components of the
+// TableWidget.
+const EVENTS = {
+ CELL_EDIT: "cell-edit",
+ COLUMN_SORTED: "column-sorted",
+ COLUMN_TOGGLED: "column-toggled",
+ FIELDS_EDITABLE: "fields-editable",
+ HEADER_CONTEXT_MENU: "header-context-menu",
+ ROW_EDIT: "row-edit",
+ ROW_CONTEXT_MENU: "row-context-menu",
+ ROW_REMOVED: "row-removed",
+ ROW_SELECTED: "row-selected",
+ ROW_UPDATED: "row-updated",
+ TABLE_CLEARED: "table-cleared",
+ TABLE_FILTERED: "table-filtered",
+ SCROLL_END: "scroll-end",
+};
+Object.defineProperty(this, "EVENTS", {
+ value: EVENTS,
+ enumerable: true,
+ writable: false,
+});
+
+/**
+ * A table widget with various features like resizble/toggleable columns,
+ * sorting, keyboard navigation etc.
+ *
+ * @param {Node} node
+ * The container element for the table widget.
+ * @param {object} options
+ * - initialColumns: map of key vs display name for initial columns of
+ * the table. See @setupColumns for more info.
+ * - uniqueId: the column which will be the unique identifier of each
+ * entry in the table. Default: name.
+ * - wrapTextInElements: Don't ever use 'value' attribute on labels.
+ * Default: false.
+ * - emptyText: Localization ID for the text to display when there are
+ * no entries in the table to display.
+ * - highlightUpdated: true to highlight the changed/added row.
+ * - removableColumns: Whether columns are removeable. If set to false,
+ * the context menu in the headers will not appear.
+ * - firstColumn: key of the first column that should appear.
+ * - cellContextMenuId: ID of a <menupopup> element to be set as a
+ * context menu of every cell.
+ */
+function TableWidget(node, options = {}) {
+ EventEmitter.decorate(this);
+
+ this.document = node.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = node;
+
+ const {
+ initialColumns,
+ emptyText,
+ uniqueId,
+ highlightUpdated,
+ removableColumns,
+ firstColumn,
+ wrapTextInElements,
+ cellContextMenuId,
+ l10n,
+ } = options;
+ this.emptyText = emptyText || "";
+ this.uniqueId = uniqueId || "name";
+ this.wrapTextInElements = wrapTextInElements || false;
+ this.firstColumn = firstColumn || "";
+ this.highlightUpdated = highlightUpdated || false;
+ this.removableColumns = removableColumns !== false;
+ this.cellContextMenuId = cellContextMenuId;
+ this.l10n = l10n;
+
+ this.tbody = this.document.createXULElement("hbox");
+ this.tbody.className = "table-widget-body theme-body";
+ this.tbody.setAttribute("flex", "1");
+ this.tbody.setAttribute("tabindex", "0");
+ this._parent.appendChild(this.tbody);
+ this.afterScroll = this.afterScroll.bind(this);
+ this.tbody.addEventListener("scroll", this.onScroll.bind(this));
+
+ // Prepare placeholder
+ this.placeholder = this.document.createElement("div");
+ this.placeholder.className = "plain table-widget-empty-text";
+ this._parent.appendChild(this.placeholder);
+ this.setPlaceholder(this.emptyText);
+
+ this.items = new Map();
+ this.columns = new Map();
+
+ // Setup the column headers context menu to allow users to hide columns at
+ // will.
+ if (this.removableColumns) {
+ this.onPopupCommand = this.onPopupCommand.bind(this);
+ this.setupHeadersContextMenu();
+ }
+
+ if (initialColumns) {
+ this.setColumns(initialColumns, uniqueId);
+ }
+
+ this.bindSelectedRow = id => {
+ this.selectedRow = id;
+ };
+ this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow);
+
+ this.onChange = this.onChange.bind(this);
+ this.onEditorDestroyed = this.onEditorDestroyed.bind(this);
+ this.onEditorTab = this.onEditorTab.bind(this);
+ this.onKeydown = this.onKeydown.bind(this);
+ this.onMousedown = this.onMousedown.bind(this);
+ this.onRowRemoved = this.onRowRemoved.bind(this);
+
+ this.document.addEventListener("keydown", this.onKeydown);
+ this.document.addEventListener("mousedown", this.onMousedown);
+}
+
+TableWidget.prototype = {
+ items: null,
+ editBookmark: null,
+ scrollIntoViewOnUpdate: null,
+
+ /**
+ * Return true if the table body has a scrollbar.
+ */
+ get hasScrollbar() {
+ return this.tbody.scrollHeight > this.tbody.clientHeight;
+ },
+
+ /**
+ * Getter for the headers context menu popup id.
+ */
+ get headersContextMenu() {
+ if (this.menupopup) {
+ return this.menupopup.id;
+ }
+ return null;
+ },
+
+ /**
+ * Select the row corresponding to the json object `id`
+ */
+ set selectedRow(id) {
+ for (const column of this.columns.values()) {
+ if (id || id === "") {
+ column.selectRow(id[this.uniqueId] || id);
+ } else {
+ column.selectedRow = null;
+ column.selectRow(null);
+ }
+ }
+ },
+
+ /**
+ * Is a row currently selected?
+ *
+ * @return {Boolean}
+ * true or false.
+ */
+ get hasSelectedRow() {
+ return (
+ this.columns.get(this.uniqueId) &&
+ this.columns.get(this.uniqueId).selectedRow
+ );
+ },
+
+ /**
+ * Returns the json object corresponding to the selected row.
+ */
+ get selectedRow() {
+ return this.items.get(this.columns.get(this.uniqueId).selectedRow);
+ },
+
+ /**
+ * Selects the row at index `index`.
+ */
+ set selectedIndex(index) {
+ for (const column of this.columns.values()) {
+ column.selectRowAt(index);
+ }
+ },
+
+ /**
+ * Returns the index of the selected row.
+ */
+ get selectedIndex() {
+ return this.columns.get(this.uniqueId).selectedIndex;
+ },
+
+ /**
+ * Returns the index of the selected row disregarding hidden rows.
+ */
+ get visibleSelectedIndex() {
+ const column = this.firstVisibleColumn;
+ const cells = column.visibleCellNodes;
+
+ for (let i = 0; i < cells.length; i++) {
+ if (cells[i].classList.contains("theme-selected")) {
+ return i;
+ }
+ }
+
+ return -1;
+ },
+
+ /**
+ * Returns the first visible column.
+ */
+ get firstVisibleColumn() {
+ for (const column of this.columns.values()) {
+ if (column._private) {
+ continue;
+ }
+
+ if (column.column.clientHeight > 0) {
+ return column;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * returns all editable columns.
+ */
+ get editableColumns() {
+ const filter = columns => {
+ columns = [...columns].filter(col => {
+ if (col.clientWidth === 0) {
+ return false;
+ }
+
+ const cell = col.querySelector(".table-widget-cell");
+
+ for (const selector of this._editableFieldsEngine.selectors) {
+ if (cell.matches(selector)) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ return columns;
+ };
+
+ const columns = this._parent.querySelectorAll(".table-widget-column");
+ return filter(columns);
+ },
+
+ /**
+ * Emit all cell edit events.
+ */
+ onChange(data) {
+ const changedField = data.change.field;
+ const colName = changedField.parentNode.id;
+ const column = this.columns.get(colName);
+ const uniqueId = column.table.uniqueId;
+ const itemIndex = column.cellNodes.indexOf(changedField);
+ const items = {};
+
+ for (const [name, col] of this.columns) {
+ items[name] = col.cellNodes[itemIndex].value;
+ }
+
+ const change = {
+ host: this.host,
+ key: uniqueId,
+ field: colName,
+ oldValue: data.change.oldValue,
+ newValue: data.change.newValue,
+ items,
+ };
+
+ // A rows position in the table can change as the result of an edit. In
+ // order to ensure that the correct row is highlighted after an edit we
+ // save the uniqueId in editBookmark.
+ this.editBookmark =
+ colName === uniqueId ? change.newValue : items[uniqueId];
+ this.emit(EVENTS.CELL_EDIT, change);
+ },
+
+ onEditorDestroyed() {
+ this._editableFieldsEngine = null;
+ },
+
+ /**
+ * Called by the inplace editor when Tab / Shift-Tab is pressed in edit-mode.
+ * Because tables are live any row, column, cell or table can be added,
+ * deleted or moved by deleting and adding e.g. a row again.
+ *
+ * This presents various challenges when navigating via the keyboard so please
+ * keep this in mind whenever editing this method.
+ *
+ * @param {Event} event
+ * Keydown event
+ */
+ onEditorTab(event) {
+ const textbox = event.target;
+ const editor = this._editableFieldsEngine;
+
+ if (textbox.id !== editor.INPUT_ID) {
+ return;
+ }
+
+ const column = textbox.parentNode;
+
+ // Changing any value can change the position of the row depending on which
+ // column it is currently sorted on. In addition to this, the table cell may
+ // have been edited and had to be recreated when the user has pressed tab or
+ // shift+tab. Both of these situations require us to recover our target,
+ // select the appropriate row and move the textbox on to the next cell.
+ if (editor.changePending) {
+ // We need to apply a change, which can mean that the position of cells
+ // within the table can change. Because of this we need to wait for
+ // EVENTS.ROW_EDIT and then move the textbox.
+ this.once(EVENTS.ROW_EDIT, uniqueId => {
+ let columnObj;
+ const cols = this.editableColumns;
+ let rowIndex = this.visibleSelectedIndex;
+ const colIndex = cols.indexOf(column);
+ let newIndex;
+
+ // If the row has been deleted we should bail out.
+ if (!uniqueId) {
+ return;
+ }
+
+ // Find the column we need to move to.
+ if (event.shiftKey) {
+ // Navigate backwards on shift tab.
+ if (colIndex === 0) {
+ if (rowIndex === 0) {
+ return;
+ }
+ newIndex = cols.length - 1;
+ } else {
+ newIndex = colIndex - 1;
+ }
+ } else if (colIndex === cols.length - 1) {
+ const id = cols[0].id;
+ columnObj = this.columns.get(id);
+ const maxRowIndex = columnObj.visibleCellNodes.length - 1;
+ if (rowIndex === maxRowIndex) {
+ return;
+ }
+ newIndex = 0;
+ } else {
+ newIndex = colIndex + 1;
+ }
+
+ const newcol = cols[newIndex];
+ columnObj = this.columns.get(newcol.id);
+
+ // Select the correct row even if it has moved due to sorting.
+ const dataId = editor.currentTarget.getAttribute("data-id");
+ if (this.items.get(dataId)) {
+ this.emit(EVENTS.ROW_SELECTED, dataId);
+ } else {
+ this.emit(EVENTS.ROW_SELECTED, uniqueId);
+ }
+
+ // EVENTS.ROW_SELECTED may have changed the selected row so let's save
+ // the result in rowIndex.
+ rowIndex = this.visibleSelectedIndex;
+
+ // Edit the appropriate cell.
+ const cells = columnObj.visibleCellNodes;
+ const cell = cells[rowIndex];
+ editor.edit(cell);
+
+ // Remove flash-out class... it won't have been auto-removed because the
+ // cell was hidden for editing.
+ cell.classList.remove("flash-out");
+ });
+ }
+
+ // Begin cell edit. We always do this so that we can begin editing even in
+ // the case that the previous edit will cause the row to move.
+ const cell = this.getEditedCellOnTab(event, column);
+ editor.edit(cell);
+
+ // Prevent default input tabbing behaviour
+ event.preventDefault();
+ },
+
+ /**
+ * Get the cell that will be edited next on tab / shift tab and highlight the
+ * appropriate row. Edits etc. are not taken into account.
+ *
+ * This is used to tab from one field to another without editing and makes the
+ * editor much more responsive.
+ *
+ * @param {Event} event
+ * Keydown event
+ */
+ getEditedCellOnTab(event, column) {
+ let cell = null;
+ const cols = this.editableColumns;
+ const rowIndex = this.visibleSelectedIndex;
+ const colIndex = cols.indexOf(column);
+ const maxCol = cols.length - 1;
+ const maxRow = this.columns.get(column.id).visibleCellNodes.length - 1;
+
+ if (event.shiftKey) {
+ // Navigate backwards on shift tab.
+ if (colIndex === 0) {
+ if (rowIndex === 0) {
+ this._editableFieldsEngine.completeEdit();
+ return null;
+ }
+
+ column = cols[cols.length - 1];
+ const cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex - 1];
+
+ const rowId = cell.getAttribute("data-id");
+ this.emit(EVENTS.ROW_SELECTED, rowId);
+ } else {
+ column = cols[colIndex - 1];
+ const cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex];
+ }
+ } else if (colIndex === maxCol) {
+ // If in the rightmost column on the last row stop editing.
+ if (rowIndex === maxRow) {
+ this._editableFieldsEngine.completeEdit();
+ return null;
+ }
+
+ // If in the rightmost column of a row then move to the first column of
+ // the next row.
+ column = cols[0];
+ const cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex + 1];
+
+ const rowId = cell.getAttribute("data-id");
+ this.emit(EVENTS.ROW_SELECTED, rowId);
+ } else {
+ // Navigate forwards on tab.
+ column = cols[colIndex + 1];
+ const cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex];
+ }
+
+ return cell;
+ },
+
+ /**
+ * Reset the editable fields engine if the currently edited row is removed.
+ *
+ * @param {String} event
+ * The event name "event-removed."
+ * @param {Object} row
+ * The values from the removed row.
+ */
+ onRowRemoved(row) {
+ if (!this._editableFieldsEngine || !this._editableFieldsEngine.isEditing) {
+ return;
+ }
+
+ const removedKey = row[this.uniqueId];
+ const column = this.columns.get(this.uniqueId);
+
+ if (removedKey in column.items) {
+ return;
+ }
+
+ // The target is lost so we need to hide the remove the textbox from the DOM
+ // and reset the target nodes.
+ this.onEditorTargetLost();
+ },
+
+ /**
+ * Cancel an edit because the edit target has been lost.
+ */
+ onEditorTargetLost() {
+ const editor = this._editableFieldsEngine;
+
+ if (!editor || !editor.isEditing) {
+ return;
+ }
+
+ editor.cancelEdit();
+ },
+
+ /**
+ * Keydown event handler for the table. Used for keyboard navigation amongst
+ * rows.
+ */
+ onKeydown(event) {
+ // If we are in edit mode bail out.
+ if (this._editableFieldsEngine && this._editableFieldsEngine.isEditing) {
+ return;
+ }
+
+ // We need to get the first *visible* selected cell. Some columns are hidden
+ // e.g. because they contain a unique compound key for cookies that is never
+ // displayed in the UI. To do this we get all selected cells and filter out
+ // any that are hidden.
+ const selectedCells = [
+ ...this.tbody.querySelectorAll(".theme-selected"),
+ ].filter(cell => cell.clientWidth > 0);
+ // Select the first visible selected cell.
+ const selectedCell = selectedCells[0];
+ if (!selectedCell) {
+ return;
+ }
+
+ let colName;
+ let column;
+ let visibleCells;
+ let index;
+ let cell;
+
+ switch (event.keyCode) {
+ case KeyCodes.DOM_VK_UP:
+ event.preventDefault();
+
+ colName = selectedCell.parentNode.id;
+ column = this.columns.get(colName);
+ visibleCells = column.visibleCellNodes;
+ index = visibleCells.indexOf(selectedCell);
+
+ if (index > 0) {
+ index--;
+ } else {
+ index = visibleCells.length - 1;
+ }
+
+ cell = visibleCells[index];
+
+ this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id"));
+ break;
+ case KeyCodes.DOM_VK_DOWN:
+ event.preventDefault();
+
+ colName = selectedCell.parentNode.id;
+ column = this.columns.get(colName);
+ visibleCells = column.visibleCellNodes;
+ index = visibleCells.indexOf(selectedCell);
+
+ if (index === visibleCells.length - 1) {
+ index = 0;
+ } else {
+ index++;
+ }
+
+ cell = visibleCells[index];
+
+ this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id"));
+ break;
+ }
+ },
+
+ /**
+ * Close any editors if the area "outside the table" is clicked. In reality,
+ * the table covers the whole area but there are labels filling the top few
+ * rows. This method clears any inline editors if an area outside a textbox or
+ * label is clicked.
+ */
+ onMousedown({ target }) {
+ const localName = target.localName;
+
+ if (localName === "input" || !this._editableFieldsEngine) {
+ return;
+ }
+
+ // Force any editor fields to hide due to XUL focus quirks.
+ this._editableFieldsEngine.blur();
+ },
+
+ /**
+ * Make table fields editable.
+ *
+ * @param {String|Array} editableColumns
+ * An array or comma separated list of editable column names.
+ */
+ makeFieldsEditable(editableColumns) {
+ const selectors = [];
+
+ if (typeof editableColumns === "string") {
+ editableColumns = [editableColumns];
+ }
+
+ for (const id of editableColumns) {
+ selectors.push("#" + id + " .table-widget-cell");
+ }
+
+ for (const [name, column] of this.columns) {
+ if (!editableColumns.includes(name)) {
+ column.column.setAttribute("readonly", "");
+ }
+ }
+
+ if (this._editableFieldsEngine) {
+ this._editableFieldsEngine.selectors = selectors;
+ this._editableFieldsEngine.items = this.items;
+ } else {
+ this._editableFieldsEngine = new EditableFieldsEngine({
+ root: this.tbody,
+ onTab: this.onEditorTab,
+ onTriggerEvent: "dblclick",
+ selectors,
+ items: this.items,
+ });
+
+ this._editableFieldsEngine.on("change", this.onChange);
+ this._editableFieldsEngine.on("destroyed", this.onEditorDestroyed);
+
+ this.on(EVENTS.ROW_REMOVED, this.onRowRemoved);
+ this.on(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit);
+
+ this.emit(EVENTS.FIELDS_EDITABLE, this._editableFieldsEngine);
+ }
+ },
+
+ destroy() {
+ this.off(EVENTS.ROW_SELECTED, this.bindSelectedRow);
+ this.off(EVENTS.ROW_REMOVED, this.onRowRemoved);
+
+ this.document.removeEventListener("keydown", this.onKeydown);
+ this.document.removeEventListener("mousedown", this.onMousedown);
+
+ if (this._editableFieldsEngine) {
+ this.off(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit);
+ this._editableFieldsEngine.off("change", this.onChange);
+ this._editableFieldsEngine.off("destroyed", this.onEditorDestroyed);
+ this._editableFieldsEngine.destroy();
+ this._editableFieldsEngine = null;
+ }
+
+ if (this.menupopup) {
+ this.menupopup.removeEventListener("command", this.onPopupCommand);
+ this.menupopup.remove();
+ }
+ },
+
+ /**
+ * Sets the localization ID of the description to be shown when the table is empty.
+ *
+ * @param {String} l10nID
+ * The ID of the localization string.
+ * @param {String} learnMoreURL
+ * A URL referring to a website with further information related to
+ * the data shown in the table widget.
+ */
+ setPlaceholder(l10nID, learnMoreURL) {
+ if (learnMoreURL) {
+ let placeholderLink = this.placeholder.firstElementChild;
+ if (!placeholderLink) {
+ placeholderLink = this.document.createElement("a");
+ placeholderLink.setAttribute("target", "_blank");
+ placeholderLink.setAttribute("data-l10n-name", "learn-more-link");
+ this.placeholder.appendChild(placeholderLink);
+ }
+ placeholderLink.setAttribute("href", learnMoreURL);
+ } else {
+ // Remove link element if no learn more URL is given
+ this.placeholder.firstElementChild?.remove();
+ }
+
+ this.l10n.setAttributes(this.placeholder, l10nID);
+ },
+
+ /**
+ * Prepares the context menu for the headers of the table columns. This
+ * context menu allows users to toggle various columns, only with an exception
+ * of the unique columns and when only two columns are visible in the table.
+ */
+ setupHeadersContextMenu() {
+ let popupset = this.document.getElementsByTagName("popupset")[0];
+ if (!popupset) {
+ popupset = this.document.createXULElement("popupset");
+ this.document.documentElement.appendChild(popupset);
+ }
+
+ this.menupopup = this.document.createXULElement("menupopup");
+ this.menupopup.id = "table-widget-column-select";
+ this.menupopup.addEventListener("command", this.onPopupCommand);
+ popupset.appendChild(this.menupopup);
+ this.populateMenuPopup();
+ },
+
+ /**
+ * Populates the header context menu with the names of the columns along with
+ * displaying which columns are hidden or visible.
+ *
+ * @param {Array} privateColumns=[]
+ * An array of column names that should never appear in the table. This
+ * allows us to e.g. have an invisible compound primary key for a
+ * table's rows.
+ */
+ populateMenuPopup(privateColumns = []) {
+ if (!this.menupopup) {
+ return;
+ }
+
+ while (this.menupopup.firstChild) {
+ this.menupopup.firstChild.remove();
+ }
+
+ for (const column of this.columns.values()) {
+ if (privateColumns.includes(column.id)) {
+ continue;
+ }
+
+ const menuitem = this.document.createXULElement("menuitem");
+ menuitem.setAttribute("label", column.header.getAttribute("value"));
+ menuitem.setAttribute("data-id", column.id);
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("checked", !column.hidden);
+ if (column.id == this.uniqueId) {
+ menuitem.setAttribute("disabled", "true");
+ }
+ this.menupopup.appendChild(menuitem);
+ }
+ const checked = this.menupopup.querySelectorAll("menuitem[checked]");
+ if (checked.length == 2) {
+ checked[checked.length - 1].setAttribute("disabled", "true");
+ }
+ },
+
+ /**
+ * Event handler for the `command` event on the column headers context menu
+ */
+ onPopupCommand(event) {
+ const item = event.originalTarget;
+ let checked = !!item.getAttribute("checked");
+ const id = item.getAttribute("data-id");
+ this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked);
+ checked = this.menupopup.querySelectorAll("menuitem[checked]");
+ const disabled = this.menupopup.querySelectorAll("menuitem[disabled]");
+ if (checked.length == 2) {
+ checked[checked.length - 1].setAttribute("disabled", "true");
+ } else if (disabled.length > 1) {
+ disabled[disabled.length - 1].removeAttribute("disabled");
+ }
+ },
+
+ /**
+ * Creates the columns in the table. Without calling this method, data cannot
+ * be inserted into the table unless `initialColumns` was supplied.
+ *
+ * @param {Object} columns
+ * A key value pair representing the columns of the table. Where the
+ * key represents the id of the column and the value is the displayed
+ * label in the header of the column.
+ * @param {String} sortOn
+ * The id of the column on which the table will be initially sorted on.
+ * @param {Array} hiddenColumns
+ * Ids of all the columns that are hidden by default.
+ * @param {Array} privateColumns=[]
+ * An array of column names that should never appear in the table. This
+ * allows us to e.g. have an invisible compound primary key for a
+ * table's rows.
+ */
+ setColumns(
+ columns,
+ sortOn = this.sortedOn,
+ hiddenColumns = [],
+ privateColumns = []
+ ) {
+ for (const column of this.columns.values()) {
+ column.destroy();
+ }
+
+ this.columns.clear();
+
+ if (!(sortOn in columns)) {
+ sortOn = null;
+ }
+
+ if (!(this.firstColumn in columns)) {
+ this.firstColumn = null;
+ }
+
+ if (this.firstColumn) {
+ this.columns.set(
+ this.firstColumn,
+ new Column(this, this.firstColumn, columns[this.firstColumn])
+ );
+ }
+
+ for (const id in columns) {
+ if (!sortOn) {
+ sortOn = id;
+ }
+
+ if (this.firstColumn && id == this.firstColumn) {
+ continue;
+ }
+
+ this.columns.set(id, new Column(this, id, columns[id]));
+ if (hiddenColumns.includes(id) || privateColumns.includes(id)) {
+ // Hide the column.
+ this.columns.get(id).toggleColumn();
+
+ if (privateColumns.includes(id)) {
+ this.columns.get(id).private = true;
+ }
+ }
+ }
+ this.sortedOn = sortOn;
+ this.sortBy(this.sortedOn);
+ this.populateMenuPopup(privateColumns);
+ },
+
+ /**
+ * Returns true if the passed string or the row json object corresponds to the
+ * selected item in the table.
+ */
+ isSelected(item) {
+ if (typeof item == "object") {
+ item = item[this.uniqueId];
+ }
+
+ return this.selectedRow && item == this.selectedRow[this.uniqueId];
+ },
+
+ /**
+ * Selects the row corresponding to the `id` json.
+ */
+ selectRow(id) {
+ this.selectedRow = id;
+ },
+
+ /**
+ * Selects the next row. Cycles over to the first row if last row is selected
+ */
+ selectNextRow() {
+ for (const column of this.columns.values()) {
+ column.selectNextRow();
+ }
+ },
+
+ /**
+ * Selects the previous row. Cycles over to the last row if first row is
+ * selected.
+ */
+ selectPreviousRow() {
+ for (const column of this.columns.values()) {
+ column.selectPreviousRow();
+ }
+ },
+
+ /**
+ * Clears any selected row.
+ */
+ clearSelection() {
+ this.selectedIndex = -1;
+ },
+
+ /**
+ * Adds a row into the table.
+ *
+ * @param {object} item
+ * The object from which the key-value pairs will be taken and added
+ * into the row. This object can have any arbitarary key value pairs,
+ * but only those will be used whose keys match to the ids of the
+ * columns.
+ * @param {boolean} suppressFlash
+ * true to not flash the row while inserting the row.
+ */
+ push(item, suppressFlash) {
+ if (!this.sortedOn || !this.columns) {
+ console.error("Can't insert item without defining columns first");
+ return;
+ }
+
+ if (this.items.has(item[this.uniqueId])) {
+ this.update(item);
+ return;
+ }
+
+ if (this.editBookmark && !this.items.has(this.editBookmark)) {
+ // Key has been updated... update bookmark.
+ this.editBookmark = item[this.uniqueId];
+ }
+
+ const index = this.columns.get(this.sortedOn).push(item);
+ for (const [key, column] of this.columns) {
+ if (key != this.sortedOn) {
+ column.insertAt(item, index);
+ }
+ column.updateZebra();
+ }
+ this.items.set(item[this.uniqueId], item);
+ this.tbody.removeAttribute("empty");
+
+ if (!suppressFlash) {
+ this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
+ }
+
+ this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]);
+ },
+
+ /**
+ * Removes the row associated with the `item` object.
+ */
+ remove(item) {
+ if (typeof item != "object") {
+ item = this.items.get(item);
+ }
+ if (!item) {
+ return;
+ }
+ const removed = this.items.delete(item[this.uniqueId]);
+
+ if (!removed) {
+ return;
+ }
+ for (const column of this.columns.values()) {
+ column.remove(item);
+ column.updateZebra();
+ }
+ if (this.items.size === 0) {
+ this.selectedRow = null;
+ this.tbody.setAttribute("empty", "empty");
+ }
+
+ this.emit(EVENTS.ROW_REMOVED, item);
+ },
+
+ /**
+ * Updates the items in the row corresponding to the `item` object previously
+ * used to insert the row using `push` method. The linking is done via the
+ * `uniqueId` key's value.
+ */
+ update(item) {
+ const oldItem = this.items.get(item[this.uniqueId]);
+ if (!oldItem) {
+ return;
+ }
+ this.items.set(item[this.uniqueId], item);
+
+ let changed = false;
+ for (const column of this.columns.values()) {
+ if (item[column.id] != oldItem[column.id]) {
+ column.update(item);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
+ this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]);
+ }
+ },
+
+ /**
+ * Removes all of the rows from the table.
+ */
+ clear() {
+ this.items.clear();
+ for (const column of this.columns.values()) {
+ column.clear();
+ }
+ this.tbody.setAttribute("empty", "empty");
+ this.setPlaceholder(this.emptyText);
+
+ this.selectedRow = null;
+
+ this.emit(EVENTS.TABLE_CLEARED, this);
+ },
+
+ /**
+ * Sorts the table by a given column.
+ *
+ * @param {string} column
+ * The id of the column on which the table should be sorted.
+ */
+ sortBy(column) {
+ this.emit(EVENTS.COLUMN_SORTED, column);
+ this.sortedOn = column;
+
+ if (!this.items.size) {
+ return;
+ }
+
+ // First sort the column to "sort by" explicitly.
+ const sortedItems = this.columns.get(column).sort([...this.items.values()]);
+
+ // Then, sort all the other columns (id !== column) only based on the
+ // sortedItems provided by the first sort.
+ // Each column keeps track of the fact that it is the "sort by" column or
+ // not, so this will not shuffle the items and will just make sure each
+ // column displays the correct value.
+ for (const [id, col] of this.columns) {
+ if (id !== column) {
+ col.sort(sortedItems);
+ }
+ }
+ },
+
+ /**
+ * Filters the table based on a specific value
+ *
+ * @param {String} value: The filter value
+ * @param {Array} ignoreProps: Props to ignore while filtering
+ */
+ filterItems(value, ignoreProps = []) {
+ if (this.filteredValue == value) {
+ return;
+ }
+ if (this._editableFieldsEngine) {
+ this._editableFieldsEngine.completeEdit();
+ }
+
+ this.filteredValue = value;
+ if (!value) {
+ this.emit(EVENTS.TABLE_FILTERED, []);
+ return;
+ }
+ // Shouldn't be case-sensitive
+ value = value.toLowerCase();
+
+ const itemsToHide = [...this.items.keys()];
+ // Loop through all items and hide unmatched items
+ for (const [id, val] of this.items) {
+ for (const prop in val) {
+ const column = this.columns.get(prop);
+ if (ignoreProps.includes(prop) || column.hidden) {
+ continue;
+ }
+
+ const propValue = val[prop].toString().toLowerCase();
+ if (propValue.includes(value)) {
+ itemsToHide.splice(itemsToHide.indexOf(id), 1);
+ break;
+ }
+ }
+ }
+ this.emit(EVENTS.TABLE_FILTERED, itemsToHide);
+ },
+
+ /**
+ * Calls the afterScroll function when the user has stopped scrolling
+ */
+ onScroll() {
+ clearNamedTimeout("table-scroll");
+ setNamedTimeout("table-scroll", AFTER_SCROLL_DELAY, this.afterScroll);
+ },
+
+ /**
+ * Emits the "scroll-end" event when the whole table is scrolled
+ */
+ afterScroll() {
+ const maxScrollTop = this.tbody.scrollHeight - this.tbody.clientHeight;
+ // Emit scroll-end event when 9/10 of the table is scrolled
+ if (this.tbody.scrollTop >= 0.9 * maxScrollTop) {
+ this.emit("scroll-end");
+ }
+ },
+};
+
+TableWidget.EVENTS = EVENTS;
+
+module.exports.TableWidget = TableWidget;
+
+/**
+ * A single column object in the table.
+ *
+ * @param {TableWidget} table
+ * The table object to which the column belongs.
+ * @param {string} id
+ * Id of the column.
+ * @param {String} header
+ * The displayed string on the column's header.
+ */
+function Column(table, id, header) {
+ // By default cells are visible in the UI.
+ this._private = false;
+
+ this.tbody = table.tbody;
+ this.document = table.document;
+ this.window = table.window;
+ this.id = id;
+ this.uniqueId = table.uniqueId;
+ this.wrapTextInElements = table.wrapTextInElements;
+ this.table = table;
+ this.cells = [];
+ this.items = {};
+
+ this.highlightUpdated = table.highlightUpdated;
+
+ this.column = this.document.createElementNS(HTML_NS, "div");
+ this.column.id = id;
+ this.column.className = "table-widget-column";
+ this.tbody.appendChild(this.column);
+
+ this.splitter = this.document.createXULElement("splitter");
+ this.splitter.className = "devtools-side-splitter";
+ this.tbody.appendChild(this.splitter);
+
+ this.header = this.document.createXULElement("label");
+ this.header.className = "devtools-toolbar table-widget-column-header";
+ this.header.setAttribute("value", header);
+ this.column.appendChild(this.header);
+ if (table.headersContextMenu) {
+ this.header.setAttribute("context", table.headersContextMenu);
+ }
+ this.toggleColumn = this.toggleColumn.bind(this);
+ this.table.on(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn);
+
+ this.onColumnSorted = this.onColumnSorted.bind(this);
+ this.table.on(EVENTS.COLUMN_SORTED, this.onColumnSorted);
+
+ this.onRowUpdated = this.onRowUpdated.bind(this);
+ this.table.on(EVENTS.ROW_UPDATED, this.onRowUpdated);
+
+ this.onTableFiltered = this.onTableFiltered.bind(this);
+ this.table.on(EVENTS.TABLE_FILTERED, this.onTableFiltered);
+
+ this.onClick = this.onClick.bind(this);
+ this.onMousedown = this.onMousedown.bind(this);
+ this.column.addEventListener("click", this.onClick);
+ this.column.addEventListener("mousedown", this.onMousedown);
+}
+
+Column.prototype = {
+ // items is a cell-id to cell-index map. It is basically a reverse map of the
+ // this.cells object and is used to quickly reverse lookup a cell by its id
+ // instead of looping through the cells array. This reverse map is not kept
+ // upto date in sync with the cells array as updating it is in itself a loop
+ // through all the cells of the columns. Thus update it on demand when it goes
+ // out of sync with this.cells.
+ items: null,
+
+ // _itemsDirty is a flag which becomes true when this.items goes out of sync
+ // with this.cells
+ _itemsDirty: null,
+
+ selectedRow: null,
+
+ cells: null,
+
+ /**
+ * Gets whether the table is sorted on this column or not.
+ * 0 - not sorted.
+ * 1 - ascending order
+ * 2 - descending order
+ */
+ get sorted() {
+ return this._sortState || 0;
+ },
+
+ /**
+ * Returns a boolean indicating whether the column is hidden.
+ */
+ get hidden() {
+ return this.column.hidden;
+ },
+
+ /**
+ * Get the private state of the column (visibility in the UI).
+ */
+ get private() {
+ return this._private;
+ },
+
+ /**
+ * Set the private state of the column (visibility in the UI).
+ *
+ * @param {Boolean} state
+ * Private (true or false)
+ */
+ set private(state) {
+ this._private = state;
+ },
+
+ /**
+ * Sets the sorted value
+ */
+ set sorted(value) {
+ if (!value) {
+ this.header.removeAttribute("sorted");
+ } else {
+ this.header.setAttribute(
+ "sorted",
+ value == 1 ? "ascending" : "descending"
+ );
+ }
+ this._sortState = value;
+ },
+
+ /**
+ * Gets the selected row in the column.
+ */
+ get selectedIndex() {
+ if (!this.selectedRow) {
+ return -1;
+ }
+ return this.items[this.selectedRow];
+ },
+
+ get cellNodes() {
+ return [...this.column.querySelectorAll(".table-widget-cell")];
+ },
+
+ get visibleCellNodes() {
+ const editor = this.table._editableFieldsEngine;
+ const nodes = this.cellNodes.filter(node => {
+ // If the cell is currently being edited we should class it as visible.
+ if (editor && editor.currentTarget === node) {
+ return true;
+ }
+ return node.clientWidth !== 0;
+ });
+
+ return nodes;
+ },
+
+ /**
+ * Called when the column is sorted by.
+ *
+ * @param {string} column
+ * The id of the column being sorted by.
+ */
+ onColumnSorted(column) {
+ if (column != this.id) {
+ this.sorted = 0;
+ return;
+ } else if (this.sorted == 0 || this.sorted == 2) {
+ this.sorted = 1;
+ } else {
+ this.sorted = 2;
+ }
+ this.updateZebra();
+ },
+
+ onTableFiltered(itemsToHide) {
+ this._updateItems();
+ if (!this.cells) {
+ return;
+ }
+ for (const cell of this.cells) {
+ cell.hidden = false;
+ }
+ for (const id of itemsToHide) {
+ this.cells[this.items[id]].hidden = true;
+ }
+ this.updateZebra();
+ },
+
+ /**
+ * Called when a row is updated e.g. a cell is changed. This means that
+ * for a new row this method will be called once for each column. If a single
+ * cell is changed this method will be called just once.
+ *
+ * @param {string} event
+ * The event name of the event. i.e. EVENTS.ROW_UPDATED
+ * @param {string} id
+ * The unique id of the object associated with the row.
+ */
+ onRowUpdated(id) {
+ this._updateItems();
+
+ if (this.highlightUpdated && this.items[id] != null) {
+ if (this.table.scrollIntoViewOnUpdate) {
+ const cell = this.cells[this.items[id]];
+
+ // When a new row is created this method is called once for each column
+ // as each cell is updated. We can only scroll to cells if they are
+ // visible. We check for visibility and once we find the first visible
+ // cell in a row we scroll it into view and reset the
+ // scrollIntoViewOnUpdate flag.
+ if (cell.label.clientHeight > 0) {
+ cell.scrollIntoView();
+
+ this.table.scrollIntoViewOnUpdate = null;
+ }
+ }
+
+ if (this.table.editBookmark) {
+ // A rows position in the table can change as the result of an edit. In
+ // order to ensure that the correct row is highlighted after an edit we
+ // save the uniqueId in editBookmark. Here we send the signal that the
+ // row has been edited and that the row needs to be selected again.
+ this.table.emit(EVENTS.ROW_SELECTED, this.table.editBookmark);
+ this.table.editBookmark = null;
+ }
+
+ this.cells[this.items[id]].flash();
+ }
+
+ this.updateZebra();
+ },
+
+ destroy() {
+ this.table.off(EVENTS.COLUMN_SORTED, this.onColumnSorted);
+ this.table.off(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn);
+ this.table.off(EVENTS.ROW_UPDATED, this.onRowUpdated);
+ this.table.off(EVENTS.TABLE_FILTERED, this.onTableFiltered);
+
+ this.column.removeEventListener("click", this.onClick);
+ this.column.removeEventListener("mousedown", this.onMousedown);
+
+ this.splitter.remove();
+ this.column.remove();
+ this.cells = null;
+ this.items = null;
+ this.selectedRow = null;
+ },
+
+ /**
+ * Selects the row at the `index` index
+ */
+ selectRowAt(index) {
+ if (this.selectedRow != null) {
+ this.cells[this.items[this.selectedRow]].classList.remove(
+ "theme-selected"
+ );
+ }
+
+ const cell = this.cells[index];
+ if (cell) {
+ cell.classList.add("theme-selected");
+ this.selectedRow = cell.id;
+ } else {
+ this.selectedRow = null;
+ }
+ },
+
+ /**
+ * Selects the row with the object having the `uniqueId` value as `id`
+ */
+ selectRow(id) {
+ this._updateItems();
+ this.selectRowAt(this.items[id]);
+ },
+
+ /**
+ * Selects the next row. Cycles to first if last row is selected.
+ */
+ selectNextRow() {
+ this._updateItems();
+ let index = this.items[this.selectedRow] + 1;
+ if (index == this.cells.length) {
+ index = 0;
+ }
+ this.selectRowAt(index);
+ },
+
+ /**
+ * Selects the previous row. Cycles to last if first row is selected.
+ */
+ selectPreviousRow() {
+ this._updateItems();
+ let index = this.items[this.selectedRow] - 1;
+ if (index == -1) {
+ index = this.cells.length - 1;
+ }
+ this.selectRowAt(index);
+ },
+
+ /**
+ * Pushes the `item` object into the column. If this column is sorted on,
+ * then inserts the object at the right position based on the column's id
+ * key's value.
+ *
+ * @returns {number}
+ * The index of the currently pushed item.
+ */
+ push(item) {
+ const value = item[this.id];
+
+ if (this.sorted) {
+ let index;
+ if (this.sorted == 1) {
+ index = this.cells.findIndex(element => {
+ return (
+ naturalSortCaseInsensitive(
+ value,
+ element.value,
+ standardSessionString
+ ) === -1
+ );
+ });
+ } else {
+ index = this.cells.findIndex(element => {
+ return (
+ naturalSortCaseInsensitive(
+ value,
+ element.value,
+ standardSessionString
+ ) === 1
+ );
+ });
+ }
+ index = index >= 0 ? index : this.cells.length;
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.items[item[this.uniqueId]] = index;
+ this.cells.splice(index, 0, new Cell(this, item, this.cells[index]));
+ return index;
+ }
+
+ this.items[item[this.uniqueId]] = this.cells.length;
+ return this.cells.push(new Cell(this, item)) - 1;
+ },
+
+ /**
+ * Inserts the `item` object at the given `index` index in the table.
+ */
+ insertAt(item, index) {
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.items[item[this.uniqueId]] = index;
+ this.cells.splice(index, 0, new Cell(this, item, this.cells[index]));
+ this.updateZebra();
+ },
+
+ /**
+ * Event handler for the command event coming from the header context menu.
+ * Toggles the column if it was requested by the user.
+ * When called explicitly without parameters, it toggles the corresponding
+ * column.
+ *
+ * @param {string} event
+ * The name of the event. i.e. EVENTS.HEADER_CONTEXT_MENU
+ * @param {string} id
+ * Id of the column to be toggled
+ * @param {string} checked
+ * true if the column is visible
+ */
+ toggleColumn(id, checked) {
+ if (!arguments.length) {
+ // Act like a toggling method when called with no params
+ id = this.id;
+ checked = this.column.hidden;
+ }
+ if (id != this.id) {
+ return;
+ }
+ if (checked) {
+ this.column.hidden = false;
+ this.tbody.insertBefore(this.splitter, this.column.nextSibling);
+ } else {
+ this.column.hidden = true;
+ this.splitter.remove();
+ }
+ },
+
+ /**
+ * Removes the corresponding item from the column and hide the last visible
+ * splitter with CSS, so we do not add splitter elements for hidden columns.
+ */
+ remove(item) {
+ this._updateItems();
+ const index = this.items[item[this.uniqueId]];
+ if (index == null) {
+ return;
+ }
+
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.cells[index].destroy();
+ this.cells.splice(index, 1);
+ delete this.items[item[this.uniqueId]];
+ },
+
+ /**
+ * Updates the corresponding item from the column.
+ */
+ update(item) {
+ this._updateItems();
+
+ const index = this.items[item[this.uniqueId]];
+ if (index == null) {
+ return;
+ }
+
+ this.cells[index].value = item[this.id];
+ },
+
+ /**
+ * Updates the `this.items` cell-id vs cell-index map to be in sync with
+ * `this.cells`.
+ */
+ _updateItems() {
+ if (!this._itemsDirty) {
+ return;
+ }
+ for (let i = 0; i < this.cells.length; i++) {
+ this.items[this.cells[i].id] = i;
+ }
+ this._itemsDirty = false;
+ },
+
+ /**
+ * Clears the current column
+ */
+ clear() {
+ this.cells = [];
+ this.items = {};
+ this._itemsDirty = false;
+ while (this.header.nextSibling) {
+ this.header.nextSibling.remove();
+ }
+ },
+
+ /**
+ * Sorts the given items and returns the sorted list if the table was sorted
+ * by this column.
+ */
+ sort(items) {
+ // Only sort the array if we are sorting based on this column
+ if (this.sorted == 1) {
+ items.sort((a, b) => {
+ const val1 = Node.isInstance(a[this.id])
+ ? a[this.id].textContent
+ : a[this.id];
+ const val2 = Node.isInstance(b[this.id])
+ ? b[this.id].textContent
+ : b[this.id];
+ return naturalSortCaseInsensitive(val1, val2, standardSessionString);
+ });
+ } else if (this.sorted > 1) {
+ items.sort((a, b) => {
+ const val1 = Node.isInstance(a[this.id])
+ ? a[this.id].textContent
+ : a[this.id];
+ const val2 = Node.isInstance(b[this.id])
+ ? b[this.id].textContent
+ : b[this.id];
+ return naturalSortCaseInsensitive(val2, val1, standardSessionString);
+ });
+ }
+
+ if (this.selectedRow) {
+ this.cells[this.items[this.selectedRow]].classList.remove(
+ "theme-selected"
+ );
+ }
+ this.items = {};
+ // Otherwise, just use the sorted array passed to update the cells value.
+ for (const [i, item] of items.entries()) {
+ // See Bug 1706679 (Intermittent)
+ // Sometimes we would reach the situation in which we were trying to sort
+ // and item that was no longer available in the TableWidget.
+ // We should find exactly what is triggering it.
+ if (!this.cells[i]) {
+ continue;
+ }
+ this.items[item[this.uniqueId]] = i;
+ this.cells[i].value = item[this.id];
+ this.cells[i].id = item[this.uniqueId];
+ }
+ if (this.selectedRow) {
+ this.cells[this.items[this.selectedRow]].classList.add("theme-selected");
+ }
+ this._itemsDirty = false;
+ this.updateZebra();
+ return items;
+ },
+
+ updateZebra() {
+ this._updateItems();
+ let i = 0;
+ for (const cell of this.cells) {
+ if (!cell.hidden) {
+ i++;
+ }
+
+ const even = !(i % 2);
+ cell.classList.toggle("even", even);
+ }
+ },
+
+ /**
+ * Click event handler for the column. Used to detect click on header for
+ * for sorting.
+ */
+ onClick(event) {
+ const target = event.originalTarget;
+
+ if (target.nodeType !== target.ELEMENT_NODE || target == this.column) {
+ return;
+ }
+
+ if (event.button == 0 && target == this.header) {
+ this.table.sortBy(this.id);
+ }
+ },
+
+ /**
+ * Mousedown event handler for the column. Used to select rows.
+ */
+ onMousedown(event) {
+ const target = event.originalTarget;
+
+ if (
+ target.nodeType !== target.ELEMENT_NODE ||
+ target == this.column ||
+ target == this.header
+ ) {
+ return;
+ }
+ if (event.button == 0) {
+ const closest = target.closest("[data-id]");
+ if (!closest) {
+ return;
+ }
+
+ const dataid = closest.getAttribute("data-id");
+ this.table.emit(EVENTS.ROW_SELECTED, dataid);
+ }
+ },
+};
+
+/**
+ * A single cell in a column
+ *
+ * @param {Column} column
+ * The column object to which the cell belongs.
+ * @param {object} item
+ * The object representing the row. It contains a key value pair
+ * representing the column id and its associated value. The value
+ * can be a DOMNode that is appended or a string value.
+ * @param {Cell} nextCell
+ * The cell object which is next to this cell. null if this cell is last
+ * cell of the column
+ */
+function Cell(column, item, nextCell) {
+ const document = column.document;
+
+ this.wrapTextInElements = column.wrapTextInElements;
+ this.label = document.createXULElement("label");
+ this.label.setAttribute("crop", "end");
+ this.label.className = "plain table-widget-cell";
+
+ if (nextCell) {
+ column.column.insertBefore(this.label, nextCell.label);
+ } else {
+ column.column.appendChild(this.label);
+ }
+
+ if (column.table.cellContextMenuId) {
+ this.label.setAttribute("context", column.table.cellContextMenuId);
+ this.label.addEventListener("contextmenu", event => {
+ // Make the ID of the clicked cell available as a property on the table.
+ // It's then available for the popupshowing or command handler.
+ column.table.contextMenuRowId = this.id;
+ });
+ }
+
+ this.value = item[column.id];
+ this.id = item[column.uniqueId];
+}
+
+Cell.prototype = {
+ set id(value) {
+ this._id = value;
+ this.label.setAttribute("data-id", value);
+ },
+
+ get id() {
+ return this._id;
+ },
+
+ get hidden() {
+ return this.label.hidden;
+ },
+
+ set hidden(value) {
+ this.label.hidden = value;
+ },
+
+ set value(value) {
+ this._value = value;
+ if (value == null) {
+ this.label.setAttribute("value", "");
+ return;
+ }
+
+ if (this.wrapTextInElements && !Node.isInstance(value)) {
+ const span = this.label.ownerDocument.createElementNS(HTML_NS, "span");
+ span.textContent = value;
+ value = span;
+ }
+
+ if (Node.isInstance(value)) {
+ this.label.removeAttribute("value");
+
+ while (this.label.firstChild) {
+ this.label.firstChild.remove();
+ }
+
+ this.label.appendChild(value);
+ } else {
+ this.label.setAttribute("value", value + "");
+ }
+ },
+
+ get value() {
+ return this._value;
+ },
+
+ get classList() {
+ return this.label.classList;
+ },
+
+ /**
+ * Flashes the cell for a brief time. This when done for with cells in all
+ * columns, makes it look like the row is being highlighted/flashed.
+ */
+ flash() {
+ if (!this.label.parentNode) {
+ return;
+ }
+ this.label.classList.remove("flash-out");
+ // Cause a reflow so that the animation retriggers on adding back the class
+ let a = this.label.parentNode.offsetWidth; // eslint-disable-line
+ const onAnimEnd = () => {
+ this.label.classList.remove("flash-out");
+ this.label.removeEventListener("animationend", onAnimEnd);
+ };
+ this.label.addEventListener("animationend", onAnimEnd);
+ this.label.classList.add("flash-out");
+ },
+
+ focus() {
+ this.label.focus();
+ },
+
+ scrollIntoView() {
+ this.label.scrollIntoView(false);
+ },
+
+ destroy() {
+ this.label.remove();
+ this.label = null;
+ },
+};
+
+/**
+ * Simple widget to make nodes matching a CSS selector editable.
+ *
+ * @param {Object} options
+ * An object with the following format:
+ * {
+ * // The node that will act as a container for the editor e.g. a
+ * // div or table.
+ * root: someNode,
+ *
+ * // The onTab event to be handled by the caller.
+ * onTab: function(event) { ... }
+ *
+ * // Optional event used to trigger the editor. By default this is
+ * // dblclick.
+ * onTriggerEvent: "dblclick",
+ *
+ * // Array or comma separated string of CSS Selectors matching
+ * // elements that are to be made editable.
+ * selectors: [
+ * "#name .table-widget-cell",
+ * "#value .table-widget-cell"
+ * ]
+ * }
+ */
+function EditableFieldsEngine(options) {
+ EventEmitter.decorate(this);
+
+ if (!Array.isArray(options.selectors)) {
+ options.selectors = [options.selectors];
+ }
+
+ this.root = options.root;
+ this.selectors = options.selectors;
+ this.onTab = options.onTab;
+ this.onTriggerEvent = options.onTriggerEvent || "dblclick";
+ this.items = options.items;
+
+ this.edit = this.edit.bind(this);
+ this.cancelEdit = this.cancelEdit.bind(this);
+ this.destroy = this.destroy.bind(this);
+
+ this.onTrigger = this.onTrigger.bind(this);
+ this.root.addEventListener(this.onTriggerEvent, this.onTrigger);
+}
+
+EditableFieldsEngine.prototype = {
+ INPUT_ID: "inlineEditor",
+
+ get changePending() {
+ return this.isEditing && this.textbox.value !== this.currentValue;
+ },
+
+ get isEditing() {
+ return this.root && !this.textbox.hidden;
+ },
+
+ get textbox() {
+ if (!this._textbox) {
+ const doc = this.root.ownerDocument;
+ this._textbox = doc.createElementNS(HTML_NS, "input");
+ this._textbox.id = this.INPUT_ID;
+
+ this.onKeydown = this.onKeydown.bind(this);
+ this._textbox.addEventListener("keydown", this.onKeydown);
+
+ this.completeEdit = this.completeEdit.bind(this);
+ doc.addEventListener("blur", this.completeEdit);
+ }
+
+ return this._textbox;
+ },
+
+ /**
+ * Called when a trigger event is detected (default is dblclick).
+ *
+ * @param {EventTarget} target
+ * Calling event's target.
+ */
+ onTrigger({ target }) {
+ this.edit(target);
+ },
+
+ /**
+ * Handle keydowns when in edit mode:
+ * - <escape> revert the value and close the textbox.
+ * - <return> apply the value and close the textbox.
+ * - <tab> Handled by the consumer's `onTab` callback.
+ * - <shift><tab> Handled by the consumer's `onTab` callback.
+ *
+ * @param {Event} event
+ * The calling event.
+ */
+ onKeydown(event) {
+ if (!this.textbox) {
+ return;
+ }
+
+ switch (event.keyCode) {
+ case KeyCodes.DOM_VK_ESCAPE:
+ this.cancelEdit();
+ event.preventDefault();
+ break;
+ case KeyCodes.DOM_VK_RETURN:
+ this.completeEdit();
+ break;
+ case KeyCodes.DOM_VK_TAB:
+ if (this.onTab) {
+ this.onTab(event);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Overlay the target node with an edit field.
+ *
+ * @param {Node} target
+ * Dom node to be edited.
+ */
+ edit(target) {
+ if (!target) {
+ return;
+ }
+
+ // Some item names and values are not parsable by the client or server so should not be
+ // editable.
+ const name = target.getAttribute("data-id");
+ const item = this.items.get(name);
+ if ("isValueEditable" in item && !item.isValueEditable) {
+ return;
+ }
+
+ target.scrollIntoView(false);
+ target.focus();
+
+ if (!target.matches(this.selectors.join(","))) {
+ return;
+ }
+
+ // If we are actively editing something complete the edit first.
+ if (this.isEditing) {
+ this.completeEdit();
+ }
+
+ this.copyStyles(target, this.textbox);
+
+ target.parentNode.insertBefore(this.textbox, target);
+ this.currentTarget = target;
+ this.textbox.value = this.currentValue = target.value;
+ target.hidden = true;
+ this.textbox.hidden = false;
+
+ this.textbox.focus();
+ this.textbox.select();
+ },
+
+ completeEdit() {
+ if (!this.isEditing) {
+ return;
+ }
+
+ const oldValue = this.currentValue;
+ const newValue = this.textbox.value;
+ const changed = oldValue !== newValue;
+
+ this.textbox.hidden = true;
+
+ if (!this.currentTarget) {
+ return;
+ }
+
+ this.currentTarget.hidden = false;
+ if (changed) {
+ this.currentTarget.value = newValue;
+
+ const data = {
+ change: {
+ field: this.currentTarget,
+ oldValue,
+ newValue,
+ },
+ };
+
+ this.emit("change", data);
+ }
+ },
+
+ /**
+ * Cancel an edit.
+ */
+ cancelEdit() {
+ if (!this.isEditing) {
+ return;
+ }
+ if (this.currentTarget) {
+ this.currentTarget.hidden = false;
+ }
+
+ this.textbox.hidden = true;
+ },
+
+ /**
+ * Stop edit mode and apply changes.
+ */
+ blur() {
+ if (this.isEditing) {
+ this.completeEdit();
+ }
+ },
+
+ /**
+ * Copies various styles from one node to another.
+ *
+ * @param {Node} source
+ * The node to copy styles from.
+ * @param {Node} destination [description]
+ * The node to copy styles to.
+ */
+ copyStyles(source, destination) {
+ const style = source.ownerDocument.defaultView.getComputedStyle(source);
+ const props = [
+ "borderTopWidth",
+ "borderRightWidth",
+ "borderBottomWidth",
+ "borderLeftWidth",
+ "fontFamily",
+ "fontSize",
+ "fontWeight",
+ "height",
+ "marginTop",
+ "marginRight",
+ "marginBottom",
+ "marginLeft",
+ "marginInlineStart",
+ "marginInlineEnd",
+ ];
+
+ for (const prop of props) {
+ destination.style[prop] = style[prop];
+ }
+
+ // We need to set the label width to 100% to work around a XUL flex bug.
+ destination.style.width = "100%";
+ },
+
+ /**
+ * Destroys all editors in the current document.
+ */
+ destroy() {
+ if (this.textbox) {
+ this.textbox.removeEventListener("keydown", this.onKeydown);
+ this.textbox.remove();
+ }
+
+ if (this.root) {
+ this.root.removeEventListener(this.onTriggerEvent, this.onTrigger);
+ this.root.ownerDocument.removeEventListener("blur", this.completeEdit);
+ }
+
+ this._textbox = this.root = this.selectors = this.onTab = null;
+ this.currentTarget = this.currentValue = null;
+
+ this.emit("destroyed");
+ },
+};
diff --git a/devtools/client/shared/widgets/TreeWidget.js b/devtools/client/shared/widgets/TreeWidget.js
new file mode 100644
index 0000000000..1a54061210
--- /dev/null
+++ b/devtools/client/shared/widgets/TreeWidget.js
@@ -0,0 +1,643 @@
+/* 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 HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+
+/**
+ * A tree widget with keyboard navigation and collapsable structure.
+ *
+ * @param {Node} node
+ * The container element for the tree widget.
+ * @param {Object} options
+ * - emptyText {string}: text to display when no entries in the table.
+ * - defaultType {string}: The default type of the tree items. For ex.
+ * 'js'
+ * - sorted {boolean}: Defaults to true. If true, tree items are kept in
+ * lexical order. If false, items will be kept in insertion order.
+ * - contextMenuId {string}: ID of context menu to be displayed on
+ * tree items.
+ */
+function TreeWidget(node, options = {}) {
+ EventEmitter.decorate(this);
+
+ this.document = node.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = node;
+
+ this.emptyText = options.emptyText || "";
+ this.defaultType = options.defaultType;
+ this.sorted = options.sorted !== false;
+ this.contextMenuId = options.contextMenuId;
+
+ this.setupRoot();
+
+ this.placeholder = this.document.createElementNS(HTML_NS, "label");
+ this.placeholder.className = "tree-widget-empty-text";
+ this._parent.appendChild(this.placeholder);
+
+ if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ // A map to hold all the passed attachment to each leaf in the tree.
+ this.attachments = new Map();
+}
+
+TreeWidget.prototype = {
+ _selectedLabel: null,
+ _selectedItem: null,
+
+ /**
+ * Select any node in the tree.
+ *
+ * @param {array} ids
+ * An array of ids leading upto the selected item
+ */
+ set selectedItem(ids) {
+ if (this._selectedLabel) {
+ this._selectedLabel.classList.remove("theme-selected");
+ }
+ const currentSelected = this._selectedLabel;
+ if (ids == -1) {
+ this._selectedLabel = this._selectedItem = null;
+ return;
+ }
+ if (!Array.isArray(ids)) {
+ return;
+ }
+ this._selectedLabel = this.root.setSelectedItem(ids);
+ if (!this._selectedLabel) {
+ this._selectedItem = null;
+ } else {
+ if (currentSelected != this._selectedLabel) {
+ this.ensureSelectedVisible();
+ }
+ this._selectedItem = ids;
+ this.emit(
+ "select",
+ this._selectedItem,
+ this.attachments.get(JSON.stringify(ids))
+ );
+ }
+ },
+
+ /**
+ * Gets the selected item in the tree.
+ *
+ * @return {array}
+ * An array of ids leading upto the selected item
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Returns if the passed array corresponds to the selected item in the tree.
+ *
+ * @return {array}
+ * An array of ids leading upto the requested item
+ */
+ isSelected(item) {
+ if (!this._selectedItem || this._selectedItem.length != item.length) {
+ return false;
+ }
+
+ for (let i = 0; i < this._selectedItem.length; i++) {
+ if (this._selectedItem[i] != item[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ destroy() {
+ this.root.remove();
+ this.root = null;
+ },
+
+ /**
+ * Sets up the root container of the TreeWidget.
+ */
+ setupRoot() {
+ this.root = new TreeItem(this.document);
+ if (this.contextMenuId) {
+ this.root.children.addEventListener("contextmenu", event => {
+ // Call stopPropagation() and preventDefault() here so that avoid to show default
+ // context menu in about:devtools-toolbox. See Bug 1515265.
+ event.stopPropagation();
+ event.preventDefault();
+ const menu = this.document.getElementById(this.contextMenuId);
+ menu.openPopupAtScreen(event.screenX, event.screenY, true);
+ });
+ }
+
+ this._parent.appendChild(this.root.children);
+
+ this.root.children.addEventListener("mousedown", e => this.onClick(e));
+ this.root.children.addEventListener("keydown", e => this.onKeydown(e));
+ },
+
+ /**
+ * Sets the text to be shown when no node is present in the tree.
+ * The placeholder will be hidden if text is empty.
+ */
+ setPlaceholderText(text) {
+ this.placeholder.textContent = text;
+ if (text) {
+ this.placeholder.removeAttribute("hidden");
+ } else {
+ this.placeholder.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Select any node in the tree.
+ *
+ * @param {array} id
+ * An array of ids leading upto the selected item
+ */
+ selectItem(id) {
+ this.selectedItem = id;
+ },
+
+ /**
+ * Selects the next visible item in the tree.
+ */
+ selectNextItem() {
+ const next = this.getNextVisibleItem();
+ if (next) {
+ this.selectedItem = next;
+ }
+ },
+
+ /**
+ * Selects the previos visible item in the tree
+ */
+ selectPreviousItem() {
+ const prev = this.getPreviousVisibleItem();
+ if (prev) {
+ this.selectedItem = prev;
+ }
+ },
+
+ /**
+ * Returns the next visible item in the tree
+ */
+ getNextVisibleItem() {
+ let node = this._selectedLabel;
+ if (node.hasAttribute("expanded") && node.nextSibling.firstChild) {
+ return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ if (node.nextSibling) {
+ return JSON.parse(node.nextSibling.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ while (node.parentNode && node != this.root.children) {
+ if (node.parentNode?.nextSibling) {
+ return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Returns the previous visible item in the tree
+ */
+ getPreviousVisibleItem() {
+ let node = this._selectedLabel.parentNode;
+ if (node.previousSibling) {
+ node = node.previousSibling.firstChild;
+ while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
+ if (!node.nextSibling.lastChild) {
+ break;
+ }
+ node = node.nextSibling.lastChild.firstChild;
+ }
+ return JSON.parse(node.parentNode.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ if (node.parentNode && node != this.root.children) {
+ node = node.parentNode;
+ while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
+ if (!node.nextSibling.firstChild) {
+ break;
+ }
+ node = node.nextSibling.firstChild.firstChild;
+ }
+ return JSON.parse(node.getAttribute("data-id"));
+ }
+ return null;
+ },
+
+ clearSelection() {
+ this.selectedItem = -1;
+ },
+
+ /**
+ * Adds an item in the tree. The item can be added as a child to any node in
+ * the tree. The method will also create any subnode not present in the
+ * process.
+ *
+ * @param {[string|object]} items
+ * An array of either string or objects where each increasing index
+ * represents an item corresponding to an equivalent depth in the tree.
+ * Each array element can be either just a string with the value as the
+ * id of of that item as well as the display value, or it can be an
+ * object with the following propeties:
+ * - id {string} The id of the item
+ * - label {string} The display value of the item
+ * - node {DOMNode} The dom node if you want to insert some custom
+ * element as the item. The label property is not used in this
+ * case
+ * - attachment {object} Any object to be associated with this item.
+ * - type {string} The type of this particular item. If this is null,
+ * then defaultType will be used.
+ * For example, if items = ["foo", "bar", { id: "id1", label: "baz" }]
+ * and the tree is empty, then the following hierarchy will be created
+ * in the tree:
+ * foo
+ * â”” bar
+ * â”” baz
+ * Passing the string id instead of the complete object helps when you
+ * are simply adding children to an already existing node and you know
+ * its id.
+ */
+ add(items) {
+ this.root.add(items, this.defaultType, this.sorted);
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].attachment) {
+ this.attachments.set(
+ JSON.stringify(items.slice(0, i + 1).map(item => item.id || item)),
+ items[i].attachment
+ );
+ }
+ }
+ // Empty the empty-tree-text
+ this.setPlaceholderText("");
+ },
+
+ /**
+ * Check if an item exists.
+ *
+ * @param {array} item
+ * The array of ids leading up to the item.
+ */
+ exists(item) {
+ let bookmark = this.root;
+
+ for (const id of item) {
+ if (bookmark.items.has(id)) {
+ bookmark = bookmark.items.get(id);
+ } else {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Removes the specified item and all of its child items from the tree.
+ *
+ * @param {array} item
+ * The array of ids leading up to the item.
+ */
+ remove(item) {
+ this.root.remove(item);
+ this.attachments.delete(JSON.stringify(item));
+ // Display the empty tree text
+ if (this.root.items.size == 0 && this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this tree.
+ */
+ clear() {
+ this.root.remove();
+ this.setupRoot();
+ this.attachments.clear();
+ if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ },
+
+ /**
+ * Expands the tree completely
+ */
+ expandAll() {
+ this.root.expandAll();
+ },
+
+ /**
+ * Collapses the tree completely
+ */
+ collapseAll() {
+ this.root.collapseAll();
+ },
+
+ /**
+ * Click handler for the tree. Used to select, open and close the tree nodes.
+ */
+ onClick(event) {
+ let target = event.originalTarget;
+ while (target && !target.classList.contains("tree-widget-item")) {
+ if (target == this.root.children) {
+ return;
+ }
+ target = target.parentNode;
+ }
+ if (!target) {
+ return;
+ }
+
+ if (target.hasAttribute("expanded")) {
+ target.removeAttribute("expanded");
+ } else {
+ target.setAttribute("expanded", "true");
+ }
+
+ if (this._selectedLabel != target) {
+ const ids = target.parentNode.getAttribute("data-id");
+ this.selectedItem = JSON.parse(ids);
+ }
+ },
+
+ /**
+ * Keydown handler for this tree. Used to select next and previous visible
+ * items, as well as collapsing and expanding any item.
+ */
+ onKeydown(event) {
+ switch (event.keyCode) {
+ case KeyCodes.DOM_VK_UP:
+ this.selectPreviousItem();
+ break;
+
+ case KeyCodes.DOM_VK_DOWN:
+ this.selectNextItem();
+ break;
+
+ case KeyCodes.DOM_VK_RIGHT:
+ if (this._selectedLabel.hasAttribute("expanded")) {
+ this.selectNextItem();
+ } else {
+ this._selectedLabel.setAttribute("expanded", "true");
+ }
+ break;
+
+ case KeyCodes.DOM_VK_LEFT:
+ if (
+ this._selectedLabel.hasAttribute("expanded") &&
+ !this._selectedLabel.hasAttribute("empty")
+ ) {
+ this._selectedLabel.removeAttribute("expanded");
+ } else {
+ this.selectPreviousItem();
+ }
+ break;
+
+ default:
+ return;
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * Scrolls the viewport of the tree so that the selected item is always
+ * visible.
+ */
+ ensureSelectedVisible() {
+ const { top, bottom } = this._selectedLabel.getBoundingClientRect();
+ const height = this.root.children.parentNode.clientHeight;
+ if (top < 0) {
+ this._selectedLabel.scrollIntoView();
+ } else if (bottom > height) {
+ this._selectedLabel.scrollIntoView(false);
+ }
+ },
+};
+
+module.exports.TreeWidget = TreeWidget;
+
+/**
+ * Any item in the tree. This can be an empty leaf node also.
+ *
+ * @param {HTMLDocument} document
+ * The document element used for creating new nodes.
+ * @param {TreeItem} parent
+ * The parent item for this item.
+ * @param {string|DOMElement} label
+ * Either the dom node to be used as the item, or the string to be
+ * displayed for this node in the tree
+ * @param {string} type
+ * The type of the current node. For ex. "js"
+ */
+function TreeItem(document, parent, label, type) {
+ this.document = document;
+ this.node = this.document.createElementNS(HTML_NS, "li");
+ this.node.setAttribute("tabindex", "0");
+ this.isRoot = !parent;
+ this.parent = parent;
+ if (this.parent) {
+ this.level = this.parent.level + 1;
+ }
+ if (label) {
+ this.label = this.document.createElementNS(HTML_NS, "div");
+ this.label.setAttribute("empty", "true");
+ this.label.setAttribute("level", this.level);
+ this.label.className = "tree-widget-item";
+ if (type) {
+ this.label.setAttribute("type", type);
+ }
+ if (typeof label == "string") {
+ this.label.textContent = label;
+ } else {
+ this.label.appendChild(label);
+ }
+ this.node.appendChild(this.label);
+ }
+ this.children = this.document.createElementNS(HTML_NS, "ul");
+ if (this.isRoot) {
+ this.children.className = "tree-widget-container";
+ } else {
+ this.children.className = "tree-widget-children";
+ }
+ this.node.appendChild(this.children);
+ this.items = new Map();
+}
+
+TreeItem.prototype = {
+ items: null,
+
+ isSelected: false,
+
+ expanded: false,
+
+ isRoot: false,
+
+ parent: null,
+
+ children: null,
+
+ level: 0,
+
+ /**
+ * Adds the item to the sub tree contained by this node. The item to be
+ * inserted can be a direct child of this node, or further down the tree.
+ *
+ * @param {array} items
+ * Same as TreeWidget.add method's argument
+ * @param {string} defaultType
+ * The default type of the item to be used when items[i].type is null
+ * @param {boolean} sorted
+ * true if the tree items are inserted in a lexically sorted manner.
+ * Otherwise, false if the item are to be appended to their parent.
+ */
+ add(items, defaultType, sorted) {
+ if (items.length == this.level) {
+ // This is the exit condition of recursive TreeItem.add calls
+ return;
+ }
+ // Get the id and label corresponding to this level inside the tree.
+ const id = items[this.level].id || items[this.level];
+ if (this.items.has(id)) {
+ // An item with same id already exists, thus calling the add method of
+ // that child to add the passed node at correct position.
+ this.items.get(id).add(items, defaultType, sorted);
+ return;
+ }
+ // No item with the id `id` exists, so we create one and call the add
+ // method of that item.
+ // The display string of the item can be the label, the id, or the item
+ // itself if its a plain string.
+ let label =
+ items[this.level].label || items[this.level].id || items[this.level];
+ const node = items[this.level].node;
+ if (node) {
+ // The item is supposed to be a DOMNode, so we fetch the textContent in
+ // order to find the correct sorted location of this new item.
+ label = node.textContent;
+ }
+ const treeItem = new TreeItem(
+ this.document,
+ this,
+ node || label,
+ items[this.level].type || defaultType
+ );
+
+ treeItem.add(items, defaultType, sorted);
+ treeItem.node.setAttribute(
+ "data-id",
+ JSON.stringify(
+ items.slice(0, this.level + 1).map(item => item.id || item)
+ )
+ );
+
+ if (sorted) {
+ // Inserting this newly created item at correct position
+ const nextSibling = [...this.items.values()].find(child => {
+ return child.label.textContent >= label;
+ });
+
+ if (nextSibling) {
+ this.children.insertBefore(treeItem.node, nextSibling.node);
+ } else {
+ this.children.appendChild(treeItem.node);
+ }
+ } else {
+ this.children.appendChild(treeItem.node);
+ }
+
+ if (this.label) {
+ this.label.removeAttribute("empty");
+ }
+ this.items.set(id, treeItem);
+ },
+
+ /**
+ * If this item is to be removed, then removes this item and thus all of its
+ * subtree. Otherwise, call the remove method of appropriate child. This
+ * recursive method goes on till we have reached the end of the branch or the
+ * current item is to be removed.
+ *
+ * @param {array} items
+ * Ids of items leading up to the item to be removed.
+ */
+ remove(items = []) {
+ const id = items.shift();
+ if (id && this.items.has(id)) {
+ const deleted = this.items.get(id);
+ if (!items.length) {
+ this.items.delete(id);
+ }
+ if (this.items.size == 0) {
+ this.label.setAttribute("empty", "true");
+ }
+ deleted.remove(items);
+ } else if (!id) {
+ this.destroy();
+ }
+ },
+
+ /**
+ * If this item is to be selected, then selected and expands the item.
+ * Otherwise, if a child item is to be selected, just expands this item.
+ *
+ * @param {array} items
+ * Ids of items leading up to the item to be selected.
+ */
+ setSelectedItem(items) {
+ if (!items[this.level]) {
+ this.label.classList.add("theme-selected");
+ this.label.setAttribute("expanded", "true");
+ return this.label;
+ }
+ if (this.items.has(items[this.level])) {
+ const label = this.items.get(items[this.level]).setSelectedItem(items);
+ if (label && this.label) {
+ this.label.setAttribute("expanded", true);
+ }
+ return label;
+ }
+ return null;
+ },
+
+ /**
+ * Collapses this item and all of its sub tree items
+ */
+ collapseAll() {
+ if (this.label) {
+ this.label.removeAttribute("expanded");
+ }
+ for (const child of this.items.values()) {
+ child.collapseAll();
+ }
+ },
+
+ /**
+ * Expands this item and all of its sub tree items
+ */
+ expandAll() {
+ if (this.label) {
+ this.label.setAttribute("expanded", "true");
+ }
+ for (const child of this.items.values()) {
+ child.expandAll();
+ }
+ },
+
+ destroy() {
+ this.children.remove();
+ this.node.remove();
+ this.label = null;
+ this.items = null;
+ this.children = null;
+ },
+};
diff --git a/devtools/client/shared/widgets/cubic-bezier.css b/devtools/client/shared/widgets/cubic-bezier.css
new file mode 100644
index 0000000000..4a73cb75ef
--- /dev/null
+++ b/devtools/client/shared/widgets/cubic-bezier.css
@@ -0,0 +1,216 @@
+/* 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/. */
+
+/* Based on Lea Verou www.cubic-bezier.com
+ See https://github.com/LeaVerou/cubic-bezier */
+
+.cubic-bezier-container {
+ display: flex;
+ width: 510px;
+ height: 370px;
+ flex-direction: row-reverse;
+ overflow: hidden;
+ padding: 5px;
+ box-sizing: border-box;
+}
+
+.cubic-bezier-container .display-wrap {
+ width: 50%;
+ height: 100%;
+ text-align: center;
+ overflow: hidden;
+}
+
+/* Coordinate Plane */
+
+.cubic-bezier-container .coordinate-plane {
+ width: 150px;
+ height: 370px;
+ margin: 0 auto;
+ position: relative;
+}
+
+.cubic-bezier-container .control-point {
+ position: absolute;
+ z-index: 1;
+ height: 10px;
+ width: 10px;
+ border: 0;
+ background: #666;
+ display: block;
+ margin: -5px 0 0 -5px;
+ outline: none;
+ border-radius: 5px;
+ padding: 0;
+ cursor: pointer;
+}
+
+.cubic-bezier-container .display-wrap {
+ background:
+ repeating-linear-gradient(0deg,
+ transparent,
+ var(--bezier-grid-color) 0,
+ var(--bezier-grid-color) 1px,
+ transparent 1px,
+ transparent 15px) no-repeat,
+ repeating-linear-gradient(90deg,
+ transparent,
+ var(--bezier-grid-color) 0,
+ var(--bezier-grid-color) 1px,
+ transparent 1px,
+ transparent 15px) no-repeat;
+ background-size: 100% 100%, 100% 100%;
+ background-position: -2px 5px, -2px 5px;
+ user-select: none;
+}
+
+.cubic-bezier-container canvas.curve {
+ background:
+ linear-gradient(-45deg,
+ transparent 49.7%,
+ var(--bezier-diagonal-color) 49.7%,
+ var(--bezier-diagonal-color) 50.3%,
+ transparent 50.3%) center no-repeat;
+ background-size: 100% 100%;
+ background-position: 0 0;
+}
+
+/* Timing Function Preview Widget */
+
+.cubic-bezier-container .timing-function-preview {
+ position: absolute;
+ bottom: 20px;
+ right: 45px;
+ width: 150px;
+}
+
+.cubic-bezier-container .timing-function-preview .scale {
+ position: absolute;
+ top: 6px;
+ left: 0;
+ z-index: 1;
+
+ width: 150px;
+ height: 1px;
+
+ background: #ccc;
+}
+
+.cubic-bezier-container .timing-function-preview .dot {
+ position: absolute;
+ top: 0;
+ left: -7px;
+ z-index: 2;
+
+ width: 10px;
+ height: 10px;
+
+ border-radius: 50%;
+ border: 2px solid white;
+ background: #4C9ED9;
+}
+
+/* Preset Widget */
+
+.cubic-bezier-container .preset-pane {
+ width: 50%;
+ height: 100%;
+ border-right: 1px solid var(--theme-splitter-color);
+ padding-right: 4px; /* Visual balance for the panel-arrowcontent border on the left */
+}
+
+#preset-categories {
+ display: flex;
+ width: 95%;
+ border: 1px solid var(--theme-splitter-color);
+ border-radius: 2px;
+ background-color: var(--theme-toolbar-background);
+ margin: 3px auto 0 auto;
+}
+
+#preset-categories .category:last-child {
+ border-right: none;
+}
+
+.cubic-bezier-container .category {
+ padding: 5px 0px;
+ width: 33.33%;
+ text-align: center;
+ text-transform: capitalize;
+ border-right: 1px solid var(--theme-splitter-color);
+ cursor: default;
+ color: var(--theme-body-color);
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.cubic-bezier-container .category:hover {
+ background-color: var(--theme-tab-toolbar-background);
+}
+
+.cubic-bezier-container .active-category {
+ background-color: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+}
+
+.cubic-bezier-container .active-category:hover {
+ background-color: var(--theme-selection-background);
+}
+
+#preset-container {
+ padding: 0px;
+ width: 100%;
+ height: 331px;
+ overflow-y: auto;
+}
+
+.cubic-bezier-container .preset-list {
+ display: none;
+ padding-top: 6px;
+}
+
+.cubic-bezier-container .active-preset-list {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+}
+
+.cubic-bezier-container .preset {
+ cursor: pointer;
+ width: 33.33%;
+ margin: 5px 0px;
+ text-align: center;
+}
+
+.cubic-bezier-container .preset canvas {
+ display: block;
+ border: 1px solid var(--theme-splitter-color);
+ border-radius: 3px;
+ background-color: var(--theme-body-background);
+ margin: 0 auto;
+}
+
+.cubic-bezier-container .preset p {
+ font-size: 80%;
+ margin: 2px auto 0px auto;
+ color: var(--theme-text-color-alt);
+ text-transform: capitalize;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.cubic-bezier-container .active-preset p,
+.cubic-bezier-container .active-preset:hover p {
+ color: var(--theme-body-color);
+}
+
+.cubic-bezier-container .preset:hover canvas {
+ border-color: var(--theme-selection-background);
+}
+
+.cubic-bezier-container .active-preset canvas,
+.cubic-bezier-container .active-preset:hover canvas {
+ background-color: var(--theme-selection-background-hover);
+ border-color: var(--theme-selection-background);
+}
diff --git a/devtools/client/shared/widgets/filter-widget.css b/devtools/client/shared/widgets/filter-widget.css
new file mode 100644
index 0000000000..aeee4db42e
--- /dev/null
+++ b/devtools/client/shared/widgets/filter-widget.css
@@ -0,0 +1,242 @@
+/* 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/. */
+
+/* Main container: Displays the filters and presets in 2 columns */
+
+#filter-container {
+ width: 510px;
+ height: 200px;
+ display: flex;
+ position: relative;
+ padding: 5px;
+ box-sizing: border-box;
+ /* when opened in a xul:panel, a gray color is applied to text */
+ color: var(--theme-body-color);
+}
+
+#filter-container.dragging {
+ user-select: none;
+}
+
+#filter-container .filters-list,
+#filter-container .presets-list {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+}
+
+#filter-container .filters-list {
+ /* Allow the filters list to take the full width when the presets list is
+ hidden */
+ flex-grow: 1;
+ padding: 0 6px;
+}
+
+#filter-container .presets-list {
+ /* Make sure that when the presets list is shown, it has a fixed width */
+ width: 200px;
+ padding-left: 6px;
+ transition: width .1s;
+ flex-shrink: 0;
+ border-left: 1px solid var(--theme-splitter-color);
+}
+
+#filter-container:not(.show-presets) .presets-list {
+ width: 0;
+ border-left: none;
+ padding-left: 0;
+ /* To hide also element's children, not on only the element */
+ overflow: hidden;
+}
+
+#filter-container.show-presets .filters-list {
+ width: 300px;
+}
+
+/* The list of filters and list of presets should push their footers to the
+ bottom, so they can take as much space as there is */
+
+#filter-container #filters,
+#filter-container #presets {
+ flex-grow: 1;
+ /* Avoid pushing below the tooltip's area */
+ overflow-y: auto;
+}
+
+/* The filters and presets list both have footers displayed at the bottom.
+ These footers have some input (taking up as much space as possible) and an
+ add button next */
+
+#filter-container .footer {
+ display: flex;
+ margin: 10px 3px;
+ align-items: center;
+}
+
+#filter-container .footer :not(button) {
+ flex-grow: 1;
+ margin-right: 3px;
+}
+
+/* Styles for 1 filter function item */
+
+#filter-container .filter,
+#filter-container .filter-name,
+#filter-container .filter-value {
+ display: flex;
+ align-items: center;
+}
+
+#filter-container .filter {
+ margin: 5px 0;
+}
+
+#filter-container .filter-name {
+ width: 120px;
+ margin-right: 10px;
+}
+
+#filter-container .filter-name label {
+ user-select: none;
+ flex-grow: 1;
+}
+
+#filter-container .filter-name label.devtools-draglabel {
+ cursor: ew-resize;
+}
+
+/* drag/drop handle */
+
+#filter-container .filter-name i {
+ width: 10px;
+ height: 10px;
+ margin-right: 10px;
+ cursor: grab;
+ background: linear-gradient(to bottom,
+ currentColor 0,
+ currentcolor 1px,
+ transparent 1px,
+ transparent 2px);
+ background-repeat: repeat-y;
+ background-size: auto 4px;
+ background-position: 0 1px;
+}
+
+#filter-container .filter-value {
+ min-width: 150px;
+ margin-right: 10px;
+ flex: 1;
+}
+
+#filter-container .filter-value input {
+ flex-grow: 1;
+}
+
+/* Fix the size of inputs */
+/* Especially needed on Linux where input are bigger */
+#filter-container input {
+ width: 8em;
+}
+
+#filter-container .preset {
+ display: flex;
+ margin-bottom: 10px;
+ cursor: pointer;
+ padding: 3px 5px;
+
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+
+#filter-container .preset label,
+#filter-container .preset span {
+ display: flex;
+ align-items: center;
+}
+
+#filter-container .preset label {
+ flex: 1 0;
+ cursor: pointer;
+ color: var(--theme-body-color);
+}
+
+#filter-container .preset:hover {
+ background: var(--theme-selection-background);
+}
+
+#filter-container .preset:hover label,
+#filter-container .preset:hover span {
+ color: var(--theme-selection-color);
+}
+
+#filter-container .preset .remove-button {
+ order: 2;
+}
+
+#filter-container .preset span {
+ flex: 2 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ order: 3;
+ color: var(--theme-text-color-alt);
+}
+
+#filter-container .remove-button {
+ width: 16px;
+ height: 16px;
+ background: url(chrome://devtools/skin/images/close.svg);
+ background-size: cover;
+ font-size: 0;
+ border: none;
+ cursor: pointer;
+}
+
+#filter-container .hidden {
+ display: none !important;
+}
+
+#filter-container .dragging {
+ position: relative;
+ z-index: 10;
+ cursor: grab;
+}
+
+/* message shown when there's no filter specified */
+#filter-container p {
+ text-align: center;
+ line-height: 20px;
+}
+
+#filter-container .add,
+#toggle-presets {
+ background-size: cover;
+ border: none;
+ width: 16px;
+ height: 16px;
+ font-size: 0;
+ vertical-align: middle;
+ cursor: pointer;
+ margin: 0 5px;
+}
+
+#filter-container .add {
+ background: url(chrome://devtools/skin/images/add.svg);
+}
+
+#toggle-presets {
+ background: url(chrome://devtools/skin/images/pseudo-class.svg);
+}
+
+#filter-container .add,
+#filter-container .remove-button,
+#toggle-presets {
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-color);
+}
+
+.show-presets #toggle-presets {
+ fill: var(--theme-icon-checked-color);
+}
diff --git a/devtools/client/shared/widgets/linear-widget.css b/devtools/client/shared/widgets/linear-widget.css
new file mode 100644
index 0000000000..af82a6d3ba
--- /dev/null
+++ b/devtools/client/shared/widgets/linear-widget.css
@@ -0,0 +1,61 @@
+/* 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/. */
+
+.linear-easing-function-container {
+ height: 100%;
+}
+
+.linear-easing-function-container .display-wrap {
+ --chart-size: 300px;
+ height: 100%;
+ display: grid;
+ grid-template-rows: var(--chart-size) 1fr;
+ justify-items: center;
+ align-items: center;
+ gap: 5px;
+}
+
+.linear-easing-function-container svg.chart {
+ aspect-ratio: 1 / 1;
+ max-height: 100%;
+}
+
+.linear-easing-function-container .chart-grid {
+ stroke: var(--bezier-grid-color);
+ pointer-events: none;
+}
+
+.linear-easing-function-container .chart-linear {
+ -moz-context-properties: stroke;
+ stroke: #4C9ED9;
+}
+
+.linear-easing-function-container .control-point {
+ -moz-context-properties: fill, stroke;
+ fill: var(--grey-55);
+ cursor: pointer;
+}
+
+:root.theme-dark .linear-easing-function-container .control-point {
+ fill: var(--grey-20);
+}
+
+/* Timing Function Preview Widget */
+
+.linear-easing-function-container .timing-function-preview {
+ width: var(--chart-size);
+ /* Draw a background line */
+ background: linear-gradient(0deg, transparent 45%,var(--bezier-grid-color) 45%, var(--bezier-grid-color) 55%,transparent 55%);
+}
+
+.linear-easing-function-container .timing-function-preview .dot::before {
+ content: "";
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ aspect-ratio: 1 / 1;
+ border-radius: 50%;
+ border: 2px solid white;
+ background: #4C9ED9;
+}
diff --git a/devtools/client/shared/widgets/moz.build b/devtools/client/shared/widgets/moz.build
new file mode 100644
index 0000000000..22fe366068
--- /dev/null
+++ b/devtools/client/shared/widgets/moz.build
@@ -0,0 +1,22 @@
+# -*- 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 += [
+ "tooltip",
+]
+
+DevToolsModules(
+ "Chart.js",
+ "CubicBezierPresets.js",
+ "CubicBezierWidget.js",
+ "FilterWidget.js",
+ "LinearEasingFunctionWidget.js",
+ "ShapesInContextEditor.js",
+ "Spectrum.js",
+ "TableWidget.js",
+ "TreeWidget.js",
+ "view-helpers.js",
+)
diff --git a/devtools/client/shared/widgets/spectrum.css b/devtools/client/shared/widgets/spectrum.css
new file mode 100644
index 0000000000..7dd708036a
--- /dev/null
+++ b/devtools/client/shared/widgets/spectrum.css
@@ -0,0 +1,331 @@
+/* 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/. */
+
+:root {
+ --learn-more-underline: var(--grey-30);
+}
+
+.theme-dark:root {
+ --learn-more-underline: var(--grey-50);
+}
+
+#eyedropper-button {
+ margin-inline-end: 5px;
+ display: block;
+}
+
+#eyedropper-button::before {
+ background-image: url(chrome://devtools/skin/images/command-eyedropper.svg);
+}
+
+/* Mix-in classes */
+
+.spectrum-checker {
+ background-color: #eee;
+ background-image: linear-gradient(
+ 45deg,
+ #ccc 25%,
+ transparent 25%,
+ transparent 75%,
+ #ccc 75%,
+ #ccc
+ ),
+ linear-gradient(
+ 45deg,
+ #ccc 25%,
+ transparent 25%,
+ transparent 75%,
+ #ccc 75%,
+ #ccc
+ );
+ background-size: 12px 12px;
+ background-position: 0 0, 6px 6px;
+}
+
+.spectrum-box {
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 2px;
+ background-clip: content-box;
+}
+
+/* Elements */
+
+#spectrum-tooltip {
+ padding: 5px;
+}
+
+/**
+ * Spectrum controls set the layout for the controls section of the color picker.
+ */
+.spectrum-controls {
+ display: flex;
+ justify-content: space-between;
+ margin-block-start: 10px;
+ margin-inline-end: 5px;
+}
+
+.spectrum-controls {
+ width: 200px;
+}
+
+.spectrum-container {
+ display: flex;
+ flex-direction: column;
+ margin: -1px;
+ padding-block-end: 6px;
+}
+
+/**
+ * This styles the color preview and adds a checkered background overlay inside of it. The overlay
+ * can be manipulated using the --overlay-color variable.
+ */
+.spectrum-color-preview {
+ --overlay-color: transparent;
+ border: 1px solid transparent;
+ border-radius: 50%;
+ width: 27px;
+ height: 27px;
+ background-color: #fff;
+ background-image: linear-gradient(var(--overlay-color), var(--overlay-color)),
+ linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%),
+ linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
+ background-size: 12px 12px;
+ background-position: 0 0, 6px 6px;
+}
+
+.spectrum-color-preview.high-luminance {
+ border-color: #ccc;
+}
+
+.spectrum-slider-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-around;
+ width: 130px;
+ margin-inline-start: 10px;
+ height: 30px;
+}
+
+/* Keep aspect ratio:
+http://www.briangrinstead.com/blog/keep-aspect-ratio-with-html-and-css */
+.spectrum-color-picker {
+ position: relative;
+ width: 205px;
+ height: 120px;
+}
+
+.spectrum-color {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+}
+
+.spectrum-sat,
+.spectrum-val {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.spectrum-alpha {
+ margin-block-start: 3px;
+}
+
+.spectrum-alpha,
+.spectrum-hue {
+ position: relative;
+ height: 8px;
+}
+
+.spectrum-alpha-input,
+.spectrum-hue-input {
+ width: 100%;
+ margin: 0;
+ position: absolute;
+ height: 8px;
+ border-radius: 2px;
+ direction: initial;
+}
+
+/* Focus style already exists on input[type="range"]. Remove overlap */
+.spectrum-hue-input:focus,
+.spectrum-alpha-input:focus {
+ outline: none;
+}
+
+.spectrum-hue-input::-moz-range-thumb,
+.spectrum-alpha-input::-moz-range-thumb {
+ cursor: pointer;
+ height: 12px;
+ width: 12px;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
+ background: #fff;
+ border-radius: 50%;
+ opacity: 0.9;
+ border: none;
+}
+
+.spectrum-hue-input::-moz-range-track {
+ border-radius: 2px;
+ height: 8px;
+ background: linear-gradient(
+ to right,
+ #ff0000 0%,
+ #ffff00 17%,
+ #00ff00 33%,
+ #00ffff 50%,
+ #0000ff 67%,
+ #ff00ff 83%,
+ #ff0000 100%
+ );
+}
+
+.spectrum-sat {
+ background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0));
+}
+
+.spectrum-val {
+ background-image: linear-gradient(to top, #000000, rgba(204, 154, 129, 0));
+}
+
+.spectrum-dragger {
+ user-select: none;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ cursor: pointer;
+ border-radius: 50%;
+ height: 8px;
+ width: 8px;
+ border: 1px solid white;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
+}
+
+.spectrum-color-contrast {
+ padding-block-start: 8px;
+ padding-inline-start: 4px;
+ padding-inline-end: 4px;
+ line-height: 1.2em;
+}
+
+.contrast-ratio-header-and-single-ratio,
+.contrast-ratio-range {
+ display: flex;
+ align-items: stretch;
+}
+
+.contrast-ratio-range {
+ margin-block-start: 4px;
+ margin-inline-start: 1px;
+ margin-block-end: 2px;
+}
+
+.spectrum-color-contrast.visible {
+ display: block;
+}
+
+.spectrum-color-contrast.visible:not(.range) .contrast-ratio-single,
+.spectrum-color-contrast.visible.range .contrast-ratio-range {
+ display: flex;
+}
+
+.spectrum-color-contrast,
+.spectrum-color-contrast .contrast-ratio-range,
+.spectrum-color-contrast.range .contrast-ratio-single,
+.spectrum-color-contrast.error .accessibility-color-contrast-separator,
+.spectrum-color-contrast.error .contrast-ratio-max {
+ display: none;
+}
+
+.contrast-ratio-label {
+ font-size: 10px;
+ padding-inline-end: 4px;
+ color: var(--theme-toolbar-color);
+}
+
+.spectrum-color-contrast .accessibility-contrast-value {
+ font-size: 10px;
+ color: var(--theme-body-color);
+ border-bottom: 1px solid var(--learn-more-underline);
+}
+
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-single .accessibility-contrast-value {
+ margin-inline-start: 10px;
+}
+
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-min .accessibility-contrast-value,
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-max .accessibility-contrast-value{
+ margin-inline-start: 7px;
+}
+
+.spectrum-color-contrast .accessibility-contrast-value:not(:empty)::before {
+ width: auto;
+ content: none;
+ padding-inline-start: 2px;
+}
+
+.spectrum-color-contrast.visible:not(.error) .contrast-value-and-swatch:before {
+ display: inline-flex;
+ content: "";
+ height: 9px;
+ width: 9px;
+ background-color: var(--accessibility-contrast-color);
+}
+
+.spectrum-color-contrast.visible:not(.error):-moz-locale-dir(ltr) .contrast-value-and-swatch:before {
+ box-shadow: 0 0 0 1px var(--grey-40), 6px 5px var(--accessibility-contrast-bg),
+ 6px 5px 0 1px var(--grey-40);
+}
+
+.spectrum-color-contrast.visible:not(.error):-moz-locale-dir(rtl) .contrast-value-and-swatch:before {
+ box-shadow: 0 0 0 1px var(--grey-40), -6px 5px var(--accessibility-contrast-bg),
+ -6px 5px 0 1px var(--grey-40);
+}
+
+.spectrum-color-contrast .accessibility-color-contrast-separator:before {
+ margin-inline-end: 4px;
+ color: var(--theme-body-color);
+}
+
+.spectrum-color-contrast .accessibility-color-contrast-large-text {
+ margin-inline-start: 1px;
+ margin-inline-end: 1px;
+ unicode-bidi: isolate;
+}
+
+.learn-more {
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ background-image: url(chrome://devtools/skin/images/info-small.svg);
+ background-color: transparent;
+ fill: var(--theme-icon-dimmed-color);
+ border: none;
+ margin-inline-start: auto;
+ margin-block-start: 1px;
+ aspect-ratio: 1 / 1;
+ width: 12px;
+}
+
+.learn-more:-moz-locale-dir(ltr) {
+ margin-inline-end: -5px;
+}
+
+.learn-more:-moz-locale-dir(rtl) {
+ margin-inline-end: -2px;
+}
+
+.learn-more:hover,
+.learn-more:focus {
+ fill: var(--theme-icon-color);
+ cursor: pointer;
+ outline: none;
+}
+
+.learn-more::-moz-focus-inner {
+ border: none;
+}
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
new file mode 100644
index 0000000000..2107180e79
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
@@ -0,0 +1,384 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
+const beautify = require("resource://devtools/shared/jsbeautify/beautify.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const CONTAINER_WIDTH = 500;
+
+class EventTooltip extends EventEmitter {
+ /**
+ * Set the content of a provided HTMLTooltip instance to display a list of event
+ * listeners, with their event type, capturing argument and a link to the code
+ * of the event handler.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the event details content should be set
+ * @param {Array} eventListenerInfos
+ * A list of event listeners
+ * @param {Toolbox} toolbox
+ * Toolbox used to select debugger panel
+ * @param {NodeFront} nodeFront
+ * The nodeFront we're displaying event listeners for.
+ */
+ constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
+ super();
+
+ this._tooltip = tooltip;
+ this._toolbox = toolbox;
+ this._eventEditors = new WeakMap();
+ this._nodeFront = nodeFront;
+ this._eventListenersAbortController = new AbortController();
+
+ // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
+ this._tooltip.eventTooltip = this;
+
+ this._headerClicked = this._headerClicked.bind(this);
+ this._eventToggleCheckboxChanged =
+ this._eventToggleCheckboxChanged.bind(this);
+
+ this._subscriptions = [];
+
+ const config = {
+ mode: Editor.modes.js,
+ lineNumbers: false,
+ lineWrapping: true,
+ readOnly: true,
+ styleActiveLine: true,
+ extraKeys: {},
+ theme: "mozilla markup-view",
+ };
+
+ const doc = this._tooltip.doc;
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "devtools-tooltip-events-container";
+
+ const sourceMapURLService = this._toolbox.sourceMapURLService;
+
+ const Bubbling = L10N.getStr("eventsTooltip.Bubbling");
+ const Capturing = L10N.getStr("eventsTooltip.Capturing");
+ for (const listener of eventListenerInfos) {
+ // Create this early so we can refer to it from a closure, below.
+ const content = doc.createElementNS(XHTML_NS, "div");
+
+ // Header
+ const header = doc.createElementNS(XHTML_NS, "div");
+ header.className = "event-header";
+ const arrow = doc.createElementNS(XHTML_NS, "span");
+ arrow.className = "theme-twisty";
+ header.appendChild(arrow);
+ this.container.appendChild(header);
+
+ if (!listener.hide.type) {
+ const eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ eventTypeLabel.className = "event-tooltip-event-type";
+ eventTypeLabel.textContent = listener.type;
+ eventTypeLabel.setAttribute("title", listener.type);
+ header.appendChild(eventTypeLabel);
+ }
+
+ const filename = doc.createElementNS(XHTML_NS, "span");
+ filename.className = "event-tooltip-filename devtools-monospace";
+
+ let location = null;
+ let text = listener.origin;
+ let title = text;
+ if (listener.hide.filename) {
+ text = L10N.getStr("eventsTooltip.unknownLocation");
+ title = L10N.getStr("eventsTooltip.unknownLocationExplanation");
+ } else {
+ location = this._parseLocation(listener.origin);
+
+ // There will be no source actor if the listener is a native function
+ // or wasn't a debuggee, in which case there's also not going to be
+ // a sourcemap, so we don't need to worry about subscribing.
+ if (location && listener.sourceActor) {
+ location.id = listener.sourceActor;
+
+ this._subscriptions.push(
+ sourceMapURLService.subscribeByID(
+ location.id,
+ location.line,
+ location.column,
+ originalLocation => {
+ const currentLoc = originalLocation || location;
+
+ const newURI = currentLoc.url + ":" + currentLoc.line;
+ filename.textContent = newURI;
+ filename.setAttribute("title", newURI);
+
+ // This is emitted for testing.
+ this._tooltip.emitForTests("event-tooltip-source-map-ready");
+ }
+ )
+ );
+ }
+ }
+
+ filename.textContent = text;
+ filename.setAttribute("title", title);
+ header.appendChild(filename);
+
+ if (!listener.hide.debugger) {
+ const debuggerIcon = doc.createElementNS(XHTML_NS, "div");
+ debuggerIcon.className = "event-tooltip-debugger-icon";
+ const openInDebugger = L10N.getStr("eventsTooltip.openInDebugger");
+ debuggerIcon.setAttribute("title", openInDebugger);
+ header.appendChild(debuggerIcon);
+ }
+
+ const attributesContainer = doc.createElementNS(XHTML_NS, "div");
+ attributesContainer.className = "event-tooltip-attributes-container";
+ header.appendChild(attributesContainer);
+
+ if (listener.tags) {
+ for (const tag of listener.tags.split(",")) {
+ const attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ const tagBox = doc.createElementNS(XHTML_NS, "span");
+ tagBox.className = "event-tooltip-attributes";
+ tagBox.textContent = tag;
+ tagBox.setAttribute("title", tag);
+ attributesBox.appendChild(tagBox);
+ }
+ }
+
+ if (!listener.hide.capturing) {
+ const attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ const capturing = doc.createElementNS(XHTML_NS, "span");
+ capturing.className = "event-tooltip-attributes";
+
+ const phase = listener.capturing ? Capturing : Bubbling;
+ capturing.textContent = phase;
+ capturing.setAttribute("title", phase);
+ attributesBox.appendChild(capturing);
+ }
+
+ const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
+ toggleListenerCheckbox.type = "checkbox";
+ toggleListenerCheckbox.className =
+ "event-tooltip-listener-toggle-checkbox";
+ if (listener.eventListenerInfoId) {
+ toggleListenerCheckbox.checked = listener.enabled;
+ toggleListenerCheckbox.setAttribute(
+ "data-event-listener-info-id",
+ listener.eventListenerInfoId
+ );
+ toggleListenerCheckbox.addEventListener(
+ "change",
+ this._eventToggleCheckboxChanged,
+ { signal: this._eventListenersAbortController.signal }
+ );
+ } else {
+ toggleListenerCheckbox.checked = true;
+ toggleListenerCheckbox.setAttribute("disabled", true);
+ }
+ header.appendChild(toggleListenerCheckbox);
+
+ // Content
+ const editor = new Editor(config);
+ this._eventEditors.set(content, {
+ editor,
+ handler: listener.handler,
+ native: listener.native,
+ appended: false,
+ location,
+ });
+
+ content.className = "event-tooltip-content-box";
+ this.container.appendChild(content);
+
+ this._addContentListeners(header);
+ }
+
+ this._tooltip.panel.innerHTML = "";
+ this._tooltip.panel.appendChild(this.container);
+ this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity });
+ }
+
+ _addContentListeners(header) {
+ header.addEventListener("click", this._headerClicked, {
+ signal: this._eventListenersAbortController.signal,
+ });
+ }
+
+ _headerClicked(event) {
+ // Clicking on the checkbox shouldn't impact the header (checkbox state change is
+ // handled in _eventToggleCheckboxChanged).
+ if (
+ event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
+ ) {
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.target.classList.contains("event-tooltip-debugger-icon")) {
+ this._debugClicked(event);
+ event.stopPropagation();
+ return;
+ }
+
+ const doc = this._tooltip.doc;
+ const header = event.currentTarget;
+ const content = header.nextElementSibling;
+
+ if (content.hasAttribute("open")) {
+ header.classList.remove("content-expanded");
+ content.removeAttribute("open");
+ } else {
+ // Close other open events first
+ const openHeaders = doc.querySelectorAll(
+ ".event-header.content-expanded"
+ );
+ const openContent = doc.querySelectorAll(
+ ".event-tooltip-content-box[open]"
+ );
+ for (const node of openHeaders) {
+ node.classList.remove("content-expanded");
+ }
+ for (const node of openContent) {
+ node.removeAttribute("open");
+ }
+
+ header.classList.add("content-expanded");
+ content.setAttribute("open", "");
+
+ const eventEditor = this._eventEditors.get(content);
+
+ if (eventEditor.appended) {
+ return;
+ }
+
+ const { editor, handler } = eventEditor;
+
+ const iframe = doc.createElementNS(XHTML_NS, "iframe");
+ iframe.classList.add("event-tooltip-editor-frame");
+
+ editor.appendTo(content, iframe).then(() => {
+ const tidied = beautify.js(handler, { indent_size: 2 });
+ editor.setText(tidied);
+
+ eventEditor.appended = true;
+
+ const container = header.parentElement.getBoundingClientRect();
+ if (header.getBoundingClientRect().top < container.top) {
+ header.scrollIntoView(true);
+ } else if (content.getBoundingClientRect().bottom > container.bottom) {
+ content.scrollIntoView(false);
+ }
+
+ this._tooltip.emitForTests("event-tooltip-ready");
+ });
+ }
+ }
+
+ _debugClicked(event) {
+ const header = event.currentTarget;
+ const content = header.nextElementSibling;
+
+ const { location } = this._eventEditors.get(content);
+ if (location) {
+ // Save a copy of toolbox as it will be set to null when we hide the tooltip.
+ const toolbox = this._toolbox;
+
+ this._tooltip.hide();
+
+ toolbox.viewSourceInDebugger(
+ location.url,
+ location.line,
+ location.column,
+ location.id
+ );
+ }
+ }
+
+ async _eventToggleCheckboxChanged(event) {
+ const checkbox = event.currentTarget;
+ const id = checkbox.getAttribute("data-event-listener-info-id");
+ if (checkbox.checked) {
+ await this._nodeFront.enableEventListener(id);
+ } else {
+ await this._nodeFront.disableEventListener(id);
+ }
+ this.emit("event-tooltip-listener-toggled", {
+ hasDisabledEventListeners:
+ // No need to query the other checkboxes if the handled checkbox is unchecked
+ !checkbox.checked ||
+ this._tooltip.doc.querySelector(
+ `input.event-tooltip-listener-toggle-checkbox:not(:checked)`
+ ) !== null,
+ });
+ }
+
+ /**
+ * Parse URI and return {url, line, column}; or return null if it can't be parsed.
+ */
+ _parseLocation(uri) {
+ if (uri && uri !== "?") {
+ uri = uri.replace(/"/g, "");
+
+ let matches = uri.match(/(.*):(\d+):(\d+$)/);
+
+ if (matches) {
+ return {
+ url: matches[1],
+ line: parseInt(matches[2], 10),
+ column: parseInt(matches[3], 10),
+ };
+ } else if ((matches = uri.match(/(.*):(\d+$)/))) {
+ return {
+ url: matches[1],
+ line: parseInt(matches[2], 10),
+ column: null,
+ };
+ }
+ return { url: uri, line: 1, column: null };
+ }
+ return null;
+ }
+
+ destroy() {
+ if (this._tooltip) {
+ const boxes = this.container.querySelectorAll(
+ ".event-tooltip-content-box"
+ );
+
+ for (const box of boxes) {
+ const { editor } = this._eventEditors.get(box);
+ editor.destroy();
+ }
+
+ this._eventEditors = null;
+ this._tooltip.eventTooltip = null;
+ }
+
+ this.clearEvents();
+ if (this._eventListenersAbortController) {
+ this._eventListenersAbortController.abort();
+ this._eventListenersAbortController = null;
+ }
+
+ for (const unsubscribe of this._subscriptions) {
+ unsubscribe();
+ }
+
+ this._toolbox = this._tooltip = this._nodeFront = null;
+ }
+}
+
+module.exports.EventTooltip = EventTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
new file mode 100644
index 0000000000..7c219b61af
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -0,0 +1,1061 @@
+/* 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("resource://devtools/shared/event-emitter.js");
+
+loader.lazyRequireGetter(
+ this,
+ "focusableSelector",
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TooltipToggle",
+ "resource://devtools/client/shared/widgets/tooltip/TooltipToggle.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "listenOnce",
+ "resource://devtools/shared/async-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const POSITION = {
+ TOP: "top",
+ BOTTOM: "bottom",
+};
+
+module.exports.POSITION = POSITION;
+
+const TYPE = {
+ NORMAL: "normal",
+ ARROW: "arrow",
+ DOORHANGER: "doorhanger",
+};
+
+module.exports.TYPE = TYPE;
+
+const ARROW_WIDTH = {
+ normal: 0,
+ arrow: 32,
+ // This is the value calculated for the .tooltip-arrow element in tooltip.css
+ // which includes the arrow width (20px) plus the extra margin added so that
+ // the drop shadow is not cropped (2px each side).
+ doorhanger: 24,
+};
+
+const ARROW_OFFSET = {
+ normal: 0,
+ // Default offset between the tooltip's edge and the tooltip arrow.
+ arrow: 20,
+ // Match other Firefox menus which use 10px from edge (but subtract the 2px
+ // margin included in the ARROW_WIDTH above).
+ doorhanger: 8,
+};
+
+const EXTRA_HEIGHT = {
+ normal: 0,
+ // The arrow is 16px tall, but merges on 3px with the panel border
+ arrow: 13,
+ // The doorhanger arrow is 10px tall, but merges on 1px with the panel border
+ doorhanger: 9,
+};
+
+const EXTRA_BORDER = {
+ normal: 0,
+ arrow: -0.5,
+ doorhanger: 0,
+};
+
+/**
+ * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
+ * respect the provided height and position preferences, unless the available height
+ * prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ * Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} viewportRect
+ * Bounding rectangle for the viewport. top/left can be different from 0 if some
+ * space should not be used by tooltips (for instance OS toolbars, taskbars etc.).
+ * @param {Number} height
+ * Preferred height for the tooltip.
+ * @param {String} pos
+ * Preferred position for the tooltip. Possible values: "top" or "bottom".
+ * @param {Number} offset
+ * Offset between the top of the anchor and the tooltip.
+ * @return {Object}
+ * - {Number} top: the top offset for the tooltip.
+ * - {Number} height: the height to use for the tooltip container.
+ * - {String} computedPosition: Can differ from the preferred position depending
+ * on the available height). "top" or "bottom"
+ */
+const calculateVerticalPosition = (
+ anchorRect,
+ viewportRect,
+ height,
+ pos,
+ offset
+) => {
+ const { TOP, BOTTOM } = POSITION;
+
+ let { top: anchorTop, height: anchorHeight } = anchorRect;
+
+ // Translate to the available viewport space before calculating dimensions and position.
+ anchorTop -= viewportRect.top;
+
+ // Calculate available space for the tooltip.
+ const availableTop = anchorTop;
+ const availableBottom = viewportRect.height - (anchorTop + anchorHeight);
+
+ // Find POSITION
+ let keepPosition = false;
+ if (pos === TOP) {
+ keepPosition = availableTop >= height + offset;
+ } else if (pos === BOTTOM) {
+ keepPosition = availableBottom >= height + offset;
+ }
+ if (!keepPosition) {
+ pos = availableTop > availableBottom ? TOP : BOTTOM;
+ }
+
+ // Calculate HEIGHT.
+ const availableHeight = pos === TOP ? availableTop : availableBottom;
+ height = Math.min(height, availableHeight - offset);
+
+ // Calculate TOP.
+ let top =
+ pos === TOP
+ ? anchorTop - height - offset
+ : anchorTop + anchorHeight + offset;
+
+ // Translate back to absolute coordinates by re-including viewport top margin.
+ top += viewportRect.top;
+
+ return {
+ top: Math.round(top),
+ height: Math.round(height),
+ computedPosition: pos,
+ };
+};
+
+/**
+ * Calculate the horizontal position & offsets to use for the tooltip. Will
+ * attempt to respect the provided width and position preferences, unless the
+ * available width prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ * Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} viewportRect
+ * Bounding rectangle for the viewport. top/left can be different from
+ * 0 if some space should not be used by tooltips (for instance OS
+ * toolbars, taskbars etc.).
+ * @param {DOMRect} windowRect
+ * Bounding rectangle for the window. Used to determine which direction
+ * doorhangers should hang.
+ * @param {Number} width
+ * Preferred width for the tooltip.
+ * @param {String} type
+ * The tooltip type (e.g. "arrow").
+ * @param {Number} offset
+ * Horizontal offset in pixels.
+ * @param {Number} borderRadius
+ * The border radius of the panel. This is added to ARROW_OFFSET to
+ * calculate the distance from the edge of the tooltip to the start
+ * of arrow. It is separate from ARROW_OFFSET since it will vary by
+ * platform.
+ * @param {Boolean} isRtl
+ * If the anchor is in RTL, the tooltip should be aligned to the right.
+ * @return {Object}
+ * - {Number} left: the left offset for the tooltip.
+ * - {Number} width: the width to use for the tooltip container.
+ * - {Number} arrowLeft: the left offset to use for the arrow element.
+ */
+const calculateHorizontalPosition = (
+ anchorRect,
+ viewportRect,
+ windowRect,
+ width,
+ type,
+ offset,
+ borderRadius,
+ isRtl,
+ isMenuTooltip
+) => {
+ // All tooltips from content should follow the writing direction.
+ //
+ // For tooltips (including doorhanger tooltips) we follow the writing
+ // direction but for menus created using doorhangers the guidelines[1] say
+ // that:
+ //
+ // "Doorhangers opening on the right side of the view show the directional
+ // arrow on the right.
+ //
+ // Doorhangers opening on the left side of the view show the directional
+ // arrow on the left.
+ //
+ // Never place the directional arrow at the center of doorhangers."
+ //
+ // [1] https://design.firefox.com/photon/components/doorhangers.html#directional-arrow
+ //
+ // So for those we need to check if the anchor is more right or left.
+ let hangDirection;
+ if (type === TYPE.DOORHANGER && isMenuTooltip) {
+ const anchorCenter = anchorRect.left + anchorRect.width / 2;
+ const viewCenter = windowRect.left + windowRect.width / 2;
+ hangDirection = anchorCenter >= viewCenter ? "left" : "right";
+ } else {
+ hangDirection = isRtl ? "left" : "right";
+ }
+
+ const anchorWidth = anchorRect.width;
+
+ // Calculate logical start of anchor relative to the viewport.
+ const anchorStart =
+ hangDirection === "right"
+ ? anchorRect.left - viewportRect.left
+ : viewportRect.right - anchorRect.right;
+
+ // Calculate tooltip width.
+ const tooltipWidth = Math.min(width, viewportRect.width);
+
+ // Calculate tooltip start.
+ let tooltipStart = anchorStart + offset;
+ tooltipStart = Math.min(tooltipStart, viewportRect.width - tooltipWidth);
+ tooltipStart = Math.max(0, tooltipStart);
+
+ // Calculate arrow start (tooltip's start might be updated)
+ const arrowWidth = ARROW_WIDTH[type];
+ let arrowStart;
+ // Arrow and doorhanger style tooltips may need to be shifted
+ if (type === TYPE.ARROW || type === TYPE.DOORHANGER) {
+ const arrowOffset = ARROW_OFFSET[type] + borderRadius;
+
+ // Where will the point of the arrow be if we apply the standard offset?
+ const arrowCenter = tooltipStart + arrowOffset + arrowWidth / 2;
+
+ // How does that compare to the center of the anchor?
+ const anchorCenter = anchorStart + anchorWidth / 2;
+
+ // If the anchor is too narrow, align the arrow and the anchor center.
+ if (arrowCenter > anchorCenter) {
+ tooltipStart = Math.max(0, tooltipStart - (arrowCenter - anchorCenter));
+ }
+ // Arrow's start offset relative to the anchor.
+ arrowStart = Math.min(arrowOffset, (anchorWidth - arrowWidth) / 2) | 0;
+ // Translate the coordinate to tooltip container
+ arrowStart += anchorStart - tooltipStart;
+ // Make sure the arrow remains in the tooltip container.
+ arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth - borderRadius);
+ arrowStart = Math.max(arrowStart, borderRadius);
+ }
+
+ // Convert from logical coordinates to physical
+ const left =
+ hangDirection === "right"
+ ? viewportRect.left + tooltipStart
+ : viewportRect.right - tooltipStart - tooltipWidth;
+ const arrowLeft =
+ hangDirection === "right"
+ ? arrowStart
+ : tooltipWidth - arrowWidth - arrowStart;
+
+ return {
+ left: Math.round(left),
+ width: Math.round(tooltipWidth),
+ arrowLeft: Math.round(arrowLeft),
+ };
+};
+
+/**
+ * Get the bounding client rectangle for a given node, relative to a custom
+ * reference element (instead of the default for getBoundingClientRect which
+ * is always the element's ownerDocument).
+ */
+const getRelativeRect = function (node, relativeTo) {
+ // getBoxQuads is a non-standard WebAPI which will not work on non-firefox
+ // browser when running launchpad on Chrome.
+ if (
+ !node.getBoxQuads ||
+ !node.getBoxQuads({
+ relativeTo,
+ createFramesForSuppressedWhitespace: false,
+ })[0]
+ ) {
+ const { top, left, width, height } = node.getBoundingClientRect();
+ const right = left + width;
+ const bottom = top + height;
+ return { top, right, bottom, left, width, height };
+ }
+
+ // Width and Height can be taken from the rect.
+ const { width, height } = node.getBoundingClientRect();
+
+ const quadBounds = node
+ .getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false })[0]
+ .getBounds();
+ const top = quadBounds.top;
+ const left = quadBounds.left;
+
+ // Compute right and bottom coordinates using the rest of the data.
+ const right = left + width;
+ const bottom = top + height;
+
+ return { top, right, bottom, left, width, height };
+};
+
+/**
+ * The HTMLTooltip can display HTML content in a tooltip popup.
+ *
+ * @param {Document} toolboxDoc
+ * The toolbox document to attach the HTMLTooltip popup.
+ * @param {Object}
+ * - {String} className
+ * A string separated list of classes to add to the tooltip container
+ * element.
+ * - {Boolean} consumeOutsideClicks
+ * Defaults to true. The tooltip is closed when clicking outside.
+ * Should this event be stopped and consumed or not.
+ * - {String} id
+ * The ID to assign to the tooltip container element.
+ * - {Boolean} isMenuTooltip
+ * Defaults to false. If the tooltip is a menu then this should be set
+ * to true.
+ * - {String} type
+ * Display type of the tooltip. Possible values: "normal", "arrow", and
+ * "doorhanger".
+ * - {Boolean} useXulWrapper
+ * Defaults to false. If the tooltip is hosted in a XUL document, use a
+ * XUL panel in order to use all the screen viewport available.
+ * - {Boolean} noAutoHide
+ * Defaults to false. If this property is set to false or omitted, the
+ * tooltip will automatically disappear after a few seconds. If this
+ * attribute is set to true, this will not happen and the tooltip will
+ * only hide when the user moves the mouse to another element.
+ */
+function HTMLTooltip(
+ toolboxDoc,
+ {
+ className = "",
+ consumeOutsideClicks = true,
+ id = "",
+ isMenuTooltip = false,
+ type = "normal",
+ useXulWrapper = false,
+ noAutoHide = false,
+ } = {}
+) {
+ EventEmitter.decorate(this);
+
+ this.doc = toolboxDoc;
+ this.id = id;
+ this.className = className;
+ this.type = type;
+ this.noAutoHide = noAutoHide;
+ // consumeOutsideClicks cannot be used if the tooltip is not closed on click
+ this.consumeOutsideClicks = this.noAutoHide ? false : consumeOutsideClicks;
+ this.isMenuTooltip = isMenuTooltip;
+ this.useXulWrapper = this._isXULPopupAvailable() && useXulWrapper;
+ this.preferredWidth = "auto";
+ this.preferredHeight = "auto";
+
+ // The top window is used to attach click event listeners to close the tooltip if the
+ // user clicks on the content page.
+ this.topWindow = this._getTopWindow();
+
+ this._position = null;
+
+ this._onClick = this._onClick.bind(this);
+ this._onMouseup = this._onMouseup.bind(this);
+ this._onXulPanelHidden = this._onXulPanelHidden.bind(this);
+
+ this.container = this._createContainer();
+ this.container.classList.toggle("tooltip-container-xul", this.useXulWrapper);
+
+ if (this.useXulWrapper) {
+ // When using a XUL panel as the wrapper, the actual markup for the tooltip is as
+ // follows :
+ // <panel> <!-- XUL panel used to position the tooltip anywhere on screen -->
+ // <div> <!-- div wrapper used to isolate the tooltip container -->
+ // <div> <! the actual tooltip.container element -->
+ this.xulPanelWrapper = this._createXulPanelWrapper();
+ const inner = this.doc.createElementNS(XHTML_NS, "div");
+ inner.classList.add("tooltip-xul-wrapper-inner");
+
+ this.doc.documentElement.appendChild(this.xulPanelWrapper);
+ this.xulPanelWrapper.appendChild(inner);
+ inner.appendChild(this.container);
+ } else if (this._hasXULRootElement()) {
+ this.doc.documentElement.appendChild(this.container);
+ } else {
+ // In non-XUL context the container is ready to use as is.
+ this.doc.body.appendChild(this.container);
+ }
+}
+
+module.exports.HTMLTooltip = HTMLTooltip;
+
+HTMLTooltip.prototype = {
+ /**
+ * The tooltip panel is the parentNode of the tooltip content.
+ */
+ get panel() {
+ return this.container.querySelector(".tooltip-panel");
+ },
+
+ /**
+ * The arrow element. Might be null depending on the tooltip type.
+ */
+ get arrow() {
+ return this.container.querySelector(".tooltip-arrow");
+ },
+
+ /**
+ * Retrieve the displayed position used for the tooltip. Null if the tooltip is hidden.
+ */
+ get position() {
+ return this.isVisible() ? this._position : null;
+ },
+
+ get toggle() {
+ if (!this._toggle) {
+ this._toggle = new TooltipToggle(this);
+ }
+
+ return this._toggle;
+ },
+
+ /**
+ * Set the preferred width/height of the panel content.
+ * The panel content is set by appending content to `this.panel`.
+ *
+ * @param {Object}
+ * - {Number} width: preferred width for the tooltip container. If not specified
+ * the tooltip container will be measured before being displayed, and the
+ * measured width will be used as the preferred width.
+ * - {Number} height: preferred height for the tooltip container. If
+ * not specified the tooltip container will be measured before being
+ * displayed, and the measured height will be used as the preferred
+ * height.
+ *
+ * For tooltips whose content height may change while being
+ * displayed, the special value Infinity may be used to produce
+ * a flexible container that accommodates resizing content. Note,
+ * however, that when used in combination with the XUL wrapper the
+ * unfilled part of this container will consume all mouse events
+ * making content behind this area inaccessible until the tooltip is
+ * dismissed.
+ */
+ setContentSize({ width = "auto", height = "auto" } = {}) {
+ this.preferredWidth = width;
+ this.preferredHeight = height;
+ },
+
+ /**
+ * Show the tooltip next to the provided anchor element, or update the tooltip position
+ * if it was already visible. A preferred position can be set.
+ * The event "shown" will be fired after the tooltip is displayed.
+ *
+ * @param {Element} anchor
+ * The reference element with which the tooltip should be aligned
+ * @param {Object} options
+ * Optional settings for positioning the tooltip.
+ * @param {String} options.position
+ * Optional, possible values: top|bottom
+ * If layout permits, the tooltip will be displayed on top/bottom
+ * of the anchor. If omitted, the tooltip will be displayed where
+ * more space is available.
+ * @param {Number} options.x
+ * Optional, horizontal offset between the anchor and the tooltip.
+ * @param {Number} options.y
+ * Optional, vertical offset between the anchor and the tooltip.
+ */
+ async show(anchor, options) {
+ const { left, top } = this._updateContainerBounds(anchor, options);
+ const isTooltipVisible = this.isVisible();
+
+ if (this.useXulWrapper) {
+ if (!isTooltipVisible) {
+ await this._showXulWrapperAt(left, top);
+ } else {
+ this._moveXulWrapperTo(left, top);
+ }
+ } else {
+ this.container.style.left = left + "px";
+ this.container.style.top = top + "px";
+ }
+
+ if (isTooltipVisible) {
+ return;
+ }
+
+ this.container.classList.add("tooltip-visible");
+
+ // Keep a pointer on the focused element to refocus it when hiding the tooltip.
+ this._focusedElement = this.doc.activeElement;
+
+ if (this.doc.defaultView) {
+ if (!this._pendingEventListenerPromise) {
+ // On Windows and Linux, if the tooltip is shown on mousedown/click (which is the
+ // case for the MenuButton component for example), attaching the events listeners
+ // on the window right away would trigger the callbacks; which means the tooltip
+ // would be instantly hidden. To prevent such thing, the event listeners are set
+ // on the next tick.
+ this._pendingEventListenerPromise = new Promise(resolve => {
+ this.doc.defaultView.setTimeout(() => {
+ // Update the top window reference each time in case the host changes.
+ this.topWindow = this._getTopWindow();
+ this.topWindow.addEventListener("click", this._onClick, true);
+ this.topWindow.addEventListener("mouseup", this._onMouseup, true);
+ resolve();
+ }, 0);
+ });
+ }
+
+ await this._pendingEventListenerPromise;
+ this._pendingEventListenerPromise = null;
+ }
+
+ this.emit("shown");
+ },
+
+ startTogglingOnHover(baseNode, targetNodeCb, options) {
+ this.toggle.start(baseNode, targetNodeCb, options);
+ },
+
+ stopTogglingOnHover() {
+ this.toggle.stop();
+ },
+
+ _updateContainerBounds(anchor, { position, x = 0, y = 0 } = {}) {
+ // Get anchor geometry
+ let anchorRect = getRelativeRect(anchor, this.doc);
+ if (this.useXulWrapper) {
+ anchorRect = this._convertToScreenRect(anchorRect);
+ }
+
+ const { viewportRect, windowRect } = this._getBoundingRects(anchorRect);
+
+ // Calculate the horizonal position and width
+ let preferredWidth;
+ // Record the height too since it might save us from having to look it up
+ // later.
+ let measuredHeight;
+ const currentScrollTop = this.panel.scrollTop;
+ if (this.preferredWidth === "auto") {
+ // Reset any styles that constrain the dimensions we want to calculate.
+ this.container.style.width = "auto";
+ if (this.preferredHeight === "auto") {
+ this.container.style.height = "auto";
+ }
+ ({ width: preferredWidth, height: measuredHeight } =
+ this._measureContainerSize());
+ } else {
+ const themeWidth = 2 * EXTRA_BORDER[this.type];
+ preferredWidth = this.preferredWidth + themeWidth;
+ }
+
+ const anchorWin = anchor.ownerDocument.defaultView;
+ const anchorCS = anchorWin.getComputedStyle(anchor);
+ const isRtl = anchorCS.direction === "rtl";
+
+ let borderRadius = 0;
+ if (this.type === TYPE.DOORHANGER) {
+ borderRadius = parseFloat(
+ anchorCS.getPropertyValue("--theme-arrowpanel-border-radius")
+ );
+ if (Number.isNaN(borderRadius)) {
+ borderRadius = 0;
+ }
+ }
+
+ const { left, width, arrowLeft } = calculateHorizontalPosition(
+ anchorRect,
+ viewportRect,
+ windowRect,
+ preferredWidth,
+ this.type,
+ x,
+ borderRadius,
+ isRtl,
+ this.isMenuTooltip
+ );
+
+ // If we constrained the width, then any measured height we have is no
+ // longer valid.
+ if (measuredHeight && width !== preferredWidth) {
+ measuredHeight = undefined;
+ }
+
+ // Apply width and arrow positioning
+ this.container.style.width = width + "px";
+ if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
+ this.arrow.style.left = arrowLeft + "px";
+ }
+
+ // Work out how much vertical margin we have.
+ //
+ // This relies on us having set either .tooltip-top or .tooltip-bottom
+ // and on the margins for both being symmetrical. Fortunately the call to
+ // _measureContainerSize above will set .tooltip-top for us and it also
+ // assumes these styles are symmetrical so this should be ok.
+ const panelWindow = this.panel.ownerDocument.defaultView;
+ const panelComputedStyle = panelWindow.getComputedStyle(this.panel);
+ const verticalMargin =
+ parseFloat(panelComputedStyle.marginTop) +
+ parseFloat(panelComputedStyle.marginBottom);
+
+ // Calculate the vertical position and height
+ let preferredHeight;
+ if (this.preferredHeight === "auto") {
+ if (measuredHeight) {
+ // We already have a valid height measured in a previous step.
+ preferredHeight = measuredHeight;
+ } else {
+ this.container.style.height = "auto";
+ ({ height: preferredHeight } = this._measureContainerSize());
+ }
+ preferredHeight += verticalMargin;
+ } else {
+ const themeHeight =
+ EXTRA_HEIGHT[this.type] + verticalMargin + 2 * EXTRA_BORDER[this.type];
+ preferredHeight = this.preferredHeight + themeHeight;
+ }
+
+ const { top, height, computedPosition } = calculateVerticalPosition(
+ anchorRect,
+ viewportRect,
+ preferredHeight,
+ position,
+ y
+ );
+
+ this._position = computedPosition;
+ const isTop = computedPosition === POSITION.TOP;
+ this.container.classList.toggle("tooltip-top", isTop);
+ this.container.classList.toggle("tooltip-bottom", !isTop);
+
+ // If the preferred height is set to Infinity, the tooltip container should grow based
+ // on its content's height and use as much height as possible.
+ this.container.classList.toggle(
+ "tooltip-flexible-height",
+ this.preferredHeight === Infinity
+ );
+
+ this.container.style.height = height + "px";
+ this.panel.scrollTop = currentScrollTop;
+
+ return { left, top };
+ },
+
+ /**
+ * Calculate the following boundary rectangles:
+ *
+ * - Viewport rect: This is the region that limits the tooltip dimensions.
+ * When using a XUL panel wrapper, the tooltip will be able to use the whole
+ * screen (excluding space reserved by the OS for toolbars etc.) and hence
+ * the result will be in screen coordinates.
+ * Otherwise, the tooltip is limited to the tooltip's document.
+ *
+ * - Window rect: This is the bounds of the view in which the tooltip is
+ * presented. It is reported in the same coordinates as the viewport
+ * rect and is used for determining in which direction a doorhanger-type
+ * tooltip should "hang".
+ * When using the XUL panel wrapper this will be the dimensions of the
+ * window in screen coordinates. Otherwise it will be the same as the
+ * viewport rect.
+ *
+ * @param {Object} anchorRect
+ * DOMRect-like object of the target anchor element.
+ * We need to pass this to detect the case when the anchor is not in
+ * the current window (because, the center of the window is in
+ * a different window to the anchor).
+ *
+ * @return {Object} An object with the following properties
+ * viewportRect {Object} DOMRect-like object with the Number
+ * properties: top, right, bottom, left, width, height
+ * representing the viewport rect.
+ * windowRect {Object} DOMRect-like object with the Number
+ * properties: top, right, bottom, left, width, height
+ * representing the window rect.
+ */
+ _getBoundingRects(anchorRect) {
+ let viewportRect;
+ let windowRect;
+
+ if (this.useXulWrapper) {
+ // availLeft/Top are the coordinates first pixel available on the screen
+ // for applications (excluding space dedicated for OS toolbars, menus
+ // etc...)
+ // availWidth/Height are the dimensions available to applications
+ // excluding all the OS reserved space
+ const { availLeft, availTop, availHeight, availWidth } =
+ this.doc.defaultView.screen;
+ viewportRect = {
+ top: availTop,
+ right: availLeft + availWidth,
+ bottom: availTop + availHeight,
+ left: availLeft,
+ width: availWidth,
+ height: availHeight,
+ };
+
+ const { screenX, screenY, outerWidth, outerHeight } =
+ this.doc.defaultView;
+ windowRect = {
+ top: screenY,
+ right: screenX + outerWidth,
+ bottom: screenY + outerHeight,
+ left: screenX,
+ width: outerWidth,
+ height: outerHeight,
+ };
+
+ // If the anchor is outside the viewport, it possibly means we have a
+ // multi-monitor environment where the anchor is displayed on a different
+ // monitor to the "current" screen (as determined by the center of the
+ // window). This can happen when, for example, the screen is spread across
+ // two monitors.
+ //
+ // In this case we simply expand viewport in the direction of the anchor
+ // so that we can still calculate the popup position correctly.
+ if (anchorRect.left > viewportRect.right) {
+ const diffWidth = windowRect.right - viewportRect.right;
+ viewportRect.right += diffWidth;
+ viewportRect.width += diffWidth;
+ }
+ if (anchorRect.right < viewportRect.left) {
+ const diffWidth = viewportRect.left - windowRect.left;
+ viewportRect.left -= diffWidth;
+ viewportRect.width += diffWidth;
+ }
+ } else {
+ viewportRect = windowRect =
+ this.doc.documentElement.getBoundingClientRect();
+ }
+
+ return { viewportRect, windowRect };
+ },
+
+ _measureContainerSize() {
+ const xulParent = this.container.parentNode;
+ if (this.useXulWrapper && !this.isVisible()) {
+ // Move the container out of the XUL Panel to measure it.
+ this.doc.documentElement.appendChild(this.container);
+ }
+
+ this.container.classList.add("tooltip-hidden");
+ // Set either of the tooltip-top or tooltip-bottom styles so that we get an
+ // accurate height. We're assuming that the two styles will be symmetrical
+ // and that we will clear this as necessary later.
+ this.container.classList.add("tooltip-top");
+ this.container.classList.remove("tooltip-bottom");
+ const { width, height } = this.container.getBoundingClientRect();
+ this.container.classList.remove("tooltip-hidden");
+
+ if (this.useXulWrapper && !this.isVisible()) {
+ xulParent.appendChild(this.container);
+ }
+
+ return { width, height };
+ },
+
+ /**
+ * Hide the current tooltip. The event "hidden" will be fired when the tooltip
+ * is hidden.
+ */
+ async hide({ fromMouseup = false } = {}) {
+ // Exit if the disable autohide setting is in effect or if hide() is called
+ // from a mouseup event and the tooltip has noAutoHide set to true.
+ if (
+ Services.prefs.getBoolPref("devtools.popup.disable_autohide", false) ||
+ (this.noAutoHide && this.isVisible() && fromMouseup)
+ ) {
+ return;
+ }
+
+ if (!this.isVisible()) {
+ this.emit("hidden");
+ return;
+ }
+
+ // If the tooltip is hidden from a mouseup event, wait for a potential click event
+ // to be consumed before removing event listeners.
+ if (fromMouseup) {
+ await new Promise(resolve => this.topWindow.setTimeout(resolve, 0));
+ }
+
+ if (this._pendingEventListenerPromise) {
+ this._pendingEventListenerPromise.then(() => this.removeEventListeners());
+ } else {
+ this.removeEventListeners();
+ }
+
+ this.container.classList.remove("tooltip-visible");
+ if (this.useXulWrapper) {
+ await this._hideXulWrapper();
+ }
+
+ this.emit("hidden");
+
+ const tooltipHasFocus = this.container.contains(this.doc.activeElement);
+ if (tooltipHasFocus && this._focusedElement) {
+ this._focusedElement.focus();
+ this._focusedElement = null;
+ }
+ },
+
+ removeEventListeners() {
+ this.topWindow.removeEventListener("click", this._onClick, true);
+ this.topWindow.removeEventListener("mouseup", this._onMouseup, true);
+ },
+
+ /**
+ * Check if the tooltip is currently displayed.
+ * @return {Boolean} true if the tooltip is visible
+ */
+ isVisible() {
+ return this.container.classList.contains("tooltip-visible");
+ },
+
+ /**
+ * Destroy the tooltip instance. Hide the tooltip if displayed, remove the
+ * tooltip container from the document.
+ */
+ destroy() {
+ this.hide();
+ this.removeEventListeners();
+ this.container.remove();
+ if (this.xulPanelWrapper) {
+ this.xulPanelWrapper.remove();
+ }
+ if (this._toggle) {
+ this._toggle.destroy();
+ this._toggle = null;
+ }
+ },
+
+ _createContainer() {
+ const container = this.doc.createElementNS(XHTML_NS, "div");
+ container.setAttribute("type", this.type);
+
+ if (this.id) {
+ container.setAttribute("id", this.id);
+ }
+
+ container.classList.add("tooltip-container");
+ if (this.className) {
+ container.classList.add(...this.className.split(" "));
+ }
+
+ const filler = this.doc.createElementNS(XHTML_NS, "div");
+ filler.classList.add("tooltip-filler");
+ container.appendChild(filler);
+
+ const panel = this.doc.createElementNS(XHTML_NS, "div");
+ panel.classList.add("tooltip-panel");
+ container.appendChild(panel);
+
+ if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
+ const arrow = this.doc.createElementNS(XHTML_NS, "div");
+ arrow.classList.add("tooltip-arrow");
+ container.appendChild(arrow);
+ }
+ return container;
+ },
+
+ _onClick(e) {
+ if (this._isInTooltipContainer(e.target)) {
+ return;
+ }
+
+ if (this.consumeOutsideClicks && e.button === 0) {
+ // Consume only left click events (button === 0).
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+
+ /**
+ * Hide the tooltip on mouseup rather than on click because the surrounding markup
+ * may change on mousedown in a way that prevents a "click" event from being fired.
+ * If the element that received the mousedown and the mouseup are different, click
+ * will not be fired.
+ */
+ _onMouseup(e) {
+ if (this._isInTooltipContainer(e.target)) {
+ return;
+ }
+
+ this.hide({ fromMouseup: true });
+ },
+
+ _isInTooltipContainer(node) {
+ // Check if the target is the tooltip arrow.
+ if (this.arrow && this.arrow === node) {
+ return true;
+ }
+
+ if (typeof node.closest == "function" && node.closest("menupopup")) {
+ // Ignore events from menupopup elements which will not be children of the
+ // tooltip container even if their owner element is in the tooltip.
+ // See Bug 1811002.
+ return true;
+ }
+
+ const tooltipWindow = this.panel.ownerDocument.defaultView;
+ let win = node.ownerDocument.defaultView;
+
+ // Check if the tooltip panel contains the node if they live in the same document.
+ if (win === tooltipWindow) {
+ return this.panel.contains(node);
+ }
+
+ // Check if the node window is in the tooltip container.
+ while (win.parent && win.parent !== win) {
+ if (win.parent === tooltipWindow) {
+ // If the parent window is the tooltip window, check if the tooltip contains
+ // the current frame element.
+ return this.panel.contains(win.frameElement);
+ }
+ win = win.parent;
+ }
+
+ return false;
+ },
+
+ _onXulPanelHidden() {
+ if (this.isVisible()) {
+ this.hide();
+ }
+ },
+
+ /**
+ * Focus on the first focusable item in the tooltip.
+ *
+ * Returns true if we found something to focus on, false otherwise.
+ */
+ focus() {
+ const focusableElement = this.panel.querySelector(focusableSelector);
+ if (focusableElement) {
+ focusableElement.focus();
+ }
+ return !!focusableElement;
+ },
+
+ /**
+ * Focus on the last focusable item in the tooltip.
+ *
+ * Returns true if we found something to focus on, false otherwise.
+ */
+ focusEnd() {
+ const focusableElements = this.panel.querySelectorAll(focusableSelector);
+ if (focusableElements.length) {
+ focusableElements[focusableElements.length - 1].focus();
+ }
+ return focusableElements.length !== 0;
+ },
+
+ _getTopWindow() {
+ return DevToolsUtils.getTopWindow(this.doc.defaultView);
+ },
+
+ /**
+ * Check if the tooltip's owner document has XUL root element.
+ */
+ _hasXULRootElement() {
+ return this.doc.documentElement.namespaceURI === XUL_NS;
+ },
+
+ _isXULPopupAvailable() {
+ return this.doc.nodePrincipal.isSystemPrincipal;
+ },
+
+ _createXulPanelWrapper() {
+ const panel = this.doc.createXULElement("panel");
+
+ // XUL panel is only a way to display DOM elements outside of the document viewport,
+ // so disable all features that impact the behavior.
+ panel.setAttribute("animate", false);
+ panel.setAttribute("consumeoutsideclicks", false);
+ panel.setAttribute("incontentshell", false);
+ panel.setAttribute("noautofocus", true);
+ panel.setAttribute("noautohide", this.noAutoHide);
+
+ panel.setAttribute("ignorekeys", true);
+ panel.setAttribute("tooltip", "aHTMLTooltip");
+
+ // Use type="arrow" to prevent side effects (see Bug 1285206)
+ panel.setAttribute("type", "arrow");
+
+ panel.setAttribute("level", "top");
+ panel.setAttribute("class", "tooltip-xul-wrapper");
+
+ // Stop this appearing as an alert to accessibility.
+ panel.setAttribute("role", "presentation");
+
+ return panel;
+ },
+
+ _showXulWrapperAt(left, top) {
+ this.xulPanelWrapper.addEventListener(
+ "popuphidden",
+ this._onXulPanelHidden
+ );
+ const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown");
+ this.xulPanelWrapper.openPopupAtScreen(left, top, false);
+ return onPanelShown;
+ },
+
+ _moveXulWrapperTo(left, top) {
+ this.xulPanelWrapper.moveTo(left, top);
+ },
+
+ _hideXulWrapper() {
+ this.xulPanelWrapper.removeEventListener(
+ "popuphidden",
+ this._onXulPanelHidden
+ );
+
+ if (this.xulPanelWrapper.state === "closed") {
+ // XUL panel is already closed, resolve immediately.
+ return Promise.resolve();
+ }
+
+ const onPanelHidden = listenOnce(this.xulPanelWrapper, "popuphidden");
+ this.xulPanelWrapper.hidePopup();
+ return onPanelHidden;
+ },
+
+ /**
+ * Convert from coordinates relative to the tooltip's document, to coordinates relative
+ * to the "available" screen. By "available" we mean the screen, excluding the OS bars
+ * display on screen edges.
+ */
+ _convertToScreenRect({ left, top, width, height }) {
+ // mozInnerScreenX/Y are the coordinates of the top left corner of the window's
+ // viewport, excluding chrome UI.
+ left += this.doc.defaultView.mozInnerScreenX;
+ top += this.doc.defaultView.mozInnerScreenY;
+ return {
+ top,
+ right: left + width,
+ bottom: top + height,
+ left,
+ width,
+ height,
+ };
+ },
+};
diff --git a/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
new file mode 100644
index 0000000000..aa2cc75eb7
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
@@ -0,0 +1,145 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Default image tooltip max dimension
+const MAX_DIMENSION = 200;
+const CONTAINER_MIN_WIDTH = 100;
+// Should remain synchronized with tooltips.css --image-tooltip-image-padding
+const IMAGE_PADDING = 4;
+// Should remain synchronized with tooltips.css --image-tooltip-label-height
+const LABEL_HEIGHT = 20;
+
+/**
+ * Image preview tooltips should be provided with the naturalHeight and
+ * naturalWidth value for the image to display. This helper loads the provided
+ * image URL in an image object in order to retrieve the image dimensions after
+ * the load.
+ *
+ * @param {Document} doc the document element to use to create the image object
+ * @param {String} imageUrl the url of the image to measure
+ * @return {Promise} returns a promise that will resolve after the iamge load:
+ * - {Number} naturalWidth natural width of the loaded image
+ * - {Number} naturalHeight natural height of the loaded image
+ */
+function getImageDimensions(doc, imageUrl) {
+ return new Promise(resolve => {
+ const imgObj = new doc.defaultView.Image();
+ imgObj.onload = () => {
+ imgObj.onload = null;
+ const { naturalWidth, naturalHeight } = imgObj;
+ resolve({ naturalWidth, naturalHeight });
+ };
+ imgObj.src = imageUrl;
+ });
+}
+
+/**
+ * Set the tooltip content of a provided HTMLTooltip instance to display an
+ * image preview matching the provided imageUrl.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ * @param {String} imageUrl
+ * Absolute URL of the image to display in the tooltip
+ * @param {Object} options
+ * - {Number} naturalWidth mandatory, width of the image to display
+ * - {Number} naturalHeight mandatory, height of the image to display
+ * - {Number} maxDim optional, max width/height of the preview
+ * - {Boolean} hideDimensionLabel optional, pass true to hide the label
+ * - {Boolean} hideCheckeredBackground optional, pass true to hide
+ the checkered background
+ */
+function setImageTooltip(tooltip, doc, imageUrl, options) {
+ let {
+ naturalWidth,
+ naturalHeight,
+ hideDimensionLabel,
+ hideCheckeredBackground,
+ maxDim,
+ } = options;
+ maxDim = maxDim || MAX_DIMENSION;
+
+ let imgHeight = naturalHeight;
+ let imgWidth = naturalWidth;
+ if (imgHeight > maxDim || imgWidth > maxDim) {
+ const scale = maxDim / Math.max(imgHeight, imgWidth);
+ // Only allow integer values to avoid rounding errors.
+ imgHeight = Math.floor(scale * naturalHeight);
+ imgWidth = Math.ceil(scale * naturalWidth);
+ }
+
+ // Create tooltip content
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.classList.add("devtools-tooltip-image-container");
+
+ const wrapper = doc.createElementNS(XHTML_NS, "div");
+ wrapper.classList.add("devtools-tooltip-image-wrapper");
+ container.appendChild(wrapper);
+
+ const img = doc.createElementNS(XHTML_NS, "img");
+ img.classList.add("devtools-tooltip-image");
+ img.classList.toggle("devtools-tooltip-tiles", !hideCheckeredBackground);
+ img.style.height = imgHeight;
+ img.src = encodeURI(imageUrl);
+ wrapper.appendChild(img);
+
+ if (!hideDimensionLabel) {
+ const dimensions = doc.createElementNS(XHTML_NS, "div");
+ dimensions.classList.add("devtools-tooltip-image-dimensions");
+ container.appendChild(dimensions);
+
+ const label = naturalWidth + " \u00D7 " + naturalHeight;
+ const span = doc.createElementNS(XHTML_NS, "span");
+ span.classList.add("theme-comment", "devtools-tooltip-caption");
+ span.textContent = label;
+ dimensions.appendChild(span);
+ }
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(container);
+
+ // Calculate tooltip dimensions
+ const width = Math.max(CONTAINER_MIN_WIDTH, imgWidth + 2 * IMAGE_PADDING);
+ let height = imgHeight + 2 * IMAGE_PADDING;
+ if (!hideDimensionLabel) {
+ height += parseFloat(LABEL_HEIGHT);
+ }
+
+ tooltip.setContentSize({ width, height });
+}
+
+/*
+ * Set the tooltip content of a provided HTMLTooltip instance to display a
+ * fallback error message when an image preview tooltip can not be displayed.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ */
+function setBrokenImageTooltip(tooltip, doc) {
+ const div = doc.createElementNS(XHTML_NS, "div");
+ div.className = "theme-comment devtools-tooltip-image-broken";
+ const message = L10N.getStr("previewTooltip.image.brokenImage");
+ div.textContent = message;
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(div);
+ tooltip.setContentSize({ width: "auto", height: "auto" });
+}
+
+module.exports.getImageDimensions = getImageDimensions;
+module.exports.setImageTooltip = setImageTooltip;
+module.exports.setBrokenImageTooltip = setBrokenImageTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js
new file mode 100644
index 0000000000..87e089d604
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js
@@ -0,0 +1,69 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+/**
+ * Tooltip displayed for when a CSS property is selected/highlighted.
+ * TODO: For now, the tooltip content only shows "No Associated Rule". In Bug 1528288,
+ * we will be implementing content for showing the source CSS rule.
+ */
+class RulePreviewTooltip {
+ constructor(doc) {
+ this.show = this.show.bind(this);
+ this.destroy = this.destroy.bind(this);
+
+ // Initialize tooltip structure.
+ this._tooltip = new HTMLTooltip(doc, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ useXulWrapper: true,
+ });
+
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "rule-preview-tooltip-container";
+
+ this.message = doc.createElementNS(XHTML_NS, "span");
+ this.message.className = "rule-preview-tooltip-message";
+ this.message.textContent = L10N.getStr(
+ "rulePreviewTooltip.noAssociatedRule"
+ );
+ this.container.appendChild(this.message);
+
+ // TODO: Implement structure for showing the source CSS rule.
+
+ this._tooltip.panel.innerHTML = "";
+ this._tooltip.panel.appendChild(this.container);
+ this._tooltip.setContentSize({ width: "auto", height: "auto" });
+ }
+
+ /**
+ * Shows the tooltip on a given element.
+ *
+ * @param {Element} element
+ * The target element to show the tooltip with.
+ */
+ show(element) {
+ element.addEventListener("mouseout", () => this._tooltip.hide());
+ this._tooltip.show(element);
+ }
+
+ destroy() {
+ this._tooltip.destroy();
+ this.container = null;
+ this.message = null;
+ }
+}
+
+module.exports = RulePreviewTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
new file mode 100644
index 0000000000..acc71125e8
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
@@ -0,0 +1,270 @@
+/* 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("resource://devtools/shared/event-emitter.js");
+const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+
+/**
+ * Base class for all (color, gradient, ...)-swatch based value editors inside
+ * tooltips
+ *
+ * @param {Document} document
+ * The document to attach the SwatchBasedEditorTooltip. This should be the
+ * toolbox document
+ */
+
+class SwatchBasedEditorTooltip {
+ constructor(document) {
+ EventEmitter.decorate(this);
+
+ // This one will consume outside clicks as it makes more sense to let the user
+ // close the tooltip by clicking out
+ // It will also close on <escape> and <enter>
+ this.tooltip = new HTMLTooltip(document, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ useXulWrapper: true,
+ });
+
+ // By default, swatch-based editor tooltips revert value change on <esc> and
+ // commit value change on <enter>
+ this.shortcuts = new KeyShortcuts({
+ window: this.tooltip.doc.defaultView,
+ });
+ this.shortcuts.on("Escape", event => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.revert();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ this.shortcuts.on("Return", event => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.commit();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ // All target swatches are kept in a map, indexed by swatch DOM elements
+ this.swatches = new Map();
+
+ // When a swatch is clicked, and for as long as the tooltip is shown, the
+ // activeSwatch property will hold the reference to the swatch DOM element
+ // that was clicked
+ this.activeSwatch = null;
+
+ this._onSwatchClick = this._onSwatchClick.bind(this);
+ this._onSwatchKeyDown = this._onSwatchKeyDown.bind(this);
+ }
+
+ /**
+ * Reports if the tooltip is currently shown
+ *
+ * @return {Boolean} True if the tooltip is displayed.
+ */
+ isVisible() {
+ return this.tooltip.isVisible();
+ }
+
+ /**
+ * Reports if the tooltip is currently editing the targeted value
+ *
+ * @return {Boolean} True if the tooltip is editing.
+ */
+ isEditing() {
+ return this.isVisible();
+ }
+
+ /**
+ * Show the editor tooltip for the currently active swatch.
+ *
+ * @return {Promise} a promise that resolves once the editor tooltip is displayed, or
+ * immediately if there is no currently active swatch.
+ */
+ show() {
+ if (this.tooltipAnchor) {
+ const onShown = this.tooltip.once("shown");
+
+ this.tooltip.show(this.tooltipAnchor);
+ this.tooltip.once("hidden", () => this.onTooltipHidden());
+
+ return onShown;
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Can be overridden by subclasses if implementation specific behavior is needed on
+ * tooltip hidden.
+ */
+ onTooltipHidden() {
+ // When the tooltip is closed by clicking outside the panel we want to commit any
+ // changes.
+ if (!this._reverted) {
+ this.commit();
+ }
+ this._reverted = false;
+
+ // Once the tooltip is hidden we need to clean up any remaining objects.
+ this.activeSwatch = null;
+ }
+
+ hide() {
+ if (this.swatchActivatedWithKeyboard) {
+ this.activeSwatch.focus();
+ this.swatchActivatedWithKeyboard = null;
+ }
+
+ this.tooltip.hide();
+ }
+
+ /**
+ * Add a new swatch DOM element to the list of swatch elements this editor
+ * tooltip knows about. That means from now on, clicking on that swatch will
+ * toggle the editor.
+ *
+ * @param {node} swatchEl
+ * The element to add
+ * @param {object} callbacks
+ * Callbacks that will be executed when the editor wants to preview a
+ * value change, or revert a change, or commit a change.
+ * - onShow: will be called when one of the swatch tooltip is shown
+ * - onPreview: will be called when one of the sub-classes calls
+ * preview
+ * - onRevert: will be called when the user ESCapes out of the tooltip
+ * - onCommit: will be called when the user presses ENTER or clicks
+ * outside the tooltip.
+ */
+ addSwatch(swatchEl, callbacks = {}) {
+ if (!callbacks.onShow) {
+ callbacks.onShow = function () {};
+ }
+ if (!callbacks.onPreview) {
+ callbacks.onPreview = function () {};
+ }
+ if (!callbacks.onRevert) {
+ callbacks.onRevert = function () {};
+ }
+ if (!callbacks.onCommit) {
+ callbacks.onCommit = function () {};
+ }
+
+ this.swatches.set(swatchEl, {
+ callbacks,
+ });
+ swatchEl.addEventListener("click", this._onSwatchClick);
+ swatchEl.addEventListener("keydown", this._onSwatchKeyDown);
+ }
+
+ removeSwatch(swatchEl) {
+ if (this.swatches.has(swatchEl)) {
+ if (this.activeSwatch === swatchEl) {
+ this.hide();
+ this.activeSwatch = null;
+ }
+ swatchEl.removeEventListener("click", this._onSwatchClick);
+ swatchEl.removeEventListener("keydown", this._onSwatchKeyDown);
+ this.swatches.delete(swatchEl);
+ }
+ }
+
+ _onSwatchKeyDown(event) {
+ if (
+ event.keyCode === KeyCodes.DOM_VK_RETURN ||
+ event.keyCode === KeyCodes.DOM_VK_SPACE
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._onSwatchClick(event);
+ }
+ }
+
+ _onSwatchClick(event) {
+ const { shiftKey, clientX, clientY, target } = event;
+
+ // If mouse coordinates are 0, the event listener could have been triggered
+ // by a keybaord
+ this.swatchActivatedWithKeyboard =
+ event.key && clientX === 0 && clientY === 0;
+
+ if (shiftKey) {
+ event.stopPropagation();
+ return;
+ }
+
+ const swatch = this.swatches.get(target);
+
+ if (swatch) {
+ this.activeSwatch = target;
+ this.show();
+ swatch.callbacks.onShow();
+ event.stopPropagation();
+ }
+ }
+
+ /**
+ * Not called by this parent class, needs to be taken care of by sub-classes
+ */
+ preview(value) {
+ if (this.activeSwatch) {
+ const swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onPreview(value);
+ }
+ }
+
+ /**
+ * This parent class only calls this on <esc> keydown
+ */
+ revert() {
+ if (this.activeSwatch) {
+ this._reverted = true;
+ const swatch = this.swatches.get(this.activeSwatch);
+ this.tooltip.once("hidden", () => {
+ swatch.callbacks.onRevert();
+ });
+ }
+ }
+
+ /**
+ * This parent class only calls this on <enter> keydown
+ */
+ commit() {
+ if (this.activeSwatch) {
+ const swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onCommit();
+ }
+ }
+
+ get tooltipAnchor() {
+ return this.activeSwatch;
+ }
+
+ destroy() {
+ this.swatches.clear();
+ this.activeSwatch = null;
+ this.tooltip.off("keydown", this._onTooltipKeydown);
+ this.tooltip.destroy();
+ this.shortcuts.destroy();
+ }
+}
+
+module.exports = SwatchBasedEditorTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
new file mode 100644
index 0000000000..abcb63333b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
@@ -0,0 +1,357 @@
+/* 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 { colorUtils } = require("resource://devtools/shared/css/color.js");
+const Spectrum = require("resource://devtools/client/shared/widgets/Spectrum.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+const { openDocLink } = require("resource://devtools/client/shared/link.js");
+const {
+ A11Y_CONTRAST_LEARN_MORE_LINK,
+} = require("resource://devtools/client/accessibility/constants.js");
+loader.lazyRequireGetter(
+ this,
+ "throttle",
+ "resource://devtools/shared/throttle.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ ["getFocusableElements", "wrapMoveFocus"],
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PICKER_TYPES",
+ "resource://devtools/shared/picker-constants.js"
+);
+
+const TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT =
+ "DEVTOOLS_PICKER_EYEDROPPER_OPENED_COUNT";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch color picker tooltip class is a specific class meant to be used
+ * along with output-parser's generated color swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * color picker.
+ *
+ * The activeSwatch element expected by the tooltip must follow some guidelines
+ * to be compatible with this feature:
+ * - the background-color of the activeSwatch should be set to the current
+ * color, it will be updated when the color is changed via the color-picker.
+ * - the `data-color` attribute should be set either on the activeSwatch or on
+ * a parent node, and should also contain the current color.
+ * - finally if the color value should be displayed next to the swatch as text,
+ * the activeSwatch should have a nextSibling. Note that this sibling may
+ * contain more than just text initially, but it will be updated after a color
+ * change and will only contain the text.
+ *
+ * An example of valid markup (with data-color on a parent and a nextSibling):
+ *
+ * <span data-color="#FFF"> <!-- activeSwatch.closest("[data-color]") -->
+ * <span
+ * style="background-color: rgb(255, 255, 255);"
+ * ></span> <!-- activeSwatch -->
+ * <span>#FFF</span> <!-- activeSwatch.nextSibling -->
+ * </span>
+ *
+ * Another example with everything on the activeSwatch itself:
+ *
+ * <span> <!-- container, to illustrate that the swatch has no sibling here. -->
+ * <span
+ * data-color="#FFF"
+ * style="background-color: rgb(255, 255, 255);"
+ * ></span> <!-- activeSwatch & activeSwatch.closest("[data-color]") -->
+ * </span>
+ *
+ * @param {Document} document
+ * The document to attach the SwatchColorPickerTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ * @param {InspectorPanel} inspector
+ * The inspector panel, needed for the eyedropper.
+ */
+
+class SwatchColorPickerTooltip extends SwatchBasedEditorTooltip {
+ constructor(document, inspector) {
+ super(document);
+ this.inspector = inspector;
+
+ // Creating a spectrum instance. this.spectrum will always be a promise that
+ // resolves to the spectrum instance
+ this.spectrum = this.setColorPickerContent([0, 0, 0, 1]);
+ this._onSpectrumColorChange = this._onSpectrumColorChange.bind(this);
+ this._openEyeDropper = this._openEyeDropper.bind(this);
+ this._openDocLink = this._openDocLink.bind(this);
+ this._onTooltipKeydown = this._onTooltipKeydown.bind(this);
+
+ // Selecting color by hovering on the spectrum widget could create a lot
+ // of requests. Throttle by 50ms to avoid this. See Bug 1665547.
+ this._selectColor = throttle(this._selectColor.bind(this), 50);
+
+ this.tooltip.container.addEventListener("keydown", this._onTooltipKeydown);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the spectrum color picker widget
+ * initialized with the given color, and return the instance of spectrum
+ */
+
+ setColorPickerContent(color) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "spectrum-tooltip";
+
+ const node = doc.createElementNS(XHTML_NS, "div");
+ node.id = "spectrum";
+ container.appendChild(node);
+
+ const widget = new Spectrum(node, color);
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 215 });
+
+ widget.inspector = this.inspector;
+
+ // Wait for the tooltip to be shown before calling widget.show
+ // as it expect to be visible in order to compute DOM element sizes.
+ this.tooltip.once("shown", () => {
+ widget.show();
+ });
+
+ return widget;
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's
+ * color.
+ */
+ async show() {
+ // set contrast enabled for the spectrum
+ const name = this.activeSwatch.dataset.propertyName;
+ const colorFunction = this.activeSwatch.dataset.colorFunction;
+
+ // Only enable contrast if the type of property is color
+ // and its value isn't inside a color-modifying function (e.g. color-mix()).
+ this.spectrum.contrastEnabled =
+ name === "color" && colorFunction !== "color-mix";
+ if (this.spectrum.contrastEnabled) {
+ const { nodeFront } = this.inspector.selection;
+ const { pageStyle } = nodeFront.inspectorFront;
+ this.spectrum.textProps = await pageStyle.getComputed(nodeFront, {
+ filterProperties: ["font-size", "font-weight", "opacity"],
+ });
+ this.spectrum.backgroundColorData = await nodeFront.getBackgroundColor();
+ }
+
+ // Then set spectrum's color and listen to color changes to preview them
+ if (this.activeSwatch) {
+ this._originalColor = this._getSwatchColorContainer().dataset.color;
+ const color = this.activeSwatch.style.backgroundColor;
+
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.rgb = this._colorToRgba(color);
+ this.spectrum.on("changed", this._onSpectrumColorChange);
+ this.spectrum.updateUI();
+ }
+
+ // Call then parent class' show function
+ await super.show();
+
+ const eyeButton =
+ this.tooltip.container.querySelector("#eyedropper-button");
+ const canShowEyeDropper = await this.inspector.supportsEyeDropper();
+ if (canShowEyeDropper) {
+ eyeButton.disabled = false;
+ eyeButton.removeAttribute("title");
+ eyeButton.addEventListener("click", this._openEyeDropper);
+ } else {
+ eyeButton.disabled = true;
+ eyeButton.title = L10N.getStr("eyedropper.disabled.title");
+ }
+
+ const learnMoreButton =
+ this.tooltip.container.querySelector("#learn-more-button");
+ if (learnMoreButton) {
+ learnMoreButton.addEventListener("click", this._openDocLink);
+ learnMoreButton.addEventListener("keydown", e => e.stopPropagation());
+ }
+
+ // Add focus to the first focusable element in the tooltip and attach keydown
+ // event listener to tooltip
+ this.focusableElements[0].focus();
+ this.tooltip.container.addEventListener(
+ "keydown",
+ this._onTooltipKeydown,
+ true
+ );
+
+ this.emit("ready");
+ }
+
+ _onTooltipKeydown(event) {
+ const { target, key, shiftKey } = event;
+
+ if (key !== "Tab") {
+ return;
+ }
+
+ const focusMoved = !!wrapMoveFocus(
+ this.focusableElements,
+ target,
+ shiftKey
+ );
+ if (focusMoved) {
+ // Focus was moved to the begining/end of the tooltip, so we need to prevent the
+ // default focus change that would happen here.
+ event.preventDefault();
+ }
+
+ event.stopPropagation();
+ }
+
+ _getSwatchColorContainer() {
+ // Depending on the UI, the data-color attribute might be set on the
+ // swatch itself, or a parent node.
+ // This data attribute is also used for the "Copy color" feature, so it
+ // can be useful to set it on a container rather than on the swatch.
+ return this.activeSwatch.closest("[data-color]");
+ }
+
+ _onSpectrumColorChange(rgba, cssColor) {
+ this._selectColor(cssColor);
+ }
+
+ _selectColor(color) {
+ if (this.activeSwatch) {
+ this.activeSwatch.style.backgroundColor = color;
+
+ color = this._toDefaultType(color);
+
+ this._getSwatchColorContainer().dataset.color = color;
+ if (this.activeSwatch.nextSibling) {
+ this.activeSwatch.nextSibling.textContent = color;
+ }
+ this.preview(color);
+
+ if (this.eyedropperOpen) {
+ this.commit();
+ }
+ }
+ }
+
+ /**
+ * Override the implementation from SwatchBasedEditorTooltip.
+ */
+ onTooltipHidden() {
+ // If the tooltip is hidden while the eyedropper is being used, we should not commit
+ // the changes.
+ if (this.eyedropperOpen) {
+ return;
+ }
+
+ super.onTooltipHidden();
+ this.tooltip.container.removeEventListener(
+ "keydown",
+ this._onTooltipKeydown
+ );
+ }
+
+ _openEyeDropper() {
+ const { inspectorFront, toolbox, telemetry } = this.inspector;
+
+ telemetry
+ .getHistogramById(TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT)
+ .add(true);
+
+ // cancelling picker(if it is already selected) on opening eye-dropper
+ toolbox.nodePicker.stop({ canceled: true });
+
+ // disable simulating touch events if RDM is active
+ toolbox.tellRDMAboutPickerState(true, PICKER_TYPES.EYEDROPPER);
+
+ // pickColorFromPage will focus the content document. If the devtools are in a
+ // separate window, the colorpicker tooltip will be closed before pickColorFromPage
+ // resolves. Flip the flag early to avoid issues with onTooltipHidden().
+ this.eyedropperOpen = true;
+
+ inspectorFront.pickColorFromPage({ copyOnSelect: false }).then(() => {
+ // close the colorpicker tooltip so that only the eyedropper is open.
+ this.hide();
+
+ this.tooltip.emit("eyedropper-opened");
+ }, console.error);
+
+ inspectorFront.once("color-picked", color => {
+ toolbox.win.focus();
+ this._selectColor(color);
+ this._onEyeDropperDone();
+ });
+
+ inspectorFront.once("color-pick-canceled", () => {
+ this._onEyeDropperDone();
+ });
+ }
+
+ _openDocLink() {
+ openDocLink(A11Y_CONTRAST_LEARN_MORE_LINK);
+ this.hide();
+ }
+
+ _onEyeDropperDone() {
+ // enable simulating touch events if RDM is active
+ this.inspector.toolbox.tellRDMAboutPickerState(
+ false,
+ PICKER_TYPES.EYEDROPPER
+ );
+
+ this.eyedropperOpen = false;
+ this.activeSwatch = null;
+ }
+
+ _colorToRgba(color) {
+ color = new colorUtils.CssColor(color);
+ const rgba = color.getRGBATuple();
+ return [rgba.r, rgba.g, rgba.b, rgba.a];
+ }
+
+ _toDefaultType(color) {
+ const colorObj = new colorUtils.CssColor(color);
+ colorObj.setAuthoredUnitFromColor(this._originalColor);
+ return colorObj.toString();
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.isEditing function to consider the
+ * eyedropper.
+ */
+ isEditing() {
+ return this.tooltip.isVisible() || this.eyedropperOpen;
+ }
+
+ get focusableElements() {
+ return getFocusableElements(this.tooltip.container).filter(
+ el => !!el.offsetParent
+ );
+ }
+
+ destroy() {
+ super.destroy();
+ this.inspector = null;
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.destroy();
+ }
+}
+
+module.exports = SwatchColorPickerTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
new file mode 100644
index 0000000000..bfec4bccea
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
@@ -0,0 +1,95 @@
+/* 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 {
+ CubicBezierWidget,
+} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch cubic-bezier tooltip class is a specific class meant to be used
+ * along with rule-view's generated cubic-bezier swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CubicBezierWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchCubicBezierTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchCubicBezierTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ // Creating a cubic-bezier instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.setCubicBezierContent([0, 0, 1, 1]);
+ this._onUpdate = this._onUpdate.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the cubic-bezier widget
+ * initialized with the given value, and return a promise that resolves to
+ * the instance of the widget
+ */
+
+ async setCubicBezierContent(bezier) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "cubic-bezier-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 510, height: 370 });
+
+ await this.tooltip.once("shown");
+ return new CubicBezierWidget(container, bezier);
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the cubic
+ * bezier curve in the widget
+ */
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the curve and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentBezierValue = this.activeSwatch.nextSibling;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.cssCubicBezierValue = this.currentBezierValue.textContent;
+ widget.on("updated", this._onUpdate);
+ this.emit("ready");
+ });
+ }
+ }
+
+ _onUpdate(bezier) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.currentBezierValue.textContent = bezier + "";
+ this.preview(bezier + "");
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentBezierValue = null;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.destroy();
+ });
+ }
+}
+
+module.exports = SwatchCubicBezierTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
new file mode 100644
index 0000000000..cc28176a13
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
@@ -0,0 +1,117 @@
+/* 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 {
+ CSSFilterEditorWidget,
+} = require("resource://devtools/client/shared/widgets/FilterWidget.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch-based css filter tooltip class is a specific class meant to be
+ * used along with rule-view's generated css filter swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CSSFilterEditorWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchFilterTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchFilterTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ // Creating a filter editor instance.
+ this.widget = this.setFilterContent("none");
+ this._onUpdate = this._onUpdate.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the CSSFilterEditorWidget
+ * widget initialized with the given filter value, and return a promise
+ * that resolves to the instance of the widget when ready.
+ */
+
+ setFilterContent(filter) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "filter-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 510, height: 200 });
+
+ return new CSSFilterEditorWidget(container, filter);
+ }
+
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the filter value and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentFilterValue = this.activeSwatch.nextSibling;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.on("updated", this._onUpdate);
+ this.widget.setCssValue(this.currentFilterValue.textContent);
+ this.widget.render();
+ this.emit("ready");
+ }
+ }
+
+ _onUpdate(filters) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ // Remove the old children and reparse the property value to
+ // recompute them.
+ while (this.currentFilterValue.firstChild) {
+ this.currentFilterValue.firstChild.remove();
+ }
+ const node = this._parser.parseCssProperty(
+ "filter",
+ filters,
+ this._options
+ );
+ this.currentFilterValue.appendChild(node);
+
+ this.preview();
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentFilterValue = null;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.destroy();
+ }
+
+ /**
+ * Like SwatchBasedEditorTooltip.addSwatch, but accepts a parser object
+ * to use when previewing the updated property value.
+ *
+ * @param {node} swatchEl
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} callbacks
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} parser
+ * A parser object; @see OutputParser object
+ * @param {object} options
+ * options to pass to the output parser, with
+ * the option |filterSwatch| set.
+ */
+ addSwatch(swatchEl, callbacks, parser, options) {
+ super.addSwatch(swatchEl, callbacks);
+ this._parser = parser;
+ this._options = options;
+ }
+}
+
+module.exports = SwatchFilterTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js
new file mode 100644
index 0000000000..371bbd79fc
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.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 {
+ LinearEasingFunctionWidget,
+} = require("devtools/client/shared/widgets/LinearEasingFunctionWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch linear-easing-function tooltip class is a specific class meant to be used
+ * along with rule-view's generated linear-easing-function swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * LinearEasingFunctionWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchLinearEasingFunctionTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchLinearEasingFunctionTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ this.onWidgetUpdated = this.onWidgetUpdated.bind(this);
+
+ // Creating a linear-easing-function instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.createWidget();
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the linear-easing-function widget
+ * initialized with the given value, and return a promise that resolves to
+ * the instance of the widget
+ */
+
+ async createWidget() {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "linear-easing-function-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 400, height: 400 });
+
+ await this.tooltip.once("shown");
+ return new LinearEasingFunctionWidget(container);
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the linear function line
+ * in the widget
+ */
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the line and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.ruleViewCurrentLinearValueElement = this.activeSwatch.nextSibling;
+ this.widget.then(widget => {
+ widget.off("updated", this.onWidgetUpdated);
+ widget.setCssLinearValue(this.activeSwatch.getAttribute("data-linear"));
+ widget.on("updated", this.onWidgetUpdated);
+ this.emit("ready");
+ });
+ }
+ }
+
+ onWidgetUpdated(newValue) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.ruleViewCurrentLinearValueElement.textContent = newValue;
+ this.activeSwatch.setAttribute("data-linear", newValue);
+ this.preview(newValue);
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentFunctionText = null;
+ this.widget.then(widget => {
+ widget.off("updated", this.onWidgetUpdated);
+ widget.destroy();
+ });
+ }
+}
+
+module.exports = SwatchLinearEasingFunctionTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
new file mode 100644
index 0000000000..36280b33ab
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
@@ -0,0 +1,203 @@
+/* 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 DEFAULT_TOGGLE_DELAY = 50;
+
+/**
+ * Tooltip helper designed to show/hide the tooltip when the mouse hovers over
+ * particular nodes.
+ *
+ * This works by tracking mouse movements on a base container node (baseNode)
+ * and showing the tooltip when the mouse stops moving. A callback can be
+ * provided to the start() method to know whether or not the node being
+ * hovered over should indeed receive the tooltip.
+ */
+function TooltipToggle(tooltip) {
+ this.tooltip = tooltip;
+ this.win = tooltip.doc.defaultView;
+
+ this._onMouseMove = this._onMouseMove.bind(this);
+ this._onMouseOut = this._onMouseOut.bind(this);
+
+ this._onTooltipMouseOver = this._onTooltipMouseOver.bind(this);
+ this._onTooltipMouseOut = this._onTooltipMouseOut.bind(this);
+}
+
+module.exports.TooltipToggle = TooltipToggle;
+
+TooltipToggle.prototype = {
+ /**
+ * Start tracking mouse movements on the provided baseNode to show the
+ * tooltip.
+ *
+ * 2 Ways to make this work:
+ * - Provide a single node to attach the tooltip to, as the baseNode, and
+ * omit the second targetNodeCb argument
+ * - Provide a baseNode that is the container of possibly numerous children
+ * elements that may receive a tooltip. In this case, provide the second
+ * targetNodeCb argument to decide wether or not a child should receive
+ * a tooltip.
+ *
+ * Note that if you call this function a second time, it will itself call
+ * stop() before adding mouse tracking listeners again.
+ *
+ * @param {node} baseNode
+ * The container for all target nodes
+ * @param {Function} targetNodeCb
+ * A function that accepts a node argument and that checks if a tooltip
+ * should be displayed. Possible return values are:
+ * - false (or a falsy value) if the tooltip should not be displayed
+ * - true if the tooltip should be displayed
+ * - a DOM node to display the tooltip on the returned anchor
+ * The function can also return a promise that will resolve to one of
+ * the values listed above.
+ * If omitted, the tooltip will be shown everytime.
+ * @param {Object} options
+ Set of optional arguments:
+ * - {Number} toggleDelay
+ * An optional delay (in ms) that will be observed before showing
+ * and before hiding the tooltip. Defaults to DEFAULT_TOGGLE_DELAY.
+ * - {Boolean} interactive
+ * If enabled, the tooltip is not hidden when mouse leaves the
+ * target element and enters the tooltip. Allows the tooltip
+ * content to be interactive.
+ */
+ start(
+ baseNode,
+ targetNodeCb,
+ { toggleDelay = DEFAULT_TOGGLE_DELAY, interactive = false } = {}
+ ) {
+ this.stop();
+
+ if (!baseNode) {
+ // Calling tool is in the process of being destroyed.
+ return;
+ }
+
+ this._baseNode = baseNode;
+ this._targetNodeCb = targetNodeCb || (() => true);
+ this._toggleDelay = toggleDelay;
+ this._interactive = interactive;
+
+ baseNode.addEventListener("mousemove", this._onMouseMove);
+ baseNode.addEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.addEventListener(
+ "mouseover",
+ this._onTooltipMouseOver
+ );
+ this.tooltip.container.addEventListener(
+ "mouseout",
+ this._onTooltipMouseOut
+ );
+ }
+ },
+
+ /**
+ * If the start() function has been used previously, and you want to get rid
+ * of this behavior, then call this function to remove the mouse movement
+ * tracking
+ */
+ stop() {
+ this.win.clearTimeout(this.toggleTimer);
+
+ if (!this._baseNode) {
+ return;
+ }
+
+ this._baseNode.removeEventListener("mousemove", this._onMouseMove);
+ this._baseNode.removeEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.removeEventListener(
+ "mouseover",
+ this._onTooltipMouseOver
+ );
+ this.tooltip.container.removeEventListener(
+ "mouseout",
+ this._onTooltipMouseOut
+ );
+ }
+
+ this._baseNode = null;
+ this._targetNodeCb = null;
+ this._lastHovered = null;
+ },
+
+ _onMouseMove(event) {
+ if (event.target !== this._lastHovered) {
+ this._lastHovered = event.target;
+
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ this.isValidHoverTarget(event.target).then(
+ target => {
+ if (target === null || !this._baseNode) {
+ // bail out if no target or if the toggle has been destroyed.
+ return;
+ }
+ this.tooltip.show(target);
+ },
+ reason => {
+ console.error(
+ "isValidHoverTarget rejected with unexpected reason:"
+ );
+ console.error(reason);
+ }
+ );
+ }, this._toggleDelay);
+ }
+ },
+
+ /**
+ * Is the given target DOMNode a valid node for toggling the tooltip on hover.
+ * This delegates to the user-defined _targetNodeCb callback.
+ * @return {Promise} a promise that will resolve the anchor to use for the
+ * tooltip or null if no valid target was found.
+ */
+ async isValidHoverTarget(target) {
+ const res = await this._targetNodeCb(target, this.tooltip);
+ if (res) {
+ return res.nodeName ? res : target;
+ }
+
+ return null;
+ },
+
+ _onMouseOut(event) {
+ // Only hide the tooltip if the mouse leaves baseNode.
+ if (
+ event &&
+ this._baseNode &&
+ this._baseNode.contains(event.relatedTarget)
+ ) {
+ return;
+ }
+
+ this._lastHovered = null;
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ _onTooltipMouseOver() {
+ this.win.clearTimeout(this.toggleTimer);
+ },
+
+ _onTooltipMouseOut() {
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ destroy() {
+ this.stop();
+ },
+};
diff --git a/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js
new file mode 100644
index 0000000000..bd458fbbf1
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js
@@ -0,0 +1,31 @@
+/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Set the tooltip content of a provided HTMLTooltip instance to display a
+ * variable preview matching the provided text.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the text preview content should be set.
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip.
+ * @param {String} text
+ * Text to display in tooltip.
+ */
+function setVariableTooltip(tooltip, doc, text) {
+ // Create tooltip content
+ const div = doc.createElementNS(XHTML_NS, "div");
+ div.classList.add("devtools-monospace", "devtools-tooltip-css-variable");
+ div.textContent = text;
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(div);
+ tooltip.setContentSize({ width: "auto", height: "auto" });
+}
+
+module.exports.setVariableTooltip = setVariableTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js
new file mode 100644
index 0000000000..40755a212b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js
@@ -0,0 +1,292 @@
+/* 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 { BrowserLoader } = ChromeUtils.import(
+ "resource://devtools/shared/loader/browser-loader.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ "openDocLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+
+class CssCompatibilityTooltipHelper {
+ constructor() {
+ this.addTab = this.addTab.bind(this);
+ }
+
+ #currentTooltip = null;
+ #currentUrl = null;
+
+ #createElement(doc, tag, classList = [], attributeList = {}) {
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
+ const newElement = doc.createElementNS(XHTML_NS, tag);
+ for (const elementClass of classList) {
+ newElement.classList.add(elementClass);
+ }
+
+ for (const key in attributeList) {
+ newElement.setAttribute(key, attributeList[key]);
+ }
+
+ return newElement;
+ }
+
+ /*
+ * Attach the UnsupportedBrowserList component to the
+ * ".compatibility-browser-list-wrapper" div to render the
+ * unsupported browser list
+ */
+ #renderUnsupportedBrowserList(container, unsupportedBrowsers) {
+ // Mount the ReactDOM only if the unsupported browser
+ // list is not empty. Else "compatibility-browser-list-wrapper"
+ // is not defined. For example, for property clip,
+ // unsupportedBrowsers is an empty array
+ if (!unsupportedBrowsers.length) {
+ return;
+ }
+
+ const { require } = BrowserLoader({
+ baseURI: "resource://devtools/client/shared/widgets/tooltip/",
+ window: this.#currentTooltip.doc.defaultView,
+ });
+ const {
+ createFactory,
+ createElement,
+ } = require("resource://devtools/client/shared/vendor/react.js");
+ const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
+ const UnsupportedBrowserList = createFactory(
+ require("resource://devtools/client/inspector/compatibility/components/UnsupportedBrowserList.js")
+ );
+
+ const unsupportedBrowserList = createElement(UnsupportedBrowserList, {
+ browsers: unsupportedBrowsers,
+ });
+ ReactDOM.render(
+ unsupportedBrowserList,
+ container.querySelector(".compatibility-browser-list-wrapper")
+ );
+ }
+
+ /*
+ * Get the first paragraph for the compatibility tooltip
+ * Return a subtree similar to:
+ * <p data-l10n-id="css-compatibility-default-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * </p>
+ */
+ #getCompatibilityMessage(doc, data) {
+ const { msgId, property } = data;
+ return this.#createElement(doc, "p", [], {
+ "data-l10n-id": msgId,
+ "data-l10n-args": JSON.stringify({ property }),
+ });
+ }
+
+ /**
+ * Gets the paragraph elements related to the browserList.
+ * This returns an array with following subtree:
+ * [
+ * <p data-l10n-id="css-compatibility-browser-list-message"></p>,
+ * <p>
+ * <ul class="compatibility-unsupported-browser-list">
+ * <list-element />
+ * </ul>
+ * </p>
+ * ]
+ * The first element is the message and the second element is the
+ * unsupported browserList itself.
+ * If the unsupportedBrowser is an empty array, we return an empty
+ * array back.
+ */
+ #getBrowserListContainer(doc, unsupportedBrowsers) {
+ if (!unsupportedBrowsers.length) {
+ return null;
+ }
+
+ const browserList = this.#createElement(doc, "p");
+ const browserListWrapper = this.#createElement(doc, "div", [
+ "compatibility-browser-list-wrapper",
+ ]);
+ browserList.appendChild(browserListWrapper);
+
+ return browserList;
+ }
+
+ /*
+ * This is the learn more message element linking to the MDN documentation
+ * for the particular incompatible CSS declaration.
+ * The element returned is:
+ * <p data-l10n-id="css-compatibility-learn-more-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <span data-l10n-name="link" class="link"></span>
+ * </p>
+ */
+ #getLearnMoreMessage(doc, { rootProperty }) {
+ const learnMoreMessage = this.#createElement(doc, "p", [], {
+ "data-l10n-id": "css-compatibility-learn-more-message",
+ "data-l10n-args": JSON.stringify({ rootProperty }),
+ });
+ learnMoreMessage.appendChild(
+ this.#createElement(doc, "span", ["link"], {
+ "data-l10n-name": "link",
+ })
+ );
+
+ return learnMoreMessage;
+ }
+
+ /**
+ * Fill the tooltip with inactive CSS information.
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * // The raw CSS declaration name that has compatibility issues
+ * declaration: <string>,
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ async setContent(data, tooltip) {
+ const fragment = this.getTemplate(data, tooltip);
+ const { doc } = tooltip;
+
+ tooltip.panel.innerHTML = "";
+
+ tooltip.panel.addEventListener("click", this.addTab);
+ tooltip.once("hidden", () => {
+ tooltip.panel.removeEventListener("click", this.addTab);
+ });
+
+ // Because Fluent is async we need to manually translate the fragment and
+ // then insert it into the tooltip. This is needed in order for the tooltip
+ // to size to the contents properly and for tests.
+ await doc.l10n.translateFragment(fragment);
+ doc.l10n.pauseObserving();
+ tooltip.panel.appendChild(fragment);
+ doc.l10n.resumeObserving();
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template that the Fluent string will be merged with. This template
+ * looks like this:
+ *
+ * <div class="devtools-tooltip-css-compatibility">
+ * <p data-l10n-id="css-compatibility-default-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <strong></strong>
+ * </p>
+ * <browser-list />
+ * <p data-l10n-id="css-compatibility-learn-more-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <span data-l10n-name="link" class="link"></span>
+ * <strong></strong>
+ * </p>
+ * </div>
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * // The raw CSS declaration name that has compatibility issues
+ * declaration: <string>,
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * // Link to the spec for the particular CSS rule
+ * specUrl: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ getTemplate(data, tooltip) {
+ const { doc } = tooltip;
+ const { specUrl, url, unsupportedBrowsers } = data;
+
+ this.#currentTooltip = tooltip;
+ this.#currentUrl = url
+ ? `${url}?utm_source=devtools&utm_medium=inspector-css-compatibility&utm_campaign=default`
+ : specUrl;
+ const templateNode = this.#createElement(doc, "template");
+
+ const tooltipContainer = this.#createElement(doc, "div", [
+ "devtools-tooltip-css-compatibility",
+ ]);
+
+ tooltipContainer.appendChild(this.#getCompatibilityMessage(doc, data));
+ const browserListContainer = this.#getBrowserListContainer(
+ doc,
+ unsupportedBrowsers
+ );
+ if (browserListContainer) {
+ tooltipContainer.appendChild(browserListContainer);
+ this.#renderUnsupportedBrowserList(tooltipContainer, unsupportedBrowsers);
+ }
+
+ if (this.#currentUrl) {
+ tooltipContainer.appendChild(this.#getLearnMoreMessage(doc, data));
+ }
+
+ templateNode.content.appendChild(tooltipContainer);
+ return doc.importNode(templateNode.content, true);
+ }
+
+ /**
+ * Hide the tooltip, open `this.#currentUrl` in a new tab and focus it.
+ *
+ * @param {DOMEvent} event
+ * The click event originating from the tooltip.
+ *
+ */
+ addTab(event) {
+ // The XUL panel swallows click events so handlers can't be added directly
+ // to the link span. As a workaround we listen to all click events in the
+ // panel and if a link span is clicked we proceed.
+ if (event.target.className !== "link") {
+ return;
+ }
+
+ const tooltip = this.#currentTooltip;
+ tooltip.hide();
+
+ const isMacOS = Services.appinfo.OS === "Darwin";
+ openDocLink(this.#currentUrl, {
+ relatedToCurrent: true,
+ inBackground: isMacOS ? event.metaKey : event.ctrlKey,
+ });
+ }
+
+ destroy() {
+ this.#currentTooltip = null;
+ this.#currentUrl = null;
+ }
+}
+
+module.exports = CssCompatibilityTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js
new file mode 100644
index 0000000000..633e57b9cf
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js
@@ -0,0 +1,145 @@
+/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+class CssQueryContainerTooltipHelper {
+ /**
+ * Fill the tooltip with container information.
+ */
+ async setContent(data, tooltip) {
+ const res = await data.rule.domRule.getQueryContainerForNode(
+ data.ancestorIndex,
+ data.rule.inherited ||
+ data.rule.elementStyle.ruleView.inspector.selection.nodeFront
+ );
+
+ const fragment = this.#getTemplate(res, tooltip);
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(fragment);
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template of the tooltip.
+ *
+ * @param {Object} data
+ * @param {NodeFront} data.node
+ * @param {string} data.containerType
+ * @param {string} data.inlineSize
+ * @param {string} data.blockSize
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ #getTemplate(data, tooltip) {
+ const { doc } = tooltip;
+
+ const templateNode = doc.createElementNS(XHTML_NS, "template");
+
+ const tooltipContainer = doc.createElementNS(XHTML_NS, "div");
+ tooltipContainer.classList.add("devtools-tooltip-query-container");
+ templateNode.content.appendChild(tooltipContainer);
+
+ const nodeContainer = doc.createElementNS(XHTML_NS, "header");
+ tooltipContainer.append(nodeContainer);
+
+ const containerQueryLabel = doc.createElementNS(XHTML_NS, "span");
+ containerQueryLabel.classList.add("property-name");
+ containerQueryLabel.appendChild(doc.createTextNode(`query container`));
+
+ const nodeEl = doc.createElementNS(XHTML_NS, "span");
+ nodeEl.classList.add("objectBox-node");
+ nodeContainer.append(doc.createTextNode("<"), nodeEl);
+
+ const nodeNameEl = doc.createElementNS(XHTML_NS, "span");
+ nodeNameEl.classList.add("tag-name");
+ nodeNameEl.appendChild(
+ doc.createTextNode(data.node.nodeName.toLowerCase())
+ );
+
+ nodeEl.appendChild(nodeNameEl);
+
+ if (data.node.id) {
+ const idEl = doc.createElementNS(XHTML_NS, "span");
+ idEl.classList.add("attribute-name");
+ idEl.appendChild(doc.createTextNode(`#${data.node.id}`));
+ nodeEl.appendChild(idEl);
+ }
+
+ for (const attr of data.node.attributes) {
+ if (attr.name !== "class") {
+ continue;
+ }
+ for (const cls of attr.value.split(/\s/)) {
+ const el = doc.createElementNS(XHTML_NS, "span");
+ el.classList.add("attribute-name");
+ el.appendChild(doc.createTextNode(`.${cls}`));
+ nodeEl.appendChild(el);
+ }
+ }
+ nodeContainer.append(doc.createTextNode(">"));
+
+ const ul = doc.createElementNS(XHTML_NS, "ul");
+ tooltipContainer.appendChild(ul);
+
+ const containerTypeEl = doc.createElementNS(XHTML_NS, "li");
+ const containerTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ containerTypeLabel.classList.add("property-name");
+ containerTypeLabel.appendChild(doc.createTextNode(`container-type`));
+
+ const containerTypeValue = doc.createElementNS(XHTML_NS, "span");
+ containerTypeValue.classList.add("property-value");
+ containerTypeValue.appendChild(doc.createTextNode(data.containerType));
+
+ containerTypeEl.append(
+ containerTypeLabel,
+ doc.createTextNode(": "),
+ containerTypeValue
+ );
+ ul.appendChild(containerTypeEl);
+
+ const inlineSizeEl = doc.createElementNS(XHTML_NS, "li");
+
+ const inlineSizeLabel = doc.createElementNS(XHTML_NS, "span");
+ inlineSizeLabel.classList.add("property-name");
+ inlineSizeLabel.appendChild(doc.createTextNode(`inline-size`));
+
+ const inlineSizeValue = doc.createElementNS(XHTML_NS, "span");
+ inlineSizeValue.classList.add("property-value");
+ inlineSizeValue.appendChild(doc.createTextNode(data.inlineSize));
+
+ inlineSizeEl.append(
+ inlineSizeLabel,
+ doc.createTextNode(": "),
+ inlineSizeValue
+ );
+ ul.appendChild(inlineSizeEl);
+
+ if (data.containerType != "inline-size") {
+ const blockSizeEl = doc.createElementNS(XHTML_NS, "li");
+ const blockSizeLabel = doc.createElementNS(XHTML_NS, "span");
+ blockSizeLabel.classList.add("property-name");
+ blockSizeLabel.appendChild(doc.createTextNode(`block-size`));
+
+ const blockSizeValue = doc.createElementNS(XHTML_NS, "span");
+ blockSizeValue.classList.add("property-value");
+ blockSizeValue.appendChild(doc.createTextNode(data.blockSize));
+
+ blockSizeEl.append(
+ blockSizeLabel,
+ doc.createTextNode(": "),
+ blockSizeValue
+ );
+ ul.appendChild(blockSizeEl);
+ }
+
+ return doc.importNode(templateNode.content, true);
+ }
+}
+
+module.exports = CssQueryContainerTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js
new file mode 100644
index 0000000000..38ecd282f4
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js
@@ -0,0 +1,127 @@
+/* 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,
+ "openDocLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+
+class InactiveCssTooltipHelper {
+ constructor() {
+ this.addTab = this.addTab.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with inactive CSS information.
+ *
+ * @param {String} propertyName
+ * The property name to be displayed in bold.
+ * @param {String} text
+ * The main text, which follows property name.
+ */
+ async setContent(data, tooltip) {
+ const fragment = this.getTemplate(data, tooltip);
+ const { doc } = tooltip;
+
+ tooltip.panel.innerHTML = "";
+
+ tooltip.panel.addEventListener("click", this.addTab);
+ tooltip.once("hidden", () => {
+ tooltip.panel.removeEventListener("click", this.addTab);
+ });
+
+ // Because Fluent is async we need to manually translate the fragment and
+ // then insert it into the tooltip. This is needed in order for the tooltip
+ // to size to the contents properly and for tests.
+ await doc.l10n.translateFragment(fragment);
+ doc.l10n.pauseObserving();
+ tooltip.panel.appendChild(fragment);
+ doc.l10n.resumeObserving();
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template that the Fluent string will be merged with. This template
+ * looks something like this but there is a variable amount of properties in the
+ * fix section:
+ *
+ * <div class="devtools-tooltip-inactive-css">
+ * <p data-l10n-id="inactive-css-not-grid-or-flex-container"
+ * data-l10n-args="{&quot;property&quot;:&quot;align-content&quot;}">
+ * </p>
+ * <p data-l10n-id="inactive-css-not-grid-or-flex-container-fix">
+ * <span data-l10n-name="link" class="link"></span>
+ * </p>
+ * </div>
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * fixId: "inactive-css-not-grid-item-fix-2", // Fluent id containing the
+ * // Inactive CSS fix.
+ * msgId: "inactive-css-not-grid-item", // Fluent id containing the
+ * // Inactive CSS message.
+ * property: "color", // Property name
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ getTemplate(data, tooltip) {
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
+ const { fixId, msgId, property, display, learnMoreURL } = data;
+ const { doc } = tooltip;
+
+ const documentURL =
+ learnMoreURL || `https://developer.mozilla.org/docs/Web/CSS/${property}`;
+ this._currentTooltip = tooltip;
+ this._currentUrl = `${documentURL}?utm_source=devtools&utm_medium=inspector-inactive-css`;
+
+ const templateNode = doc.createElementNS(XHTML_NS, "template");
+
+ // eslint-disable-next-line
+ templateNode.innerHTML = `
+ <div class="devtools-tooltip-inactive-css">
+ <p data-l10n-id="${msgId}"
+ data-l10n-args='${JSON.stringify({ property, display })}'>
+ </p>
+ <p data-l10n-id="${fixId}">
+ <span data-l10n-name="link" class="link"></span>
+ </p>
+ </div>`;
+
+ return doc.importNode(templateNode.content, true);
+ }
+
+ /**
+ * Hide the tooltip, open `this._currentUrl` in a new tab and focus it.
+ *
+ * @param {DOMEvent} event
+ * The click event originating from the tooltip.
+ *
+ */
+ addTab(event) {
+ // The XUL panel swallows click events so handlers can't be added directly
+ // to the link span. As a workaround we listen to all click events in the
+ // panel and if a link span is clicked we proceed.
+ if (event.target.className !== "link") {
+ return;
+ }
+
+ const tooltip = this._currentTooltip;
+ tooltip.hide();
+ openDocLink(this._currentUrl);
+ }
+
+ destroy() {
+ this._currentTooltip = null;
+ this._currentUrl = null;
+ }
+}
+
+module.exports = InactiveCssTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/moz.build b/devtools/client/shared/widgets/tooltip/moz.build
new file mode 100644
index 0000000000..40effd4196
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/moz.build
@@ -0,0 +1,22 @@
+# -*- 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(
+ "css-compatibility-tooltip-helper.js",
+ "css-query-container-tooltip-helper.js",
+ "EventTooltipHelper.js",
+ "HTMLTooltip.js",
+ "ImageTooltipHelper.js",
+ "inactive-css-tooltip-helper.js",
+ "RulePreviewTooltip.js",
+ "SwatchBasedEditorTooltip.js",
+ "SwatchColorPickerTooltip.js",
+ "SwatchCubicBezierTooltip.js",
+ "SwatchFilterTooltip.js",
+ "SwatchLinearEasingFunctionTooltip.js",
+ "TooltipToggle.js",
+ "VariableTooltipHelper.js",
+)
diff --git a/devtools/client/shared/widgets/view-helpers.js b/devtools/client/shared/widgets/view-helpers.js
new file mode 100644
index 0000000000..589d9c6299
--- /dev/null
+++ b/devtools/client/shared/widgets/view-helpers.js
@@ -0,0 +1,430 @@
+/* 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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+
+const PANE_APPEARANCE_DELAY = 50;
+
+var namedTimeoutsStore = new Map();
+
+/**
+ * Helper for draining a rapid succession of events and invoking a callback
+ * once everything settles down.
+ *
+ * @param string id
+ * A string identifier for the named timeout.
+ * @param number wait
+ * The amount of milliseconds to wait after no more events are fired.
+ * @param function callback
+ * Invoked when no more events are fired after the specified time.
+ */
+const setNamedTimeout = function setNamedTimeout(id, wait, callback) {
+ clearNamedTimeout(id);
+
+ namedTimeoutsStore.set(
+ id,
+ setTimeout(() => namedTimeoutsStore.delete(id) && callback(), wait)
+ );
+};
+exports.setNamedTimeout = setNamedTimeout;
+
+/**
+ * Clears a named timeout.
+ * @see setNamedTimeout
+ *
+ * @param string id
+ * A string identifier for the named timeout.
+ */
+const clearNamedTimeout = function clearNamedTimeout(id) {
+ if (!namedTimeoutsStore) {
+ return;
+ }
+ clearTimeout(namedTimeoutsStore.get(id));
+ namedTimeoutsStore.delete(id);
+};
+exports.clearNamedTimeout = clearNamedTimeout;
+
+/**
+ * Helpers for creating and messaging between UI components.
+ */
+exports.ViewHelpers = {
+ /**
+ * Convenience method, dispatching a custom event.
+ *
+ * @param Node target
+ * A custom target element to dispatch the event from.
+ * @param string type
+ * The name of the event.
+ * @param any detail
+ * The data passed when initializing the event.
+ * @return boolean
+ * True if the event was cancelled or a registered handler
+ * called preventDefault.
+ */
+ dispatchEvent(target, type, detail) {
+ if (!(target instanceof Node)) {
+ // Event cancelled.
+ return true;
+ }
+ const document = target.ownerDocument || target;
+ const dispatcher = target.ownerDocument ? target : document.documentElement;
+
+ const event = document.createEvent("CustomEvent");
+ event.initCustomEvent(type, true, true, detail);
+ return dispatcher.dispatchEvent(event);
+ },
+
+ /**
+ * Helper delegating some of the DOM attribute methods of a node to a widget.
+ *
+ * @param object widget
+ * The widget to assign the methods to.
+ * @param Node node
+ * A node to delegate the methods to.
+ */
+ delegateWidgetAttributeMethods(widget, node) {
+ widget.getAttribute = widget.getAttribute || node.getAttribute.bind(node);
+ widget.setAttribute = widget.setAttribute || node.setAttribute.bind(node);
+ widget.removeAttribute =
+ widget.removeAttribute || node.removeAttribute.bind(node);
+ },
+
+ /**
+ * Helper delegating some of the DOM event methods of a node to a widget.
+ *
+ * @param object widget
+ * The widget to assign the methods to.
+ * @param Node node
+ * A node to delegate the methods to.
+ */
+ delegateWidgetEventMethods(widget, node) {
+ widget.addEventListener =
+ widget.addEventListener || node.addEventListener.bind(node);
+ widget.removeEventListener =
+ widget.removeEventListener || node.removeEventListener.bind(node);
+ },
+
+ /**
+ * Checks if the specified object looks like it's been decorated by an
+ * event emitter.
+ *
+ * @return boolean
+ * True if it looks, walks and quacks like an event emitter.
+ */
+ isEventEmitter(object) {
+ return object?.on && object?.off && object?.once && object?.emit;
+ },
+
+ /**
+ * Checks if the specified object is an instance of a DOM node.
+ *
+ * @return boolean
+ * True if it's a node, false otherwise.
+ */
+ isNode(object) {
+ return (
+ object instanceof Node ||
+ object instanceof Element ||
+ Cu.getClassName(object) == "DocumentFragment"
+ );
+ },
+
+ /**
+ * Prevents event propagation when navigation keys are pressed.
+ *
+ * @param Event e
+ * The event to be prevented.
+ */
+ preventScrolling(e) {
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_UP:
+ case KeyCodes.DOM_VK_DOWN:
+ case KeyCodes.DOM_VK_LEFT:
+ case KeyCodes.DOM_VK_RIGHT:
+ case KeyCodes.DOM_VK_PAGE_UP:
+ case KeyCodes.DOM_VK_PAGE_DOWN:
+ case KeyCodes.DOM_VK_HOME:
+ case KeyCodes.DOM_VK_END:
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+
+ /**
+ * Check if the enter key or space was pressed
+ *
+ * @param event event
+ * The event triggered by a keydown or keypress on an element
+ */
+ isSpaceOrReturn(event) {
+ return (
+ event.keyCode === KeyCodes.DOM_VK_SPACE ||
+ event.keyCode === KeyCodes.DOM_VK_RETURN
+ );
+ },
+
+ /**
+ * Sets a toggled pane hidden or visible. The pane can either be displayed on
+ * the side (right or left depending on the locale) or at the bottom.
+ *
+ * @param object flags
+ * An object containing some of the following properties:
+ * - visible: true if the pane should be shown, false to hide
+ * - animated: true to display an animation on toggle
+ * - delayed: true to wait a few cycles before toggle
+ * - callback: a function to invoke when the toggle finishes
+ * @param Node pane
+ * The element representing the pane to toggle.
+ */
+ togglePane(flags, pane) {
+ // Make sure a pane is actually available first.
+ if (!pane) {
+ return;
+ }
+
+ // Hiding is always handled via margins, not the hidden attribute.
+ pane.removeAttribute("hidden");
+
+ // Add a class to the pane to handle min-widths, margins and animations.
+ pane.classList.add("generic-toggled-pane");
+
+ // Avoid toggles in the middle of animation.
+ if (pane.hasAttribute("animated")) {
+ return;
+ }
+
+ // Avoid useless toggles.
+ if (flags.visible == !pane.classList.contains("pane-collapsed")) {
+ if (flags.callback) {
+ flags.callback();
+ }
+ return;
+ }
+
+ // The "animated" attributes enables animated toggles (slide in-out).
+ if (flags.animated) {
+ pane.setAttribute("animated", "");
+ } else {
+ pane.removeAttribute("animated");
+ }
+
+ // Computes and sets the pane margins in order to hide or show it.
+ const doToggle = () => {
+ // Negative margins are applied to "right" and "left" to support RTL and
+ // LTR directions, as well as to "bottom" to support vertical layouts.
+ // Unnecessary negative margins are forced to 0 via CSS in widgets.css.
+ if (flags.visible) {
+ pane.style.marginLeft = "0";
+ pane.style.marginRight = "0";
+ pane.style.marginBottom = "0";
+ pane.classList.remove("pane-collapsed");
+ } else {
+ const width = Math.floor(pane.getAttribute("width")) + 1;
+ const height = Math.floor(pane.getAttribute("height")) + 1;
+ pane.style.marginLeft = -width + "px";
+ pane.style.marginRight = -width + "px";
+ pane.style.marginBottom = -height + "px";
+ }
+
+ // Wait for the animation to end before calling afterToggle()
+ if (flags.animated) {
+ const options = {
+ useCapture: false,
+ once: true,
+ };
+
+ pane.addEventListener(
+ "transitionend",
+ () => {
+ // Prevent unwanted transitions: if the panel is hidden and the layout
+ // changes margins will be updated and the panel will pop out.
+ pane.removeAttribute("animated");
+
+ if (!flags.visible) {
+ pane.classList.add("pane-collapsed");
+ }
+ if (flags.callback) {
+ flags.callback();
+ }
+ },
+ options
+ );
+ } else {
+ if (!flags.visible) {
+ pane.classList.add("pane-collapsed");
+ }
+
+ // Invoke the callback immediately since there's no transition.
+ if (flags.callback) {
+ flags.callback();
+ }
+ }
+ };
+
+ // Sometimes it's useful delaying the toggle a few ticks to ensure
+ // a smoother slide in-out animation.
+ if (flags.delayed) {
+ pane.ownerDocument.defaultView.setTimeout(
+ doToggle,
+ PANE_APPEARANCE_DELAY
+ );
+ } else {
+ doToggle();
+ }
+ },
+};
+
+/**
+ * A generic Item is used to describe children present in a Widget.
+ *
+ * This is basically a very thin wrapper around a Node, with a few
+ * characteristics, like a `value` and an `attachment`.
+ *
+ * The characteristics are optional, and their meaning is entirely up to you.
+ * - The `value` should be a string, passed as an argument.
+ * - The `attachment` is any kind of primitive or object, passed as an argument.
+ *
+ * Iterable via "for (let childItem of parentItem) { }".
+ *
+ * @param object ownerView
+ * The owner view creating this item.
+ * @param Node element
+ * A prebuilt node to be wrapped.
+ * @param string value
+ * A string identifying the node.
+ * @param any attachment
+ * Some attached primitive/object.
+ */
+function Item(ownerView, element, value, attachment) {
+ this.ownerView = ownerView;
+ this.attachment = attachment;
+ this._value = value + "";
+ this._prebuiltNode = element;
+ this._itemsByElement = new Map();
+}
+
+Item.prototype = {
+ get value() {
+ return this._value;
+ },
+ get target() {
+ return this._target;
+ },
+ get prebuiltNode() {
+ return this._prebuiltNode;
+ },
+
+ /**
+ * Immediately appends a child item to this item.
+ *
+ * @param Node element
+ * A Node representing the child element to append.
+ * @param object options [optional]
+ * Additional options or flags supported by this operation:
+ * - attachment: some attached primitive/object for the item
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function invoked when the child item is removed
+ * @return Item
+ * The item associated with the displayed element.
+ */
+ append(element, options = {}) {
+ const item = new Item(this, element, "", options.attachment);
+
+ // Entangle the item with the newly inserted child node.
+ // Make sure this is done with the value returned by appendChild(),
+ // to avoid storing a potential DocumentFragment.
+ this._entangleItem(item, this._target.appendChild(element));
+
+ // Handle any additional options after entangling the item.
+ if (options.attributes) {
+ options.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
+ }
+ if (options.finalize) {
+ item.finalize = options.finalize;
+ }
+
+ // Return the item associated with the displayed element.
+ return item;
+ },
+
+ /**
+ * Immediately removes the specified child item from this item.
+ *
+ * @param Item item
+ * The item associated with the element to remove.
+ */
+ remove(item) {
+ if (!item) {
+ return;
+ }
+ this._target.removeChild(item._target);
+ this._untangleItem(item);
+ },
+
+ /**
+ * Entangles an item (model) with a displayed node element (view).
+ *
+ * @param Item item
+ * The item describing a target element.
+ * @param Node element
+ * The element displaying the item.
+ */
+ _entangleItem(item, element) {
+ this._itemsByElement.set(element, item);
+ item._target = element;
+ },
+
+ /**
+ * Untangles an item (model) from a displayed node element (view).
+ *
+ * @param Item item
+ * The item describing a target element.
+ */
+ _untangleItem(item) {
+ if (item.finalize) {
+ item.finalize(item);
+ }
+ for (const childItem of item) {
+ item.remove(childItem);
+ }
+
+ this._unlinkItem(item);
+ item._target = null;
+ },
+
+ /**
+ * Deletes an item from the its parent's storage maps.
+ *
+ * @param Item item
+ * The item describing a target element.
+ */
+ _unlinkItem(item) {
+ this._itemsByElement.delete(item._target);
+ },
+
+ /**
+ * Returns a string representing the object.
+ * Avoid using `toString` to avoid accidental JSONification.
+ * @return string
+ */
+ stringify() {
+ return JSON.stringify(
+ {
+ value: this._value,
+ target: this._target + "",
+ prebuiltNode: this._prebuiltNode + "",
+ attachment: this.attachment,
+ },
+ null,
+ 2
+ );
+ },
+
+ _value: "",
+ _target: null,
+ _prebuiltNode: null,
+ finalize: null,
+ attachment: null,
+};
diff --git a/devtools/client/shared/widgets/widgets.css b/devtools/client/shared/widgets/widgets.css
new file mode 100644
index 0000000000..dbf558b2f3
--- /dev/null
+++ b/devtools/client/shared/widgets/widgets.css
@@ -0,0 +1,79 @@
+/* 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/. */
+
+/* BreacrumbsWidget */
+
+.breadcrumbs-widget-item {
+ direction: ltr;
+}
+
+.breadcrumbs-widget-item {
+ -moz-user-focus: normal;
+}
+
+/* VariablesView */
+
+.variables-view-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+ direction: ltr;
+}
+
+.variables-view-element-details:not([open]) {
+ display: none;
+}
+
+.variable-or-property {
+ -moz-user-focus: normal;
+}
+
+.variables-view-scope > .title,
+.variable-or-property > .title {
+ overflow: hidden;
+}
+
+.variables-view-scope[untitled] > .title,
+.variable-or-property[untitled] > .title,
+.variable-or-property[unmatched] > .title {
+ display: none;
+}
+
+.variable-or-property:not([safe-getter]) > tooltip > label.WebIDL,
+.variable-or-property:not([overridden]) > tooltip > label.overridden,
+.variable-or-property:not([non-extensible]) > tooltip > label.extensible,
+.variable-or-property:not([frozen]) > tooltip > label.frozen,
+.variable-or-property:not([sealed]) > tooltip > label.sealed {
+ display: none;
+}
+
+.variable-or-property[pseudo-item] > tooltip,
+.variable-or-property[pseudo-item] > .title > .variables-view-edit,
+.variable-or-property[pseudo-item] > .title > .variables-view-delete,
+.variable-or-property[pseudo-item] > .title > .variables-view-add-property,
+.variable-or-property[pseudo-item] > .title > .variables-view-open-inspector,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-frozen-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-sealed-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-non-extensible-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-non-writable-icon {
+ display: none;
+}
+
+.variable-or-property > .title .toolbarbutton-text {
+ display: none;
+}
+
+*:not(:hover) .variables-view-delete,
+*:not(:hover) .variables-view-add-property,
+*:not(:hover) .variables-view-open-inspector {
+ visibility: hidden;
+}
+
+.variables-view-container[aligned-values] [optional-visibility] {
+ display: none;
+}
+
+/* Table Widget */
+.table-widget-body > .devtools-side-splitter:last-child {
+ display: none;
+}