summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets')
-rw-r--r--devtools/client/shared/widgets/AbstractTreeItem.jsm661
-rw-r--r--devtools/client/shared/widgets/BarGraphWidget.js498
-rw-r--r--devtools/client/shared/widgets/BreadcrumbsWidget.jsm250
-rw-r--r--devtools/client/shared/widgets/Chart.jsm449
-rw-r--r--devtools/client/shared/widgets/CubicBezierPresets.js64
-rw-r--r--devtools/client/shared/widgets/CubicBezierWidget.js897
-rw-r--r--devtools/client/shared/widgets/FastListWidget.js249
-rw-r--r--devtools/client/shared/widgets/FilterWidget.js1073
-rw-r--r--devtools/client/shared/widgets/FlameGraph.js1462
-rw-r--r--devtools/client/shared/widgets/Graphs.js1424
-rw-r--r--devtools/client/shared/widgets/GraphsWorker.js103
-rw-r--r--devtools/client/shared/widgets/LineGraphWidget.js402
-rw-r--r--devtools/client/shared/widgets/MdnDocsWidget.js510
-rw-r--r--devtools/client/shared/widgets/MountainGraphWidget.js195
-rw-r--r--devtools/client/shared/widgets/SideMenuWidget.jsm725
-rw-r--r--devtools/client/shared/widgets/SimpleListWidget.jsm255
-rw-r--r--devtools/client/shared/widgets/Spectrum.js336
-rw-r--r--devtools/client/shared/widgets/TableWidget.js1817
-rw-r--r--devtools/client/shared/widgets/TreeWidget.js605
-rw-r--r--devtools/client/shared/widgets/VariablesView.jsm4182
-rw-r--r--devtools/client/shared/widgets/VariablesView.xul18
-rw-r--r--devtools/client/shared/widgets/VariablesViewController.jsm858
-rw-r--r--devtools/client/shared/widgets/cubic-bezier.css216
-rw-r--r--devtools/client/shared/widgets/filter-widget.css238
-rw-r--r--devtools/client/shared/widgets/graphs-frame.xhtml26
-rw-r--r--devtools/client/shared/widgets/mdn-docs.css39
-rw-r--r--devtools/client/shared/widgets/moz.build34
-rw-r--r--devtools/client/shared/widgets/spectrum.css155
-rw-r--r--devtools/client/shared/widgets/tooltip/CssDocsTooltip.js93
-rw-r--r--devtools/client/shared/widgets/tooltip/EventTooltipHelper.js313
-rw-r--r--devtools/client/shared/widgets/tooltip/HTMLTooltip.js638
-rw-r--r--devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js131
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js209
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js182
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js102
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js116
-rw-r--r--devtools/client/shared/widgets/tooltip/Tooltip.js410
-rw-r--r--devtools/client/shared/widgets/tooltip/TooltipToggle.js182
-rw-r--r--devtools/client/shared/widgets/tooltip/VariableContentHelper.js89
-rw-r--r--devtools/client/shared/widgets/tooltip/moz.build19
-rw-r--r--devtools/client/shared/widgets/view-helpers.js1625
-rw-r--r--devtools/client/shared/widgets/widgets.css109
42 files changed, 21959 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/AbstractTreeItem.jsm b/devtools/client/shared/widgets/AbstractTreeItem.jsm
new file mode 100644
index 000000000..541ab6777
--- /dev/null
+++ b/devtools/client/shared/widgets/AbstractTreeItem.jsm
@@ -0,0 +1,661 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 { interfaces: Ci, utils: Cu } = Components;
+
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
+const { KeyCodes } = require("devtools/client/shared/keycodes");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+ "resource://devtools/shared/event-emitter.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+ "resource://gre/modules/Console.jsm");
+
+this.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 an nsIDOMNode.
+ *
+ * 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 = Heritage.extend(AbstractTreeItem.prototype, {
+ * _displaySelf: function(document, arrowNode) {
+ * let node = document.createElement("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(nsIDOMNode);
+ * 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);
+ }
+}
+this.AbstractTreeItem = AbstractTreeItem;
+
+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 nsIDOMNode document
+ * @param nsIDOMNode arrowNode
+ * @return nsIDOMNode
+ */
+ _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 nsIDOMNode
+ */
+ 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() {
+ let win = this.document.defaultView;
+ let utils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+ return utils.getBoundsWithoutFlushing(this._containerNode);
+ },
+
+ /**
+ * Creates and appends this tree item to the specified parent element.
+ *
+ * @param nsIDOMNode containerNode
+ * The parent element for this tree item (and every other tree item).
+ * @param nsIDOMNode fragmentNode [optional]
+ * An optional document fragment temporarily holding this tree item in
+ * the current batch. Defaults to the `containerNode`.
+ * @param nsIDOMNode 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 (let 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 (let 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();
+ }
+ // 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.
+ else {
+ this._appendChildrenSuccessive();
+ }
+ },
+
+ /**
+ * Hides all children of this item in the tree.
+ */
+ _hideChildren: function () {
+ for (let 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();
+ }
+
+ let 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 () {
+ let childTreeItems = this._childTreeItems;
+ let expandedChildTreeItems = childTreeItems.filter(e => e._expanded);
+ let 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._onKeyPress = this._onKeyPress.bind(this);
+ this._onFocus = this._onFocus.bind(this);
+ this._onBlur = this._onBlur.bind(this);
+
+ let document = this.document;
+
+ let arrowNode = this._arrowNode = document.createElement("hbox");
+ arrowNode.className = "arrow theme-twisty";
+ arrowNode.addEventListener("mousedown", this._onArrowClick);
+
+ let targetNode = this._targetNode = this._displaySelf(document, arrowNode);
+ targetNode.style.MozUserFocus = "normal";
+
+ targetNode.addEventListener("mousedown", this._onClick);
+ targetNode.addEventListener("dblclick", this._onDoubleClick);
+ targetNode.addEventListener("keypress", this._onKeyPress);
+ 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 nsIDOMNode
+ * The element displaying the target item at the specified offset.
+ */
+ _getSiblingAtDelta: function (delta) {
+ let childNodes = this._containerNode.childNodes;
+ let indexOfSelf = Array.indexOf(childNodes, this._targetNode);
+ if (indexOfSelf + delta >= 0) {
+ return childNodes[indexOfSelf + delta];
+ }
+ return undefined;
+ },
+
+ _getNodesPerPageSize: function() {
+ let childNodes = this._containerNode.childNodes;
+ let nodeHeight = this._getHeight(childNodes[childNodes.length - 1]);
+ let containerHeight = this.bounds.height;
+ return Math.ceil(containerHeight / nodeHeight);
+ },
+
+ _getHeight: function(elem) {
+ let win = this.document.defaultView;
+ let utils = win.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ return utils.getBoundsWithoutFlushing(elem).height;
+ },
+
+ /**
+ * Focuses the first item in this tree.
+ */
+ _focusFirstNode: function () {
+ let 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 () {
+ let childNodes = this._containerNode.childNodes;
+ childNodes[childNodes.length - 1].focus();
+ },
+
+ /**
+ * Focuses the next item in this tree.
+ */
+ _focusNextNode: function () {
+ let nextElement = this._getSiblingAtDelta(1);
+ if (nextElement) nextElement.focus(); // nsIDOMNode
+ },
+
+ /**
+ * Focuses the previous item in this tree.
+ */
+ _focusPrevNode: function () {
+ let prevElement = this._getSiblingAtDelta(-1);
+ if (prevElement) prevElement.focus(); // nsIDOMNode
+ },
+
+ /**
+ * 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 () {
+ let 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 "keypress" event on the element displaying this tree item.
+ */
+ _onKeyPress: 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:
+ let 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:
+ let 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();
+ return;
+ }
+ },
+
+ /**
+ * 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/BarGraphWidget.js b/devtools/client/shared/widgets/BarGraphWidget.js
new file mode 100644
index 000000000..b11c6c021
--- /dev/null
+++ b/devtools/client/shared/widgets/BarGraphWidget.js
@@ -0,0 +1,498 @@
+"use strict";
+
+const { Heritage, setNamedTimeout, clearNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
+const { AbstractCanvasGraph, CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Bar graph constants.
+
+const GRAPH_DAMPEN_VALUES_FACTOR = 0.75;
+
+// The following are in pixels
+const GRAPH_BARS_MARGIN_TOP = 1;
+const GRAPH_BARS_MARGIN_END = 1;
+const GRAPH_MIN_BARS_WIDTH = 5;
+const GRAPH_MIN_BLOCKS_HEIGHT = 1;
+
+const GRAPH_BACKGROUND_GRADIENT_START = "rgba(0,136,204,0.0)";
+const GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.25)";
+
+const GRAPH_CLIPHEAD_LINE_COLOR = "#666";
+const GRAPH_SELECTION_LINE_COLOR = "#555";
+const GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(0,136,204,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)";
+
+const GRAPH_HIGHLIGHTS_MASK_BACKGROUND = "rgba(255,255,255,0.75)";
+const GRAPH_HIGHLIGHTS_MASK_STRIPES = "rgba(255,255,255,0.5)";
+
+// in ms
+const GRAPH_LEGEND_MOUSEOVER_DEBOUNCE = 50;
+
+/**
+ * A bar graph, plotting tuples of values as rectangles.
+ *
+ * @see AbstractCanvasGraph for emitted events and other options.
+ *
+ * Example usage:
+ * let graph = new BarGraphWidget(node);
+ * graph.format = ...;
+ * graph.once("ready", () => {
+ * graph.setData(src);
+ * });
+ *
+ * The `graph.format` traits are mandatory and will determine how the values
+ * are styled as "blocks" in every "bar":
+ * [
+ * { color: "#f00", label: "Foo" },
+ * { color: "#0f0", label: "Bar" },
+ * ...
+ * { color: "#00f", label: "Baz" }
+ * ]
+ *
+ * Data source format:
+ * [
+ * { delta: x1, values: [y11, y12, ... y1n] },
+ * { delta: x2, values: [y21, y22, ... y2n] },
+ * ...
+ * { delta: xm, values: [ym1, ym2, ... ymn] }
+ * ]
+ * where each item in the array represents a "bar", for which every value
+ * represents a "block" inside that "bar", plotted at the "delta" position.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ */
+this.BarGraphWidget = function (parent, ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "bar-graph", ...args]);
+
+ this.once("ready", () => {
+ this._onLegendMouseOver = this._onLegendMouseOver.bind(this);
+ this._onLegendMouseOut = this._onLegendMouseOut.bind(this);
+ this._onLegendMouseDown = this._onLegendMouseDown.bind(this);
+ this._onLegendMouseUp = this._onLegendMouseUp.bind(this);
+ this._createLegend();
+ });
+};
+
+BarGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ 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 colors used to fill each block inside every bar, also
+ * corresponding to labels displayed in this graph's legend.
+ * @see constructor
+ */
+ 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,
+
+ /**
+ * Bars that are too close too each other in the graph will be combined.
+ * This scalar specifies the required minimum width of each bar.
+ */
+ minBarsWidth: GRAPH_MIN_BARS_WIDTH,
+
+ /**
+ * Blocks in a bar that are too thin inside the bar will not be rendered.
+ * This scalar specifies the required minimum height of each block.
+ */
+ minBlocksHeight: GRAPH_MIN_BLOCKS_HEIGHT,
+
+ /**
+ * Renders the graph's background.
+ * @see AbstractCanvasGraph.prototype.buildBackgroundImage
+ */
+ buildBackgroundImage: function () {
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-background");
+ let width = this._width;
+ let height = this._height;
+
+ let gradient = ctx.createLinearGradient(0, 0, 0, height);
+ gradient.addColorStop(0, GRAPH_BACKGROUND_GRADIENT_START);
+ gradient.addColorStop(1, GRAPH_BACKGROUND_GRADIENT_END);
+ ctx.fillStyle = gradient;
+ 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.");
+ }
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalTypes = this.format.length;
+ let totalTicks = this._data.length;
+ let lastTick = this._data[totalTicks - 1].delta;
+
+ let minBarsWidth = this.minBarsWidth * this._pixelRatio;
+ let minBlocksHeight = this.minBlocksHeight * this._pixelRatio;
+
+ let duration = this.dataDuration || lastTick;
+ let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
+ let dataScaleY = this.dataScaleY = height / this._calcMaxHeight({
+ data: this._data,
+ dataScaleX: dataScaleX,
+ minBarsWidth: minBarsWidth
+ }) * this.dampenValuesFactor;
+
+ // Draw the graph.
+
+ // Iterate over the blocks, then the bars, to draw all rectangles of
+ // the same color in a single pass. See the @constructor for more
+ // information about the data source, and how a "bar" contains "blocks".
+
+ this._blocksBoundingRects = [];
+ let prevHeight = [];
+ let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
+ let scaledMarginTop = GRAPH_BARS_MARGIN_TOP * this._pixelRatio;
+
+ for (let type = 0; type < totalTypes; type++) {
+ ctx.fillStyle = this.format[type].color || "#000";
+ ctx.beginPath();
+
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+
+ for (let tick = 0; tick < totalTicks; tick++) {
+ let delta = this._data[tick].delta;
+ let value = this._data[tick].values[type] || 0;
+ let blockRight = (delta - this.dataOffsetX) * dataScaleX;
+ let blockHeight = value * dataScaleY;
+
+ let blockWidth = blockRight - prevRight;
+ if (blockWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += blockHeight;
+ continue;
+ }
+
+ let averageHeight = (blockHeight + skippedHeight) / (skippedCount + 1);
+ if (averageHeight >= minBlocksHeight) {
+ let bottom = height - ~~prevHeight[tick];
+ ctx.moveTo(prevRight, bottom);
+ ctx.lineTo(prevRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom);
+
+ // Remember this block's type and location.
+ this._blocksBoundingRects.push({
+ type: type,
+ start: prevRight,
+ end: blockRight,
+ top: bottom - averageHeight,
+ bottom: bottom
+ });
+
+ if (prevHeight[tick] === undefined) {
+ prevHeight[tick] = averageHeight + scaledMarginTop;
+ } else {
+ prevHeight[tick] += averageHeight + scaledMarginTop;
+ }
+ }
+
+ prevRight += blockWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ ctx.fill();
+ }
+
+ // The blocks bounding rects isn't guaranteed to be sorted ascending by
+ // block location on the X axis. This should be the case, for better
+ // cache cohesion and a faster `buildMaskImage`.
+ this._blocksBoundingRects.sort((a, b) => a.start > b.start ? 1 : -1);
+
+ // Update the legend.
+
+ while (this._legendNode.hasChildNodes()) {
+ this._legendNode.firstChild.remove();
+ }
+ for (let { color, label } of this.format) {
+ this._createLegendItem(color, label);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Renders the graph's mask.
+ * Fades in only the parts of the graph that are inside the specified areas.
+ *
+ * @param array highlights
+ * A list of { start, end } values. Optionally, each object
+ * in the list may also specify { top, bottom } pixel values if the
+ * highlighting shouldn't span across the full height of the graph.
+ * @param boolean inPixels
+ * Set this to true if the { start, end } values in the highlights
+ * list are pixel values, and not values from the data source.
+ * @param function unpack [optional]
+ * @see AbstractCanvasGraph.prototype.getMappedSelection
+ */
+ buildMaskImage: function (highlights, inPixels = false,
+ unpack = e => e.delta) {
+ // A null `highlights` array is used to clear the mask. An empty array
+ // will mask the entire graph.
+ if (!highlights) {
+ return null;
+ }
+
+ // Get a render target for the highlights. It will be overlaid on top of
+ // the existing graph, masking the areas that aren't highlighted.
+
+ let { canvas, ctx } = this._getNamedCanvas("graph-highlights");
+ let width = this._width;
+ let height = this._height;
+
+ // Draw the background mask.
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: GRAPH_HIGHLIGHTS_MASK_BACKGROUND,
+ stripesColor: GRAPH_HIGHLIGHTS_MASK_STRIPES
+ });
+ ctx.fillStyle = pattern;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clear highlighted areas.
+
+ let totalTicks = this._data.length;
+ let firstTick = unpack(this._data[0]);
+ let lastTick = unpack(this._data[totalTicks - 1]);
+
+ for (let { start, end, top, bottom } of highlights) {
+ if (!inPixels) {
+ start = CanvasGraphUtils.map(start, firstTick, lastTick, 0, width);
+ end = CanvasGraphUtils.map(end, firstTick, lastTick, 0, width);
+ }
+ let firstSnap = findFirst(this._blocksBoundingRects,
+ e => e.start >= start);
+ let lastSnap = findLast(this._blocksBoundingRects,
+ e => e.start >= start && e.end <= end);
+
+ let x1 = firstSnap ? firstSnap.start : start;
+ let x2;
+ if (lastSnap) {
+ x2 = lastSnap.end;
+ } else {
+ x2 = firstSnap ? firstSnap.end : end;
+ }
+
+ let y1 = top || 0;
+ let y2 = bottom || height;
+ ctx.clearRect(x1, y1, x2 - x1, y2 - y1);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * A list storing the bounding rectangle for each drawn block in the graph.
+ * Created whenever `buildGraphImage` is invoked.
+ */
+ _blocksBoundingRects: null,
+
+ /**
+ * Calculates the height of the tallest bar that would eventially be rendered
+ * in this graph.
+ *
+ * Bars that are too close too each other in the graph will be combined.
+ * @see `minBarsWidth`
+ *
+ * @return number
+ * The tallest bar height in this graph.
+ */
+ _calcMaxHeight: function ({ data, dataScaleX, minBarsWidth }) {
+ let maxHeight = 0;
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+ let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
+
+ for (let { delta, values } of data) {
+ let barRight = (delta - this.dataOffsetX) * dataScaleX;
+ let barHeight = values.reduce((a, b) => a + b, 0);
+
+ let barWidth = barRight - prevRight;
+ if (barWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += barHeight;
+ continue;
+ }
+
+ let averageHeight = (barHeight + skippedHeight) / (skippedCount + 1);
+ maxHeight = Math.max(averageHeight, maxHeight);
+
+ prevRight += barWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ return maxHeight;
+ },
+
+ /**
+ * Creates the legend container when constructing this graph.
+ */
+ _createLegend: function () {
+ let legendNode = this._legendNode = this._document.createElementNS(HTML_NS,
+ "div");
+ legendNode.className = "bar-graph-widget-legend";
+ this._container.appendChild(legendNode);
+ },
+
+ /**
+ * Creates a legend item when constructing this graph.
+ */
+ _createLegendItem: function (color, label) {
+ let itemNode = this._document.createElementNS(HTML_NS, "div");
+ itemNode.className = "bar-graph-widget-legend-item";
+
+ let colorNode = this._document.createElementNS(HTML_NS, "span");
+ colorNode.setAttribute("view", "color");
+ colorNode.setAttribute("data-index", this._legendNode.childNodes.length);
+ colorNode.style.backgroundColor = color;
+ colorNode.addEventListener("mouseover", this._onLegendMouseOver);
+ colorNode.addEventListener("mouseout", this._onLegendMouseOut);
+ colorNode.addEventListener("mousedown", this._onLegendMouseDown);
+ colorNode.addEventListener("mouseup", this._onLegendMouseUp);
+
+ let labelNode = this._document.createElementNS(HTML_NS, "span");
+ labelNode.setAttribute("view", "label");
+ labelNode.textContent = label;
+
+ itemNode.appendChild(colorNode);
+ itemNode.appendChild(labelNode);
+ this._legendNode.appendChild(itemNode);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is hovered.
+ */
+ _onLegendMouseOver: function (ev) {
+ setNamedTimeout(
+ "bar-graph-debounce",
+ GRAPH_LEGEND_MOUSEOVER_DEBOUNCE,
+ () => {
+ let type = ev.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+
+ this._originalHighlights = this._mask;
+ this._hasCustomHighlights = true;
+ this.setMask(rects, true);
+
+ this.emit("legend-hover", [type, rects]);
+ }
+ );
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is unhovered.
+ */
+ _onLegendMouseOut: function () {
+ clearNamedTimeout("bar-graph-debounce");
+
+ if (this._hasCustomHighlights) {
+ this.setMask(this._originalHighlights);
+ this._hasCustomHighlights = false;
+ this._originalHighlights = null;
+ }
+
+ this.emit("legend-unhover");
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is pressed.
+ */
+ _onLegendMouseDown: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ let type = ev.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+ let leftmost = rects[0];
+ let rightmost = rects[rects.length - 1];
+ if (!leftmost || !rightmost) {
+ this.dropSelection();
+ } else {
+ this.setSelection({ start: leftmost.start, end: rightmost.end });
+ }
+
+ this.emit("legend-selection", [leftmost, rightmost]);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is released.
+ */
+ _onLegendMouseUp: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+});
+
+/**
+ * Finds the first element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findFirst(array, predicate) {
+ for (let i = 0, len = array.length; i < len; i++) {
+ let element = array[i];
+ if (predicate(element)) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * Finds the last element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findLast(array, predicate) {
+ for (let i = array.length - 1; i >= 0; i--) {
+ let element = array[i];
+ if (predicate(element)) {
+ return element;
+ }
+ }
+ return null;
+}
+
+module.exports = BarGraphWidget;
diff --git a/devtools/client/shared/widgets/BreadcrumbsWidget.jsm b/devtools/client/shared/widgets/BreadcrumbsWidget.jsm
new file mode 100644
index 000000000..900a125b0
--- /dev/null
+++ b/devtools/client/shared/widgets/BreadcrumbsWidget.jsm
@@ -0,0 +1,250 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 = Components.utils;
+
+const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms
+
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const { ViewHelpers, setNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+this.EXPORTED_SYMBOLS = ["BreadcrumbsWidget"];
+
+/**
+ * A breadcrumb-like list of items.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * view-helpers.js.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ * @param Object aOptions
+ * - smoothScroll: specifies if smooth scrolling on selection is enabled.
+ */
+this.BreadcrumbsWidget = function BreadcrumbsWidget(aNode, aOptions = {}) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ // Create an internal arrowscrollbox container.
+ this._list = this.document.createElement("arrowscrollbox");
+ this._list.className = "breadcrumbs-widget-container";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "horizontal");
+ this._list.setAttribute("clicktoscroll", "true");
+ this._list.setAttribute("smoothscroll", !!aOptions.smoothScroll);
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
+ this._parent.appendChild(this._list);
+
+ // By default, hide the arrows. We let the arrowscrollbox show them
+ // in case of overflow.
+ this._list._scrollButtonUp.collapsed = true;
+ this._list._scrollButtonDown.collapsed = true;
+ this._list.addEventListener("underflow", this._onUnderflow.bind(this), false);
+ this._list.addEventListener("overflow", this._onOverflow.bind(this), false);
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+};
+
+BreadcrumbsWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function (aIndex, aContents) {
+ let list = this._list;
+ let breadcrumb = new Breadcrumb(this, aContents);
+ return list.insertBefore(breadcrumb._target, list.childNodes[aIndex]);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function (aIndex) {
+ return this._list.childNodes[aIndex];
+ },
+
+ /**
+ * Removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function (aChild) {
+ this._list.removeChild(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function () {
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let childNodes = this._list.childNodes;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of childNodes) {
+ if (node == aChild) {
+ node.setAttribute("checked", "");
+ this._selectedItem = node;
+ } else {
+ node.removeAttribute("checked");
+ }
+ }
+ },
+
+ /**
+ * Returns the value of the named attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @return string
+ * The current attribute value.
+ */
+ getAttribute: function (aName) {
+ if (aName == "scrollPosition") return this._list.scrollPosition;
+ if (aName == "scrollWidth") return this._list.scrollWidth;
+ return this._parent.getAttribute(aName);
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function (aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Repeated calls to ensureElementIsVisible would interfere with each other
+ // and may sometimes result in incorrect scroll positions.
+ setNamedTimeout("breadcrumb-select", ENSURE_SELECTION_VISIBLE_DELAY, () => {
+ if (this._list.ensureElementIsVisible) {
+ this._list.ensureElementIsVisible(aElement);
+ }
+ });
+ },
+
+ /**
+ * The underflow and overflow listener for the arrowscrollbox container.
+ */
+ _onUnderflow: function ({ target }) {
+ if (target != this._list) {
+ return;
+ }
+ target._scrollButtonUp.collapsed = true;
+ target._scrollButtonDown.collapsed = true;
+ target.removeAttribute("overflows");
+ },
+
+ /**
+ * The underflow and overflow listener for the arrowscrollbox container.
+ */
+ _onOverflow: function ({ target }) {
+ if (target != this._list) {
+ return;
+ }
+ target._scrollButtonUp.collapsed = false;
+ target._scrollButtonDown.collapsed = false;
+ target.setAttribute("overflows", "");
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null
+};
+
+/**
+ * A Breadcrumb constructor for the BreadcrumbsWidget.
+ *
+ * @param BreadcrumbsWidget aWidget
+ * The widget to contain this breadcrumb.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ */
+function Breadcrumb(aWidget, aContents) {
+ this.document = aWidget.document;
+ this.window = aWidget.window;
+ this.ownerView = aWidget;
+
+ this._target = this.document.createElement("hbox");
+ this._target.className = "breadcrumbs-widget-item";
+ this._target.setAttribute("align", "center");
+ this.contents = aContents;
+}
+
+Breadcrumb.prototype = {
+ /**
+ * Sets the contents displayed in this item's view.
+ *
+ * @param string | nsIDOMNode aContents
+ * The string or node displayed in the container.
+ */
+ set contents(aContents) {
+ // If there are already some contents displayed, replace them.
+ if (this._target.hasChildNodes()) {
+ this._target.replaceChild(aContents, this._target.firstChild);
+ return;
+ }
+ // These are the first contents ever displayed.
+ this._target.appendChild(aContents);
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ _target: null
+};
diff --git a/devtools/client/shared/widgets/Chart.jsm b/devtools/client/shared/widgets/Chart.jsm
new file mode 100644
index 000000000..0894a62ca
--- /dev/null
+++ b/devtools/client/shared/widgets/Chart.jsm
@@ -0,0 +1,449 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 = Components.utils;
+
+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 { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+const EventEmitter = require("devtools/shared/event-emitter");
+const { LocalizationHelper } = require("devtools/shared/l10n");
+
+this.EXPORTED_SYMBOLS = ["Chart"];
+
+/**
+ * Localization convenience methods.
+ */
+var 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 nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode 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 nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode 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 nsIDOMNode 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 nsIDocument 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 }) {
+ if (data && sorted) {
+ data = data.slice().sort((a, b) => +(a.size < b.size));
+ }
+
+ let pie = Chart.Pie(document, {
+ width: diameter,
+ data: data
+ });
+
+ let table = Chart.Table(document, {
+ title: title,
+ data: data,
+ strings: strings,
+ totals: totals
+ });
+
+ let container = document.createElement("hbox");
+ container.className = "pie-table-chart-container";
+ container.appendChild(pie.node);
+ container.appendChild(table.node);
+
+ let proxy = new PieTableChart(container, pie, table);
+
+ pie.on("click", (event, item) => {
+ proxy.emit(event, item);
+ });
+
+ table.on("click", (event, item) => {
+ proxy.emit(event, item);
+ });
+
+ pie.on("mouseover", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).setAttribute("focused", "");
+ }
+ });
+
+ pie.on("mouseout", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).removeAttribute("focused");
+ }
+ });
+
+ table.on("mouseover", (event, item) => {
+ proxy.emit(event, item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).setAttribute("focused", "");
+ }
+ });
+
+ table.on("mouseout", (event, item) => {
+ proxy.emit(event, 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 nsIDocument 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;
+ }
+
+ let 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);
+
+ let proxy = new PieChart(container);
+
+ let total = data.reduce((acc, e) => acc + e.size, 0);
+ let angles = data.map(e => e.size / total * (TAU - EPSILON));
+ let largest = data.reduce((a, b) => a.size > b.size ? a : b);
+ let smallest = data.reduce((a, b) => a.size < b.size ? a : b);
+
+ let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO;
+ let 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--) {
+ let sliceInfo = data[i];
+ let sliceAngle = angles[i];
+ if (!sliceInfo.size || sliceAngle < EPSILON) {
+ continue;
+ }
+
+ endAngle = startAngle - sliceAngle;
+ midAngle = (startAngle + endAngle) / 2;
+
+ let x1 = centerX + radius * Math.sin(startAngle);
+ let y1 = centerY - radius * Math.cos(startAngle);
+ let x2 = centerX + radius * Math.sin(endAngle);
+ let y2 = centerY - radius * Math.cos(endAngle);
+ let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0;
+
+ let 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", "");
+ }
+
+ let hoverX = translateDistance * Math.sin(midAngle);
+ let hoverY = -translateDistance * Math.cos(midAngle);
+ let 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) {
+ let textX = centerX + textDistance * Math.sin(midAngle);
+ let textY = centerY - textDistance * Math.cos(midAngle);
+ let 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 nsIDocument 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
+ * }
+ * @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 }) {
+ strings = strings || {};
+ totals = totals || {};
+ 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;
+ }
+
+ let container = document.createElement("vbox");
+ 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);
+
+ let proxy = new TableChart(container);
+
+ let titleNode = document.createElement("label");
+ titleNode.className = "plain table-chart-title";
+ titleNode.setAttribute("value", title);
+ container.appendChild(titleNode);
+
+ let tableNode = document.createElement("vbox");
+ tableNode.className = "plain table-chart-grid";
+ container.appendChild(tableNode);
+
+ for (let rowInfo of data) {
+ let rowNode = document.createElement("hbox");
+ rowNode.className = "table-chart-row";
+ rowNode.setAttribute("align", "center");
+
+ let boxNode = document.createElement("hbox");
+ boxNode.className = "table-chart-row-box chart-colored-blob";
+ boxNode.setAttribute("name", rowInfo.label);
+ rowNode.appendChild(boxNode);
+
+ for (let [key, value] of Object.entries(rowInfo)) {
+ let index = data.indexOf(rowInfo);
+ let stringified = strings[key] ? strings[key](value, index) : value;
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-row-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", stringified);
+ rowNode.appendChild(labelNode);
+ }
+
+ proxy.rows.set(rowInfo, rowNode);
+ delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo);
+ tableNode.appendChild(rowNode);
+ }
+
+ let totalsNode = document.createElement("vbox");
+ totalsNode.className = "table-chart-totals";
+
+ for (let [key, value] of Object.entries(totals)) {
+ let total = data.reduce((acc, e) => acc + e[key], 0);
+ let stringified = totals[key] ? totals[key](total || 0) : total;
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-summary-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", stringified);
+ totalsNode.appendChild(labelNode);
+ }
+
+ container.appendChild(totalsNode);
+
+ return proxy;
+}
+
+XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => {
+ return [{ size: 1, label: L10N.getStr("pieChart.loading") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => {
+ return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => {
+ return [{ size: "", label: L10N.getStr("tableChart.loading") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => {
+ return [{ size: "", label: L10N.getStr("tableChart.unavailable") }];
+});
+
+/**
+ * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy.
+ *
+ * @param EventEmitter emitter
+ * The event emitter proxy instance.
+ * @param array events
+ * An array of events, e.g. ["mouseover", "mouseout"].
+ * @param nsIDOMNode 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 (let event of events) {
+ node.addEventListener(event, emitter.emit.bind(emitter, event, args));
+ }
+}
diff --git a/devtools/client/shared/widgets/CubicBezierPresets.js b/devtools/client/shared/widgets/CubicBezierPresets.js
new file mode 100644
index 000000000..d2a77a85c
--- /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 000000000..337282d46
--- /dev/null
+++ b/devtools/client/shared/widgets/CubicBezierWidget.js
@@ -0,0 +1,897 @@
+/**
+ * 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--;) {
+ let 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
+ let 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");
+ let 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() {
+ let 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) {
+ let p = this.padding, w = this.canvas.width, h = this.canvas.height;
+
+ // Convert padding percentage to actual padding
+ p = p.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 = {}) {
+ let xy = this.bezier.coordinates;
+
+ let defaultSettings = {
+ handleColor: "#666",
+ handleThickness: .008,
+ bezierColor: "#4C9ED9",
+ bezierThickness: .015,
+ drawHandles: true
+ };
+
+ for (let 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();
+
+ let 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;
+ let {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.30, 0]);
+ this.bezierCanvas.plot();
+
+ // Place the control points
+ let 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);
+
+ // Add preset preview menu
+ this.presets = new CubicBezierPresetWidget(parent);
+
+ // Add the timing function previewer
+ this.timingPreview = new TimingFunctionPreviewWidget(parent);
+
+ this._initEvents();
+}
+
+exports.CubicBezierWidget = CubicBezierWidget;
+
+CubicBezierWidget.prototype = {
+ _initMarkup: function () {
+ let doc = this.parent.ownerDocument;
+
+ let wrap = doc.createElementNS(XHTML_NS, "div");
+ wrap.className = "display-wrap";
+
+ let plane = doc.createElementNS(XHTML_NS, "div");
+ plane.className = "coordinate-plane";
+
+ let p1 = doc.createElementNS(XHTML_NS, "button");
+ p1.className = "control-point";
+ plane.appendChild(p1);
+
+ let p2 = doc.createElementNS(XHTML_NS, "button");
+ p2.className = "control-point";
+ plane.appendChild(p2);
+
+ let 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
+ };
+ },
+
+ _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();
+
+ let point = event.target;
+ let doc = point.ownerDocument;
+ let self = this;
+
+ doc.onmousemove = function drag(e) {
+ let x = e.pageX;
+ let y = e.pageY;
+ let left = self.curveBoundingBox.left;
+ let 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) {
+ let point = event.target;
+ let code = event.keyCode;
+
+ if (code >= 37 && code <= 40) {
+ event.preventDefault();
+
+ // Arrow keys pressed
+ let left = parseInt(point.style.left, 10);
+ let top = parseInt(point.style.top, 10);
+ let 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();
+
+ let left = this.curveBoundingBox.left;
+ let top = this.curveBoundingBox.top;
+ let x = event.pageX - left;
+ let y = event.pageY - top;
+
+ // Find which point is closer
+ let distP1 = distance(x, y,
+ parseInt(this.p1.style.left, 10), parseInt(this.p1.style.top, 10));
+ let distP2 = distance(x, y,
+ parseInt(this.p2.style.left, 10), parseInt(this.p2.style.top, 10));
+
+ let point = distP1 < distP2 ? this.p1 : this.p2;
+ point.style.left = x + "px";
+ point.style.top = y + "px";
+
+ this._updateFromPoints();
+ },
+
+ _onNewCoordinates: function (event, 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);
+
+ this.timingPreview.preview(this.bezierCanvas.bezier + "");
+ },
+
+ /**
+ * Set new coordinates for the control points and redraw the curve
+ * @param {Array} coordinates
+ */
+ set coordinates(coordinates) {
+ this._redraw(coordinates);
+
+ // Move the points
+ let 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
+ let coordinates = parseTimingFunction(value);
+
+ this.presets.refreshMenu(coordinates);
+ this.coordinates = coordinates;
+ },
+
+ destroy: function () {
+ this._removeEvents();
+ this._removeMarkup();
+
+ this.timingPreview.destroy();
+ 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;
+
+ let {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 () {
+ let doc = this.parent.ownerDocument;
+
+ let presetPane = doc.createElementNS(XHTML_NS, "div");
+ presetPane.className = "preset-pane";
+
+ let categoryList = doc.createElementNS(XHTML_NS, "div");
+ categoryList.id = "preset-categories";
+
+ let presetContainer = doc.createElementNS(XHTML_NS, "div");
+ presetContainer.id = "preset-container";
+
+ Object.keys(PRESETS).forEach(categoryLabel => {
+ let category = this._createCategory(categoryLabel);
+ categoryList.appendChild(category);
+
+ let presetList = this._createPresetList(categoryLabel);
+ presetContainer.appendChild(presetList);
+ });
+
+ presetPane.appendChild(categoryList);
+ presetPane.appendChild(presetContainer);
+
+ this.parent.appendChild(presetPane);
+
+ let allCategories = presetPane.querySelectorAll(".category");
+ let allPresets = presetPane.querySelectorAll(".preset");
+
+ return {
+ presetPane: presetPane,
+ presets: allPresets,
+ categories: allCategories
+ };
+ },
+
+ _createCategory: function (categoryLabel) {
+ let doc = this.parent.ownerDocument;
+
+ let category = doc.createElementNS(XHTML_NS, "div");
+ category.id = categoryLabel;
+ category.classList.add("category");
+
+ let categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel);
+ category.textContent = categoryDisplayLabel;
+ category.setAttribute("title", categoryDisplayLabel);
+
+ return category;
+ },
+
+ _normalizeCategoryLabel: function (categoryLabel) {
+ return categoryLabel.replace("/-/g", " ");
+ },
+
+ _createPresetList: function (categoryLabel) {
+ let doc = this.parent.ownerDocument;
+
+ let presetList = doc.createElementNS(XHTML_NS, "div");
+ presetList.id = "preset-category-" + categoryLabel;
+ presetList.classList.add("preset-list");
+
+ Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
+ let preset = this._createPreset(categoryLabel, presetLabel);
+ presetList.appendChild(preset);
+ });
+
+ return presetList;
+ },
+
+ _createPreset: function (categoryLabel, presetLabel) {
+ let doc = this.parent.ownerDocument;
+
+ let preset = doc.createElementNS(XHTML_NS, "div");
+ preset.classList.add("preset");
+ preset.id = presetLabel;
+ preset.coordinates = PRESETS[categoryLabel][presetLabel];
+ // Create preset preview
+ let curve = doc.createElementNS(XHTML_NS, "canvas");
+ let 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
+ let presetLabelElem = doc.createElementNS(XHTML_NS, "p");
+ let 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 (let category of this.categories) {
+ category.addEventListener("click", this._onCategoryClick);
+ }
+
+ for (let preset of this.presets) {
+ preset.addEventListener("click", this._onPresetClick);
+ }
+ },
+
+ _removeEvents: function () {
+ for (let category of this.categories) {
+ category.removeEventListener("click", this._onCategoryClick);
+ }
+
+ for (let 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) {
+ let 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.autoRestartAnimation = null;
+
+ this.parent = parent;
+ this._initMarkup();
+}
+
+TimingFunctionPreviewWidget.prototype = {
+ PREVIEW_DURATION: 1000,
+
+ _initMarkup: function () {
+ let doc = this.parent.ownerDocument;
+
+ let 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);
+
+ let scale = doc.createElementNS(XHTML_NS, "div");
+ scale.className = "scale";
+ container.appendChild(scale);
+
+ this.parent.appendChild(container);
+ },
+
+ destroy: function () {
+ clearTimeout(this.autoRestartAnimation);
+ 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;
+ }
+
+ clearTimeout(this.autoRestartAnimation);
+
+ if (parseTimingFunction(value)) {
+ this.dot.style.animationTimingFunction = value;
+ this.restartAnimation();
+ }
+
+ this.previousValue = value;
+ },
+
+ /**
+ * Re-start the preview animation from the beginning
+ */
+ restartAnimation: function () {
+ // Just toggling the class won't do it unless there's a sync reflow
+ this.dot.animate([
+ { left: "-7px", offset: 0 },
+ { left: "143px", offset: 0.25 },
+ { left: "143px", offset: 0.5 },
+ { left: "-7px", offset: 0.75 },
+ { left: "-7px", offset: 1 }
+ ], {
+ duration: (this.PREVIEW_DURATION * 2),
+ fill: "forwards"
+ });
+
+ // Restart it again after a while
+ this.autoRestartAnimation = setTimeout(this.restartAnimation.bind(this),
+ this.PREVIEW_DURATION * 2);
+ }
+};
+
+// Helpers
+
+function getPadding(padding) {
+ let 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];
+ }
+
+ let tokenStream = getCSSLexer(value);
+ let getNextToken = () => {
+ while (true) {
+ let 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;
+ }
+
+ let 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;
+}
+
+// This is exported for testing.
+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/FastListWidget.js b/devtools/client/shared/widgets/FastListWidget.js
new file mode 100644
index 000000000..d005ead51
--- /dev/null
+++ b/devtools/client/shared/widgets/FastListWidget.js
@@ -0,0 +1,249 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
+
+/**
+ * A list menu widget that attempts to be very fast.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * view-helpers.js.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ */
+const FastListWidget = module.exports = function FastListWidget(node) {
+ this.document = node.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = node;
+ this._fragment = this.document.createDocumentFragment();
+
+ // This is a prototype element that each item added to the list clones.
+ this._templateElement = this.document.createElement("hbox");
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "fast-list-widget-container theme-body";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._list.setAttribute("tabindex", "0");
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e),
+ false);
+ this._parent.appendChild(this._list);
+
+ this._orderedMenuElementsArray = [];
+ this._itemsByElement = new Map();
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, node);
+ ViewHelpers.delegateWidgetEventMethods(this, node);
+};
+
+FastListWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index, optionally
+ * grouping by name.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node to be displayed in the container.
+ * @param Object aAttachment [optional]
+ * Extra data for the user.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function (index, contents, attachment = {}) {
+ let element = this._templateElement.cloneNode();
+ element.appendChild(contents);
+
+ if (index >= 0) {
+ throw new Error("FastListWidget only supports appending items.");
+ }
+
+ this._fragment.appendChild(element);
+ this._orderedMenuElementsArray.push(element);
+ this._itemsByElement.set(element, this);
+
+ return element;
+ },
+
+ /**
+ * This is a non-standard widget implementation method. When appending items,
+ * they are queued in a document fragment. This method appends the document
+ * fragment to the dom.
+ */
+ flush: function () {
+ this._list.appendChild(this._fragment);
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function () {
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+
+ this._orderedMenuElementsArray.length = 0;
+ this._itemsByElement.clear();
+ },
+
+ /**
+ * Remove the given item.
+ */
+ removeChild: function (child) {
+ throw new Error("Not yet implemented");
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode child
+ */
+ set selectedItem(child) {
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (!child) {
+ this._selectedItem = null;
+ }
+ for (let node of menuArray) {
+ if (node == child) {
+ node.classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ node.classList.remove("selected");
+ }
+ }
+
+ this.ensureElementIsVisible(this.selectedItem);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number index
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function (index) {
+ return this._orderedMenuElementsArray[index];
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string name
+ * The name of the attribute.
+ * @param string value
+ * The desired attribute value.
+ */
+ setAttribute: function (name, value) {
+ this._parent.setAttribute(name, value);
+
+ if (name == "emptyText") {
+ this._textWhenEmpty = value;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string name
+ * The name of the attribute.
+ */
+ removeAttribute: function (name) {
+ this._parent.removeAttribute(name);
+
+ if (name == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode element
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function (element) {
+ if (!element) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(element);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(value) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", value);
+ }
+ this._emptyTextValue = value;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function () {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain fast-list-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyText: function () {
+ if (!this._emptyTextNode) {
+ return;
+ }
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _orderedMenuElementsArray: null,
+ _itemsByElement: null,
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
diff --git a/devtools/client/shared/widgets/FilterWidget.js b/devtools/client/shared/widgets/FilterWidget.js
new file mode 100644
index 000000000..9cdb27a5a
--- /dev/null
+++ b/devtools/client/shared/widgets/FilterWidget.js
@@ -0,0 +1,1073 @@
+/* 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 { Cc, Ci } = require("chrome");
+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");
+
+loader.lazyGetter(this, "DOMUtils", () => {
+ return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
+});
+
+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 {nsIDOMNode} el
+ * The widget container.
+ * @param {String} value
+ * CSS filter value
+ * @param {Function} cssIsValid
+ * Test whether css name / value is valid.
+ */
+function CSSFilterEditorWidget(el, value = "", cssIsValid) {
+ this.doc = el.ownerDocument;
+ this.win = this.doc.defaultView;
+ this.el = el;
+ this._cssIsValid = cssIsValid;
+
+ 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 () {
+ let filterListSelectPlaceholder =
+ L10N.getStr("filterListSelectPlaceholder");
+ let addNewFilterButton = L10N.getStr("addNewFilterButton");
+ let presetsToggleButton = L10N.getStr("presetsToggleButton");
+ let newPresetPlaceholder = L10N.getStr("newPresetPlaceholder");
+ let savePresetButton = L10N.getStr("savePresetButton");
+
+ this.el.innerHTML = `
+ <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>
+ `;
+ this.filtersList = this.el.querySelector("#filters");
+ this.presetsList = this.el.querySelector("#presets");
+ this.togglePresets = this.el.querySelector("#toggle-presets");
+ this.filterSelect = this.el.querySelector("select");
+ this.addPresetButton = this.el.querySelector(".presets-list .add");
+ this.addPresetInput = this.el.querySelector(".presets-list .footer input");
+
+ this.el.querySelector(".presets-list input").value = "";
+
+ this._populateFilterSelect();
+ },
+
+ _destroyMarkup: function () {
+ this._filterItemMarkup.remove();
+ this.el.remove();
+ this.el = this.filtersList = this._filterItemMarkup = null;
+ this.presetsList = 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 () {
+ let select = this.filterSelect;
+ filterList.forEach(filter => {
+ let option = this.doc.createElementNS(XHTML_NS, "option");
+ option.innerHTML = option.value = filter.name;
+ select.appendChild(option);
+ });
+ },
+
+ /**
+ * Creates a template for filter elements which is cloned and used in render
+ */
+ _buildFilterItemMarkup: function () {
+ let base = this.doc.createElementNS(XHTML_NS, "div");
+ base.className = "filter";
+
+ let name = this.doc.createElementNS(XHTML_NS, "div");
+ name.className = "filter-name";
+
+ let value = this.doc.createElementNS(XHTML_NS, "div");
+ value.className = "filter-value";
+
+ let drag = this.doc.createElementNS(XHTML_NS, "i");
+ drag.title = L10N.getStr("dragHandleTooltipText");
+
+ let label = this.doc.createElementNS(XHTML_NS, "label");
+
+ name.appendChild(drag);
+ name.appendChild(label);
+
+ let unitPreview = this.doc.createElementNS(XHTML_NS, "span");
+ let input = this.doc.createElementNS(XHTML_NS, "input");
+ input.classList.add("devtools-textinput");
+
+ value.appendChild(input);
+ value.appendChild(unitPreview);
+
+ let 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 () {
+ let base = this.doc.createElementNS(XHTML_NS, "div");
+ base.classList.add("preset");
+
+ let name = this.doc.createElementNS(XHTML_NS, "label");
+ base.appendChild(name);
+
+ let value = this.doc.createElementNS(XHTML_NS, "span");
+ base.appendChild(value);
+
+ let 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.filtersList.addEventListener("click", this._removeButtonClick);
+ this.filtersList.addEventListener("mousedown", this._mouseDown);
+ this.filtersList.addEventListener("keydown", this._keyDown);
+ this.el.addEventListener("mousedown", this._resetFocus);
+
+ this.presetsList.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.filtersList.addEventListener("input", this._input);
+ },
+
+ _removeEventListeners: function () {
+ this.addButton.removeEventListener("click", this._addButtonClick);
+ this.filtersList.removeEventListener("click", this._removeButtonClick);
+ this.filtersList.removeEventListener("mousedown", this._mouseDown);
+ this.filtersList.removeEventListener("keydown", this._keyDown);
+ this.el.removeEventListener("mousedown", this._resetFocus);
+
+ this.presetsList.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.filtersList.removeEventListener("input", this._input);
+ },
+
+ _getFilterElementIndex: function (el) {
+ return [...this.filtersList.children].indexOf(el);
+ },
+
+ _keyDown: function (e) {
+ if (e.target.tagName.toLowerCase() !== "input" ||
+ (e.keyCode !== 40 && e.keyCode !== 38)) {
+ return;
+ }
+ let 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 keypress happened close to a number first.
+ if (filter.unit) {
+ let 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;
+ let num = getNeighbourNumber(input.value, selectionStart);
+ if (!num) {
+ return;
+ }
+
+ let {start, end, value} = num;
+
+ let split = input.value.split("");
+ let computed = fixFloat(value + direction * multiplier);
+ let 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) {
+ let filterEl = e.target.closest(".filter");
+ let index = this._getFilterElementIndex(filterEl);
+ let filter = this.filters[index];
+ let 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) {
+ let 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")) {
+ let label = e.target;
+ let input = filterEl.querySelector("input");
+ let 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;
+ }
+
+ let filterEl = e.target.closest(".filter");
+ let 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.filtersList.getBoundingClientRect();
+ let top = e.pageY - LIST_PADDING;
+ let 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.filtersList.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.filtersList.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.filtersList.insertBefore(filterEl, target);
+ } else {
+ this.filtersList.appendChild(filterEl);
+ }
+
+ filterEl.removeAttribute("style");
+
+ const currentPosition = change * LIST_ITEM_HEIGHT;
+ filterEl.startingY = e.pageY + currentPosition - delta;
+ },
+
+ _dragLabel: function (e) {
+ let dragging = this._dragging;
+
+ let 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;
+ }
+ let filterEl = this.filtersList.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) {
+ let el = e.target;
+ let preset = el.closest(".preset");
+ if (!preset) {
+ return;
+ }
+
+ let 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,
+ ex => console.error(ex));
+ } else {
+ // Or if the click happened on a preset.
+ let p = presets[id];
+
+ this.setCssValue(p.value);
+ this.addPresetInput.value = p.name;
+ }
+ }, ex => console.error(ex));
+ },
+
+ _togglePresets: function () {
+ this.el.classList.toggle("show-presets");
+ this.emit("render");
+ },
+
+ _savePreset: function (e) {
+ e.preventDefault();
+
+ let name = this.addPresetInput.value;
+ let value = this.getCssValue();
+
+ if (!name || !value || SPECIAL_VALUES.has(value)) {
+ this.emit("preset-save-error");
+ return;
+ }
+
+ this.getPresets().then(presets => {
+ let 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,
+ ex => console.error(ex));
+ }, ex => console.error(ex));
+ },
+
+ /**
+ * 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) {
+ this.filtersList.innerHTML = `<p> ${L10N.getStr("emptyFilterList")} <br />
+ ${L10N.getStr("addUsingList")} </p>`;
+ this.emit("render");
+ return;
+ }
+
+ this.filtersList.innerHTML = "";
+
+ let base = this._filterItemMarkup;
+
+ for (let filter of this.filters) {
+ const def = this._definition(filter.name);
+
+ let el = base.cloneNode(true);
+
+ let [name, value] = el.children;
+ let label = name.children[1];
+ let [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.filtersList.appendChild(el);
+ }
+
+ let lastInput =
+ this.filtersList.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.presetsList) {
+ return;
+ }
+
+ if (!presets || !presets.length) {
+ this.presetsList.innerHTML = `<p>${L10N.getStr("emptyPresetList")}</p>`;
+ this.emit("render");
+ return;
+ }
+ let base = this._presetItemMarkup;
+
+ this.presetsList.innerHTML = "";
+
+ for (let [index, preset] of presets.entries()) {
+ let el = base.cloneNode(true);
+
+ let [label, span] = el.children;
+
+ el.dataset.id = index;
+
+ label.textContent = preset.name;
+ span.textContent = preset.value;
+
+ this.presetsList.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) {
+ let 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) {
+ let 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;
+ }, e => console.error(e));
+ },
+
+ setPresets: function (presets) {
+ return asyncStorage.setItem("cssFilterPresets", presets)
+ .catch(e => console.error(e));
+ }
+};
+
+// Fixes JavaScript's float precision
+function fixFloat(a, number) {
+ let 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) {
+ let filters = [];
+ let depth = 0;
+
+ if (SPECIAL_VALUES.has(css)) {
+ return filters;
+ }
+
+ let state = "initial";
+ let name;
+ let contents;
+ for (let 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.
+ let originalText = css.substring(token.startOffset, token.endOffset);
+ let [, 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 000000000..e9d25b345
--- /dev/null
+++ b/devtools/client/shared/widgets/FlameGraph.js
@@ -0,0 +1,1462 @@
+/* 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 { Task } = require("devtools/shared/task");
+const { ViewHelpers, setNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
+const { ELLIPSIS } = require("devtools/shared/l10n");
+
+loader.lazyRequireGetter(this, "defer", "devtools/shared/defer");
+loader.lazyRequireGetter(this, "EventEmitter",
+ "devtools/shared/event-emitter");
+
+loader.lazyRequireGetter(this, "getColor",
+ "devtools/client/shared/theme", true);
+
+loader.lazyRequireGetter(this, "CATEGORY_MAPPINGS",
+ "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",
+ "devtools/client/shared/widgets/Graphs", true);
+loader.lazyRequireGetter(this, "GraphArea",
+ "devtools/client/shared/widgets/Graphs", true);
+loader.lazyRequireGetter(this, "GraphAreaDragger",
+ "devtools/client/shared/widgets/Graphs", true);
+
+const GRAPH_SRC = "chrome://devtools/content/shared/widgets/graphs-frame.xhtml";
+
+// ms
+const GRAPH_RESIZE_EVENTS_DRAIN = 100;
+
+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;
+
+// ms
+const GRAPH_MIN_SELECTION_WIDTH = 0.001;
+
+// px
+const GRAPH_HORIZONTAL_PAN_THRESHOLD = 10;
+const GRAPH_VERTICAL_PAN_THRESHOLD = 30;
+
+const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
+
+// ms
+const TIMELINE_TICKS_MULTIPLE = 5;
+// px
+const TIMELINE_TICKS_SPACING_MIN = 75;
+
+// px
+const OVERVIEW_HEADER_HEIGHT = 16;
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9;
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+// px
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6;
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 5;
+const OVERVIEW_HEADER_TIMELINE_STROKE_COLOR = "rgba(128, 128, 128, 0.5)";
+
+// px
+const FLAME_GRAPH_BLOCK_HEIGHT = 15;
+const FLAME_GRAPH_BLOCK_BORDER = 1;
+const FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 10;
+const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "message-box, Helvetica Neue," +
+ "Helvetica, sans-serif";
+// px
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP = 0;
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT = 3;
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT = 3;
+
+// 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 nsIDOMNode 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._ready = defer();
+
+ this.setTheme();
+
+ AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
+ this._iframe = iframe;
+ this._window = iframe.contentWindow;
+ this._document = iframe.contentDocument;
+ this._pixelRatio = sharpness || this._window.devicePixelRatio;
+
+ let container =
+ this._container = this._document.getElementById("graph-container");
+ container.className = "flame-graph-widget-container graph-widget-container";
+
+ let canvas = this._canvas = this._document.getElementById("graph-canvas");
+ canvas.className = "flame-graph-widget-canvas graph-widget-canvas";
+
+ let 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 = {};
+
+ let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
+ let 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._onKeyPress = this._onKeyPress.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("keypress", this._onKeyPress);
+ this._window.addEventListener("mousemove", this._onMouseMove);
+ this._window.addEventListener("mousedown", this._onMouseDown);
+ this._window.addEventListener("mouseup", this._onMouseUp);
+ this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.addEventListener("resize", this._onResize);
+
+ this._animationId =
+ this._window.requestAnimationFrame(this._onAnimationFrame);
+
+ this._ready.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.promise;
+ },
+
+ /**
+ * Destroys this graph.
+ */
+ destroy: Task.async(function* () {
+ yield this.ready();
+
+ this._window.removeEventListener("keydown", this._onKeyDown);
+ this._window.removeEventListener("keyup", this._onKeyUp);
+ this._window.removeEventListener("keypress", this._onKeyPress);
+ this._window.removeEventListener("mousemove", this._onMouseMove);
+ this._window.removeEventListener("mousedown", this._onMouseDown);
+ this._window.removeEventListener("mouseup", this._onMouseUp);
+ this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+ let 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.
+ */
+ setDataWhenReady: Task.async(function* (data) {
+ yield 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 = {}) {
+ let bounds = this._parent.getBoundingClientRect();
+ let newWidth = this.fixedWidth || bounds.width;
+ let 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();
+
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let 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;
+
+ let canvasWidth = this._width;
+ let pressed = this._keysPressed;
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let selectionScale = canvasWidth / selectionWidth;
+
+ let 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.
+ let 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) {
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let 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) {
+ let { from, to, renderText } = options || {};
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+ let scaledOffset = dataOffset * dataScale;
+
+ let fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ let textPaddingLeft = OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ let textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+ let 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) {
+ let lineLeft = x;
+ let textLeft = lineLeft + textPaddingLeft;
+ let time = Math.round((x / dataScale + dataOffset) / this._pixelRatio);
+ let 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) {
+ let ctx = this._ctx;
+
+ let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+ let 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) {
+ let visibleBlocksInfoStore = [];
+ let minVisibleBlockWidth = this._overflowCharWidth;
+
+ for (let { 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 (let { 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) {
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+ let scaledOffset = dataOffset * dataScale;
+
+ ctx.fillStyle = color;
+ ctx.beginPath();
+
+ for (let block of blocks) {
+ let { x, y, width, height } = block;
+ let rectLeft = x * this._pixelRatio * dataScale - scaledOffset;
+ let rectTop = (y - verticalOffset + OVERVIEW_HEADER_HEIGHT)
+ * this._pixelRatio;
+ let rectWidth = width * this._pixelRatio * dataScale;
+ let 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) {
+ let ctx = this._ctx;
+
+ let { text } = block;
+ let { rectLeft, rectTop, rectWidth, rectHeight } = rect;
+
+ let paddingTop = FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP * this._pixelRatio;
+ let paddingLeft = FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT * this._pixelRatio;
+ let paddingRight = FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT * this._pixelRatio;
+ let 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;
+ }
+
+ let textLeft = rectLeft + paddingLeft;
+ let textTop = rectTop + rectHeight / 2 + paddingTop;
+ let textAvailableWidth = rectWidth - totalHorizontalPadding;
+
+ // Massage the text to fit inside a given width. This clamps the string
+ // at the end to avoid overflowing.
+ let 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) {
+ let cachedWidth = this._textWidthsCache[text];
+ if (cachedWidth) {
+ return cachedWidth;
+ }
+ let 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
+ let start = 32;
+ // "z"
+ let end = 123;
+
+ for (let i = start; i < end; i++) {
+ let 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) {
+ let textWidth = this._getTextWidth(text);
+ if (textWidth < maxWidth) {
+ return text;
+ }
+ if (this._overflowCharWidth > maxWidth) {
+ return "";
+ }
+ for (let i = 1, len = text.length; i <= len; i++) {
+ let trimmedText = text.substring(0, len - i);
+ let 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 "keypress" event on the graph's container.
+ */
+ _onKeyPress: function (e) {
+ ViewHelpers.preventScrolling(e);
+ },
+
+ /**
+ * Listener for the "mousemove" event on the graph's container.
+ */
+ _onMouseMove: function (e) {
+ let {mouseX, mouseY} = this._getRelativeEventCoordinates(e);
+
+ let canvasWidth = this._width;
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let selectionScale = canvasWidth / selectionWidth;
+
+ let horizDrag = this._selectionDragger;
+ let 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) {
+ let horizDiff = Math.abs(horizDrag.origin - mouseX);
+ if (horizDiff > this.horizontalPanThreshold) {
+ this._horizontalDragDirection = Math.sign(horizDrag.origin - mouseX);
+ this._horizontalDragEnabled = true;
+ }
+ let 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) {
+ let 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) {
+ let 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) {
+ let {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) {
+ let {mouseX} = this._getRelativeEventCoordinates(e);
+
+ let canvasWidth = this._width;
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let selectionScale = canvasWidth / selectionWidth;
+
+ switch (e.axis) {
+ case e.VERTICAL_AXIS: {
+ let distFromStart = mouseX;
+ let distFromEnd = canvasWidth - mouseX;
+ let vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY / selectionScale;
+ selection.start -= distFromStart * vector;
+ selection.end += distFromEnd * vector;
+ break;
+ }
+ case e.HORIZONTAL_AXIS: {
+ let 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 () {
+ let boundsStart = this._bounds.start;
+ let 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) {
+ let 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;
+ let spacingMin = TIMELINE_TICKS_SPACING_MIN * this._pixelRatio;
+ let maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
+ let numIters = 0;
+
+ if (dataScale > spacingMin) {
+ return dataScale;
+ }
+
+ while (true) {
+ let 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
+ };
+ }
+
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+ let 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 = []) {
+ let 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.
+
+ let buckets = Array.from({ length: PALLETTE_SIZE }, () => []);
+
+ // 2. Populate the buckets by iterating over every frame in every sample.
+
+ let { 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;
+
+ let inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable);
+ let labelCache = Object.create(null);
+
+ let samplesData = samples.data;
+ let stacksData = stackTable.data;
+
+ let flattenRecursion = options.flattenRecursion;
+
+ // Reused objects.
+ let 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;
+ let prevFrames = [];
+ let sampleFrames = [];
+ let sampleFrameKeys = [];
+
+ for (let i = 1; i < samplesData.length; i++) {
+ let sample = samplesData[i];
+ let 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) {
+ let stackEntry = stacksData[stackIndex];
+ let frameIndex = stackEntry[STACK_FRAME_SLOT];
+
+ // Fetch the stack prefix (i.e. older frames) index.
+ stackIndex = stackEntry[STACK_PREFIX_SLOT];
+
+ // Inflate the frame.
+ let 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) {
+ frameKey = CATEGORY_MAPPINGS[frameKey].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++) {
+ let key = sampleFrameKeys[frameIndex];
+ let 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.
+ let hash = this._getStringHash(key);
+ let 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) {
+ let { 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 000000000..485da2b1b
--- /dev/null
+++ b/devtools/client/shared/widgets/Graphs.js
@@ -0,0 +1,1424 @@
+/* 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 { Task } = require("devtools/shared/task");
+const { setNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
+const { getCurrentZoom } = require("devtools/shared/layout/utils");
+
+loader.lazyRequireGetter(this, "defer", "devtools/shared/defer");
+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.
+
+// ms
+const GRAPH_RESIZE_EVENTS_DRAIN = 100;
+const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075;
+const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1;
+// px
+const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10;
+
+// px
+const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4;
+const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10;
+const GRAPH_MAX_SELECTION_LEFT_PADDING = 1;
+const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1;
+
+// px
+const GRAPH_REGION_LINE_WIDTH = 1;
+const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)";
+
+// px
+const GRAPH_STRIPE_PATTERN_WIDTH = 16;
+const GRAPH_STRIPE_PATTERN_HEIGHT = 16;
+const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 2;
+const GRAPH_STRIPE_PATTERN_LINE_SPACING = 4;
+
+/**
+ * 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 nsIDOMNode 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._ready = defer();
+
+ this._uid = "canvas-graph-" + Date.now();
+ this._renderTargets = new Map();
+
+ AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
+ this._iframe = iframe;
+ this._window = iframe.contentWindow;
+ this._topWindow = this._window.top;
+ this._document = iframe.contentDocument;
+ this._pixelRatio = sharpness || this._window.devicePixelRatio;
+
+ let container =
+ this._container = this._document.getElementById("graph-container");
+ container.className = name + "-widget-container graph-widget-container";
+
+ let canvas = this._canvas = this._document.getElementById("graph-canvas");
+ canvas.className = name + "-widget-canvas graph-widget-canvas";
+
+ let 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);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.addEventListener("resize", this._onResize);
+
+ this._animationId =
+ this._window.requestAnimationFrame(this._onAnimationFrame);
+
+ this._ready.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.promise;
+ },
+
+ /**
+ * Destroys this graph.
+ */
+ destroy: Task.async(function* () {
+ yield 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);
+
+ let 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 () {
+ let 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.
+ */
+ setDataWhenReady: Task.async(function* (data) {
+ yield 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");
+ }
+
+ let { mapStart, mapEnd } = mapping;
+ let startTime = (mapStart || (e => e.delta))(this._data[0]);
+ let 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 };
+ }
+
+ let { mapStart, mapEnd } = mapping;
+ let startTime = (mapStart || (e => e.delta))(this._data[0]);
+ let 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.
+ let 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;
+ }
+ let 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;
+ }
+ let 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 () {
+ let 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;
+ }
+ let { 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 = {}) {
+ let bounds = this._parent.getBoundingClientRect();
+ let newWidth = this.fixedWidth || bounds.width;
+ let 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()) {
+ let 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) {
+ let cachedRenderTarget = this._renderTargets.get(name);
+ if (cachedRenderTarget) {
+ let { canvas, ctx } = cachedRenderTarget;
+ canvas.width = width;
+ canvas.height = height;
+ ctx.clearRect(0, 0, width, height);
+ return cachedRenderTarget;
+ }
+
+ let canvas = this._document.createElementNS(HTML_NS, "canvas");
+ let ctx = canvas.getContext("2d");
+ canvas.width = width;
+ canvas.height = height;
+
+ let 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;
+ }
+ let 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;
+ }
+
+ let 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 () {
+ let { start, end } = this.getSelection();
+ let input = this._canvas.getAttribute("input");
+
+ let ctx = this._ctx;
+ ctx.strokeStyle = this.selectionLineColor;
+
+ // Fill selection.
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: this.selectionBackgroundColor,
+ stripesColor: this.selectionStripesColor
+ });
+ ctx.fillStyle = pattern;
+ let rectStart = Math.min(this._width, Math.max(0, start));
+ let 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) {
+ let ctx = destination.getContext("2d");
+
+ let 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;
+
+ let y = -GRAPH_REGION_LINE_WIDTH;
+ let height = this._height + GRAPH_REGION_LINE_WIDTH;
+
+ for (let { start, end } of regions) {
+ let x = start;
+ let 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;
+ }
+ let { x } = this._cursor;
+ let { start } = this._selection;
+ let 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;
+ }
+ let { x } = this._cursor;
+ let { end } = this._selection;
+ let 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;
+ }
+ let { x } = this._cursor;
+ let { 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) {
+ let topDocument = this._topWindow.document;
+ let boxQuad = this._canvas.getBoxQuads({ relativeTo: topDocument })[0];
+ this._boundingBox = boxQuad;
+ this._maybeDirtyBoundingBox = false;
+ }
+
+ let bb = this._boundingBox;
+ let x = (e.screenX - this._topWindow.screenX) - bb.p1.x;
+ let 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.
+ let maxX = bb.p2.x - bb.p1.x;
+ let 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.
+ let zoom = getCurrentZoom(this._canvas);
+ mouseX /= zoom;
+ mouseY /= zoom;
+
+ return {mouseX, mouseY};
+ },
+
+ /**
+ * Listener for the "mousemove" event on the graph's container.
+ */
+ _onMouseMove: function (e) {
+ let resizer = this._selectionResizer;
+ let 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;
+ }
+
+ let {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;
+ }
+ }
+
+ let 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;
+ let {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) {
+ let 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;
+ }
+
+ let {mouseX} = this._getRelativeEventCoordinates(e);
+ let focusX = mouseX;
+
+ let 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()) {
+ let distStart = selection.start - focusX;
+ let 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.
+
+ let minStart = GRAPH_MAX_SELECTION_LEFT_PADDING;
+ let 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.
+
+ let thickness = Math.abs(selection.start - selection.end);
+ if (thickness < GRAPH_WHEEL_MIN_SELECTION_WIDTH) {
+ let 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 nsIDOMNode 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) {
+ let iframe = parent.ownerDocument.createElementNS(HTML_NS, "iframe");
+
+ iframe.addEventListener("DOMContentLoaded", function onLoad() {
+ iframe.removeEventListener("DOMContentLoaded", onLoad);
+ callback(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";
+ iframe.src = url;
+
+ parent.style.display = "flex";
+ parent.appendChild(iframe);
+};
+
+/**
+ * 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) {
+ let { ownerDocument, backgroundColor, stripesColor } = data;
+ let id = [backgroundColor, stripesColor].join(",");
+
+ if (gCachedStripePattern.has(id)) {
+ return gCachedStripePattern.get(id);
+ }
+
+ let canvas = ownerDocument.createElementNS(HTML_NS, "canvas");
+ let ctx = canvas.getContext("2d");
+ let width = canvas.width = GRAPH_STRIPE_PATTERN_WIDTH;
+ let height = canvas.height = GRAPH_STRIPE_PATTERN_HEIGHT;
+
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, width, height);
+
+ let pixelRatio = ownerDocument.defaultView.devicePixelRatio;
+ let scaledLineWidth = GRAPH_STRIPE_PATTERN_LINE_WIDTH * pixelRatio;
+ let 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();
+
+ let 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.
+ */
+ linkAnimation: Task.async(function* (graph1, graph2) {
+ if (!graph1 || !graph2) {
+ return;
+ }
+ yield graph1.ready();
+ yield graph2.ready();
+
+ let window = graph1._window;
+ window.cancelAnimationFrame(graph1._animationId);
+ window.cancelAnimationFrame(graph2._animationId);
+
+ let 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) {
+ let 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) {
+ let 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 000000000..1e12f1d11
--- /dev/null
+++ b/devtools/client/shared/widgets/GraphsWorker.js
@@ -0,0 +1,103 @@
+/* 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 }) {
+ let plottedData = plotTimestamps(timestamps, interval);
+ let 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) {
+ let 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
+ let avgValue = totalFrames / (duration / 1000);
+
+ for (let { 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) {
+ let timeline = [];
+ let 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++) {
+ let currTime = +timestamps[i];
+ frameCount++;
+
+ let elapsedTime = currTime - prevTime;
+ if (elapsedTime < interval) {
+ continue;
+ }
+
+ let 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 000000000..12ca425ad
--- /dev/null
+++ b/devtools/client/shared/widgets/LineGraphWidget.js
@@ -0,0 +1,402 @@
+"use strict";
+
+const { Task } = require("devtools/shared/task");
+const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
+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;
+// px
+const GRAPH_TOOLTIP_SAFE_BOUNDS = 8;
+const GRAPH_MIN_MAX_TOOLTIP_DISTANCE = 14;
+
+const GRAPH_BACKGROUND_COLOR = "#0088cc";
+// px
+const GRAPH_STROKE_WIDTH = 1;
+const GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)";
+// px
+const GRAPH_HELPER_LINES_DASH = [5];
+const GRAPH_HELPER_LINES_WIDTH = 1;
+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 nsIDOMNode 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) {
+ let { 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 = Heritage.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.
+ */
+ setDataFromTimestamps: Task.async(function* (timestamps, interval, duration) {
+ let {
+ plottedData,
+ plottedMinMaxSum
+ } = yield CanvasGraphUtils._performTaskInWorker("plotTimestampsGraph", {
+ timestamps, interval, duration
+ });
+
+ this._tempMinMaxSum = plottedMinMaxSum;
+ this.setData(plottedData);
+ }),
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function () {
+ let { canvas, ctx } = this._getNamedCanvas("line-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalTicks = this._data.length;
+ let firstTick = totalTicks ? this._data[0].delta : 0;
+ let 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 (let { value } of this._data) {
+ maxValue = Math.max(value, maxValue);
+ minValue = Math.min(value, minValue);
+ sumValues += value;
+ }
+ avgValue = sumValues / totalTicks;
+ }
+
+ let duration = this.dataDuration || lastTick;
+ let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
+ let dataScaleY =
+ this.dataScaleY = height / maxValue * this.dampenValuesFactor;
+
+ // Draw the background.
+
+ ctx.fillStyle = this.backgroundColor;
+ ctx.fillRect(0, 0, width, height);
+
+ // Draw the graph.
+
+ let 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 (let { delta, value } of this._data) {
+ let currX = (delta - this.dataOffsetX) * dataScaleX;
+ let 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) {
+ let width = this._width;
+ let height = this._height;
+ let 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();
+ let 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();
+ let 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();
+ let 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);
+
+ let bottom = height / this._pixelRatio;
+ let maxPosY = CanvasGraphUtils.map(maxValue * this.dampenValuesFactor, 0,
+ maxValue, bottom, 0);
+ let avgPosY = CanvasGraphUtils.map(avgValue * this.dampenValuesFactor, 0,
+ maxValue, bottom, 0);
+ let minPosY = CanvasGraphUtils.map(minValue * this.dampenValuesFactor, 0,
+ maxValue, bottom, 0);
+
+ let safeTop = GRAPH_TOOLTIP_SAFE_BOUNDS;
+ let safeBottom = bottom - GRAPH_TOOLTIP_SAFE_BOUNDS;
+
+ let maxTooltipTop = (this.withFixedTooltipPositions
+ ? safeTop : CanvasGraphUtils.clamp(maxPosY, safeTop, safeBottom));
+ let avgTooltipTop = (this.withFixedTooltipPositions
+ ? safeTop : CanvasGraphUtils.clamp(avgPosY, safeTop, safeBottom));
+ let 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);
+
+ let 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 nsIDOMNode
+ */
+ _createGutter: function () {
+ let 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 nsIDOMNode
+ */
+ _createGutterLine: function (type) {
+ let 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 nsIDOMNode
+ */
+ _createTooltip: function (type, arrow, info, metric) {
+ let 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);
+
+ let infoNode = this._document.createElementNS(HTML_NS, "span");
+ infoNode.textContent = info;
+ infoNode.setAttribute("text", "info");
+
+ let valueNode = this._document.createElementNS(HTML_NS, "span");
+ valueNode.textContent = 0;
+ valueNode.setAttribute("text", "value");
+
+ let 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/MdnDocsWidget.js b/devtools/client/shared/widgets/MdnDocsWidget.js
new file mode 100644
index 000000000..6a26b05c8
--- /dev/null
+++ b/devtools/client/shared/widgets/MdnDocsWidget.js
@@ -0,0 +1,510 @@
+/* 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/. */
+
+/**
+ * This file contains functions to retrieve docs content from
+ * MDN (developer.mozilla.org) for particular items, and to display
+ * the content in a tooltip.
+ *
+ * At the moment it only supports fetching content for CSS properties,
+ * but it might support other types of content in the future
+ * (Web APIs, for example).
+ *
+ * It's split into two parts:
+ *
+ * - functions like getCssDocs that just fetch content from MDN,
+ * without any constraints on what to do with the content. If you
+ * want to embed the content in some custom way, use this.
+ *
+ * - the MdnDocsWidget class, that manages and updates a tooltip
+ * document whose content is taken from MDN. If you want to embed
+ * the content in a tooltip, use this in conjunction with Tooltip.js.
+ */
+
+"use strict";
+
+const Services = require("Services");
+const defer = require("devtools/shared/defer");
+const {getCSSLexer} = require("devtools/shared/css/lexer");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {gDevTools} = require("devtools/client/framework/devtools");
+
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Parameters for the XHR request
+// see https://developer.mozilla.org/en-US/docs/MDN/Kuma/API#Document_parameters
+const XHR_PARAMS = "?raw&macros";
+// URL for the XHR request
+var XHR_CSS_URL = "https://developer.mozilla.org/en-US/docs/Web/CSS/";
+
+// Parameters for the link to MDN in the tooltip, so
+// so we know which MDN visits come from this feature
+const PAGE_LINK_PARAMS =
+ "?utm_source=mozilla&utm_medium=firefox-inspector&utm_campaign=default";
+// URL for the page link omits locale, so a locale-specific page will be loaded
+var PAGE_LINK_URL = "https://developer.mozilla.org/docs/Web/CSS/";
+exports.PAGE_LINK_URL = PAGE_LINK_URL;
+
+const PROPERTY_NAME_COLOR = "theme-fg-color5";
+const PROPERTY_VALUE_COLOR = "theme-fg-color1";
+const COMMENT_COLOR = "theme-comment";
+
+/**
+ * Turns a string containing a series of CSS declarations into
+ * a series of DOM nodes, with classes applied to provide syntax
+ * highlighting.
+ *
+ * It uses the CSS tokenizer to generate a stream of CSS tokens.
+ * https://dxr.mozilla.org/mozilla-central/source/dom/webidl/CSSLexer.webidl
+ * lists all the token types.
+ *
+ * - "whitespace", "comment", and "symbol" tokens are appended as TEXT nodes,
+ * and will inherit the default style for text.
+ *
+ * - "ident" tokens that we think are property names are considered to be
+ * a property name, and are appended as SPAN nodes with a distinct color class.
+ *
+ * - "ident" nodes which we do not think are property names, and nodes
+ * of all other types ("number", "url", "percentage", ...) are considered
+ * to be part of a property value, and are appended as SPAN nodes with
+ * a different color class.
+ *
+ * @param {Document} doc
+ * Used to create nodes.
+ *
+ * @param {String} syntaxText
+ * The CSS input. This is assumed to consist of a series of
+ * CSS declarations, with trailing semicolons.
+ *
+ * @param {DOM node} syntaxSection
+ * This is the parent for the output nodes. Generated nodes
+ * are appended to this as children.
+ */
+function appendSyntaxHighlightedCSS(cssText, parentElement) {
+ let doc = parentElement.ownerDocument;
+ let identClass = PROPERTY_NAME_COLOR;
+ let lexer = getCSSLexer(cssText);
+
+ /**
+ * Create a SPAN node with the given text content and class.
+ */
+ function createStyledNode(textContent, className) {
+ let newNode = doc.createElementNS(XHTML_NS, "span");
+ newNode.classList.add(className);
+ newNode.textContent = textContent;
+ return newNode;
+ }
+
+ /**
+ * If the symbol is ":", we will expect the next
+ * "ident" token to be part of a property value.
+ *
+ * If the symbol is ";", we will expect the next
+ * "ident" token to be a property name.
+ */
+ function updateIdentClass(tokenText) {
+ if (tokenText === ":") {
+ identClass = PROPERTY_VALUE_COLOR;
+ } else if (tokenText === ";") {
+ identClass = PROPERTY_NAME_COLOR;
+ }
+ }
+
+ /**
+ * Create the appropriate node for this token type.
+ *
+ * If this token is a symbol, also update our expectations
+ * for what the next "ident" token represents.
+ */
+ function tokenToNode(token, tokenText) {
+ switch (token.tokenType) {
+ case "ident":
+ return createStyledNode(tokenText, identClass);
+ case "symbol":
+ updateIdentClass(tokenText);
+ return doc.createTextNode(tokenText);
+ case "whitespace":
+ return doc.createTextNode(tokenText);
+ case "comment":
+ return createStyledNode(tokenText, COMMENT_COLOR);
+ default:
+ return createStyledNode(tokenText, PROPERTY_VALUE_COLOR);
+ }
+ }
+
+ let token = lexer.nextToken();
+ while (token) {
+ let tokenText = cssText.slice(token.startOffset, token.endOffset);
+ let newNode = tokenToNode(token, tokenText);
+ parentElement.appendChild(newNode);
+ token = lexer.nextToken();
+ }
+}
+
+exports.appendSyntaxHighlightedCSS = appendSyntaxHighlightedCSS;
+
+/**
+ * Fetch an MDN page.
+ *
+ * @param {string} pageUrl
+ * URL of the page to fetch.
+ *
+ * @return {promise}
+ * The promise is resolved with the page as an XML document.
+ *
+ * The promise is rejected with an error message if
+ * we could not load the page.
+ */
+function getMdnPage(pageUrl) {
+ let deferred = defer();
+
+ let xhr = new XMLHttpRequest();
+
+ xhr.addEventListener("load", onLoaded, false);
+ xhr.addEventListener("error", onError, false);
+
+ xhr.open("GET", pageUrl);
+ xhr.responseType = "document";
+ xhr.send();
+
+ function onLoaded(e) {
+ if (xhr.status != 200) {
+ deferred.reject({page: pageUrl, status: xhr.status});
+ } else {
+ deferred.resolve(xhr.responseXML);
+ }
+ }
+
+ function onError(e) {
+ deferred.reject({page: pageUrl, status: xhr.status});
+ }
+
+ return deferred.promise;
+}
+
+/**
+ * Gets some docs for the given CSS property.
+ * Loads an MDN page for the property and gets some
+ * information about the property.
+ *
+ * @param {string} cssProperty
+ * The property for which we want docs.
+ *
+ * @return {promise}
+ * The promise is resolved with an object containing:
+ * - summary: a short summary of the property
+ * - syntax: some example syntax
+ *
+ * The promise is rejected with an error message if
+ * we could not load the page.
+ */
+function getCssDocs(cssProperty) {
+ let deferred = defer();
+ let pageUrl = XHR_CSS_URL + cssProperty + XHR_PARAMS;
+
+ getMdnPage(pageUrl).then(parseDocsFromResponse, handleRejection);
+
+ function parseDocsFromResponse(responseDocument) {
+ let theDocs = {};
+ theDocs.summary = getSummary(responseDocument);
+ theDocs.syntax = getSyntax(responseDocument);
+ if (theDocs.summary || theDocs.syntax) {
+ deferred.resolve(theDocs);
+ } else {
+ deferred.reject("Couldn't find the docs in the page.");
+ }
+ }
+
+ function handleRejection(e) {
+ deferred.reject(e.status);
+ }
+
+ return deferred.promise;
+}
+
+exports.getCssDocs = getCssDocs;
+
+/**
+ * The MdnDocsWidget is used by tooltip code that needs to display docs
+ * from MDN in a tooltip.
+ *
+ * In the constructor, the widget does some general setup that's not
+ * dependent on the particular item we need docs for.
+ *
+ * After that, when the tooltip code needs to display docs for an item, it
+ * asks the widget to retrieve the docs and update the document with them.
+ *
+ * @param {Element} tooltipContainer
+ * A DOM element where the MdnDocs widget markup should be created.
+ */
+function MdnDocsWidget(tooltipContainer) {
+ EventEmitter.decorate(this);
+
+ tooltipContainer.innerHTML =
+ `<header>
+ <h1 class="mdn-property-name theme-fg-color5"></h1>
+ </header>
+ <div class="mdn-property-info">
+ <div class="mdn-summary"></div>
+ <pre class="mdn-syntax devtools-monospace"></pre>
+ </div>
+ <footer>
+ <a class="mdn-visit-page theme-link" href="#">Visit MDN (placeholder)</a>
+ </footer>`;
+
+ // fetch all the bits of the document that we will manipulate later
+ this.elements = {
+ heading: tooltipContainer.querySelector(".mdn-property-name"),
+ summary: tooltipContainer.querySelector(".mdn-summary"),
+ syntax: tooltipContainer.querySelector(".mdn-syntax"),
+ info: tooltipContainer.querySelector(".mdn-property-info"),
+ linkToMdn: tooltipContainer.querySelector(".mdn-visit-page")
+ };
+
+ // get the localized string for the link text
+ this.elements.linkToMdn.textContent = L10N.getStr("docsTooltip.visitMDN");
+
+ // listen for clicks and open in the browser window instead
+ let mainWindow = Services.wm.getMostRecentWindow(gDevTools.chromeWindowType);
+ this.elements.linkToMdn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ mainWindow.openUILinkIn(e.target.href, "tab");
+ this.emit("visitlink");
+ });
+}
+
+exports.MdnDocsWidget = MdnDocsWidget;
+
+MdnDocsWidget.prototype = {
+ /**
+ * This is called just before the tooltip is displayed, and is
+ * passed the CSS property for which we want to display help.
+ *
+ * Its job is to make sure the document contains the docs
+ * content for that CSS property.
+ *
+ * First, it initializes the document, setting the things it can
+ * set synchronously, resetting the things it needs to get
+ * asynchronously, and making sure the throbber is throbbing.
+ *
+ * Then it tries to get the content asynchronously, updating
+ * the document with the content or with an error message.
+ *
+ * It returns immediately, so the caller can display the tooltip
+ * without waiting for the asynch operation to complete.
+ *
+ * @param {string} propertyName
+ * The name of the CSS property for which we need to display help.
+ */
+ loadCssDocs: function (propertyName) {
+ /**
+ * Do all the setup we can do synchronously, and get the document in
+ * a state where it can be displayed while we are waiting for the
+ * MDN docs content to be retrieved.
+ */
+ function initializeDocument(propName) {
+ // set property name heading
+ elements.heading.textContent = propName;
+
+ // set link target
+ elements.linkToMdn.setAttribute("href",
+ PAGE_LINK_URL + propName + PAGE_LINK_PARAMS);
+
+ // clear docs summary and syntax
+ elements.summary.textContent = "";
+ while (elements.syntax.firstChild) {
+ elements.syntax.firstChild.remove();
+ }
+
+ // reset the scroll position
+ elements.info.scrollTop = 0;
+ elements.info.scrollLeft = 0;
+
+ // show the throbber
+ elements.info.classList.add("devtools-throbber");
+ }
+
+ /**
+ * This is called if we successfully got the docs content.
+ * Finishes setting up the tooltip content, and disables the throbber.
+ */
+ function finalizeDocument({summary, syntax}) {
+ // set docs summary and syntax
+ elements.summary.textContent = summary;
+ appendSyntaxHighlightedCSS(syntax, elements.syntax);
+
+ // hide the throbber
+ elements.info.classList.remove("devtools-throbber");
+
+ deferred.resolve(this);
+ }
+
+ /**
+ * This is called if we failed to get the docs content.
+ * Sets the content to contain an error message, and disables the throbber.
+ */
+ function gotError(error) {
+ // show error message
+ elements.summary.textContent = L10N.getStr("docsTooltip.loadDocsError");
+
+ // hide the throbber
+ elements.info.classList.remove("devtools-throbber");
+
+ // although gotError is called when there's an error, we have handled
+ // the error, so call resolve not reject.
+ deferred.resolve(this);
+ }
+
+ let deferred = defer();
+ let elements = this.elements;
+
+ initializeDocument(propertyName);
+ getCssDocs(propertyName).then(finalizeDocument, gotError);
+
+ return deferred.promise;
+ },
+
+ destroy: function () {
+ this.elements = null;
+ }
+};
+
+/**
+ * Test whether a node is all whitespace.
+ *
+ * @return {boolean}
+ * True if the node all whitespace, otherwise false.
+ */
+function isAllWhitespace(node) {
+ return !(/[^\t\n\r ]/.test(node.textContent));
+}
+
+/**
+ * Test whether a node is a comment or whitespace node.
+ *
+ * @return {boolean}
+ * True if the node is a comment node or is all whitespace, otherwise false.
+ */
+function isIgnorable(node) {
+ // Comment nodes (8), text nodes (3) or whitespace
+ return (node.nodeType == 8) ||
+ ((node.nodeType == 3) && isAllWhitespace(node));
+}
+
+/**
+ * Get the next node, skipping comments and whitespace.
+ *
+ * @return {node}
+ * The next sibling node that is not a comment or whitespace, or null if
+ * there isn't one.
+ */
+function nodeAfter(sib) {
+ while ((sib = sib.nextSibling)) {
+ if (!isIgnorable(sib)) {
+ return sib;
+ }
+ }
+ return null;
+}
+
+/**
+ * Test whether the argument `node` is a node whose tag is `tagName`.
+ *
+ * @param {node} node
+ * The code to test. May be null.
+ *
+ * @param {string} tagName
+ * The tag name to test against.
+ *
+ * @return {boolean}
+ * True if the node is not null and has the tag name `tagName`,
+ * otherwise false.
+ */
+function hasTagName(node, tagName) {
+ return node && node.tagName &&
+ node.tagName.toLowerCase() == tagName.toLowerCase();
+}
+
+/**
+ * Given an MDN page, get the "summary" portion.
+ *
+ * This is the textContent of the first non-whitespace
+ * element in the #Summary section of the document.
+ *
+ * It's expected to be a <P> element.
+ *
+ * @param {Document} mdnDocument
+ * The document in which to look for the "summary" section.
+ *
+ * @return {string}
+ * The summary section as a string, or null if it could not be found.
+ */
+function getSummary(mdnDocument) {
+ let summary = mdnDocument.getElementById("Summary");
+ if (!hasTagName(summary, "H2")) {
+ return null;
+ }
+
+ let firstParagraph = nodeAfter(summary);
+ if (!hasTagName(firstParagraph, "P")) {
+ return null;
+ }
+
+ return firstParagraph.textContent;
+}
+
+/**
+ * Given an MDN page, get the "syntax" portion.
+ *
+ * First we get the #Syntax section of the document. The syntax
+ * section we want is somewhere inside there.
+ *
+ * If the page is in the old structure, then the *first two*
+ * non-whitespace elements in the #Syntax section will be <PRE>
+ * nodes, and the second of these will be the syntax section.
+ *
+ * If the page is in the new structure, then the only the *first*
+ * non-whitespace element in the #Syntax section will be a <PRE>
+ * node, and it will be the syntax section.
+ *
+ * @param {Document} mdnDocument
+ * The document in which to look for the "syntax" section.
+ *
+ * @return {string}
+ * The syntax section as a string, or null if it could not be found.
+ */
+function getSyntax(mdnDocument) {
+ let syntax = mdnDocument.getElementById("Syntax");
+ if (!hasTagName(syntax, "H2")) {
+ return null;
+ }
+
+ let firstParagraph = nodeAfter(syntax);
+ if (!hasTagName(firstParagraph, "PRE")) {
+ return null;
+ }
+
+ let secondParagraph = nodeAfter(firstParagraph);
+ if (hasTagName(secondParagraph, "PRE")) {
+ return secondParagraph.textContent;
+ }
+ return firstParagraph.textContent;
+}
+
+/**
+ * Use a different URL for CSS docs pages. Used only for testing.
+ *
+ * @param {string} baseUrl
+ * The baseURL to use.
+ */
+function setBaseCssDocsUrl(baseUrl) {
+ PAGE_LINK_URL = baseUrl;
+ XHR_CSS_URL = baseUrl;
+}
+
+exports.setBaseCssDocsUrl = setBaseCssDocsUrl;
diff --git a/devtools/client/shared/widgets/MountainGraphWidget.js b/devtools/client/shared/widgets/MountainGraphWidget.js
new file mode 100644
index 000000000..394ac4584
--- /dev/null
+++ b/devtools/client/shared/widgets/MountainGraphWidget.js
@@ -0,0 +1,195 @@
+"use strict";
+
+const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
+const { AbstractCanvasGraph } = require("devtools/client/shared/widgets/Graphs");
+
+// Bar graph constants.
+
+const GRAPH_DAMPEN_VALUES_FACTOR = 0.9;
+
+const GRAPH_BACKGROUND_COLOR = "#ddd";
+// px
+const GRAPH_STROKE_WIDTH = 1;
+const GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)";
+// px
+const GRAPH_HELPER_LINES_DASH = [5];
+const GRAPH_HELPER_LINES_WIDTH = 1;
+
+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 nsIDOMNode parent
+ * The parent node holding the graph.
+ */
+this.MountainGraphWidget = function (parent, ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "mountain-graph", ...args]);
+};
+
+MountainGraphWidget.prototype = Heritage.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 () {
+ let { canvas, ctx } = this._getNamedCanvas("mountain-graph-background");
+ let width = this._width;
+ let 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.");
+ }
+ let { canvas, ctx } = this._getNamedCanvas("mountain-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalSections = this.format.length;
+ let totalTicks = this._data.length;
+ let firstTick = totalTicks ? this._data[0].delta : 0;
+ let lastTick = totalTicks ? this._data[totalTicks - 1].delta : 0;
+
+ let duration = this.dataDuration || lastTick;
+ let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
+ let dataScaleY = this.dataScaleY = height * this.dampenValuesFactor;
+
+ // Draw the graph.
+
+ let 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++) {
+ let { delta, values } = this._data[tick];
+ let currX = (delta - this.dataOffsetX) * dataScaleX;
+ let currY = values[section] * dataScaleY;
+ let 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();
+ let maximumY = height * this.dampenValuesFactor;
+ ctx.moveTo(0, maximumY);
+ ctx.lineTo(width, maximumY);
+ ctx.stroke();
+
+ // Draw the average value horizontal line.
+
+ ctx.beginPath();
+ let 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/SideMenuWidget.jsm b/devtools/client/shared/widgets/SideMenuWidget.jsm
new file mode 100644
index 000000000..0c132f232
--- /dev/null
+++ b/devtools/client/shared/widgets/SideMenuWidget.jsm
@@ -0,0 +1,725 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const SHARED_STRINGS_URI = "devtools/client/locales/shared.properties";
+
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const EventEmitter = require("devtools/shared/event-emitter");
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
+
+this.EXPORTED_SYMBOLS = ["SideMenuWidget"];
+
+/**
+ * Localization convenience methods.
+ */
+var L10N = new LocalizationHelper(SHARED_STRINGS_URI);
+
+/**
+ * A simple side menu, with the ability of grouping menu items.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * view-helpers.js.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ * @param Object aOptions
+ * - contextMenu: optional element or element ID that serves as a context menu.
+ * - showArrows: specifies if items should display horizontal arrows.
+ * - showItemCheckboxes: specifies if items should display checkboxes.
+ * - showGroupCheckboxes: specifies if groups should display checkboxes.
+ */
+this.SideMenuWidget = function SideMenuWidget(aNode, aOptions = {}) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ let { contextMenu, showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions;
+ this._contextMenu = contextMenu || null;
+ this._showArrows = showArrows || false;
+ this._showItemCheckboxes = showItemCheckboxes || false;
+ this._showGroupCheckboxes = showGroupCheckboxes || false;
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "side-menu-widget-container theme-sidebar";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._list.setAttribute("with-arrows", this._showArrows);
+ this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes);
+ this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes);
+ this._list.setAttribute("tabindex", "0");
+ this._list.addEventListener("contextmenu", e => this._showContextMenu(e), false);
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
+ this._parent.appendChild(this._list);
+
+ // Menu items can optionally be grouped.
+ this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings.
+ this._orderedGroupElementsArray = [];
+ this._orderedMenuElementsArray = [];
+ this._itemsByElement = new Map();
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+};
+
+SideMenuWidget.prototype = {
+ /**
+ * Specifies if groups in this container should be sorted.
+ */
+ sortedGroups: true,
+
+ /**
+ * The comparator used to sort groups.
+ */
+ groupSortPredicate: (a, b) => a.localeCompare(b),
+
+ /**
+ * Inserts an item in this container at the specified index, optionally
+ * grouping by name.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * Some attached primitive/object. Custom options supported:
+ * - group: a string specifying the group to place this item into
+ * - checkboxState: the checked state of the checkbox, if shown
+ * - checkboxTooltip: the tooltip text for the checkbox, if shown
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function (aIndex, aContents, aAttachment = {}) {
+ let group = this._getMenuGroupForName(aAttachment.group);
+ let item = this._getMenuItemForGroup(group, aContents, aAttachment);
+ let element = item.insertSelfAt(aIndex);
+
+ return element;
+ },
+
+ /**
+ * Checks to see if the list is scrolled all the way to the bottom.
+ * Uses getBoundsWithoutFlushing to limit the performance impact
+ * of this function.
+ *
+ * @return bool
+ */
+ isScrolledToBottom: function () {
+ if (this._list.lastElementChild) {
+ let utils = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let childRect = utils.getBoundsWithoutFlushing(this._list.lastElementChild);
+ let listRect = utils.getBoundsWithoutFlushing(this._list);
+
+ // Cheap way to check if it's scrolled all the way to the bottom.
+ return (childRect.height + childRect.top) <= listRect.bottom;
+ }
+
+ return false;
+ },
+
+ /**
+ * Scroll the list to the bottom after a timeout.
+ * If the user scrolls in the meantime, cancel this operation.
+ */
+ scrollToBottom: function () {
+ this._list.scrollTop = this._list.scrollHeight;
+ this.emit("scroll-to-bottom");
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function (aIndex) {
+ return this._orderedMenuElementsArray[aIndex];
+ },
+
+ /**
+ * Removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function (aChild) {
+ this._getNodeForContents(aChild).remove();
+
+ this._orderedMenuElementsArray.splice(
+ this._orderedMenuElementsArray.indexOf(aChild), 1);
+
+ this._itemsByElement.delete(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function () {
+ let parent = this._parent;
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+
+ this._groupsByName.clear();
+ this._orderedGroupElementsArray.length = 0;
+ this._orderedMenuElementsArray.length = 0;
+ this._itemsByElement.clear();
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of menuArray) {
+ if (node == aChild) {
+ this._getNodeForContents(node).classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ this._getNodeForContents(node).classList.remove("selected");
+ }
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function (aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(aElement);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Shows all the groups, even the ones with no visible children.
+ */
+ showEmptyGroups: function () {
+ for (let group of this._orderedGroupElementsArray) {
+ group.hidden = false;
+ }
+ },
+
+ /**
+ * Hides all the groups which have no visible children.
+ */
+ hideEmptyGroups: function () {
+ let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])";
+
+ for (let group of this._orderedGroupElementsArray) {
+ group.hidden = group.querySelectorAll(visibleChildNodes).length == 0;
+ }
+ for (let menuItem of this._orderedMenuElementsArray) {
+ menuItem.parentNode.hidden = menuItem.hidden;
+ }
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @param string aValue
+ * The desired attribute value.
+ */
+ setAttribute: function (aName, aValue) {
+ this._parent.setAttribute(aName, aValue);
+
+ if (aName == "emptyText") {
+ this._textWhenEmpty = aValue;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ */
+ removeAttribute: function (aName) {
+ this._parent.removeAttribute(aName);
+
+ if (aName == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Set the checkbox state for the item associated with the given node.
+ *
+ * @param nsIDOMNode aNode
+ * The dom node for an item we want to check.
+ * @param boolean aCheckState
+ * True to check, false to uncheck.
+ */
+ checkItem: function (aNode, aCheckState) {
+ const widgetItem = this._itemsByElement.get(aNode);
+ if (!widgetItem) {
+ throw new Error("No item for " + aNode);
+ }
+ widgetItem.check(aCheckState);
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function () {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain side-menu-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label representing a notice in this container.
+ */
+ _removeEmptyText: function () {
+ if (!this._emptyTextNode) {
+ return;
+ }
+
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ /**
+ * Gets a container representing a group for menu items. If the container
+ * is not available yet, it is immediately created.
+ *
+ * @param string aName
+ * The required group name.
+ * @return SideMenuGroup
+ * The newly created group.
+ */
+ _getMenuGroupForName: function (aName) {
+ let cachedGroup = this._groupsByName.get(aName);
+ if (cachedGroup) {
+ return cachedGroup;
+ }
+
+ let group = new SideMenuGroup(this, aName, {
+ showCheckbox: this._showGroupCheckboxes
+ });
+
+ this._groupsByName.set(aName, group);
+ group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1);
+
+ return group;
+ },
+
+ /**
+ * Gets a menu item to be displayed inside a group.
+ * @see SideMenuWidget.prototype._getMenuGroupForName
+ *
+ * @param SideMenuGroup aGroup
+ * The group to contain the menu item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * Some attached primitive/object.
+ */
+ _getMenuItemForGroup: function (aGroup, aContents, aAttachment) {
+ return new SideMenuItem(aGroup, aContents, aAttachment, {
+ showArrow: this._showArrows,
+ showCheckbox: this._showItemCheckboxes
+ });
+ },
+
+ /**
+ * Returns the .side-menu-widget-item node corresponding to a SideMenuItem.
+ * To optimize the markup, some redundant elemenst are skipped when creating
+ * these child items, in which case we need to be careful on which nodes
+ * .selected class names are added, or which nodes are removed.
+ *
+ * @param nsIDOMNode aChild
+ * An element which is the target node of a SideMenuItem.
+ * @return nsIDOMNode
+ * The wrapper node if there is one, or the same child otherwise.
+ */
+ _getNodeForContents: function (aChild) {
+ if (aChild.hasAttribute("merged-item-contents")) {
+ return aChild;
+ } else {
+ return aChild.parentNode;
+ }
+ },
+
+ /**
+ * Shows the contextMenu element.
+ */
+ _showContextMenu: function (e) {
+ if (!this._contextMenu) {
+ return;
+ }
+
+ // Don't show the menu if a descendant node is going to be visible also.
+ let node = e.originalTarget;
+ while (node && node !== this._list) {
+ if (node.hasAttribute("contextmenu")) {
+ return;
+ }
+ node = node.parentNode;
+ }
+
+ this._contextMenu.openPopupAtScreen(e.screenX, e.screenY, true);
+ },
+
+ window: null,
+ document: null,
+ _showArrows: false,
+ _showItemCheckboxes: false,
+ _showGroupCheckboxes: false,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _groupsByName: null,
+ _orderedGroupElementsArray: null,
+ _orderedMenuElementsArray: null,
+ _itemsByElement: null,
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
+
+/**
+ * A SideMenuGroup constructor for the BreadcrumbsWidget.
+ * Represents a group which should contain SideMenuItems.
+ *
+ * @param SideMenuWidget aWidget
+ * The widget to contain this menu item.
+ * @param string aName
+ * The string displayed in the container.
+ * @param object aOptions [optional]
+ * An object containing the following properties:
+ * - showCheckbox: specifies if a checkbox should be displayed.
+ */
+function SideMenuGroup(aWidget, aName, aOptions = {}) {
+ this.document = aWidget.document;
+ this.window = aWidget.window;
+ this.ownerView = aWidget;
+ this.identifier = aName;
+
+ // Create an internal title and list container.
+ if (aName) {
+ let target = this._target = this.document.createElement("vbox");
+ target.className = "side-menu-widget-group";
+ target.setAttribute("name", aName);
+
+ let list = this._list = this.document.createElement("vbox");
+ list.className = "side-menu-widget-group-list";
+
+ let title = this._title = this.document.createElement("hbox");
+ title.className = "side-menu-widget-group-title";
+
+ let name = this._name = this.document.createElement("label");
+ name.className = "plain name";
+ name.setAttribute("value", aName);
+ name.setAttribute("crop", "end");
+ name.setAttribute("flex", "1");
+
+ // Show a checkbox before the content.
+ if (aOptions.showCheckbox) {
+ let checkbox = this._checkbox = makeCheckbox(title, {
+ description: aName,
+ checkboxTooltip: L10N.getStr("sideMenu.groupCheckbox.tooltip")
+ });
+ checkbox.className = "side-menu-widget-group-checkbox";
+ }
+
+ title.appendChild(name);
+ target.appendChild(title);
+ target.appendChild(list);
+ }
+ // Skip a few redundant nodes when no title is shown.
+ else {
+ let target = this._target = this._list = this.document.createElement("vbox");
+ target.className = "side-menu-widget-group side-menu-widget-group-list";
+ target.setAttribute("merged-group-contents", "");
+ }
+}
+
+SideMenuGroup.prototype = {
+ get _orderedGroupElementsArray() {
+ return this.ownerView._orderedGroupElementsArray;
+ },
+ get _orderedMenuElementsArray() {
+ return this.ownerView._orderedMenuElementsArray;
+ },
+ get _itemsByElement() { return this.ownerView._itemsByElement; },
+
+ /**
+ * Inserts this group in the parent container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this group.
+ */
+ insertSelfAt: function (aIndex) {
+ let ownerList = this.ownerView._list;
+ let groupsArray = this._orderedGroupElementsArray;
+
+ if (aIndex >= 0) {
+ ownerList.insertBefore(this._target, groupsArray[aIndex]);
+ groupsArray.splice(aIndex, 0, this._target);
+ } else {
+ ownerList.appendChild(this._target);
+ groupsArray.push(this._target);
+ }
+ },
+
+ /**
+ * Finds the expected index of this group based on its name.
+ *
+ * @return number
+ * The expected index.
+ */
+ findExpectedIndexForSelf: function (sortPredicate) {
+ let identifier = this.identifier;
+ let groupsArray = this._orderedGroupElementsArray;
+
+ for (let group of groupsArray) {
+ let name = group.getAttribute("name");
+ if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :)
+ !name.includes(identifier)) { // Least significant group should be last.
+ return groupsArray.indexOf(group);
+ }
+ }
+ return -1;
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ identifier: "",
+ _target: null,
+ _checkbox: null,
+ _title: null,
+ _name: null,
+ _list: null
+};
+
+/**
+ * A SideMenuItem constructor for the BreadcrumbsWidget.
+ *
+ * @param SideMenuGroup aGroup
+ * The group to contain this menu item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * The attachment object.
+ * @param object aOptions [optional]
+ * An object containing the following properties:
+ * - showArrow: specifies if a horizontal arrow should be displayed.
+ * - showCheckbox: specifies if a checkbox should be displayed.
+ */
+function SideMenuItem(aGroup, aContents, aAttachment = {}, aOptions = {}) {
+ this.document = aGroup.document;
+ this.window = aGroup.window;
+ this.ownerView = aGroup;
+
+ if (aOptions.showArrow || aOptions.showCheckbox) {
+ let container = this._container = this.document.createElement("hbox");
+ container.className = "side-menu-widget-item";
+
+ let target = this._target = this.document.createElement("vbox");
+ target.className = "side-menu-widget-item-contents";
+
+ // Show a checkbox before the content.
+ if (aOptions.showCheckbox) {
+ let checkbox = this._checkbox = makeCheckbox(container, aAttachment);
+ checkbox.className = "side-menu-widget-item-checkbox";
+ }
+
+ container.appendChild(target);
+
+ // Show a horizontal arrow towards the content.
+ if (aOptions.showArrow) {
+ let arrow = this._arrow = this.document.createElement("hbox");
+ arrow.className = "side-menu-widget-item-arrow";
+ container.appendChild(arrow);
+ }
+ }
+ // Skip a few redundant nodes when no horizontal arrow or checkbox is shown.
+ else {
+ let target = this._target = this._container = this.document.createElement("hbox");
+ target.className = "side-menu-widget-item side-menu-widget-item-contents";
+ target.setAttribute("merged-item-contents", "");
+ }
+
+ this._target.setAttribute("flex", "1");
+ this.contents = aContents;
+}
+
+SideMenuItem.prototype = {
+ get _orderedGroupElementsArray() {
+ return this.ownerView._orderedGroupElementsArray;
+ },
+ get _orderedMenuElementsArray() {
+ return this.ownerView._orderedMenuElementsArray;
+ },
+ get _itemsByElement() { return this.ownerView._itemsByElement; },
+
+ /**
+ * Inserts this item in the parent group at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertSelfAt: function (aIndex) {
+ let ownerList = this.ownerView._list;
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (aIndex >= 0) {
+ ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]);
+ menuArray.splice(aIndex, 0, this._target);
+ } else {
+ ownerList.appendChild(this._container);
+ menuArray.push(this._target);
+ }
+ this._itemsByElement.set(this._target, this);
+
+ return this._target;
+ },
+
+ /**
+ * Check or uncheck the checkbox associated with this item.
+ *
+ * @param boolean aCheckState
+ * True to check, false to uncheck.
+ */
+ check: function (aCheckState) {
+ if (!this._checkbox) {
+ throw new Error("Cannot check items that do not have checkboxes.");
+ }
+ // Don't set or remove the "checked" attribute, assign the property instead.
+ // Otherwise, the "CheckboxStateChange" event will not be fired. XUL!!
+ this._checkbox.checked = !!aCheckState;
+ },
+
+ /**
+ * Sets the contents displayed in this item's view.
+ *
+ * @param string | nsIDOMNode aContents
+ * The string or node displayed in the container.
+ */
+ set contents(aContents) {
+ // If there are already some contents displayed, replace them.
+ if (this._target.hasChildNodes()) {
+ this._target.replaceChild(aContents, this._target.firstChild);
+ return;
+ }
+ // These are the first contents ever displayed.
+ this._target.appendChild(aContents);
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ _target: null,
+ _container: null,
+ _checkbox: null,
+ _arrow: null
+};
+
+/**
+ * Creates a checkbox to a specified parent node. Emits a "check" event
+ * whenever the checkbox is checked or unchecked by the user.
+ *
+ * @param nsIDOMNode aParentNode
+ * The parent node to contain this checkbox.
+ * @param object aOptions
+ * An object containing some or all of the following properties:
+ * - description: defaults to "item" if unspecified
+ * - checkboxState: true for checked, false for unchecked
+ * - checkboxTooltip: the tooltip text of the checkbox
+ */
+function makeCheckbox(aParentNode, aOptions) {
+ let checkbox = aParentNode.ownerDocument.createElement("checkbox");
+
+ checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip || "");
+
+ if (aOptions.checkboxState) {
+ checkbox.setAttribute("checked", true);
+ } else {
+ checkbox.removeAttribute("checked");
+ }
+
+ // Stop the toggling of the checkbox from selecting the list item.
+ checkbox.addEventListener("mousedown", e => {
+ e.stopPropagation();
+ }, false);
+
+ // Emit an event from the checkbox when it is toggled. Don't listen for the
+ // "command" event! It won't fire for programmatic changes. XUL!!
+ checkbox.addEventListener("CheckboxStateChange", e => {
+ ViewHelpers.dispatchEvent(checkbox, "check", {
+ description: aOptions.description || "item",
+ checked: checkbox.checked
+ });
+ }, false);
+
+ aParentNode.appendChild(checkbox);
+ return checkbox;
+}
diff --git a/devtools/client/shared/widgets/SimpleListWidget.jsm b/devtools/client/shared/widgets/SimpleListWidget.jsm
new file mode 100644
index 000000000..ec47ab0da
--- /dev/null
+++ b/devtools/client/shared/widgets/SimpleListWidget.jsm
@@ -0,0 +1,255 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
+
+this.EXPORTED_SYMBOLS = ["SimpleListWidget"];
+
+/**
+ * A very simple vertical list view.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * view-helpers.js.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ */
+function SimpleListWidget(aNode) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ // Create an internal list container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "simple-list-widget-container theme-body";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._parent.appendChild(this._list);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by WidgetMethods instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+}
+this.SimpleListWidget = SimpleListWidget;
+
+SimpleListWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function (aIndex, aContents) {
+ aContents.classList.add("simple-list-widget-item");
+
+ let list = this._list;
+ return list.insertBefore(aContents, list.childNodes[aIndex]);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function (aIndex) {
+ return this._list.childNodes[aIndex];
+ },
+
+ /**
+ * Immediately removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function (aChild) {
+ this._list.removeChild(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function () {
+ let list = this._list;
+ let parent = this._parent;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ parent.scrollTop = 0;
+ parent.scrollLeft = 0;
+ this._selectedItem = null;
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let childNodes = this._list.childNodes;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of childNodes) {
+ if (node == aChild) {
+ node.classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ node.classList.remove("selected");
+ }
+ }
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @param string aValue
+ * The desired attribute value.
+ */
+ setAttribute: function (aName, aValue) {
+ this._parent.setAttribute(aName, aValue);
+
+ if (aName == "emptyText") {
+ this._textWhenEmpty = aValue;
+ } else if (aName == "headerText") {
+ this._textAsHeader = aValue;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ */
+ removeAttribute: function (aName) {
+ this._parent.removeAttribute(aName);
+
+ if (aName == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function (aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(aElement);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Sets the text displayed permanently in this container as a header.
+ * @param string aValue
+ */
+ set _textAsHeader(aValue) {
+ if (this._headerTextNode) {
+ this._headerTextNode.setAttribute("value", aValue);
+ }
+ this._headerTextValue = aValue;
+ this._showHeaderText();
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label displayed as this container's header.
+ */
+ _showHeaderText: function () {
+ if (this._headerTextNode || !this._headerTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain simple-list-widget-perma-text";
+ label.setAttribute("value", this._headerTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._headerTextNode = label;
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function () {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain simple-list-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.appendChild(label);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyText: function () {
+ if (!this._emptyTextNode) {
+ return;
+ }
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _headerTextNode: null,
+ _headerTextValue: "",
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
diff --git a/devtools/client/shared/widgets/Spectrum.js b/devtools/client/shared/widgets/Spectrum.js
new file mode 100644
index 000000000..00110f13e
--- /dev/null
+++ b/devtools/client/shared/widgets/Spectrum.js
@@ -0,0 +1,336 @@
+/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * 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", (event, 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
+ */
+function Spectrum(parentEl, rgb) {
+ EventEmitter.decorate(this);
+
+ this.element = parentEl.ownerDocument.createElementNS(XHTML_NS, "div");
+ this.parentEl = parentEl;
+
+ this.element.className = "spectrum-container";
+ this.element.innerHTML = `
+ <div class="spectrum-top">
+ <div class="spectrum-fill"></div>
+ <div class="spectrum-top-inner">
+ <div class="spectrum-color spectrum-box">
+ <div class="spectrum-sat">
+ <div class="spectrum-val">
+ <div class="spectrum-dragger"></div>
+ </div>
+ </div>
+ </div>
+ <div class="spectrum-hue spectrum-box">
+ <div class="spectrum-slider spectrum-slider-control"></div>
+ </div>
+ </div>
+ </div>
+ <div class="spectrum-alpha spectrum-checker spectrum-box">
+ <div class="spectrum-alpha-inner">
+ <div class="spectrum-alpha-handle spectrum-slider-control"></div>
+ </div>
+ </div>
+ `;
+
+ this.onElementClick = this.onElementClick.bind(this);
+ this.element.addEventListener("click", this.onElementClick, false);
+
+ this.parentEl.appendChild(this.element);
+
+ this.slider = this.element.querySelector(".spectrum-hue");
+ this.slideHelper = this.element.querySelector(".spectrum-slider");
+ Spectrum.draggable(this.slider, this.onSliderMove.bind(this));
+
+ this.dragger = this.element.querySelector(".spectrum-color");
+ this.dragHelper = this.element.querySelector(".spectrum-dragger");
+ Spectrum.draggable(this.dragger, this.onDraggerMove.bind(this));
+
+ this.alphaSlider = this.element.querySelector(".spectrum-alpha");
+ this.alphaSliderInner = this.element.querySelector(".spectrum-alpha-inner");
+ this.alphaSliderHelper = this.element.querySelector(".spectrum-alpha-handle");
+ Spectrum.draggable(this.alphaSliderInner, this.onAlphaSliderMove.bind(this));
+
+ if (rgb) {
+ this.rgb = rgb;
+ this.updateUI();
+ }
+}
+
+module.exports.Spectrum = Spectrum;
+
+Spectrum.hsvToRgb = function (h, s, v, a) {
+ let r, g, b;
+
+ let i = Math.floor(h * 6);
+ let f = h * 6 - i;
+ let p = v * (1 - s);
+ let q = v * (1 - f * s);
+ let 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];
+};
+
+Spectrum.rgbToHsv = function (r, g, b, a) {
+ r = r / 255;
+ g = g / 255;
+ b = b / 255;
+
+ let max = Math.max(r, g, b), min = Math.min(r, g, b);
+ let h, s, v = max;
+
+ let d = max - min;
+ s = max == 0 ? 0 : d / max;
+
+ 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];
+};
+
+Spectrum.draggable = function (element, onmove, onstart, onstop) {
+ onmove = onmove || function () {};
+ onstart = onstart || function () {};
+ onstop = onstop || function () {};
+
+ let doc = element.ownerDocument;
+ let dragging = false;
+ let offset = {};
+ let maxHeight = 0;
+ let maxWidth = 0;
+
+ 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;
+ }
+ let pageX = e.pageX;
+ let pageY = e.pageY;
+
+ let dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
+ let dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+ }
+
+ function start(e) {
+ let rightclick = e.which === 3;
+
+ if (!rightclick && !dragging) {
+ if (onstart.apply(element, arguments) !== false) {
+ dragging = true;
+ maxHeight = element.offsetHeight;
+ maxWidth = element.offsetWidth;
+
+ offset = element.getBoundingClientRect();
+
+ move(e);
+
+ doc.addEventListener("selectstart", prevent, false);
+ doc.addEventListener("dragstart", prevent, false);
+ doc.addEventListener("mousemove", move, false);
+ doc.addEventListener("mouseup", stop, false);
+
+ prevent(e);
+ }
+ }
+ }
+
+ function stop() {
+ if (dragging) {
+ doc.removeEventListener("selectstart", prevent, false);
+ doc.removeEventListener("dragstart", prevent, false);
+ doc.removeEventListener("mousemove", move, false);
+ doc.removeEventListener("mouseup", stop, false);
+ onstop.apply(element, arguments);
+ }
+ dragging = false;
+ }
+
+ element.addEventListener("mousedown", start, false);
+};
+
+Spectrum.prototype = {
+ set rgb(color) {
+ this.hsv = Spectrum.rgbToHsv(color[0], color[1], color[2], color[3]);
+ },
+
+ get rgb() {
+ let rgb = Spectrum.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];
+ },
+
+ get rgbNoSatVal() {
+ let rgb = Spectrum.hsvToRgb(this.hsv[0], 1, 1);
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
+ },
+
+ get rgbCssString() {
+ let rgb = this.rgb;
+ return "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " +
+ rgb[3] + ")";
+ },
+
+ show: function () {
+ this.element.classList.add("spectrum-show");
+
+ this.slideHeight = this.slider.offsetHeight;
+ this.dragWidth = this.dragger.offsetWidth;
+ this.dragHeight = this.dragger.offsetHeight;
+ this.dragHelperHeight = this.dragHelper.offsetHeight;
+ this.slideHelperHeight = this.slideHelper.offsetHeight;
+ this.alphaSliderWidth = this.alphaSliderInner.offsetWidth;
+ this.alphaSliderHelperWidth = this.alphaSliderHelper.offsetWidth;
+
+ this.updateUI();
+ },
+
+ onElementClick: function (e) {
+ e.stopPropagation();
+ },
+
+ onSliderMove: function (dragX, dragY) {
+ this.hsv[0] = (dragY / this.slideHeight);
+ this.updateUI();
+ this.onChange();
+ },
+
+ onDraggerMove: function (dragX, dragY) {
+ this.hsv[1] = dragX / this.dragWidth;
+ this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
+ this.updateUI();
+ this.onChange();
+ },
+
+ onAlphaSliderMove: function (dragX, dragY) {
+ this.hsv[3] = dragX / this.alphaSliderWidth;
+ this.updateUI();
+ this.onChange();
+ },
+
+ onChange: function () {
+ this.emit("changed", this.rgb, this.rgbCssString);
+ },
+
+ updateHelperLocations: function () {
+ // If the UI hasn't been shown yet then none of the dimensions will be
+ // correct
+ if (!this.element.classList.contains("spectrum-show")) {
+ return;
+ }
+
+ let h = this.hsv[0];
+ let s = this.hsv[1];
+ let v = this.hsv[2];
+
+ // Placing the color dragger
+ let dragX = s * this.dragWidth;
+ let dragY = this.dragHeight - (v * this.dragHeight);
+ let 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
+ let slideY = (h * this.slideHeight) - this.slideHelperHeight / 2;
+ this.slideHelper.style.top = slideY + "px";
+
+ // Placing the alpha slider
+ let alphaSliderX = (this.hsv[3] * this.alphaSliderWidth) -
+ (this.alphaSliderHelperWidth / 2);
+ this.alphaSliderHelper.style.left = alphaSliderX + "px";
+ },
+
+ updateUI: function () {
+ this.updateHelperLocations();
+
+ let rgb = this.rgb;
+ let rgbNoSatVal = this.rgbNoSatVal;
+
+ let flatColor = "rgb(" + rgbNoSatVal[0] + ", " + rgbNoSatVal[1] + ", " +
+ rgbNoSatVal[2] + ")";
+
+ this.dragger.style.backgroundColor = flatColor;
+
+ let rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
+ let rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
+ let alphaGradient = "linear-gradient(to right, " + rgbAlpha0 + ", " +
+ rgbNoAlpha + ")";
+ this.alphaSliderInner.style.background = alphaGradient;
+ },
+
+ destroy: function () {
+ this.element.removeEventListener("click", this.onElementClick, false);
+
+ this.parentEl.removeChild(this.element);
+
+ this.slider = null;
+ this.dragger = null;
+ this.alphaSlider = this.alphaSliderInner = this.alphaSliderHelper = null;
+ this.parentEl = null;
+ this.element = null;
+ }
+};
diff --git a/devtools/client/shared/widgets/TableWidget.js b/devtools/client/shared/widgets/TableWidget.js
new file mode 100644
index 000000000..5dacd1b67
--- /dev/null
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -0,0 +1,1817 @@
+/* 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, "setNamedTimeout",
+ "devtools/client/shared/widgets/view-helpers", true);
+loader.lazyRequireGetter(this, "clearNamedTimeout",
+ "devtools/client/shared/widgets/view-helpers", true);
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+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 {nsIDOMNode} 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: text to display when 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;
+
+ let {initialColumns, emptyText, uniqueId, highlightUpdated, removableColumns,
+ firstColumn, wrapTextInElements, cellContextMenuId} = 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.tbody = this.document.createElementNS(XUL_NS, "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));
+
+ this.placeholder = this.document.createElementNS(XUL_NS, "label");
+ this.placeholder.className = "plain table-widget-empty-text";
+ this.placeholder.setAttribute("flex", "1");
+ this._parent.appendChild(this.placeholder);
+
+ 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);
+ } else if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+
+ this.bindSelectedRow = (event, 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, false);
+ this.document.addEventListener("mousedown", this.onMousedown, false);
+}
+
+TableWidget.prototype = {
+
+ items: 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 (let column of this.columns.values()) {
+ column.selectRow(id[this.uniqueId] || id);
+ }
+ },
+
+ /**
+ * 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 (let 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() {
+ let cells = this.columns.get(this.uniqueId).visibleCellNodes;
+
+ for (let i = 0; i < cells.length; i++) {
+ if (cells[i].classList.contains("theme-selected")) {
+ return i;
+ }
+ }
+
+ return -1;
+ },
+
+ /**
+ * returns all editable columns.
+ */
+ get editableColumns() {
+ let filter = columns => {
+ columns = [...columns].filter(col => {
+ if (col.clientWidth === 0) {
+ return false;
+ }
+
+ let cell = col.querySelector(".table-widget-cell");
+
+ for (let selector of this._editableFieldsEngine.selectors) {
+ if (cell.matches(selector)) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ return columns;
+ };
+
+ let columns = this._parent.querySelectorAll(".table-widget-column");
+ return filter(columns);
+ },
+
+ /**
+ * Emit all cell edit events.
+ */
+ onChange: function (type, data) {
+ let changedField = data.change.field;
+ let colName = changedField.parentNode.id;
+ let column = this.columns.get(colName);
+ let uniqueId = column.table.uniqueId;
+ let itemIndex = column.cellNodes.indexOf(changedField);
+ let items = {};
+
+ for (let [name, col] of this.columns) {
+ items[name] = col.cellNodes[itemIndex].value;
+ }
+
+ let 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) {
+ let textbox = event.target;
+ let editor = this._editableFieldsEngine;
+
+ if (textbox.id !== editor.INPUT_ID) {
+ return;
+ }
+
+ let 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, (e, uniqueId) => {
+ let cell;
+ let cells;
+ let columnObj;
+ let cols = this.editableColumns;
+ let rowIndex = this.visibleSelectedIndex;
+ let 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) {
+ let id = cols[0].id;
+ columnObj = this.columns.get(id);
+ let maxRowIndex = columnObj.visibleCellNodes.length - 1;
+ if (rowIndex === maxRowIndex) {
+ return;
+ }
+ newIndex = 0;
+ } else {
+ newIndex = colIndex + 1;
+ }
+
+ let newcol = cols[newIndex];
+ columnObj = this.columns.get(newcol.id);
+
+ // Select the correct row even if it has moved due to sorting.
+ let 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.
+ cells = columnObj.visibleCellNodes;
+ 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.
+ let cell = this.getEditedCellOnTab(event, column);
+ editor.edit(cell);
+ },
+
+ /**
+ * 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;
+ let cols = this.editableColumns;
+ let rowIndex = this.visibleSelectedIndex;
+ let colIndex = cols.indexOf(column);
+ let maxCol = cols.length - 1;
+ let 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];
+ let cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex - 1];
+
+ let rowId = cell.getAttribute("data-id");
+ this.emit(EVENTS.ROW_SELECTED, rowId);
+ } else {
+ column = cols[colIndex - 1];
+ let 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];
+ let cells = this.columns.get(column.id).visibleCellNodes;
+ cell = cells[rowIndex + 1];
+
+ let rowId = cell.getAttribute("data-id");
+ this.emit(EVENTS.ROW_SELECTED, rowId);
+ } else {
+ // Navigate forwards on tab.
+ column = cols[colIndex + 1];
+ let 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 (event, row) {
+ if (!this._editableFieldsEngine || !this._editableFieldsEngine.isEditing) {
+ return;
+ }
+
+ let removedKey = row[this.uniqueId];
+ let 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 () {
+ let 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;
+ }
+
+ let selectedCell = this.tbody.querySelector(".theme-selected");
+ 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}) {
+ let nodeName = target.nodeName;
+
+ if (nodeName === "textbox" || !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) {
+ let selectors = [];
+
+ if (typeof editableColumns === "string") {
+ editableColumns = [editableColumns];
+ }
+
+ for (let id of editableColumns) {
+ selectors.push("#" + id + " .table-widget-cell");
+ }
+
+ for (let [name, column] of this.columns) {
+ if (!editableColumns.includes(name)) {
+ column.column.setAttribute("readonly", "");
+ }
+ }
+
+ if (this._editableFieldsEngine) {
+ this._editableFieldsEngine.selectors = selectors;
+ } else {
+ this._editableFieldsEngine = new EditableFieldsEngine({
+ root: this.tbody,
+ onTab: this.onEditorTab,
+ onTriggerEvent: "dblclick",
+ selectors: selectors
+ });
+
+ 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, false);
+ this.document.removeEventListener("mousedown", this.onMousedown, false);
+
+ 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 text to be shown when the table is empty.
+ */
+ setPlaceholderText: function (text) {
+ this.placeholder.setAttribute("value", text);
+ },
+
+ /**
+ * 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.createElementNS(XUL_NS, "popupset");
+ this.document.documentElement.appendChild(popupset);
+ }
+
+ this.menupopup = this.document.createElementNS(XUL_NS, "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.
+ */
+ populateMenuPopup: function () {
+ if (!this.menupopup) {
+ return;
+ }
+
+ while (this.menupopup.firstChild) {
+ this.menupopup.firstChild.remove();
+ }
+
+ for (let column of this.columns.values()) {
+ let menuitem = this.document.createElementNS(XUL_NS, "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);
+ }
+ let 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) {
+ let item = event.originalTarget;
+ let checked = !!item.getAttribute("checked");
+ let id = item.getAttribute("data-id");
+ this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked);
+ checked = this.menupopup.querySelectorAll("menuitem[checked]");
+ let 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.
+ */
+ setColumns: function (columns, sortOn = this.sortedOn, hiddenColumns = []) {
+ for (let 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 (let 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.indexOf(id) > -1) {
+ this.columns.get(id).toggleColumn();
+ }
+ }
+ this.sortedOn = sortOn;
+ this.sortBy(this.sortedOn);
+ this.populateMenuPopup();
+ },
+
+ /**
+ * 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 (let 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 (let 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;
+ }
+
+ let index = this.columns.get(this.sortedOn).push(item);
+ for (let [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;
+ }
+ let removed = this.items.delete(item[this.uniqueId]);
+
+ if (!removed) {
+ return;
+ }
+ for (let column of this.columns.values()) {
+ column.remove(item);
+ column.updateZebra();
+ }
+ if (this.items.size == 0) {
+ 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) {
+ let oldItem = this.items.get(item[this.uniqueId]);
+ if (!oldItem) {
+ return;
+ }
+ this.items.set(item[this.uniqueId], item);
+
+ let changed = false;
+ for (let 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 (let column of this.columns.values()) {
+ column.clear();
+ }
+ this.tbody.setAttribute("empty", "empty");
+ this.setPlaceholderText(this.emptyText);
+
+ 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;
+ }
+
+ let sortedItems = this.columns.get(column).sort([...this.items.values()]);
+ for (let [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();
+
+ let itemsToHide = [...this.items.keys()];
+ // Loop through all items and hide unmatched items
+ for (let [id, val] of this.items) {
+ for (let prop in val) {
+ if (ignoreProps.includes(prop)) {
+ continue;
+ }
+ let 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 () {
+ let scrollHeight = this.tbody.getBoundingClientRect().height -
+ this.tbody.querySelector(".table-widget-column-header").clientHeight;
+
+ // Emit scroll-end event when 9/10 of the table is scrolled
+ if (this.tbody.scrollTop >= 0.9 * scrollHeight) {
+ 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) {
+ 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.createElementNS(XUL_NS, "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.createElementNS(XUL_NS, "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.createElementNS(XUL_NS, "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;
+ },
+
+ /**
+ * 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() {
+ let editor = this.table._editableFieldsEngine;
+ let 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 (event, 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 (event, itemsToHide) {
+ this._updateItems();
+ if (!this.cells) {
+ return;
+ }
+ for (let cell of this.cells) {
+ cell.hidden = false;
+ }
+ for (let id of itemsToHide) {
+ this.cells[this.items[id]].hidden = true;
+ }
+ this.updateZebra();
+ },
+
+ /**
+ * Called when a row is updated.
+ *
+ * @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 (event, id) {
+ this._updateItems();
+ if (this.highlightUpdated && this.items[id] != 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]].toggleClass("theme-selected");
+ }
+ if (index < 0) {
+ this.selectedRow = null;
+ return;
+ }
+ let cell = this.cells[index];
+ cell.toggleClass("theme-selected");
+ this.selectedRow = cell.id;
+ },
+
+ /**
+ * 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) {
+ let value = item[this.id];
+
+ if (this.sorted) {
+ let index;
+ if (this.sorted == 1) {
+ index = this.cells.findIndex(element => {
+ return value < element.value;
+ });
+ } else {
+ index = this.cells.findIndex(element => {
+ return value > element.value;
+ });
+ }
+ 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 (event, 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");
+ } else {
+ this.wrapper.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Removes the corresponding item from the column.
+ */
+ remove: function (item) {
+ this._updateItems();
+ let 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();
+
+ let 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;
+ this.selectedRow = null;
+ 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) => {
+ let val1 = (a[this.id] instanceof Node) ?
+ a[this.id].textContent : a[this.id];
+ let val2 = (b[this.id] instanceof Node) ?
+ b[this.id].textContent : b[this.id];
+ return val1 > val2;
+ });
+ } else if (this.sorted > 1) {
+ items.sort((a, b) => {
+ let val1 = (a[this.id] instanceof Node) ?
+ a[this.id].textContent : a[this.id];
+ let val2 = (b[this.id] instanceof Node) ?
+ b[this.id].textContent : b[this.id];
+ return val2 > val1;
+ });
+ }
+
+ if (this.selectedRow) {
+ this.cells[this.items[this.selectedRow]].toggleClass("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]].toggleClass("theme-selected");
+ }
+ this._itemsDirty = false;
+ this.updateZebra();
+ return items;
+ },
+
+ updateZebra() {
+ this._updateItems();
+ let i = 0;
+ for (let cell of this.cells) {
+ if (!cell.hidden) {
+ i++;
+ }
+ cell.toggleClass("even", !(i % 2));
+ }
+ },
+
+ /**
+ * Click event handler for the column. Used to detect click on header for
+ * for sorting.
+ */
+ onClick: function (event) {
+ let 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) {
+ let target = event.originalTarget;
+
+ if (target.nodeType !== target.ELEMENT_NODE ||
+ target == this.column ||
+ target == this.header) {
+ return;
+ }
+ if (event.button == 0) {
+ let closest = target.closest("[data-id]");
+ if (!closest) {
+ return;
+ }
+
+ let 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) {
+ let document = column.document;
+
+ this.wrapTextInElements = column.wrapTextInElements;
+ this.label = document.createElementNS(XUL_NS, "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;
+ }, false);
+ }
+
+ 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)) {
+ let 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.removeChild(this.label.firstChild);
+ }
+
+ this.label.appendChild(value);
+ } else {
+ this.label.setAttribute("value", value + "");
+ }
+ },
+
+ get value() {
+ return this._value;
+ },
+
+ toggleClass: function (className, condition) {
+ this.label.classList.toggle(className, condition);
+ },
+
+ /**
+ * 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
+ let 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();
+ },
+
+ 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.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) {
+ let doc = this.root.ownerDocument;
+ this._textbox = doc.createElementNS(XUL_NS, "textbox");
+ this._textbox.id = this.INPUT_ID;
+
+ this._textbox.setAttribute("flex", "1");
+
+ 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 keypresses 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;
+ }
+
+ 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;
+ }
+
+ let oldValue = this.currentValue;
+ let newValue = this.textbox.value;
+ let changed = oldValue !== newValue;
+
+ this.textbox.hidden = true;
+
+ if (!this.currentTarget) {
+ return;
+ }
+
+ this.currentTarget.hidden = false;
+ if (changed) {
+ this.currentTarget.value = newValue;
+
+ let 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) {
+ let style = source.ownerDocument.defaultView.getComputedStyle(source);
+ let props = [
+ "borderTopWidth",
+ "borderRightWidth",
+ "borderBottomWidth",
+ "borderLeftWidth",
+ "fontFamily",
+ "fontSize",
+ "fontWeight",
+ "height",
+ "marginTop",
+ "marginRight",
+ "marginBottom",
+ "marginLeft",
+ "marginInlineStart",
+ "marginInlineEnd"
+ ];
+
+ for (let 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 000000000..1f766cc6b
--- /dev/null
+++ b/devtools/client/shared/widgets/TreeWidget.js
@@ -0,0 +1,605 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {nsIDOMNode} 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");
+ }
+ let 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) => {
+ let 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("keypress", e => this.onKeypress(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 () {
+ let next = this.getNextVisibleItem();
+ if (next) {
+ this.selectedItem = next;
+ }
+ },
+
+ /**
+ * Selects the previos visible item in the tree
+ */
+ selectPreviousItem: function () {
+ let 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 && 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("");
+ },
+
+ /**
+ * 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) {
+ let ids = target.parentNode.getAttribute("data-id");
+ this.selectedItem = JSON.parse(ids);
+ }
+ },
+
+ /**
+ * Keypress handler for this tree. Used to select next and previous visible
+ * items, as well as collapsing and expanding any item.
+ */
+ onKeypress: 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 () {
+ let {top, bottom} = this._selectedLabel.getBoundingClientRect();
+ let 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.
+ let 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];
+ let 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;
+ }
+ let 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
+ let 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 = []) {
+ let id = items.shift();
+ if (id && this.items.has(id)) {
+ let 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])) {
+ let 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 (let 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 (let 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/VariablesView.jsm b/devtools/client/shared/widgets/VariablesView.jsm
new file mode 100644
index 000000000..c291066ba
--- /dev/null
+++ b/devtools/client/shared/widgets/VariablesView.jsm
@@ -0,0 +1,4182 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const DBG_STRINGS_URI = "devtools/client/locales/debugger.properties";
+const LAZY_EMPTY_DELAY = 150; // ms
+const SCROLL_PAGE_SIZE_DEFAULT = 0;
+const PAGE_SIZE_SCROLL_HEIGHT_RATIO = 100;
+const PAGE_SIZE_MAX_JUMPS = 30;
+const SEARCH_ACTION_MAX_DELAY = 300; // ms
+const ITEM_FLASH_DURATION = 300; // ms
+
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
+const EventEmitter = require("devtools/shared/event-emitter");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const Services = require("Services");
+const { getSourceNames } = require("devtools/client/shared/source-utils");
+const promise = require("promise");
+const defer = require("devtools/shared/defer");
+const { Heritage, ViewHelpers, setNamedTimeout } =
+ require("devtools/client/shared/widgets/view-helpers");
+const { Task } = require("devtools/shared/task");
+const nodeConstants = require("devtools/shared/dom-node-constants");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+const {PluralForm} = require("devtools/shared/plural-form");
+const {LocalizationHelper, ELLIPSIS} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper(DBG_STRINGS_URI);
+
+XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
+ "@mozilla.org/widget/clipboardhelper;1",
+ "nsIClipboardHelper");
+
+Object.defineProperty(this, "WebConsoleUtils", {
+ get: function () {
+ return require("devtools/client/webconsole/utils").Utils;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+Object.defineProperty(this, "NetworkHelper", {
+ get: function () {
+ return require("devtools/shared/webconsole/network-helper");
+ },
+ configurable: true,
+ enumerable: true
+});
+
+this.EXPORTED_SYMBOLS = ["VariablesView", "escapeHTML"];
+
+/**
+ * A tree view for inspecting scopes, objects and properties.
+ * Iterable via "for (let [id, scope] of instance) { }".
+ * Requires the devtools common.css and debugger.css skin stylesheets.
+ *
+ * To allow replacing variable or property values in this view, provide an
+ * "eval" function property. To allow replacing variable or property names,
+ * provide a "switch" function. To handle deleting variables or properties,
+ * provide a "delete" function.
+ *
+ * @param nsIDOMNode aParentNode
+ * The parent node to hold this view.
+ * @param object aFlags [optional]
+ * An object contaning initialization options for this view.
+ * e.g. { lazyEmpty: true, searchEnabled: true ... }
+ */
+this.VariablesView = function VariablesView(aParentNode, aFlags = {}) {
+ this._store = []; // Can't use a Map because Scope names needn't be unique.
+ this._itemsByElement = new WeakMap();
+ this._prevHierarchy = new Map();
+ this._currHierarchy = new Map();
+
+ this._parent = aParentNode;
+ this._parent.classList.add("variables-view-container");
+ this._parent.classList.add("theme-body");
+ this._appendEmptyNotice();
+
+ this._onSearchboxInput = this._onSearchboxInput.bind(this);
+ this._onSearchboxKeyPress = this._onSearchboxKeyPress.bind(this);
+ this._onViewKeyPress = this._onViewKeyPress.bind(this);
+ this._onViewKeyDown = this._onViewKeyDown.bind(this);
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.setAttribute("orient", "vertical");
+ this._list.addEventListener("keypress", this._onViewKeyPress, false);
+ this._list.addEventListener("keydown", this._onViewKeyDown, false);
+ this._parent.appendChild(this._list);
+
+ for (let name in aFlags) {
+ this[name] = aFlags[name];
+ }
+
+ EventEmitter.decorate(this);
+};
+
+VariablesView.prototype = {
+ /**
+ * Helper setter for populating this container with a raw object.
+ *
+ * @param object aObject
+ * The raw object to display. You can only provide this object
+ * if you want the variables view to work in sync mode.
+ */
+ set rawObject(aObject) {
+ this.empty();
+ this.addScope()
+ .addItem(undefined, { enumerable: true })
+ .populate(aObject, { sorted: true });
+ },
+
+ /**
+ * Adds a scope to contain any inspected variables.
+ *
+ * This new scope will be considered the parent of any other scope
+ * added afterwards.
+ *
+ * @param string aName
+ * The scope's name (e.g. "Local", "Global" etc.).
+ * @param string aCustomClass
+ * An additional class name for the containing element.
+ * @return Scope
+ * The newly created Scope instance.
+ */
+ addScope: function (aName = "", aCustomClass = "") {
+ this._removeEmptyNotice();
+ this._toggleSearchVisibility(true);
+
+ let scope = new Scope(this, aName, { customClass: aCustomClass });
+ this._store.push(scope);
+ this._itemsByElement.set(scope._target, scope);
+ this._currHierarchy.set(aName, scope);
+ scope.header = !!aName;
+
+ return scope;
+ },
+
+ /**
+ * Removes all items from this container.
+ *
+ * @param number aTimeout [optional]
+ * The number of milliseconds to delay the operation if
+ * lazy emptying of this container is enabled.
+ */
+ empty: function (aTimeout = this.lazyEmptyDelay) {
+ // If there are no items in this container, emptying is useless.
+ if (!this._store.length) {
+ return;
+ }
+
+ this._store.length = 0;
+ this._itemsByElement = new WeakMap();
+ this._prevHierarchy = this._currHierarchy;
+ this._currHierarchy = new Map(); // Don't clear, this is just simple swapping.
+
+ // Check if this empty operation may be executed lazily.
+ if (this.lazyEmpty && aTimeout > 0) {
+ this._emptySoon(aTimeout);
+ return;
+ }
+
+ while (this._list.hasChildNodes()) {
+ this._list.firstChild.remove();
+ }
+
+ this._appendEmptyNotice();
+ this._toggleSearchVisibility(false);
+ },
+
+ /**
+ * Emptying this container and rebuilding it immediately afterwards would
+ * result in a brief redraw flicker, because the previously expanded nodes
+ * may get asynchronously re-expanded, after fetching the prototype and
+ * properties from a server.
+ *
+ * To avoid such behaviour, a normal container list is rebuild, but not
+ * immediately attached to the parent container. The old container list
+ * is kept around for a short period of time, hopefully accounting for the
+ * data fetching delay. In the meantime, any operations can be executed
+ * normally.
+ *
+ * @see VariablesView.empty
+ * @see VariablesView.commitHierarchy
+ */
+ _emptySoon: function (aTimeout) {
+ let prevList = this._list;
+ let currList = this._list = this.document.createElement("scrollbox");
+
+ this.window.setTimeout(() => {
+ prevList.removeEventListener("keypress", this._onViewKeyPress, false);
+ prevList.removeEventListener("keydown", this._onViewKeyDown, false);
+ currList.addEventListener("keypress", this._onViewKeyPress, false);
+ currList.addEventListener("keydown", this._onViewKeyDown, false);
+ currList.setAttribute("orient", "vertical");
+
+ this._parent.removeChild(prevList);
+ this._parent.appendChild(currList);
+
+ if (!this._store.length) {
+ this._appendEmptyNotice();
+ this._toggleSearchVisibility(false);
+ }
+ }, aTimeout);
+ },
+
+ /**
+ * Optional DevTools toolbox containing this VariablesView. Used to
+ * communicate with the inspector and highlighter.
+ */
+ toolbox: null,
+
+ /**
+ * The controller for this VariablesView, if it has one.
+ */
+ controller: null,
+
+ /**
+ * The amount of time (in milliseconds) it takes to empty this view lazily.
+ */
+ lazyEmptyDelay: LAZY_EMPTY_DELAY,
+
+ /**
+ * Specifies if this view may be emptied lazily.
+ * @see VariablesView.prototype.empty
+ */
+ lazyEmpty: false,
+
+ /**
+ * Specifies if nodes in this view may be searched lazily.
+ */
+ lazySearch: true,
+
+ /**
+ * The number of elements in this container to jump when Page Up or Page Down
+ * keys are pressed. If falsy, then the page size will be based on the
+ * container height.
+ */
+ scrollPageSize: SCROLL_PAGE_SIZE_DEFAULT,
+
+ /**
+ * Function called each time a variable or property's value is changed via
+ * user interaction. If null, then value changes are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ eval: null,
+
+ /**
+ * Function called each time a variable or property's name is changed via
+ * user interaction. If null, then name changes are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ switch: null,
+
+ /**
+ * Function called each time a variable or property is deleted via
+ * user interaction. If null, then deletions are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ delete: null,
+
+ /**
+ * Function called each time a property is added via user interaction. If
+ * null, then property additions are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ new: null,
+
+ /**
+ * Specifies if after an eval or switch operation, the variable or property
+ * which has been edited should be disabled.
+ */
+ preventDisableOnChange: false,
+
+ /**
+ * Specifies if, whenever a variable or property descriptor is available,
+ * configurable, enumerable, writable, frozen, sealed and extensible
+ * attributes should not affect presentation.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ preventDescriptorModifiers: false,
+
+ /**
+ * The tooltip text shown on a variable or property's value if an |eval|
+ * function is provided, in order to change the variable or property's value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editableValueTooltip: L10N.getStr("variablesEditableValueTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's name if a |switch|
+ * function is provided, in order to change the variable or property's name.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editableNameTooltip: L10N.getStr("variablesEditableNameTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's edit button if an
+ * |eval| function is provided and a getter/setter descriptor is present,
+ * in order to change the variable or property to a plain value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editButtonTooltip: L10N.getStr("variablesEditButtonTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's value if that value is
+ * a DOMNode that can be highlighted and selected in the inspector.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ domNodeValueTooltip: L10N.getStr("variablesDomNodeValueTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's delete button if a
+ * |delete| function is provided, in order to delete the variable or property.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ deleteButtonTooltip: L10N.getStr("variablesCloseButtonTooltip"),
+
+ /**
+ * Specifies the context menu attribute set on variables and properties.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ contextMenuId: "",
+
+ /**
+ * The separator label between the variables or properties name and value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ separatorStr: L10N.getStr("variablesSeparatorLabel"),
+
+ /**
+ * Specifies if enumerable properties and variables should be displayed.
+ * These variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set enumVisible(aFlag) {
+ this._enumVisible = aFlag;
+
+ for (let scope of this._store) {
+ scope._enumVisible = aFlag;
+ }
+ },
+
+ /**
+ * Specifies if non-enumerable properties and variables should be displayed.
+ * These variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set nonEnumVisible(aFlag) {
+ this._nonEnumVisible = aFlag;
+
+ for (let scope of this._store) {
+ scope._nonEnumVisible = aFlag;
+ }
+ },
+
+ /**
+ * Specifies if only enumerable properties and variables should be displayed.
+ * Both types of these variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set onlyEnumVisible(aFlag) {
+ if (aFlag) {
+ this.enumVisible = true;
+ this.nonEnumVisible = false;
+ } else {
+ this.enumVisible = true;
+ this.nonEnumVisible = true;
+ }
+ },
+
+ /**
+ * Sets if the variable and property searching is enabled.
+ * @param boolean aFlag
+ */
+ set searchEnabled(aFlag) {
+ aFlag ? this._enableSearch() : this._disableSearch();
+ },
+
+ /**
+ * Gets if the variable and property searching is enabled.
+ * @return boolean
+ */
+ get searchEnabled() {
+ return !!this._searchboxContainer;
+ },
+
+ /**
+ * Sets the text displayed for the searchbox in this container.
+ * @param string aValue
+ */
+ set searchPlaceholder(aValue) {
+ if (this._searchboxNode) {
+ this._searchboxNode.setAttribute("placeholder", aValue);
+ }
+ this._searchboxPlaceholder = aValue;
+ },
+
+ /**
+ * Gets the text displayed for the searchbox in this container.
+ * @return string
+ */
+ get searchPlaceholder() {
+ return this._searchboxPlaceholder;
+ },
+
+ /**
+ * Enables variable and property searching in this view.
+ * Use the "searchEnabled" setter to enable searching.
+ */
+ _enableSearch: function () {
+ // If searching was already enabled, no need to re-enable it again.
+ if (this._searchboxContainer) {
+ return;
+ }
+ let document = this.document;
+ let ownerNode = this._parent.parentNode;
+
+ let container = this._searchboxContainer = document.createElement("hbox");
+ container.className = "devtools-toolbar";
+
+ // Hide the variables searchbox container if there are no variables or
+ // properties to display.
+ container.hidden = !this._store.length;
+
+ let searchbox = this._searchboxNode = document.createElement("textbox");
+ searchbox.className = "variables-view-searchinput devtools-filterinput";
+ searchbox.setAttribute("placeholder", this._searchboxPlaceholder);
+ searchbox.setAttribute("type", "search");
+ searchbox.setAttribute("flex", "1");
+ searchbox.addEventListener("command", this._onSearchboxInput, false);
+ searchbox.addEventListener("keypress", this._onSearchboxKeyPress, false);
+
+ container.appendChild(searchbox);
+ ownerNode.insertBefore(container, this._parent);
+ },
+
+ /**
+ * Disables variable and property searching in this view.
+ * Use the "searchEnabled" setter to disable searching.
+ */
+ _disableSearch: function () {
+ // If searching was already disabled, no need to re-disable it again.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.remove();
+ this._searchboxNode.removeEventListener("command", this._onSearchboxInput, false);
+ this._searchboxNode.removeEventListener("keypress", this._onSearchboxKeyPress, false);
+
+ this._searchboxContainer = null;
+ this._searchboxNode = null;
+ },
+
+ /**
+ * Sets the variables searchbox container hidden or visible.
+ * It's hidden by default.
+ *
+ * @param boolean aVisibleFlag
+ * Specifies the intended visibility.
+ */
+ _toggleSearchVisibility: function (aVisibleFlag) {
+ // If searching was already disabled, there's no need to hide it.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.hidden = !aVisibleFlag;
+ },
+
+ /**
+ * Listener handling the searchbox input event.
+ */
+ _onSearchboxInput: function () {
+ this.scheduleSearch(this._searchboxNode.value);
+ },
+
+ /**
+ * Listener handling the searchbox key press event.
+ */
+ _onSearchboxKeyPress: function (e) {
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_RETURN:
+ this._onSearchboxInput();
+ return;
+ case KeyCodes.DOM_VK_ESCAPE:
+ this._searchboxNode.value = "";
+ this._onSearchboxInput();
+ return;
+ }
+ },
+
+ /**
+ * Schedules searching for variables or properties matching the query.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ * @param number aWait
+ * The amount of milliseconds to wait until draining.
+ */
+ scheduleSearch: function (aToken, aWait) {
+ // Check if this search operation may not be executed lazily.
+ if (!this.lazySearch) {
+ this._doSearch(aToken);
+ return;
+ }
+
+ // The amount of time to wait for the requests to settle.
+ let maxDelay = SEARCH_ACTION_MAX_DELAY;
+ let delay = aWait === undefined ? maxDelay / aToken.length : aWait;
+
+ // Allow requests to settle down first.
+ setNamedTimeout("vview-search", delay, () => this._doSearch(aToken));
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * If aToken is falsy, then all the scopes are unhidden and expanded,
+ * while the available variables and properties inside those scopes are
+ * just unhidden.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ */
+ _doSearch: function (aToken) {
+ if (this.controller && this.controller.supportsSearch()) {
+ // Retrieve the main Scope in which we add attributes
+ let scope = this._store[0]._store.get(undefined);
+ if (!aToken) {
+ // Prune the view from old previous content
+ // so that we delete the intermediate search results
+ // we created in previous searches
+ for (let property of scope._store.values()) {
+ property.remove();
+ }
+ }
+ // Retrieve new attributes eventually hidden in splits
+ this.controller.performSearch(scope, aToken);
+ // Filter already displayed attributes
+ if (aToken) {
+ scope._performSearch(aToken.toLowerCase());
+ }
+ return;
+ }
+ for (let scope of this._store) {
+ switch (aToken) {
+ case "":
+ case null:
+ case undefined:
+ scope.expand();
+ scope._performSearch("");
+ break;
+ default:
+ scope._performSearch(aToken.toLowerCase());
+ break;
+ }
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this container that
+ * matches the predicate. Searches in visual order (the order seen by the
+ * user). Descends into each scope to check the scope and its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems: function (aPredicate) {
+ for (let scope of this._store) {
+ let result = scope._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this container that
+ * matches the predicate. Searches in reverse visual order (opposite of the
+ * order seen by the user). Descends into each scope to check the scope and
+ * its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse: function (aPredicate) {
+ for (let i = this._store.length - 1; i >= 0; i--) {
+ let scope = this._store[i];
+ let result = scope._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Gets the scope at the specified index.
+ *
+ * @param number aIndex
+ * The scope's index.
+ * @return Scope
+ * The scope if found, undefined if not.
+ */
+ getScopeAtIndex: function (aIndex) {
+ return this._store[aIndex];
+ },
+
+ /**
+ * Recursively searches this container for the scope, variable or property
+ * displayed by the specified node.
+ *
+ * @param nsIDOMNode aNode
+ * The node to search for.
+ * @return Scope | Variable | Property
+ * The matched scope, variable or property, or null if nothing is found.
+ */
+ getItemForNode: function (aNode) {
+ return this._itemsByElement.get(aNode);
+ },
+
+ /**
+ * Gets the scope owning a Variable or Property.
+ *
+ * @param Variable | Property
+ * The variable or property to retrieven the owner scope for.
+ * @return Scope
+ * The owner scope.
+ */
+ getOwnerScopeForVariableOrProperty: function (aItem) {
+ if (!aItem) {
+ return null;
+ }
+ // If this is a Scope, return it.
+ if (!(aItem instanceof Variable)) {
+ return aItem;
+ }
+ // If this is a Variable or Property, find its owner scope.
+ if (aItem instanceof Variable && aItem.ownerView) {
+ return this.getOwnerScopeForVariableOrProperty(aItem.ownerView);
+ }
+ return null;
+ },
+
+ /**
+ * Gets the parent scopes for a specified Variable or Property.
+ * The returned list will not include the owner scope.
+ *
+ * @param Variable | Property
+ * The variable or property for which to find the parent scopes.
+ * @return array
+ * A list of parent Scopes.
+ */
+ getParentScopesForVariableOrProperty: function (aItem) {
+ let scope = this.getOwnerScopeForVariableOrProperty(aItem);
+ return this._store.slice(0, Math.max(this._store.indexOf(scope), 0));
+ },
+
+ /**
+ * Gets the currently focused scope, variable or property in this view.
+ *
+ * @return Scope | Variable | Property
+ * The focused scope, variable or property, or null if nothing is found.
+ */
+ getFocusedItem: function () {
+ let focused = this.document.commandDispatcher.focusedElement;
+ return this.getItemForNode(focused);
+ },
+
+ /**
+ * Focuses the first visible scope, variable, or property in this container.
+ */
+ focusFirstVisibleItem: function () {
+ let focusableItem = this._findInVisibleItems(item => item.focusable);
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = 0;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the last visible scope, variable, or property in this container.
+ */
+ focusLastVisibleItem: function () {
+ let focusableItem = this._findInVisibleItemsReverse(item => item.focusable);
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = this._parent.scrollHeight;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the next scope, variable or property in this view.
+ */
+ focusNextItem: function () {
+ this.focusItemAtDelta(+1);
+ },
+
+ /**
+ * Focuses the previous scope, variable or property in this view.
+ */
+ focusPrevItem: function () {
+ this.focusItemAtDelta(-1);
+ },
+
+ /**
+ * Focuses another scope, variable or property in this view, based on
+ * the index distance from the currently focused item.
+ *
+ * @param number aDelta
+ * A scalar specifying by how many items should the selection change.
+ */
+ focusItemAtDelta: function (aDelta) {
+ let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
+ let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
+ while (distance--) {
+ if (!this._focusChange(direction)) {
+ break; // Out of bounds.
+ }
+ }
+ },
+
+ /**
+ * Focuses the next or previous scope, variable or property in this view.
+ *
+ * @param string aDirection
+ * Either "advanceFocus" or "rewindFocus".
+ * @return boolean
+ * False if the focus went out of bounds and the first or last element
+ * in this view was focused instead.
+ */
+ _focusChange: function (aDirection) {
+ let commandDispatcher = this.document.commandDispatcher;
+ let prevFocusedElement = commandDispatcher.focusedElement;
+ let currFocusedItem = null;
+
+ do {
+ commandDispatcher.suppressFocusScroll = true;
+ commandDispatcher[aDirection]();
+
+ // Make sure the newly focused item is a part of this view.
+ // If the focus goes out of bounds, revert the previously focused item.
+ if (!(currFocusedItem = this.getFocusedItem())) {
+ prevFocusedElement.focus();
+ return false;
+ }
+ } while (!currFocusedItem.focusable);
+
+ // Focus remained within bounds.
+ return true;
+ },
+
+ /**
+ * Focuses a scope, variable or property and makes sure it's visible.
+ *
+ * @param aItem Scope | Variable | Property
+ * The item to focus.
+ * @param boolean aCollapseFlag
+ * True if the focused item should also be collapsed.
+ * @return boolean
+ * True if the item was successfully focused.
+ */
+ _focusItem: function (aItem, aCollapseFlag) {
+ if (!aItem.focusable) {
+ return false;
+ }
+ if (aCollapseFlag) {
+ aItem.collapse();
+ }
+ aItem._target.focus();
+ this.boxObject.ensureElementIsVisible(aItem._arrow);
+ return true;
+ },
+
+ /**
+ * Listener handling a key press event on the view.
+ */
+ _onViewKeyPress: function (e) {
+ let item = this.getFocusedItem();
+
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(e);
+
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_UP:
+ // Always rewind focus.
+ this.focusPrevItem(true);
+ return;
+
+ case KeyCodes.DOM_VK_DOWN:
+ // Always advance focus.
+ this.focusNextItem(true);
+ return;
+
+ case KeyCodes.DOM_VK_LEFT:
+ // Collapse scopes, variables and properties before rewinding focus.
+ if (item._isExpanded && item._isArrowVisible) {
+ item.collapse();
+ } else {
+ this._focusItem(item.ownerView);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_RIGHT:
+ // Nothing to do here if this item never expands.
+ if (!item._isArrowVisible) {
+ return;
+ }
+ // Expand scopes, variables and properties before advancing focus.
+ if (!item._isExpanded) {
+ item.expand();
+ } else {
+ this.focusNextItem(true);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_PAGE_UP:
+ // Rewind a certain number of elements based on the container height.
+ this.focusItemAtDelta(-(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight /
+ PAGE_SIZE_SCROLL_HEIGHT_RATIO),
+ PAGE_SIZE_MAX_JUMPS)));
+ return;
+
+ case KeyCodes.DOM_VK_PAGE_DOWN:
+ // Advance a certain number of elements based on the container height.
+ this.focusItemAtDelta(+(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight /
+ PAGE_SIZE_SCROLL_HEIGHT_RATIO),
+ PAGE_SIZE_MAX_JUMPS)));
+ return;
+
+ case KeyCodes.DOM_VK_HOME:
+ this.focusFirstVisibleItem();
+ return;
+
+ case KeyCodes.DOM_VK_END:
+ this.focusLastVisibleItem();
+ return;
+
+ case KeyCodes.DOM_VK_RETURN:
+ // Start editing the value or name of the Variable or Property.
+ if (item instanceof Variable) {
+ if (e.metaKey || e.altKey || e.shiftKey) {
+ item._activateNameInput();
+ } else {
+ item._activateValueInput();
+ }
+ }
+ return;
+
+ case KeyCodes.DOM_VK_DELETE:
+ case KeyCodes.DOM_VK_BACK_SPACE:
+ // Delete the Variable or Property if allowed.
+ if (item instanceof Variable) {
+ item._onDelete(e);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_INSERT:
+ item._onAddProperty(e);
+ return;
+ }
+ },
+
+ /**
+ * Listener handling a key down event on the view.
+ */
+ _onViewKeyDown: function (e) {
+ if (e.keyCode == KeyCodes.DOM_VK_C) {
+ // Copy current selection to clipboard.
+ if (e.ctrlKey || e.metaKey) {
+ let item = this.getFocusedItem();
+ clipboardHelper.copyString(
+ item._nameString + item.separatorStr + item._valueString
+ );
+ }
+ }
+ },
+
+ /**
+ * Sets the text displayed in this container when there are no available items.
+ * @param string aValue
+ */
+ set emptyText(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._appendEmptyNotice();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _appendEmptyNotice: function () {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+
+ let label = this.document.createElement("label");
+ label.className = "variables-view-empty-notice";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.appendChild(label);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyNotice: function () {
+ if (!this._emptyTextNode) {
+ return;
+ }
+
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ /**
+ * Gets if all values should be aligned together.
+ * @return boolean
+ */
+ get alignedValues() {
+ return this._alignedValues;
+ },
+
+ /**
+ * Sets if all values should be aligned together.
+ * @param boolean aFlag
+ */
+ set alignedValues(aFlag) {
+ this._alignedValues = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("aligned-values", "");
+ } else {
+ this._parent.removeAttribute("aligned-values");
+ }
+ },
+
+ /**
+ * Gets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @return boolean
+ */
+ get actionsFirst() {
+ return this._actionsFirst;
+ },
+
+ /**
+ * Sets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @param boolean aFlag
+ */
+ set actionsFirst(aFlag) {
+ this._actionsFirst = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("actions-first", "");
+ } else {
+ this._parent.removeAttribute("actions-first");
+ }
+ },
+
+ /**
+ * Gets the parent node holding this view.
+ * @return nsIDOMNode
+ */
+ get boxObject() {
+ return this._list.boxObject;
+ },
+
+ /**
+ * Gets the parent node holding this view.
+ * @return nsIDOMNode
+ */
+ get parentNode() {
+ return this._parent;
+ },
+
+ /**
+ * Gets the owner document holding this view.
+ * @return nsIHTMLDocument
+ */
+ get document() {
+ return this._document || (this._document = this._parent.ownerDocument);
+ },
+
+ /**
+ * Gets the default window holding this view.
+ * @return nsIDOMWindow
+ */
+ get window() {
+ return this._window || (this._window = this.document.defaultView);
+ },
+
+ _document: null,
+ _window: null,
+
+ _store: null,
+ _itemsByElement: null,
+ _prevHierarchy: null,
+ _currHierarchy: null,
+
+ _enumVisible: true,
+ _nonEnumVisible: true,
+ _alignedValues: false,
+ _actionsFirst: false,
+
+ _parent: null,
+ _list: null,
+ _searchboxNode: null,
+ _searchboxContainer: null,
+ _searchboxPlaceholder: "",
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
+
+VariablesView.NON_SORTABLE_CLASSES = [
+ "Array",
+ "Int8Array",
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Int16Array",
+ "Uint16Array",
+ "Int32Array",
+ "Uint32Array",
+ "Float32Array",
+ "Float64Array",
+ "NodeList"
+];
+
+/**
+ * Determine whether an object's properties should be sorted based on its class.
+ *
+ * @param string aClassName
+ * The class of the object.
+ */
+VariablesView.isSortable = function (aClassName) {
+ return VariablesView.NON_SORTABLE_CLASSES.indexOf(aClassName) == -1;
+};
+
+/**
+ * Generates the string evaluated when performing simple value changes.
+ *
+ * @param Variable | Property aItem
+ * The current variable or property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.simpleValueEvalMacro = function (aItem, aCurrentString, aPrefix = "") {
+ return aPrefix + aItem.symbolicName + "=" + aCurrentString;
+};
+
+/**
+ * Generates the string evaluated when overriding getters and setters with
+ * plain values.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.overrideValueEvalMacro = function (aItem, aCurrentString, aPrefix = "") {
+ let property = escapeString(aItem._nameString);
+ let parent = aPrefix + aItem.ownerView.symbolicName || "this";
+
+ return "Object.defineProperty(" + parent + "," + property + "," +
+ "{ value: " + aCurrentString +
+ ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" +
+ ", configurable: true" +
+ ", writable: true" +
+ "})";
+};
+
+/**
+ * Generates the string evaluated when performing getters and setters changes.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.getterOrSetterEvalMacro = function (aItem, aCurrentString, aPrefix = "") {
+ let type = aItem._nameString;
+ let propertyObject = aItem.ownerView;
+ let parentObject = propertyObject.ownerView;
+ let property = escapeString(propertyObject._nameString);
+ let parent = aPrefix + parentObject.symbolicName || "this";
+
+ switch (aCurrentString) {
+ case "":
+ case "null":
+ case "undefined":
+ let mirrorType = type == "get" ? "set" : "get";
+ let mirrorLookup = type == "get" ? "__lookupSetter__" : "__lookupGetter__";
+
+ // If the parent object will end up without any getter or setter,
+ // morph it into a plain value.
+ if ((type == "set" && propertyObject.getter.type == "undefined") ||
+ (type == "get" && propertyObject.setter.type == "undefined")) {
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ return propertyObject.evaluationMacro(propertyObject, "undefined", aPrefix);
+ }
+
+ // Construct and return the getter/setter removal evaluation string.
+ // e.g: Object.defineProperty(foo, "bar", {
+ // get: foo.__lookupGetter__("bar"),
+ // set: undefined,
+ // enumerable: true,
+ // configurable: true
+ // })
+ return "Object.defineProperty(" + parent + "," + property + "," +
+ "{" + mirrorType + ":" + parent + "." + mirrorLookup + "(" + property + ")" +
+ "," + type + ":" + undefined +
+ ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" +
+ ", configurable: true" +
+ "})";
+
+ default:
+ // Wrap statements inside a function declaration if not already wrapped.
+ if (!aCurrentString.startsWith("function")) {
+ let header = "function(" + (type == "set" ? "value" : "") + ")";
+ let body = "";
+ // If there's a return statement explicitly written, always use the
+ // standard function definition syntax
+ if (aCurrentString.includes("return ")) {
+ body = "{" + aCurrentString + "}";
+ }
+ // If block syntax is used, use the whole string as the function body.
+ else if (aCurrentString.startsWith("{")) {
+ body = aCurrentString;
+ }
+ // Prefer an expression closure.
+ else {
+ body = "(" + aCurrentString + ")";
+ }
+ aCurrentString = header + body;
+ }
+
+ // Determine if a new getter or setter should be defined.
+ let defineType = type == "get" ? "__defineGetter__" : "__defineSetter__";
+
+ // Make sure all quotes are escaped in the expression's syntax,
+ let defineFunc = "eval(\"(" + aCurrentString.replace(/"/g, "\\$&") + ")\")";
+
+ // Construct and return the getter/setter evaluation string.
+ // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })"))
+ return parent + "." + defineType + "(" + property + "," + defineFunc + ")";
+ }
+};
+
+/**
+ * Function invoked when a getter or setter is deleted.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ */
+VariablesView.getterOrSetterDeleteCallback = function (aItem) {
+ aItem._disable();
+
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ aItem.ownerView.eval(aItem, "");
+
+ return true; // Don't hide the element.
+};
+
+
+/**
+ * A Scope is an object holding Variable instances.
+ * Iterable via "for (let [name, variable] of instance) { }".
+ *
+ * @param VariablesView aView
+ * The view to contain this scope.
+ * @param string aName
+ * The scope's name.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+function Scope(aView, aName, aFlags = {}) {
+ this.ownerView = aView;
+
+ this._onClick = this._onClick.bind(this);
+ this._openEnum = this._openEnum.bind(this);
+ this._openNonEnum = this._openNonEnum.bind(this);
+
+ // Inherit properties and flags from the parent view. You can override
+ // each of these directly onto any scope, variable or property instance.
+ this.scrollPageSize = aView.scrollPageSize;
+ this.eval = aView.eval;
+ this.switch = aView.switch;
+ this.delete = aView.delete;
+ this.new = aView.new;
+ this.preventDisableOnChange = aView.preventDisableOnChange;
+ this.preventDescriptorModifiers = aView.preventDescriptorModifiers;
+ this.editableNameTooltip = aView.editableNameTooltip;
+ this.editableValueTooltip = aView.editableValueTooltip;
+ this.editButtonTooltip = aView.editButtonTooltip;
+ this.deleteButtonTooltip = aView.deleteButtonTooltip;
+ this.domNodeValueTooltip = aView.domNodeValueTooltip;
+ this.contextMenuId = aView.contextMenuId;
+ this.separatorStr = aView.separatorStr;
+
+ this._init(aName, aFlags);
+}
+
+Scope.prototype = {
+ /**
+ * Whether this Scope should be prefetched when it is remoted.
+ */
+ shouldPrefetch: true,
+
+ /**
+ * Whether this Scope should paginate its contents.
+ */
+ allowPaginate: false,
+
+ /**
+ * The class name applied to this scope's target element.
+ */
+ targetClassName: "variables-view-scope",
+
+ /**
+ * Create a new Variable that is a child of this Scope.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by addItem.
+ * @return Variable
+ * The newly created child Variable.
+ */
+ _createChild: function (aName, aDescriptor, aOptions) {
+ return new Variable(this, aName, aDescriptor, aOptions);
+ },
+
+ /**
+ * Adds a child to contain any inspected properties.
+ *
+ * @param string aName
+ * The child's name.
+ * @param object aDescriptor
+ * Specifies the value and/or type & class of the child,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value. If this parameter is omitted,
+ * a property without a value will be added (useful for branch nodes).
+ * e.g. - { value: 42 }
+ * - { value: true }
+ * - { value: "nasu" }
+ * - { value: { type: "undefined" } }
+ * - { value: { type: "null" } }
+ * - { value: { type: "object", class: "Object" } }
+ * - { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } }
+ * @param object aOptions
+ * Specifies some options affecting the new variable.
+ * Recognized properties are
+ * * boolean relaxed true if name duplicates should be allowed.
+ * You probably shouldn't do it. Use this
+ * with caution.
+ * * boolean internalItem true if the item is internally generated.
+ * This is used for special variables
+ * like <return> or <exception> and distinguishes
+ * them from ordinary properties that happen
+ * to have the same name
+ * @return Variable
+ * The newly created Variable instance, null if it already exists.
+ */
+ addItem: function (aName, aDescriptor = {}, aOptions = {}) {
+ let {relaxed} = aOptions;
+ if (this._store.has(aName) && !relaxed) {
+ return this._store.get(aName);
+ }
+
+ let child = this._createChild(aName, aDescriptor, aOptions);
+ this._store.set(aName, child);
+ this._variablesView._itemsByElement.set(child._target, child);
+ this._variablesView._currHierarchy.set(child.absoluteName, child);
+ child.header = aName !== undefined;
+
+ return child;
+ },
+
+ /**
+ * Adds items for this variable.
+ *
+ * @param object aItems
+ * An object containing some { name: descriptor } data properties,
+ * specifying the value and/or type & class of the variable,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value.
+ * e.g. - { someProp0: { value: 42 },
+ * someProp1: { value: true },
+ * someProp2: { value: "nasu" },
+ * someProp3: { value: { type: "undefined" } },
+ * someProp4: { value: { type: "null" } },
+ * someProp5: { value: { type: "object", class: "Object" } },
+ * someProp6: { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } } }
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - callback: function invoked after each item is added
+ */
+ addItems: function (aItems, aOptions = {}) {
+ let names = Object.keys(aItems);
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted) {
+ names.sort(this._naturalSort);
+ }
+
+ // Add the properties to the current scope.
+ for (let name of names) {
+ let descriptor = aItems[name];
+ let item = this.addItem(name, descriptor);
+
+ if (aOptions.callback) {
+ aOptions.callback(item, descriptor && descriptor.value);
+ }
+ }
+ },
+
+ /**
+ * Remove this Scope from its parent and remove all children recursively.
+ */
+ remove: function () {
+ let view = this._variablesView;
+ view._store.splice(view._store.indexOf(this), 1);
+ view._itemsByElement.delete(this._target);
+ view._currHierarchy.delete(this._nameString);
+
+ this._target.remove();
+
+ for (let variable of this._store.values()) {
+ variable.remove();
+ }
+ },
+
+ /**
+ * Gets the variable in this container having the specified name.
+ *
+ * @param string aName
+ * The name of the variable to get.
+ * @return Variable
+ * The matched variable, or null if nothing is found.
+ */
+ get: function (aName) {
+ return this._store.get(aName);
+ },
+
+ /**
+ * Recursively searches for the variable or property in this container
+ * displayed by the specified node.
+ *
+ * @param nsIDOMNode aNode
+ * The node to search for.
+ * @return Variable | Property
+ * The matched variable or property, or null if nothing is found.
+ */
+ find: function (aNode) {
+ for (let [, variable] of this._store) {
+ let match;
+ if (variable._target == aNode) {
+ match = variable;
+ } else {
+ match = variable.find(aNode);
+ }
+ if (match) {
+ return match;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Determines if this scope is a direct child of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a direct child, false otherwise.
+ */
+ isChildOf: function (aParent) {
+ return this.ownerView == aParent;
+ },
+
+ /**
+ * Determines if this scope is a descendant of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a descendant, false otherwise.
+ */
+ isDescendantOf: function (aParent) {
+ if (this.isChildOf(aParent)) {
+ return true;
+ }
+
+ // Recurse to parent if it is a Scope, Variable, or Property.
+ if (this.ownerView instanceof Scope) {
+ return this.ownerView.isDescendantOf(aParent);
+ }
+
+ return false;
+ },
+
+ /**
+ * Shows the scope.
+ */
+ show: function () {
+ this._target.hidden = false;
+ this._isContentVisible = true;
+
+ if (this.onshow) {
+ this.onshow(this);
+ }
+ },
+
+ /**
+ * Hides the scope.
+ */
+ hide: function () {
+ this._target.hidden = true;
+ this._isContentVisible = false;
+
+ if (this.onhide) {
+ this.onhide(this);
+ }
+ },
+
+ /**
+ * Expands the scope, showing all the added details.
+ */
+ expand: function () {
+ if (this._isExpanded || this._isLocked) {
+ return;
+ }
+ if (this._variablesView._enumVisible) {
+ this._openEnum();
+ }
+ if (this._variablesView._nonEnumVisible) {
+ Services.tm.currentThread.dispatch({ run: this._openNonEnum }, 0);
+ }
+ this._isExpanded = true;
+
+ if (this.onexpand) {
+ // We return onexpand as it sometimes returns a promise
+ // (up to the user of VariableView to do it)
+ // that can indicate when the view is done expanding
+ // and attributes are available. (Mostly used for tests)
+ return this.onexpand(this);
+ }
+ },
+
+ /**
+ * Collapses the scope, hiding all the added details.
+ */
+ collapse: function () {
+ if (!this._isExpanded || this._isLocked) {
+ return;
+ }
+ this._arrow.removeAttribute("open");
+ this._enum.removeAttribute("open");
+ this._nonenum.removeAttribute("open");
+ this._isExpanded = false;
+
+ if (this.oncollapse) {
+ this.oncollapse(this);
+ }
+ },
+
+ /**
+ * Toggles between the scope's collapsed and expanded state.
+ */
+ toggle: function (e) {
+ if (e && e.button != 0) {
+ // Only allow left-click to trigger this event.
+ return;
+ }
+ this.expanded ^= 1;
+
+ // Make sure the scope and its contents are visibile.
+ for (let [, variable] of this._store) {
+ variable.header = true;
+ variable._matched = true;
+ }
+ if (this.ontoggle) {
+ this.ontoggle(this);
+ }
+ },
+
+ /**
+ * Shows the scope's title header.
+ */
+ showHeader: function () {
+ if (this._isHeaderVisible || !this._nameString) {
+ return;
+ }
+ this._target.removeAttribute("untitled");
+ this._isHeaderVisible = true;
+ },
+
+ /**
+ * Hides the scope's title header.
+ * This action will automatically expand the scope.
+ */
+ hideHeader: function () {
+ if (!this._isHeaderVisible) {
+ return;
+ }
+ this.expand();
+ this._target.setAttribute("untitled", "");
+ this._isHeaderVisible = false;
+ },
+
+ /**
+ * Sort in ascending order
+ * This only needs to compare non-numbers since it is dealing with an array
+ * which numeric-based indices are placed in order.
+ *
+ * @param string a
+ * @param string b
+ * @return number
+ * -1 if a is less than b, 0 if no change in order, +1 if a is greater than 0
+ */
+ _naturalSort: function (a, b) {
+ if (isNaN(parseFloat(a)) && isNaN(parseFloat(b))) {
+ return a < b ? -1 : 1;
+ }
+ },
+
+ /**
+ * Shows the scope's expand/collapse arrow.
+ */
+ showArrow: function () {
+ if (this._isArrowVisible) {
+ return;
+ }
+ this._arrow.removeAttribute("invisible");
+ this._isArrowVisible = true;
+ },
+
+ /**
+ * Hides the scope's expand/collapse arrow.
+ */
+ hideArrow: function () {
+ if (!this._isArrowVisible) {
+ return;
+ }
+ this._arrow.setAttribute("invisible", "");
+ this._isArrowVisible = false;
+ },
+
+ /**
+ * Gets the visibility state.
+ * @return boolean
+ */
+ get visible() {
+ return this._isContentVisible;
+ },
+
+ /**
+ * Gets the expanded state.
+ * @return boolean
+ */
+ get expanded() {
+ return this._isExpanded;
+ },
+
+ /**
+ * Gets the header visibility state.
+ * @return boolean
+ */
+ get header() {
+ return this._isHeaderVisible;
+ },
+
+ /**
+ * Gets the twisty visibility state.
+ * @return boolean
+ */
+ get twisty() {
+ return this._isArrowVisible;
+ },
+
+ /**
+ * Gets the expand lock state.
+ * @return boolean
+ */
+ get locked() {
+ return this._isLocked;
+ },
+
+ /**
+ * Sets the visibility state.
+ * @param boolean aFlag
+ */
+ set visible(aFlag) {
+ aFlag ? this.show() : this.hide();
+ },
+
+ /**
+ * Sets the expanded state.
+ * @param boolean aFlag
+ */
+ set expanded(aFlag) {
+ aFlag ? this.expand() : this.collapse();
+ },
+
+ /**
+ * Sets the header visibility state.
+ * @param boolean aFlag
+ */
+ set header(aFlag) {
+ aFlag ? this.showHeader() : this.hideHeader();
+ },
+
+ /**
+ * Sets the twisty visibility state.
+ * @param boolean aFlag
+ */
+ set twisty(aFlag) {
+ aFlag ? this.showArrow() : this.hideArrow();
+ },
+
+ /**
+ * Sets the expand lock state.
+ * @param boolean aFlag
+ */
+ set locked(aFlag) {
+ this._isLocked = aFlag;
+ },
+
+ /**
+ * Specifies if this target node may be focused.
+ * @return boolean
+ */
+ get focusable() {
+ // Check if this target node is actually visibile.
+ if (!this._nameString ||
+ !this._isContentVisible ||
+ !this._isHeaderVisible ||
+ !this._isMatch) {
+ return false;
+ }
+ // Check if all parent objects are expanded.
+ let item = this;
+
+ // Recurse while parent is a Scope, Variable, or Property
+ while ((item = item.ownerView) && item instanceof Scope) {
+ if (!item._isExpanded) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Focus this scope.
+ */
+ focus: function () {
+ this._variablesView._focusItem(this);
+ },
+
+ /**
+ * Adds an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ addEventListener: function (aName, aCallback, aCapture) {
+ this._title.addEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Removes an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ removeEventListener: function (aName, aCallback, aCapture) {
+ this._title.removeEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Gets the id associated with this item.
+ * @return string
+ */
+ get id() {
+ return this._idString;
+ },
+
+ /**
+ * Gets the name associated with this item.
+ * @return string
+ */
+ get name() {
+ return this._nameString;
+ },
+
+ /**
+ * Gets the displayed value for this item.
+ * @return string
+ */
+ get displayValue() {
+ return this._valueString;
+ },
+
+ /**
+ * Gets the class names used for the displayed value.
+ * @return string
+ */
+ get displayValueClassName() {
+ return this._valueClassName;
+ },
+
+ /**
+ * Gets the element associated with this item.
+ * @return nsIDOMNode
+ */
+ get target() {
+ return this._target;
+ },
+
+ /**
+ * Initializes this scope's id, view and binds event listeners.
+ *
+ * @param string aName
+ * The scope's name.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+ _init: function (aName, aFlags) {
+ this._idString = generateId(this._nameString = aName);
+ this._displayScope(aName, `${this.targetClassName} ${aFlags.customClass}`,
+ "devtools-toolbar");
+ this._addEventListeners();
+ this.parentNode.appendChild(this._target);
+ },
+
+ /**
+ * Creates the necessary nodes for this scope.
+ *
+ * @param string aName
+ * The scope's name.
+ * @param string aTargetClassName
+ * A custom class name for this scope's target element.
+ * @param string aTitleClassName [optional]
+ * A custom class name for this scope's title element.
+ */
+ _displayScope: function (aName = "", aTargetClassName, aTitleClassName = "") {
+ let document = this.document;
+
+ let element = this._target = document.createElement("vbox");
+ element.id = this._idString;
+ element.className = aTargetClassName;
+
+ let arrow = this._arrow = document.createElement("hbox");
+ arrow.className = "arrow theme-twisty";
+
+ let name = this._name = document.createElement("label");
+ name.className = "plain name";
+ name.setAttribute("value", aName.trim());
+ name.setAttribute("crop", "end");
+
+ let title = this._title = document.createElement("hbox");
+ title.className = "title " + aTitleClassName;
+ title.setAttribute("align", "center");
+
+ let enumerable = this._enum = document.createElement("vbox");
+ let nonenum = this._nonenum = document.createElement("vbox");
+ enumerable.className = "variables-view-element-details enum";
+ nonenum.className = "variables-view-element-details nonenum";
+
+ title.appendChild(arrow);
+ title.appendChild(name);
+
+ element.appendChild(title);
+ element.appendChild(enumerable);
+ element.appendChild(nonenum);
+ },
+
+ /**
+ * Adds the necessary event listeners for this scope.
+ */
+ _addEventListeners: function () {
+ this._title.addEventListener("mousedown", this._onClick, false);
+ },
+
+ /**
+ * The click listener for this scope's title.
+ */
+ _onClick: function (e) {
+ if (this.editing ||
+ e.button != 0 ||
+ e.target == this._editNode ||
+ e.target == this._deleteNode ||
+ e.target == this._addPropertyNode) {
+ return;
+ }
+ this.toggle();
+ this.focus();
+ },
+
+ /**
+ * Opens the enumerable items container.
+ */
+ _openEnum: function () {
+ this._arrow.setAttribute("open", "");
+ this._enum.setAttribute("open", "");
+ },
+
+ /**
+ * Opens the non-enumerable items container.
+ */
+ _openNonEnum: function () {
+ this._nonenum.setAttribute("open", "");
+ },
+
+ /**
+ * Specifies if enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _enumVisible(aFlag) {
+ for (let [, variable] of this._store) {
+ variable._enumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._enum.setAttribute("open", "");
+ } else {
+ this._enum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Specifies if non-enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _nonEnumVisible(aFlag) {
+ for (let [, variable] of this._store) {
+ variable._nonEnumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._nonenum.setAttribute("open", "");
+ } else {
+ this._nonenum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * @param string aLowerCaseQuery
+ * The lowercased name of the variable or property to search for.
+ */
+ _performSearch: function (aLowerCaseQuery) {
+ for (let [, variable] of this._store) {
+ let currentObject = variable;
+ let lowerCaseName = variable._nameString.toLowerCase();
+ let lowerCaseValue = variable._valueString.toLowerCase();
+
+ // Non-matched variables or properties require a corresponding attribute.
+ if (!lowerCaseName.includes(aLowerCaseQuery) &&
+ !lowerCaseValue.includes(aLowerCaseQuery)) {
+ variable._matched = false;
+ }
+ // Variable or property is matched.
+ else {
+ variable._matched = true;
+
+ // If the variable was ever expanded, there's a possibility it may
+ // contain some matched properties, so make sure they're visible
+ // ("expand downwards").
+ if (variable._store.size) {
+ variable.expand();
+ }
+
+ // If the variable is contained in another Scope, Variable, or Property,
+ // the parent may not be a match, thus hidden. It should be visible
+ // ("expand upwards").
+ while ((variable = variable.ownerView) && variable instanceof Scope) {
+ variable._matched = true;
+ variable.expand();
+ }
+ }
+
+ // Proceed with the search recursively inside this variable or property.
+ if (currentObject._store.size || currentObject.getter || currentObject.setter) {
+ currentObject._performSearch(aLowerCaseQuery);
+ }
+ }
+ },
+
+ /**
+ * Sets if this object instance is a matched or non-matched item.
+ * @param boolean aStatus
+ */
+ set _matched(aStatus) {
+ if (this._isMatch == aStatus) {
+ return;
+ }
+ if (aStatus) {
+ this._isMatch = true;
+ this.target.removeAttribute("unmatched");
+ } else {
+ this._isMatch = false;
+ this.target.setAttribute("unmatched", "");
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this item that matches
+ * the predicate. Searches in visual order (the order seen by the user).
+ * Tests itself, then descends into first the enumerable children and then
+ * the non-enumerable children (since they are presented in separate groups).
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems: function (aPredicate) {
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ if (this._isExpanded) {
+ if (this._variablesView._enumVisible) {
+ for (let item of this._enumItems) {
+ let result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._nonEnumVisible) {
+ for (let item of this._nonEnumItems) {
+ let result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this item that matches
+ * the predicate. Searches in reverse visual order (opposite of the order
+ * seen by the user). Descends into first the non-enumerable children, then
+ * the enumerable children (since they are presented in separate groups), and
+ * finally tests itself.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse: function (aPredicate) {
+ if (this._isExpanded) {
+ if (this._variablesView._nonEnumVisible) {
+ for (let i = this._nonEnumItems.length - 1; i >= 0; i--) {
+ let item = this._nonEnumItems[i];
+ let result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._enumVisible) {
+ for (let i = this._enumItems.length - 1; i >= 0; i--) {
+ let item = this._enumItems[i];
+ let result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ return null;
+ },
+
+ /**
+ * Gets top level variables view instance.
+ * @return VariablesView
+ */
+ get _variablesView() {
+ return this._topView || (this._topView = (() => {
+ let parentView = this.ownerView;
+ let topView;
+
+ while ((topView = parentView.ownerView)) {
+ parentView = topView;
+ }
+ return parentView;
+ })());
+ },
+
+ /**
+ * Gets the parent node holding this scope.
+ * @return nsIDOMNode
+ */
+ get parentNode() {
+ return this.ownerView._list;
+ },
+
+ /**
+ * Gets the owner document holding this scope.
+ * @return nsIHTMLDocument
+ */
+ get document() {
+ return this._document || (this._document = this.ownerView.document);
+ },
+
+ /**
+ * Gets the default window holding this scope.
+ * @return nsIDOMWindow
+ */
+ get window() {
+ return this._window || (this._window = this.ownerView.window);
+ },
+
+ _topView: null,
+ _document: null,
+ _window: null,
+
+ ownerView: null,
+ eval: null,
+ switch: null,
+ delete: null,
+ new: null,
+ preventDisableOnChange: false,
+ preventDescriptorModifiers: false,
+ editing: false,
+ editableNameTooltip: "",
+ editableValueTooltip: "",
+ editButtonTooltip: "",
+ deleteButtonTooltip: "",
+ domNodeValueTooltip: "",
+ contextMenuId: "",
+ separatorStr: "",
+
+ _store: null,
+ _enumItems: null,
+ _nonEnumItems: null,
+ _fetched: false,
+ _committed: false,
+ _isLocked: false,
+ _isExpanded: false,
+ _isContentVisible: true,
+ _isHeaderVisible: true,
+ _isArrowVisible: true,
+ _isMatch: true,
+ _idString: "",
+ _nameString: "",
+ _target: null,
+ _arrow: null,
+ _name: null,
+ _title: null,
+ _enum: null,
+ _nonenum: null,
+};
+
+// Creating maps and arrays thousands of times for variables or properties
+// with a large number of children fills up a lot of memory. Make sure
+// these are instantiated only if needed.
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_store", () => new Map());
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array);
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_nonEnumItems", Array);
+
+/**
+ * A Variable is a Scope holding Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Scope aScope
+ * The scope to contain this variable.
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ */
+function Variable(aScope, aName, aDescriptor, aOptions) {
+ this._setTooltips = this._setTooltips.bind(this);
+ this._activateNameInput = this._activateNameInput.bind(this);
+ this._activateValueInput = this._activateValueInput.bind(this);
+ this.openNodeInInspector = this.openNodeInInspector.bind(this);
+ this.highlightDomNode = this.highlightDomNode.bind(this);
+ this.unhighlightDomNode = this.unhighlightDomNode.bind(this);
+ this._internalItem = aOptions.internalItem;
+
+ // Treat safe getter descriptors as descriptors with a value.
+ if ("getterValue" in aDescriptor) {
+ aDescriptor.value = aDescriptor.getterValue;
+ delete aDescriptor.get;
+ delete aDescriptor.set;
+ }
+
+ Scope.call(this, aScope, aName, this._initialDescriptor = aDescriptor);
+ this.setGrip(aDescriptor.value);
+}
+
+Variable.prototype = Heritage.extend(Scope.prototype, {
+ /**
+ * Whether this Variable should be prefetched when it is remoted.
+ */
+ get shouldPrefetch() {
+ return this.name == "window" || this.name == "this";
+ },
+
+ /**
+ * Whether this Variable should paginate its contents.
+ */
+ get allowPaginate() {
+ return this.name != "window" && this.name != "this";
+ },
+
+ /**
+ * The class name applied to this variable's target element.
+ */
+ targetClassName: "variables-view-variable variable-or-property",
+
+ /**
+ * Create a new Property that is a child of Variable.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The property's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ * @return Property
+ * The newly created child Property.
+ */
+ _createChild: function (aName, aDescriptor, aOptions) {
+ return new Property(this, aName, aDescriptor, aOptions);
+ },
+
+ /**
+ * Remove this Variable from its parent and remove all children recursively.
+ */
+ remove: function () {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ this._valueLabel.removeEventListener("mouseover", this.highlightDomNode, false);
+ this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode, false);
+ this._openInspectorNode.removeEventListener("mousedown", this.openNodeInInspector, false);
+ }
+
+ this.ownerView._store.delete(this._nameString);
+ this._variablesView._itemsByElement.delete(this._target);
+ this._variablesView._currHierarchy.delete(this.absoluteName);
+
+ this._target.remove();
+
+ for (let property of this._store.values()) {
+ property.remove();
+ }
+ },
+
+ /**
+ * Populates this variable to contain all the properties of an object.
+ *
+ * @param object aObject
+ * The raw object you want to display.
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - expanded: true to expand all the properties after adding them
+ */
+ populate: function (aObject, aOptions = {}) {
+ // Retrieve the properties only once.
+ if (this._fetched) {
+ return;
+ }
+ this._fetched = true;
+
+ let propertyNames = Object.getOwnPropertyNames(aObject);
+ let prototype = Object.getPrototypeOf(aObject);
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted) {
+ propertyNames.sort(this._naturalSort);
+ }
+
+ // Add all the variable properties.
+ for (let name of propertyNames) {
+ let descriptor = Object.getOwnPropertyDescriptor(aObject, name);
+ if (descriptor.get || descriptor.set) {
+ let prop = this._addRawNonValueProperty(name, descriptor);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ } else {
+ let prop = this._addRawValueProperty(name, descriptor, aObject[name]);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ }
+ }
+ // Add the variable's __proto__.
+ if (prototype) {
+ this._addRawValueProperty("__proto__", {}, prototype);
+ }
+ },
+
+ /**
+ * Populates a specific variable or property instance to contain all the
+ * properties of an object
+ *
+ * @param Variable | Property aVar
+ * The target variable to populate.
+ * @param object aObject [optional]
+ * The raw object you want to display. If unspecified, the object is
+ * assumed to be defined in a _sourceValue property on the target.
+ */
+ _populateTarget: function (aVar, aObject = aVar._sourceValue) {
+ aVar.populate(aObject);
+ },
+
+ /**
+ * Adds a property for this variable based on a raw value descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @param object aValue
+ * The raw property value you want to display.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawValueProperty: function (aName, aDescriptor, aValue) {
+ let descriptor = Object.create(aDescriptor);
+ descriptor.value = VariablesView.getGrip(aValue);
+
+ let propertyItem = this.addItem(aName, descriptor);
+ propertyItem._sourceValue = aValue;
+
+ // Add an 'onexpand' callback for the property, lazily handling
+ // the addition of new child properties.
+ if (!VariablesView.isPrimitive(descriptor)) {
+ propertyItem.onexpand = this._populateTarget;
+ }
+ return propertyItem;
+ },
+
+ /**
+ * Adds a property for this variable based on a getter/setter descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawNonValueProperty: function (aName, aDescriptor) {
+ let descriptor = Object.create(aDescriptor);
+ descriptor.get = VariablesView.getGrip(aDescriptor.get);
+ descriptor.set = VariablesView.getGrip(aDescriptor.set);
+
+ return this.addItem(aName, descriptor);
+ },
+
+ /**
+ * Gets this variable's path to the topmost scope in the form of a string
+ * meant for use via eval() or a similar approach.
+ * For example, a symbolic name may look like "arguments['0']['foo']['bar']".
+ * @return string
+ */
+ get symbolicName() {
+ return this._nameString || "";
+ },
+
+ /**
+ * Gets full path to this variable, including name of the scope.
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName = this.ownerView._nameString + "[" + escapeString(this._nameString) + "]";
+ return this._absoluteName;
+ },
+
+ /**
+ * Gets this variable's symbolic path to the topmost scope.
+ * @return array
+ * @see Variable._buildSymbolicPath
+ */
+ get symbolicPath() {
+ if (this._symbolicPath) {
+ return this._symbolicPath;
+ }
+ this._symbolicPath = this._buildSymbolicPath();
+ return this._symbolicPath;
+ },
+
+ /**
+ * Build this variable's path to the topmost scope in form of an array of
+ * strings, one for each segment of the path.
+ * For example, a symbolic path may look like ["0", "foo", "bar"].
+ * @return array
+ */
+ _buildSymbolicPath: function (path = []) {
+ if (this.name) {
+ path.unshift(this.name);
+ if (this.ownerView instanceof Variable) {
+ return this.ownerView._buildSymbolicPath(path);
+ }
+ }
+ return path;
+ },
+
+ /**
+ * Returns this variable's value from the descriptor if available.
+ * @return any
+ */
+ get value() {
+ return this._initialDescriptor.value;
+ },
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get getter() {
+ return this._initialDescriptor.get;
+ },
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get setter() {
+ return this._initialDescriptor.set;
+ },
+
+ /**
+ * Sets the specific grip for this variable (applies the text content and
+ * class name to the value label).
+ *
+ * The grip should contain the value or the type & class, as defined in the
+ * remote debugger protocol. For convenience, undefined and null are
+ * both considered types.
+ *
+ * @param any aGrip
+ * Specifies the value and/or type & class of the variable.
+ * e.g. - 42
+ * - true
+ * - "nasu"
+ * - { type: "undefined" }
+ * - { type: "null" }
+ * - { type: "object", class: "Object" }
+ */
+ setGrip: function (aGrip) {
+ // Don't allow displaying grip information if there's no name available
+ // or the grip is malformed.
+ if (this._nameString === undefined || aGrip === undefined || aGrip === null) {
+ return;
+ }
+ // Getters and setters should display grip information in sub-properties.
+ if (this.getter || this.setter) {
+ return;
+ }
+
+ let prevGrip = this._valueGrip;
+ if (prevGrip) {
+ this._valueLabel.classList.remove(VariablesView.getClass(prevGrip));
+ }
+ this._valueGrip = aGrip;
+
+ if (aGrip && (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments)) {
+ if (aGrip.optimizedOut) {
+ this._valueString = L10N.getStr("variablesViewOptimizedOut");
+ }
+ else if (aGrip.uninitialized) {
+ this._valueString = L10N.getStr("variablesViewUninitialized");
+ }
+ else if (aGrip.missingArguments) {
+ this._valueString = L10N.getStr("variablesViewMissingArgs");
+ }
+ this.eval = null;
+ }
+ else {
+ this._valueString = VariablesView.getString(aGrip, {
+ concise: true,
+ noEllipsis: true,
+ });
+ this.eval = this.ownerView.eval;
+ }
+
+ this._valueClassName = VariablesView.getClass(aGrip);
+
+ this._valueLabel.classList.add(this._valueClassName);
+ this._valueLabel.setAttribute("value", this._valueString);
+ this._separatorLabel.hidden = false;
+
+ // DOMNodes get special treatment since they can be linked to the inspector
+ if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") {
+ this._linkToInspector();
+ }
+ },
+
+ /**
+ * Marks this variable as overridden.
+ *
+ * @param boolean aFlag
+ * Whether this variable is overridden or not.
+ */
+ setOverridden: function (aFlag) {
+ if (aFlag) {
+ this._target.setAttribute("overridden", "");
+ } else {
+ this._target.removeAttribute("overridden");
+ }
+ },
+
+ /**
+ * Briefly flashes this variable.
+ *
+ * @param number aDuration [optional]
+ * An optional flash animation duration.
+ */
+ flash: function (aDuration = ITEM_FLASH_DURATION) {
+ let fadeInDelay = this._variablesView.lazyEmptyDelay + 1;
+ let fadeOutDelay = fadeInDelay + aDuration;
+
+ setNamedTimeout("vview-flash-in" + this.absoluteName,
+ fadeInDelay, () => this._target.setAttribute("changed", ""));
+
+ setNamedTimeout("vview-flash-out" + this.absoluteName,
+ fadeOutDelay, () => this._target.removeAttribute("changed"));
+ },
+
+ /**
+ * Initializes this variable's id, view and binds event listeners.
+ *
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+ _init: function (aName, aDescriptor) {
+ this._idString = generateId(this._nameString = aName);
+ this._displayScope(aName, this.targetClassName);
+ this._displayVariable();
+ this._customizeVariable();
+ this._prepareTooltips();
+ this._setAttributes();
+ this._addEventListeners();
+
+ if (this._initialDescriptor.enumerable ||
+ this._nameString == "this" ||
+ this._internalItem) {
+ this.ownerView._enum.appendChild(this._target);
+ this.ownerView._enumItems.push(this);
+ } else {
+ this.ownerView._nonenum.appendChild(this._target);
+ this.ownerView._nonEnumItems.push(this);
+ }
+ },
+
+ /**
+ * Creates the necessary nodes for this variable.
+ */
+ _displayVariable: function () {
+ let document = this.document;
+ let descriptor = this._initialDescriptor;
+
+ let separatorLabel = this._separatorLabel = document.createElement("label");
+ separatorLabel.className = "plain separator";
+ separatorLabel.setAttribute("value", this.separatorStr + " ");
+
+ let valueLabel = this._valueLabel = document.createElement("label");
+ valueLabel.className = "plain value";
+ valueLabel.setAttribute("flex", "1");
+ valueLabel.setAttribute("crop", "center");
+
+ this._title.appendChild(separatorLabel);
+ this._title.appendChild(valueLabel);
+
+ if (VariablesView.isPrimitive(descriptor)) {
+ this.hideArrow();
+ }
+
+ // If no value will be displayed, we don't need the separator.
+ if (!descriptor.get && !descriptor.set && !("value" in descriptor)) {
+ separatorLabel.hidden = true;
+ }
+
+ // If this is a getter/setter property, create two child pseudo-properties
+ // called "get" and "set" that display the corresponding functions.
+ if (descriptor.get || descriptor.set) {
+ separatorLabel.hidden = true;
+ valueLabel.hidden = true;
+
+ // Changing getter/setter names is never allowed.
+ this.switch = null;
+
+ // Getter/setter properties require special handling when it comes to
+ // evaluation and deletion.
+ if (this.ownerView.eval) {
+ this.delete = VariablesView.getterOrSetterDeleteCallback;
+ this.evaluationMacro = VariablesView.overrideValueEvalMacro;
+ }
+ // Deleting getters and setters individually is not allowed if no
+ // evaluation method is provided.
+ else {
+ this.delete = null;
+ this.evaluationMacro = null;
+ }
+
+ let getter = this.addItem("get", { value: descriptor.get });
+ let setter = this.addItem("set", { value: descriptor.set });
+ getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+ setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+
+ getter.hideArrow();
+ setter.hideArrow();
+ this.expand();
+ }
+ },
+
+ /**
+ * Adds specific nodes for this variable based on custom flags.
+ */
+ _customizeVariable: function () {
+ let ownerView = this.ownerView;
+ let descriptor = this._initialDescriptor;
+
+ if (ownerView.eval && this.getter || this.setter) {
+ let editNode = this._editNode = this.document.createElement("toolbarbutton");
+ editNode.className = "plain variables-view-edit";
+ editNode.addEventListener("mousedown", this._onEdit.bind(this), false);
+ this._title.insertBefore(editNode, this._spacer);
+ }
+
+ if (ownerView.delete) {
+ let deleteNode = this._deleteNode = this.document.createElement("toolbarbutton");
+ deleteNode.className = "plain variables-view-delete";
+ deleteNode.addEventListener("click", this._onDelete.bind(this), false);
+ this._title.appendChild(deleteNode);
+ }
+
+ if (ownerView.new) {
+ let addPropertyNode = this._addPropertyNode = this.document.createElement("toolbarbutton");
+ addPropertyNode.className = "plain variables-view-add-property";
+ addPropertyNode.addEventListener("mousedown", this._onAddProperty.bind(this), false);
+ this._title.appendChild(addPropertyNode);
+
+ // Can't add properties to primitive values, hide the node in those cases.
+ if (VariablesView.isPrimitive(descriptor)) {
+ addPropertyNode.setAttribute("invisible", "");
+ }
+ }
+
+ if (ownerView.contextMenuId) {
+ this._title.setAttribute("context", ownerView.contextMenuId);
+ }
+
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ let nonWritableIcon = this.document.createElement("hbox");
+ nonWritableIcon.className = "plain variable-or-property-non-writable-icon";
+ nonWritableIcon.setAttribute("optional-visibility", "");
+ this._title.appendChild(nonWritableIcon);
+ }
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ let frozenLabel = this.document.createElement("label");
+ frozenLabel.className = "plain variable-or-property-frozen-label";
+ frozenLabel.setAttribute("optional-visibility", "");
+ frozenLabel.setAttribute("value", "F");
+ this._title.appendChild(frozenLabel);
+ }
+ if (descriptor.value.sealed) {
+ let sealedLabel = this.document.createElement("label");
+ sealedLabel.className = "plain variable-or-property-sealed-label";
+ sealedLabel.setAttribute("optional-visibility", "");
+ sealedLabel.setAttribute("value", "S");
+ this._title.appendChild(sealedLabel);
+ }
+ if (!descriptor.value.extensible) {
+ let nonExtensibleLabel = this.document.createElement("label");
+ nonExtensibleLabel.className = "plain variable-or-property-non-extensible-label";
+ nonExtensibleLabel.setAttribute("optional-visibility", "");
+ nonExtensibleLabel.setAttribute("value", "N");
+ this._title.appendChild(nonExtensibleLabel);
+ }
+ }
+ },
+
+ /**
+ * Prepares all tooltips for this variable.
+ */
+ _prepareTooltips: function () {
+ this._target.addEventListener("mouseover", this._setTooltips, false);
+ },
+
+ /**
+ * Sets all tooltips for this variable.
+ */
+ _setTooltips: function () {
+ this._target.removeEventListener("mouseover", this._setTooltips, false);
+
+ let ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ let tooltip = this.document.createElement("tooltip");
+ tooltip.id = "tooltip-" + this._idString;
+ tooltip.setAttribute("orient", "horizontal");
+
+ let labels = [
+ "configurable", "enumerable", "writable",
+ "frozen", "sealed", "extensible", "overridden", "WebIDL"];
+
+ for (let type of labels) {
+ let labelElement = this.document.createElement("label");
+ labelElement.className = type;
+ labelElement.setAttribute("value", L10N.getStr(type + "Tooltip"));
+ tooltip.appendChild(labelElement);
+ }
+
+ this._target.appendChild(tooltip);
+ this._target.setAttribute("tooltip", tooltip.id);
+
+ if (this._editNode && ownerView.eval) {
+ this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip);
+ }
+ if (this._openInspectorNode && this._linkedToInspector) {
+ this._openInspectorNode.setAttribute("tooltiptext", this.ownerView.domNodeValueTooltip);
+ }
+ if (this._valueLabel && ownerView.eval) {
+ this._valueLabel.setAttribute("tooltiptext", ownerView.editableValueTooltip);
+ }
+ if (this._name && ownerView.switch) {
+ this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip);
+ }
+ if (this._deleteNode && ownerView.delete) {
+ this._deleteNode.setAttribute("tooltiptext", ownerView.deleteButtonTooltip);
+ }
+ },
+
+ /**
+ * Get the parent variablesview toolbox, if any.
+ */
+ get toolbox() {
+ return this._variablesView.toolbox;
+ },
+
+ /**
+ * Checks if this variable is a DOMNode and is part of a variablesview that
+ * has been linked to the toolbox, so that highlighting and jumping to the
+ * inspector can be done.
+ */
+ _isLinkableToInspector: function () {
+ let isDomNode = this._valueGrip && this._valueGrip.preview.kind === "DOMNode";
+ let hasBeenLinked = this._linkedToInspector;
+ let hasToolbox = !!this.toolbox;
+
+ return isDomNode && !hasBeenLinked && hasToolbox;
+ },
+
+ /**
+ * If the variable is a DOMNode, and if a toolbox is set, then link it to the
+ * inspector (highlight on hover, and jump to markup-view on click)
+ */
+ _linkToInspector: function () {
+ if (!this._isLinkableToInspector()) {
+ return;
+ }
+
+ // Listen to value mouseover/click events to highlight and jump
+ this._valueLabel.addEventListener("mouseover", this.highlightDomNode, false);
+ this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode, false);
+
+ // Add a button to open the node in the inspector
+ this._openInspectorNode = this.document.createElement("toolbarbutton");
+ this._openInspectorNode.className = "plain variables-view-open-inspector";
+ this._openInspectorNode.addEventListener("mousedown", this.openNodeInInspector, false);
+ this._title.appendChild(this._openInspectorNode);
+
+ this._linkedToInspector = true;
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then select the corresponding node in
+ * the inspector, and switch the inspector tool in the toolbox
+ * @return a promise that resolves when the node is selected and the inspector
+ * has been switched to and is ready
+ */
+ openNodeInInspector: function (event) {
+ if (!this.toolbox) {
+ return promise.reject(new Error("Toolbox not available"));
+ }
+
+ event && event.stopPropagation();
+
+ return Task.spawn(function* () {
+ yield this.toolbox.initInspector();
+
+ let nodeFront = this._nodeFront;
+ if (!nodeFront) {
+ nodeFront = yield this.toolbox.walker.getNodeActorFromObjectActor(this._valueGrip.actor);
+ }
+
+ if (nodeFront) {
+ yield this.toolbox.selectTool("inspector");
+
+ let inspectorReady = defer();
+ this.toolbox.getPanel("inspector").once("inspector-updated", inspectorReady.resolve);
+ yield this.toolbox.selection.setNodeFront(nodeFront, "variables-view");
+ yield inspectorReady.promise;
+ }
+ }.bind(this));
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then highlight the corresponding node
+ */
+ highlightDomNode: function () {
+ if (this.toolbox) {
+ if (this._nodeFront) {
+ // If the nodeFront has been retrieved before, no need to ask the server
+ // again for it
+ this.toolbox.highlighterUtils.highlightNodeFront(this._nodeFront);
+ return;
+ }
+
+ this.toolbox.highlighterUtils.highlightDomValueGrip(this._valueGrip).then(front => {
+ this._nodeFront = front;
+ });
+ }
+ },
+
+ /**
+ * Unhighlight a previously highlit node
+ * @see highlightDomNode
+ */
+ unhighlightDomNode: function () {
+ if (this.toolbox) {
+ this.toolbox.highlighterUtils.unhighlight();
+ }
+ },
+
+ /**
+ * Sets a variable's configurable, enumerable and writable attributes,
+ * and specifies if it's a 'this', '<exception>', '<return>' or '__proto__'
+ * reference.
+ */
+ _setAttributes: function () {
+ let ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ let descriptor = this._initialDescriptor;
+ let target = this._target;
+ let name = this._nameString;
+
+ if (ownerView.eval) {
+ target.setAttribute("editable", "");
+ }
+
+ if (!descriptor.configurable) {
+ target.setAttribute("non-configurable", "");
+ }
+ if (!descriptor.enumerable) {
+ target.setAttribute("non-enumerable", "");
+ }
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ target.setAttribute("non-writable", "");
+ }
+
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ target.setAttribute("frozen", "");
+ }
+ if (descriptor.value.sealed) {
+ target.setAttribute("sealed", "");
+ }
+ if (!descriptor.value.extensible) {
+ target.setAttribute("non-extensible", "");
+ }
+ }
+
+ if (descriptor && "getterValue" in descriptor) {
+ target.setAttribute("safe-getter", "");
+ }
+
+ if (name == "this") {
+ target.setAttribute("self", "");
+ }
+ else if (this._internalItem && name == "<exception>") {
+ target.setAttribute("exception", "");
+ target.setAttribute("pseudo-item", "");
+ }
+ else if (this._internalItem && name == "<return>") {
+ target.setAttribute("return", "");
+ target.setAttribute("pseudo-item", "");
+ }
+ else if (name == "__proto__") {
+ target.setAttribute("proto", "");
+ target.setAttribute("pseudo-item", "");
+ }
+
+ if (Object.keys(descriptor).length == 0) {
+ target.setAttribute("pseudo-item", "");
+ }
+ },
+
+ /**
+ * Adds the necessary event listeners for this variable.
+ */
+ _addEventListeners: function () {
+ this._name.addEventListener("dblclick", this._activateNameInput, false);
+ this._valueLabel.addEventListener("mousedown", this._activateValueInput, false);
+ this._title.addEventListener("mousedown", this._onClick, false);
+ },
+
+ /**
+ * Makes this variable's name editable.
+ */
+ _activateNameInput: function (e) {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = true;
+ this._valueLabel.hidden = true;
+ }
+
+ EditableName.create(this, {
+ onSave: aKey => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.switch(this, aKey);
+ },
+ onCleanup: () => {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = false;
+ this._valueLabel.hidden = false;
+ }
+ }
+ }, e);
+ },
+
+ /**
+ * Makes this variable's value editable.
+ */
+ _activateValueInput: function (e) {
+ EditableValue.create(this, {
+ onSave: aString => {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ }
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.eval(this, aString);
+ }
+ }, e);
+ },
+
+ /**
+ * Disables this variable prior to a new name switch or value evaluation.
+ */
+ _disable: function () {
+ // Prevent the variable from being collapsed or expanded.
+ this.hideArrow();
+
+ // Hide any nodes that may offer information about the variable.
+ for (let node of this._title.childNodes) {
+ node.hidden = node != this._arrow && node != this._name;
+ }
+ this._enum.hidden = true;
+ this._nonenum.hidden = true;
+ },
+
+ /**
+ * The current macro used to generate the string evaluated when performing
+ * a variable or property value change.
+ */
+ evaluationMacro: VariablesView.simpleValueEvalMacro,
+
+ /**
+ * The click listener for the edit button.
+ */
+ _onEdit: function (e) {
+ if (e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ this._activateValueInput();
+ },
+
+ /**
+ * The click listener for the delete button.
+ */
+ _onDelete: function (e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (this.ownerView.delete) {
+ if (!this.ownerView.delete(this)) {
+ this.hide();
+ }
+ }
+ },
+
+ /**
+ * The click listener for the add property button.
+ */
+ _onAddProperty: function (e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.expanded = true;
+
+ let item = this.addItem(" ", {
+ value: undefined,
+ configurable: true,
+ enumerable: true,
+ writable: true
+ }, {relaxed: true});
+
+ // Force showing the separator.
+ item._separatorLabel.hidden = false;
+
+ EditableNameAndValue.create(item, {
+ onSave: ([aKey, aValue]) => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.new(this, aKey, aValue);
+ }
+ }, e);
+ },
+
+ _symbolicName: null,
+ _symbolicPath: null,
+ _absoluteName: null,
+ _initialDescriptor: null,
+ _separatorLabel: null,
+ _valueLabel: null,
+ _spacer: null,
+ _editNode: null,
+ _deleteNode: null,
+ _addPropertyNode: null,
+ _tooltip: null,
+ _valueGrip: null,
+ _valueString: "",
+ _valueClassName: "",
+ _prevExpandable: false,
+ _prevExpanded: false
+});
+
+/**
+ * A Property is a Variable holding additional child Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Variable aVar
+ * The variable to contain this property.
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * The property's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ */
+function Property(aVar, aName, aDescriptor, aOptions) {
+ Variable.call(this, aVar, aName, aDescriptor, aOptions);
+}
+
+Property.prototype = Heritage.extend(Variable.prototype, {
+ /**
+ * The class name applied to this property's target element.
+ */
+ targetClassName: "variables-view-property variable-or-property",
+
+ /**
+ * @see Variable.symbolicName
+ * @return string
+ */
+ get symbolicName() {
+ if (this._symbolicName) {
+ return this._symbolicName;
+ }
+
+ this._symbolicName = this.ownerView.symbolicName + "[" + escapeString(this._nameString) + "]";
+ return this._symbolicName;
+ },
+
+ /**
+ * @see Variable.absoluteName
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName = this.ownerView.absoluteName + "[" + escapeString(this._nameString) + "]";
+ return this._absoluteName;
+ }
+});
+
+/**
+ * A generator-iterator over the VariablesView, Scopes, Variables and Properties.
+ */
+VariablesView.prototype[Symbol.iterator] =
+Scope.prototype[Symbol.iterator] =
+Variable.prototype[Symbol.iterator] =
+Property.prototype[Symbol.iterator] = function* () {
+ yield* this._store;
+};
+
+/**
+ * Forget everything recorded about added scopes, variables or properties.
+ * @see VariablesView.commitHierarchy
+ */
+VariablesView.prototype.clearHierarchy = function () {
+ this._prevHierarchy.clear();
+ this._currHierarchy.clear();
+};
+
+/**
+ * Perform operations on all the VariablesView Scopes, Variables and Properties
+ * after you've added all the items you wanted.
+ *
+ * Calling this method is optional, and does the following:
+ * - styles the items overridden by other items in parent scopes
+ * - reopens the items which were previously expanded
+ * - flashes the items whose values changed
+ */
+VariablesView.prototype.commitHierarchy = function () {
+ for (let [, currItem] of this._currHierarchy) {
+ // Avoid performing expensive operations.
+ if (this.commitHierarchyIgnoredItems[currItem._nameString]) {
+ continue;
+ }
+ let overridden = this.isOverridden(currItem);
+ if (overridden) {
+ currItem.setOverridden(true);
+ }
+ let expanded = !currItem._committed && this.wasExpanded(currItem);
+ if (expanded) {
+ currItem.expand();
+ }
+ let changed = !currItem._committed && this.hasChanged(currItem);
+ if (changed) {
+ currItem.flash();
+ }
+ currItem._committed = true;
+ }
+ if (this.oncommit) {
+ this.oncommit(this);
+ }
+};
+
+// Some variables are likely to contain a very large number of properties.
+// It would be a bad idea to re-expand them or perform expensive operations.
+VariablesView.prototype.commitHierarchyIgnoredItems = Heritage.extend(null, {
+ "window": true,
+ "this": true
+});
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.wasExpanded = function (aItem) {
+ if (!(aItem instanceof Scope)) {
+ return false;
+ }
+ let prevItem = this._prevHierarchy.get(aItem.absoluteName || aItem._nameString);
+ return prevItem ? prevItem._isExpanded : false;
+};
+
+/**
+ * Checks if the an item's displayed value (a representation of the grip)
+ * has changed, if it existed in a previous hierarchy.
+ *
+ * @param Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item has changed.
+ */
+VariablesView.prototype.hasChanged = function (aItem) {
+ // Only analyze Variables and Properties for displayed value changes.
+ // Scopes are just collections of Variables and Properties and
+ // don't have a "value", so they can't change.
+ if (!(aItem instanceof Variable)) {
+ return false;
+ }
+ let prevItem = this._prevHierarchy.get(aItem.absoluteName);
+ return prevItem ? prevItem._valueString != aItem._valueString : false;
+};
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.isOverridden = function (aItem) {
+ // Only analyze Variables for being overridden in different Scopes.
+ if (!(aItem instanceof Variable) || aItem instanceof Property) {
+ return false;
+ }
+ let currVariableName = aItem._nameString;
+ let parentScopes = this.getParentScopesForVariableOrProperty(aItem);
+
+ for (let otherScope of parentScopes) {
+ for (let [otherVariableName] of otherScope) {
+ if (otherVariableName == currVariableName) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined, null or
+ * primitive value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isPrimitive = function (aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ let getter = aDescriptor.get;
+ let setter = aDescriptor.set;
+ if (getter || setter) {
+ return false;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return true;
+ }
+
+ // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long
+ // strings are considered types.
+ let type = grip.type;
+ if (type == "undefined" ||
+ type == "null" ||
+ type == "Infinity" ||
+ type == "-Infinity" ||
+ type == "NaN" ||
+ type == "-0" ||
+ type == "symbol" ||
+ type == "longString") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isUndefined = function (aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ let getter = aDescriptor.get;
+ let setter = aDescriptor.set;
+ if (typeof getter == "object" && getter.type == "undefined" &&
+ typeof setter == "object" && setter.type == "undefined") {
+ return true;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip == "object" && grip.type == "undefined") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents a falsy value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isFalsy = function (aDescriptor) {
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return !grip;
+ }
+
+ // For convenience, undefined, null, NaN, and -0 are all considered types.
+ let type = grip.type;
+ if (type == "undefined" ||
+ type == "null" ||
+ type == "NaN" ||
+ type == "-0") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the value is an instance of Variable or Property.
+ *
+ * @param any aValue
+ * The value to test.
+ */
+VariablesView.isVariable = function (aValue) {
+ return aValue instanceof Variable;
+};
+
+/**
+ * Returns a standard grip for a value.
+ *
+ * @param any aValue
+ * The raw value to get a grip for.
+ * @return any
+ * The value's grip.
+ */
+VariablesView.getGrip = function (aValue) {
+ switch (typeof aValue) {
+ case "boolean":
+ case "string":
+ return aValue;
+ case "number":
+ if (aValue === Infinity) {
+ return { type: "Infinity" };
+ } else if (aValue === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(aValue)) {
+ return { type: "NaN" };
+ } else if (1 / aValue === -Infinity) {
+ return { type: "-0" };
+ }
+ return aValue;
+ case "undefined":
+ // document.all is also "undefined"
+ if (aValue === undefined) {
+ return { type: "undefined" };
+ }
+ case "object":
+ if (aValue === null) {
+ return { type: "null" };
+ }
+ case "function":
+ return { type: "object",
+ class: WebConsoleUtils.getObjectClassName(aValue) };
+ default:
+ console.error("Failed to provide a grip for value of " + typeof value +
+ ": " + aValue);
+ return null;
+ }
+};
+
+/**
+ * Returns a custom formatted property string for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @param object aOptions
+ * Options:
+ * - concise: boolean that tells you want a concisely formatted string.
+ * - noStringQuotes: boolean that tells to not quote strings.
+ * - noEllipsis: boolean that tells to not add an ellipsis after the
+ * initial text of a longString.
+ * @return string
+ * The formatted property string.
+ */
+VariablesView.getString = function (aGrip, aOptions = {}) {
+ if (aGrip && typeof aGrip == "object") {
+ switch (aGrip.type) {
+ case "undefined":
+ case "null":
+ case "NaN":
+ case "Infinity":
+ case "-Infinity":
+ case "-0":
+ return aGrip.type;
+ default:
+ let stringifier = VariablesView.stringifiers.byType[aGrip.type];
+ if (stringifier) {
+ let result = stringifier(aGrip, aOptions);
+ if (result != null) {
+ return result;
+ }
+ }
+
+ if (aGrip.displayString) {
+ return VariablesView.getString(aGrip.displayString, aOptions);
+ }
+
+ if (aGrip.type == "object" && aOptions.concise) {
+ return aGrip.class;
+ }
+
+ return "[" + aGrip.type + " " + aGrip.class + "]";
+ }
+ }
+
+ switch (typeof aGrip) {
+ case "string":
+ return VariablesView.stringifiers.byType.string(aGrip, aOptions);
+ case "boolean":
+ return aGrip ? "true" : "false";
+ case "number":
+ if (!aGrip && 1 / aGrip === -Infinity) {
+ return "-0";
+ }
+ default:
+ return aGrip + "";
+ }
+};
+
+/**
+ * The VariablesView stringifiers are used by VariablesView.getString(). These
+ * are organized by object type, object class and by object actor preview kind.
+ * Some objects share identical ways for previews, for example Arrays, Sets and
+ * NodeLists.
+ *
+ * Any stringifier function must return a string. If null is returned, * then
+ * the default stringifier will be used. When invoked, the stringifier is
+ * given the same two arguments as those given to VariablesView.getString().
+ */
+VariablesView.stringifiers = {};
+
+VariablesView.stringifiers.byType = {
+ string: function (aGrip, {noStringQuotes}) {
+ if (noStringQuotes) {
+ return aGrip;
+ }
+ return '"' + aGrip + '"';
+ },
+
+ longString: function ({initial}, {noStringQuotes, noEllipsis}) {
+ let ellipsis = noEllipsis ? "" : ELLIPSIS;
+ if (noStringQuotes) {
+ return initial + ellipsis;
+ }
+ let result = '"' + initial + '"';
+ if (!ellipsis) {
+ return result;
+ }
+ return result.substr(0, result.length - 1) + ellipsis + '"';
+ },
+
+ object: function (aGrip, aOptions) {
+ let {preview} = aGrip;
+ let stringifier;
+ if (aGrip.class) {
+ stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class];
+ }
+ if (!stringifier && preview && preview.kind) {
+ stringifier = VariablesView.stringifiers.byObjectKind[preview.kind];
+ }
+ if (stringifier) {
+ return stringifier(aGrip, aOptions);
+ }
+ return null;
+ },
+
+ symbol: function (aGrip, aOptions) {
+ const name = aGrip.name || "";
+ return "Symbol(" + name + ")";
+ },
+
+ mapEntry: function (aGrip, {concise}) {
+ let { preview: { key, value }} = aGrip;
+
+ let keyString = VariablesView.getString(key, {
+ concise: true,
+ noStringQuotes: true,
+ });
+ let valueString = VariablesView.getString(value, { concise: true });
+
+ return keyString + " \u2192 " + valueString;
+ },
+
+}; // VariablesView.stringifiers.byType
+
+VariablesView.stringifiers.byObjectClass = {
+ Function: function (aGrip, {concise}) {
+ // TODO: Bug 948484 - support arrow functions and ES6 generators
+
+ let name = aGrip.userDisplayName || aGrip.displayName || aGrip.name || "";
+ name = VariablesView.getString(name, { noStringQuotes: true });
+
+ // TODO: Bug 948489 - Support functions with destructured parameters and
+ // rest parameters
+ let params = aGrip.parameterNames || "";
+ if (!concise) {
+ return "function " + name + "(" + params + ")";
+ }
+ return (name || "function ") + "(" + params + ")";
+ },
+
+ RegExp: function ({displayString}) {
+ return VariablesView.getString(displayString, { noStringQuotes: true });
+ },
+
+ Date: function ({preview}) {
+ if (!preview || !("timestamp" in preview)) {
+ return null;
+ }
+
+ if (typeof preview.timestamp != "number") {
+ return new Date(preview.timestamp).toString(); // invalid date
+ }
+
+ return "Date " + new Date(preview.timestamp).toISOString();
+ },
+
+ Number: function (aGrip) {
+ let {preview} = aGrip;
+ if (preview === undefined) {
+ return null;
+ }
+ return aGrip.class + " { " + VariablesView.getString(preview.wrappedValue) +
+ " }";
+ },
+}; // VariablesView.stringifiers.byObjectClass
+
+VariablesView.stringifiers.byObjectClass.Boolean =
+ VariablesView.stringifiers.byObjectClass.Number;
+
+VariablesView.stringifiers.byObjectKind = {
+ ArrayLike: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise) {
+ return aGrip.class + "[" + preview.length + "]";
+ }
+
+ if (!preview.items) {
+ return null;
+ }
+
+ let shown = 0, result = [], lastHole = null;
+ for (let item of preview.items) {
+ if (item === null) {
+ if (lastHole !== null) {
+ result[lastHole] += ",";
+ } else {
+ result.push("");
+ }
+ lastHole = result.length - 1;
+ } else {
+ lastHole = null;
+ result.push(VariablesView.getString(item, { concise: true }));
+ }
+ shown++;
+ }
+
+ if (shown < preview.length) {
+ let n = preview.length - shown;
+ result.push(VariablesView.stringifiers._getNMoreString(n));
+ } else if (lastHole !== null) {
+ // make sure we have the right number of commas...
+ result[lastHole] += ",";
+ }
+
+ let prefix = aGrip.class == "Array" ? "" : aGrip.class + " ";
+ return prefix + "[" + result.join(", ") + "]";
+ },
+
+ MapLike: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise || !preview.entries) {
+ let size = typeof preview.size == "number" ?
+ "[" + preview.size + "]" : "";
+ return aGrip.class + size;
+ }
+
+ let entries = [];
+ for (let [key, value] of preview.entries) {
+ let keyString = VariablesView.getString(key, {
+ concise: true,
+ noStringQuotes: true,
+ });
+ let valueString = VariablesView.getString(value, { concise: true });
+ entries.push(keyString + ": " + valueString);
+ }
+
+ if (typeof preview.size == "number" && preview.size > entries.length) {
+ let n = preview.size - entries.length;
+ entries.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+
+ return aGrip.class + " {" + entries.join(", ") + "}";
+ },
+
+ ObjectWithText: function (aGrip, {concise}) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ return aGrip.class + " " + VariablesView.getString(aGrip.preview.text);
+ },
+
+ ObjectWithURL: function (aGrip, {concise}) {
+ let result = aGrip.class;
+ let url = aGrip.preview.url;
+ if (!VariablesView.isFalsy({ value: url })) {
+ result += ` \u2192 ${getSourceNames(url)[concise ? "short" : "long"]}`;
+ }
+ return result;
+ },
+
+ // Stringifier for any kind of object.
+ Object: function (aGrip, {concise}) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ let {preview} = aGrip;
+ let props = [];
+
+ if (aGrip.class == "Promise" && aGrip.promiseState) {
+ let { state, value, reason } = aGrip.promiseState;
+ props.push("<state>: " + VariablesView.getString(state));
+ if (state == "fulfilled") {
+ props.push("<value>: " + VariablesView.getString(value, { concise: true }));
+ } else if (state == "rejected") {
+ props.push("<reason>: " + VariablesView.getString(reason, { concise: true }));
+ }
+ }
+
+ for (let key of Object.keys(preview.ownProperties || {})) {
+ let value = preview.ownProperties[key];
+ let valueString = "";
+ if (value.get) {
+ valueString = "Getter";
+ } else if (value.set) {
+ valueString = "Setter";
+ } else {
+ valueString = VariablesView.getString(value.value, { concise: true });
+ }
+ props.push(key + ": " + valueString);
+ }
+
+ for (let key of Object.keys(preview.safeGetterValues || {})) {
+ let value = preview.safeGetterValues[key];
+ let valueString = VariablesView.getString(value.getterValue,
+ { concise: true });
+ props.push(key + ": " + valueString);
+ }
+
+ if (!props.length) {
+ return null;
+ }
+
+ if (preview.ownPropertiesLength) {
+ let previewLength = Object.keys(preview.ownProperties).length;
+ let diff = preview.ownPropertiesLength - previewLength;
+ if (diff > 0) {
+ props.push(VariablesView.stringifiers._getNMoreString(diff));
+ }
+ }
+
+ let prefix = aGrip.class != "Object" ? aGrip.class + " " : "";
+ return prefix + "{" + props.join(", ") + "}";
+ }, // Object
+
+ Error: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+ let name = VariablesView.getString(preview.name, { noStringQuotes: true });
+ if (concise) {
+ return name || aGrip.class;
+ }
+
+ let msg = name + ": " +
+ VariablesView.getString(preview.message, { noStringQuotes: true });
+
+ if (!VariablesView.isFalsy({ value: preview.stack })) {
+ msg += "\n" + L10N.getStr("variablesViewErrorStacktrace") +
+ "\n" + preview.stack;
+ }
+
+ return msg;
+ },
+
+ DOMException: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise) {
+ return preview.name || aGrip.class;
+ }
+
+ let msg = aGrip.class + " [" + preview.name + ": " +
+ VariablesView.getString(preview.message) + "\n" +
+ "code: " + preview.code + "\n" +
+ "nsresult: 0x" + (+preview.result).toString(16);
+
+ if (preview.filename) {
+ msg += "\nlocation: " + preview.filename;
+ if (preview.lineNumber) {
+ msg += ":" + preview.lineNumber;
+ }
+ }
+
+ return msg + "]";
+ },
+
+ DOMEvent: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (!preview.type) {
+ return null;
+ }
+
+ if (concise) {
+ return aGrip.class + " " + preview.type;
+ }
+
+ let result = preview.type;
+
+ if (preview.eventKind == "key" && preview.modifiers &&
+ preview.modifiers.length) {
+ result += " " + preview.modifiers.join("-");
+ }
+
+ let props = [];
+ if (preview.target) {
+ let target = VariablesView.getString(preview.target, { concise: true });
+ props.push("target: " + target);
+ }
+
+ for (let prop in preview.properties) {
+ let value = preview.properties[prop];
+ props.push(prop + ": " + VariablesView.getString(value, { concise: true }));
+ }
+
+ return result + " {" + props.join(", ") + "}";
+ }, // DOMEvent
+
+ DOMNode: function (aGrip, {concise}) {
+ let {preview} = aGrip;
+
+ switch (preview.nodeType) {
+ case nodeConstants.DOCUMENT_NODE: {
+ let result = aGrip.class;
+ if (preview.location) {
+ result += ` \u2192 ${getSourceNames(preview.location)[concise ? "short" : "long"]}`;
+ }
+
+ return result;
+ }
+
+ case nodeConstants.ATTRIBUTE_NODE: {
+ let value = VariablesView.getString(preview.value, { noStringQuotes: true });
+ return preview.nodeName + '="' + escapeHTML(value) + '"';
+ }
+
+ case nodeConstants.TEXT_NODE:
+ return preview.nodeName + " " +
+ VariablesView.getString(preview.textContent);
+
+ case nodeConstants.COMMENT_NODE: {
+ let comment = VariablesView.getString(preview.textContent,
+ { noStringQuotes: true });
+ return "<!--" + comment + "-->";
+ }
+
+ case nodeConstants.DOCUMENT_FRAGMENT_NODE: {
+ if (concise || !preview.childNodes) {
+ return aGrip.class + "[" + preview.childNodesLength + "]";
+ }
+ let nodes = [];
+ for (let node of preview.childNodes) {
+ nodes.push(VariablesView.getString(node));
+ }
+ if (nodes.length < preview.childNodesLength) {
+ let n = preview.childNodesLength - nodes.length;
+ nodes.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+ return aGrip.class + " [" + nodes.join(", ") + "]";
+ }
+
+ case nodeConstants.ELEMENT_NODE: {
+ let attrs = preview.attributes;
+ if (!concise) {
+ let n = 0, result = "<" + preview.nodeName;
+ for (let name in attrs) {
+ let value = VariablesView.getString(attrs[name],
+ { noStringQuotes: true });
+ result += " " + name + '="' + escapeHTML(value) + '"';
+ n++;
+ }
+ if (preview.attributesLength > n) {
+ result += " " + ELLIPSIS;
+ }
+ return result + ">";
+ }
+
+ let result = "<" + preview.nodeName;
+ if (attrs.id) {
+ result += "#" + attrs.id;
+ }
+
+ if (attrs.class) {
+ result += "." + attrs.class.trim().replace(/\s+/, ".");
+ }
+ return result + ">";
+ }
+
+ default:
+ return null;
+ }
+ }, // DOMNode
+}; // VariablesView.stringifiers.byObjectKind
+
+
+/**
+ * Get the "N more…" formatted string, given an N. This is used for displaying
+ * how many elements are not displayed in an object preview (eg. an array).
+ *
+ * @private
+ * @param number aNumber
+ * @return string
+ */
+VariablesView.stringifiers._getNMoreString = function (aNumber) {
+ let str = L10N.getStr("variablesViewMoreObjects");
+ return PluralForm.get(aNumber, str).replace("#1", aNumber);
+};
+
+/**
+ * Returns a custom class style for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @return string
+ * The custom class style.
+ */
+VariablesView.getClass = function (aGrip) {
+ if (aGrip && typeof aGrip == "object") {
+ if (aGrip.preview) {
+ switch (aGrip.preview.kind) {
+ case "DOMNode":
+ return "token-domnode";
+ }
+ }
+
+ switch (aGrip.type) {
+ case "undefined":
+ return "token-undefined";
+ case "null":
+ return "token-null";
+ case "Infinity":
+ case "-Infinity":
+ case "NaN":
+ case "-0":
+ return "token-number";
+ case "longString":
+ return "token-string";
+ }
+ }
+ switch (typeof aGrip) {
+ case "string":
+ return "token-string";
+ case "boolean":
+ return "token-boolean";
+ case "number":
+ return "token-number";
+ default:
+ return "token-other";
+ }
+};
+
+/**
+ * A monotonically-increasing counter, that guarantees the uniqueness of scope,
+ * variables and properties ids.
+ *
+ * @param string aName
+ * An optional string to prefix the id with.
+ * @return number
+ * A unique id.
+ */
+var generateId = (function () {
+ let count = 0;
+ return function (aName = "") {
+ return aName.toLowerCase().trim().replace(/\s+/g, "-") + (++count);
+ };
+})();
+
+/**
+ * Serialize a string to JSON. The result can be inserted in a string evaluated by `eval`.
+ *
+ * @param string aString
+ * The string to be escaped. If undefined, the function returns the empty string.
+ * @return string
+ */
+function escapeString(aString) {
+ return JSON.stringify(aString) || "";
+}
+
+/**
+ * Escape some HTML special characters. We do not need full HTML serialization
+ * here, we just want to make strings safe to display in HTML attributes, for
+ * the stringifiers.
+ *
+ * @param string aString
+ * @return string
+ */
+function escapeHTML(aString) {
+ return aString.replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
+
+
+/**
+ * An Editable encapsulates the UI of an edit box that overlays a label,
+ * allowing the user to edit the value.
+ *
+ * @param Variable aVariable
+ * The Variable or Property to make editable.
+ * @param object aOptions
+ * - onSave
+ * The callback to call with the value when editing is complete.
+ * - onCleanup
+ * The callback to call when the editable is removed for any reason.
+ */
+function Editable(aVariable, aOptions) {
+ this._variable = aVariable;
+ this._onSave = aOptions.onSave;
+ this._onCleanup = aOptions.onCleanup;
+}
+
+Editable.create = function (aVariable, aOptions, aEvent) {
+ let editable = new this(aVariable, aOptions);
+ editable.activate(aEvent);
+ return editable;
+};
+
+Editable.prototype = {
+ /**
+ * The class name for targeting this Editable type's label element. Overridden
+ * by inheriting classes.
+ */
+ className: null,
+
+ /**
+ * Boolean indicating whether this Editable should activate. Overridden by
+ * inheriting classes.
+ */
+ shouldActivate: null,
+
+ /**
+ * The label element for this Editable. Overridden by inheriting classes.
+ */
+ label: null,
+
+ /**
+ * Activate this editable by replacing the input box it overlays and
+ * initialize the handlers.
+ *
+ * @param Event e [optional]
+ * Optionally, the Event object that was used to activate the Editable.
+ */
+ activate: function (e) {
+ if (!this.shouldActivate) {
+ this._onCleanup && this._onCleanup();
+ return;
+ }
+
+ let { label } = this;
+ let initialString = label.getAttribute("value");
+
+ if (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ // Create a texbox input element which will be shown in the current
+ // element's specified label location.
+ let input = this._input = this._variable.document.createElement("textbox");
+ input.className = "plain " + this.className;
+ input.setAttribute("value", initialString);
+ input.setAttribute("flex", "1");
+
+ // Replace the specified label with a textbox input element.
+ label.parentNode.replaceChild(input, label);
+ this._variable._variablesView.boxObject.ensureElementIsVisible(input);
+ input.select();
+
+ // When the value is a string (displayed as "value"), then we probably want
+ // to change it to another string in the textbox, so to avoid typing the ""
+ // again, tackle with the selection bounds just a bit.
+ if (initialString.match(/^".+"$/)) {
+ input.selectionEnd--;
+ input.selectionStart++;
+ }
+
+ this._onKeypress = this._onKeypress.bind(this);
+ this._onBlur = this._onBlur.bind(this);
+ input.addEventListener("keypress", this._onKeypress);
+ input.addEventListener("blur", this._onBlur);
+
+ this._prevExpandable = this._variable.twisty;
+ this._prevExpanded = this._variable.expanded;
+ this._variable.collapse();
+ this._variable.hideArrow();
+ this._variable.locked = true;
+ this._variable.editing = true;
+ },
+
+ /**
+ * Remove the input box and restore the Variable or Property to its previous
+ * state.
+ */
+ deactivate: function () {
+ this._input.removeEventListener("keypress", this._onKeypress);
+ this._input.removeEventListener("blur", this.deactivate);
+ this._input.parentNode.replaceChild(this.label, this._input);
+ this._input = null;
+
+ let { boxObject } = this._variable._variablesView;
+ boxObject.scrollBy(-this._variable._target, 0);
+ this._variable.locked = false;
+ this._variable.twisty = this._prevExpandable;
+ this._variable.expanded = this._prevExpanded;
+ this._variable.editing = false;
+ this._onCleanup && this._onCleanup();
+ },
+
+ /**
+ * Save the current value and deactivate the Editable.
+ */
+ _save: function () {
+ let initial = this.label.getAttribute("value");
+ let current = this._input.value.trim();
+ this.deactivate();
+ if (initial != current) {
+ this._onSave(current);
+ }
+ },
+
+ /**
+ * Called when tab is pressed, allowing subclasses to link different
+ * behavior to tabbing if desired.
+ */
+ _next: function () {
+ this._save();
+ },
+
+ /**
+ * Called when escape is pressed, indicating a cancelling of editing without
+ * saving.
+ */
+ _reset: function () {
+ this.deactivate();
+ this._variable.focus();
+ },
+
+ /**
+ * Event handler for when the input loses focus.
+ */
+ _onBlur: function () {
+ this.deactivate();
+ },
+
+ /**
+ * Event handler for when the input receives a key press.
+ */
+ _onKeypress: function (e) {
+ e.stopPropagation();
+
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_TAB:
+ this._next();
+ break;
+ case KeyCodes.DOM_VK_RETURN:
+ this._save();
+ break;
+ case KeyCodes.DOM_VK_ESCAPE:
+ this._reset();
+ break;
+ }
+ },
+};
+
+
+/**
+ * An Editable specific to editing the name of a Variable or Property.
+ */
+function EditableName(aVariable, aOptions) {
+ Editable.call(this, aVariable, aOptions);
+}
+
+EditableName.create = Editable.create;
+
+EditableName.prototype = Heritage.extend(Editable.prototype, {
+ className: "element-name-input",
+
+ get label() {
+ return this._variable._name;
+ },
+
+ get shouldActivate() {
+ return !!this._variable.ownerView.switch;
+ },
+});
+
+
+/**
+ * An Editable specific to editing the value of a Variable or Property.
+ */
+function EditableValue(aVariable, aOptions) {
+ Editable.call(this, aVariable, aOptions);
+}
+
+EditableValue.create = Editable.create;
+
+EditableValue.prototype = Heritage.extend(Editable.prototype, {
+ className: "element-value-input",
+
+ get label() {
+ return this._variable._valueLabel;
+ },
+
+ get shouldActivate() {
+ return !!this._variable.ownerView.eval;
+ },
+});
+
+
+/**
+ * An Editable specific to editing the key and value of a new property.
+ */
+function EditableNameAndValue(aVariable, aOptions) {
+ EditableName.call(this, aVariable, aOptions);
+}
+
+EditableNameAndValue.create = Editable.create;
+
+EditableNameAndValue.prototype = Heritage.extend(EditableName.prototype, {
+ _reset: function (e) {
+ // Hide the Variable or Property if the user presses escape.
+ this._variable.remove();
+ this.deactivate();
+ },
+
+ _next: function (e) {
+ // Override _next so as to set both key and value at the same time.
+ let key = this._input.value;
+ this.label.setAttribute("value", key);
+
+ let valueEditable = EditableValue.create(this._variable, {
+ onSave: aValue => {
+ this._onSave([key, aValue]);
+ }
+ });
+ valueEditable._reset = () => {
+ this._variable.remove();
+ valueEditable.deactivate();
+ };
+ },
+
+ _save: function (e) {
+ // Both _save and _next activate the value edit box.
+ this._next(e);
+ }
+});
diff --git a/devtools/client/shared/widgets/VariablesView.xul b/devtools/client/shared/widgets/VariablesView.xul
new file mode 100644
index 000000000..fe8bb13ec
--- /dev/null
+++ b/devtools/client/shared/widgets/VariablesView.xul
@@ -0,0 +1,18 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?>
+<!DOCTYPE window [
+ <!ENTITY % viewDTD SYSTEM "chrome://devtools/locale/VariablesView.dtd">
+ %viewDTD;
+]>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="&PropertiesViewWindowTitle;">
+
+ <script type="application/javascript;version=1.8"
+ src="chrome://devtools/content/shared/theme-switching.js"/>
+ <vbox id="variables" flex="1"/>
+</window>
diff --git a/devtools/client/shared/widgets/VariablesViewController.jsm b/devtools/client/shared/widgets/VariablesViewController.jsm
new file mode 100644
index 000000000..5413ce1bf
--- /dev/null
+++ b/devtools/client/shared/widgets/VariablesViewController.jsm
@@ -0,0 +1,858 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 { utils: Cu } = Components;
+
+var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+var {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
+var {VariablesView} = require("resource://devtools/client/shared/widgets/VariablesView.jsm");
+var Services = require("Services");
+var promise = require("promise");
+var defer = require("devtools/shared/defer");
+var {LocalizationHelper, ELLIPSIS} = require("devtools/shared/l10n");
+
+Object.defineProperty(this, "WebConsoleUtils", {
+ get: function () {
+ return require("devtools/client/webconsole/utils").Utils;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+XPCOMUtils.defineLazyGetter(this, "VARIABLES_SORTING_ENABLED", () =>
+ Services.prefs.getBoolPref("devtools.debugger.ui.variables-sorting-enabled")
+);
+
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+ "resource://gre/modules/Console.jsm");
+
+const MAX_LONG_STRING_LENGTH = 200000;
+const MAX_PROPERTY_ITEMS = 2000;
+const DBG_STRINGS_URI = "devtools/client/locales/debugger.properties";
+
+this.EXPORTED_SYMBOLS = ["VariablesViewController", "StackFrameUtils"];
+
+/**
+ * Localization convenience methods.
+ */
+var L10N = new LocalizationHelper(DBG_STRINGS_URI);
+
+/**
+ * Controller for a VariablesView that handles interfacing with the debugger
+ * protocol. Is able to populate scopes and variables via the protocol as well
+ * as manage actor lifespans.
+ *
+ * @param VariablesView aView
+ * The view to attach to.
+ * @param object aOptions [optional]
+ * Options for configuring the controller. Supported options:
+ * - getObjectClient: @see this._setClientGetters
+ * - getLongStringClient: @see this._setClientGetters
+ * - getEnvironmentClient: @see this._setClientGetters
+ * - releaseActor: @see this._setClientGetters
+ * - overrideValueEvalMacro: @see _setEvaluationMacros
+ * - getterOrSetterEvalMacro: @see _setEvaluationMacros
+ * - simpleValueEvalMacro: @see _setEvaluationMacros
+ */
+function VariablesViewController(aView, aOptions = {}) {
+ this.addExpander = this.addExpander.bind(this);
+
+ this._setClientGetters(aOptions);
+ this._setEvaluationMacros(aOptions);
+
+ this._actors = new Set();
+ this.view = aView;
+ this.view.controller = this;
+}
+this.VariablesViewController = VariablesViewController;
+
+VariablesViewController.prototype = {
+ /**
+ * The default getter/setter evaluation macro.
+ */
+ _getterOrSetterEvalMacro: VariablesView.getterOrSetterEvalMacro,
+
+ /**
+ * The default override value evaluation macro.
+ */
+ _overrideValueEvalMacro: VariablesView.overrideValueEvalMacro,
+
+ /**
+ * The default simple value evaluation macro.
+ */
+ _simpleValueEvalMacro: VariablesView.simpleValueEvalMacro,
+
+ /**
+ * Set the functions used to retrieve debugger client grips.
+ *
+ * @param object aOptions
+ * Options for getting the client grips. Supported options:
+ * - getObjectClient: callback for creating an object grip client
+ * - getLongStringClient: callback for creating a long string grip client
+ * - getEnvironmentClient: callback for creating an environment client
+ * - releaseActor: callback for releasing an actor when it's no longer needed
+ */
+ _setClientGetters: function (aOptions) {
+ if (aOptions.getObjectClient) {
+ this._getObjectClient = aOptions.getObjectClient;
+ }
+ if (aOptions.getLongStringClient) {
+ this._getLongStringClient = aOptions.getLongStringClient;
+ }
+ if (aOptions.getEnvironmentClient) {
+ this._getEnvironmentClient = aOptions.getEnvironmentClient;
+ }
+ if (aOptions.releaseActor) {
+ this._releaseActor = aOptions.releaseActor;
+ }
+ },
+
+ /**
+ * Sets the functions used when evaluating strings in the variables view.
+ *
+ * @param object aOptions
+ * Options for configuring the macros. Supported options:
+ * - overrideValueEvalMacro: callback for creating an overriding eval macro
+ * - getterOrSetterEvalMacro: callback for creating a getter/setter eval macro
+ * - simpleValueEvalMacro: callback for creating a simple value eval macro
+ */
+ _setEvaluationMacros: function (aOptions) {
+ if (aOptions.overrideValueEvalMacro) {
+ this._overrideValueEvalMacro = aOptions.overrideValueEvalMacro;
+ }
+ if (aOptions.getterOrSetterEvalMacro) {
+ this._getterOrSetterEvalMacro = aOptions.getterOrSetterEvalMacro;
+ }
+ if (aOptions.simpleValueEvalMacro) {
+ this._simpleValueEvalMacro = aOptions.simpleValueEvalMacro;
+ }
+ },
+
+ /**
+ * Populate a long string into a target using a grip.
+ *
+ * @param Variable aTarget
+ * The target Variable/Property to put the retrieved string into.
+ * @param LongStringActor aGrip
+ * The long string grip that use to retrieve the full string.
+ * @return Promise
+ * The promise that will be resolved when the string is retrieved.
+ */
+ _populateFromLongString: function (aTarget, aGrip) {
+ let deferred = defer();
+
+ let from = aGrip.initial.length;
+ let to = Math.min(aGrip.length, MAX_LONG_STRING_LENGTH);
+
+ this._getLongStringClient(aGrip).substring(from, to, aResponse => {
+ // Stop tracking the actor because it's no longer needed.
+ this.releaseActor(aGrip);
+
+ // Replace the preview with the full string and make it non-expandable.
+ aTarget.onexpand = null;
+ aTarget.setGrip(aGrip.initial + aResponse.substring);
+ aTarget.hideArrow();
+
+ deferred.resolve();
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Adds pseudo items in case there is too many properties to display.
+ * Each item can expand into property slices.
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aGrip
+ * The property iterator grip.
+ */
+ _populatePropertySlices: function (aTarget, aGrip) {
+ if (aGrip.count < MAX_PROPERTY_ITEMS) {
+ return this._populateFromPropertyIterator(aTarget, aGrip);
+ }
+
+ // Divide the keys into quarters.
+ let items = Math.ceil(aGrip.count / 4);
+ let iterator = aGrip.propertyIterator;
+ let promises = [];
+ for (let i = 0; i < 4; i++) {
+ let start = aGrip.start + i * items;
+ let count = i != 3 ? items : aGrip.count - i * items;
+
+ // Create a new kind of grip, with additional fields to define the slice
+ let sliceGrip = {
+ type: "property-iterator",
+ propertyIterator: iterator,
+ start: start,
+ count: count
+ };
+
+ // Query the name of the first and last items for this slice
+ let deferred = defer();
+ iterator.names([start, start + count - 1], ({ names }) => {
+ let label = "[" + names[0] + ELLIPSIS + names[1] + "]";
+ let item = aTarget.addItem(label, {}, { internalItem: true });
+ item.showArrow();
+ this.addExpander(item, sliceGrip);
+ deferred.resolve();
+ });
+ promises.push(deferred.promise);
+ }
+
+ return promise.all(promises);
+ },
+
+ /**
+ * Adds a property slice for a Variable in the view using the already
+ * property iterator
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aGrip
+ * The property iterator grip.
+ */
+ _populateFromPropertyIterator: function (aTarget, aGrip) {
+ if (aGrip.count >= MAX_PROPERTY_ITEMS) {
+ // We already started to split, but there is still too many properties, split again.
+ return this._populatePropertySlices(aTarget, aGrip);
+ }
+ // We started slicing properties, and the slice is now small enough to be displayed
+ let deferred = defer();
+ aGrip.propertyIterator.slice(aGrip.start, aGrip.count,
+ ({ ownProperties }) => {
+ // Add all the variable properties.
+ if (Object.keys(ownProperties).length > 0) {
+ aTarget.addItems(ownProperties, {
+ sorted: true,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+ }
+ deferred.resolve();
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Adds the properties for a Variable in the view using a new feature in FF40+
+ * that allows iteration over properties in slices.
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aGrip
+ * The grip to use to populate the target.
+ * @param string aQuery [optional]
+ * The query string used to fetch only a subset of properties
+ */
+ _populateFromObjectWithIterator: function (aTarget, aGrip, aQuery) {
+ // FF40+ starts exposing `ownPropertyLength` on ObjectActor's grip,
+ // as well as `enumProperties` request.
+ let deferred = defer();
+ let objectClient = this._getObjectClient(aGrip);
+ let isArray = aGrip.preview && aGrip.preview.kind === "ArrayLike";
+ if (isArray) {
+ // First enumerate array items, e.g. properties from `0` to `array.length`.
+ let options = {
+ ignoreNonIndexedProperties: true,
+ query: aQuery
+ };
+ objectClient.enumProperties(options, ({ iterator }) => {
+ let sliceGrip = {
+ type: "property-iterator",
+ propertyIterator: iterator,
+ start: 0,
+ count: iterator.count
+ };
+ this._populatePropertySlices(aTarget, sliceGrip)
+ .then(() => {
+ // Then enumerate the rest of the properties, like length, buffer, etc.
+ let options = {
+ ignoreIndexedProperties: true,
+ sort: true,
+ query: aQuery
+ };
+ objectClient.enumProperties(options, ({ iterator }) => {
+ let sliceGrip = {
+ type: "property-iterator",
+ propertyIterator: iterator,
+ start: 0,
+ count: iterator.count
+ };
+ deferred.resolve(this._populatePropertySlices(aTarget, sliceGrip));
+ });
+ });
+ });
+ } else {
+ // For objects, we just enumerate all the properties sorted by name.
+ objectClient.enumProperties({ sort: true, query: aQuery }, ({ iterator }) => {
+ let sliceGrip = {
+ type: "property-iterator",
+ propertyIterator: iterator,
+ start: 0,
+ count: iterator.count
+ };
+ deferred.resolve(this._populatePropertySlices(aTarget, sliceGrip));
+ });
+
+ }
+ return deferred.promise;
+ },
+
+ /**
+ * Adds the given prototype in the view.
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aProtype
+ * The prototype grip.
+ */
+ _populateObjectPrototype: function (aTarget, aPrototype) {
+ // Add the variable's __proto__.
+ if (aPrototype && aPrototype.type != "null") {
+ let proto = aTarget.addItem("__proto__", { value: aPrototype });
+ this.addExpander(proto, aPrototype);
+ }
+ },
+
+ /**
+ * Adds properties to a Scope, Variable, or Property in the view. Triggered
+ * when a scope is expanded or certain variables are hovered.
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aGrip
+ * The grip to use to populate the target.
+ */
+ _populateFromObject: function (aTarget, aGrip) {
+ if (aGrip.class === "Proxy") {
+ this.addExpander(
+ aTarget.addItem("<target>", { value: aGrip.proxyTarget }, { internalItem: true }),
+ aGrip.proxyTarget);
+ this.addExpander(
+ aTarget.addItem("<handler>", { value: aGrip.proxyHandler }, { internalItem: true }),
+ aGrip.proxyHandler);
+
+ // Refuse to play the proxy's stupid game and return immediately
+ let deferred = defer();
+ deferred.resolve();
+ return deferred.promise;
+ }
+
+ if (aGrip.class === "Promise" && aGrip.promiseState) {
+ const { state, value, reason } = aGrip.promiseState;
+ aTarget.addItem("<state>", { value: state }, { internalItem: true });
+ if (state === "fulfilled") {
+ this.addExpander(
+ aTarget.addItem("<value>", { value }, { internalItem: true }),
+ value);
+ } else if (state === "rejected") {
+ this.addExpander(
+ aTarget.addItem("<reason>", { value: reason }, { internalItem: true }),
+ reason);
+ }
+ } else if (["Map", "WeakMap", "Set", "WeakSet"].includes(aGrip.class)) {
+ let entriesList = aTarget.addItem("<entries>", {}, { internalItem: true });
+ entriesList.showArrow();
+ this.addExpander(entriesList, {
+ type: "entries-list",
+ obj: aGrip
+ });
+ }
+
+ // Fetch properties by slices if there is too many in order to prevent UI freeze.
+ if ("ownPropertyLength" in aGrip && aGrip.ownPropertyLength >= MAX_PROPERTY_ITEMS) {
+ return this._populateFromObjectWithIterator(aTarget, aGrip)
+ .then(() => {
+ let deferred = defer();
+ let objectClient = this._getObjectClient(aGrip);
+ objectClient.getPrototype(({ prototype }) => {
+ this._populateObjectPrototype(aTarget, prototype);
+ deferred.resolve();
+ });
+ return deferred.promise;
+ });
+ }
+
+ return this._populateProperties(aTarget, aGrip);
+ },
+
+ _populateProperties: function (aTarget, aGrip, aOptions) {
+ let deferred = defer();
+
+ let objectClient = this._getObjectClient(aGrip);
+ objectClient.getPrototypeAndProperties(aResponse => {
+ let ownProperties = aResponse.ownProperties || {};
+ let prototype = aResponse.prototype || null;
+ // 'safeGetterValues' is new and isn't necessary defined on old actors.
+ let safeGetterValues = aResponse.safeGetterValues || {};
+ let sortable = VariablesView.isSortable(aGrip.class);
+
+ // Merge the safe getter values into one object such that we can use it
+ // in VariablesView.
+ for (let name of Object.keys(safeGetterValues)) {
+ if (name in ownProperties) {
+ let { getterValue, getterPrototypeLevel } = safeGetterValues[name];
+ ownProperties[name].getterValue = getterValue;
+ ownProperties[name].getterPrototypeLevel = getterPrototypeLevel;
+ } else {
+ ownProperties[name] = safeGetterValues[name];
+ }
+ }
+
+ // Add all the variable properties.
+ aTarget.addItems(ownProperties, {
+ // Not all variables need to force sorted properties.
+ sorted: sortable,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+
+ // Add the variable's __proto__.
+ this._populateObjectPrototype(aTarget, prototype);
+
+ // If the object is a function we need to fetch its scope chain
+ // to show them as closures for the respective function.
+ if (aGrip.class == "Function") {
+ objectClient.getScope(aResponse => {
+ if (aResponse.error) {
+ // This function is bound to a built-in object or it's not present
+ // in the current scope chain. Not necessarily an actual error,
+ // it just means that there's no closure for the function.
+ console.warn(aResponse.error + ": " + aResponse.message);
+ return void deferred.resolve();
+ }
+ this._populateWithClosure(aTarget, aResponse.scope).then(deferred.resolve);
+ });
+ } else {
+ deferred.resolve();
+ }
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Adds the scope chain elements (closures) of a function variable.
+ *
+ * @param Variable aTarget
+ * The variable where the properties will be placed into.
+ * @param Scope aScope
+ * The lexical environment form as specified in the protocol.
+ */
+ _populateWithClosure: function (aTarget, aScope) {
+ let objectScopes = [];
+ let environment = aScope;
+ let funcScope = aTarget.addItem("<Closure>");
+ funcScope.target.setAttribute("scope", "");
+ funcScope.showArrow();
+
+ do {
+ // Create a scope to contain all the inspected variables.
+ let label = StackFrameUtils.getScopeLabel(environment);
+
+ // Block scopes may have the same label, so make addItem allow duplicates.
+ let closure = funcScope.addItem(label, undefined, {relaxed: true});
+ closure.target.setAttribute("scope", "");
+ closure.showArrow();
+
+ // Add nodes for every argument and every other variable in scope.
+ if (environment.bindings) {
+ this._populateWithEnvironmentBindings(closure, environment.bindings);
+ } else {
+ let deferred = defer();
+ objectScopes.push(deferred.promise);
+ this._getEnvironmentClient(environment).getBindings(response => {
+ this._populateWithEnvironmentBindings(closure, response.bindings);
+ deferred.resolve();
+ });
+ }
+ } while ((environment = environment.parent));
+
+ return promise.all(objectScopes).then(() => {
+ // Signal that scopes have been fetched.
+ this.view.emit("fetched", "scopes", funcScope);
+ });
+ },
+
+ /**
+ * Adds nodes for every specified binding to the closure node.
+ *
+ * @param Variable aTarget
+ * The variable where the bindings will be placed into.
+ * @param object aBindings
+ * The bindings form as specified in the protocol.
+ */
+ _populateWithEnvironmentBindings: function (aTarget, aBindings) {
+ // Add nodes for every argument in the scope.
+ aTarget.addItems(aBindings.arguments.reduce((accumulator, arg) => {
+ let name = Object.getOwnPropertyNames(arg)[0];
+ let descriptor = arg[name];
+ accumulator[name] = descriptor;
+ return accumulator;
+ }, {}), {
+ // Arguments aren't sorted.
+ sorted: false,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+
+ // Add nodes for every other variable in the scope.
+ aTarget.addItems(aBindings.variables, {
+ // Not all variables need to force sorted properties.
+ sorted: VARIABLES_SORTING_ENABLED,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+ },
+
+ _populateFromEntries: function (target, grip) {
+ let objGrip = grip.obj;
+ let objectClient = this._getObjectClient(objGrip);
+
+ return new promise((resolve, reject) => {
+ objectClient.enumEntries((response) => {
+ if (response.error) {
+ // Older server might not support the enumEntries method
+ console.warn(response.error + ": " + response.message);
+ resolve();
+ } else {
+ let sliceGrip = {
+ type: "property-iterator",
+ propertyIterator: response.iterator,
+ start: 0,
+ count: response.iterator.count
+ };
+
+ resolve(this._populatePropertySlices(target, sliceGrip));
+ }
+ });
+ });
+ },
+
+ /**
+ * Adds an 'onexpand' callback for a variable, lazily handling
+ * the addition of new properties.
+ *
+ * @param Variable aTarget
+ * The variable where the properties will be placed into.
+ * @param any aSource
+ * The source to use to populate the target.
+ */
+ addExpander: function (aTarget, aSource) {
+ // Attach evaluation macros as necessary.
+ if (aTarget.getter || aTarget.setter) {
+ aTarget.evaluationMacro = this._overrideValueEvalMacro;
+ let getter = aTarget.get("get");
+ if (getter) {
+ getter.evaluationMacro = this._getterOrSetterEvalMacro;
+ }
+ let setter = aTarget.get("set");
+ if (setter) {
+ setter.evaluationMacro = this._getterOrSetterEvalMacro;
+ }
+ } else {
+ aTarget.evaluationMacro = this._simpleValueEvalMacro;
+ }
+
+ // If the source is primitive then an expander is not needed.
+ if (VariablesView.isPrimitive({ value: aSource })) {
+ return;
+ }
+
+ // If the source is a long string then show the arrow.
+ if (WebConsoleUtils.isActorGrip(aSource) && aSource.type == "longString") {
+ aTarget.showArrow();
+ }
+
+ // Make sure that properties are always available on expansion.
+ aTarget.onexpand = () => this.populate(aTarget, aSource);
+
+ // Some variables are likely to contain a very large number of properties.
+ // It's a good idea to be prepared in case of an expansion.
+ if (aTarget.shouldPrefetch) {
+ aTarget.addEventListener("mouseover", aTarget.onexpand, false);
+ }
+
+ // Register all the actors that this controller now depends on.
+ for (let grip of [aTarget.value, aTarget.getter, aTarget.setter]) {
+ if (WebConsoleUtils.isActorGrip(grip)) {
+ this._actors.add(grip.actor);
+ }
+ }
+ },
+
+ /**
+ * Adds properties to a Scope, Variable, or Property in the view. Triggered
+ * when a scope is expanded or certain variables are hovered.
+ *
+ * This does not expand the target, it only populates it.
+ *
+ * @param Scope aTarget
+ * The Scope to be expanded.
+ * @param object aSource
+ * The source to use to populate the target.
+ * @return Promise
+ * The promise that is resolved once the target has been expanded.
+ */
+ populate: function (aTarget, aSource) {
+ // Fetch the variables only once.
+ if (aTarget._fetched) {
+ return aTarget._fetched;
+ }
+ // Make sure the source grip is available.
+ if (!aSource) {
+ return promise.reject(new Error("No actor grip was given for the variable."));
+ }
+
+ let deferred = defer();
+ aTarget._fetched = deferred.promise;
+
+ if (aSource.type === "property-iterator") {
+ return this._populateFromPropertyIterator(aTarget, aSource);
+ }
+
+ if (aSource.type === "entries-list") {
+ return this._populateFromEntries(aTarget, aSource);
+ }
+
+ if (aSource.type === "mapEntry") {
+ aTarget.addItems({
+ key: { value: aSource.preview.key },
+ value: { value: aSource.preview.value }
+ }, {
+ callback: this.addExpander
+ });
+
+ return promise.resolve();
+ }
+
+ // If the target is a Variable or Property then we're fetching properties.
+ if (VariablesView.isVariable(aTarget)) {
+ this._populateFromObject(aTarget, aSource).then(() => {
+ // Signal that properties have been fetched.
+ this.view.emit("fetched", "properties", aTarget);
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ });
+ return deferred.promise;
+ }
+
+ switch (aSource.type) {
+ case "longString":
+ this._populateFromLongString(aTarget, aSource).then(() => {
+ // Signal that a long string has been fetched.
+ this.view.emit("fetched", "longString", aTarget);
+ deferred.resolve();
+ });
+ break;
+ case "with":
+ case "object":
+ this._populateFromObject(aTarget, aSource.object).then(() => {
+ // Signal that variables have been fetched.
+ this.view.emit("fetched", "variables", aTarget);
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ });
+ break;
+ case "block":
+ case "function":
+ this._populateWithEnvironmentBindings(aTarget, aSource.bindings);
+ // No need to signal that variables have been fetched, since
+ // the scope arguments and variables are already attached to the
+ // environment bindings, so pausing the active thread is unnecessary.
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ break;
+ default:
+ let error = "Unknown Debugger.Environment type: " + aSource.type;
+ console.error(error);
+ deferred.reject(error);
+ }
+
+ return deferred.promise;
+ },
+
+ /**
+ * Indicates to the view if the targeted actor supports properties search
+ *
+ * @return boolean True, if the actor supports enumProperty request
+ */
+ supportsSearch: function () {
+ // FF40+ starts exposing ownPropertyLength on object actor's grip
+ // as well as enumProperty which allows to query a subset of properties.
+ return this.objectActor && ("ownPropertyLength" in this.objectActor);
+ },
+
+ /**
+ * Try to use the actor to perform an attribute search.
+ *
+ * @param Scope aScope
+ * The Scope instance to populate with properties
+ * @param string aToken
+ * The query string
+ */
+ performSearch: function (aScope, aToken) {
+ this._populateFromObjectWithIterator(aScope, this.objectActor, aToken)
+ .then(() => {
+ this.view.emit("fetched", "search", aScope);
+ });
+ },
+
+ /**
+ * Release an actor from the controller.
+ *
+ * @param object aActor
+ * The actor to release.
+ */
+ releaseActor: function (aActor) {
+ if (this._releaseActor) {
+ this._releaseActor(aActor);
+ }
+ this._actors.delete(aActor);
+ },
+
+ /**
+ * Release all the actors referenced by the controller, optionally filtered.
+ *
+ * @param function aFilter [optional]
+ * Callback to filter which actors are released.
+ */
+ releaseActors: function (aFilter) {
+ for (let actor of this._actors) {
+ if (!aFilter || aFilter(actor)) {
+ this.releaseActor(actor);
+ }
+ }
+ },
+
+ /**
+ * Helper function for setting up a single Scope with a single Variable
+ * contained within it.
+ *
+ * This function will empty the variables view.
+ *
+ * @param object options
+ * Options for the contents of the view:
+ * - objectActor: the grip of the new ObjectActor to show.
+ * - rawObject: the raw object to show.
+ * - label: the label for the inspected object.
+ * @param object configuration
+ * Additional options for the controller:
+ * - overrideValueEvalMacro: @see _setEvaluationMacros
+ * - getterOrSetterEvalMacro: @see _setEvaluationMacros
+ * - simpleValueEvalMacro: @see _setEvaluationMacros
+ * @return Object
+ * - variable: the created Variable.
+ * - expanded: the Promise that resolves when the variable expands.
+ */
+ setSingleVariable: function (options, configuration = {}) {
+ this._setEvaluationMacros(configuration);
+ this.view.empty();
+
+ let scope = this.view.addScope(options.label);
+ scope.expanded = true; // Expand the scope by default.
+ scope.locked = true; // Prevent collapsing the scope.
+
+ let variable = scope.addItem(undefined, { enumerable: true });
+ let populated;
+
+ if (options.objectActor) {
+ // Save objectActor for properties filtering
+ this.objectActor = options.objectActor;
+ if (VariablesView.isPrimitive({ value: this.objectActor })) {
+ populated = promise.resolve();
+ } else {
+ populated = this.populate(variable, options.objectActor);
+ variable.expand();
+ }
+ } else if (options.rawObject) {
+ variable.populate(options.rawObject, { expanded: true });
+ populated = promise.resolve();
+ }
+
+ return { variable: variable, expanded: populated };
+ },
+};
+
+
+/**
+ * Attaches a VariablesViewController to a VariablesView if it doesn't already
+ * have one.
+ *
+ * @param VariablesView aView
+ * The view to attach to.
+ * @param object aOptions
+ * The options to use in creating the controller.
+ * @return VariablesViewController
+ */
+VariablesViewController.attach = function (aView, aOptions) {
+ if (aView.controller) {
+ return aView.controller;
+ }
+ return new VariablesViewController(aView, aOptions);
+};
+
+/**
+ * Utility functions for handling stackframes.
+ */
+var StackFrameUtils = this.StackFrameUtils = {
+ /**
+ * Create a textual representation for the specified stack frame
+ * to display in the stackframes container.
+ *
+ * @param object aFrame
+ * The stack frame to label.
+ */
+ getFrameTitle: function (aFrame) {
+ if (aFrame.type == "call") {
+ let c = aFrame.callee;
+ return (c.name || c.userDisplayName || c.displayName || "(anonymous)");
+ }
+ return "(" + aFrame.type + ")";
+ },
+
+ /**
+ * Constructs a scope label based on its environment.
+ *
+ * @param object aEnv
+ * The scope's environment.
+ * @return string
+ * The scope's label.
+ */
+ getScopeLabel: function (aEnv) {
+ let name = "";
+
+ // Name the outermost scope Global.
+ if (!aEnv.parent) {
+ name = L10N.getStr("globalScopeLabel");
+ }
+ // Otherwise construct the scope name.
+ else {
+ name = aEnv.type.charAt(0).toUpperCase() + aEnv.type.slice(1);
+ }
+
+ let label = L10N.getFormatStr("scopeLabel", name);
+ switch (aEnv.type) {
+ case "with":
+ case "object":
+ label += " [" + aEnv.object.class + "]";
+ break;
+ case "function":
+ let f = aEnv.function;
+ label += " [" +
+ (f.name || f.userDisplayName || f.displayName || "(anonymous)") +
+ "]";
+ break;
+ }
+ return label;
+ }
+};
diff --git a/devtools/client/shared/widgets/cubic-bezier.css b/devtools/client/shared/widgets/cubic-bezier.css
new file mode 100644
index 000000000..203fe336a
--- /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;
+}
+
+.display-wrap {
+ width: 50%;
+ height: 100%;
+ text-align: center;
+ overflow: hidden;
+}
+
+/* Coordinate Plane */
+
+.coordinate-plane {
+ width: 150px;
+ height: 370px;
+ margin: 0 auto;
+ position: relative;
+}
+
+.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;
+}
+
+.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;
+
+ -moz-user-select: none;
+}
+
+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 */
+
+.timing-function-preview {
+ position: absolute;
+ bottom: 20px;
+ right: 45px;
+ width: 150px;
+}
+
+.timing-function-preview .scale {
+ position: absolute;
+ top: 6px;
+ left: 0;
+ z-index: 1;
+
+ width: 150px;
+ height: 1px;
+
+ background: #ccc;
+}
+
+.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 */
+
+.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;
+}
+
+.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;
+}
+
+.category:hover {
+ background-color: var(--theme-tab-toolbar-background);
+}
+
+.active-category {
+ background-color: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+}
+
+.active-category:hover {
+ background-color: var(--theme-selection-background);
+}
+
+#preset-container {
+ padding: 0px;
+ width: 100%;
+ height: 331px;
+ overflow-y: auto;
+}
+
+.preset-list {
+ display: none;
+ padding-top: 6px;
+}
+
+.active-preset-list {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+}
+
+.preset {
+ cursor: pointer;
+ width: 33.33%;
+ margin: 5px 0px;
+ text-align: center;
+}
+
+.preset canvas {
+ display: block;
+ border: 1px solid var(--theme-splitter-color);
+ border-radius: 3px;
+ background-color: var(--theme-body-background);
+ margin: 0 auto;
+}
+
+.preset p {
+ font-size: 80%;
+ margin: 2px auto 0px auto;
+ color: var(--theme-body-color-alt);
+ text-transform: capitalize;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.active-preset p, .active-preset:hover p {
+ color: var(--theme-body-color);
+}
+
+.preset:hover canvas {
+ border-color: var(--theme-selection-background);
+}
+
+.active-preset canvas,
+.active-preset:hover canvas {
+ background-color: var(--theme-selection-background-semitransparent);
+ 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 000000000..d015cb5b1
--- /dev/null
+++ b/devtools/client/shared/widgets/filter-widget.css
@@ -0,0 +1,238 @@
+/* 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 {
+ -moz-user-select: none;
+}
+
+.filters-list,
+.presets-list {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+}
+
+.filters-list {
+ /* Allow the filters list to take the full width when the presets list is
+ hidden */
+ flex-grow: 1;
+ padding: 0 6px;
+}
+
+.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;
+}
+
+#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 */
+
+#filters,
+#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 */
+
+.footer {
+ display: flex;
+ margin: 10px 3px;
+ align-items: center;
+}
+
+.footer :not(button) {
+ flex-grow: 1;
+ margin-right: 3px;
+}
+
+/* Styles for 1 filter function item */
+
+.filter,
+.filter-name,
+.filter-value {
+ display: flex;
+ align-items: center;
+}
+
+.filter {
+ margin: 5px 0;
+}
+
+.filter-name {
+ width: 120px;
+ margin-right: 10px;
+}
+
+.filter-name label {
+ -moz-user-select: none;
+ flex-grow: 1;
+}
+
+.filter-name label.devtools-draglabel {
+ cursor: ew-resize;
+}
+
+/* drag/drop handle */
+
+.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-value {
+ min-width: 150px;
+ margin-right: 10px;
+ flex: 1;
+}
+
+.filter-value input {
+ flex-grow: 1;
+}
+
+/* Fix the size of inputs */
+/* Especially needed on Linux where input are bigger */
+input {
+ width: 8em;
+}
+
+.preset {
+ display: flex;
+ margin-bottom: 10px;
+ cursor: pointer;
+ padding: 3px 5px;
+
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+
+.preset label,
+.preset span {
+ display: flex;
+ align-items: center;
+}
+
+.preset label {
+ flex: 1 0;
+ cursor: pointer;
+ color: var(--theme-body-color);
+}
+
+.preset:hover {
+ background: var(--theme-selection-background);
+}
+
+.preset:hover label, .preset:hover span {
+ color: var(--theme-selection-color);
+}
+
+.preset .remove-button {
+ order: 2;
+}
+
+.preset span {
+ flex: 2 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ order: 3;
+ color: var(--theme-body-color-alt);
+}
+
+.remove-button {
+ width: 16px;
+ height: 16px;
+ background: url(chrome://devtools/skin/images/close.svg);
+ background-size: cover;
+ font-size: 0;
+ border: none;
+ cursor: pointer;
+}
+
+.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;
+}
+
+.add,
+#toggle-presets {
+ background-size: cover;
+ border: none;
+ width: 16px;
+ height: 16px;
+ font-size: 0;
+ vertical-align: middle;
+ cursor: pointer;
+ margin: 0 5px;
+}
+
+.add {
+ background: url(chrome://devtools/skin/images/add.svg);
+}
+
+#toggle-presets {
+ background: url(chrome://devtools/skin/images/pseudo-class.svg);
+}
+
+.add,
+.remove-button,
+#toggle-presets {
+ filter: var(--icon-filter);
+}
+
+.show-presets #toggle-presets {
+ filter: url(chrome://devtools/skin/images/filters.svg#checked-icon-state);
+}
diff --git a/devtools/client/shared/widgets/graphs-frame.xhtml b/devtools/client/shared/widgets/graphs-frame.xhtml
new file mode 100644
index 000000000..8c6f45e03
--- /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 type="application/javascript;version=1.8" 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/mdn-docs.css b/devtools/client/shared/widgets/mdn-docs.css
new file mode 100644
index 000000000..e3547489f
--- /dev/null
+++ b/devtools/client/shared/widgets/mdn-docs.css
@@ -0,0 +1,39 @@
+/* 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/. */
+
+.mdn-container {
+ height: 300px;
+ margin: 4px;
+ overflow: auto;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+}
+
+.mdn-container header,
+.mdn-container footer {
+ flex: 1;
+ padding: 0 1em;
+}
+
+.mdn-property-info {
+ flex: 10;
+ padding: 0 1em;
+ overflow: auto;
+ transition: opacity 400ms ease-in;
+}
+
+.mdn-syntax {
+ margin-top: 1em;
+}
+
+.devtools-throbber {
+ align-self: center;
+ opacity: 0;
+}
+
+.mdn-visit-page {
+ display: inline-block;
+ padding: 1em 0;
+}
diff --git a/devtools/client/shared/widgets/moz.build b/devtools/client/shared/widgets/moz.build
new file mode 100644
index 000000000..5a28d21ca
--- /dev/null
+++ b/devtools/client/shared/widgets/moz.build
@@ -0,0 +1,34 @@
+# -*- 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',
+ 'BarGraphWidget.js',
+ 'BreadcrumbsWidget.jsm',
+ 'Chart.jsm',
+ 'CubicBezierPresets.js',
+ 'CubicBezierWidget.js',
+ 'FastListWidget.js',
+ 'FilterWidget.js',
+ 'FlameGraph.js',
+ 'Graphs.js',
+ 'GraphsWorker.js',
+ 'LineGraphWidget.js',
+ 'MdnDocsWidget.js',
+ 'MountainGraphWidget.js',
+ 'SideMenuWidget.jsm',
+ 'SimpleListWidget.jsm',
+ 'Spectrum.js',
+ 'TableWidget.js',
+ 'TreeWidget.js',
+ 'VariablesView.jsm',
+ 'VariablesViewController.jsm',
+ 'view-helpers.js',
+)
diff --git a/devtools/client/shared/widgets/spectrum.css b/devtools/client/shared/widgets/spectrum.css
new file mode 100644
index 000000000..46826f2e1
--- /dev/null
+++ b/devtools/client/shared/widgets/spectrum.css
@@ -0,0 +1,155 @@
+/* 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/. */
+
+#eyedropper-button {
+ margin-inline-start: 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-slider-control {
+ cursor: pointer;
+ box-shadow: 0 0 2px rgba(0,0,0,.6);
+ background: #fff;
+ border-radius: 10px;
+ opacity: .8;
+}
+
+.spectrum-box {
+ border: 1px solid rgba(0,0,0,0.2);
+ border-radius: 2px;
+ background-clip: content-box;
+}
+
+/* Elements */
+
+#spectrum-tooltip {
+ padding: 4px;
+}
+
+.spectrum-container {
+ position: relative;
+ display: none;
+ top: 0;
+ left: 0;
+ border-radius: 0;
+ width: 200px;
+ padding: 5px;
+}
+
+.spectrum-show {
+ display: inline-block;
+}
+
+/* Keep aspect ratio:
+http://www.briangrinstead.com/blog/keep-aspect-ratio-with-html-and-css */
+.spectrum-top {
+ position: relative;
+ width: 100%;
+ display: inline-block;
+}
+
+.spectrum-top-inner {
+ position: absolute;
+ top:0;
+ left:0;
+ bottom:0;
+ right:0;
+}
+
+.spectrum-color {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 20%;
+}
+
+.spectrum-hue {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 83%;
+}
+
+.spectrum-fill {
+ /* Same as spectrum-color width */
+ margin-top: 85%;
+}
+
+.spectrum-sat, .spectrum-val {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.spectrum-dragger, .spectrum-slider {
+ -moz-user-select: none;
+}
+
+.spectrum-alpha {
+ position: relative;
+ height: 8px;
+ margin-top: 3px;
+}
+
+.spectrum-alpha-inner {
+ height: 100%;
+}
+
+.spectrum-alpha-handle {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 5px;
+ left: 50%;
+}
+
+.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-hue {
+ background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
+}
+
+.spectrum-dragger {
+ 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,.6);
+}
+
+.spectrum-slider {
+ position: absolute;
+ top: 0;
+ height: 5px;
+ left: -3px;
+ right: -3px;
+}
diff --git a/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js b/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js
new file mode 100644
index 000000000..880c34de3
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js
@@ -0,0 +1,93 @@
+/* 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 {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+const {MdnDocsWidget} = require("devtools/client/shared/widgets/MdnDocsWidget");
+const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const TOOLTIP_WIDTH = 418;
+const TOOLTIP_HEIGHT = 308;
+
+/**
+ * Tooltip for displaying docs for CSS properties from MDN.
+ *
+ * @param {Document} toolboxDoc
+ * The toolbox document to attach the CSS docs tooltip.
+ */
+function CssDocsTooltip(toolboxDoc) {
+ this.tooltip = new HTMLTooltip(toolboxDoc, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ autofocus: true,
+ useXulWrapper: true,
+ stylesheet: "chrome://devtools/content/shared/widgets/mdn-docs.css",
+ });
+ this.widget = this.setMdnDocsContent();
+ this._onVisitLink = this._onVisitLink.bind(this);
+ this.widget.on("visitlink", this._onVisitLink);
+
+ // Initialize keyboard shortcuts
+ this.shortcuts = new KeyShortcuts({ window: this.tooltip.topWindow });
+ this._onShortcut = this._onShortcut.bind(this);
+
+ this.shortcuts.on("Escape", this._onShortcut);
+}
+
+CssDocsTooltip.prototype = {
+ /**
+ * Load CSS docs for the given property,
+ * then display the tooltip.
+ */
+ show: function (anchor, propertyName) {
+ this.tooltip.once("shown", () => {
+ this.widget.loadCssDocs(propertyName);
+ });
+ this.tooltip.show(anchor);
+ },
+
+ hide: function () {
+ this.tooltip.hide();
+ },
+
+ _onShortcut: function (shortcut, event) {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ this.hide();
+ },
+
+ _onVisitLink: function () {
+ this.hide();
+ },
+
+ /**
+ * Set the content of this tooltip to the MDN docs widget. This is called when the
+ * tooltip is first constructed.
+ * The caller can use the MdnDocsWidget to update the tooltip's UI with new content
+ * each time the tooltip is shown.
+ *
+ * @return {MdnDocsWidget} the created MdnDocsWidget instance.
+ */
+ setMdnDocsContent: function () {
+ let container = this.tooltip.doc.createElementNS(XHTML_NS, "div");
+ container.setAttribute("class", "mdn-container theme-body");
+ this.tooltip.setContent(container, {width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT});
+ return new MdnDocsWidget(container);
+ },
+
+ destroy: function () {
+ this.widget.off("visitlink", this._onVisitLink);
+ this.widget.destroy();
+
+ this.shortcuts.destroy();
+ this.tooltip.destroy();
+ }
+};
+
+module.exports = CssDocsTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
new file mode 100644
index 000000000..63507bc5e
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
@@ -0,0 +1,313 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/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) {
+ let 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);
+}
+
+EventTooltip.prototype = {
+ init: function () {
+ let config = {
+ mode: Editor.modes.js,
+ lineNumbers: false,
+ lineWrapping: true,
+ readOnly: true,
+ styleActiveLine: true,
+ extraKeys: {},
+ theme: "mozilla markup-view"
+ };
+
+ let doc = this._tooltip.doc;
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "devtools-tooltip-events-container";
+
+ for (let listener of this._eventListenerInfos) {
+ let phase = listener.capturing ? "Capturing" : "Bubbling";
+ let level = listener.DOM0 ? "DOM0" : "DOM2";
+
+ // Header
+ let header = doc.createElementNS(XHTML_NS, "div");
+ header.className = "event-header devtools-toolbar";
+ this.container.appendChild(header);
+
+ if (!listener.hide.debugger) {
+ let debuggerIcon = doc.createElementNS(XHTML_NS, "img");
+ debuggerIcon.className = "event-tooltip-debugger-icon";
+ debuggerIcon.setAttribute("src",
+ "chrome://devtools/skin/images/tool-debugger.svg");
+ let openInDebugger = L10N.getStr("eventsTooltip.openInDebugger");
+ debuggerIcon.setAttribute("title", openInDebugger);
+ header.appendChild(debuggerIcon);
+ }
+
+ if (!listener.hide.type) {
+ let eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ eventTypeLabel.className = "event-tooltip-event-type";
+ eventTypeLabel.textContent = listener.type;
+ eventTypeLabel.setAttribute("title", listener.type);
+ header.appendChild(eventTypeLabel);
+ }
+
+ if (!listener.hide.filename) {
+ let filename = doc.createElementNS(XHTML_NS, "span");
+ filename.className = "event-tooltip-filename devtools-monospace";
+ filename.textContent = listener.origin;
+ filename.setAttribute("title", listener.origin);
+ header.appendChild(filename);
+ }
+
+ let attributesContainer = doc.createElementNS(XHTML_NS, "div");
+ attributesContainer.className = "event-tooltip-attributes-container";
+ header.appendChild(attributesContainer);
+
+ if (!listener.hide.capturing) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let 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 (let tag of listener.tags.split(",")) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let 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) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let dom0 = doc.createElementNS(XHTML_NS, "span");
+ dom0.className = "event-tooltip-attributes";
+ dom0.textContent = level;
+ dom0.setAttribute("title", level);
+ attributesBox.appendChild(dom0);
+ }
+
+ // Content
+ let content = doc.createElementNS(XHTML_NS, "div");
+ let editor = new Editor(config);
+ this._eventEditors.set(content, {
+ editor: editor,
+ handler: listener.handler,
+ searchString: listener.searchString,
+ uri: listener.origin,
+ dom0: listener.DOM0,
+ appended: false
+ });
+
+ content.className = "event-tooltip-content-box";
+ this.container.appendChild(content);
+
+ this._addContentListeners(header);
+ }
+
+ this._tooltip.setContent(this.container, {width: CONTAINER_WIDTH});
+ 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;
+ }
+
+ let doc = this._tooltip.doc;
+ let header = event.currentTarget;
+ let content = header.nextElementSibling;
+
+ if (content.hasAttribute("open")) {
+ content.removeAttribute("open");
+ } else {
+ let contentNodes = doc.querySelectorAll(".event-tooltip-content-box");
+
+ for (let node of contentNodes) {
+ if (node !== content) {
+ node.removeAttribute("open");
+ }
+ }
+
+ content.setAttribute("open", "");
+
+ let eventEditor = this._eventEditors.get(content);
+
+ if (eventEditor.appended) {
+ return;
+ }
+
+ let {editor, handler} = eventEditor;
+
+ let iframe = doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("style", "width: 100%; height: 100%; border-style: none;");
+
+ editor.appendTo(content, iframe).then(() => {
+ let tidied = beautify.js(handler, { "indent_size": 2 });
+ editor.setText(tidied);
+
+ eventEditor.appended = true;
+
+ let 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) {
+ let header = event.currentTarget;
+ let content = header.nextElementSibling;
+
+ let {uri, searchString, dom0} = this._eventEditors.get(content);
+
+ if (uri && uri !== "?") {
+ // Save a copy of toolbox as it will be set to null when we hide the tooltip.
+ let toolbox = this._toolbox;
+
+ this._tooltip.hide();
+
+ uri = uri.replace(/"/g, "");
+
+ let showSource = ({ DebuggerView }) => {
+ let matches = uri.match(/(.*):(\d+$)/);
+ let line = 1;
+
+ if (matches) {
+ uri = matches[1];
+ line = matches[2];
+ }
+
+ let item = DebuggerView.Sources.getItemForAttachment(a => a.source.url === uri);
+ if (item) {
+ let actor = item.attachment.source.actor;
+ DebuggerView.setEditorLocation(
+ actor, line, {noDebug: true}
+ ).then(() => {
+ if (dom0) {
+ let text = DebuggerView.editor.getText();
+ let index = text.indexOf(searchString);
+ let lastIndex = text.lastIndexOf(searchString);
+
+ // To avoid confusion we only search for DOM0 event handlers when
+ // there is only one possible match in the file.
+ if (index !== -1 && index === lastIndex) {
+ text = text.substr(0, index);
+ let newlineMatches = text.match(/\n/g);
+
+ if (newlineMatches) {
+ DebuggerView.editor.setCursor({
+ line: newlineMatches.length
+ });
+ }
+ }
+ }
+ });
+ }
+ };
+
+ let debuggerAlreadyOpen = toolbox.getPanel("jsdebugger");
+ toolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
+ if (debuggerAlreadyOpen) {
+ showSource(dbg);
+ } else {
+ dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
+ }
+ });
+ }
+ },
+
+ destroy: function () {
+ if (this._tooltip) {
+ this._tooltip.off("hidden", this.destroy);
+
+ let boxes = this.container.querySelectorAll(".event-tooltip-content-box");
+
+ for (let box of boxes) {
+ let {editor} = this._eventEditors.get(box);
+ editor.destroy();
+ }
+
+ this._eventEditors = null;
+ this._tooltip.eventTooltip = null;
+ }
+
+ let headerNodes = this.container.querySelectorAll(".event-header");
+
+ for (let node of headerNodes) {
+ node.removeEventListener("click", this._headerClicked);
+ }
+
+ let sourceNodes = this.container.querySelectorAll(".event-tooltip-debugger-icon");
+ for (let node of sourceNodes) {
+ node.removeEventListener("click", this._debugClicked);
+ }
+
+ 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 000000000..749878220
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -0,0 +1,638 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
+const {listenOnce} = require("devtools/shared/async-utils");
+const {Task} = require("devtools/shared/task");
+
+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",
+};
+
+module.exports.TYPE = TYPE;
+
+const ARROW_WIDTH = 32;
+
+// Default offset between the tooltip's left edge and the tooltip arrow.
+const ARROW_OFFSET = 20;
+
+const EXTRA_HEIGHT = {
+ "normal": 0,
+ // The arrow is 16px tall, but merges on 3px with the panel border
+ "arrow": 13,
+};
+
+const EXTRA_BORDER = {
+ "normal": 0,
+ "arrow": 3,
+};
+
+/**
+ * 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".
+ * @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 =
+function (anchorRect, viewportRect, height, pos, offset) {
+ let {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.
+ let availableTop = anchorTop;
+ let 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.
+ let availableHeight = pos === TOP ? availableTop : availableBottom;
+ height = Math.min(height, availableHeight - offset);
+ height = Math.floor(height);
+
+ // 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, height, computedPosition: pos};
+};
+
+/**
+ * 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} width
+ * Preferred width for the tooltip.
+ * @param {String} type
+ * The tooltip type (e.g. "arrow").
+ * @param {Number} offset
+ * Horizontal offset in pixels.
+ * @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 =
+function (anchorRect, viewportRect, width, type, offset, isRtl) {
+ let anchorWidth = anchorRect.width;
+ let anchorStart = isRtl ? anchorRect.right : anchorRect.left;
+
+ // Translate to the available viewport space before calculating dimensions and position.
+ anchorStart -= viewportRect.left;
+
+ // Calculate WIDTH.
+ width = Math.min(width, viewportRect.width);
+
+ // Calculate LEFT.
+ // By default the tooltip is aligned with the anchor left edge. Unless this
+ // makes it overflow the viewport, in which case is shifts to the left.
+ let left = anchorStart + offset - (isRtl ? width : 0);
+ left = Math.min(left, viewportRect.width - width);
+ left = Math.max(0, left);
+
+ // Calculate ARROW LEFT (tooltip's LEFT might be updated)
+ let arrowLeft;
+ // Arrow style tooltips may need to be shifted to the left
+ if (type === TYPE.ARROW) {
+ let arrowCenter = left + ARROW_OFFSET + ARROW_WIDTH / 2;
+ let anchorCenter = anchorStart + anchorWidth / 2;
+ // If the anchor is too narrow, align the arrow and the anchor center.
+ if (arrowCenter > anchorCenter) {
+ left = Math.max(0, left - (arrowCenter - anchorCenter));
+ }
+ // Arrow's left offset relative to the anchor.
+ arrowLeft = Math.min(ARROW_OFFSET, (anchorWidth - ARROW_WIDTH) / 2) | 0;
+ // Translate the coordinate to tooltip container
+ arrowLeft += anchorStart - left;
+ // Make sure the arrow remains in the tooltip container.
+ arrowLeft = Math.min(arrowLeft, width - ARROW_WIDTH);
+ arrowLeft = Math.max(arrowLeft, 0);
+ }
+
+ // Translate back to absolute coordinates by re-including viewport left margin.
+ left += viewportRect.left;
+
+ return {left, width, 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) {
+ // Width and Height can be taken from the rect.
+ let {width, height} = node.getBoundingClientRect();
+
+ let quads = node.getBoxQuads({relativeTo});
+ let top = quads[0].bounds.top;
+ let left = quads[0].bounds.left;
+
+ // Compute right and bottom coordinates using the rest of the data.
+ let right = left + width;
+ let 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} type
+ * Display type of the tooltip. Possible values: "normal", "arrow"
+ * - {Boolean} autofocus
+ * Defaults to false. Should the tooltip be focused when opening it.
+ * - {Boolean} consumeOutsideClicks
+ * Defaults to true. The tooltip is closed when clicking outside.
+ * Should this event be stopped and consumed or not.
+ * - {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.
+ * - {String} stylesheet
+ * Style sheet URL to apply to the tooltip content.
+ */
+function HTMLTooltip(toolboxDoc, {
+ type = "normal",
+ autofocus = false,
+ consumeOutsideClicks = true,
+ useXulWrapper = false,
+ stylesheet = "",
+ } = {}) {
+ EventEmitter.decorate(this);
+
+ this.doc = toolboxDoc;
+ this.type = type;
+ this.autofocus = autofocus;
+ this.consumeOutsideClicks = consumeOutsideClicks;
+ this.useXulWrapper = this._isXUL() && useXulWrapper;
+
+ // 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._onXulPanelHidden = this._onXulPanelHidden.bind(this);
+
+ this._toggle = new TooltipToggle(this);
+ this.startTogglingOnHover = this._toggle.start.bind(this._toggle);
+ this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle);
+
+ this.container = this._createContainer();
+
+ if (stylesheet) {
+ this._applyStylesheet(stylesheet);
+ }
+ 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();
+ let 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._isXUL()) {
+ 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 provided in
+ * setContent().
+ */
+ 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;
+ },
+
+ /**
+ * Set the tooltip content element. The preferred width/height should also be
+ * specified here.
+ *
+ * @param {Element} content
+ * The tooltip content, should be a HTML element.
+ * @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 preferred width.
+ * - {Number} height: optional, preferred height for the tooltip container. If
+ * not specified, the tooltip will be able to use all the height available.
+ */
+ setContent: function (content, {width = "auto", height = Infinity} = {}) {
+ this.preferredWidth = width;
+ this.preferredHeight = height;
+
+ this.panel.innerHTML = "";
+ this.panel.appendChild(content);
+ },
+
+ /**
+ * Show the tooltip next to the provided anchor element. 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}
+ * - {String} position: optional, possible values: top|bottom
+ * If layout permits, the tooltip will be displayed on top/bottom
+ * of the anchor. If ommitted, the tooltip will be displayed where
+ * more space is available.
+ * - {Number} x: optional, horizontal offset between the anchor and the tooltip
+ * - {Number} y: optional, vertical offset between the anchor and the tooltip
+ */
+ show: Task.async(function* (anchor, {position, x = 0, y = 0} = {}) {
+ // Get anchor geometry
+ let anchorRect = getRelativeRect(anchor, this.doc);
+ if (this.useXulWrapper) {
+ anchorRect = this._convertToScreenRect(anchorRect);
+ }
+
+ // Get viewport size
+ let viewportRect = this._getViewportRect();
+
+ let themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type];
+ let preferredHeight = this.preferredHeight + themeHeight;
+
+ let {top, height, computedPosition} =
+ calculateVerticalPosition(anchorRect, viewportRect, preferredHeight, position, y);
+
+ this._position = computedPosition;
+ // Apply height before measuring the content width (if width="auto").
+ let 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";
+
+ let preferredWidth;
+ if (this.preferredWidth === "auto") {
+ preferredWidth = this._measureContainerWidth();
+ } else {
+ let themeWidth = 2 * EXTRA_BORDER[this.type];
+ preferredWidth = this.preferredWidth + themeWidth;
+ }
+
+ let anchorWin = anchor.ownerDocument.defaultView;
+ let isRtl = anchorWin.getComputedStyle(anchor).direction === "rtl";
+ let {left, width, arrowLeft} = calculateHorizontalPosition(
+ anchorRect, viewportRect, preferredWidth, this.type, x, isRtl);
+
+ this.container.style.width = width + "px";
+
+ if (this.type === TYPE.ARROW) {
+ this.arrow.style.left = arrowLeft + "px";
+ }
+
+ if (this.useXulWrapper) {
+ yield this._showXulWrapperAt(left, top);
+ } else {
+ this.container.style.left = left + "px";
+ this.container.style.top = top + "px";
+ }
+
+ 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;
+
+ this.doc.defaultView.clearTimeout(this.attachEventsTimer);
+ this.attachEventsTimer = this.doc.defaultView.setTimeout(() => {
+ this._maybeFocusTooltip();
+ // Updated the top window reference each time in case the host changes.
+ this.topWindow = this._getTopWindow();
+ this.topWindow.addEventListener("click", this._onClick, true);
+ this.emit("shown");
+ }, 0);
+ }),
+
+ /**
+ * Calculate the rect of the viewport that limits the tooltip dimensions. When using a
+ * XUL panel wrapper, the viewport will be able to use the whole screen (excluding space
+ * reserved by the OS for toolbars etc.). Otherwise, the viewport is limited to the
+ * tooltip's document.
+ *
+ * @return {Object} DOMRect-like object with the Number properties: top, right, bottom,
+ * left, width, height
+ */
+ _getViewportRect: function () {
+ 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
+ let {availLeft, availTop, availHeight, availWidth} = this.doc.defaultView.screen;
+ return {
+ top: availTop,
+ right: availLeft + availWidth,
+ bottom: availTop + availHeight,
+ left: availLeft,
+ width: availWidth,
+ height: availHeight,
+ };
+ }
+
+ return this.doc.documentElement.getBoundingClientRect();
+ },
+
+ _measureContainerWidth: function () {
+ let 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");
+ this.container.style.width = "auto";
+ let width = this.container.getBoundingClientRect().width;
+ this.container.classList.remove("tooltip-hidden");
+
+ if (this.useXulWrapper && !this.isVisible()) {
+ xulParent.appendChild(this.container);
+ }
+
+ return width;
+ },
+
+ /**
+ * Hide the current tooltip. The event "hidden" will be fired when the tooltip
+ * is hidden.
+ */
+ hide: Task.async(function* () {
+ this.doc.defaultView.clearTimeout(this.attachEventsTimer);
+ if (!this.isVisible()) {
+ this.emit("hidden");
+ return;
+ }
+
+ this.topWindow.removeEventListener("click", this._onClick, true);
+ this.container.classList.remove("tooltip-visible");
+ if (this.useXulWrapper) {
+ yield this._hideXulWrapper();
+ }
+
+ this.emit("hidden");
+
+ let tooltipHasFocus = this.container.contains(this.doc.activeElement);
+ if (tooltipHasFocus && this._focusedElement) {
+ this._focusedElement.focus();
+ this._focusedElement = null;
+ }
+ }),
+
+ /**
+ * 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.container.remove();
+ if (this.xulPanelWrapper) {
+ this.xulPanelWrapper.remove();
+ }
+ },
+
+ _createContainer: function () {
+ let container = this.doc.createElementNS(XHTML_NS, "div");
+ container.setAttribute("type", this.type);
+ container.classList.add("tooltip-container");
+
+ let html = '<div class="tooltip-filler"></div>';
+ html += '<div class="tooltip-panel"></div>';
+
+ if (this.type === TYPE.ARROW) {
+ html += '<div class="tooltip-arrow"></div>';
+ }
+ container.innerHTML = html;
+ return container;
+ },
+
+ _onClick: function (e) {
+ if (this._isInTooltipContainer(e.target)) {
+ return;
+ }
+
+ this.hide();
+ if (this.consumeOutsideClicks && e.button === 0) {
+ // Consume only left click events (button === 0).
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+
+ _isInTooltipContainer: function (node) {
+ // Check if the target is the tooltip arrow.
+ if (this.arrow && this.arrow === node) {
+ return true;
+ }
+
+ let 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();
+ }
+ },
+
+ /**
+ * If the tootlip is configured to autofocus and a focusable element can be found,
+ * focus it.
+ */
+ _maybeFocusTooltip: function () {
+ // Simplied selector targetting elements that can receive the focus, full version at
+ // http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus .
+ let focusableSelector = "a, button, iframe, input, select, textarea";
+ let focusableElement = this.panel.querySelector(focusableSelector);
+ if (this.autofocus && focusableElement) {
+ focusableElement.focus();
+ }
+ },
+
+ _getTopWindow: function () {
+ return this.doc.defaultView.top;
+ },
+
+ /**
+ * Check if the tooltip's owner document is a XUL document.
+ */
+ _isXUL: function () {
+ return this.doc.documentElement.namespaceURI === XUL_NS;
+ },
+
+ _createXulPanelWrapper: function () {
+ let panel = this.doc.createElementNS(XUL_NS, "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("noautofocus", true);
+ 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");
+
+ return panel;
+ },
+
+ _showXulWrapperAt: function (left, top) {
+ this.xulPanelWrapper.addEventListener("popuphidden", this._onXulPanelHidden);
+ let onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown");
+ this.xulPanelWrapper.openPopupAtScreen(left, top, false);
+ return onPanelShown;
+ },
+
+ _hideXulWrapper: function () {
+ this.xulPanelWrapper.removeEventListener("popuphidden", this._onXulPanelHidden);
+
+ if (this.xulPanelWrapper.state === "closed") {
+ // XUL panel is already closed, resolve immediately.
+ return Promise.resolve();
+ }
+
+ let 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};
+ },
+
+ /**
+ * Apply a scoped stylesheet to the container so that this css file only
+ * applies to it.
+ */
+ _applyStylesheet: function (url) {
+ let style = this.doc.createElementNS(XHTML_NS, "style");
+ style.setAttribute("scoped", "true");
+ url = url.replace(/"/g, "\\\"");
+ style.textContent = `@import url("${url}");`;
+ this.container.appendChild(style);
+ }
+};
diff --git a/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
new file mode 100644
index 000000000..04c932005
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
@@ -0,0 +1,131 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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;
+const LABEL_HEIGHT = 20;
+const IMAGE_PADDING = 4;
+
+/**
+ * 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 => {
+ let imgObj = new doc.defaultView.Image();
+ imgObj.onload = () => {
+ imgObj.onload = null;
+ let { 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
+ */
+function setImageTooltip(tooltip, doc, imageUrl, options) {
+ let {naturalWidth, naturalHeight, hideDimensionLabel, maxDim} = options;
+ maxDim = maxDim || MAX_DIMENSION;
+
+ let imgHeight = naturalHeight;
+ let imgWidth = naturalWidth;
+ if (imgHeight > maxDim || imgWidth > maxDim) {
+ let 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
+ let div = doc.createElementNS(XHTML_NS, "div");
+ div.style.cssText = `
+ height: 100%;
+ min-width: 100px;
+ display: flex;
+ flex-direction: column;
+ text-align: center;`;
+ let html = `
+ <div style="flex: 1;
+ display: flex;
+ padding: ${IMAGE_PADDING}px;
+ align-items: center;
+ justify-content: center;
+ min-height: 1px;">
+ <img style="height: ${imgHeight}px; max-height: 100%;"
+ src="${encodeURI(imageUrl)}"/>
+ </div>`;
+
+ if (!hideDimensionLabel) {
+ let label = naturalWidth + " \u00D7 " + naturalHeight;
+ html += `
+ <div style="height: ${LABEL_HEIGHT}px;
+ text-align: center;">
+ <span class="theme-comment devtools-tooltip-caption">${label}</span>
+ </div>`;
+ }
+ div.innerHTML = html;
+
+ // Calculate tooltip dimensions
+ let height = imgHeight + 2 * IMAGE_PADDING;
+ if (!hideDimensionLabel) {
+ height += LABEL_HEIGHT;
+ }
+ let width = Math.max(CONTAINER_MIN_WIDTH, imgWidth + 2 * IMAGE_PADDING);
+
+ tooltip.setContent(div, {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) {
+ let div = doc.createElementNS(XHTML_NS, "div");
+ div.className = "theme-comment devtools-tooltip-image-broken";
+ let message = L10N.getStr("previewTooltip.image.brokenImage");
+ div.textContent = message;
+ tooltip.setContent(div, {width: 150, height: 30});
+}
+
+module.exports.getImageDimensions = getImageDimensions;
+module.exports.setImageTooltip = setImageTooltip;
+module.exports.setBrokenImageTooltip = setBrokenImageTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
new file mode 100644
index 000000000..52bf565e2
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
@@ -0,0 +1,209 @@
+/* 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");
+
+/**
+ * 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.
+ */
+function SwatchBasedEditorTooltip(document, stylesheet) {
+ EventEmitter.decorate(this);
+ // Creating a tooltip instance
+ // 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,
+ stylesheet
+ });
+
+ // By default, swatch-based editor tooltips revert value change on <esc> and
+ // commit value change on <enter>
+ this.shortcuts = new KeyShortcuts({
+ window: this.tooltip.topWindow
+ });
+ this.shortcuts.on("Escape", (name, event) => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.revert();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ this.shortcuts.on("Return", (name, 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);
+}
+
+SwatchBasedEditorTooltip.prototype = {
+ /**
+ * 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: function () {
+ if (this.activeSwatch) {
+ let onShown = this.tooltip.once("shown");
+ this.tooltip.show(this.activeSwatch, "topcenter bottomleft");
+
+ // When the tooltip is closed by clicking outside the panel we want to
+ // commit any changes.
+ this.tooltip.once("hidden", () => {
+ if (!this._reverted && !this.eyedropperOpen) {
+ this.commit();
+ }
+ this._reverted = false;
+
+ // Once the tooltip is hidden we need to clean up any remaining objects.
+ if (!this.eyedropperOpen) {
+ this.activeSwatch = null;
+ }
+ });
+
+ return onShown;
+ }
+
+ return Promise.resolve();
+ },
+
+ hide: function () {
+ 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: function (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, false);
+ },
+
+ removeSwatch: function (swatchEl) {
+ if (this.swatches.has(swatchEl)) {
+ if (this.activeSwatch === swatchEl) {
+ this.hide();
+ this.activeSwatch = null;
+ }
+ swatchEl.removeEventListener("click", this._onSwatchClick, false);
+ this.swatches.delete(swatchEl);
+ }
+ },
+
+ _onSwatchClick: function (event) {
+ let swatch = this.swatches.get(event.target);
+
+ if (event.shiftKey) {
+ event.stopPropagation();
+ return;
+ }
+ if (swatch) {
+ this.activeSwatch = event.target;
+ this.show();
+ swatch.callbacks.onShow();
+ event.stopPropagation();
+ }
+ },
+
+ /**
+ * Not called by this parent class, needs to be taken care of by sub-classes
+ */
+ preview: function (value) {
+ if (this.activeSwatch) {
+ let swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onPreview(value);
+ }
+ },
+
+ /**
+ * This parent class only calls this on <esc> keypress
+ */
+ revert: function () {
+ if (this.activeSwatch) {
+ this._reverted = true;
+ let swatch = this.swatches.get(this.activeSwatch);
+ this.tooltip.once("hidden", () => {
+ swatch.callbacks.onRevert();
+ });
+ }
+ },
+
+ /**
+ * This parent class only calls this on <enter> keypress
+ */
+ commit: function () {
+ if (this.activeSwatch) {
+ let swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onCommit();
+ }
+ },
+
+ destroy: function () {
+ this.swatches.clear();
+ this.activeSwatch = null;
+ this.tooltip.off("keypress", this._onTooltipKeypress);
+ 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 000000000..bf211b8b9
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
@@ -0,0 +1,182 @@
+/* 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 {Task} = require("devtools/shared/task");
+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 Heritage = require("sdk/core/heritage");
+
+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.
+ *
+ * @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.
+ */
+function SwatchColorPickerTooltip(document, inspector) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/spectrum.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+
+ 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);
+}
+
+SwatchColorPickerTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * 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: function (color) {
+ let { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "spectrum-tooltip";
+ let spectrumNode = doc.createElementNS(XHTML_NS, "div");
+ spectrumNode.id = "spectrum";
+ container.appendChild(spectrumNode);
+ let eyedropper = doc.createElementNS(XHTML_NS, "button");
+ eyedropper.id = "eyedropper-button";
+ eyedropper.className = "devtools-button";
+ /* pointerEvents for eyedropper has to be set auto to display tooltip when
+ * eyedropper is disabled in non-HTML documents.
+ */
+ eyedropper.style.pointerEvents = "auto";
+ container.appendChild(eyedropper);
+
+ this.tooltip.setContent(container, { width: 218, height: 224 });
+
+ let spectrum = new Spectrum(spectrumNode, color);
+
+ // Wait for the tooltip to be shown before calling spectrum.show
+ // as it expect to be visible in order to compute DOM element sizes.
+ this.tooltip.once("shown", () => {
+ spectrum.show();
+ });
+
+ return spectrum;
+ },
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's
+ * color.
+ */
+ show: Task.async(function* () {
+ // Call then parent class' show function
+ yield SwatchBasedEditorTooltip.prototype.show.call(this);
+ // Then set spectrum's color and listen to color changes to preview them
+ if (this.activeSwatch) {
+ this.currentSwatchColor = this.activeSwatch.nextSibling;
+ this._originalColor = this.currentSwatchColor.textContent;
+ let 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();
+ }
+
+ let {target} = this.inspector;
+ target.actorHasMethod("inspector", "pickColorFromPage").then(value => {
+ let tooltipDoc = this.tooltip.doc;
+ let eyeButton = tooltipDoc.querySelector("#eyedropper-button");
+ if (value && this.inspector.selection.nodeFront.isInHTMLDocument) {
+ eyeButton.disabled = false;
+ eyeButton.removeAttribute("title");
+ eyeButton.addEventListener("click", this._openEyeDropper);
+ } else {
+ eyeButton.disabled = true;
+ eyeButton.title = L10N.getStr("eyedropper.disabled.title");
+ }
+ this.emit("ready");
+ }, e => console.error(e));
+ }),
+
+ _onSpectrumColorChange: function (event, rgba, cssColor) {
+ this._selectColor(cssColor);
+ },
+
+ _selectColor: function (color) {
+ if (this.activeSwatch) {
+ this.activeSwatch.style.backgroundColor = color;
+ this.activeSwatch.parentNode.dataset.color = color;
+
+ color = this._toDefaultType(color);
+ this.currentSwatchColor.textContent = color;
+ this.preview(color);
+
+ if (this.eyedropperOpen) {
+ this.commit();
+ }
+ }
+ },
+
+ _openEyeDropper: function () {
+ let {inspector, toolbox, telemetry} = this.inspector;
+ telemetry.toolOpened("pickereyedropper");
+ inspector.pickColorFromPage(toolbox, {copyOnSelect: false}).then(() => {
+ this.eyedropperOpen = true;
+
+ // close the colorpicker tooltip so that only the eyedropper is open.
+ this.hide();
+
+ this.tooltip.emit("eyedropper-opened");
+ }, e => console.error(e));
+
+ inspector.once("color-picked", color => {
+ toolbox.win.focus();
+ this._selectColor(color);
+ this._onEyeDropperDone();
+ });
+
+ inspector.once("color-pick-canceled", () => {
+ this._onEyeDropperDone();
+ });
+ },
+
+ _onEyeDropperDone: function () {
+ this.eyedropperOpen = false;
+ this.activeSwatch = null;
+ },
+
+ _colorToRgba: function (color) {
+ color = new colorUtils.CssColor(color);
+ let rgba = color._getRGBATuple();
+ return [rgba.r, rgba.g, rgba.b, rgba.a];
+ },
+
+ _toDefaultType: function (color) {
+ let colorObj = new colorUtils.CssColor(color);
+ colorObj.setAuthoredUnitFromColor(this._originalColor);
+ return colorObj.toString();
+ },
+
+ destroy: function () {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ this.inspector = null;
+ this.currentSwatchColor = 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 000000000..02f6fbea4
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
@@ -0,0 +1,102 @@
+/* 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 defer = require("devtools/shared/defer");
+const {Task} = require("devtools/shared/task");
+const {CubicBezierWidget} = require("devtools/client/shared/widgets/CubicBezierWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const Heritage = require("sdk/core/heritage");
+
+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.
+ */
+function SwatchCubicBezierTooltip(document) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/cubic-bezier.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+
+ // 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);
+}
+
+SwatchCubicBezierTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * 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
+ */
+ setCubicBezierContent: function (bezier) {
+ let { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "cubic-bezier-container";
+
+ this.tooltip.setContent(container, { width: 510, height: 370 });
+
+ let def = defer();
+
+ // Wait for the tooltip to be shown before calling instanciating the widget
+ // as it expect its DOM elements to be visible.
+ this.tooltip.once("shown", () => {
+ let widget = new CubicBezierWidget(container, bezier);
+ def.resolve(widget);
+ });
+
+ return def.promise;
+ },
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the cubic
+ * bezier curve in the widget
+ */
+ show: Task.async(function* () {
+ // Call the parent class' show function
+ yield SwatchBasedEditorTooltip.prototype.show.call(this);
+ // 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: function (event, bezier) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.currentBezierValue.textContent = bezier + "";
+ this.preview(bezier + "");
+ },
+
+ destroy: function () {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ 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 000000000..bc69c3b70
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
@@ -0,0 +1,116 @@
+/* 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 {Task} = require("devtools/shared/task");
+const {CSSFilterEditorWidget} = require("devtools/client/shared/widgets/FilterWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const Heritage = require("sdk/core/heritage");
+
+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.
+ * @param {function} cssIsValid
+ * A function to check that css declaration's name and values are valid together.
+ * This can be obtained from "shared/fronts/css-properties.js".
+ */
+function SwatchFilterTooltip(document, cssIsValid) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/filter-widget.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+ this._cssIsValid = cssIsValid;
+
+ // Creating a filter editor instance.
+ this.widget = this.setFilterContent("none");
+ this._onUpdate = this._onUpdate.bind(this);
+}
+
+SwatchFilterTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * 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: function (filter) {
+ let { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "filter-container";
+
+ this.tooltip.setContent(container, { width: 510, height: 200 });
+
+ return new CSSFilterEditorWidget(container, filter, this._cssIsValid);
+ },
+
+ show: Task.async(function* () {
+ // Call the parent class' show function
+ yield SwatchBasedEditorTooltip.prototype.show.call(this);
+ // 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: function (event, 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();
+ }
+ let node = this._parser.parseCssProperty("filter", filters, this._options);
+ this.currentFilterValue.appendChild(node);
+
+ this.preview();
+ },
+
+ destroy: function () {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ 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: function (swatchEl, callbacks, parser, options) {
+ SwatchBasedEditorTooltip.prototype.addSwatch.call(this, swatchEl,
+ callbacks);
+ this._parser = parser;
+ this._options = options;
+ }
+});
+
+module.exports = SwatchFilterTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/Tooltip.js b/devtools/client/shared/widgets/tooltip/Tooltip.js
new file mode 100644
index 000000000..c3c365152
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/Tooltip.js
@@ -0,0 +1,410 @@
+/* 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 defer = require("devtools/shared/defer");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const ESCAPE_KEYCODE = KeyCodes.DOM_VK_ESCAPE;
+const POPUP_EVENTS = ["shown", "hidden", "showing", "hiding"];
+
+/**
+ * Tooltip widget.
+ *
+ * This widget is intended at any tool that may need to show rich content in the
+ * form of floating panels.
+ * A common use case is image previewing in the CSS rule view, but more complex
+ * use cases may include color pickers, object inspection, etc...
+ *
+ * Tooltips are based on XUL (namely XUL arrow-type <panel>s), and therefore
+ * need a XUL Document to live in.
+ * This is pretty much the only requirement they have on their environment.
+ *
+ * The way to use a tooltip is simply by instantiating a tooltip yourself and
+ * attaching some content in it, or using one of the ready-made content types.
+ *
+ * A convenient `startTogglingOnHover` method may avoid having to register event
+ * handlers yourself if the tooltip has to be shown when hovering over a
+ * specific element or group of elements (which is usually the most common case)
+ */
+
+/**
+ * Tooltip class.
+ *
+ * Basic usage:
+ * let t = new Tooltip(xulDoc);
+ * t.content = someXulContent;
+ * t.show();
+ * t.hide();
+ * t.destroy();
+ *
+ * Better usage:
+ * let t = new Tooltip(xulDoc);
+ * t.startTogglingOnHover(container, target => {
+ * if (<condition based on target>) {
+ * t.content = el;
+ * return true;
+ * }
+ * });
+ * t.destroy();
+ *
+ * @param {XULDocument} doc
+ * The XUL document hosting this tooltip
+ * @param {Object} options
+ * Optional options that give options to consumers:
+ * - consumeOutsideClick {Boolean} Wether the first click outside of the
+ * tooltip should close the tooltip and be consumed or not.
+ * Defaults to false.
+ * - closeOnKeys {Array} An array of key codes that should close the
+ * tooltip. Defaults to [27] (escape key).
+ * - closeOnEvents [{emitter: {Object}, event: {String},
+ * useCapture: {Boolean}}]
+ * Provide an optional list of emitter objects and event names here to
+ * trigger the closing of the tooltip when these events are fired by the
+ * emitters. The emitter objects should either implement
+ * on/off(event, cb) or addEventListener/removeEventListener(event, cb).
+ * Defaults to [].
+ * For instance, the following would close the tooltip whenever the
+ * toolbox selects a new tool and when a DOM node gets scrolled:
+ * new Tooltip(doc, {
+ * closeOnEvents: [
+ * {emitter: toolbox, event: "select"},
+ * {emitter: myContainer, event: "scroll", useCapture: true}
+ * ]
+ * });
+ * - noAutoFocus {Boolean} Should the focus automatically go to the panel
+ * when it opens. Defaults to true.
+ *
+ * Fires these events:
+ * - showing : just before the tooltip shows
+ * - shown : when the tooltip is shown
+ * - hiding : just before the tooltip closes
+ * - hidden : when the tooltip gets hidden
+ * - keypress : when any key gets pressed, with keyCode
+ */
+function Tooltip(doc, {
+ consumeOutsideClick = false,
+ closeOnKeys = [ESCAPE_KEYCODE],
+ noAutoFocus = true,
+ closeOnEvents = [],
+ } = {}) {
+ EventEmitter.decorate(this);
+
+ this.doc = doc;
+ this.consumeOutsideClick = consumeOutsideClick;
+ this.closeOnKeys = closeOnKeys;
+ this.noAutoFocus = noAutoFocus;
+ this.closeOnEvents = closeOnEvents;
+
+ this.panel = this._createPanel();
+
+ // Create tooltip toggle helper and decorate the Tooltip instance with
+ // shortcut methods.
+ this._toggle = new TooltipToggle(this);
+ this.startTogglingOnHover = this._toggle.start.bind(this._toggle);
+ this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle);
+
+ // Emit show/hide events when the panel does.
+ for (let eventName of POPUP_EVENTS) {
+ this["_onPopup" + eventName] = (name => {
+ return e => {
+ if (e.target === this.panel) {
+ this.emit(name);
+ }
+ };
+ })(eventName);
+ this.panel.addEventListener("popup" + eventName,
+ this["_onPopup" + eventName], false);
+ }
+
+ // Listen to keypress events to close the tooltip if configured to do so
+ let win = this.doc.querySelector("window");
+ this._onKeyPress = event => {
+ if (this.panel.hidden) {
+ return;
+ }
+
+ this.emit("keypress", event.keyCode);
+ if (this.closeOnKeys.indexOf(event.keyCode) !== -1 &&
+ this.isShown()) {
+ event.stopPropagation();
+ this.hide();
+ }
+ };
+ win.addEventListener("keypress", this._onKeyPress, false);
+
+ // Listen to custom emitters' events to close the tooltip
+ this.hide = this.hide.bind(this);
+ for (let {emitter, event, useCapture} of this.closeOnEvents) {
+ for (let add of ["addEventListener", "on"]) {
+ if (add in emitter) {
+ emitter[add](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+}
+
+Tooltip.prototype = {
+ defaultPosition: "before_start",
+ // px
+ defaultOffsetX: 0,
+ // px
+ defaultOffsetY: 0,
+ // px
+
+ /**
+ * Show the tooltip. It might be wise to append some content first if you
+ * don't want the tooltip to be empty. You may access the content of the
+ * tooltip by setting a XUL node to t.content.
+ * @param {node} anchor
+ * Which node should the tooltip be shown on
+ * @param {string} position [optional]
+ * Optional tooltip position. Defaults to before_start
+ * https://developer.mozilla.org/en-US/docs/XUL/PopupGuide/Positioning
+ * @param {number} x, y [optional]
+ * The left and top offset coordinates, in pixels.
+ */
+ show: function (anchor,
+ position = this.defaultPosition,
+ x = this.defaultOffsetX,
+ y = this.defaultOffsetY) {
+ this.panel.hidden = false;
+ this.panel.openPopup(anchor, position, x, y);
+ },
+
+ /**
+ * Hide the tooltip
+ */
+ hide: function () {
+ this.panel.hidden = true;
+ this.panel.hidePopup();
+ },
+
+ isShown: function () {
+ return this.panel &&
+ this.panel.state !== "closed" &&
+ this.panel.state !== "hiding";
+ },
+
+ setSize: function (width, height) {
+ this.panel.sizeTo(width, height);
+ },
+
+ /**
+ * Empty the tooltip's content
+ */
+ empty: function () {
+ while (this.panel.hasChildNodes()) {
+ this.panel.removeChild(this.panel.firstChild);
+ }
+ },
+
+ /**
+ * Gets this panel's visibility state.
+ * @return boolean
+ */
+ isHidden: function () {
+ return this.panel.state == "closed" || this.panel.state == "hiding";
+ },
+
+ /**
+ * Gets if this panel has any child nodes.
+ * @return boolean
+ */
+ isEmpty: function () {
+ return !this.panel.hasChildNodes();
+ },
+
+ /**
+ * Get rid of references and event listeners
+ */
+ destroy: function () {
+ this.hide();
+
+ for (let eventName of POPUP_EVENTS) {
+ this.panel.removeEventListener("popup" + eventName,
+ this["_onPopup" + eventName], false);
+ }
+
+ let win = this.doc.querySelector("window");
+ win.removeEventListener("keypress", this._onKeyPress, false);
+
+ for (let {emitter, event, useCapture} of this.closeOnEvents) {
+ for (let remove of ["removeEventListener", "off"]) {
+ if (remove in emitter) {
+ emitter[remove](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+
+ this.content = null;
+
+ this._toggle.destroy();
+
+ this.doc = null;
+
+ this.panel.remove();
+ this.panel = null;
+ },
+
+ /**
+ * Returns the outer container node (that includes the arrow etc.). Happens
+ * to be identical to this.panel here, can be different element in other
+ * Tooltip implementations.
+ */
+ get container() {
+ return this.panel;
+ },
+
+ /**
+ * Set the content of this tooltip. Will first empty the tooltip and then
+ * append the new content element.
+ * Consider using one of the set<type>Content() functions instead.
+ * @param {node} content
+ * A node that can be appended in the tooltip XUL element
+ */
+ set content(content) {
+ if (this.content == content) {
+ return;
+ }
+
+ this.empty();
+ this.panel.removeAttribute("clamped-dimensions");
+ this.panel.removeAttribute("clamped-dimensions-no-min-height");
+ this.panel.removeAttribute("clamped-dimensions-no-max-or-min-height");
+ this.panel.removeAttribute("wide");
+
+ if (content) {
+ this.panel.appendChild(content);
+ }
+ },
+
+ get content() {
+ return this.panel.firstChild;
+ },
+
+ /**
+ * Sets some text as the content of this tooltip.
+ *
+ * @param {array} messages
+ * A list of text messages.
+ * @param {string} messagesClass [optional]
+ * A style class for the text messages.
+ * @param {string} containerClass [optional]
+ * A style class for the text messages container.
+ */
+ setTextContent: function (
+ {
+ messages,
+ messagesClass,
+ containerClass
+ },
+ extraButtons = []) {
+ messagesClass = messagesClass || "default-tooltip-simple-text-colors";
+ containerClass = containerClass || "default-tooltip-simple-text-colors";
+
+ let vbox = this.doc.createElement("vbox");
+ vbox.className = "devtools-tooltip-simple-text-container " + containerClass;
+ vbox.setAttribute("flex", "1");
+
+ for (let text of messages) {
+ let description = this.doc.createElement("description");
+ description.setAttribute("flex", "1");
+ description.className = "devtools-tooltip-simple-text " + messagesClass;
+ description.textContent = text;
+ vbox.appendChild(description);
+ }
+
+ for (let { label, className, command } of extraButtons) {
+ let button = this.doc.createElement("button");
+ button.className = className;
+ button.setAttribute("label", label);
+ button.addEventListener("command", command);
+ vbox.appendChild(button);
+ }
+
+ this.content = vbox;
+ },
+
+ /**
+ * Load a document into an iframe, and set the iframe
+ * to be the tooltip's content.
+ *
+ * Used by tooltips that want to load their interface
+ * into an iframe from a URL.
+ *
+ * @param {string} width
+ * Width of the iframe.
+ * @param {string} height
+ * Height of the iframe.
+ * @param {string} url
+ * URL of the document to load into the iframe.
+ *
+ * @return {promise} A promise which is resolved with
+ * the iframe.
+ *
+ * This function creates an iframe, loads the specified document
+ * into it, sets the tooltip's content to the iframe, and returns
+ * a promise.
+ *
+ * When the document is loaded, the function gets the content window
+ * and resolves the promise with the content window.
+ */
+ setIFrameContent: function ({width, height}, url) {
+ let def = defer();
+
+ // Create an iframe
+ let iframe = this.doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("transparent", true);
+ iframe.setAttribute("width", width);
+ iframe.setAttribute("height", height);
+ iframe.setAttribute("flex", "1");
+ iframe.setAttribute("tooltip", "aHTMLTooltip");
+ iframe.setAttribute("class", "devtools-tooltip-iframe");
+
+ // Wait for the load to initialize the widget
+ function onLoad() {
+ iframe.removeEventListener("load", onLoad, true);
+ def.resolve(iframe);
+ }
+ iframe.addEventListener("load", onLoad, true);
+
+ // load the document from url into the iframe
+ iframe.setAttribute("src", url);
+
+ // Put the iframe in the tooltip
+ this.content = iframe;
+
+ return def.promise;
+ },
+
+ /**
+ * Create the tooltip panel
+ */
+ _createPanel() {
+ let panel = this.doc.createElement("panel");
+ panel.setAttribute("hidden", true);
+ panel.setAttribute("ignorekeys", true);
+ panel.setAttribute("animate", false);
+
+ panel.setAttribute("consumeoutsideclicks",
+ this.consumeOutsideClick);
+ panel.setAttribute("noautofocus", this.noAutoFocus);
+ panel.setAttribute("type", "arrow");
+ panel.setAttribute("level", "top");
+
+ panel.setAttribute("class", "devtools-tooltip theme-tooltip-panel");
+ this.doc.querySelector("window").appendChild(panel);
+
+ return panel;
+ }
+};
+
+module.exports = Tooltip;
diff --git a/devtools/client/shared/widgets/tooltip/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
new file mode 100644
index 000000000..b53664f34
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
@@ -0,0 +1,182 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {Task} = require("devtools/shared/task");
+
+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) {
+ 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.
+ */
+ isValidHoverTarget: Task.async(function* (target) {
+ let res = yield 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/VariableContentHelper.js b/devtools/client/shared/widgets/tooltip/VariableContentHelper.js
new file mode 100644
index 000000000..4dc02da9b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/VariableContentHelper.js
@@ -0,0 +1,89 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesView",
+ "resource://devtools/client/shared/widgets/VariablesView.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesViewController",
+ "resource://devtools/client/shared/widgets/VariablesViewController.jsm");
+
+/**
+ * Fill the tooltip with a variables view, inspecting an object via its
+ * corresponding object actor, as specified in the remote debugging protocol.
+ *
+ * @param {Tooltip} tooltip
+ * The tooltip to use
+ * @param {object} objectActor
+ * The value grip for the object actor.
+ * @param {object} viewOptions [optional]
+ * Options for the variables view visualization.
+ * @param {object} controllerOptions [optional]
+ * Options for the variables view controller.
+ * @param {object} relayEvents [optional]
+ * A collection of events to listen on the variables view widget.
+ * For example, { fetched: () => ... }
+ * @param {array} extraButtons [optional]
+ * An array of extra buttons to add. Each element of the array
+ * should be of the form {label, className, command}.
+ * @param {Toolbox} toolbox [optional]
+ * Pass the instance of the current toolbox if you want the variables
+ * view widget to allow highlighting and selection of DOM nodes
+ */
+
+function setTooltipVariableContent(tooltip, objectActor,
+ viewOptions = {}, controllerOptions = {},
+ relayEvents = {}, extraButtons = [],
+ toolbox = null) {
+ let doc = tooltip.doc;
+ let vbox = doc.createElement("vbox");
+ vbox.className = "devtools-tooltip-variables-view-box";
+ vbox.setAttribute("flex", "1");
+
+ let innerbox = doc.createElement("vbox");
+ innerbox.className = "devtools-tooltip-variables-view-innerbox";
+ innerbox.setAttribute("flex", "1");
+ vbox.appendChild(innerbox);
+
+ for (let { label, className, command } of extraButtons) {
+ let button = doc.createElement("button");
+ button.className = className;
+ button.setAttribute("label", label);
+ button.addEventListener("command", command);
+ vbox.appendChild(button);
+ }
+
+ let widget = new VariablesView(innerbox, viewOptions);
+
+ // If a toolbox was provided, link it to the vview
+ if (toolbox) {
+ widget.toolbox = toolbox;
+ }
+
+ // Analyzing state history isn't useful with transient object inspectors.
+ widget.commitHierarchy = () => {};
+
+ for (let e in relayEvents) {
+ widget.on(e, relayEvents[e]);
+ }
+ VariablesViewController.attach(widget, controllerOptions);
+
+ // Some of the view options are allowed to change between uses.
+ widget.searchPlaceholder = viewOptions.searchPlaceholder;
+ widget.searchEnabled = viewOptions.searchEnabled;
+
+ // Use the object actor's grip to display it as a variable in the widget.
+ // The controller options are allowed to change between uses.
+ widget.controller.setSingleVariable(
+ { objectActor: objectActor }, controllerOptions);
+
+ tooltip.content = vbox;
+ tooltip.panel.setAttribute("clamped-dimensions", "");
+}
+
+exports.setTooltipVariableContent = setTooltipVariableContent;
diff --git a/devtools/client/shared/widgets/tooltip/moz.build b/devtools/client/shared/widgets/tooltip/moz.build
new file mode 100644
index 000000000..93172227a
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/moz.build
@@ -0,0 +1,19 @@
+# -*- 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(
+ 'CssDocsTooltip.js',
+ 'EventTooltipHelper.js',
+ 'HTMLTooltip.js',
+ 'ImageTooltipHelper.js',
+ 'SwatchBasedEditorTooltip.js',
+ 'SwatchColorPickerTooltip.js',
+ 'SwatchCubicBezierTooltip.js',
+ 'SwatchFilterTooltip.js',
+ 'Tooltip.js',
+ 'TooltipToggle.js',
+ 'VariableContentHelper.js',
+)
diff --git a/devtools/client/shared/widgets/view-helpers.js b/devtools/client/shared/widgets/view-helpers.js
new file mode 100644
index 000000000..4686d4e1c
--- /dev/null
+++ b/devtools/client/shared/widgets/view-helpers.js
@@ -0,0 +1,1625 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+
+const PANE_APPEARANCE_DELAY = 50;
+const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
+const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]);
+
+var namedTimeoutsStore = new Map();
+
+/**
+ * Inheritance helpers from the addon SDK's core/heritage.
+ * Remove these when all devtools are loadered.
+ */
+exports.Heritage = {
+ /**
+ * @see extend in sdk/core/heritage.
+ */
+ extend: function (prototype, properties = {}) {
+ return Object.create(prototype, this.getOwnPropertyDescriptors(properties));
+ },
+
+ /**
+ * @see getOwnPropertyDescriptors in sdk/core/heritage.
+ */
+ getOwnPropertyDescriptors: function (object) {
+ return Object.getOwnPropertyNames(object).reduce((descriptor, name) => {
+ descriptor[name] = Object.getOwnPropertyDescriptor(object, name);
+ return descriptor;
+ }, {});
+ }
+};
+
+/**
+ * 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;
+
+/**
+ * Same as `setNamedTimeout`, but invokes the callback only if the provided
+ * predicate function returns true. Otherwise, the timeout is re-triggered.
+ *
+ * @param string id
+ * A string identifier for the conditional timeout.
+ * @param number wait
+ * The amount of milliseconds to wait after no more events are fired.
+ * @param function predicate
+ * The predicate function used to determine whether the timeout restarts.
+ * @param function callback
+ * Invoked when no more events are fired after the specified time, and
+ * the provided predicate function returns true.
+ */
+const setConditionalTimeout = function setConditionalTimeout(id, wait,
+ predicate,
+ callback) {
+ setNamedTimeout(id, wait, function maybeCallback() {
+ if (predicate()) {
+ callback();
+ return;
+ }
+ setConditionalTimeout(id, wait, predicate, callback);
+ });
+};
+exports.setConditionalTimeout = setConditionalTimeout;
+
+/**
+ * Clears a conditional timeout.
+ * @see setConditionalTimeout
+ *
+ * @param string id
+ * A string identifier for the conditional timeout.
+ */
+const clearConditionalTimeout = function clearConditionalTimeout(id) {
+ clearNamedTimeout(id);
+};
+exports.clearConditionalTimeout = clearConditionalTimeout;
+
+/**
+ * Helpers for creating and messaging between UI components.
+ */
+const ViewHelpers = exports.ViewHelpers = {
+ /**
+ * Convenience method, dispatching a custom event.
+ *
+ * @param nsIDOMNode 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;
+ }
+ let document = target.ownerDocument || target;
+ let dispatcher = target.ownerDocument ? target : document.documentElement;
+
+ let 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 nsIDOMNode 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 nsIDOMNode 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 && 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 ||
+ object instanceof 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 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 nsIDOMNode 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.
+ let 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 {
+ let width = Math.floor(pane.getAttribute("width")) + 1;
+ let 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) {
+ let 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 an nsIDOMNode, 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 nsIDOMNode 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 nsIDOMNode element
+ * An nsIDOMNode 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 = {}) {
+ let 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 nsIDOMNode 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 (let 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
+};
+
+/**
+ * Some generic Widget methods handling Item instances.
+ * Iterable via "for (let childItem of wrappedView) { }".
+ *
+ * Usage:
+ * function MyView() {
+ * this.widget = new MyWidget(document.querySelector(".my-node"));
+ * }
+ *
+ * MyView.prototype = Heritage.extend(WidgetMethods, {
+ * myMethod: function() {},
+ * ...
+ * });
+ *
+ * See https://gist.github.com/victorporof/5749386 for more details.
+ * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation
+ * example.
+ *
+ * Language:
+ * - An "item" is an instance of an Item.
+ * - An "element" or "node" is a nsIDOMNode.
+ *
+ * The supplied widget can be any object implementing the following
+ * methods:
+ * - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode,
+ * aValue:string)
+ * - function:nsIDOMNode getItemAtIndex(aIndex:number)
+ * - function removeChild(aChild:nsIDOMNode)
+ * - function removeAllItems()
+ * - get:nsIDOMNode selectedItem()
+ * - set selectedItem(aChild:nsIDOMNode)
+ * - function getAttribute(aName:string)
+ * - function setAttribute(aName:string, aValue:string)
+ * - function removeAttribute(aName:string)
+ * - function addEventListener(aName:string, aCallback:function,
+ * aBubbleFlag:boolean)
+ * - function removeEventListener(aName:string, aCallback:function,
+ * aBubbleFlag:boolean)
+ *
+ * Optional methods that can be implemented by the widget:
+ * - function ensureElementIsVisible(aChild:nsIDOMNode)
+ *
+ * Optional attributes that may be handled (when calling
+ * get/set/removeAttribute):
+ * - "emptyText": label temporarily added when there are no items present
+ * - "headerText": label permanently added as a header
+ *
+ * For automagical keyboard and mouse accessibility, the widget should be an
+ * event emitter with the following events:
+ * - "keyPress" -> (aName:string, aEvent:KeyboardEvent)
+ * - "mousePress" -> (aName:string, aEvent:MouseEvent)
+ */
+const WidgetMethods = exports.WidgetMethods = {
+ /**
+ * Sets the element node or widget associated with this container.
+ * @param nsIDOMNode | object widget
+ */
+ set widget(widget) {
+ this._widget = widget;
+
+ // Can't use a WeakMap for _itemsByValue because keys are strings, and
+ // can't use one for _itemsByElement either, since it needs to be iterable.
+ this._itemsByValue = new Map();
+ this._itemsByElement = new Map();
+ this._stagedItems = [];
+
+ // Handle internal events emitted by the widget if necessary.
+ if (ViewHelpers.isEventEmitter(widget)) {
+ widget.on("keyPress", this._onWidgetKeyPress.bind(this));
+ widget.on("mousePress", this._onWidgetMousePress.bind(this));
+ }
+ },
+
+ /**
+ * Gets the element node or widget associated with this container.
+ * @return nsIDOMNode | object
+ */
+ get widget() {
+ return this._widget;
+ },
+
+ /**
+ * Prepares an item to be added to this container. This allows, for example,
+ * for a large number of items to be batched up before being sorted & added.
+ *
+ * If the "staged" flag is *not* set to true, the item will be immediately
+ * inserted at the correct position in this container, so that all the items
+ * still remain sorted. This can (possibly) be much slower than batching up
+ * multiple items.
+ *
+ * By default, this container assumes that all the items should be displayed
+ * sorted by their value. This can be overridden with the "index" flag,
+ * specifying on which position should an item be appended. The "staged" and
+ * "index" flags are mutually exclusive, meaning that all staged items
+ * will always be appended.
+ *
+ * @param nsIDOMNode element
+ * A prebuilt node to be wrapped.
+ * @param string value
+ * A string identifying the node.
+ * @param object options [optional]
+ * Additional options or flags supported by this operation:
+ * - attachment: some attached primitive/object for the item
+ * - staged: true to stage the item to be appended later
+ * - index: specifies on which position should the item be appended
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function invoked when the item is removed
+ * @return Item
+ * The item associated with the displayed element if an unstaged push,
+ * undefined if the item was staged for a later commit.
+ */
+ push: function ([element, value], options = {}) {
+ let item = new Item(this, element, value, options.attachment);
+
+ // Batch the item to be added later.
+ if (options.staged) {
+ // An ulterior commit operation will ignore any specified index, so
+ // no reason to keep it around.
+ options.index = undefined;
+ return void this._stagedItems.push({ item: item, options: options });
+ }
+ // Find the target position in this container and insert the item there.
+ if (!("index" in options)) {
+ return this._insertItemAt(this._findExpectedIndexFor(item), item,
+ options);
+ }
+ // Insert the item at the specified index. If negative or out of bounds,
+ // the item will be simply appended.
+ return this._insertItemAt(options.index, item, options);
+ },
+
+ /**
+ * Flushes all the prepared items into this container.
+ * Any specified index on the items will be ignored. Everything is appended.
+ *
+ * @param object options [optional]
+ * Additional options or flags supported by this operation:
+ * - sorted: true to sort all the items before adding them
+ */
+ commit: function (options = {}) {
+ let stagedItems = this._stagedItems;
+
+ // Sort the items before adding them to this container, if preferred.
+ if (options.sorted) {
+ stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item));
+ }
+ // Append the prepared items to this container.
+ for (let { item, opt } of stagedItems) {
+ this._insertItemAt(-1, item, opt);
+ }
+ // Recreate the temporary items list for ulterior pushes.
+ this._stagedItems.length = 0;
+ },
+
+ /**
+ * Immediately removes the specified item from this container.
+ *
+ * @param Item item
+ * The item associated with the element to remove.
+ */
+ remove: function (item) {
+ if (!item) {
+ return;
+ }
+ this._widget.removeChild(item._target);
+ this._untangleItem(item);
+
+ if (!this._itemsByElement.size) {
+ this._preferredValue = this.selectedValue;
+ this._widget.selectedItem = null;
+ this._widget.setAttribute("emptyText", this._emptyText);
+ }
+ },
+
+ /**
+ * Removes the item at the specified index from this container.
+ *
+ * @param number index
+ * The index of the item to remove.
+ */
+ removeAt: function (index) {
+ this.remove(this.getItemAtIndex(index));
+ },
+
+ /**
+ * Removes the items in this container based on a predicate.
+ */
+ removeForPredicate: function (predicate) {
+ let item;
+ while ((item = this.getItemForPredicate(predicate))) {
+ this.remove(item);
+ }
+ },
+
+ /**
+ * Removes all items from this container.
+ */
+ empty: function () {
+ this._preferredValue = this.selectedValue;
+ this._widget.selectedItem = null;
+ this._widget.removeAllItems();
+ this._widget.setAttribute("emptyText", this._emptyText);
+
+ for (let [, item] of this._itemsByElement) {
+ this._untangleItem(item);
+ }
+
+ this._itemsByValue.clear();
+ this._itemsByElement.clear();
+ this._stagedItems.length = 0;
+ },
+
+ /**
+ * Ensures the specified item is visible in this container.
+ *
+ * @param Item item
+ * The item to bring into view.
+ */
+ ensureItemIsVisible: function (item) {
+ this._widget.ensureElementIsVisible(item._target);
+ },
+
+ /**
+ * Ensures the item at the specified index is visible in this container.
+ *
+ * @param number index
+ * The index of the item to bring into view.
+ */
+ ensureIndexIsVisible: function (index) {
+ this.ensureItemIsVisible(this.getItemAtIndex(index));
+ },
+
+ /**
+ * Sugar for ensuring the selected item is visible in this container.
+ */
+ ensureSelectedItemIsVisible: function () {
+ this.ensureItemIsVisible(this.selectedItem);
+ },
+
+ /**
+ * If supported by the widget, the label string temporarily added to this
+ * container when there are no child items present.
+ */
+ set emptyText(value) {
+ this._emptyText = value;
+
+ // Apply the emptyText attribute right now if there are no child items.
+ if (!this._itemsByElement.size) {
+ this._widget.setAttribute("emptyText", value);
+ }
+ },
+
+ /**
+ * If supported by the widget, the label string permanently added to this
+ * container as a header.
+ * @param string value
+ */
+ set headerText(value) {
+ this._headerText = value;
+ this._widget.setAttribute("headerText", value);
+ },
+
+ /**
+ * Toggles all the items in this container hidden or visible.
+ *
+ * This does not change the default filtering predicate, so newly inserted
+ * items will always be visible. Use WidgetMethods.filterContents if you care.
+ *
+ * @param boolean visibleFlag
+ * Specifies the intended visibility.
+ */
+ toggleContents: function (visibleFlag) {
+ for (let [element] of this._itemsByElement) {
+ element.hidden = !visibleFlag;
+ }
+ },
+
+ /**
+ * Toggles all items in this container hidden or visible based on a predicate.
+ *
+ * @param function predicate [optional]
+ * Items are toggled according to the return value of this function,
+ * which will become the new default filtering predicate in this
+ * container.
+ * If unspecified, all items will be toggled visible.
+ */
+ filterContents: function (predicate = this._currentFilterPredicate) {
+ this._currentFilterPredicate = predicate;
+
+ for (let [element, item] of this._itemsByElement) {
+ element.hidden = !predicate(item);
+ }
+ },
+
+ /**
+ * Sorts all the items in this container based on a predicate.
+ *
+ * @param function predicate [optional]
+ * Items are sorted according to the return value of the function,
+ * which will become the new default sorting predicate in this
+ * container. If unspecified, all items will be sorted by their value.
+ */
+ sortContents: function (predicate = this._currentSortPredicate) {
+ let sortedItems = this.items.sort(this._currentSortPredicate = predicate);
+
+ for (let i = 0, len = sortedItems.length; i < len; i++) {
+ this.swapItems(this.getItemAtIndex(i), sortedItems[i]);
+ }
+ },
+
+ /**
+ * Visually swaps two items in this container.
+ *
+ * @param Item first
+ * The first item to be swapped.
+ * @param Item second
+ * The second item to be swapped.
+ */
+ swapItems: function (first, second) {
+ if (first == second) {
+ // We're just dandy, thank you.
+ return;
+ }
+ let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = first;
+ let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = second;
+
+ // If the two items were constructed with prebuilt nodes as
+ // DocumentFragments, then those DocumentFragments are now
+ // empty and need to be reassembled.
+ if (firstPrebuiltTarget instanceof DocumentFragment) {
+ for (let node of firstTarget.childNodes) {
+ firstPrebuiltTarget.appendChild(node.cloneNode(true));
+ }
+ }
+ if (secondPrebuiltTarget instanceof DocumentFragment) {
+ for (let node of secondTarget.childNodes) {
+ secondPrebuiltTarget.appendChild(node.cloneNode(true));
+ }
+ }
+
+ // 1. Get the indices of the two items to swap.
+ let i = this._indexOfElement(firstTarget);
+ let j = this._indexOfElement(secondTarget);
+
+ // 2. Remeber the selection index, to reselect an item, if necessary.
+ let selectedTarget = this._widget.selectedItem;
+ let selectedIndex = -1;
+ if (selectedTarget == firstTarget) {
+ selectedIndex = i;
+ } else if (selectedTarget == secondTarget) {
+ selectedIndex = j;
+ }
+
+ // 3. Silently nuke both items, nobody needs to know about this.
+ this._widget.removeChild(firstTarget);
+ this._widget.removeChild(secondTarget);
+ this._unlinkItem(first);
+ this._unlinkItem(second);
+
+ // 4. Add the items again, but reversing their indices.
+ this._insertItemAt.apply(this, i < j ? [i, second] : [j, first]);
+ this._insertItemAt.apply(this, i < j ? [j, first] : [i, second]);
+
+ // 5. Restore the previous selection, if necessary.
+ if (selectedIndex == i) {
+ this._widget.selectedItem = first._target;
+ } else if (selectedIndex == j) {
+ this._widget.selectedItem = second._target;
+ }
+
+ // 6. Let the outside world know that these two items were swapped.
+ ViewHelpers.dispatchEvent(first.target, "swap", [second, first]);
+ },
+
+ /**
+ * Visually swaps two items in this container at specific indices.
+ *
+ * @param number first
+ * The index of the first item to be swapped.
+ * @param number second
+ * The index of the second item to be swapped.
+ */
+ swapItemsAtIndices: function (first, second) {
+ this.swapItems(this.getItemAtIndex(first), this.getItemAtIndex(second));
+ },
+
+ /**
+ * Checks whether an item with the specified value is among the elements
+ * shown in this container.
+ *
+ * @param string value
+ * The item's value.
+ * @return boolean
+ * True if the value is known, false otherwise.
+ */
+ containsValue: function (value) {
+ return this._itemsByValue.has(value) ||
+ this._stagedItems.some(({ item }) => item._value == value);
+ },
+
+ /**
+ * Gets the "preferred value". This is the latest selected item's value,
+ * remembered just before emptying this container.
+ * @return string
+ */
+ get preferredValue() {
+ return this._preferredValue;
+ },
+
+ /**
+ * Retrieves the item associated with the selected element.
+ * @return Item | null
+ */
+ get selectedItem() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement);
+ }
+ return null;
+ },
+
+ /**
+ * Retrieves the selected element's index in this container.
+ * @return number
+ */
+ get selectedIndex() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._indexOfElement(selectedElement);
+ }
+ return -1;
+ },
+
+ /**
+ * Retrieves the value of the selected element.
+ * @return string
+ */
+ get selectedValue() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement)._value;
+ }
+ return "";
+ },
+
+ /**
+ * Retrieves the attachment of the selected element.
+ * @return object | null
+ */
+ get selectedAttachment() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement).attachment;
+ }
+ return null;
+ },
+
+ _selectItem: function (item) {
+ // A falsy item is allowed to invalidate the current selection.
+ let targetElement = item ? item._target : null;
+ let prevElement = this._widget.selectedItem;
+
+ // Make sure the selected item's target element is focused and visible.
+ if (this.autoFocusOnSelection && targetElement) {
+ targetElement.focus();
+ }
+
+ if (targetElement != prevElement) {
+ this._widget.selectedItem = targetElement;
+ }
+ },
+
+ /**
+ * Selects the element with the entangled item in this container.
+ * @param Item | function item
+ */
+ set selectedItem(item) {
+ // A predicate is allowed to select a specific item.
+ // If no item is matched, then the current selection is removed.
+ if (typeof item == "function") {
+ item = this.getItemForPredicate(item);
+ }
+
+ let targetElement = item ? item._target : null;
+ let prevElement = this._widget.selectedItem;
+
+ if (this.maintainSelectionVisible && targetElement) {
+ // Some methods are optional. See the WidgetMethods object documentation
+ // for a comprehensive list.
+ if ("ensureElementIsVisible" in this._widget) {
+ this._widget.ensureElementIsVisible(targetElement);
+ }
+ }
+
+ this._selectItem(item);
+
+ // Prevent selecting the same item again and avoid dispatching
+ // a redundant selection event, so return early.
+ if (targetElement != prevElement) {
+ let dispTarget = targetElement || prevElement;
+ let dispName = this.suppressSelectionEvents ? "suppressed-select"
+ : "select";
+ ViewHelpers.dispatchEvent(dispTarget, dispName, item);
+ }
+ },
+
+ /**
+ * Selects the element at the specified index in this container.
+ * @param number index
+ */
+ set selectedIndex(index) {
+ let targetElement = this._widget.getItemAtIndex(index);
+ if (targetElement) {
+ this.selectedItem = this._itemsByElement.get(targetElement);
+ return;
+ }
+ this.selectedItem = null;
+ },
+
+ /**
+ * Selects the element with the specified value in this container.
+ * @param string value
+ */
+ set selectedValue(value) {
+ this.selectedItem = this._itemsByValue.get(value);
+ },
+
+ /**
+ * Deselects and re-selects an item in this container.
+ *
+ * Useful when you want a "select" event to be emitted, even though
+ * the specified item was already selected.
+ *
+ * @param Item | function item
+ * @see `set selectedItem`
+ */
+ forceSelect: function (item) {
+ this.selectedItem = null;
+ this.selectedItem = item;
+ },
+
+ /**
+ * Specifies if this container should try to keep the selected item visible.
+ * (For example, when new items are added the selection is brought into view).
+ */
+ maintainSelectionVisible: true,
+
+ /**
+ * Specifies if "select" events dispatched from the elements in this container
+ * when their respective items are selected should be suppressed or not.
+ *
+ * If this flag is set to true, then consumers of this container won't
+ * be normally notified when items are selected.
+ */
+ suppressSelectionEvents: false,
+
+ /**
+ * Focus this container the first time an element is inserted?
+ *
+ * If this flag is set to true, then when the first item is inserted in
+ * this container (and thus it's the only item available), its corresponding
+ * target element is focused as well.
+ */
+ autoFocusOnFirstItem: true,
+
+ /**
+ * Focus on selection?
+ *
+ * If this flag is set to true, then whenever an item is selected in
+ * this container (e.g. via the selectedIndex or selectedItem setters),
+ * its corresponding target element is focused as well.
+ *
+ * You can disable this flag, for example, to maintain a certain node
+ * focused but visually indicate a different selection in this container.
+ */
+ autoFocusOnSelection: true,
+
+ /**
+ * Focus on input (e.g. mouse click)?
+ *
+ * If this flag is set to true, then whenever an item receives user input in
+ * this container, its corresponding target element is focused as well.
+ */
+ autoFocusOnInput: true,
+
+ /**
+ * When focusing on input, allow right clicks?
+ * @see WidgetMethods.autoFocusOnInput
+ */
+ allowFocusOnRightClick: false,
+
+ /**
+ * The number of elements in this container to jump when Page Up or Page Down
+ * keys are pressed. If falsy, then the page size will be based on the
+ * number of visible items in the container.
+ */
+ pageSize: 0,
+
+ /**
+ * Focuses the first visible item in this container.
+ */
+ focusFirstVisibleItem: function () {
+ this.focusItemAtDelta(-this.itemCount);
+ },
+
+ /**
+ * Focuses the last visible item in this container.
+ */
+ focusLastVisibleItem: function () {
+ this.focusItemAtDelta(+this.itemCount);
+ },
+
+ /**
+ * Focuses the next item in this container.
+ */
+ focusNextItem: function () {
+ this.focusItemAtDelta(+1);
+ },
+
+ /**
+ * Focuses the previous item in this container.
+ */
+ focusPrevItem: function () {
+ this.focusItemAtDelta(-1);
+ },
+
+ /**
+ * Focuses another item in this container based on the index distance
+ * from the currently focused item.
+ *
+ * @param number delta
+ * A scalar specifying by how many items should the selection change.
+ */
+ focusItemAtDelta: function (delta) {
+ // Make sure the currently selected item is also focused, so that the
+ // command dispatcher mechanism has a relative node to work with.
+ // If there's no selection, just select an item at a corresponding index
+ // (e.g. the first item in this container if delta <= 1).
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ selectedElement.focus();
+ } else {
+ this.selectedIndex = Math.max(0, delta - 1);
+ return;
+ }
+
+ let direction = delta > 0 ? "advanceFocus" : "rewindFocus";
+ let distance = Math.abs(Math[delta > 0 ? "ceil" : "floor"](delta));
+ while (distance--) {
+ if (!this._focusChange(direction)) {
+ // Out of bounds.
+ break;
+ }
+ }
+
+ // Synchronize the selected item as being the currently focused element.
+ this.selectedItem = this.getItemForElement(this._focusedElement);
+ },
+
+ /**
+ * Focuses the next or previous item in this container.
+ *
+ * @param string direction
+ * Either "advanceFocus" or "rewindFocus".
+ * @return boolean
+ * False if the focus went out of bounds and the first or last item
+ * in this container was focused instead.
+ */
+ _focusChange: function (direction) {
+ let commandDispatcher = this._commandDispatcher;
+ let prevFocusedElement = commandDispatcher.focusedElement;
+ let currFocusedElement;
+
+ do {
+ commandDispatcher.suppressFocusScroll = true;
+ commandDispatcher[direction]();
+ currFocusedElement = commandDispatcher.focusedElement;
+
+ // Make sure the newly focused item is a part of this container. If the
+ // focus goes out of bounds, revert the previously focused item.
+ if (!this.getItemForElement(currFocusedElement)) {
+ prevFocusedElement.focus();
+ return false;
+ }
+ } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName));
+
+ // Focus remained within bounds.
+ return true;
+ },
+
+ /**
+ * Gets the command dispatcher instance associated with this container's DOM.
+ * If there are no items displayed in this container, null is returned.
+ * @return nsIDOMXULCommandDispatcher | null
+ */
+ get _commandDispatcher() {
+ if (this._cachedCommandDispatcher) {
+ return this._cachedCommandDispatcher;
+ }
+ let someElement = this._widget.getItemAtIndex(0);
+ if (someElement) {
+ let commandDispatcher = someElement.ownerDocument.commandDispatcher;
+ this._cachedCommandDispatcher = commandDispatcher;
+ return commandDispatcher;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the currently focused element in this container.
+ *
+ * @return nsIDOMNode
+ * The focused element, or null if nothing is found.
+ */
+ get _focusedElement() {
+ let commandDispatcher = this._commandDispatcher;
+ if (commandDispatcher) {
+ return commandDispatcher.focusedElement;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the item in the container having the specified index.
+ *
+ * @param number index
+ * The index used to identify the element.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemAtIndex: function (index) {
+ return this.getItemForElement(this._widget.getItemAtIndex(index));
+ },
+
+ /**
+ * Gets the item in the container having the specified value.
+ *
+ * @param string value
+ * The value used to identify the element.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemByValue: function (value) {
+ return this._itemsByValue.get(value);
+ },
+
+ /**
+ * Gets the item in the container associated with the specified element.
+ *
+ * @param nsIDOMNode element
+ * The element used to identify the item.
+ * @param object flags [optional]
+ * Additional options for showing the source. Supported options:
+ * - noSiblings: if siblings shouldn't be taken into consideration
+ * when searching for the associated item.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemForElement: function (element, flags = {}) {
+ while (element) {
+ let item = this._itemsByElement.get(element);
+
+ // Also search the siblings if allowed.
+ if (!flags.noSiblings) {
+ item = item ||
+ this._itemsByElement.get(element.nextElementSibling) ||
+ this._itemsByElement.get(element.previousElementSibling);
+ }
+ if (item) {
+ return item;
+ }
+ element = element.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Gets a visible item in this container validating a specified predicate.
+ *
+ * @param function predicate
+ * The first item which validates this predicate is returned
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemForPredicate: function (predicate, owner = this) {
+ // Recursively check the items in this widget for a predicate match.
+ for (let [element, item] of owner._itemsByElement) {
+ let match;
+ if (predicate(item) && !element.hidden) {
+ match = item;
+ } else {
+ match = this.getItemForPredicate(predicate, item);
+ }
+ if (match) {
+ return match;
+ }
+ }
+ // Also check the staged items. No need to do this recursively since
+ // they're not even appended to the view yet.
+ for (let { item } of this._stagedItems) {
+ if (predicate(item)) {
+ return item;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Shortcut function for getItemForPredicate which works on item attachments.
+ * @see getItemForPredicate
+ */
+ getItemForAttachment: function (predicate, owner = this) {
+ return this.getItemForPredicate(e => predicate(e.attachment));
+ },
+
+ /**
+ * Finds the index of an item in the container.
+ *
+ * @param Item item
+ * The item get the index for.
+ * @return number
+ * The index of the matched item, or -1 if nothing is found.
+ */
+ indexOfItem: function (item) {
+ return this._indexOfElement(item._target);
+ },
+
+ /**
+ * Finds the index of an element in the container.
+ *
+ * @param nsIDOMNode element
+ * The element get the index for.
+ * @return number
+ * The index of the matched element, or -1 if nothing is found.
+ */
+ _indexOfElement: function (element) {
+ for (let i = 0; i < this._itemsByElement.size; i++) {
+ if (this._widget.getItemAtIndex(i) == element) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ /**
+ * Gets the total number of items in this container.
+ * @return number
+ */
+ get itemCount() {
+ return this._itemsByElement.size;
+ },
+
+ /**
+ * Returns a list of items in this container, in the displayed order.
+ * @return array
+ */
+ get items() {
+ let store = [];
+ let itemCount = this.itemCount;
+ for (let i = 0; i < itemCount; i++) {
+ store.push(this.getItemAtIndex(i));
+ }
+ return store;
+ },
+
+ /**
+ * Returns a list of values in this container, in the displayed order.
+ * @return array
+ */
+ get values() {
+ return this.items.map(e => e._value);
+ },
+
+ /**
+ * Returns a list of attachments in this container, in the displayed order.
+ * @return array
+ */
+ get attachments() {
+ return this.items.map(e => e.attachment);
+ },
+
+ /**
+ * Returns a list of all the visible (non-hidden) items in this container,
+ * in the displayed order
+ * @return array
+ */
+ get visibleItems() {
+ return this.items.filter(e => !e._target.hidden);
+ },
+
+ /**
+ * Checks if an item is unique in this container. If an item's value is an
+ * empty string, "undefined" or "null", it is considered unique.
+ *
+ * @param Item item
+ * The item for which to verify uniqueness.
+ * @return boolean
+ * True if the item is unique, false otherwise.
+ */
+ isUnique: function (item) {
+ let value = item._value;
+ if (value == "" || value == "undefined" || value == "null") {
+ return true;
+ }
+ return !this._itemsByValue.has(value);
+ },
+
+ /**
+ * Checks if an item is eligible for this container. By default, this checks
+ * whether an item is unique and has a prebuilt target node.
+ *
+ * @param Item item
+ * The item for which to verify eligibility.
+ * @return boolean
+ * True if the item is eligible, false otherwise.
+ */
+ isEligible: function (item) {
+ return this.isUnique(item) && item._prebuiltNode;
+ },
+
+ /**
+ * Finds the expected item index in this container based on the default
+ * sort predicate.
+ *
+ * @param Item item
+ * The item for which to get the expected index.
+ * @return number
+ * The expected item index.
+ */
+ _findExpectedIndexFor: function (item) {
+ let itemCount = this.itemCount;
+ for (let i = 0; i < itemCount; i++) {
+ if (this._currentSortPredicate(this.getItemAtIndex(i), item) > 0) {
+ return i;
+ }
+ }
+ return itemCount;
+ },
+
+ /**
+ * Immediately inserts an item in this container at the specified index.
+ *
+ * @param number index
+ * The position in the container intended for this item.
+ * @param Item item
+ * The item describing a target element.
+ * @param object options [optional]
+ * Additional options or flags supported by this operation:
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function when the item is untangled (removed)
+ * @return Item
+ * The item associated with the displayed element, null if rejected.
+ */
+ _insertItemAt: function (index, item, options = {}) {
+ if (!this.isEligible(item)) {
+ return null;
+ }
+
+ // Entangle the item with the newly inserted node.
+ // Make sure this is done with the value returned by insertItemAt(),
+ // to avoid storing a potential DocumentFragment.
+ let node = item._prebuiltNode;
+ let attachment = item.attachment;
+ this._entangleItem(item,
+ this._widget.insertItemAt(index, node, attachment));
+
+ // Handle any additional options after entangling the item.
+ if (!this._currentFilterPredicate(item)) {
+ item._target.hidden = true;
+ }
+ if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) {
+ item._target.focus();
+ }
+ if (options.attributes) {
+ options.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
+ }
+ if (options.finalize) {
+ item.finalize = options.finalize;
+ }
+
+ // Hide the empty text if the selection wasn't lost.
+ this._widget.removeAttribute("emptyText");
+
+ // Return the item associated with the displayed element.
+ return item;
+ },
+
+ /**
+ * Entangles an item (model) with a displayed node element (view).
+ *
+ * @param Item item
+ * The item describing a target element.
+ * @param nsIDOMNode element
+ * The element displaying the item.
+ */
+ _entangleItem: function (item, element) {
+ this._itemsByValue.set(item._value, item);
+ 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 (let 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._itemsByValue.delete(item._value);
+ this._itemsByElement.delete(item._target);
+ },
+
+ /**
+ * The keyPress event listener for this container.
+ * @param string name
+ * @param KeyboardEvent event
+ */
+ _onWidgetKeyPress: function (name, event) {
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(event);
+
+ switch (event.keyCode) {
+ case KeyCodes.DOM_VK_UP:
+ case KeyCodes.DOM_VK_LEFT:
+ this.focusPrevItem();
+ return;
+ case KeyCodes.DOM_VK_DOWN:
+ case KeyCodes.DOM_VK_RIGHT:
+ this.focusNextItem();
+ return;
+ case KeyCodes.DOM_VK_PAGE_UP:
+ this.focusItemAtDelta(-(this.pageSize ||
+ (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
+ return;
+ case KeyCodes.DOM_VK_PAGE_DOWN:
+ this.focusItemAtDelta(+(this.pageSize ||
+ (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
+ return;
+ case KeyCodes.DOM_VK_HOME:
+ this.focusFirstVisibleItem();
+ return;
+ case KeyCodes.DOM_VK_END:
+ this.focusLastVisibleItem();
+ return;
+ }
+ },
+
+ /**
+ * The mousePress event listener for this container.
+ * @param string name
+ * @param MouseEvent event
+ */
+ _onWidgetMousePress: function (name, event) {
+ if (event.button != 0 && !this.allowFocusOnRightClick) {
+ // Only allow left-click to trigger this event.
+ return;
+ }
+
+ let item = this.getItemForElement(event.target);
+ if (item) {
+ // The container is not empty and we clicked on an actual item.
+ this.selectedItem = item;
+ // Make sure the current event's target element is also focused.
+ this.autoFocusOnInput && item._target.focus();
+ }
+ },
+
+ /**
+ * The predicate used when filtering items. By default, all items in this
+ * view are visible.
+ *
+ * @param Item item
+ * The item passing through the filter.
+ * @return boolean
+ * True if the item should be visible, false otherwise.
+ */
+ _currentFilterPredicate: function (item) {
+ return true;
+ },
+
+ /**
+ * The predicate used when sorting items. By default, items in this view
+ * are sorted by their label.
+ *
+ * @param Item first
+ * The first item used in the comparison.
+ * @param Item second
+ * The second item used in the comparison.
+ * @return number
+ * -1 to sort first to a lower index than second
+ * 0 to leave first and second unchanged with respect to each other
+ * 1 to sort second to a lower index than first
+ */
+ _currentSortPredicate: function (first, second) {
+ return +(first._value.toLowerCase() > second._value.toLowerCase());
+ },
+
+ /**
+ * Call a method on this widget named `methodName`. Any further arguments are
+ * passed on to the method. Returns the result of the method call.
+ *
+ * @param String methodName
+ * The name of the method you want to call.
+ * @param args
+ * Optional. Any arguments you want to pass through to the method.
+ */
+ callMethod: function (methodName, ...args) {
+ return this._widget[methodName].apply(this._widget, args);
+ },
+
+ _widget: null,
+ _emptyText: "",
+ _headerText: "",
+ _preferredValue: "",
+ _cachedCommandDispatcher: null
+};
+
+/**
+ * A generator-iterator over all the items in this container.
+ */
+Item.prototype[Symbol.iterator] =
+WidgetMethods[Symbol.iterator] = function* () {
+ yield* this._itemsByElement.values();
+};
diff --git a/devtools/client/shared/widgets/widgets.css b/devtools/client/shared/widgets/widgets.css
new file mode 100644
index 000000000..b979cf266
--- /dev/null
+++ b/devtools/client/shared/widgets/widgets.css
@@ -0,0 +1,109 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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;
+}
+
+/* SimpleListWidget */
+
+.simple-list-widget-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+/* FastListWidget */
+
+.fast-list-widget-container {
+ overflow: auto;
+}
+
+/* SideMenuWidget */
+
+.side-menu-widget-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.side-menu-widget-item-contents {
+ -moz-user-focus: normal;
+}
+
+.side-menu-widget-group-checkbox .checkbox-label-box,
+.side-menu-widget-item-checkbox .checkbox-label-box {
+ display: none; /* See bug 669507 */
+}
+
+/* VariablesView */
+
+.variables-view-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.variables-view-element-details:not([open]) {
+ display: none;
+}
+
+.variables-view-scope,
+.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;
+}