diff options
Diffstat (limited to 'devtools/client/shared/widgets')
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¯os"; +// 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, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} + + +/** + * 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; +} |