From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- .../client/shared/widgets/AbstractTreeItem.jsm | 661 ++++ devtools/client/shared/widgets/BarGraphWidget.js | 498 +++ .../client/shared/widgets/BreadcrumbsWidget.jsm | 250 ++ devtools/client/shared/widgets/Chart.jsm | 449 +++ .../client/shared/widgets/CubicBezierPresets.js | 64 + .../client/shared/widgets/CubicBezierWidget.js | 897 +++++ devtools/client/shared/widgets/FastListWidget.js | 249 ++ devtools/client/shared/widgets/FilterWidget.js | 1073 +++++ devtools/client/shared/widgets/FlameGraph.js | 1462 +++++++ devtools/client/shared/widgets/Graphs.js | 1424 +++++++ devtools/client/shared/widgets/GraphsWorker.js | 103 + devtools/client/shared/widgets/LineGraphWidget.js | 402 ++ devtools/client/shared/widgets/MdnDocsWidget.js | 510 +++ .../client/shared/widgets/MountainGraphWidget.js | 195 + devtools/client/shared/widgets/SideMenuWidget.jsm | 725 ++++ .../client/shared/widgets/SimpleListWidget.jsm | 255 ++ devtools/client/shared/widgets/Spectrum.js | 336 ++ devtools/client/shared/widgets/TableWidget.js | 1817 +++++++++ devtools/client/shared/widgets/TreeWidget.js | 605 +++ devtools/client/shared/widgets/VariablesView.jsm | 4182 ++++++++++++++++++++ devtools/client/shared/widgets/VariablesView.xul | 18 + .../shared/widgets/VariablesViewController.jsm | 858 ++++ devtools/client/shared/widgets/cubic-bezier.css | 216 + devtools/client/shared/widgets/filter-widget.css | 238 ++ devtools/client/shared/widgets/graphs-frame.xhtml | 26 + devtools/client/shared/widgets/mdn-docs.css | 39 + devtools/client/shared/widgets/moz.build | 34 + devtools/client/shared/widgets/spectrum.css | 155 + .../shared/widgets/tooltip/CssDocsTooltip.js | 93 + .../shared/widgets/tooltip/EventTooltipHelper.js | 313 ++ .../client/shared/widgets/tooltip/HTMLTooltip.js | 638 +++ .../shared/widgets/tooltip/ImageTooltipHelper.js | 131 + .../widgets/tooltip/SwatchBasedEditorTooltip.js | 209 + .../widgets/tooltip/SwatchColorPickerTooltip.js | 182 + .../widgets/tooltip/SwatchCubicBezierTooltip.js | 102 + .../shared/widgets/tooltip/SwatchFilterTooltip.js | 116 + devtools/client/shared/widgets/tooltip/Tooltip.js | 410 ++ .../client/shared/widgets/tooltip/TooltipToggle.js | 182 + .../widgets/tooltip/VariableContentHelper.js | 89 + devtools/client/shared/widgets/tooltip/moz.build | 19 + devtools/client/shared/widgets/view-helpers.js | 1625 ++++++++ devtools/client/shared/widgets/widgets.css | 109 + 42 files changed, 21959 insertions(+) create mode 100644 devtools/client/shared/widgets/AbstractTreeItem.jsm create mode 100644 devtools/client/shared/widgets/BarGraphWidget.js create mode 100644 devtools/client/shared/widgets/BreadcrumbsWidget.jsm create mode 100644 devtools/client/shared/widgets/Chart.jsm create mode 100644 devtools/client/shared/widgets/CubicBezierPresets.js create mode 100644 devtools/client/shared/widgets/CubicBezierWidget.js create mode 100644 devtools/client/shared/widgets/FastListWidget.js create mode 100644 devtools/client/shared/widgets/FilterWidget.js create mode 100644 devtools/client/shared/widgets/FlameGraph.js create mode 100644 devtools/client/shared/widgets/Graphs.js create mode 100644 devtools/client/shared/widgets/GraphsWorker.js create mode 100644 devtools/client/shared/widgets/LineGraphWidget.js create mode 100644 devtools/client/shared/widgets/MdnDocsWidget.js create mode 100644 devtools/client/shared/widgets/MountainGraphWidget.js create mode 100644 devtools/client/shared/widgets/SideMenuWidget.jsm create mode 100644 devtools/client/shared/widgets/SimpleListWidget.jsm create mode 100644 devtools/client/shared/widgets/Spectrum.js create mode 100644 devtools/client/shared/widgets/TableWidget.js create mode 100644 devtools/client/shared/widgets/TreeWidget.js create mode 100644 devtools/client/shared/widgets/VariablesView.jsm create mode 100644 devtools/client/shared/widgets/VariablesView.xul create mode 100644 devtools/client/shared/widgets/VariablesViewController.jsm create mode 100644 devtools/client/shared/widgets/cubic-bezier.css create mode 100644 devtools/client/shared/widgets/filter-widget.css create mode 100644 devtools/client/shared/widgets/graphs-frame.xhtml create mode 100644 devtools/client/shared/widgets/mdn-docs.css create mode 100644 devtools/client/shared/widgets/moz.build create mode 100644 devtools/client/shared/widgets/spectrum.css create mode 100644 devtools/client/shared/widgets/tooltip/CssDocsTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/EventTooltipHelper.js create mode 100644 devtools/client/shared/widgets/tooltip/HTMLTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js create mode 100644 devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/Tooltip.js create mode 100644 devtools/client/shared/widgets/tooltip/TooltipToggle.js create mode 100644 devtools/client/shared/widgets/tooltip/VariableContentHelper.js create mode 100644 devtools/client/shared/widgets/tooltip/moz.build create mode 100644 devtools/client/shared/widgets/view-helpers.js create mode 100644 devtools/client/shared/widgets/widgets.css (limited to 'devtools/client/shared/widgets') 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 = ` +
+
+ +
+ +
+
+ +
+ `; + 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