diff options
Diffstat (limited to 'devtools/client/shared/widgets')
35 files changed, 15462 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/AbstractTreeItem.jsm b/devtools/client/shared/widgets/AbstractTreeItem.jsm new file mode 100644 index 0000000000..de0d78be9d --- /dev/null +++ b/devtools/client/shared/widgets/AbstractTreeItem.jsm @@ -0,0 +1,670 @@ +/* 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 { require, loader } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" +); +const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +const EXPORTED_SYMBOLS = ["AbstractTreeItem"]; + +/** + * A very generic and low-level tree view implementation. It is not intended + * to be used alone, but as a base class that you can extend to build your + * own custom implementation. + * + * Language: + * - An "item" is an instance of an AbstractTreeItem. + * - An "element" or "node" is a Node. + * + * The following events are emitted by this tree, always from the root item, + * with the first argument pointing to the affected child item: + * - "expand": when an item is expanded in the tree + * - "collapse": when an item is collapsed in the tree + * - "focus": when an item is selected in the tree + * + * For example, you can extend this abstract class like this: + * + * function MyCustomTreeItem(dataSrc, properties) { + * AbstractTreeItem.call(this, properties); + * this.itemDataSrc = dataSrc; + * } + * + * MyCustomTreeItem.prototype = extend(AbstractTreeItem.prototype, { + * _displaySelf: function(document, arrowNode) { + * let node = document.createXULElement("hbox"); + * ... + * // Append the provided arrow node wherever you want. + * node.appendChild(arrowNode); + * ... + * // Use `this.itemDataSrc` to customize the tree item and + * // `this.level` to calculate the indentation. + * node.style.marginInlineStart = (this.level * 10) + "px"; + * node.appendChild(document.createTextNode(this.itemDataSrc.label)); + * ... + * return node; + * }, + * _populateSelf: function(children) { + * ... + * // Use `this.itemDataSrc` to get the data source for the child items. + * let someChildDataSrc = this.itemDataSrc.children[0]; + * ... + * children.push(new MyCustomTreeItem(someChildDataSrc, { + * parent: this, + * level: this.level + 1 + * })); + * ... + * } + * }); + * + * And then you could use it like this: + * + * let dataSrc = { + * label: "root", + * children: [{ + * label: "foo", + * children: [] + * }, { + * label: "bar", + * children: [{ + * label: "baz", + * children: [] + * }] + * }] + * }; + * let root = new MyCustomTreeItem(dataSrc, { parent: null }); + * root.attachTo(Node); + * root.expand(); + * + * The following tree view will be generated (after expanding all nodes): + * ▼ root + * ▶ foo + * ▼ bar + * ▶ baz + * + * The way the data source is implemented is completely up to you. There's + * no assumptions made and you can use it however you like inside the + * `_displaySelf` and `populateSelf` methods. If you need to add children to a + * node at a later date, you just need to modify the data source: + * + * dataSrc[...path-to-foo...].children.push({ + * label: "lazily-added-node" + * children: [] + * }); + * + * The existing tree view will be modified like so (after expanding `foo`): + * ▼ root + * ▼ foo + * ▶ lazily-added-node + * ▼ bar + * ▶ baz + * + * Everything else is taken care of automagically! + * + * @param AbstractTreeItem parent + * The parent tree item. Should be null for root items. + * @param number level + * The indentation level in the tree. The root item is at level 0. + */ +function AbstractTreeItem({ parent, level }) { + this._rootItem = parent ? parent._rootItem : this; + this._parentItem = parent; + this._level = level || 0; + this._childTreeItems = []; + + // Events are always propagated through the root item. Decorating every + // tree item as an event emitter is a very costly operation. + if (this == this._rootItem) { + EventEmitter.decorate(this); + } +} + +AbstractTreeItem.prototype = { + _containerNode: null, + _targetNode: null, + _arrowNode: null, + _constructed: false, + _populated: false, + _expanded: false, + + /** + * Optionally, trees may be allowed to automatically expand a few levels deep + * to avoid initially displaying a completely collapsed tree. + */ + autoExpandDepth: 0, + + /** + * Creates the view for this tree item. Implement this method in the + * inheriting classes to create the child node displayed in the tree. + * Use `this.level` and the provided `arrowNode` as you see fit. + * + * @param Node document + * @param Node arrowNode + * @return Node + */ + _displaySelf: function(document, arrowNode) { + throw new Error( + "The `_displaySelf` method needs to be implemented by inheriting classes." + ); + }, + + /** + * Populates this tree item with child items, whenever it's expanded. + * Implement this method in the inheriting classes to fill the provided + * `children` array with AbstractTreeItem instances, which will then be + * magically handled by this tree item. + * + * @param array:AbstractTreeItem children + */ + _populateSelf: function(children) { + throw new Error( + "The `_populateSelf` method needs to be implemented by inheriting classes." + ); + }, + + /** + * Gets the this tree's owner document. + * @return Document + */ + get document() { + return this._containerNode.ownerDocument; + }, + + /** + * Gets the root item of this tree. + * @return AbstractTreeItem + */ + get root() { + return this._rootItem; + }, + + /** + * Gets the parent of this tree item. + * @return AbstractTreeItem + */ + get parent() { + return this._parentItem; + }, + + /** + * Gets the indentation level of this tree item. + */ + get level() { + return this._level; + }, + + /** + * Gets the element displaying this tree item. + */ + get target() { + return this._targetNode; + }, + + /** + * Gets the element containing all tree items. + * @return Node + */ + get container() { + return this._containerNode; + }, + + /** + * Returns whether or not this item is populated in the tree. + * Collapsed items can still be populated. + * @return boolean + */ + get populated() { + return this._populated; + }, + + /** + * Returns whether or not this item is expanded in the tree. + * Expanded items with no children aren't consudered `populated`. + * @return boolean + */ + get expanded() { + return this._expanded; + }, + + /** + * Gets the bounds for this tree's container without flushing. + * @return object + */ + get bounds() { + const win = this.document.defaultView; + const utils = win.windowUtils; + return utils.getBoundsWithoutFlushing(this._containerNode); + }, + + /** + * Creates and appends this tree item to the specified parent element. + * + * @param Node containerNode + * The parent element for this tree item (and every other tree item). + * @param Node fragmentNode [optional] + * An optional document fragment temporarily holding this tree item in + * the current batch. Defaults to the `containerNode`. + * @param Node beforeNode [optional] + * An optional child element which should succeed this tree item. + */ + attachTo: function( + containerNode, + fragmentNode = containerNode, + beforeNode = null + ) { + this._containerNode = containerNode; + this._constructTargetNode(); + + if (beforeNode) { + fragmentNode.insertBefore(this._targetNode, beforeNode); + } else { + fragmentNode.appendChild(this._targetNode); + } + + if (this._level < this.autoExpandDepth) { + this.expand(); + } + }, + + /** + * Permanently removes this tree item (and all subsequent children) from the + * parent container. + */ + remove: function() { + this._targetNode.remove(); + this._hideChildren(); + this._childTreeItems.length = 0; + }, + + /** + * Focuses this item in the tree. + */ + focus: function() { + this._targetNode.focus(); + }, + + /** + * Expands this item in the tree. + */ + expand: function() { + if (this._expanded) { + return; + } + this._expanded = true; + this._arrowNode.setAttribute("open", ""); + this._targetNode.setAttribute("expanded", ""); + this._toggleChildren(true); + this._rootItem.emit("expand", this); + }, + + /** + * Collapses this item in the tree. + */ + collapse: function() { + if (!this._expanded) { + return; + } + this._expanded = false; + this._arrowNode.removeAttribute("open"); + this._targetNode.removeAttribute("expanded", ""); + this._toggleChildren(false); + this._rootItem.emit("collapse", this); + }, + + /** + * Returns the child item at the specified index. + * + * @param number index + * @return AbstractTreeItem + */ + getChild: function(index = 0) { + return this._childTreeItems[index]; + }, + + /** + * Calls the provided function on all the descendants of this item. + * If this item was never expanded, then no descendents exist yet. + * @param function cb + */ + traverse: function(cb) { + for (const child of this._childTreeItems) { + cb(child); + child.bfs(); + } + }, + + /** + * Calls the provided function on all descendants of this item until + * a truthy value is returned by the predicate. + * @param function predicate + * @return AbstractTreeItem + */ + find: function(predicate) { + for (const child of this._childTreeItems) { + if (predicate(child) || child.find(predicate)) { + return child; + } + } + return null; + }, + + /** + * Shows or hides all the children of this item in the tree. If neessary, + * populates this item with children. + * + * @param boolean visible + * True if the children should be visible, false otherwise. + */ + _toggleChildren: function(visible) { + if (visible) { + if (!this._populated) { + this._populateSelf(this._childTreeItems); + this._populated = this._childTreeItems.length > 0; + } + this._showChildren(); + } else { + this._hideChildren(); + } + }, + + /** + * Shows all children of this item in the tree. + */ + _showChildren: function() { + // If this is the root item and we're not expanding any child nodes, + // it is safe to append everything at once. + if (this == this._rootItem && this.autoExpandDepth == 0) { + this._appendChildrenBatch(); + } else { + // Otherwise, append the child items and their descendants successively; + // if not, the tree will become garbled and nodes will intertwine, + // since all the tree items are sharing a single container node. + this._appendChildrenSuccessive(); + } + }, + + /** + * Hides all children of this item in the tree. + */ + _hideChildren: function() { + for (const item of this._childTreeItems) { + item._targetNode.remove(); + item._hideChildren(); + } + }, + + /** + * Appends all children in a single batch. + * This only works properly for root nodes when no child nodes will expand. + */ + _appendChildrenBatch: function() { + if (this._fragment === undefined) { + this._fragment = this.document.createDocumentFragment(); + } + + const childTreeItems = this._childTreeItems; + + for (let i = 0, len = childTreeItems.length; i < len; i++) { + childTreeItems[i].attachTo(this._containerNode, this._fragment); + } + + this._containerNode.appendChild(this._fragment); + }, + + /** + * Appends all children successively. + */ + _appendChildrenSuccessive: function() { + const childTreeItems = this._childTreeItems; + const expandedChildTreeItems = childTreeItems.filter(e => e._expanded); + const nextNode = this._getSiblingAtDelta(1); + + for (let i = 0, len = childTreeItems.length; i < len; i++) { + childTreeItems[i].attachTo(this._containerNode, undefined, nextNode); + } + for (let i = 0, len = expandedChildTreeItems.length; i < len; i++) { + expandedChildTreeItems[i]._showChildren(); + } + }, + + /** + * Constructs and stores the target node displaying this tree item. + */ + _constructTargetNode: function() { + if (this._constructed) { + return; + } + this._onArrowClick = this._onArrowClick.bind(this); + this._onClick = this._onClick.bind(this); + this._onDoubleClick = this._onDoubleClick.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onFocus = this._onFocus.bind(this); + this._onBlur = this._onBlur.bind(this); + + const document = this.document; + + const arrowNode = (this._arrowNode = document.createXULElement("hbox")); + arrowNode.className = "arrow theme-twisty"; + arrowNode.addEventListener("mousedown", this._onArrowClick); + + const targetNode = (this._targetNode = this._displaySelf( + document, + arrowNode + )); + targetNode.style.MozUserFocus = "normal"; + + targetNode.addEventListener("mousedown", this._onClick); + targetNode.addEventListener("dblclick", this._onDoubleClick); + targetNode.addEventListener("keydown", this._onKeyDown); + targetNode.addEventListener("focus", this._onFocus); + targetNode.addEventListener("blur", this._onBlur); + + this._constructed = true; + }, + + /** + * Gets the element displaying an item in the tree at the specified offset + * relative to this item. + * + * @param number delta + * The offset from this item to the target item. + * @return Node + * The element displaying the target item at the specified offset. + */ + _getSiblingAtDelta: function(delta) { + const childNodes = this._containerNode.childNodes; + const indexOfSelf = Array.prototype.indexOf.call( + childNodes, + this._targetNode + ); + if (indexOfSelf + delta >= 0) { + return childNodes[indexOfSelf + delta]; + } + return undefined; + }, + + _getNodesPerPageSize: function() { + const childNodes = this._containerNode.childNodes; + const nodeHeight = this._getHeight(childNodes[childNodes.length - 1]); + const containerHeight = this.bounds.height; + return Math.ceil(containerHeight / nodeHeight); + }, + + _getHeight: function(elem) { + const win = this.document.defaultView; + const utils = win.windowUtils; + return utils.getBoundsWithoutFlushing(elem).height; + }, + + /** + * Focuses the first item in this tree. + */ + _focusFirstNode: function() { + const childNodes = this._containerNode.childNodes; + // The root node of the tree may be hidden in practice, so uses for-loop + // here to find the next visible node. + for (let i = 0; i < childNodes.length; i++) { + // The height will be 0 if an element is invisible. + if (this._getHeight(childNodes[i])) { + childNodes[i].focus(); + return; + } + } + }, + + /** + * Focuses the last item in this tree. + */ + _focusLastNode: function() { + const childNodes = this._containerNode.childNodes; + childNodes[childNodes.length - 1].focus(); + }, + + /** + * Focuses the next item in this tree. + */ + _focusNextNode: function() { + const nextElement = this._getSiblingAtDelta(1); + if (nextElement) { + nextElement.focus(); + } // Node + }, + + /** + * Focuses the previous item in this tree. + */ + _focusPrevNode: function() { + const prevElement = this._getSiblingAtDelta(-1); + if (prevElement) { + prevElement.focus(); + } // Node + }, + + /** + * Focuses the parent item in this tree. + * + * The parent item is not always the previous item, because any tree item + * may have multiple children. + */ + _focusParentNode: function() { + const parentItem = this._parentItem; + if (parentItem) { + parentItem.focus(); + } // AbstractTreeItem + }, + + /** + * Handler for the "click" event on the arrow node of this tree item. + */ + _onArrowClick: function(e) { + if (!this._expanded) { + this.expand(); + } else { + this.collapse(); + } + }, + + /** + * Handler for the "click" event on the element displaying this tree item. + */ + _onClick: function(e) { + e.stopPropagation(); + this.focus(); + }, + + /** + * Handler for the "dblclick" event on the element displaying this tree item. + */ + _onDoubleClick: function(e) { + // Ignore dblclick on the arrow as it has already recived and handled two + // click events. + if (!e.target.classList.contains("arrow")) { + this._onArrowClick(e); + } + this.focus(); + }, + + /** + * Handler for the "keydown" event on the element displaying this tree item. + */ + _onKeyDown: function(e) { + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(e); + + switch (e.keyCode) { + case KeyCodes.DOM_VK_UP: + this._focusPrevNode(); + return; + + case KeyCodes.DOM_VK_DOWN: + this._focusNextNode(); + return; + + case KeyCodes.DOM_VK_LEFT: + if (this._expanded && this._populated) { + this.collapse(); + } else { + this._focusParentNode(); + } + return; + + case KeyCodes.DOM_VK_RIGHT: + if (!this._expanded) { + this.expand(); + } else { + this._focusNextNode(); + } + return; + + case KeyCodes.DOM_VK_PAGE_UP: + const pageUpElement = this._getSiblingAtDelta( + -this._getNodesPerPageSize() + ); + // There's a chance that the root node is hidden. In this case, its + // height will be 0. + if (pageUpElement && this._getHeight(pageUpElement)) { + pageUpElement.focus(); + } else { + this._focusFirstNode(); + } + return; + + case KeyCodes.DOM_VK_PAGE_DOWN: + const pageDownElement = this._getSiblingAtDelta( + this._getNodesPerPageSize() + ); + if (pageDownElement) { + pageDownElement.focus(); + } else { + this._focusLastNode(); + } + return; + + case KeyCodes.DOM_VK_HOME: + this._focusFirstNode(); + return; + + case KeyCodes.DOM_VK_END: + this._focusLastNode(); + } + }, + + /** + * Handler for the "focus" event on the element displaying this tree item. + */ + _onFocus: function(e) { + this._rootItem.emit("focus", this); + }, + + /** + * Handler for the "blur" event on the element displaying this tree item. + */ + _onBlur: function(e) { + this._rootItem.emit("blur", this); + }, +}; diff --git a/devtools/client/shared/widgets/Chart.js b/devtools/client/shared/widgets/Chart.js new file mode 100644 index 0000000000..186f50e0e8 --- /dev/null +++ b/devtools/client/shared/widgets/Chart.js @@ -0,0 +1,493 @@ +/* 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("devtools/shared/event-emitter"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +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: data, + }); + + const table = Chart.Table(document, { + title: title, + data: data, + strings: strings, + totals: totals, + header: 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; + + // Filter out very small sizes, as they'll just render invisible slices. + data = data ? data.filter(e => e.size > EPSILON) : null; + + // 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("pack", "center"); + container.setAttribute("flex", "1"); + container.setAttribute("width", width); + container.setAttribute("height", height); + container.setAttribute("viewBox", "0 0 " + width + " " + height); + container.setAttribute("slices", data.length); + container.setAttribute("placeholder", isPlaceholder); + + 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]; + if (!sliceInfo.size || sliceAngle < EPSILON) { + continue; + } + + 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.setAttribute("class", "pie-chart-slice chart-colored-blob"); + pathNode.setAttribute("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"], pathNode, sliceInfo); + container.appendChild(pathNode); + + if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) { + const textX = centerX + textDistance * Math.sin(midAngle); + const textY = centerY - textDistance * Math.cos(midAngle); + const label = document.createElementNS(SVG_NS, "text"); + label.appendChild(document.createTextNode(sliceInfo.label)); + 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); + container.appendChild(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("pack", "center"); + container.setAttribute("flex", "1"); + container.setAttribute("rows", data.length); + container.setAttribute("placeholder", isPlaceholder); + container.setAttribute("style", "-moz-box-orient: vertical"); + + 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("div"); + tableNode.className = "plain table-chart-grid"; + tableNode.setAttribute("style", "-moz-box-orient: vertical"); + container.appendChild(tableNode); + + const headerNode = document.createElement("div"); + headerNode.className = "table-chart-row"; + + const headerBoxNode = document.createElement("div"); + headerBoxNode.className = "table-chart-row-box"; + headerNode.appendChild(headerBoxNode); + + for (const [key, value] of Object.entries(header)) { + const headerLabelNode = document.createElement("span"); + headerLabelNode.className = "plain table-chart-row-label"; + headerLabelNode.setAttribute("name", key); + headerLabelNode.textContent = value; + + headerNode.appendChild(headerLabelNode); + } + + tableNode.appendChild(headerNode); + + for (const rowInfo of data) { + const rowNode = document.createElement("div"); + rowNode.className = "table-chart-row"; + rowNode.setAttribute("align", "center"); + + const boxNode = document.createElement("div"); + boxNode.className = "table-chart-row-box chart-colored-blob"; + boxNode.setAttribute("name", rowInfo.label); + rowNode.appendChild(boxNode); + + for (const [key, value] of Object.entries(rowInfo)) { + const index = data.indexOf(rowInfo); + const stringified = strings[key] ? strings[key](value, index) : value; + const labelNode = document.createElement("span"); + 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); + tableNode.appendChild(rowNode); + } + + const totalsNode = document.createElement("div"); + totalsNode.className = "table-chart-totals"; + totalsNode.setAttribute("style", "-moz-box-orient: vertical"); + + 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..e9f2d98109 --- /dev/null +++ b/devtools/client/shared/widgets/CubicBezierWidget.js @@ -0,0 +1,987 @@ +/** + * 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("devtools/shared/event-emitter"); +const { + PREDEFINED, + PRESETS, + DEFAULT_PRESET_CATEGORY, +} = require("devtools/client/shared/widgets/CubicBezierPresets"); +const { getCSSLexer } = require("devtools/shared/css/lexer"); +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: function() { + // 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: function(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: function(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: function() { + 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: function(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: function() { + this.parent.querySelector(".display-wrap").remove(); + }, + + _initEvents: function() { + 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: function() { + 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: function(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: function(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: function(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: function(coordinates) { + this.coordinates = coordinates; + }, + + /** + * Get the current point coordinates and redraw the curve to match + */ + _updateFromPoints: function() { + // 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: function(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: function() { + 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: function() { + 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: presetPane, + presets: allPresets, + categories: allCategories, + }; + }, + + _createCategory: function(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: function(categoryLabel) { + return categoryLabel.replace("/-/g", " "); + }, + + _createPresetList: function(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: function(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: function(categoryLabel, presetLabel) { + return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " "); + }, + + _initEvents: function() { + for (const category of this.categories) { + category.addEventListener("click", this._onCategoryClick); + } + + for (const preset of this.presets) { + preset.addEventListener("click", this._onPresetClick); + } + }, + + _removeEvents: function() { + for (const category of this.categories) { + category.removeEventListener("click", this._onCategoryClick); + } + + for (const preset of this.presets) { + preset.removeEventListener("click", this._onPresetClick); + } + }, + + _onPresetClick: function(event) { + this.emit("new-coordinates", event.currentTarget.coordinates); + this.activePreset = event.currentTarget; + }, + + _onCategoryClick: function(event) { + this.activeCategory = event.target; + }, + + _setActivePresetList: function(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: function(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: function() { + 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: function() { + 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: function() { + 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: function(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: function(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..635a39510b --- /dev/null +++ b/devtools/client/shared/widgets/FilterWidget.js @@ -0,0 +1,1130 @@ +/* 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("devtools/shared/event-emitter"); +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const STRINGS_URI = "devtools/client/locales/filterwidget.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const { cssTokenizer } = require("devtools/shared/css/parsing-utils"); + +const asyncStorage = require("devtools/shared/async-storage"); + +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: function() { + // 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: function() { + this._filterItemMarkup.remove(); + this.el.remove(); + this.el = this.filterList = this._filterItemMarkup = null; + this.presetList = this.togglePresets = this.filterSelect = null; + this.addPresetButton = null; + }, + + destroy: function() { + this._removeEventListeners(); + this._destroyMarkup(); + }, + + /** + * Creates <option> elements for each filter definition + * in filterList + */ + _populateFilterSelect: function() { + const select = this.filterSelect; + filterList.forEach(filter => { + const option = this.doc.createElementNS(XHTML_NS, "option"); + // eslint-disable-next-line no-unsanitized/property + option.innerHTML = option.value = filter.name; + select.appendChild(option); + }); + }, + + /** + * Creates a template for filter elements which is cloned and used in render + */ + _buildFilterItemMarkup: function() { + 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: function() { + 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: function() { + 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: function() { + 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: function(el) { + return [...this.filterList.children].indexOf(el); + }, + + _keyDown: function(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: function(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: function(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: function() { + const select = this.filterSelect; + if (!select.value) { + return; + } + + const key = select.value; + this.add(key, null); + + this.render(); + }, + + _removeButtonClick: function(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: function(e) { + if (this.isReorderingFilter) { + this._dragFilterElement(e); + } else if (this.isDraggingLabel) { + this._dragLabel(e); + } + }, + + _dragFilterElement: function(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: function(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: function() { + // 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: function(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: function() { + this.el.classList.toggle("show-presets"); + this.emit("render"); + }, + + _savePreset: function(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: function() { + this.filterSelect.ownerDocument.defaultView.focus(); + }, + + /** + * Clears the list and renders filters, binding required events. + * There are some delegated events bound in _addEventListeners method + */ + render: function() { + 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: function() { + 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: function(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: function(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: function(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: function(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: function(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: function() { + 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: function(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: function() { + return asyncStorage.getItem("cssFilterPresets").then(presets => { + if (!presets) { + return []; + } + + return presets; + }, console.error); + }, + + setPresets: function(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: 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: 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/FlameGraph.js b/devtools/client/shared/widgets/FlameGraph.js new file mode 100644 index 0000000000..a4c2bcff36 --- /dev/null +++ b/devtools/client/shared/widgets/FlameGraph.js @@ -0,0 +1,1529 @@ +/* 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 { + ViewHelpers, + setNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); +const { ELLIPSIS } = require("devtools/shared/l10n"); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +loader.lazyRequireGetter( + this, + "getColor", + "devtools/client/shared/theme", + true +); + +loader.lazyRequireGetter( + this, + ["CATEGORIES", "CATEGORY_INDEX"], + "devtools/client/performance/modules/categories", + true +); +loader.lazyRequireGetter( + this, + "FrameUtils", + "devtools/client/performance/modules/logic/frame-utils" +); +loader.lazyRequireGetter(this, "demangle", "devtools/client/shared/demangle"); + +loader.lazyRequireGetter( + this, + ["AbstractCanvasGraph", "GraphArea", "GraphAreaDragger"], + "devtools/client/shared/widgets/Graphs", + true +); + +const GRAPH_SRC = "chrome://devtools/content/shared/widgets/graphs-frame.xhtml"; + +const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms + +const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00035; +const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.5; +const GRAPH_KEYBOARD_ZOOM_SENSITIVITY = 20; +const GRAPH_KEYBOARD_PAN_SENSITIVITY = 20; +const GRAPH_KEYBOARD_ACCELERATION = 1.05; +const GRAPH_KEYBOARD_TRANSLATION_MAX = 150; + +const GRAPH_MIN_SELECTION_WIDTH = 0.001; // ms + +const GRAPH_HORIZONTAL_PAN_THRESHOLD = 10; // px +const GRAPH_VERTICAL_PAN_THRESHOLD = 30; // px + +const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100; + +const TIMELINE_TICKS_MULTIPLE = 5; // ms +const TIMELINE_TICKS_SPACING_MIN = 75; // px + +const OVERVIEW_HEADER_HEIGHT = 16; // px +const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px +const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif"; +const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px +const OVERVIEW_HEADER_TEXT_PADDING_TOP = 5; // px +const OVERVIEW_HEADER_TIMELINE_STROKE_COLOR = "rgba(128, 128, 128, 0.5)"; + +const FLAME_GRAPH_BLOCK_HEIGHT = 15; // px +const FLAME_GRAPH_BLOCK_BORDER = 1; // px +const FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 10; // px +const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = + "message-box, Helvetica Neue," + "Helvetica, sans-serif"; +const FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP = 0; // px +const FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT = 3; // px +const FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT = 3; // px + +// Large enough number for a diverse pallette. +const PALLETTE_SIZE = 20; +const PALLETTE_HUE_OFFSET = Math.random() * 90; +const PALLETTE_HUE_RANGE = 270; +const PALLETTE_SATURATION = 100; +const PALLETTE_BRIGHTNESS = 55; +const PALLETTE_OPACITY = 0.35; + +const COLOR_PALLETTE = Array.from(Array(PALLETTE_SIZE)).map( + (_, i) => + "hsla" + + "(" + + ((PALLETTE_HUE_OFFSET + (i / PALLETTE_SIZE) * PALLETTE_HUE_RANGE) | + 0 % 360) + + "," + + PALLETTE_SATURATION + + "%" + + "," + + PALLETTE_BRIGHTNESS + + "%" + + "," + + PALLETTE_OPACITY + + ")" +); + +/** + * A flamegraph visualization. This implementation is responsable only with + * drawing the graph, using a data source consisting of rectangles and + * their corresponding widths. + * + * Example usage: + * let graph = new FlameGraph(node); + * graph.once("ready", () => { + * let data = FlameGraphUtils.createFlameGraphDataFromThread(thread); + * let bounds = { startTime, endTime }; + * graph.setData({ data, bounds }); + * }); + * + * Data source format: + * [ + * { + * color: "string", + * blocks: [ + * { + * x: number, + * y: number, + * width: number, + * height: number, + * text: "string" + * }, + * ... + * ] + * }, + * { + * color: "string", + * blocks: [...] + * }, + * ... + * { + * color: "string", + * blocks: [...] + * } + * ] + * + * Use `FlameGraphUtils` to convert profiler data (or any other data source) + * into a drawable format. + * + * @param Node parent + * The parent node holding the graph. + * @param number sharpness [optional] + * Defaults to the current device pixel ratio. + */ +function FlameGraph(parent, sharpness) { + EventEmitter.decorate(this); + + this._parent = parent; + + this.setTheme(); + + this._ready = new Promise(resolve => { + AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => { + this._iframe = iframe; + this._window = iframe.contentWindow; + this._document = iframe.contentDocument; + this._pixelRatio = sharpness || this._window.devicePixelRatio; + + const container = (this._container = this._document.getElementById( + "graph-container" + )); + container.className = + "flame-graph-widget-container graph-widget-container"; + + const canvas = (this._canvas = this._document.getElementById( + "graph-canvas" + )); + canvas.className = "flame-graph-widget-canvas graph-widget-canvas"; + + const bounds = parent.getBoundingClientRect(); + bounds.width = this.fixedWidth || bounds.width; + bounds.height = this.fixedHeight || bounds.height; + iframe.setAttribute("width", bounds.width); + iframe.setAttribute("height", bounds.height); + + this._width = canvas.width = bounds.width * this._pixelRatio; + this._height = canvas.height = bounds.height * this._pixelRatio; + this._ctx = canvas.getContext("2d"); + + this._bounds = new GraphArea(); + this._selection = new GraphArea(); + this._selectionDragger = new GraphAreaDragger(); + this._verticalOffset = 0; + this._verticalOffsetDragger = new GraphAreaDragger(0); + this._keyboardZoomAccelerationFactor = 1; + this._keyboardPanAccelerationFactor = 1; + + this._userInputStack = 0; + this._keysPressed = []; + + // Calculating text widths is necessary to trim the text inside the blocks + // while the scaling changes (e.g. via scrolling). This is very expensive, + // so maintain a cache of string contents to text widths. + this._textWidthsCache = {}; + + const fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY; + this._ctx.font = fontSize + "px " + fontFamily; + this._averageCharWidth = this._calcAverageCharWidth(); + this._overflowCharWidth = this._getTextWidth(this.overflowChar); + + this._onAnimationFrame = this._onAnimationFrame.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseUp = this._onMouseUp.bind(this); + this._onMouseWheel = this._onMouseWheel.bind(this); + this._onResize = this._onResize.bind(this); + this.refresh = this.refresh.bind(this); + + this._window.addEventListener("keydown", this._onKeyDown); + this._window.addEventListener("keyup", this._onKeyUp); + this._window.addEventListener("mousemove", this._onMouseMove); + this._window.addEventListener("mousedown", this._onMouseDown); + this._window.addEventListener("mouseup", this._onMouseUp); + this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel); + + const ownerWindow = this._parent.ownerDocument.defaultView; + ownerWindow.addEventListener("resize", this._onResize); + + this._animationId = this._window.requestAnimationFrame( + this._onAnimationFrame + ); + + resolve(this); + this.emit("ready", this); + }); + }); +} + +FlameGraph.prototype = { + /** + * Read-only width and height of the canvas. + * @return number + */ + get width() { + return this._width; + }, + get height() { + return this._height; + }, + + /** + * Returns a promise resolved once this graph is ready to receive data. + */ + ready: function() { + return this._ready; + }, + + /** + * Destroys this graph. + */ + async destroy() { + await this.ready(); + + this._window.removeEventListener("keydown", this._onKeyDown); + this._window.removeEventListener("keyup", this._onKeyUp); + this._window.removeEventListener("mousemove", this._onMouseMove); + this._window.removeEventListener("mousedown", this._onMouseDown); + this._window.removeEventListener("mouseup", this._onMouseUp); + this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel); + + const ownerWindow = this._parent.ownerDocument.defaultView; + if (ownerWindow) { + ownerWindow.removeEventListener("resize", this._onResize); + } + + this._window.cancelAnimationFrame(this._animationId); + this._iframe.remove(); + + this._bounds = null; + this._selection = null; + this._selectionDragger = null; + this._verticalOffset = null; + this._verticalOffsetDragger = null; + this._keyboardZoomAccelerationFactor = null; + this._keyboardPanAccelerationFactor = null; + this._textWidthsCache = null; + + this._data = null; + + this.emit("destroyed"); + }, + + /** + * Makes sure the canvas graph is of the specified width or height, and + * doesn't flex to fit all the available space. + */ + fixedWidth: null, + fixedHeight: null, + + /** + * How much preliminar drag is necessary to determine the panning direction. + */ + horizontalPanThreshold: GRAPH_HORIZONTAL_PAN_THRESHOLD, + verticalPanThreshold: GRAPH_VERTICAL_PAN_THRESHOLD, + + /** + * The units used in the overhead ticks. Could be "ms", for example. + * Overwrite this with your own localized format. + */ + timelineTickUnits: "", + + /** + * Character used when a block's text is overflowing. + * Defaults to an ellipsis. + */ + overflowChar: ELLIPSIS, + + /** + * Sets the data source for this graph. + * + * @param object data + * An object containing the following properties: + * - data: the data source; see the constructor for more info + * - bounds: the minimum/maximum { start, end }, in ms or px + * - visible: optional, the shown { start, end }, in ms or px + */ + setData: function({ data, bounds, visible }) { + this._data = data; + this.setOuterBounds(bounds); + this.setViewRange(visible || bounds); + }, + + /** + * Same as `setData`, but waits for this graph to finish initializing first. + * + * @param object data + * The data source. See the constructor for more information. + * @return promise + * A promise resolved once the data is set. + */ + async setDataWhenReady(data) { + await this.ready(); + this.setData(data); + }, + + /** + * Gets whether or not this graph has a data source. + * @return boolean + */ + hasData: function() { + return !!this._data; + }, + + /** + * Sets the maximum selection (i.e. the 'graph bounds'). + * @param object { start, end } + */ + setOuterBounds: function({ startTime, endTime }) { + this._bounds.start = startTime * this._pixelRatio; + this._bounds.end = endTime * this._pixelRatio; + this._shouldRedraw = true; + }, + + /** + * Sets the selection and vertical offset (i.e. the 'view range'). + * @return number + */ + setViewRange: function({ startTime, endTime }, verticalOffset = 0) { + this._selection.start = startTime * this._pixelRatio; + this._selection.end = endTime * this._pixelRatio; + this._verticalOffset = verticalOffset * this._pixelRatio; + this._shouldRedraw = true; + }, + + /** + * Gets the maximum selection (i.e. the 'graph bounds'). + * @return number + */ + getOuterBounds: function() { + return { + startTime: this._bounds.start / this._pixelRatio, + endTime: this._bounds.end / this._pixelRatio, + }; + }, + + /** + * Gets the current selection and vertical offset (i.e. the 'view range'). + * @return number + */ + getViewRange: function() { + return { + startTime: this._selection.start / this._pixelRatio, + endTime: this._selection.end / this._pixelRatio, + verticalOffset: this._verticalOffset / this._pixelRatio, + }; + }, + + /** + * Focuses this graph's iframe window. + */ + focus: function() { + this._window.focus(); + }, + + /** + * Updates this graph to reflect the new dimensions of the parent node. + * + * @param boolean options.force + * Force redraw everything. + */ + refresh: function(options = {}) { + const bounds = this._parent.getBoundingClientRect(); + const newWidth = this.fixedWidth || bounds.width; + const newHeight = this.fixedHeight || bounds.height; + + // Prevent redrawing everything if the graph's width & height won't change, + // except if force=true. + if ( + !options.force && + this._width == newWidth * this._pixelRatio && + this._height == newHeight * this._pixelRatio + ) { + this.emit("refresh-cancelled"); + return; + } + + bounds.width = newWidth; + bounds.height = newHeight; + this._iframe.setAttribute("width", bounds.width); + this._iframe.setAttribute("height", bounds.height); + this._width = this._canvas.width = bounds.width * this._pixelRatio; + this._height = this._canvas.height = bounds.height * this._pixelRatio; + + this._shouldRedraw = true; + this.emit("refresh"); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + this.overviewHeaderBackgroundColor = getColor("body-background", theme); + this.overviewHeaderTextColor = getColor("body-color", theme); + // Hard to get a color that is readable across both themes for the text + // on the flames + this.blockTextColor = getColor( + theme === "dark" ? "selection-color" : "body-color", + theme + ); + }, + + /** + * The contents of this graph are redrawn only when something changed, + * like the data source, or the selection bounds etc. This flag tracks + * if the rendering is "dirty" and needs to be refreshed. + */ + _shouldRedraw: false, + + /** + * Animation frame callback, invoked on each tick of the refresh driver. + */ + _onAnimationFrame: function() { + this._animationId = this._window.requestAnimationFrame( + this._onAnimationFrame + ); + this._drawWidget(); + }, + + /** + * Redraws the widget when necessary. The actual graph is not refreshed + * every time this function is called, only the cliphead, selection etc. + */ + _drawWidget: function() { + if (!this._shouldRedraw) { + return; + } + + // Unlike mouse events which are updated as needed in their own respective + // handlers, keyboard events are granular and non-continuous (not even + // "keydown", which is fired with a low frequency). Therefore, to maintain + // animation smoothness, update anything that's controllable via the + // keyboard here, in the animation loop, before any actual drawing. + this._keyboardUpdateLoop(); + + const ctx = this._ctx; + const canvasWidth = this._width; + const canvasHeight = this._height; + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + const selection = this._selection; + const selectionWidth = selection.end - selection.start; + const selectionScale = canvasWidth / selectionWidth; + this._drawTicks(selection.start, selectionScale); + this._drawPyramid( + this._data, + this._verticalOffset, + selection.start, + selectionScale + ); + this._drawHeader(selection.start, selectionScale); + + // If the user isn't doing anything anymore, it's safe to stop drawing. + // XXX: This doesn't handle cases where we should still be drawing even + // if any input stops (e.g. smooth panning transitions after the user + // finishes input). We don't care about that right now. + if (this._userInputStack == 0) { + this._shouldRedraw = false; + return; + } + if (this._userInputStack < 0) { + throw new Error("The user went back in time from a pyramid."); + } + }, + + /** + * Performs any necessary changes to the graph's state based on the + * user's input on a keyboard. + */ + _keyboardUpdateLoop: function() { + const KEY_CODE_UP = 38; + const KEY_CODE_DOWN = 40; + const KEY_CODE_LEFT = 37; + const KEY_CODE_RIGHT = 39; + const KEY_CODE_W = 87; + const KEY_CODE_A = 65; + const KEY_CODE_S = 83; + const KEY_CODE_D = 68; + + const canvasWidth = this._width; + const pressed = this._keysPressed; + + const selection = this._selection; + const selectionWidth = selection.end - selection.start; + const selectionScale = canvasWidth / selectionWidth; + + const translation = [0, 0]; + let isZooming = false; + let isPanning = false; + + if (pressed[KEY_CODE_UP] || pressed[KEY_CODE_W]) { + translation[0] += GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale; + translation[1] -= GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale; + isZooming = true; + } + if (pressed[KEY_CODE_DOWN] || pressed[KEY_CODE_S]) { + translation[0] -= GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale; + translation[1] += GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale; + isZooming = true; + } + if (pressed[KEY_CODE_LEFT] || pressed[KEY_CODE_A]) { + translation[0] -= GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale; + translation[1] -= GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale; + isPanning = true; + } + if (pressed[KEY_CODE_RIGHT] || pressed[KEY_CODE_D]) { + translation[0] += GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale; + translation[1] += GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale; + isPanning = true; + } + + if (isPanning) { + // Accelerate the left/right selection panning continuously + // while the pan keys are pressed. + this._keyboardPanAccelerationFactor *= GRAPH_KEYBOARD_ACCELERATION; + translation[0] *= this._keyboardPanAccelerationFactor; + translation[1] *= this._keyboardPanAccelerationFactor; + } else { + this._keyboardPanAccelerationFactor = 1; + } + + if (isZooming) { + // Accelerate the in/out selection zooming continuously + // while the zoom keys are pressed. + this._keyboardZoomAccelerationFactor *= GRAPH_KEYBOARD_ACCELERATION; + translation[0] *= this._keyboardZoomAccelerationFactor; + translation[1] *= this._keyboardZoomAccelerationFactor; + } else { + this._keyboardZoomAccelerationFactor = 1; + } + + if (translation[0] != 0 || translation[1] != 0) { + // Make sure the panning translation speed doesn't end up + // being too high. + const maxTranslation = GRAPH_KEYBOARD_TRANSLATION_MAX / selectionScale; + if (Math.abs(translation[0]) > maxTranslation) { + translation[0] = Math.sign(translation[0]) * maxTranslation; + } + if (Math.abs(translation[1]) > maxTranslation) { + translation[1] = Math.sign(translation[1]) * maxTranslation; + } + this._selection.start += translation[0]; + this._selection.end += translation[1]; + this._normalizeSelectionBounds(); + this.emit("selecting"); + } + }, + + /** + * Draws the overhead header, with time markers and ticks in this graph. + * + * @param number dataOffset, dataScale + * Offsets and scales the data source by the specified amount. + * This is used for scrolling the visualization. + */ + _drawHeader: function(dataOffset, dataScale) { + const ctx = this._ctx; + const canvasWidth = this._width; + const headerHeight = OVERVIEW_HEADER_HEIGHT * this._pixelRatio; + + ctx.fillStyle = this.overviewHeaderBackgroundColor; + ctx.fillRect(0, 0, canvasWidth, headerHeight); + + this._drawTicks(dataOffset, dataScale, { + from: 0, + to: headerHeight, + renderText: true, + }); + }, + + /** + * Draws the overhead ticks in this graph in the flame graph area. + * + * @param number dataOffset, dataScale, from, to, renderText + * Offsets and scales the data source by the specified amount. + * from and to determine the Y position of how far the stroke + * should be drawn. + * This is used when scrolling the visualization. + */ + _drawTicks: function(dataOffset, dataScale, options) { + const { from, to, renderText } = options || {}; + const ctx = this._ctx; + const canvasWidth = this._width; + const canvasHeight = this._height; + const scaledOffset = dataOffset * dataScale; + + const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY; + const textPaddingLeft = + OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio; + const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio; + const tickInterval = this._findOptimalTickInterval(dataScale); + + ctx.textBaseline = "top"; + ctx.font = fontSize + "px " + fontFamily; + ctx.fillStyle = this.overviewHeaderTextColor; + ctx.strokeStyle = OVERVIEW_HEADER_TIMELINE_STROKE_COLOR; + ctx.beginPath(); + + for ( + let x = -scaledOffset % tickInterval; + x < canvasWidth; + x += tickInterval + ) { + const lineLeft = x; + const textLeft = lineLeft + textPaddingLeft; + const time = Math.round((x / dataScale + dataOffset) / this._pixelRatio); + const label = time + " " + this.timelineTickUnits; + if (renderText) { + ctx.fillText(label, textLeft, textPaddingTop); + } + ctx.moveTo(lineLeft, from || 0); + ctx.lineTo(lineLeft, to || canvasHeight); + } + + ctx.stroke(); + }, + + /** + * Draws the blocks and text in this graph. + * + * @param object dataSource + * The data source. See the constructor for more information. + * @param number verticalOffset + * Offsets the drawing vertically by the specified amount. + * @param number dataOffset, dataScale + * Offsets and scales the data source by the specified amount. + * This is used for scrolling the visualization. + */ + _drawPyramid: function(dataSource, verticalOffset, dataOffset, dataScale) { + const ctx = this._ctx; + + const fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY; + const visibleBlocksInfo = this._drawPyramidFill( + dataSource, + verticalOffset, + dataOffset, + dataScale + ); + + ctx.textBaseline = "middle"; + ctx.font = fontSize + "px " + fontFamily; + ctx.fillStyle = this.blockTextColor; + + this._drawPyramidText( + visibleBlocksInfo, + verticalOffset, + dataOffset, + dataScale + ); + }, + + /** + * Fills all block inside this graph's pyramid. + * @see FlameGraph.prototype._drawPyramid + */ + _drawPyramidFill: function( + dataSource, + verticalOffset, + dataOffset, + dataScale + ) { + const visibleBlocksInfoStore = []; + const minVisibleBlockWidth = this._overflowCharWidth; + + for (const { color, blocks } of dataSource) { + this._drawBlocksFill( + color, + blocks, + verticalOffset, + dataOffset, + dataScale, + visibleBlocksInfoStore, + minVisibleBlockWidth + ); + } + + return visibleBlocksInfoStore; + }, + + /** + * Adds the text for all block inside this graph's pyramid. + * @see FlameGraph.prototype._drawPyramid + */ + _drawPyramidText: function( + blocksInfo, + verticalOffset, + dataOffset, + dataScale + ) { + for (const { block, rect } of blocksInfo) { + this._drawBlockText(block, rect, verticalOffset, dataOffset, dataScale); + } + }, + + /** + * Fills a group of blocks sharing the same style. + * + * @param string color + * The color used as the block's background. + * @param array blocks + * A list of { x, y, width, height } objects visually representing + * all the blocks sharing this particular style. + * @param number verticalOffset + * Offsets the drawing vertically by the specified amount. + * @param number dataOffset, dataScale + * Offsets and scales the data source by the specified amount. + * This is used for scrolling the visualization. + * @param array visibleBlocksInfoStore + * An array to store all the visible blocks into, along with the + * final baked coordinates and dimensions, after drawing them. + * The provided array will be populated. + * @param number minVisibleBlockWidth + * The minimum width of the blocks that will be added into + * the `visibleBlocksInfoStore`. + */ + _drawBlocksFill: function( + color, + blocks, + verticalOffset, + dataOffset, + dataScale, + visibleBlocksInfoStore, + minVisibleBlockWidth + ) { + const ctx = this._ctx; + const canvasWidth = this._width; + const canvasHeight = this._height; + const scaledOffset = dataOffset * dataScale; + + ctx.fillStyle = color; + ctx.beginPath(); + + for (const block of blocks) { + const { x, y, width, height } = block; + let rectLeft = x * this._pixelRatio * dataScale - scaledOffset; + const rectTop = + (y - verticalOffset + OVERVIEW_HEADER_HEIGHT) * this._pixelRatio; + let rectWidth = width * this._pixelRatio * dataScale; + const rectHeight = height * this._pixelRatio; + + // Too far respectively right/left/bottom/top + if ( + rectLeft > canvasWidth || + rectLeft < -rectWidth || + rectTop > canvasHeight || + rectTop < -rectHeight + ) { + continue; + } + + // Clamp the blocks position to start at 0. Avoid negative X coords, + // to properly place the text inside the blocks. + if (rectLeft < 0) { + rectWidth += rectLeft; + rectLeft = 0; + } + + // Avoid drawing blocks that are too narrow. + if ( + rectWidth <= FLAME_GRAPH_BLOCK_BORDER || + rectHeight <= FLAME_GRAPH_BLOCK_BORDER + ) { + continue; + } + + ctx.rect( + rectLeft, + rectTop, + rectWidth - FLAME_GRAPH_BLOCK_BORDER, + rectHeight - FLAME_GRAPH_BLOCK_BORDER + ); + + // Populate the visible blocks store with this block if the width + // is longer than a given threshold. + if (rectWidth > minVisibleBlockWidth) { + visibleBlocksInfoStore.push({ + block: block, + rect: { rectLeft, rectTop, rectWidth, rectHeight }, + }); + } + } + + ctx.fill(); + }, + + /** + * Adds text for a single block. + * + * @param object block + * A single { x, y, width, height, text } object visually representing + * the block containing the text. + * @param object rect + * A single { rectLeft, rectTop, rectWidth, rectHeight } object + * representing the final baked coordinates of the drawn rectangle. + * Think of them as screen-space values, vs. object-space values. These + * differ from the scalars in `block` when the graph is scaled/panned. + * @param number verticalOffset + * Offsets the drawing vertically by the specified amount. + * @param number dataOffset, dataScale + * Offsets and scales the data source by the specified amount. + * This is used for scrolling the visualization. + */ + _drawBlockText: function(block, rect, verticalOffset, dataOffset, dataScale) { + const ctx = this._ctx; + + const { text } = block; + let { rectLeft, rectTop, rectWidth, rectHeight } = rect; + + const paddingTop = FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP * this._pixelRatio; + const paddingLeft = FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT * this._pixelRatio; + const paddingRight = + FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT * this._pixelRatio; + const totalHorizontalPadding = paddingLeft + paddingRight; + + // Clamp the blocks position to start at 0. Avoid negative X coords, + // to properly place the text inside the blocks. + if (rectLeft < 0) { + rectWidth += rectLeft; + rectLeft = 0; + } + + const textLeft = rectLeft + paddingLeft; + const textTop = rectTop + rectHeight / 2 + paddingTop; + const textAvailableWidth = rectWidth - totalHorizontalPadding; + + // Massage the text to fit inside a given width. This clamps the string + // at the end to avoid overflowing. + const fittedText = this._getFittedText(text, textAvailableWidth); + if (fittedText.length < 1) { + return; + } + + ctx.fillText(fittedText, textLeft, textTop); + }, + + /** + * Calculating text widths is necessary to trim the text inside the blocks + * while the scaling changes (e.g. via scrolling). This is very expensive, + * so maintain a cache of string contents to text widths. + */ + _textWidthsCache: null, + _overflowCharWidth: null, + _averageCharWidth: null, + + /** + * Gets the width of the specified text, for the current context state + * (font size, family etc.). + * + * @param string text + * The text to analyze. + * @return number + * The text width. + */ + _getTextWidth: function(text) { + const cachedWidth = this._textWidthsCache[text]; + if (cachedWidth) { + return cachedWidth; + } + const metrics = this._ctx.measureText(text); + return (this._textWidthsCache[text] = metrics.width); + }, + + /** + * Gets an approximate width of the specified text. This is much faster + * than `_getTextWidth`, but inexact. + * + * @param string text + * The text to analyze. + * @return number + * The approximate text width. + */ + _getTextWidthApprox: function(text) { + return text.length * this._averageCharWidth; + }, + + /** + * Gets the average letter width in the English alphabet, for the current + * context state (font size, family etc.). This provides a close enough + * value to use in `_getTextWidthApprox`. + * + * @return number + * The average letter width. + */ + _calcAverageCharWidth: function() { + let letterWidthsSum = 0; + // space + const start = 32; + // "z" + const end = 123; + + for (let i = start; i < end; i++) { + const char = String.fromCharCode(i); + letterWidthsSum += this._getTextWidth(char); + } + + return letterWidthsSum / (end - start); + }, + + /** + * Massage a text to fit inside a given width. This clamps the string + * at the end to avoid overflowing. + * + * @param string text + * The text to fit inside the given width. + * @param number maxWidth + * The available width for the given text. + * @return string + * The fitted text. + */ + _getFittedText: function(text, maxWidth) { + const textWidth = this._getTextWidth(text); + if (textWidth < maxWidth) { + return text; + } + if (this._overflowCharWidth > maxWidth) { + return ""; + } + for (let i = 1, len = text.length; i <= len; i++) { + const trimmedText = text.substring(0, len - i); + const trimmedWidth = + this._getTextWidthApprox(trimmedText) + this._overflowCharWidth; + if (trimmedWidth < maxWidth) { + return trimmedText + this.overflowChar; + } + } + return ""; + }, + + /** + * Listener for the "keydown" event on the graph's container. + */ + _onKeyDown: function(e) { + ViewHelpers.preventScrolling(e); + + const hasModifier = e.ctrlKey || e.shiftKey || e.altKey || e.metaKey; + + if (!hasModifier && !this._keysPressed[e.keyCode]) { + this._keysPressed[e.keyCode] = true; + this._userInputStack++; + this._shouldRedraw = true; + } + }, + + /** + * Listener for the "keyup" event on the graph's container. + */ + _onKeyUp: function(e) { + ViewHelpers.preventScrolling(e); + + if (this._keysPressed[e.keyCode]) { + this._keysPressed[e.keyCode] = false; + this._userInputStack--; + this._shouldRedraw = true; + } + }, + + /** + * Listener for the "mousemove" event on the graph's container. + */ + _onMouseMove: function(e) { + const { mouseX, mouseY } = this._getRelativeEventCoordinates(e); + + const canvasWidth = this._width; + + const selection = this._selection; + const selectionWidth = selection.end - selection.start; + const selectionScale = canvasWidth / selectionWidth; + + const horizDrag = this._selectionDragger; + const vertDrag = this._verticalOffsetDragger; + + // Avoid dragging both horizontally and vertically at the same time, + // as this doesn't feel natural. Based on a minimum distance, enable either + // one, and remember the drag direction to offset the mouse coords later. + if (!this._horizontalDragEnabled && !this._verticalDragEnabled) { + const horizDiff = Math.abs(horizDrag.origin - mouseX); + if (horizDiff > this.horizontalPanThreshold) { + this._horizontalDragDirection = Math.sign(horizDrag.origin - mouseX); + this._horizontalDragEnabled = true; + } + const vertDiff = Math.abs(vertDrag.origin - mouseY); + if (vertDiff > this.verticalPanThreshold) { + this._verticalDragDirection = Math.sign(vertDrag.origin - mouseY); + this._verticalDragEnabled = true; + } + } + + if (horizDrag.origin != null && this._horizontalDragEnabled) { + const relativeX = + mouseX + this._horizontalDragDirection * this.horizontalPanThreshold; + selection.start = + horizDrag.anchor.start + + (horizDrag.origin - relativeX) / selectionScale; + selection.end = + horizDrag.anchor.end + (horizDrag.origin - relativeX) / selectionScale; + this._normalizeSelectionBounds(); + this._shouldRedraw = true; + this.emit("selecting"); + } + + if (vertDrag.origin != null && this._verticalDragEnabled) { + const relativeY = + mouseY + this._verticalDragDirection * this.verticalPanThreshold; + this._verticalOffset = + vertDrag.anchor + (vertDrag.origin - relativeY) / this._pixelRatio; + this._normalizeVerticalOffset(); + this._shouldRedraw = true; + this.emit("panning-vertically"); + } + }, + + /** + * Listener for the "mousedown" event on the graph's container. + */ + _onMouseDown: function(e) { + const { mouseX, mouseY } = this._getRelativeEventCoordinates(e); + + this._selectionDragger.origin = mouseX; + this._selectionDragger.anchor.start = this._selection.start; + this._selectionDragger.anchor.end = this._selection.end; + + this._verticalOffsetDragger.origin = mouseY; + this._verticalOffsetDragger.anchor = this._verticalOffset; + + this._horizontalDragEnabled = false; + this._verticalDragEnabled = false; + + this._canvas.setAttribute("input", "adjusting-view-area"); + }, + + /** + * Listener for the "mouseup" event on the graph's container. + */ + _onMouseUp: function() { + this._selectionDragger.origin = null; + this._verticalOffsetDragger.origin = null; + this._horizontalDragEnabled = false; + this._horizontalDragDirection = 0; + this._verticalDragEnabled = false; + this._verticalDragDirection = 0; + this._canvas.removeAttribute("input"); + }, + + /** + * Listener for the "wheel" event on the graph's container. + */ + _onMouseWheel: function(e) { + const { mouseX } = this._getRelativeEventCoordinates(e); + + const canvasWidth = this._width; + + const selection = this._selection; + const selectionWidth = selection.end - selection.start; + const selectionScale = canvasWidth / selectionWidth; + + switch (e.axis) { + case e.VERTICAL_AXIS: { + const distFromStart = mouseX; + const distFromEnd = canvasWidth - mouseX; + const vector = + (e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY) / selectionScale; + selection.start -= distFromStart * vector; + selection.end += distFromEnd * vector; + break; + } + case e.HORIZONTAL_AXIS: { + const vector = + (e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY) / selectionScale; + selection.start += vector; + selection.end += vector; + break; + } + } + + this._normalizeSelectionBounds(); + this._shouldRedraw = true; + this.emit("selecting"); + }, + + /** + * Makes sure the start and end points of the current selection + * are withing the graph's visible bounds, and that they form a selection + * wider than the allowed minimum width. + */ + _normalizeSelectionBounds: function() { + const boundsStart = this._bounds.start; + const boundsEnd = this._bounds.end; + let selectionStart = this._selection.start; + let selectionEnd = this._selection.end; + + if (selectionStart < boundsStart) { + selectionStart = boundsStart; + } + if (selectionEnd < boundsStart) { + selectionStart = boundsStart; + selectionEnd = GRAPH_MIN_SELECTION_WIDTH; + } + if (selectionEnd > boundsEnd) { + selectionEnd = boundsEnd; + } + if (selectionStart > boundsEnd) { + selectionEnd = boundsEnd; + selectionStart = boundsEnd - GRAPH_MIN_SELECTION_WIDTH; + } + if (selectionEnd - selectionStart < GRAPH_MIN_SELECTION_WIDTH) { + const midPoint = (selectionStart + selectionEnd) / 2; + selectionStart = midPoint - GRAPH_MIN_SELECTION_WIDTH / 2; + selectionEnd = midPoint + GRAPH_MIN_SELECTION_WIDTH / 2; + } + + this._selection.start = selectionStart; + this._selection.end = selectionEnd; + }, + + /** + * Makes sure that the current vertical offset is within the allowed + * panning range. + */ + _normalizeVerticalOffset: function() { + this._verticalOffset = Math.max(this._verticalOffset, 0); + }, + + /** + * + * Finds the optimal tick interval between time markers in this graph. + * + * @param number dataScale + * @return number + */ + _findOptimalTickInterval: function(dataScale) { + let timingStep = TIMELINE_TICKS_MULTIPLE; + const spacingMin = TIMELINE_TICKS_SPACING_MIN * this._pixelRatio; + const maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS; + let numIters = 0; + + if (dataScale > spacingMin) { + return dataScale; + } + + while (true) { + const scaledStep = dataScale * timingStep; + if (++numIters > maxIters) { + return scaledStep; + } + if (scaledStep < spacingMin) { + timingStep <<= 1; + continue; + } + return scaledStep; + } + }, + + /** + * Gets the offset of this graph's container relative to the owner window. + * + * @return object + * The { left, top } offset. + */ + _getContainerOffset: function() { + let node = this._canvas; + let x = 0; + let y = 0; + + while ((node = node.offsetParent)) { + x += node.offsetLeft; + y += node.offsetTop; + } + + return { left: x, top: y }; + }, + + /** + * Given a MouseEvent, make it relative to this._canvas. + * @return object {mouseX,mouseY} + */ + _getRelativeEventCoordinates: function(e) { + // For ease of testing, testX and testY can be passed in as the event + // object. + if ("testX" in e && "testY" in e) { + return { + mouseX: e.testX * this._pixelRatio, + mouseY: e.testY * this._pixelRatio, + }; + } + + const offset = this._getContainerOffset(); + const mouseX = (e.clientX - offset.left) * this._pixelRatio; + const mouseY = (e.clientY - offset.top) * this._pixelRatio; + + return { mouseX, mouseY }; + }, + + /** + * Listener for the "resize" event on the graph's parent node. + */ + _onResize: function() { + if (this.hasData()) { + setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh); + } + }, +}; + +/** + * A collection of utility functions converting various data sources + * into a format drawable by the FlameGraph. + */ +var FlameGraphUtils = { + _cache: new WeakMap(), + + /** + * Create data suitable for use with FlameGraph from a profile's samples. + * Iterate the profile's samples and keep a moving window of stack traces. + * + * @param object thread + * The raw thread object received from the backend. + * @param object options + * Additional supported options, + * - boolean contentOnly [optional] + * - boolean invertTree [optional] + * - boolean flattenRecursion [optional] + * - string showIdleBlocks [optional] + * @return object + * Data source usable by FlameGraph. + */ + createFlameGraphDataFromThread: function(thread, options = {}, out = []) { + const cached = this._cache.get(thread); + if (cached) { + return cached; + } + + // 1. Create a map of colors to arrays, representing buckets of + // blocks inside the flame graph pyramid sharing the same style. + + const buckets = Array.from({ length: PALLETTE_SIZE }, () => []); + + // 2. Populate the buckets by iterating over every frame in every sample. + + const { samples, stackTable, frameTable, stringTable } = thread; + + const SAMPLE_STACK_SLOT = samples.schema.stack; + const SAMPLE_TIME_SLOT = samples.schema.time; + + const STACK_PREFIX_SLOT = stackTable.schema.prefix; + const STACK_FRAME_SLOT = stackTable.schema.frame; + + const getOrAddInflatedFrame = FrameUtils.getOrAddInflatedFrame; + + const inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable); + const labelCache = Object.create(null); + + const samplesData = samples.data; + const stacksData = stackTable.data; + + const flattenRecursion = options.flattenRecursion; + + // Reused objects. + const mutableFrameKeyOptions = { + contentOnly: options.contentOnly, + isRoot: false, + isLeaf: false, + isMetaCategoryOut: false, + }; + + // Take the timestamp of the first sample as prevTime. 0 is incorrect due + // to circular buffer wraparound. If wraparound happens, then the first + // sample will have an incorrect, large duration. + let prevTime = + samplesData.length > 0 ? samplesData[0][SAMPLE_TIME_SLOT] : 0; + const prevFrames = []; + const sampleFrames = []; + const sampleFrameKeys = []; + + for (let i = 1; i < samplesData.length; i++) { + const sample = samplesData[i]; + const time = sample[SAMPLE_TIME_SLOT]; + + let stackIndex = sample[SAMPLE_STACK_SLOT]; + let prevFrameKey; + + let stackDepth = 0; + + // Inflate the stack and keep a moving window of call stacks. + // + // For reference, see the similar block comment in + // ThreadNode.prototype._buildInverted. + // + // In a similar fashion to _buildInverted, frames are inflated on the + // fly while stackwalking the stackTable trie. The exact same frame key + // is computed in both _buildInverted and here. + // + // Unlike _buildInverted, which builds a call tree directly, the flame + // graph inflates the stack into an array, as it maintains a moving + // window of stacks over time. + // + // Like _buildInverted, the various filtering functions are also inlined + // into stack inflation loop. + while (stackIndex !== null) { + const stackEntry = stacksData[stackIndex]; + const frameIndex = stackEntry[STACK_FRAME_SLOT]; + + // Fetch the stack prefix (i.e. older frames) index. + stackIndex = stackEntry[STACK_PREFIX_SLOT]; + + // Inflate the frame. + const inflatedFrame = getOrAddInflatedFrame( + inflatedFrameCache, + frameIndex, + frameTable, + stringTable + ); + + mutableFrameKeyOptions.isRoot = stackIndex === null; + mutableFrameKeyOptions.isLeaf = stackDepth === 0; + let frameKey = inflatedFrame.getFrameKey(mutableFrameKeyOptions); + + // If not skipping the frame, add it to the current level. The (root) + // node isn't useful for flame graphs. + if (frameKey !== "" && frameKey !== "(root)") { + // If the frame is a meta category, use the category label. + if (mutableFrameKeyOptions.isMetaCategoryOut) { + const category = + CATEGORIES[frameKey] || CATEGORIES[CATEGORY_INDEX("other")]; + frameKey = category.label; + } + + sampleFrames[stackDepth] = inflatedFrame; + sampleFrameKeys[stackDepth] = frameKey; + + // If we shouldn't flatten the current frame into the previous one, + // increment the stack depth. + if (!flattenRecursion || frameKey !== prevFrameKey) { + stackDepth++; + } + + prevFrameKey = frameKey; + } + } + + // Uninvert frames in place if needed. + if (!options.invertTree) { + sampleFrames.length = stackDepth; + sampleFrames.reverse(); + sampleFrameKeys.length = stackDepth; + sampleFrameKeys.reverse(); + } + + // If no frames are available, add a pseudo "idle" block in between. + let isIdleFrame = false; + if (options.showIdleBlocks && stackDepth === 0) { + sampleFrames[0] = null; + sampleFrameKeys[0] = options.showIdleBlocks; + stackDepth = 1; + isIdleFrame = true; + } + + // Put each frame in a bucket. + for (let frameIndex = 0; frameIndex < stackDepth; frameIndex++) { + const key = sampleFrameKeys[frameIndex]; + const prevFrame = prevFrames[frameIndex]; + + // Frames at the same location and the same depth will be reused. + // If there is a block already created, change its width. + if (prevFrame && prevFrame.frameKey === key) { + prevFrame.width = time - prevFrame.startTime; + } else { + // Otherwise, create a new block for this frame at this depth, + // using a simple location based salt for picking a color. + const hash = this._getStringHash(key); + const bucket = buckets[hash % PALLETTE_SIZE]; + + let label; + if (isIdleFrame) { + label = key; + } else { + label = labelCache[key]; + if (!label) { + label = labelCache[key] = this._formatLabel( + key, + sampleFrames[frameIndex] + ); + } + } + + bucket.push( + (prevFrames[frameIndex] = { + startTime: prevTime, + frameKey: key, + x: prevTime, + y: frameIndex * FLAME_GRAPH_BLOCK_HEIGHT, + width: time - prevTime, + height: FLAME_GRAPH_BLOCK_HEIGHT, + text: label, + }) + ); + } + } + + // Previous frames at stack depths greater than the current sample's + // maximum need to be nullified. It's nonsensical to reuse them. + prevFrames.length = stackDepth; + prevTime = time; + } + + // 3. Convert the buckets into a data source usable by the FlameGraph. + // This is a simple conversion from a Map to an Array. + + for (let i = 0; i < buckets.length; i++) { + out.push({ color: COLOR_PALLETTE[i], blocks: buckets[i] }); + } + + this._cache.set(thread, out); + return out; + }, + + /** + * Clears the cached flame graph data created for the given source. + * @param any source + */ + removeFromCache: function(source) { + this._cache.delete(source); + }, + + /** + * Very dumb hashing of a string. Used to pick colors from a pallette. + * + * @param string input + * @return number + */ + _getStringHash: function(input) { + const STRING_HASH_PRIME1 = 7; + const STRING_HASH_PRIME2 = 31; + + let hash = STRING_HASH_PRIME1; + + for (let i = 0, len = input.length; i < len; i++) { + hash *= STRING_HASH_PRIME2; + hash += input.charCodeAt(i); + + if (hash > Number.MAX_SAFE_INTEGER / STRING_HASH_PRIME2) { + return hash; + } + } + + return hash; + }, + + /** + * Takes a frame key and a frame, and returns a string that should be + * displayed in its flame block. + * + * @param string key + * @param object frame + * @return string + */ + _formatLabel: function(key, frame) { + const { functionName, fileName, line } = FrameUtils.parseLocation( + key, + frame.line + ); + let label = FrameUtils.shouldDemangle(functionName) + ? demangle(functionName) + : functionName; + + if (fileName) { + label += ` (${fileName}${line != null ? ":" + line : ""})`; + } + + return label; + }, +}; + +exports.FlameGraph = FlameGraph; +exports.FlameGraphUtils = FlameGraphUtils; +exports.PALLETTE_SIZE = PALLETTE_SIZE; +exports.FLAME_GRAPH_BLOCK_HEIGHT = FLAME_GRAPH_BLOCK_HEIGHT; +exports.FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE; +exports.FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY; diff --git a/devtools/client/shared/widgets/Graphs.js b/devtools/client/shared/widgets/Graphs.js new file mode 100644 index 0000000000..4c8615b471 --- /dev/null +++ b/devtools/client/shared/widgets/Graphs.js @@ -0,0 +1,1465 @@ +/* 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 { + setNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); +const { getCurrentZoom } = require("devtools/shared/layout/utils"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { DOMHelpers } = require("devtools/shared/dom-helpers"); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +loader.lazyImporter( + this, + "DevToolsWorker", + "resource://devtools/shared/worker/worker.js" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const GRAPH_SRC = "chrome://devtools/content/shared/widgets/graphs-frame.xhtml"; +const WORKER_URL = "resource://devtools/client/shared/widgets/GraphsWorker.js"; + +// Generic constants. + +const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms +const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075; +const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1; +const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10; // px + +const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4; // px +const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10; // px +const GRAPH_MAX_SELECTION_LEFT_PADDING = 1; // px +const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1; // px + +const GRAPH_REGION_LINE_WIDTH = 1; // px +const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)"; + +const GRAPH_STRIPE_PATTERN_WIDTH = 16; // px +const GRAPH_STRIPE_PATTERN_HEIGHT = 16; // px +const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 2; // px +const GRAPH_STRIPE_PATTERN_LINE_SPACING = 4; // px + +/** + * Small data primitives for all graphs. + */ +this.GraphCursor = function() { + this.x = null; + this.y = null; +}; + +this.GraphArea = function() { + this.start = null; + this.end = null; +}; + +this.GraphAreaDragger = function(anchor = new GraphArea()) { + this.origin = null; + this.anchor = anchor; +}; + +this.GraphAreaResizer = function() { + this.margin = null; +}; + +/** + * Base class for all graphs using a canvas to render the data source. Handles + * frame creation, data source, selection bounds, cursor position, etc. + * + * Language: + * - The "data" represents the values used when building the graph. + * Its specific format is defined by the inheriting classes. + * + * - A "cursor" is the cliphead position across the X axis of the graph. + * + * - A "selection" is defined by a "start" and an "end" value and + * represents the selected bounds in the graph. + * + * - A "region" is a highlighted area in the graph, also defined by a + * "start" and an "end" value, but distinct from the "selection". It is + * simply used to highlight important regions in the data. + * + * Instances of this class are EventEmitters with the following events: + * - "ready": when the container iframe and canvas are created. + * - "selecting": when the selection is set or changed. + * - "deselecting": when the selection is dropped. + * + * @param Node parent + * The parent node holding the graph. + * @param string name + * The graph type, used for setting the correct class names. + * Currently supported: "line-graph" only. + * @param number sharpness [optional] + * Defaults to the current device pixel ratio. + */ +this.AbstractCanvasGraph = function(parent, name, sharpness) { + EventEmitter.decorate(this); + + this._parent = parent; + this._uid = "canvas-graph-" + Date.now(); + this._renderTargets = new Map(); + + this._ready = new Promise(resolve => { + AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => { + this._iframe = iframe; + this._window = iframe.contentWindow; + this._topWindow = DevToolsUtils.getTopWindow(this._window); + this._document = iframe.contentDocument; + this._pixelRatio = sharpness || this._window.devicePixelRatio; + + const container = (this._container = this._document.getElementById( + "graph-container" + )); + container.className = name + "-widget-container graph-widget-container"; + + const canvas = (this._canvas = this._document.getElementById( + "graph-canvas" + )); + canvas.className = name + "-widget-canvas graph-widget-canvas"; + + const bounds = parent.getBoundingClientRect(); + bounds.width = this.fixedWidth || bounds.width; + bounds.height = this.fixedHeight || bounds.height; + iframe.setAttribute("width", bounds.width); + iframe.setAttribute("height", bounds.height); + + this._width = canvas.width = bounds.width * this._pixelRatio; + this._height = canvas.height = bounds.height * this._pixelRatio; + this._ctx = canvas.getContext("2d"); + this._ctx.imageSmoothingEnabled = false; + + this._cursor = new GraphCursor(); + this._selection = new GraphArea(); + this._selectionDragger = new GraphAreaDragger(); + this._selectionResizer = new GraphAreaResizer(); + this._isMouseActive = false; + + this._onAnimationFrame = this._onAnimationFrame.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseUp = this._onMouseUp.bind(this); + this._onMouseWheel = this._onMouseWheel.bind(this); + this._onMouseOut = this._onMouseOut.bind(this); + this._onResize = this._onResize.bind(this); + this.refresh = this.refresh.bind(this); + + this._window.addEventListener("mousemove", this._onMouseMove); + this._window.addEventListener("mousedown", this._onMouseDown); + this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel); + this._window.addEventListener("mouseout", this._onMouseOut); + + const ownerWindow = this._parent.ownerDocument.defaultView; + ownerWindow.addEventListener("resize", this._onResize); + + this._animationId = this._window.requestAnimationFrame( + this._onAnimationFrame + ); + + resolve(this); + this.emit("ready", this); + }); + }); +}; + +AbstractCanvasGraph.prototype = { + /** + * Read-only width and height of the canvas. + * @return number + */ + get width() { + return this._width; + }, + get height() { + return this._height; + }, + + /** + * Return true if the mouse is actively messing with the selection, false + * otherwise. + */ + get isMouseActive() { + return this._isMouseActive; + }, + + /** + * Returns a promise resolved once this graph is ready to receive data. + */ + ready: function() { + return this._ready; + }, + + /** + * Destroys this graph. + */ + async destroy() { + await this.ready(); + + this._topWindow.removeEventListener("mousemove", this._onMouseMove); + this._topWindow.removeEventListener("mouseup", this._onMouseUp); + this._window.removeEventListener("mousemove", this._onMouseMove); + this._window.removeEventListener("mousedown", this._onMouseDown); + this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel); + this._window.removeEventListener("mouseout", this._onMouseOut); + + const ownerWindow = this._parent.ownerDocument.defaultView; + if (ownerWindow) { + ownerWindow.removeEventListener("resize", this._onResize); + } + + this._window.cancelAnimationFrame(this._animationId); + this._iframe.remove(); + + this._cursor = null; + this._selection = null; + this._selectionDragger = null; + this._selectionResizer = null; + + this._data = null; + this._mask = null; + this._maskArgs = null; + this._regions = null; + + this._cachedBackgroundImage = null; + this._cachedGraphImage = null; + this._cachedMaskImage = null; + this._renderTargets.clear(); + gCachedStripePattern.clear(); + + this.emit("destroyed"); + }, + + /** + * Rendering options. Subclasses should override these. + */ + clipheadLineWidth: 1, + clipheadLineColor: "transparent", + selectionLineWidth: 1, + selectionLineColor: "transparent", + selectionBackgroundColor: "transparent", + selectionStripesColor: "transparent", + regionBackgroundColor: "transparent", + regionStripesColor: "transparent", + + /** + * Makes sure the canvas graph is of the specified width or height, and + * doesn't flex to fit all the available space. + */ + fixedWidth: null, + fixedHeight: null, + + /** + * Optionally builds and caches a background image for this graph. + * Inheriting classes may override this method. + */ + buildBackgroundImage: function() { + return null; + }, + + /** + * Builds and caches a graph image, based on the data source supplied + * in `setData`. The graph image is not rebuilt on each frame, but + * only when the data source changes. + */ + buildGraphImage: function() { + const error = "This method needs to be implemented by inheriting classes."; + throw new Error(error); + }, + + /** + * Optionally builds and caches a mask image for this graph, composited + * over the data image created via `buildGraphImage`. Inheriting classes + * may override this method. + */ + buildMaskImage: function() { + return null; + }, + + /** + * When setting the data source, the coordinates and values may be + * stretched or squeezed on the X/Y axis, to fit into the available space. + */ + dataScaleX: 1, + dataScaleY: 1, + + /** + * Sets the data source for this graph. + * + * @param object data + * The data source. The actual format is specified by subclasses. + */ + setData: function(data) { + this._data = data; + this._cachedBackgroundImage = this.buildBackgroundImage(); + this._cachedGraphImage = this.buildGraphImage(); + this._shouldRedraw = true; + }, + + /** + * Same as `setData`, but waits for this graph to finish initializing first. + * + * @param object data + * The data source. The actual format is specified by subclasses. + * @return promise + * A promise resolved once the data is set. + */ + async setDataWhenReady(data) { + await this.ready(); + this.setData(data); + }, + + /** + * Adds a mask to this graph. + * + * @param any mask, options + * See `buildMaskImage` in inheriting classes for the required args. + */ + setMask: function(mask, ...options) { + this._mask = mask; + this._maskArgs = [mask, ...options]; + this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs); + this._shouldRedraw = true; + }, + + /** + * Adds regions to this graph. + * + * See the "Language" section in the constructor documentation + * for details about what "regions" represent. + * + * @param array regions + * A list of { start, end } values. + */ + setRegions: function(regions) { + if (!this._cachedGraphImage) { + throw new Error( + "Can't highlight regions on a graph with " + "no data displayed." + ); + } + if (this._regions) { + throw new Error("Regions were already highlighted on the graph."); + } + this._regions = regions.map(e => ({ + start: e.start * this.dataScaleX, + end: e.end * this.dataScaleX, + })); + this._bakeRegions(this._regions, this._cachedGraphImage); + this._shouldRedraw = true; + }, + + /** + * Gets whether or not this graph has a data source. + * @return boolean + */ + hasData: function() { + return !!this._data; + }, + + /** + * Gets whether or not this graph has any mask applied. + * @return boolean + */ + hasMask: function() { + return !!this._mask; + }, + + /** + * Gets whether or not this graph has any regions. + * @return boolean + */ + hasRegions: function() { + return !!this._regions; + }, + + /** + * Sets the selection bounds. + * Use `dropSelection` to remove the selection. + * + * If the bounds aren't different, no "selection" event is emitted. + * + * See the "Language" section in the constructor documentation + * for details about what a "selection" represents. + * + * @param object selection + * The selection's { start, end } values. + */ + setSelection: function(selection) { + if (!selection || selection.start == null || selection.end == null) { + throw new Error("Invalid selection coordinates"); + } + if (!this.isSelectionDifferent(selection)) { + return; + } + this._selection.start = selection.start; + this._selection.end = selection.end; + this._shouldRedraw = true; + this.emit("selecting"); + }, + + /** + * Gets the selection bounds. + * If there's no selection, the bounds have null values. + * + * @return object + * The selection's { start, end } values. + */ + getSelection: function() { + if (this.hasSelection()) { + return { start: this._selection.start, end: this._selection.end }; + } + if (this.hasSelectionInProgress()) { + return { start: this._selection.start, end: this._cursor.x }; + } + return { start: null, end: null }; + }, + + /** + * Sets the selection bounds, scaled to correlate with the data source ranges, + * such that a [0, max width] selection maps to [first value, last value]. + * + * @param object selection + * The selection's { start, end } values. + * @param object { mapStart, mapEnd } mapping [optional] + * Invoked when retrieving the numbers in the data source representing + * the first and last values, on the X axis. + */ + setMappedSelection: function(selection, mapping = {}) { + if (!this.hasData()) { + throw new Error( + "A data source is necessary for retrieving " + "a mapped selection." + ); + } + if (!selection || selection.start == null || selection.end == null) { + throw new Error("Invalid selection coordinates"); + } + + const { mapStart, mapEnd } = mapping; + const startTime = (mapStart || (e => e.delta))(this._data[0]); + const endTime = (mapEnd || (e => e.delta))( + this._data[this._data.length - 1] + ); + + // The selection's start and end values are not guaranteed to be ascending. + // Also make sure that the selection bounds fit inside the data bounds. + let min = Math.max(Math.min(selection.start, selection.end), startTime); + let max = Math.min(Math.max(selection.start, selection.end), endTime); + min = map(min, startTime, endTime, 0, this._width); + max = map(max, startTime, endTime, 0, this._width); + + this.setSelection({ start: min, end: max }); + }, + + /** + * Gets the selection bounds, scaled to correlate with the data source ranges, + * such that a [0, max width] selection maps to [first value, last value]. + * + * @param object { mapStart, mapEnd } mapping [optional] + * Invoked when retrieving the numbers in the data source representing + * the first and last values, on the X axis. + * @return object + * The mapped selection's { min, max } values. + */ + getMappedSelection: function(mapping = {}) { + if (!this.hasData()) { + throw new Error( + "A data source is necessary for retrieving a " + "mapped selection." + ); + } + if (!this.hasSelection() && !this.hasSelectionInProgress()) { + return { min: null, max: null }; + } + + const { mapStart, mapEnd } = mapping; + const startTime = (mapStart || (e => e.delta))(this._data[0]); + const endTime = (mapEnd || (e => e.delta))( + this._data[this._data.length - 1] + ); + + // The selection's start and end values are not guaranteed to be ascending. + // This can happen, for example, when click & dragging from right to left. + // Also make sure that the selection bounds fit inside the canvas bounds. + const selection = this.getSelection(); + let min = Math.max(Math.min(selection.start, selection.end), 0); + let max = Math.min(Math.max(selection.start, selection.end), this._width); + min = map(min, 0, this._width, startTime, endTime); + max = map(max, 0, this._width, startTime, endTime); + + return { min: min, max: max }; + }, + + /** + * Removes the selection. + */ + dropSelection: function() { + if (!this.hasSelection() && !this.hasSelectionInProgress()) { + return; + } + this._selection.start = null; + this._selection.end = null; + this._shouldRedraw = true; + this.emit("deselecting"); + }, + + /** + * Gets whether or not this graph has a selection. + * @return boolean + */ + hasSelection: function() { + return ( + this._selection && + this._selection.start != null && + this._selection.end != null + ); + }, + + /** + * Gets whether or not a selection is currently being made, for example + * via a click+drag operation. + * @return boolean + */ + hasSelectionInProgress: function() { + return ( + this._selection && + this._selection.start != null && + this._selection.end == null + ); + }, + + /** + * Specifies whether or not mouse selection is allowed. + * @type boolean + */ + selectionEnabled: true, + + /** + * Sets the selection bounds. + * Use `dropCursor` to hide the cursor. + * + * @param object cursor + * The cursor's { x, y } position. + */ + setCursor: function(cursor) { + if (!cursor || cursor.x == null || cursor.y == null) { + throw new Error("Invalid cursor coordinates"); + } + if (!this.isCursorDifferent(cursor)) { + return; + } + this._cursor.x = cursor.x; + this._cursor.y = cursor.y; + this._shouldRedraw = true; + }, + + /** + * Gets the cursor position. + * If there's no cursor, the position has null values. + * + * @return object + * The cursor's { x, y } values. + */ + getCursor: function() { + return { x: this._cursor.x, y: this._cursor.y }; + }, + + /** + * Hides the cursor. + */ + dropCursor: function() { + if (!this.hasCursor()) { + return; + } + this._cursor.x = null; + this._cursor.y = null; + this._shouldRedraw = true; + }, + + /** + * Gets whether or not this graph has a visible cursor. + * @return boolean + */ + hasCursor: function() { + return this._cursor && this._cursor.x != null; + }, + + /** + * Specifies if this graph's selection is different from another one. + * + * @param object other + * The other graph's selection, as { start, end } values. + */ + isSelectionDifferent: function(other) { + if (!other) { + return true; + } + const current = this.getSelection(); + return current.start != other.start || current.end != other.end; + }, + + /** + * Specifies if this graph's cursor is different from another one. + * + * @param object other + * The other graph's position, as { x, y } values. + */ + isCursorDifferent: function(other) { + if (!other) { + return true; + } + const current = this.getCursor(); + return current.x != other.x || current.y != other.y; + }, + + /** + * Gets the width of the current selection. + * If no selection is available, 0 is returned. + * + * @return number + * The selection width. + */ + getSelectionWidth: function() { + const selection = this.getSelection(); + return Math.abs(selection.start - selection.end); + }, + + /** + * Gets the currently hovered region, if any. + * If no region is currently hovered, null is returned. + * + * @return object + * The hovered region, as { start, end } values. + */ + getHoveredRegion: function() { + if (!this.hasRegions() || !this.hasCursor()) { + return null; + } + const { x } = this._cursor; + return this._regions.find( + ({ start, end }) => + (start < end && start < x && end > x) || + (start > end && end < x && start > x) + ); + }, + + /** + * Updates this graph to reflect the new dimensions of the parent node. + * + * @param boolean options.force + * Force redrawing everything + */ + refresh: function(options = {}) { + const bounds = this._parent.getBoundingClientRect(); + const newWidth = this.fixedWidth || bounds.width; + const newHeight = this.fixedHeight || bounds.height; + + // Prevent redrawing everything if the graph's width & height won't change, + // except if force=true. + if ( + !options.force && + this._width == newWidth * this._pixelRatio && + this._height == newHeight * this._pixelRatio + ) { + this.emit("refresh-cancelled"); + return; + } + + // Handle a changed size by mapping the old selection to the new width + if (this._width && newWidth && this.hasSelection()) { + const ratio = this._width / (newWidth * this._pixelRatio); + this._selection.start = Math.round(this._selection.start / ratio); + this._selection.end = Math.round(this._selection.end / ratio); + } + + bounds.width = newWidth; + bounds.height = newHeight; + this._iframe.setAttribute("width", bounds.width); + this._iframe.setAttribute("height", bounds.height); + this._width = this._canvas.width = bounds.width * this._pixelRatio; + this._height = this._canvas.height = bounds.height * this._pixelRatio; + + if (this.hasData()) { + this._cachedBackgroundImage = this.buildBackgroundImage(); + this._cachedGraphImage = this.buildGraphImage(); + } + if (this.hasMask()) { + this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs); + } + if (this.hasRegions()) { + this._bakeRegions(this._regions, this._cachedGraphImage); + } + + this._shouldRedraw = true; + this.emit("refresh"); + }, + + /** + * Gets a canvas with the specified name, for this graph. + * + * If it doesn't exist yet, it will be created, otherwise the cached instance + * will be cleared and returned. + * + * @param string name + * The canvas name. + * @param number width, height [optional] + * A custom width and height for the canvas. Defaults to this graph's + * container canvas width and height. + */ + _getNamedCanvas: function(name, width = this._width, height = this._height) { + const cachedRenderTarget = this._renderTargets.get(name); + if (cachedRenderTarget) { + const { canvas, ctx } = cachedRenderTarget; + canvas.width = width; + canvas.height = height; + ctx.clearRect(0, 0, width, height); + return cachedRenderTarget; + } + + const canvas = this._document.createElementNS(HTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + canvas.width = width; + canvas.height = height; + + const renderTarget = { canvas: canvas, ctx: ctx }; + this._renderTargets.set(name, renderTarget); + return renderTarget; + }, + + /** + * The contents of this graph are redrawn only when something changed, + * like the data source, or the selection bounds etc. This flag tracks + * if the rendering is "dirty" and needs to be refreshed. + */ + _shouldRedraw: false, + + /** + * Animation frame callback, invoked on each tick of the refresh driver. + */ + _onAnimationFrame: function() { + this._animationId = this._window.requestAnimationFrame( + this._onAnimationFrame + ); + this._drawWidget(); + }, + + /** + * Redraws the widget when necessary. The actual graph is not refreshed + * every time this function is called, only the cliphead, selection etc. + */ + _drawWidget: function() { + if (!this._shouldRedraw) { + return; + } + const ctx = this._ctx; + ctx.clearRect(0, 0, this._width, this._height); + + if (this._cachedGraphImage) { + ctx.drawImage(this._cachedGraphImage, 0, 0, this._width, this._height); + } + if (this._cachedMaskImage) { + ctx.globalCompositeOperation = "destination-out"; + ctx.drawImage(this._cachedMaskImage, 0, 0, this._width, this._height); + } + if (this._cachedBackgroundImage) { + ctx.globalCompositeOperation = "destination-over"; + ctx.drawImage( + this._cachedBackgroundImage, + 0, + 0, + this._width, + this._height + ); + } + + // Revert to the original global composition operation. + if (this._cachedMaskImage || this._cachedBackgroundImage) { + ctx.globalCompositeOperation = "source-over"; + } + + if (this.hasCursor()) { + this._drawCliphead(); + } + if (this.hasSelection() || this.hasSelectionInProgress()) { + this._drawSelection(); + } + + this._shouldRedraw = false; + }, + + /** + * Draws the cliphead, if available and necessary. + */ + _drawCliphead: function() { + if ( + this._isHoveringSelectionContentsOrBoundaries() || + this._isHoveringRegion() + ) { + return; + } + + const ctx = this._ctx; + ctx.lineWidth = this.clipheadLineWidth; + ctx.strokeStyle = this.clipheadLineColor; + ctx.beginPath(); + ctx.moveTo(this._cursor.x, 0); + ctx.lineTo(this._cursor.x, this._height); + ctx.stroke(); + }, + + /** + * Draws the selection, if available and necessary. + */ + _drawSelection: function() { + const { start, end } = this.getSelection(); + const input = this._canvas.getAttribute("input"); + + const ctx = this._ctx; + ctx.strokeStyle = this.selectionLineColor; + + // Fill selection. + + const pattern = AbstractCanvasGraph.getStripePattern({ + ownerDocument: this._document, + backgroundColor: this.selectionBackgroundColor, + stripesColor: this.selectionStripesColor, + }); + ctx.fillStyle = pattern; + const rectStart = Math.min(this._width, Math.max(0, start)); + const rectEnd = Math.min(this._width, Math.max(0, end)); + ctx.fillRect(rectStart, 0, rectEnd - rectStart, this._height); + + // Draw left boundary. + + if (input == "hovering-selection-start-boundary") { + ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH; + } else { + ctx.lineWidth = this.clipheadLineWidth; + } + ctx.beginPath(); + ctx.moveTo(start, 0); + ctx.lineTo(start, this._height); + ctx.stroke(); + + // Draw right boundary. + + if (input == "hovering-selection-end-boundary") { + ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH; + } else { + ctx.lineWidth = this.clipheadLineWidth; + } + ctx.beginPath(); + ctx.moveTo(end, this._height); + ctx.lineTo(end, 0); + ctx.stroke(); + }, + + /** + * Draws regions into the cached graph image, created via `buildGraphImage`. + * Called when new regions are set. + */ + _bakeRegions: function(regions, destination) { + const ctx = destination.getContext("2d"); + + const pattern = AbstractCanvasGraph.getStripePattern({ + ownerDocument: this._document, + backgroundColor: this.regionBackgroundColor, + stripesColor: this.regionStripesColor, + }); + ctx.fillStyle = pattern; + ctx.strokeStyle = GRAPH_REGION_LINE_COLOR; + ctx.lineWidth = GRAPH_REGION_LINE_WIDTH; + + const y = -GRAPH_REGION_LINE_WIDTH; + const height = this._height + GRAPH_REGION_LINE_WIDTH; + + for (const { start, end } of regions) { + const x = start; + const width = end - start; + ctx.fillRect(x, y, width, height); + ctx.strokeRect(x, y, width, height); + } + }, + + /** + * Checks whether the start handle of the selection is hovered. + * @return boolean + */ + _isHoveringStartBoundary: function() { + if (!this.hasSelection() || !this.hasCursor()) { + return false; + } + const { x } = this._cursor; + const { start } = this._selection; + const threshold = + GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio; + return Math.abs(start - x) < threshold; + }, + + /** + * Checks whether the end handle of the selection is hovered. + * @return boolean + */ + _isHoveringEndBoundary: function() { + if (!this.hasSelection() || !this.hasCursor()) { + return false; + } + const { x } = this._cursor; + const { end } = this._selection; + const threshold = + GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio; + return Math.abs(end - x) < threshold; + }, + + /** + * Checks whether the selection is hovered. + * @return boolean + */ + _isHoveringSelectionContents: function() { + if (!this.hasSelection() || !this.hasCursor()) { + return false; + } + const { x } = this._cursor; + const { start, end } = this._selection; + return ( + (start < end && start < x && end > x) || + (start > end && end < x && start > x) + ); + }, + + /** + * Checks whether the selection or its handles are hovered. + * @return boolean + */ + _isHoveringSelectionContentsOrBoundaries: function() { + return ( + this._isHoveringSelectionContents() || + this._isHoveringStartBoundary() || + this._isHoveringEndBoundary() + ); + }, + + /** + * Checks whether a region is hovered. + * @return boolean + */ + _isHoveringRegion: function() { + return !!this.getHoveredRegion(); + }, + + /** + * Given a MouseEvent, make it relative to this._canvas. + * @return object {mouseX,mouseY} + */ + _getRelativeEventCoordinates: function(e) { + // For ease of testing, testX and testY can be passed in as the event + // object. If so, just return this. + if ("testX" in e && "testY" in e) { + return { + mouseX: e.testX * this._pixelRatio, + mouseY: e.testY * this._pixelRatio, + }; + } + + // This method is concerned with converting mouse event coordinates from + // "screen space" to "local space" (in other words, relative to this + // canvas's position, thus (0,0) would correspond to the upper left corner). + // We can't simply use `clientX` and `clientY` because the given MouseEvent + // object may be generated from events coming from other DOM nodes. + // Therefore, we need to get a bounding box relative to the top document and + // do some simple math to convert screen coords into local coords. + // However, `getBoxQuads` may be a very costly operation depending on the + // complexity of the "outside world" DOM, so cache the results until we + // suspect they might change (e.g. on a resize). + // It'd sure be nice if we could use `getBoundsWithoutFlushing`, but it's + // not taking the document zoom factor into consideration consistently. + if (!this._boundingBox || this._maybeDirtyBoundingBox) { + const topDocument = this._topWindow.document; + const boxQuad = this._canvas.getBoxQuads({ + relativeTo: topDocument, + createFramesForSuppressedWhitespace: false, + })[0]; + this._boundingBox = boxQuad; + this._maybeDirtyBoundingBox = false; + } + + const bb = this._boundingBox; + const x = e.screenX - this._topWindow.screenX - bb.p1.x; + const y = e.screenY - this._topWindow.screenY - bb.p1.y; + + // Don't allow the event coordinates to be bigger than the canvas + // or less than 0. + const maxX = bb.p2.x - bb.p1.x; + const maxY = bb.p3.y - bb.p1.y; + let mouseX = Math.max(0, Math.min(x, maxX)) * this._pixelRatio; + let mouseY = Math.max(0, Math.min(y, maxY)) * this._pixelRatio; + + // The coordinates need to be modified with the current zoom level + // to prevent them from being wrong. + const zoom = getCurrentZoom(this._canvas); + mouseX /= zoom; + mouseY /= zoom; + + return { mouseX, mouseY }; + }, + + /** + * Listener for the "mousemove" event on the graph's container. + */ + _onMouseMove: function(e) { + const resizer = this._selectionResizer; + const dragger = this._selectionDragger; + + // Need to stop propagation here, since this function can be bound + // to both this._window and this._topWindow. It's only attached to + // this._topWindow during a drag event. Null check here since tests + // don't pass this method into the event object. + if (e.stopPropagation && this._isMouseActive) { + e.stopPropagation(); + } + + // If a mouseup happened outside the window and the current operation + // is causing the selection to change, then end it. + if ( + e.buttons == 0 && + (this.hasSelectionInProgress() || + resizer.margin != null || + dragger.origin != null) + ) { + this._onMouseUp(); + return; + } + + const { mouseX, mouseY } = this._getRelativeEventCoordinates(e); + this._cursor.x = mouseX; + this._cursor.y = mouseY; + + if (resizer.margin != null) { + this._selection[resizer.margin] = mouseX; + this._shouldRedraw = true; + this.emit("selecting"); + return; + } + + if (dragger.origin != null) { + this._selection.start = dragger.anchor.start - dragger.origin + mouseX; + this._selection.end = dragger.anchor.end - dragger.origin + mouseX; + this._shouldRedraw = true; + this.emit("selecting"); + return; + } + + if (this.hasSelectionInProgress()) { + this._shouldRedraw = true; + this.emit("selecting"); + return; + } + + if (this.hasSelection()) { + if (this._isHoveringStartBoundary()) { + this._canvas.setAttribute("input", "hovering-selection-start-boundary"); + this._shouldRedraw = true; + return; + } + if (this._isHoveringEndBoundary()) { + this._canvas.setAttribute("input", "hovering-selection-end-boundary"); + this._shouldRedraw = true; + return; + } + if (this._isHoveringSelectionContents()) { + this._canvas.setAttribute("input", "hovering-selection-contents"); + this._shouldRedraw = true; + return; + } + } + + const region = this.getHoveredRegion(); + if (region) { + this._canvas.setAttribute("input", "hovering-region"); + } else { + this._canvas.setAttribute("input", "hovering-background"); + } + + this._shouldRedraw = true; + }, + + /** + * Listener for the "mousedown" event on the graph's container. + */ + _onMouseDown: function(e) { + this._isMouseActive = true; + const { mouseX } = this._getRelativeEventCoordinates(e); + + switch (this._canvas.getAttribute("input")) { + case "hovering-background": + case "hovering-region": + if (!this.selectionEnabled) { + break; + } + this._selection.start = mouseX; + this._selection.end = null; + this.emit("selecting"); + break; + + case "hovering-selection-start-boundary": + this._selectionResizer.margin = "start"; + break; + + case "hovering-selection-end-boundary": + this._selectionResizer.margin = "end"; + break; + + case "hovering-selection-contents": + this._selectionDragger.origin = mouseX; + this._selectionDragger.anchor.start = this._selection.start; + this._selectionDragger.anchor.end = this._selection.end; + this._canvas.setAttribute("input", "dragging-selection-contents"); + break; + } + + // During a drag, bind to the top level window so that mouse movement + // outside of this frame will still work. + this._topWindow.addEventListener("mousemove", this._onMouseMove); + this._topWindow.addEventListener("mouseup", this._onMouseUp); + + this._shouldRedraw = true; + this.emit("mousedown"); + }, + + /** + * Listener for the "mouseup" event on the graph's container. + */ + _onMouseUp: function() { + this._isMouseActive = false; + switch (this._canvas.getAttribute("input")) { + case "hovering-background": + case "hovering-region": + if (!this.selectionEnabled) { + break; + } + if (this.getSelectionWidth() < 1) { + const region = this.getHoveredRegion(); + if (region) { + this._selection.start = region.start; + this._selection.end = region.end; + this.emit("selecting"); + } else { + this._selection.start = null; + this._selection.end = null; + this.emit("deselecting"); + } + } else { + this._selection.end = this._cursor.x; + this.emit("selecting"); + } + break; + + case "hovering-selection-start-boundary": + case "hovering-selection-end-boundary": + this._selectionResizer.margin = null; + break; + + case "dragging-selection-contents": + this._selectionDragger.origin = null; + this._canvas.setAttribute("input", "hovering-selection-contents"); + break; + } + + // No longer dragging, no need to bind to the top level window. + this._topWindow.removeEventListener("mousemove", this._onMouseMove); + this._topWindow.removeEventListener("mouseup", this._onMouseUp); + + this._shouldRedraw = true; + this.emit("mouseup"); + }, + + /** + * Listener for the "wheel" event on the graph's container. + */ + _onMouseWheel: function(e) { + if (!this.hasSelection()) { + return; + } + + const { mouseX } = this._getRelativeEventCoordinates(e); + const focusX = mouseX; + + const selection = this._selection; + let vector = 0; + + // If the selection is hovered, "zoom" towards or away the cursor, + // by shrinking or growing the selection. + if (this._isHoveringSelectionContentsOrBoundaries()) { + const distStart = selection.start - focusX; + const distEnd = selection.end - focusX; + vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY; + selection.start = selection.start + distStart * vector; + selection.end = selection.end + distEnd * vector; + } else { + // Otherwise, simply pan the selection towards the left or right. + let direction = 0; + if (focusX > selection.end) { + direction = Math.sign(focusX - selection.end); + } else if (focusX < selection.start) { + direction = Math.sign(focusX - selection.start); + } + vector = direction * e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY; + selection.start -= vector; + selection.end -= vector; + } + + // Make sure the selection bounds are still comfortably inside the + // graph's bounds when zooming out, to keep the margin handles accessible. + + const minStart = GRAPH_MAX_SELECTION_LEFT_PADDING; + const maxEnd = this._width - GRAPH_MAX_SELECTION_RIGHT_PADDING; + if (selection.start < minStart) { + selection.start = minStart; + } + if (selection.start > maxEnd) { + selection.start = maxEnd; + } + if (selection.end < minStart) { + selection.end = minStart; + } + if (selection.end > maxEnd) { + selection.end = maxEnd; + } + + // Make sure the selection doesn't get too narrow when zooming in. + + const thickness = Math.abs(selection.start - selection.end); + if (thickness < GRAPH_WHEEL_MIN_SELECTION_WIDTH) { + const midPoint = (selection.start + selection.end) / 2; + selection.start = midPoint - GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2; + selection.end = midPoint + GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2; + } + + this._shouldRedraw = true; + this.emit("selecting"); + this.emit("scroll"); + }, + + /** + * Listener for the "mouseout" event on the graph's container. + * Clear any active cursors if a drag isn't happening. + */ + _onMouseOut: function(e) { + if (!this._isMouseActive) { + this._cursor.x = null; + this._cursor.y = null; + this._canvas.removeAttribute("input"); + this._shouldRedraw = true; + } + }, + + /** + * Listener for the "resize" event on the graph's parent node. + */ + _onResize: function() { + if (this.hasData()) { + // The assumption is that resize events may change the outside world + // layout in a way that affects this graph's bounding box location + // relative to the top window's document. Graphs aren't currently + // (or ever) expected to move around on their own. + this._maybeDirtyBoundingBox = true; + setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh); + } + }, +}; + +// Helper functions. + +/** + * Creates an iframe element with the provided source URL, appends it to + * the specified node and invokes the callback once the content is loaded. + * + * @param string url + * The desired source URL for the iframe. + * @param Node parent + * The desired parent node for the iframe. + * @param function callback + * Invoked once the content is loaded, with the iframe as an argument. + */ +AbstractCanvasGraph.createIframe = function(url, parent, callback) { + const iframe = parent.ownerDocument.createElementNS(HTML_NS, "iframe"); + + // Setting 100% width on the frame and flex on the parent allows the graph + // to properly shrink when the window is resized to be smaller. + iframe.setAttribute("frameborder", "0"); + iframe.style.width = "100%"; + iframe.style.minWidth = "50px"; + + parent.style.display = "flex"; + parent.appendChild(iframe); + + // Use DOMHelpers to wait for the frame load. DOMHelpers relies on chromeEventHandler + // so this will still work if DevTools are loaded in a content frame. + DOMHelpers.onceDOMReady(iframe.contentWindow, function() { + callback(iframe); + }); + + iframe.src = url; +}; + +/** + * Gets a striped pattern used as a background in selections and regions. + * + * @param object data + * The following properties are required: + * - ownerDocument: the nsIDocumentElement owning the canvas + * - backgroundColor: a string representing the fill style + * - stripesColor: a string representing the stroke style + * @return nsIDOMCanvasPattern + * The custom striped pattern. + */ +AbstractCanvasGraph.getStripePattern = function(data) { + const { ownerDocument, backgroundColor, stripesColor } = data; + const id = [backgroundColor, stripesColor].join(","); + + if (gCachedStripePattern.has(id)) { + return gCachedStripePattern.get(id); + } + + const canvas = ownerDocument.createElementNS(HTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + const width = (canvas.width = GRAPH_STRIPE_PATTERN_WIDTH); + const height = (canvas.height = GRAPH_STRIPE_PATTERN_HEIGHT); + + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); + + const pixelRatio = ownerDocument.defaultView.devicePixelRatio; + const scaledLineWidth = GRAPH_STRIPE_PATTERN_LINE_WIDTH * pixelRatio; + const scaledLineSpacing = GRAPH_STRIPE_PATTERN_LINE_SPACING * pixelRatio; + + ctx.strokeStyle = stripesColor; + ctx.lineWidth = scaledLineWidth; + ctx.lineCap = "square"; + ctx.beginPath(); + + for (let i = -height; i <= height; i += scaledLineSpacing) { + ctx.moveTo(width, i); + ctx.lineTo(0, i + height); + } + + ctx.stroke(); + + const pattern = ctx.createPattern(canvas, "repeat"); + gCachedStripePattern.set(id, pattern); + return pattern; +}; + +/** + * Cache used by `AbstractCanvasGraph.getStripePattern`. + */ +const gCachedStripePattern = new Map(); + +/** + * Utility functions for graph canvases. + */ +this.CanvasGraphUtils = { + _graphUtilsWorker: null, + _graphUtilsTaskId: 0, + + /** + * Merges the animation loop of two graphs. + */ + async linkAnimation(graph1, graph2) { + if (!graph1 || !graph2) { + return; + } + await graph1.ready(); + await graph2.ready(); + + const window = graph1._window; + window.cancelAnimationFrame(graph1._animationId); + window.cancelAnimationFrame(graph2._animationId); + + const loop = () => { + window.requestAnimationFrame(loop); + graph1._drawWidget(); + graph2._drawWidget(); + }; + + window.requestAnimationFrame(loop); + }, + + /** + * Makes sure selections in one graph are reflected in another. + */ + linkSelection: function(graph1, graph2) { + if (!graph1 || !graph2) { + return; + } + + if (graph1.hasSelection()) { + graph2.setSelection(graph1.getSelection()); + } else { + graph2.dropSelection(); + } + + graph1.on("selecting", () => { + graph2.setSelection(graph1.getSelection()); + }); + graph2.on("selecting", () => { + graph1.setSelection(graph2.getSelection()); + }); + graph1.on("deselecting", () => { + graph2.dropSelection(); + }); + graph2.on("deselecting", () => { + graph1.dropSelection(); + }); + }, + + /** + * Performs the given task in a chrome worker, assuming it exists. + * + * @param string task + * The task name. Currently supported: "plotTimestampsGraph". + * @param any data + * Extra arguments to pass to the worker. + * @return object + * A promise that is resolved once the worker finishes the task. + */ + _performTaskInWorker: function(task, data) { + const worker = this._graphUtilsWorker || new DevToolsWorker(WORKER_URL); + return worker.performTask(task, data); + }, +}; + +/** + * Maps a value from one range to another. + * @param number value, istart, istop, ostart, ostop + * @return number + */ +function map(value, istart, istop, ostart, ostop) { + const ratio = istop - istart; + if (ratio == 0) { + return value; + } + return ostart + (ostop - ostart) * ((value - istart) / ratio); +} + +/** + * Constrains a value to a range. + * @param number value, min, max + * @return number + */ +function clamp(value, min, max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +exports.GraphCursor = GraphCursor; +exports.GraphArea = GraphArea; +exports.GraphAreaDragger = GraphAreaDragger; +exports.GraphAreaResizer = GraphAreaResizer; +exports.AbstractCanvasGraph = AbstractCanvasGraph; +exports.CanvasGraphUtils = CanvasGraphUtils; +exports.CanvasGraphUtils.map = map; +exports.CanvasGraphUtils.clamp = clamp; diff --git a/devtools/client/shared/widgets/GraphsWorker.js b/devtools/client/shared/widgets/GraphsWorker.js new file mode 100644 index 0000000000..a3e618c38f --- /dev/null +++ b/devtools/client/shared/widgets/GraphsWorker.js @@ -0,0 +1,106 @@ +/* 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"; + +/* eslint-env worker */ + +/** + * Import `createTask` to communicate with `devtools/shared/worker`. + */ +importScripts("resource://gre/modules/workers/require.js"); +const { createTask } = require("resource://devtools/shared/worker/helper.js"); + +/** + * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.js + * @param number id + * @param array timestamps + * @param number interval + * @param number duration + */ +createTask(self, "plotTimestampsGraph", function({ + timestamps, + interval, + duration, +}) { + const plottedData = plotTimestamps(timestamps, interval); + const plottedMinMaxSum = getMinMaxAvg(plottedData, timestamps, duration); + + return { plottedData, plottedMinMaxSum }; +}); + +/** + * Gets the min, max and average of the values in an array. + * @param array source + * @param array timestamps + * @param number duration + * @return object + */ +function getMinMaxAvg(source, timestamps, duration) { + const totalFrames = timestamps.length; + let maxValue = Number.MIN_SAFE_INTEGER; + let minValue = Number.MAX_SAFE_INTEGER; + // Calculate the average by counting how many frames occurred + // in the duration of the recording, rather than average the frame points + // we have, as that weights higher FPS, as there'll be more timestamps for + // those values + const avgValue = totalFrames / (duration / 1000); + + for (const { value } of source) { + maxValue = Math.max(value, maxValue); + minValue = Math.min(value, minValue); + } + + return { minValue, maxValue, avgValue }; +} + +/** + * Takes a list of numbers and plots them on a line graph representing + * the rate of occurences in a specified interval. + * + * @param array timestamps + * A list of numbers representing time, ordered ascending. For example, + * this can be the raw data received from the framerate actor, which + * represents the elapsed time on each refresh driver tick. + * @param number interval + * The maximum amount of time to wait between calculations. + * @param number clamp + * The maximum allowed value. + * @return array + * A collection of { delta, value } objects representing the + * plotted value at every delta time. + */ +function plotTimestamps(timestamps, interval = 100, clamp = 60) { + const timeline = []; + const totalTicks = timestamps.length; + + // If the refresh driver didn't get a chance to tick before the + // recording was stopped, assume rate was 0. + if (totalTicks == 0) { + timeline.push({ delta: 0, value: 0 }); + timeline.push({ delta: interval, value: 0 }); + return timeline; + } + + let frameCount = 0; + let prevTime = +timestamps[0]; + + for (let i = 1; i < totalTicks; i++) { + const currTime = +timestamps[i]; + frameCount++; + + const elapsedTime = currTime - prevTime; + if (elapsedTime < interval) { + continue; + } + + const rate = Math.min(1000 / (elapsedTime / frameCount), clamp); + timeline.push({ delta: prevTime, value: rate }); + timeline.push({ delta: currTime, value: rate }); + + frameCount = 0; + prevTime = currTime; + } + + return timeline; +} diff --git a/devtools/client/shared/widgets/LineGraphWidget.js b/devtools/client/shared/widgets/LineGraphWidget.js new file mode 100644 index 0000000000..711462da9d --- /dev/null +++ b/devtools/client/shared/widgets/LineGraphWidget.js @@ -0,0 +1,443 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { extend } = require("devtools/shared/extend"); +const { + AbstractCanvasGraph, + CanvasGraphUtils, +} = require("devtools/client/shared/widgets/Graphs"); +const { LocalizationHelper } = require("devtools/shared/l10n"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const L10N = new LocalizationHelper( + "devtools/client/locales/graphs.properties" +); + +// Line graph constants. + +const GRAPH_DAMPEN_VALUES_FACTOR = 0.85; +const GRAPH_TOOLTIP_SAFE_BOUNDS = 8; // px +const GRAPH_MIN_MAX_TOOLTIP_DISTANCE = 14; // px + +const GRAPH_BACKGROUND_COLOR = "#0088cc"; +const GRAPH_STROKE_WIDTH = 1; // px +const GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)"; +const GRAPH_HELPER_LINES_DASH = [5]; // px +const GRAPH_HELPER_LINES_WIDTH = 1; // px +const GRAPH_MAXIMUM_LINE_COLOR = "rgba(255,255,255,0.4)"; +const GRAPH_AVERAGE_LINE_COLOR = "rgba(255,255,255,0.7)"; +const GRAPH_MINIMUM_LINE_COLOR = "rgba(255,255,255,0.9)"; +const GRAPH_BACKGROUND_GRADIENT_START = "rgba(255,255,255,0.25)"; +const GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.0)"; + +const GRAPH_CLIPHEAD_LINE_COLOR = "#fff"; +const GRAPH_SELECTION_LINE_COLOR = "#fff"; +const GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(44,187,15,0.25)"; +const GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)"; +const GRAPH_REGION_BACKGROUND_COLOR = "transparent"; +const GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)"; + +/** + * A basic line graph, plotting values on a curve and adding helper lines + * and tooltips for maximum, average and minimum values. + * + * @see AbstractCanvasGraph for emitted events and other options. + * + * Example usage: + * let graph = new LineGraphWidget(node, "units"); + * graph.once("ready", () => { + * graph.setData(src); + * }); + * + * Data source format: + * [ + * { delta: x1, value: y1 }, + * { delta: x2, value: y2 }, + * ... + * { delta: xn, value: yn } + * ] + * where each item in the array represents a point in the graph. + * + * @param Node parent + * The parent node holding the graph. + * @param object options [optional] + * `metric`: The metric displayed in the graph, e.g. "fps" or "bananas". + * `min`: Boolean whether to show the min tooltip/gutter/line (default: true) + * `max`: Boolean whether to show the max tooltip/gutter/line (default: true) + * `avg`: Boolean whether to show the avg tooltip/gutter/line (default: true) + */ +this.LineGraphWidget = function(parent, options = {}, ...args) { + const { metric, min, max, avg } = options; + + this._showMin = min !== false; + this._showMax = max !== false; + this._showAvg = avg !== false; + + AbstractCanvasGraph.apply(this, [parent, "line-graph", ...args]); + + this.once("ready", () => { + // Create all gutters and tooltips incase the showing of min/max/avg + // are changed later + this._gutter = this._createGutter(); + this._maxGutterLine = this._createGutterLine("maximum"); + this._maxTooltip = this._createTooltip( + "maximum", + "start", + L10N.getStr("graphs.label.maximum"), + metric + ); + this._minGutterLine = this._createGutterLine("minimum"); + this._minTooltip = this._createTooltip( + "minimum", + "start", + L10N.getStr("graphs.label.minimum"), + metric + ); + this._avgGutterLine = this._createGutterLine("average"); + this._avgTooltip = this._createTooltip( + "average", + "end", + L10N.getStr("graphs.label.average"), + metric + ); + }); +}; + +LineGraphWidget.prototype = extend(AbstractCanvasGraph.prototype, { + backgroundColor: GRAPH_BACKGROUND_COLOR, + backgroundGradientStart: GRAPH_BACKGROUND_GRADIENT_START, + backgroundGradientEnd: GRAPH_BACKGROUND_GRADIENT_END, + strokeColor: GRAPH_STROKE_COLOR, + strokeWidth: GRAPH_STROKE_WIDTH, + maximumLineColor: GRAPH_MAXIMUM_LINE_COLOR, + averageLineColor: GRAPH_AVERAGE_LINE_COLOR, + minimumLineColor: GRAPH_MINIMUM_LINE_COLOR, + clipheadLineColor: GRAPH_CLIPHEAD_LINE_COLOR, + selectionLineColor: GRAPH_SELECTION_LINE_COLOR, + selectionBackgroundColor: GRAPH_SELECTION_BACKGROUND_COLOR, + selectionStripesColor: GRAPH_SELECTION_STRIPES_COLOR, + regionBackgroundColor: GRAPH_REGION_BACKGROUND_COLOR, + regionStripesColor: GRAPH_REGION_STRIPES_COLOR, + + /** + * Optionally offsets the `delta` in the data source by this scalar. + */ + dataOffsetX: 0, + + /** + * Optionally uses this value instead of the last tick in the data source + * to compute the horizontal scaling. + */ + dataDuration: 0, + + /** + * The scalar used to multiply the graph values to leave some headroom. + */ + dampenValuesFactor: GRAPH_DAMPEN_VALUES_FACTOR, + + /** + * Specifies if min/max/avg tooltips have arrow handlers on their sides. + */ + withTooltipArrows: true, + + /** + * Specifies if min/max/avg tooltips are positioned based on the actual + * values, or just placed next to the graph corners. + */ + withFixedTooltipPositions: false, + + /** + * Takes a list of numbers and plots them on a line graph representing + * the rate of occurences in a specified interval. Useful for drawing + * framerate, for example, from a sequence of timestamps. + * + * @param array timestamps + * A list of numbers representing time, ordered ascending. For example, + * this can be the raw data received from the framerate actor, which + * represents the elapsed time on each refresh driver tick. + * @param number interval + * The maximum amount of time to wait between calculations. + * @param number duration + * The duration of the recording in milliseconds. + */ + async setDataFromTimestamps(timestamps, interval, duration) { + const { + plottedData, + plottedMinMaxSum, + } = await CanvasGraphUtils._performTaskInWorker("plotTimestampsGraph", { + timestamps, + interval, + duration, + }); + + this._tempMinMaxSum = plottedMinMaxSum; + this.setData(plottedData); + }, + + /** + * Renders the graph's data source. + * @see AbstractCanvasGraph.prototype.buildGraphImage + */ + buildGraphImage: function() { + const { canvas, ctx } = this._getNamedCanvas("line-graph-data"); + const width = this._width; + const height = this._height; + + const totalTicks = this._data.length; + const firstTick = totalTicks ? this._data[0].delta : 0; + const lastTick = totalTicks ? this._data[totalTicks - 1].delta : 0; + let maxValue = Number.MIN_SAFE_INTEGER; + let minValue = Number.MAX_SAFE_INTEGER; + let avgValue = 0; + + if (this._tempMinMaxSum) { + maxValue = this._tempMinMaxSum.maxValue; + minValue = this._tempMinMaxSum.minValue; + avgValue = this._tempMinMaxSum.avgValue; + } else { + let sumValues = 0; + for (const { value } of this._data) { + maxValue = Math.max(value, maxValue); + minValue = Math.min(value, minValue); + sumValues += value; + } + avgValue = sumValues / totalTicks; + } + + const duration = this.dataDuration || lastTick; + const dataScaleX = (this.dataScaleX = + width / (duration - this.dataOffsetX)); + const dataScaleY = (this.dataScaleY = + (height / maxValue) * this.dampenValuesFactor); + + // Draw the background. + + ctx.fillStyle = this.backgroundColor; + ctx.fillRect(0, 0, width, height); + + // Draw the graph. + + const gradient = ctx.createLinearGradient(0, height / 2, 0, height); + gradient.addColorStop(0, this.backgroundGradientStart); + gradient.addColorStop(1, this.backgroundGradientEnd); + ctx.fillStyle = gradient; + ctx.strokeStyle = this.strokeColor; + ctx.lineWidth = this.strokeWidth * this._pixelRatio; + ctx.beginPath(); + + for (const { delta, value } of this._data) { + const currX = (delta - this.dataOffsetX) * dataScaleX; + const currY = height - value * dataScaleY; + + if (delta == firstTick) { + ctx.moveTo(-GRAPH_STROKE_WIDTH, height); + ctx.lineTo(-GRAPH_STROKE_WIDTH, currY); + } + + ctx.lineTo(currX, currY); + + if (delta == lastTick) { + ctx.lineTo(width + GRAPH_STROKE_WIDTH, currY); + ctx.lineTo(width + GRAPH_STROKE_WIDTH, height); + } + } + + ctx.fill(); + ctx.stroke(); + + this._drawOverlays(ctx, minValue, maxValue, avgValue, dataScaleY); + + return canvas; + }, + + /** + * Draws the min, max and average horizontal lines, along with their + * repsective tooltips. + * + * @param CanvasRenderingContext2D ctx + * @param number minValue + * @param number maxValue + * @param number avgValue + * @param number dataScaleY + */ + _drawOverlays: function(ctx, minValue, maxValue, avgValue, dataScaleY) { + const width = this._width; + const height = this._height; + const totalTicks = this._data.length; + + // Draw the maximum value horizontal line. + if (this._showMax) { + ctx.strokeStyle = this.maximumLineColor; + ctx.lineWidth = GRAPH_HELPER_LINES_WIDTH; + ctx.setLineDash(GRAPH_HELPER_LINES_DASH); + ctx.beginPath(); + const maximumY = height - maxValue * dataScaleY; + ctx.moveTo(0, maximumY); + ctx.lineTo(width, maximumY); + ctx.stroke(); + } + + // Draw the average value horizontal line. + if (this._showAvg) { + ctx.strokeStyle = this.averageLineColor; + ctx.lineWidth = GRAPH_HELPER_LINES_WIDTH; + ctx.setLineDash(GRAPH_HELPER_LINES_DASH); + ctx.beginPath(); + const averageY = height - avgValue * dataScaleY; + ctx.moveTo(0, averageY); + ctx.lineTo(width, averageY); + ctx.stroke(); + } + + // Draw the minimum value horizontal line. + if (this._showMin) { + ctx.strokeStyle = this.minimumLineColor; + ctx.lineWidth = GRAPH_HELPER_LINES_WIDTH; + ctx.setLineDash(GRAPH_HELPER_LINES_DASH); + ctx.beginPath(); + const minimumY = height - minValue * dataScaleY; + ctx.moveTo(0, minimumY); + ctx.lineTo(width, minimumY); + ctx.stroke(); + } + + // Update the tooltips text and gutter lines. + + this._maxTooltip.querySelector( + "[text=value]" + ).textContent = L10N.numberWithDecimals(maxValue, 2); + this._avgTooltip.querySelector( + "[text=value]" + ).textContent = L10N.numberWithDecimals(avgValue, 2); + this._minTooltip.querySelector( + "[text=value]" + ).textContent = L10N.numberWithDecimals(minValue, 2); + + const bottom = height / this._pixelRatio; + const maxPosY = CanvasGraphUtils.map( + maxValue * this.dampenValuesFactor, + 0, + maxValue, + bottom, + 0 + ); + const avgPosY = CanvasGraphUtils.map( + avgValue * this.dampenValuesFactor, + 0, + maxValue, + bottom, + 0 + ); + const minPosY = CanvasGraphUtils.map( + minValue * this.dampenValuesFactor, + 0, + maxValue, + bottom, + 0 + ); + + const safeTop = GRAPH_TOOLTIP_SAFE_BOUNDS; + const safeBottom = bottom - GRAPH_TOOLTIP_SAFE_BOUNDS; + + const maxTooltipTop = this.withFixedTooltipPositions + ? safeTop + : CanvasGraphUtils.clamp(maxPosY, safeTop, safeBottom); + const avgTooltipTop = this.withFixedTooltipPositions + ? safeTop + : CanvasGraphUtils.clamp(avgPosY, safeTop, safeBottom); + const minTooltipTop = this.withFixedTooltipPositions + ? safeBottom + : CanvasGraphUtils.clamp(minPosY, safeTop, safeBottom); + + this._maxTooltip.style.top = maxTooltipTop + "px"; + this._avgTooltip.style.top = avgTooltipTop + "px"; + this._minTooltip.style.top = minTooltipTop + "px"; + + this._maxGutterLine.style.top = maxPosY + "px"; + this._avgGutterLine.style.top = avgPosY + "px"; + this._minGutterLine.style.top = minPosY + "px"; + + this._maxTooltip.setAttribute("with-arrows", this.withTooltipArrows); + this._avgTooltip.setAttribute("with-arrows", this.withTooltipArrows); + this._minTooltip.setAttribute("with-arrows", this.withTooltipArrows); + + const distanceMinMax = Math.abs(maxTooltipTop - minTooltipTop); + this._maxTooltip.hidden = + this._showMax === false || + !totalTicks || + distanceMinMax < GRAPH_MIN_MAX_TOOLTIP_DISTANCE; + this._avgTooltip.hidden = this._showAvg === false || !totalTicks; + this._minTooltip.hidden = this._showMin === false || !totalTicks; + this._gutter.hidden = + (this._showMin === false && + this._showAvg === false && + this._showMax === false) || + !totalTicks; + + this._maxGutterLine.hidden = this._showMax === false; + this._avgGutterLine.hidden = this._showAvg === false; + this._minGutterLine.hidden = this._showMin === false; + }, + + /** + * Creates the gutter node when constructing this graph. + * @return Node + */ + _createGutter: function() { + const gutter = this._document.createElementNS(HTML_NS, "div"); + gutter.className = "line-graph-widget-gutter"; + gutter.setAttribute("hidden", true); + this._container.appendChild(gutter); + + return gutter; + }, + + /** + * Creates the gutter line nodes when constructing this graph. + * @return Node + */ + _createGutterLine: function(type) { + const line = this._document.createElementNS(HTML_NS, "div"); + line.className = "line-graph-widget-gutter-line"; + line.setAttribute("type", type); + this._gutter.appendChild(line); + + return line; + }, + + /** + * Creates the tooltip nodes when constructing this graph. + * @return Node + */ + _createTooltip: function(type, arrow, info, metric) { + const tooltip = this._document.createElementNS(HTML_NS, "div"); + tooltip.className = "line-graph-widget-tooltip"; + tooltip.setAttribute("type", type); + tooltip.setAttribute("arrow", arrow); + tooltip.setAttribute("hidden", true); + + const infoNode = this._document.createElementNS(HTML_NS, "span"); + infoNode.textContent = info; + infoNode.setAttribute("text", "info"); + + const valueNode = this._document.createElementNS(HTML_NS, "span"); + valueNode.textContent = 0; + valueNode.setAttribute("text", "value"); + + const metricNode = this._document.createElementNS(HTML_NS, "span"); + metricNode.textContent = metric; + metricNode.setAttribute("text", "metric"); + + tooltip.appendChild(infoNode); + tooltip.appendChild(valueNode); + tooltip.appendChild(metricNode); + this._container.appendChild(tooltip); + + return tooltip; + }, +}); + +module.exports = LineGraphWidget; diff --git a/devtools/client/shared/widgets/MountainGraphWidget.js b/devtools/client/shared/widgets/MountainGraphWidget.js new file mode 100644 index 0000000000..728475114c --- /dev/null +++ b/devtools/client/shared/widgets/MountainGraphWidget.js @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { extend } = require("devtools/shared/extend"); +const { + AbstractCanvasGraph, +} = require("devtools/client/shared/widgets/Graphs"); + +// Bar graph constants. + +const GRAPH_DAMPEN_VALUES_FACTOR = 0.9; + +const GRAPH_BACKGROUND_COLOR = "#ddd"; +const GRAPH_STROKE_WIDTH = 1; // px +const GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)"; +const GRAPH_HELPER_LINES_DASH = [5]; // px +const GRAPH_HELPER_LINES_WIDTH = 1; // px + +const GRAPH_CLIPHEAD_LINE_COLOR = "#fff"; +const GRAPH_SELECTION_LINE_COLOR = "#fff"; +const GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(44,187,15,0.25)"; +const GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)"; +const GRAPH_REGION_BACKGROUND_COLOR = "transparent"; +const GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)"; + +/** + * A mountain graph, plotting sets of values as line graphs. + * + * @see AbstractCanvasGraph for emitted events and other options. + * + * Example usage: + * let graph = new MountainGraphWidget(node); + * graph.format = ...; + * graph.once("ready", () => { + * graph.setData(src); + * }); + * + * The `graph.format` traits are mandatory and will determine how each + * section of the moutain will be styled: + * [ + * { color: "#f00", ... }, + * { color: "#0f0", ... }, + * ... + * { color: "#00f", ... } + * ] + * + * Data source format: + * [ + * { delta: x1, values: [y11, y12, ... y1n] }, + * { delta: x2, values: [y21, y22, ... y2n] }, + * ... + * { delta: xm, values: [ym1, ym2, ... ymn] } + * ] + * where the [ymn] values is assumed to aready be normalized from [0..1]. + * + * @param Node parent + * The parent node holding the graph. + */ +this.MountainGraphWidget = function(parent, ...args) { + AbstractCanvasGraph.apply(this, [parent, "mountain-graph", ...args]); +}; + +MountainGraphWidget.prototype = extend(AbstractCanvasGraph.prototype, { + backgroundColor: GRAPH_BACKGROUND_COLOR, + strokeColor: GRAPH_STROKE_COLOR, + strokeWidth: GRAPH_STROKE_WIDTH, + clipheadLineColor: GRAPH_CLIPHEAD_LINE_COLOR, + selectionLineColor: GRAPH_SELECTION_LINE_COLOR, + selectionBackgroundColor: GRAPH_SELECTION_BACKGROUND_COLOR, + selectionStripesColor: GRAPH_SELECTION_STRIPES_COLOR, + regionBackgroundColor: GRAPH_REGION_BACKGROUND_COLOR, + regionStripesColor: GRAPH_REGION_STRIPES_COLOR, + + /** + * List of rules used to style each section of the mountain. + * @see constructor + * @type array + */ + format: null, + + /** + * Optionally offsets the `delta` in the data source by this scalar. + */ + dataOffsetX: 0, + + /** + * Optionally uses this value instead of the last tick in the data source + * to compute the horizontal scaling. + */ + dataDuration: 0, + + /** + * The scalar used to multiply the graph values to leave some headroom + * on the top. + */ + dampenValuesFactor: GRAPH_DAMPEN_VALUES_FACTOR, + + /** + * Renders the graph's background. + * @see AbstractCanvasGraph.prototype.buildBackgroundImage + */ + buildBackgroundImage: function() { + const { canvas, ctx } = this._getNamedCanvas("mountain-graph-background"); + const width = this._width; + const height = this._height; + + ctx.fillStyle = this.backgroundColor; + ctx.fillRect(0, 0, width, height); + + return canvas; + }, + + /** + * Renders the graph's data source. + * @see AbstractCanvasGraph.prototype.buildGraphImage + */ + buildGraphImage: function() { + if (!this.format || !this.format.length) { + throw new Error( + "The graph format traits are mandatory to style " + "the data source." + ); + } + const { canvas, ctx } = this._getNamedCanvas("mountain-graph-data"); + const width = this._width; + const height = this._height; + + const totalSections = this.format.length; + const totalTicks = this._data.length; + const firstTick = totalTicks ? this._data[0].delta : 0; + const lastTick = totalTicks ? this._data[totalTicks - 1].delta : 0; + + const duration = this.dataDuration || lastTick; + const dataScaleX = (this.dataScaleX = + width / (duration - this.dataOffsetX)); + const dataScaleY = (this.dataScaleY = height * this.dampenValuesFactor); + + // Draw the graph. + + const prevHeights = Array.from({ length: totalTicks }).fill(0); + + ctx.globalCompositeOperation = "destination-over"; + ctx.strokeStyle = this.strokeColor; + ctx.lineWidth = this.strokeWidth * this._pixelRatio; + + for (let section = 0; section < totalSections; section++) { + ctx.fillStyle = this.format[section].color || "#000"; + ctx.beginPath(); + + for (let tick = 0; tick < totalTicks; tick++) { + const { delta, values } = this._data[tick]; + const currX = (delta - this.dataOffsetX) * dataScaleX; + const currY = values[section] * dataScaleY; + const prevY = prevHeights[tick]; + + if (delta == firstTick) { + ctx.moveTo(-GRAPH_STROKE_WIDTH, height); + ctx.lineTo(-GRAPH_STROKE_WIDTH, height - currY - prevY); + } + + ctx.lineTo(currX, height - currY - prevY); + + if (delta == lastTick) { + ctx.lineTo(width + GRAPH_STROKE_WIDTH, height - currY - prevY); + ctx.lineTo(width + GRAPH_STROKE_WIDTH, height); + } + + prevHeights[tick] += currY; + } + + ctx.fill(); + ctx.stroke(); + } + + ctx.globalCompositeOperation = "source-over"; + ctx.lineWidth = GRAPH_HELPER_LINES_WIDTH; + ctx.setLineDash(GRAPH_HELPER_LINES_DASH); + + // Draw the maximum value horizontal line. + + ctx.beginPath(); + const maximumY = height * this.dampenValuesFactor; + ctx.moveTo(0, maximumY); + ctx.lineTo(width, maximumY); + ctx.stroke(); + + // Draw the average value horizontal line. + + ctx.beginPath(); + const averageY = (height / 2) * this.dampenValuesFactor; + ctx.moveTo(0, averageY); + ctx.lineTo(width, averageY); + ctx.stroke(); + + return canvas; + }, +}); + +module.exports = MountainGraphWidget; diff --git a/devtools/client/shared/widgets/ShapesInContextEditor.js b/devtools/client/shared/widgets/ShapesInContextEditor.js new file mode 100644 index 0000000000..6ee4eae5da --- /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("devtools/shared/event-emitter"); +const { debounce } = require("devtools/shared/debounce"); + +/** + * 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..30031089b9 --- /dev/null +++ b/devtools/client/shared/widgets/Spectrum.js @@ -0,0 +1,777 @@ +/* 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("devtools/shared/event-emitter"); +const { MultiLocalizationHelper } = require("devtools/shared/l10n"); + +loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true); +loader.lazyRequireGetter( + this, + "labColors", + "devtools/shared/css/color-db", + true +); +loader.lazyRequireGetter( + this, + ["getTextProperties", "getContrastRatioAgainstBackground"], + "devtools/shared/accessibility", + 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..5bcbd332b5 --- /dev/null +++ b/devtools/client/shared/widgets/TableWidget.js @@ -0,0 +1,2003 @@ +/* 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("devtools/shared/event-emitter"); +loader.lazyRequireGetter( + this, + ["clearNamedTimeout", "setNamedTimeout"], + "devtools/client/shared/widgets/view-helpers", + true +); +loader.lazyRequireGetter( + this, + "naturalSortCaseInsensitive", + "devtools/shared/natural-sort", + true +); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +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, + + /** + * 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) { + 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: function(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: 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: function() { + 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: function(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: function(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: function(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: function() { + const editor = this._editableFieldsEngine; + + if (!editor || !editor.isEditing) { + return; + } + + editor.cancelEdit(); + }, + + /** + * Keydown event handler for the table. Used for keyboard navigation amongst + * rows. + */ + onKeydown: function(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: function({ 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: function(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: 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: function() { + 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: function(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: function() { + 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: function(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.wrapper.getAttribute("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: function(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: function( + 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: function(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: function(id) { + this.selectedRow = id; + }, + + /** + * Selects the next row. Cycles over to the first row if last row is selected + */ + selectNextRow: function() { + 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: function() { + for (const column of this.columns.values()) { + column.selectPreviousRow(); + } + }, + + /** + * Clears any selected row. + */ + clearSelection: function() { + 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: function(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: function(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: function(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: function() { + 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: function(column) { + this.emit(EVENTS.COLUMN_SORTED, column); + this.sortedOn = column; + + if (!this.items.size) { + return; + } + + const sortedItems = this.columns.get(column).sort([...this.items.values()]); + for (const [id, col] of this.columns) { + if (id != col) { + 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: function() { + clearNamedTimeout("table-scroll"); + setNamedTimeout("table-scroll", AFTER_SCROLL_DELAY, this.afterScroll); + }, + + /** + * Emits the "scroll-end" event when the whole table is scrolled + */ + afterScroll: function() { + 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 wrapping element is required solely so that position:sticky works on + // the headers of the columns. + this.wrapper = this.document.createXULElement("vbox"); + this.wrapper.className = "table-widget-wrapper"; + this.wrapper.setAttribute("flex", "1"); + this.wrapper.setAttribute("tabindex", "0"); + this.tbody.appendChild(this.wrapper); + + this.splitter = this.document.createXULElement("splitter"); + this.splitter.className = "devtools-side-splitter"; + this.tbody.appendChild(this.splitter); + + this.column = this.document.createElementNS(HTML_NS, "div"); + this.column.id = id; + this.column.className = "table-widget-column"; + this.wrapper.appendChild(this.column); + + 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.wrapper.hasAttribute("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} event + * The event name of the event. i.e. EVENTS.COLUMN_SORTED + * @param {string} column + * The id of the column being sorted by. + */ + onColumnSorted: function(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: function(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: function(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: function() { + 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.parentNode.remove(); + this.cells = null; + this.items = null; + this.selectedRow = null; + }, + + /** + * Selects the row at the `index` index + */ + selectRowAt: function(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: function(id) { + this._updateItems(); + this.selectRowAt(this.items[id]); + }, + + /** + * Selects the next row. Cycles to first if last row is selected. + */ + selectNextRow: function() { + 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: function() { + 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: function(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) === -1; + }); + } else { + index = this.cells.findIndex(element => { + return naturalSortCaseInsensitive(value, element.value) === 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: function(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: function(id, checked) { + if (arguments.length == 0) { + // Act like a toggling method when called with no params + id = this.id; + checked = this.wrapper.hasAttribute("hidden"); + } + if (id != this.id) { + return; + } + if (checked) { + this.wrapper.removeAttribute("hidden"); + this.tbody.insertBefore(this.splitter, this.wrapper.nextSibling); + } else { + this.wrapper.setAttribute("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: function(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: function(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: function() { + 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: function() { + 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: function(items) { + // Only sort the array if we are sorting based on this column + if (this.sorted == 1) { + items.sort((a, b) => { + const val1 = + a[this.id] instanceof Node ? a[this.id].textContent : a[this.id]; + const val2 = + b[this.id] instanceof Node ? b[this.id].textContent : b[this.id]; + return naturalSortCaseInsensitive(val1, val2); + }); + } else if (this.sorted > 1) { + items.sort((a, b) => { + const val1 = + a[this.id] instanceof Node ? a[this.id].textContent : a[this.id]; + const val2 = + b[this.id] instanceof Node ? b[this.id].textContent : b[this.id]; + return naturalSortCaseInsensitive(val2, val1); + }); + } + + 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. + items.forEach((item, i) => { + 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: function(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: function(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.hasAttribute("hidden"); + }, + + set hidden(value) { + if (value) { + this.label.setAttribute("hidden", "hidden"); + } else { + this.label.removeAttribute("hidden"); + } + }, + + set value(value) { + this._value = value; + if (value == null) { + this.label.setAttribute("value", ""); + return; + } + + if (this.wrapTextInElements && !(value instanceof Node)) { + const span = this.label.ownerDocument.createElementNS(HTML_NS, "span"); + span.textContent = value; + value = span; + } + + if (value instanceof Node) { + 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: function() { + 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: function() { + this.label.focus(); + }, + + scrollIntoView: function() { + this.label.scrollIntoView(false); + }, + + destroy: function() { + 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: function({ 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: function(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: function(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: function() { + 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: oldValue, + newValue: newValue, + }, + }; + + this.emit("change", data); + } + }, + + /** + * Cancel an edit. + */ + cancelEdit: function() { + if (!this.isEditing) { + return; + } + if (this.currentTarget) { + this.currentTarget.hidden = false; + } + + this.textbox.hidden = true; + }, + + /** + * Stop edit mode and apply changes. + */ + blur: function() { + 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: function(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: function() { + 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..de1d8aa580 --- /dev/null +++ b/devtools/client/shared/widgets/TreeWidget.js @@ -0,0 +1,637 @@ +/* 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("devtools/shared/event-emitter"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +/** + * 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: function(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: function() { + this.root.remove(); + this.root = null; + }, + + /** + * Sets up the root container of the TreeWidget. + */ + setupRoot: function() { + 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 + */ + setPlaceholderText: function(text) { + this.placeholder.textContent = text; + }, + + /** + * Select any node in the tree. + * + * @param {array} id + * An array of ids leading upto the selected item + */ + selectItem: function(id) { + this.selectedItem = id; + }, + + /** + * Selects the next visible item in the tree. + */ + selectNextItem: function() { + const next = this.getNextVisibleItem(); + if (next) { + this.selectedItem = next; + } + }, + + /** + * Selects the previos visible item in the tree + */ + selectPreviousItem: function() { + const prev = this.getPreviousVisibleItem(); + if (prev) { + this.selectedItem = prev; + } + }, + + /** + * Returns the next visible item in the tree + */ + getNextVisibleItem: function() { + 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: function() { + 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: function() { + 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: function(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: function(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: function(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: function() { + this.root.remove(); + this.setupRoot(); + this.attachments.clear(); + if (this.emptyText) { + this.setPlaceholderText(this.emptyText); + } + }, + + /** + * Expands the tree completely + */ + expandAll: function() { + this.root.expandAll(); + }, + + /** + * Collapses the tree completely + */ + collapseAll: function() { + this.root.collapseAll(); + }, + + /** + * Click handler for the tree. Used to select, open and close the tree nodes. + */ + onClick: function(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: function(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: function() { + 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: function(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: function(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: function(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: function() { + 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: function() { + if (this.label) { + this.label.setAttribute("expanded", "true"); + } + for (const child of this.items.values()) { + child.expandAll(); + } + }, + + destroy: function() { + 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/graphs-frame.xhtml b/devtools/client/shared/widgets/graphs-frame.xhtml new file mode 100644 index 0000000000..ae07bdd77b --- /dev/null +++ b/devtools/client/shared/widgets/graphs-frame.xhtml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<!DOCTYPE html> + +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + <link rel="stylesheet" href="chrome://devtools/skin/widgets.css" ype="text/css"/> + <script src="chrome://devtools/content/shared/theme-switching.js"/> + <style> + body { + overflow: hidden; + margin: 0; + padding: 0; + font-size: 0; + } + </style> +</head> +<body role="application"> + <div id="graph-container"> + <canvas id="graph-canvas"></canvas> + </div> +</body> +</html> diff --git a/devtools/client/shared/widgets/moz.build b/devtools/client/shared/widgets/moz.build new file mode 100644 index 0000000000..7c1a23d4db --- /dev/null +++ b/devtools/client/shared/widgets/moz.build @@ -0,0 +1,27 @@ +# -*- 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( + "AbstractTreeItem.jsm", + "Chart.js", + "CubicBezierPresets.js", + "CubicBezierWidget.js", + "FilterWidget.js", + "FlameGraph.js", + "Graphs.js", + "GraphsWorker.js", + "LineGraphWidget.js", + "MountainGraphWidget.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..a29ffbce6c --- /dev/null +++ b/devtools/client/shared/widgets/spectrum.css @@ -0,0 +1,329 @@ +/* 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; +} + +.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..808150416a --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.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 { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +const Editor = require("devtools/client/shared/sourceeditor/editor"); +const beautify = require("devtools/shared/jsbeautify/beautify"); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const CONTAINER_WIDTH = 500; + +/** + * 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 + */ +function setEventTooltip(tooltip, eventListenerInfos, toolbox) { + const eventTooltip = new EventTooltip(tooltip, eventListenerInfos, toolbox); + eventTooltip.init(); +} + +function EventTooltip(tooltip, eventListenerInfos, toolbox) { + this._tooltip = tooltip; + this._eventListenerInfos = eventListenerInfos; + this._toolbox = toolbox; + this._eventEditors = new WeakMap(); + + // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip. + this._tooltip.eventTooltip = this; + + this._headerClicked = this._headerClicked.bind(this); + this._debugClicked = this._debugClicked.bind(this); + this.destroy = this.destroy.bind(this); + this._subscriptions = []; +} + +EventTooltip.prototype = { + init: function() { + 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 this._eventListenerInfos) { + const phase = listener.capturing ? Capturing : Bubbling; + const level = listener.DOM0 ? "DOM0" : "DOM2"; + + // 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.emit("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.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"; + capturing.textContent = phase; + capturing.setAttribute("title", phase); + attributesBox.appendChild(capturing); + } + + 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.dom0) { + const attributesBox = doc.createElementNS(XHTML_NS, "div"); + attributesBox.className = "event-tooltip-attributes-box"; + attributesContainer.appendChild(attributesBox); + + const dom0 = doc.createElementNS(XHTML_NS, "span"); + dom0.className = "event-tooltip-attributes"; + dom0.textContent = level; + attributesBox.appendChild(dom0); + } + + // Content + const editor = new Editor(config); + this._eventEditors.set(content, { + editor: editor, + handler: listener.handler, + dom0: listener.DOM0, + 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 }); + this._tooltip.on("hidden", this.destroy); + }, + + _addContentListeners: function(header) { + header.addEventListener("click", this._headerClicked); + }, + + _headerClicked: function(event) { + 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.emit("event-tooltip-ready"); + }); + } + }, + + _debugClicked: function(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 + ); + } + }, + + /** + * Parse URI and return {url, line, column}; or return null if it can't be parsed. + */ + _parseLocation: function(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: function() { + if (this._tooltip) { + this._tooltip.off("hidden", this.destroy); + + 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; + } + + const headerNodes = this.container.querySelectorAll(".event-header"); + + for (const node of headerNodes) { + node.removeEventListener("click", this._headerClicked); + } + + const sourceNodes = this.container.querySelectorAll( + ".event-tooltip-debugger-icon" + ); + for (const node of sourceNodes) { + node.removeEventListener("click", this._debugClicked); + } + + for (const unsubscribe of this._subscriptions) { + unsubscribe(); + } + + this._eventListenerInfos = this._toolbox = this._tooltip = null; + }, +}; + +module.exports.setEventTooltip = setEventTooltip; diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js new file mode 100644 index 0000000000..59c609c6e7 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js @@ -0,0 +1,1070 @@ +/* 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 Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); + +loader.lazyRequireGetter( + this, + "focusableSelector", + "devtools/client/shared/focus", + true +); +loader.lazyRequireGetter( + this, + "TooltipToggle", + "devtools/client/shared/widgets/tooltip/TooltipToggle", + true +); +loader.lazyRequireGetter( + this, + "getCurrentZoom", + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + "listenOnce", + "devtools/shared/async-utils", + true +); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "devtools/shared/DevToolsUtils" +); + +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: function({ 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; + 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"; + + 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: function(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: function() { + 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: function() { + 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: function() { + return this.container.classList.contains("tooltip-visible"); + }, + + /** + * Destroy the tooltip instance. Hide the tooltip if displayed, remove the + * tooltip container from the document. + */ + destroy: function() { + this.hide(); + this.removeEventListeners(); + this.container.remove(); + if (this.xulPanelWrapper) { + this.xulPanelWrapper.remove(); + } + if (this._toggle) { + this._toggle.destroy(); + this._toggle = null; + } + }, + + _createContainer: function() { + 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: function(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: function(e) { + if (this._isInTooltipContainer(e.target)) { + return; + } + + this.hide({ fromMouseup: true }); + }, + + _isInTooltipContainer: function(node) { + // Check if the target is the tooltip arrow. + if (this.arrow && this.arrow === node) { + 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: function() { + 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: function() { + 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: function() { + const focusableElements = this.panel.querySelectorAll(focusableSelector); + if (focusableElements.length) { + focusableElements[focusableElements.length - 1].focus(); + } + return focusableElements.length !== 0; + }, + + _getTopWindow: function() { + return DevToolsUtils.getTopWindow(this.doc.defaultView); + }, + + /** + * Check if the tooltip's owner document has XUL root element. + */ + _hasXULRootElement: function() { + return this.doc.documentElement.namespaceURI === XUL_NS; + }, + + _isXULPopupAvailable: function() { + return this.doc.nodePrincipal.isSystemPrincipal; + }, + + _createXulPanelWrapper: function() { + 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: function(left, top) { + this.xulPanelWrapper.addEventListener( + "popuphidden", + this._onXulPanelHidden + ); + const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown"); + const zoom = getCurrentZoom(this.xulPanelWrapper); + this.xulPanelWrapper.openPopupAtScreen(left * zoom, top * zoom, false); + return onPanelShown; + }, + + _moveXulWrapperTo: function(left, top) { + const zoom = getCurrentZoom(this.xulPanelWrapper); + this.xulPanelWrapper.moveTo(left * zoom, top * zoom); + }, + + _hideXulWrapper: function() { + 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: function({ 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..477e025772 --- /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("devtools/shared/l10n"); +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/InlineTooltip.js b/devtools/client/shared/widgets/tooltip/InlineTooltip.js new file mode 100644 index 0000000000..a99a1a9400 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/InlineTooltip.js @@ -0,0 +1,100 @@ +/* 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("devtools/shared/event-emitter"); + +/** + * The InlineTooltip can display widgets for the CSS Rules view in an + * inline container. + * + * @param {Document} doc + * The toolbox document to attach the InlineTooltip container. + */ +function InlineTooltip(doc) { + EventEmitter.decorate(this); + + this.doc = doc; + + this.panel = this.doc.createElement("div"); + + this.topWindow = this._getTopWindow(); +} + +InlineTooltip.prototype = { + /** + * Show the tooltip. It might be wise to append some content first if you + * don't want the tooltip to be empty. + * + * @param {Node} anchor + * Which node below which the tooltip should be shown. + */ + show(anchor) { + anchor.parentNode.insertBefore(this.panel, anchor.nextSibling); + + this.emit("shown"); + }, + + /** + * Hide the current tooltip. + */ + hide() { + if (!this.isVisible()) { + return; + } + + this.panel.parentNode.remove(this.panel); + + this.emit("hidden"); + }, + + /** + * Check if the tooltip is currently displayed. + * + * @return {Boolean} true if the tooltip is visible + */ + isVisible() { + return ( + typeof this.panel.parentNode !== "undefined" && + this.panel.parentNode !== null + ); + }, + + /** + * Clears the HTML content of the tooltip panel + */ + clear() { + this.panel.innerHTML = ""; + }, + + /** + * Set the content of this tooltip. Will first clear the tooltip and then + * append the new content element. + * + * @param {DOMNode} content + * A node that can be appended in the tooltip + */ + setContent(content) { + this.clear(); + + this.panel.appendChild(content); + }, + + get content() { + return this.panel.firstChild; + }, + + _getTopWindow: function() { + return this.doc.defaultView; + }, + + destroy() { + this.hide(); + this.doc = null; + this.panel = null; + }, +}; + +module.exports = InlineTooltip; diff --git a/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js new file mode 100644 index 0000000000..8ec8f089ca --- /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("devtools/shared/l10n"); +const { + HTMLTooltip, +} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip"); + +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..fc96e561a6 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js @@ -0,0 +1,285 @@ +/* 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("devtools/shared/event-emitter"); +const KeyShortcuts = require("devtools/client/shared/key-shortcuts"); +const { + HTMLTooltip, +} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip"); +const InlineTooltip = require("devtools/client/shared/widgets/tooltip/InlineTooltip"); + +loader.lazyRequireGetter( + this, + "KeyCodes", + "devtools/client/shared/keycodes", + true +); + +const INLINE_TOOLTIP_CLASS = "inline-tooltip-container"; + +/** + * Base class for all (color, gradient, ...)-swatch based value editors inside + * tooltips + * + * @param {Document} document + * The document to attach the SwatchBasedEditorTooltip. 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 {Boolean} useInline + * A boolean flag representing whether or not the InlineTooltip should be used. + */ + +class SwatchBasedEditorTooltip { + constructor(document, useInline) { + EventEmitter.decorate(this); + + this.useInline = useInline; + + // Creating a tooltip instance + if (useInline) { + this.tooltip = new InlineTooltip(document); + } else { + // 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: 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.useInline + ? this.activeSwatch.closest(`.${INLINE_TOOLTIP_CLASS}`) + : 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..ccbfb284e6 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js @@ -0,0 +1,354 @@ +/* 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("devtools/shared/css/color"); +const Spectrum = require("devtools/client/shared/widgets/Spectrum"); +const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); +const { openDocLink } = require("devtools/client/shared/link"); +const { + A11Y_CONTRAST_LEARN_MORE_LINK, +} = require("devtools/client/accessibility/constants"); +loader.lazyRequireGetter(this, "throttle", "devtools/shared/throttle", true); + +loader.lazyRequireGetter( + this, + ["getFocusableElements", "wrapMoveFocus"], + "devtools/client/shared/focus", + true +); +loader.lazyRequireGetter( + this, + "PICKER_TYPES", + "devtools/shared/picker-constants" +); + +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. + * @param {Function} supportsCssColor4ColorFunction + * A function for checking the supporting of css-color-4 color function. + */ + +class SwatchColorPickerTooltip extends SwatchBasedEditorTooltip { + constructor(document, inspector, { supportsCssColor4ColorFunction }) { + 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); + this.cssColor4 = supportsCssColor4ColorFunction(); + + // 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; + + // Only enable contrast if the type of property is color. + this.spectrum.contrastEnabled = name === "color"; + 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.cancel(); + + // 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, this.cssColor4); + 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, this.cssColor4); + 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..adf2391bbd --- /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("devtools/client/shared/widgets/CubicBezierWidget"); +const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip"); + +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..898e76a8e1 --- /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("devtools/client/shared/widgets/FilterWidget"); +const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip"); + +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/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js new file mode 100644 index 0000000000..9de4e99586 --- /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: function( + 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: function() { + 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: function(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: function(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: function() { + 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..2ffd9b163c --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js @@ -0,0 +1,279 @@ +/* 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 ChromeUtils = require("ChromeUtils"); +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/client/shared/browser-loader.js" +); + +loader.lazyRequireGetter( + this, + "openDocLink", + "devtools/client/shared/link", + true +); + +class CssCompatibilityTooltipHelper { + constructor() { + this.addTab = this.addTab.bind(this); + } + + _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("devtools/client/shared/vendor/react"); + const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + const UnsupportedBrowserList = createFactory( + require("devtools/client/inspector/compatibility/components/UnsupportedBrowserList") + ); + + 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>, + * 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 { url, unsupportedBrowsers } = data; + + this._currentTooltip = tooltip; + this._currentUrl = `${url}?utm_source=devtools&utm_medium=inspector-css-compatibility&utm_campaign=default`; + 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); + } + + tooltipContainer.appendChild(this._getLearnMoreMessage(doc, data)); + templateNode.content.appendChild(tooltipContainer); + + this._renderUnsupportedBrowserList(tooltipContainer, unsupportedBrowsers); + 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 = CssCompatibilityTooltipHelper; 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..11c02285db --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js @@ -0,0 +1,134 @@ +/* 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", + "devtools/client/shared/link", + 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"}"> + * <strong></strong> + * </p> + * <p data-l10n-id="inactive-css-not-grid-or-flex-container-fix"> + * <strong></strong> + * <strong></strong> + * <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. + * numFixProps: 2, // Number of properties in the fix section of the + * // tooltip. + * 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, numFixProps, 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 })}'> + <strong></strong> + </p> + <p data-l10n-id="${fixId}"> + ${"<strong></strong>".repeat(numFixProps)} + <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..b441e61a88 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/moz.build @@ -0,0 +1,21 @@ +# -*- 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", + "EventTooltipHelper.js", + "HTMLTooltip.js", + "ImageTooltipHelper.js", + "inactive-css-tooltip-helper.js", + "InlineTooltip.js", + "RulePreviewTooltip.js", + "SwatchBasedEditorTooltip.js", + "SwatchColorPickerTooltip.js", + "SwatchCubicBezierTooltip.js", + "SwatchFilterTooltip.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..2b2d80d40e --- /dev/null +++ b/devtools/client/shared/widgets/view-helpers.js @@ -0,0 +1,431 @@ +/* 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 { Cu } = require("chrome"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(item) { + this._itemsByElement.delete(item._target); + }, + + /** + * Returns a string representing the object. + * Avoid using `toString` to avoid accidental JSONification. + * @return string + */ + stringify: function() { + 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; +} |