diff options
Diffstat (limited to 'devtools/client/shared/widgets')
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="{"property":"user-select"}"> + * </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="{"property":"user-select"}"> + * <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="{"property":"user-select"}"> + * <strong></strong> + * </p> + * <browser-list /> + * <p data-l10n-id="css-compatibility-learn-more-message" + * data-l10n-args="{"property":"user-select"}"> + * <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="{"property":"align-content"}"> + * </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; +} |