/* -*- 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; }