diff options
Diffstat (limited to 'devtools/client/shared/widgets/SideMenuWidget.jsm')
-rw-r--r-- | devtools/client/shared/widgets/SideMenuWidget.jsm | 725 |
1 files changed, 725 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/SideMenuWidget.jsm b/devtools/client/shared/widgets/SideMenuWidget.jsm new file mode 100644 index 000000000..0c132f232 --- /dev/null +++ b/devtools/client/shared/widgets/SideMenuWidget.jsm @@ -0,0 +1,725 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const Ci = Components.interfaces; +const Cu = Components.utils; + +const SHARED_STRINGS_URI = "devtools/client/locales/shared.properties"; + +const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const EventEmitter = require("devtools/shared/event-emitter"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers"); + +this.EXPORTED_SYMBOLS = ["SideMenuWidget"]; + +/** + * Localization convenience methods. + */ +var L10N = new LocalizationHelper(SHARED_STRINGS_URI); + +/** + * A simple side menu, with the ability of grouping menu items. + * + * Note: this widget should be used in tandem with the WidgetMethods in + * view-helpers.js. + * + * @param nsIDOMNode aNode + * The element associated with the widget. + * @param Object aOptions + * - contextMenu: optional element or element ID that serves as a context menu. + * - showArrows: specifies if items should display horizontal arrows. + * - showItemCheckboxes: specifies if items should display checkboxes. + * - showGroupCheckboxes: specifies if groups should display checkboxes. + */ +this.SideMenuWidget = function SideMenuWidget(aNode, aOptions = {}) { + this.document = aNode.ownerDocument; + this.window = this.document.defaultView; + this._parent = aNode; + + let { contextMenu, showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions; + this._contextMenu = contextMenu || null; + this._showArrows = showArrows || false; + this._showItemCheckboxes = showItemCheckboxes || false; + this._showGroupCheckboxes = showGroupCheckboxes || false; + + // Create an internal scrollbox container. + this._list = this.document.createElement("scrollbox"); + this._list.className = "side-menu-widget-container theme-sidebar"; + this._list.setAttribute("flex", "1"); + this._list.setAttribute("orient", "vertical"); + this._list.setAttribute("with-arrows", this._showArrows); + this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes); + this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes); + this._list.setAttribute("tabindex", "0"); + this._list.addEventListener("contextmenu", e => this._showContextMenu(e), false); + this._list.addEventListener("keypress", e => this.emit("keyPress", e), false); + this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false); + this._parent.appendChild(this._list); + + // Menu items can optionally be grouped. + this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings. + this._orderedGroupElementsArray = []; + this._orderedMenuElementsArray = []; + this._itemsByElement = new Map(); + + // This widget emits events that can be handled in a MenuContainer. + EventEmitter.decorate(this); + + // Delegate some of the associated node's methods to satisfy the interface + // required by MenuContainer instances. + ViewHelpers.delegateWidgetAttributeMethods(this, aNode); + ViewHelpers.delegateWidgetEventMethods(this, aNode); +}; + +SideMenuWidget.prototype = { + /** + * Specifies if groups in this container should be sorted. + */ + sortedGroups: true, + + /** + * The comparator used to sort groups. + */ + groupSortPredicate: (a, b) => a.localeCompare(b), + + /** + * Inserts an item in this container at the specified index, optionally + * grouping by name. + * + * @param number aIndex + * The position in the container intended for this item. + * @param nsIDOMNode aContents + * The node displayed in the container. + * @param object aAttachment [optional] + * Some attached primitive/object. Custom options supported: + * - group: a string specifying the group to place this item into + * - checkboxState: the checked state of the checkbox, if shown + * - checkboxTooltip: the tooltip text for the checkbox, if shown + * @return nsIDOMNode + * The element associated with the displayed item. + */ + insertItemAt: function (aIndex, aContents, aAttachment = {}) { + let group = this._getMenuGroupForName(aAttachment.group); + let item = this._getMenuItemForGroup(group, aContents, aAttachment); + let element = item.insertSelfAt(aIndex); + + return element; + }, + + /** + * Checks to see if the list is scrolled all the way to the bottom. + * Uses getBoundsWithoutFlushing to limit the performance impact + * of this function. + * + * @return bool + */ + isScrolledToBottom: function () { + if (this._list.lastElementChild) { + let utils = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let childRect = utils.getBoundsWithoutFlushing(this._list.lastElementChild); + let listRect = utils.getBoundsWithoutFlushing(this._list); + + // Cheap way to check if it's scrolled all the way to the bottom. + return (childRect.height + childRect.top) <= listRect.bottom; + } + + return false; + }, + + /** + * Scroll the list to the bottom after a timeout. + * If the user scrolls in the meantime, cancel this operation. + */ + scrollToBottom: function () { + this._list.scrollTop = this._list.scrollHeight; + this.emit("scroll-to-bottom"); + }, + + /** + * Returns the child node in this container situated at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + getItemAtIndex: function (aIndex) { + return this._orderedMenuElementsArray[aIndex]; + }, + + /** + * Removes the specified child node from this container. + * + * @param nsIDOMNode aChild + * The element associated with the displayed item. + */ + removeChild: function (aChild) { + this._getNodeForContents(aChild).remove(); + + this._orderedMenuElementsArray.splice( + this._orderedMenuElementsArray.indexOf(aChild), 1); + + this._itemsByElement.delete(aChild); + + if (this._selectedItem == aChild) { + this._selectedItem = null; + } + }, + + /** + * Removes all of the child nodes from this container. + */ + removeAllItems: function () { + let parent = this._parent; + let list = this._list; + + while (list.hasChildNodes()) { + list.firstChild.remove(); + } + + this._selectedItem = null; + + this._groupsByName.clear(); + this._orderedGroupElementsArray.length = 0; + this._orderedMenuElementsArray.length = 0; + this._itemsByElement.clear(); + }, + + /** + * Gets the currently selected child node in this container. + * @return nsIDOMNode + */ + get selectedItem() { + return this._selectedItem; + }, + + /** + * Sets the currently selected child node in this container. + * @param nsIDOMNode aChild + */ + set selectedItem(aChild) { + let menuArray = this._orderedMenuElementsArray; + + if (!aChild) { + this._selectedItem = null; + } + for (let node of menuArray) { + if (node == aChild) { + this._getNodeForContents(node).classList.add("selected"); + this._selectedItem = node; + } else { + this._getNodeForContents(node).classList.remove("selected"); + } + } + }, + + /** + * Ensures the specified element is visible. + * + * @param nsIDOMNode aElement + * The element to make visible. + */ + ensureElementIsVisible: function (aElement) { + if (!aElement) { + return; + } + + // Ensure the element is visible but not scrolled horizontally. + let boxObject = this._list.boxObject; + boxObject.ensureElementIsVisible(aElement); + boxObject.scrollBy(-this._list.clientWidth, 0); + }, + + /** + * Shows all the groups, even the ones with no visible children. + */ + showEmptyGroups: function () { + for (let group of this._orderedGroupElementsArray) { + group.hidden = false; + } + }, + + /** + * Hides all the groups which have no visible children. + */ + hideEmptyGroups: function () { + let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])"; + + for (let group of this._orderedGroupElementsArray) { + group.hidden = group.querySelectorAll(visibleChildNodes).length == 0; + } + for (let menuItem of this._orderedMenuElementsArray) { + menuItem.parentNode.hidden = menuItem.hidden; + } + }, + + /** + * Adds a new attribute or changes an existing attribute on this container. + * + * @param string aName + * The name of the attribute. + * @param string aValue + * The desired attribute value. + */ + setAttribute: function (aName, aValue) { + this._parent.setAttribute(aName, aValue); + + if (aName == "emptyText") { + this._textWhenEmpty = aValue; + } + }, + + /** + * Removes an attribute on this container. + * + * @param string aName + * The name of the attribute. + */ + removeAttribute: function (aName) { + this._parent.removeAttribute(aName); + + if (aName == "emptyText") { + this._removeEmptyText(); + } + }, + + /** + * Set the checkbox state for the item associated with the given node. + * + * @param nsIDOMNode aNode + * The dom node for an item we want to check. + * @param boolean aCheckState + * True to check, false to uncheck. + */ + checkItem: function (aNode, aCheckState) { + const widgetItem = this._itemsByElement.get(aNode); + if (!widgetItem) { + throw new Error("No item for " + aNode); + } + widgetItem.check(aCheckState); + }, + + /** + * Sets the text displayed in this container when empty. + * @param string aValue + */ + set _textWhenEmpty(aValue) { + if (this._emptyTextNode) { + this._emptyTextNode.setAttribute("value", aValue); + } + this._emptyTextValue = aValue; + this._showEmptyText(); + }, + + /** + * Creates and appends a label signaling that this container is empty. + */ + _showEmptyText: function () { + if (this._emptyTextNode || !this._emptyTextValue) { + return; + } + let label = this.document.createElement("label"); + label.className = "plain side-menu-widget-empty-text"; + label.setAttribute("value", this._emptyTextValue); + + this._parent.insertBefore(label, this._list); + this._emptyTextNode = label; + }, + + /** + * Removes the label representing a notice in this container. + */ + _removeEmptyText: function () { + if (!this._emptyTextNode) { + return; + } + + this._parent.removeChild(this._emptyTextNode); + this._emptyTextNode = null; + }, + + /** + * Gets a container representing a group for menu items. If the container + * is not available yet, it is immediately created. + * + * @param string aName + * The required group name. + * @return SideMenuGroup + * The newly created group. + */ + _getMenuGroupForName: function (aName) { + let cachedGroup = this._groupsByName.get(aName); + if (cachedGroup) { + return cachedGroup; + } + + let group = new SideMenuGroup(this, aName, { + showCheckbox: this._showGroupCheckboxes + }); + + this._groupsByName.set(aName, group); + group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1); + + return group; + }, + + /** + * Gets a menu item to be displayed inside a group. + * @see SideMenuWidget.prototype._getMenuGroupForName + * + * @param SideMenuGroup aGroup + * The group to contain the menu item. + * @param nsIDOMNode aContents + * The node displayed in the container. + * @param object aAttachment [optional] + * Some attached primitive/object. + */ + _getMenuItemForGroup: function (aGroup, aContents, aAttachment) { + return new SideMenuItem(aGroup, aContents, aAttachment, { + showArrow: this._showArrows, + showCheckbox: this._showItemCheckboxes + }); + }, + + /** + * Returns the .side-menu-widget-item node corresponding to a SideMenuItem. + * To optimize the markup, some redundant elemenst are skipped when creating + * these child items, in which case we need to be careful on which nodes + * .selected class names are added, or which nodes are removed. + * + * @param nsIDOMNode aChild + * An element which is the target node of a SideMenuItem. + * @return nsIDOMNode + * The wrapper node if there is one, or the same child otherwise. + */ + _getNodeForContents: function (aChild) { + if (aChild.hasAttribute("merged-item-contents")) { + return aChild; + } else { + return aChild.parentNode; + } + }, + + /** + * Shows the contextMenu element. + */ + _showContextMenu: function (e) { + if (!this._contextMenu) { + return; + } + + // Don't show the menu if a descendant node is going to be visible also. + let node = e.originalTarget; + while (node && node !== this._list) { + if (node.hasAttribute("contextmenu")) { + return; + } + node = node.parentNode; + } + + this._contextMenu.openPopupAtScreen(e.screenX, e.screenY, true); + }, + + window: null, + document: null, + _showArrows: false, + _showItemCheckboxes: false, + _showGroupCheckboxes: false, + _parent: null, + _list: null, + _selectedItem: null, + _groupsByName: null, + _orderedGroupElementsArray: null, + _orderedMenuElementsArray: null, + _itemsByElement: null, + _emptyTextNode: null, + _emptyTextValue: "" +}; + +/** + * A SideMenuGroup constructor for the BreadcrumbsWidget. + * Represents a group which should contain SideMenuItems. + * + * @param SideMenuWidget aWidget + * The widget to contain this menu item. + * @param string aName + * The string displayed in the container. + * @param object aOptions [optional] + * An object containing the following properties: + * - showCheckbox: specifies if a checkbox should be displayed. + */ +function SideMenuGroup(aWidget, aName, aOptions = {}) { + this.document = aWidget.document; + this.window = aWidget.window; + this.ownerView = aWidget; + this.identifier = aName; + + // Create an internal title and list container. + if (aName) { + let target = this._target = this.document.createElement("vbox"); + target.className = "side-menu-widget-group"; + target.setAttribute("name", aName); + + let list = this._list = this.document.createElement("vbox"); + list.className = "side-menu-widget-group-list"; + + let title = this._title = this.document.createElement("hbox"); + title.className = "side-menu-widget-group-title"; + + let name = this._name = this.document.createElement("label"); + name.className = "plain name"; + name.setAttribute("value", aName); + name.setAttribute("crop", "end"); + name.setAttribute("flex", "1"); + + // Show a checkbox before the content. + if (aOptions.showCheckbox) { + let checkbox = this._checkbox = makeCheckbox(title, { + description: aName, + checkboxTooltip: L10N.getStr("sideMenu.groupCheckbox.tooltip") + }); + checkbox.className = "side-menu-widget-group-checkbox"; + } + + title.appendChild(name); + target.appendChild(title); + target.appendChild(list); + } + // Skip a few redundant nodes when no title is shown. + else { + let target = this._target = this._list = this.document.createElement("vbox"); + target.className = "side-menu-widget-group side-menu-widget-group-list"; + target.setAttribute("merged-group-contents", ""); + } +} + +SideMenuGroup.prototype = { + get _orderedGroupElementsArray() { + return this.ownerView._orderedGroupElementsArray; + }, + get _orderedMenuElementsArray() { + return this.ownerView._orderedMenuElementsArray; + }, + get _itemsByElement() { return this.ownerView._itemsByElement; }, + + /** + * Inserts this group in the parent container at the specified index. + * + * @param number aIndex + * The position in the container intended for this group. + */ + insertSelfAt: function (aIndex) { + let ownerList = this.ownerView._list; + let groupsArray = this._orderedGroupElementsArray; + + if (aIndex >= 0) { + ownerList.insertBefore(this._target, groupsArray[aIndex]); + groupsArray.splice(aIndex, 0, this._target); + } else { + ownerList.appendChild(this._target); + groupsArray.push(this._target); + } + }, + + /** + * Finds the expected index of this group based on its name. + * + * @return number + * The expected index. + */ + findExpectedIndexForSelf: function (sortPredicate) { + let identifier = this.identifier; + let groupsArray = this._orderedGroupElementsArray; + + for (let group of groupsArray) { + let name = group.getAttribute("name"); + if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :) + !name.includes(identifier)) { // Least significant group should be last. + return groupsArray.indexOf(group); + } + } + return -1; + }, + + window: null, + document: null, + ownerView: null, + identifier: "", + _target: null, + _checkbox: null, + _title: null, + _name: null, + _list: null +}; + +/** + * A SideMenuItem constructor for the BreadcrumbsWidget. + * + * @param SideMenuGroup aGroup + * The group to contain this menu item. + * @param nsIDOMNode aContents + * The node displayed in the container. + * @param object aAttachment [optional] + * The attachment object. + * @param object aOptions [optional] + * An object containing the following properties: + * - showArrow: specifies if a horizontal arrow should be displayed. + * - showCheckbox: specifies if a checkbox should be displayed. + */ +function SideMenuItem(aGroup, aContents, aAttachment = {}, aOptions = {}) { + this.document = aGroup.document; + this.window = aGroup.window; + this.ownerView = aGroup; + + if (aOptions.showArrow || aOptions.showCheckbox) { + let container = this._container = this.document.createElement("hbox"); + container.className = "side-menu-widget-item"; + + let target = this._target = this.document.createElement("vbox"); + target.className = "side-menu-widget-item-contents"; + + // Show a checkbox before the content. + if (aOptions.showCheckbox) { + let checkbox = this._checkbox = makeCheckbox(container, aAttachment); + checkbox.className = "side-menu-widget-item-checkbox"; + } + + container.appendChild(target); + + // Show a horizontal arrow towards the content. + if (aOptions.showArrow) { + let arrow = this._arrow = this.document.createElement("hbox"); + arrow.className = "side-menu-widget-item-arrow"; + container.appendChild(arrow); + } + } + // Skip a few redundant nodes when no horizontal arrow or checkbox is shown. + else { + let target = this._target = this._container = this.document.createElement("hbox"); + target.className = "side-menu-widget-item side-menu-widget-item-contents"; + target.setAttribute("merged-item-contents", ""); + } + + this._target.setAttribute("flex", "1"); + this.contents = aContents; +} + +SideMenuItem.prototype = { + get _orderedGroupElementsArray() { + return this.ownerView._orderedGroupElementsArray; + }, + get _orderedMenuElementsArray() { + return this.ownerView._orderedMenuElementsArray; + }, + get _itemsByElement() { return this.ownerView._itemsByElement; }, + + /** + * Inserts this item in the parent group at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + insertSelfAt: function (aIndex) { + let ownerList = this.ownerView._list; + let menuArray = this._orderedMenuElementsArray; + + if (aIndex >= 0) { + ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]); + menuArray.splice(aIndex, 0, this._target); + } else { + ownerList.appendChild(this._container); + menuArray.push(this._target); + } + this._itemsByElement.set(this._target, this); + + return this._target; + }, + + /** + * Check or uncheck the checkbox associated with this item. + * + * @param boolean aCheckState + * True to check, false to uncheck. + */ + check: function (aCheckState) { + if (!this._checkbox) { + throw new Error("Cannot check items that do not have checkboxes."); + } + // Don't set or remove the "checked" attribute, assign the property instead. + // Otherwise, the "CheckboxStateChange" event will not be fired. XUL!! + this._checkbox.checked = !!aCheckState; + }, + + /** + * Sets the contents displayed in this item's view. + * + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + */ + set contents(aContents) { + // If there are already some contents displayed, replace them. + if (this._target.hasChildNodes()) { + this._target.replaceChild(aContents, this._target.firstChild); + return; + } + // These are the first contents ever displayed. + this._target.appendChild(aContents); + }, + + window: null, + document: null, + ownerView: null, + _target: null, + _container: null, + _checkbox: null, + _arrow: null +}; + +/** + * Creates a checkbox to a specified parent node. Emits a "check" event + * whenever the checkbox is checked or unchecked by the user. + * + * @param nsIDOMNode aParentNode + * The parent node to contain this checkbox. + * @param object aOptions + * An object containing some or all of the following properties: + * - description: defaults to "item" if unspecified + * - checkboxState: true for checked, false for unchecked + * - checkboxTooltip: the tooltip text of the checkbox + */ +function makeCheckbox(aParentNode, aOptions) { + let checkbox = aParentNode.ownerDocument.createElement("checkbox"); + + checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip || ""); + + if (aOptions.checkboxState) { + checkbox.setAttribute("checked", true); + } else { + checkbox.removeAttribute("checked"); + } + + // Stop the toggling of the checkbox from selecting the list item. + checkbox.addEventListener("mousedown", e => { + e.stopPropagation(); + }, false); + + // Emit an event from the checkbox when it is toggled. Don't listen for the + // "command" event! It won't fire for programmatic changes. XUL!! + checkbox.addEventListener("CheckboxStateChange", e => { + ViewHelpers.dispatchEvent(checkbox, "check", { + description: aOptions.description || "item", + checked: checkbox.checked + }); + }, false); + + aParentNode.appendChild(checkbox); + return checkbox; +} |