diff -r 000000000000 -r 6474c204b198 browser/devtools/shared/widgets/SideMenuWidget.jsm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/shared/widgets/SideMenuWidget.jsm Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,676 @@ +/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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; + +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource://gre/modules/devtools/event-emitter.js"); + +this.EXPORTED_SYMBOLS = ["SideMenuWidget"]; + +/** + * A simple side menu, with the ability of grouping menu items. + * + * Note: this widget should be used in tandem with the WidgetMethods in + * ViewHelpers.jsm. + * + * @param nsIDOMNode aNode + * The element associated with the widget. + * @param Object aOptions + * - 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 { showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions; + 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("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: function(a, b) a.localeCompare(b), + + /** + * Specifies that the container viewport should be "stuck" to the + * bottom. That is, the container is automatically scrolled down to + * keep appended items visible, but only when the scroll position is + * already at the bottom. + */ + autoscrollWithAppendedItems: false, + + /** + * 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={}) { + // Maintaining scroll position at the bottom when a new item is inserted + // depends on several factors (the order of testing is important to avoid + // needlessly expensive operations that may cause reflows): + let maintainScrollAtBottom = + // 1. The behavior should be enabled, + this.autoscrollWithAppendedItems && + // 2. There shouldn't currently be any selected item in the list. + !this._selectedItem && + // 3. The new item should be appended at the end of the list. + (aIndex < 0 || aIndex >= this._orderedMenuElementsArray.length) && + // 4. The list should already be scrolled at the bottom. + (this._list.scrollTop + this._list.clientHeight >= this._list.scrollHeight); + + let group = this._getMenuGroupForName(aAttachment.group); + let item = this._getMenuItemForGroup(group, aContents, aAttachment); + let element = item.insertSelfAt(aIndex); + + if (maintainScrollAtBottom) { + this._list.scrollTop = this._list.scrollHeight; + } + + return element; + }, + + /** + * 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.QueryInterface(Ci.nsIScrollBoxObject); + 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; + } + }, + + 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 }); + 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() this.ownerView._orderedGroupElementsArray, + get _orderedMenuElementsArray() 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.contains(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() this.ownerView._orderedGroupElementsArray, + get _orderedMenuElementsArray() 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; +}