1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/shared/widgets/ViewHelpers.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1681 @@ 1.4 +/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 1.5 +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ 1.6 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.7 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.8 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.9 +"use strict"; 1.10 + 1.11 +const Cc = Components.classes; 1.12 +const Ci = Components.interfaces; 1.13 +const Cu = Components.utils; 1.14 + 1.15 +const PANE_APPEARANCE_DELAY = 50; 1.16 +const PAGE_SIZE_ITEM_COUNT_RATIO = 5; 1.17 +const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]); 1.18 + 1.19 +Cu.import("resource://gre/modules/Services.jsm"); 1.20 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.21 +Cu.import("resource://gre/modules/Timer.jsm"); 1.22 +Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm"); 1.23 + 1.24 +this.EXPORTED_SYMBOLS = [ 1.25 + "Heritage", "ViewHelpers", "WidgetMethods", 1.26 + "setNamedTimeout", "clearNamedTimeout", 1.27 + "setConditionalTimeout", "clearConditionalTimeout", 1.28 +]; 1.29 + 1.30 +/** 1.31 + * Inheritance helpers from the addon SDK's core/heritage. 1.32 + * Remove these when all devtools are loadered. 1.33 + */ 1.34 +this.Heritage = { 1.35 + /** 1.36 + * @see extend in sdk/core/heritage. 1.37 + */ 1.38 + extend: function(aPrototype, aProperties = {}) { 1.39 + return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties)); 1.40 + }, 1.41 + 1.42 + /** 1.43 + * @see getOwnPropertyDescriptors in sdk/core/heritage. 1.44 + */ 1.45 + getOwnPropertyDescriptors: function(aObject) { 1.46 + return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => { 1.47 + aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName); 1.48 + return aDescriptor; 1.49 + }, {}); 1.50 + } 1.51 +}; 1.52 + 1.53 +/** 1.54 + * Helper for draining a rapid succession of events and invoking a callback 1.55 + * once everything settles down. 1.56 + * 1.57 + * @param string aId 1.58 + * A string identifier for the named timeout. 1.59 + * @param number aWait 1.60 + * The amount of milliseconds to wait after no more events are fired. 1.61 + * @param function aCallback 1.62 + * Invoked when no more events are fired after the specified time. 1.63 + */ 1.64 +this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) { 1.65 + clearNamedTimeout(aId); 1.66 + 1.67 + namedTimeoutsStore.set(aId, setTimeout(() => 1.68 + namedTimeoutsStore.delete(aId) && aCallback(), aWait)); 1.69 +}; 1.70 + 1.71 +/** 1.72 + * Clears a named timeout. 1.73 + * @see setNamedTimeout 1.74 + * 1.75 + * @param string aId 1.76 + * A string identifier for the named timeout. 1.77 + */ 1.78 +this.clearNamedTimeout = function clearNamedTimeout(aId) { 1.79 + if (!namedTimeoutsStore) { 1.80 + return; 1.81 + } 1.82 + clearTimeout(namedTimeoutsStore.get(aId)); 1.83 + namedTimeoutsStore.delete(aId); 1.84 +}; 1.85 + 1.86 +/** 1.87 + * Same as `setNamedTimeout`, but invokes the callback only if the provided 1.88 + * predicate function returns true. Otherwise, the timeout is re-triggered. 1.89 + * 1.90 + * @param string aId 1.91 + * A string identifier for the conditional timeout. 1.92 + * @param number aWait 1.93 + * The amount of milliseconds to wait after no more events are fired. 1.94 + * @param function aPredicate 1.95 + * The predicate function used to determine whether the timeout restarts. 1.96 + * @param function aCallback 1.97 + * Invoked when no more events are fired after the specified time, and 1.98 + * the provided predicate function returns true. 1.99 + */ 1.100 +this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) { 1.101 + setNamedTimeout(aId, aWait, function maybeCallback() { 1.102 + if (aPredicate()) { 1.103 + aCallback(); 1.104 + return; 1.105 + } 1.106 + setConditionalTimeout(aId, aWait, aPredicate, aCallback); 1.107 + }); 1.108 +}; 1.109 + 1.110 +/** 1.111 + * Clears a conditional timeout. 1.112 + * @see setConditionalTimeout 1.113 + * 1.114 + * @param string aId 1.115 + * A string identifier for the conditional timeout. 1.116 + */ 1.117 +this.clearConditionalTimeout = function clearConditionalTimeout(aId) { 1.118 + clearNamedTimeout(aId); 1.119 +}; 1.120 + 1.121 +XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map()); 1.122 + 1.123 +/** 1.124 + * Helpers for creating and messaging between UI components. 1.125 + */ 1.126 +this.ViewHelpers = { 1.127 + /** 1.128 + * Convenience method, dispatching a custom event. 1.129 + * 1.130 + * @param nsIDOMNode aTarget 1.131 + * A custom target element to dispatch the event from. 1.132 + * @param string aType 1.133 + * The name of the event. 1.134 + * @param any aDetail 1.135 + * The data passed when initializing the event. 1.136 + * @return boolean 1.137 + * True if the event was cancelled or a registered handler 1.138 + * called preventDefault. 1.139 + */ 1.140 + dispatchEvent: function(aTarget, aType, aDetail) { 1.141 + if (!(aTarget instanceof Ci.nsIDOMNode)) { 1.142 + return true; // Event cancelled. 1.143 + } 1.144 + let document = aTarget.ownerDocument || aTarget; 1.145 + let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement; 1.146 + 1.147 + let event = document.createEvent("CustomEvent"); 1.148 + event.initCustomEvent(aType, true, true, aDetail); 1.149 + return dispatcher.dispatchEvent(event); 1.150 + }, 1.151 + 1.152 + /** 1.153 + * Helper delegating some of the DOM attribute methods of a node to a widget. 1.154 + * 1.155 + * @param object aWidget 1.156 + * The widget to assign the methods to. 1.157 + * @param nsIDOMNode aNode 1.158 + * A node to delegate the methods to. 1.159 + */ 1.160 + delegateWidgetAttributeMethods: function(aWidget, aNode) { 1.161 + aWidget.getAttribute = 1.162 + aWidget.getAttribute || aNode.getAttribute.bind(aNode); 1.163 + aWidget.setAttribute = 1.164 + aWidget.setAttribute || aNode.setAttribute.bind(aNode); 1.165 + aWidget.removeAttribute = 1.166 + aWidget.removeAttribute || aNode.removeAttribute.bind(aNode); 1.167 + }, 1.168 + 1.169 + /** 1.170 + * Helper delegating some of the DOM event methods of a node to a widget. 1.171 + * 1.172 + * @param object aWidget 1.173 + * The widget to assign the methods to. 1.174 + * @param nsIDOMNode aNode 1.175 + * A node to delegate the methods to. 1.176 + */ 1.177 + delegateWidgetEventMethods: function(aWidget, aNode) { 1.178 + aWidget.addEventListener = 1.179 + aWidget.addEventListener || aNode.addEventListener.bind(aNode); 1.180 + aWidget.removeEventListener = 1.181 + aWidget.removeEventListener || aNode.removeEventListener.bind(aNode); 1.182 + }, 1.183 + 1.184 + /** 1.185 + * Checks if the specified object looks like it's been decorated by an 1.186 + * event emitter. 1.187 + * 1.188 + * @return boolean 1.189 + * True if it looks, walks and quacks like an event emitter. 1.190 + */ 1.191 + isEventEmitter: function(aObject) { 1.192 + return aObject && aObject.on && aObject.off && aObject.once && aObject.emit; 1.193 + }, 1.194 + 1.195 + /** 1.196 + * Checks if the specified object is an instance of a DOM node. 1.197 + * 1.198 + * @return boolean 1.199 + * True if it's a node, false otherwise. 1.200 + */ 1.201 + isNode: function(aObject) { 1.202 + return aObject instanceof Ci.nsIDOMNode || 1.203 + aObject instanceof Ci.nsIDOMElement || 1.204 + aObject instanceof Ci.nsIDOMDocumentFragment; 1.205 + }, 1.206 + 1.207 + /** 1.208 + * Prevents event propagation when navigation keys are pressed. 1.209 + * 1.210 + * @param Event e 1.211 + * The event to be prevented. 1.212 + */ 1.213 + preventScrolling: function(e) { 1.214 + switch (e.keyCode) { 1.215 + case e.DOM_VK_UP: 1.216 + case e.DOM_VK_DOWN: 1.217 + case e.DOM_VK_LEFT: 1.218 + case e.DOM_VK_RIGHT: 1.219 + case e.DOM_VK_PAGE_UP: 1.220 + case e.DOM_VK_PAGE_DOWN: 1.221 + case e.DOM_VK_HOME: 1.222 + case e.DOM_VK_END: 1.223 + e.preventDefault(); 1.224 + e.stopPropagation(); 1.225 + } 1.226 + }, 1.227 + 1.228 + /** 1.229 + * Sets a side pane hidden or visible. 1.230 + * 1.231 + * @param object aFlags 1.232 + * An object containing some of the following properties: 1.233 + * - visible: true if the pane should be shown, false to hide 1.234 + * - animated: true to display an animation on toggle 1.235 + * - delayed: true to wait a few cycles before toggle 1.236 + * - callback: a function to invoke when the toggle finishes 1.237 + * @param nsIDOMNode aPane 1.238 + * The element representing the pane to toggle. 1.239 + */ 1.240 + togglePane: function(aFlags, aPane) { 1.241 + // Make sure a pane is actually available first. 1.242 + if (!aPane) { 1.243 + return; 1.244 + } 1.245 + 1.246 + // Hiding is always handled via margins, not the hidden attribute. 1.247 + aPane.removeAttribute("hidden"); 1.248 + 1.249 + // Add a class to the pane to handle min-widths, margins and animations. 1.250 + if (!aPane.classList.contains("generic-toggled-side-pane")) { 1.251 + aPane.classList.add("generic-toggled-side-pane"); 1.252 + } 1.253 + 1.254 + // Avoid useless toggles. 1.255 + if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) { 1.256 + if (aFlags.callback) aFlags.callback(); 1.257 + return; 1.258 + } 1.259 + 1.260 + // The "animated" attributes enables animated toggles (slide in-out). 1.261 + if (aFlags.animated) { 1.262 + aPane.setAttribute("animated", ""); 1.263 + } else { 1.264 + aPane.removeAttribute("animated"); 1.265 + } 1.266 + 1.267 + // Computes and sets the pane margins in order to hide or show it. 1.268 + let doToggle = () => { 1.269 + if (aFlags.visible) { 1.270 + aPane.style.marginLeft = "0"; 1.271 + aPane.style.marginRight = "0"; 1.272 + aPane.removeAttribute("pane-collapsed"); 1.273 + } else { 1.274 + let margin = ~~(aPane.getAttribute("width")) + 1; 1.275 + aPane.style.marginLeft = -margin + "px"; 1.276 + aPane.style.marginRight = -margin + "px"; 1.277 + aPane.setAttribute("pane-collapsed", ""); 1.278 + } 1.279 + 1.280 + // Invoke the callback when the transition ended. 1.281 + if (aFlags.animated) { 1.282 + aPane.addEventListener("transitionend", function onEvent() { 1.283 + aPane.removeEventListener("transitionend", onEvent, false); 1.284 + if (aFlags.callback) aFlags.callback(); 1.285 + }, false); 1.286 + } 1.287 + // Invoke the callback immediately since there's no transition. 1.288 + else { 1.289 + if (aFlags.callback) aFlags.callback(); 1.290 + } 1.291 + } 1.292 + 1.293 + // Sometimes it's useful delaying the toggle a few ticks to ensure 1.294 + // a smoother slide in-out animation. 1.295 + if (aFlags.delayed) { 1.296 + aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY); 1.297 + } else { 1.298 + doToggle(); 1.299 + } 1.300 + } 1.301 +}; 1.302 + 1.303 +/** 1.304 + * Localization convenience methods. 1.305 + * 1.306 + * @param string aStringBundleName 1.307 + * The desired string bundle's name. 1.308 + */ 1.309 +ViewHelpers.L10N = function(aStringBundleName) { 1.310 + XPCOMUtils.defineLazyGetter(this, "stringBundle", () => 1.311 + Services.strings.createBundle(aStringBundleName)); 1.312 + 1.313 + XPCOMUtils.defineLazyGetter(this, "ellipsis", () => 1.314 + Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data); 1.315 +}; 1.316 + 1.317 +ViewHelpers.L10N.prototype = { 1.318 + stringBundle: null, 1.319 + 1.320 + /** 1.321 + * L10N shortcut function. 1.322 + * 1.323 + * @param string aName 1.324 + * @return string 1.325 + */ 1.326 + getStr: function(aName) { 1.327 + return this.stringBundle.GetStringFromName(aName); 1.328 + }, 1.329 + 1.330 + /** 1.331 + * L10N shortcut function. 1.332 + * 1.333 + * @param string aName 1.334 + * @param array aArgs 1.335 + * @return string 1.336 + */ 1.337 + getFormatStr: function(aName, ...aArgs) { 1.338 + return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length); 1.339 + }, 1.340 + 1.341 + /** 1.342 + * L10N shortcut function for numeric arguments that need to be formatted. 1.343 + * All numeric arguments will be fixed to 2 decimals and given a localized 1.344 + * decimal separator. Other arguments will be left alone. 1.345 + * 1.346 + * @param string aName 1.347 + * @param array aArgs 1.348 + * @return string 1.349 + */ 1.350 + getFormatStrWithNumbers: function(aName, ...aArgs) { 1.351 + let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x); 1.352 + return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length); 1.353 + }, 1.354 + 1.355 + /** 1.356 + * Converts a number to a locale-aware string format and keeps a certain 1.357 + * number of decimals. 1.358 + * 1.359 + * @param number aNumber 1.360 + * The number to convert. 1.361 + * @param number aDecimals [optional] 1.362 + * Total decimals to keep. 1.363 + * @return string 1.364 + * The localized number as a string. 1.365 + */ 1.366 + numberWithDecimals: function(aNumber, aDecimals = 0) { 1.367 + // If this is an integer, don't do anything special. 1.368 + if (aNumber == (aNumber | 0)) { 1.369 + return aNumber; 1.370 + } 1.371 + // Remove {n} trailing decimals. Can't use toFixed(n) because 1.372 + // toLocaleString converts the number to a string. Also can't use 1.373 + // toLocaleString(, { maximumFractionDigits: n }) because it's not 1.374 + // implemented on OS X (bug 368838). Gross. 1.375 + let localized = aNumber.toLocaleString(); // localize 1.376 + let padded = localized + new Array(aDecimals).join("0"); // pad with zeros 1.377 + let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$"); 1.378 + return match.pop(); 1.379 + } 1.380 +}; 1.381 + 1.382 +/** 1.383 + * Shortcuts for lazily accessing and setting various preferences. 1.384 + * Usage: 1.385 + * let prefs = new ViewHelpers.Prefs("root.path.to.branch", { 1.386 + * myIntPref: ["Int", "leaf.path.to.my-int-pref"], 1.387 + * myCharPref: ["Char", "leaf.path.to.my-char-pref"], 1.388 + * ... 1.389 + * }); 1.390 + * 1.391 + * prefs.myCharPref = "foo"; 1.392 + * let aux = prefs.myCharPref; 1.393 + * 1.394 + * @param string aPrefsRoot 1.395 + * The root path to the required preferences branch. 1.396 + * @param object aPrefsObject 1.397 + * An object containing { accessorName: [prefType, prefName] } keys. 1.398 + */ 1.399 +ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) { 1.400 + this.root = aPrefsRoot; 1.401 + 1.402 + for (let accessorName in aPrefsObject) { 1.403 + let [prefType, prefName] = aPrefsObject[accessorName]; 1.404 + this.map(accessorName, prefType, prefName); 1.405 + } 1.406 +}; 1.407 + 1.408 +ViewHelpers.Prefs.prototype = { 1.409 + /** 1.410 + * Helper method for getting a pref value. 1.411 + * 1.412 + * @param string aType 1.413 + * @param string aPrefName 1.414 + * @return any 1.415 + */ 1.416 + _get: function(aType, aPrefName) { 1.417 + if (this[aPrefName] === undefined) { 1.418 + this[aPrefName] = Services.prefs["get" + aType + "Pref"](aPrefName); 1.419 + } 1.420 + return this[aPrefName]; 1.421 + }, 1.422 + 1.423 + /** 1.424 + * Helper method for setting a pref value. 1.425 + * 1.426 + * @param string aType 1.427 + * @param string aPrefName 1.428 + * @param any aValue 1.429 + */ 1.430 + _set: function(aType, aPrefName, aValue) { 1.431 + Services.prefs["set" + aType + "Pref"](aPrefName, aValue); 1.432 + this[aPrefName] = aValue; 1.433 + }, 1.434 + 1.435 + /** 1.436 + * Maps a property name to a pref, defining lazy getters and setters. 1.437 + * Supported types are "Bool", "Char", "Int" and "Json" (which is basically 1.438 + * just sugar for "Char" using the standard JSON serializer). 1.439 + * 1.440 + * @param string aAccessorName 1.441 + * @param string aType 1.442 + * @param string aPrefName 1.443 + * @param array aSerializer 1.444 + */ 1.445 + map: function(aAccessorName, aType, aPrefName, aSerializer = { in: e => e, out: e => e }) { 1.446 + if (aType == "Json") { 1.447 + this.map(aAccessorName, "Char", aPrefName, { in: JSON.parse, out: JSON.stringify }); 1.448 + return; 1.449 + } 1.450 + 1.451 + Object.defineProperty(this, aAccessorName, { 1.452 + get: () => aSerializer.in(this._get(aType, [this.root, aPrefName].join("."))), 1.453 + set: (e) => this._set(aType, [this.root, aPrefName].join("."), aSerializer.out(e)) 1.454 + }); 1.455 + } 1.456 +}; 1.457 + 1.458 +/** 1.459 + * A generic Item is used to describe children present in a Widget. 1.460 + * 1.461 + * This is basically a very thin wrapper around an nsIDOMNode, with a few 1.462 + * characteristics, like a `value` and an `attachment`. 1.463 + * 1.464 + * The characteristics are optional, and their meaning is entirely up to you. 1.465 + * - The `value` should be a string, passed as an argument. 1.466 + * - The `attachment` is any kind of primitive or object, passed as an argument. 1.467 + * 1.468 + * Iterable via "for (let childItem of parentItem) { }". 1.469 + * 1.470 + * @param object aOwnerView 1.471 + * The owner view creating this item. 1.472 + * @param nsIDOMNode aElement 1.473 + * A prebuilt node to be wrapped. 1.474 + * @param string aValue 1.475 + * A string identifying the node. 1.476 + * @param any aAttachment 1.477 + * Some attached primitive/object. 1.478 + */ 1.479 +function Item(aOwnerView, aElement, aValue, aAttachment) { 1.480 + this.ownerView = aOwnerView; 1.481 + this.attachment = aAttachment; 1.482 + this._value = aValue + ""; 1.483 + this._prebuiltNode = aElement; 1.484 +}; 1.485 + 1.486 +Item.prototype = { 1.487 + get value() { return this._value; }, 1.488 + get target() { return this._target; }, 1.489 + 1.490 + /** 1.491 + * Immediately appends a child item to this item. 1.492 + * 1.493 + * @param nsIDOMNode aElement 1.494 + * An nsIDOMNode representing the child element to append. 1.495 + * @param object aOptions [optional] 1.496 + * Additional options or flags supported by this operation: 1.497 + * - attachment: some attached primitive/object for the item 1.498 + * - attributes: a batch of attributes set to the displayed element 1.499 + * - finalize: function invoked when the child item is removed 1.500 + * @return Item 1.501 + * The item associated with the displayed element. 1.502 + */ 1.503 + append: function(aElement, aOptions = {}) { 1.504 + let item = new Item(this, aElement, "", aOptions.attachment); 1.505 + 1.506 + // Entangle the item with the newly inserted child node. 1.507 + // Make sure this is done with the value returned by appendChild(), 1.508 + // to avoid storing a potential DocumentFragment. 1.509 + this._entangleItem(item, this._target.appendChild(aElement)); 1.510 + 1.511 + // Handle any additional options after entangling the item. 1.512 + if (aOptions.attributes) { 1.513 + aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); 1.514 + } 1.515 + if (aOptions.finalize) { 1.516 + item.finalize = aOptions.finalize; 1.517 + } 1.518 + 1.519 + // Return the item associated with the displayed element. 1.520 + return item; 1.521 + }, 1.522 + 1.523 + /** 1.524 + * Immediately removes the specified child item from this item. 1.525 + * 1.526 + * @param Item aItem 1.527 + * The item associated with the element to remove. 1.528 + */ 1.529 + remove: function(aItem) { 1.530 + if (!aItem) { 1.531 + return; 1.532 + } 1.533 + this._target.removeChild(aItem._target); 1.534 + this._untangleItem(aItem); 1.535 + }, 1.536 + 1.537 + /** 1.538 + * Entangles an item (model) with a displayed node element (view). 1.539 + * 1.540 + * @param Item aItem 1.541 + * The item describing a target element. 1.542 + * @param nsIDOMNode aElement 1.543 + * The element displaying the item. 1.544 + */ 1.545 + _entangleItem: function(aItem, aElement) { 1.546 + this._itemsByElement.set(aElement, aItem); 1.547 + aItem._target = aElement; 1.548 + }, 1.549 + 1.550 + /** 1.551 + * Untangles an item (model) from a displayed node element (view). 1.552 + * 1.553 + * @param Item aItem 1.554 + * The item describing a target element. 1.555 + */ 1.556 + _untangleItem: function(aItem) { 1.557 + if (aItem.finalize) { 1.558 + aItem.finalize(aItem); 1.559 + } 1.560 + for (let childItem of aItem) { 1.561 + aItem.remove(childItem); 1.562 + } 1.563 + 1.564 + this._unlinkItem(aItem); 1.565 + aItem._target = null; 1.566 + }, 1.567 + 1.568 + /** 1.569 + * Deletes an item from the its parent's storage maps. 1.570 + * 1.571 + * @param Item aItem 1.572 + * The item describing a target element. 1.573 + */ 1.574 + _unlinkItem: function(aItem) { 1.575 + this._itemsByElement.delete(aItem._target); 1.576 + }, 1.577 + 1.578 + /** 1.579 + * Returns a string representing the object. 1.580 + * @return string 1.581 + */ 1.582 + toString: function() { 1.583 + return this._value + " :: " + this._target + " :: " + this.attachment; 1.584 + }, 1.585 + 1.586 + _value: "", 1.587 + _target: null, 1.588 + _prebuiltNode: null, 1.589 + finalize: null, 1.590 + attachment: null 1.591 +}; 1.592 + 1.593 +// Creating maps thousands of times for widgets with a large number of children 1.594 +// fills up a lot of memory. Make sure these are instantiated only if needed. 1.595 +DevToolsUtils.defineLazyPrototypeGetter(Item.prototype, "_itemsByElement", Map); 1.596 + 1.597 +/** 1.598 + * Some generic Widget methods handling Item instances. 1.599 + * Iterable via "for (let childItem of wrappedView) { }". 1.600 + * 1.601 + * Usage: 1.602 + * function MyView() { 1.603 + * this.widget = new MyWidget(document.querySelector(".my-node")); 1.604 + * } 1.605 + * 1.606 + * MyView.prototype = Heritage.extend(WidgetMethods, { 1.607 + * myMethod: function() {}, 1.608 + * ... 1.609 + * }); 1.610 + * 1.611 + * See https://gist.github.com/victorporof/5749386 for more details. 1.612 + * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation example. 1.613 + * 1.614 + * Language: 1.615 + * - An "item" is an instance of an Item. 1.616 + * - An "element" or "node" is a nsIDOMNode. 1.617 + * 1.618 + * The supplied widget can be any object implementing the following methods: 1.619 + * - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, aValue:string) 1.620 + * - function:nsIDOMNode getItemAtIndex(aIndex:number) 1.621 + * - function removeChild(aChild:nsIDOMNode) 1.622 + * - function removeAllItems() 1.623 + * - get:nsIDOMNode selectedItem() 1.624 + * - set selectedItem(aChild:nsIDOMNode) 1.625 + * - function getAttribute(aName:string) 1.626 + * - function setAttribute(aName:string, aValue:string) 1.627 + * - function removeAttribute(aName:string) 1.628 + * - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) 1.629 + * - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) 1.630 + * 1.631 + * Optional methods that can be implemented by the widget: 1.632 + * - function ensureElementIsVisible(aChild:nsIDOMNode) 1.633 + * 1.634 + * Optional attributes that may be handled (when calling get/set/removeAttribute): 1.635 + * - "emptyText": label temporarily added when there are no items present 1.636 + * - "headerText": label permanently added as a header 1.637 + * 1.638 + * For automagical keyboard and mouse accessibility, the widget should be an 1.639 + * event emitter with the following events: 1.640 + * - "keyPress" -> (aName:string, aEvent:KeyboardEvent) 1.641 + * - "mousePress" -> (aName:string, aEvent:MouseEvent) 1.642 + */ 1.643 +this.WidgetMethods = { 1.644 + /** 1.645 + * Sets the element node or widget associated with this container. 1.646 + * @param nsIDOMNode | object aWidget 1.647 + */ 1.648 + set widget(aWidget) { 1.649 + this._widget = aWidget; 1.650 + 1.651 + 1.652 + // Can't use a WeakMap for _itemsByValue because keys are strings, and 1.653 + // can't use one for _itemsByElement either, since it needs to be iterable. 1.654 + XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map()); 1.655 + XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); 1.656 + XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []); 1.657 + 1.658 + // Handle internal events emitted by the widget if necessary. 1.659 + if (ViewHelpers.isEventEmitter(aWidget)) { 1.660 + aWidget.on("keyPress", this._onWidgetKeyPress.bind(this)); 1.661 + aWidget.on("mousePress", this._onWidgetMousePress.bind(this)); 1.662 + } 1.663 + }, 1.664 + 1.665 + /** 1.666 + * Gets the element node or widget associated with this container. 1.667 + * @return nsIDOMNode | object 1.668 + */ 1.669 + get widget() this._widget, 1.670 + 1.671 + /** 1.672 + * Prepares an item to be added to this container. This allows, for example, 1.673 + * for a large number of items to be batched up before being sorted & added. 1.674 + * 1.675 + * If the "staged" flag is *not* set to true, the item will be immediately 1.676 + * inserted at the correct position in this container, so that all the items 1.677 + * still remain sorted. This can (possibly) be much slower than batching up 1.678 + * multiple items. 1.679 + * 1.680 + * By default, this container assumes that all the items should be displayed 1.681 + * sorted by their value. This can be overridden with the "index" flag, 1.682 + * specifying on which position should an item be appended. The "staged" and 1.683 + * "index" flags are mutually exclusive, meaning that all staged items 1.684 + * will always be appended. 1.685 + * 1.686 + * @param nsIDOMNode aElement 1.687 + * A prebuilt node to be wrapped. 1.688 + * @param string aValue 1.689 + * A string identifying the node. 1.690 + * @param object aOptions [optional] 1.691 + * Additional options or flags supported by this operation: 1.692 + * - attachment: some attached primitive/object for the item 1.693 + * - staged: true to stage the item to be appended later 1.694 + * - index: specifies on which position should the item be appended 1.695 + * - attributes: a batch of attributes set to the displayed element 1.696 + * - finalize: function invoked when the item is removed 1.697 + * @return Item 1.698 + * The item associated with the displayed element if an unstaged push, 1.699 + * undefined if the item was staged for a later commit. 1.700 + */ 1.701 + push: function([aElement, aValue], aOptions = {}) { 1.702 + let item = new Item(this, aElement, aValue, aOptions.attachment); 1.703 + 1.704 + // Batch the item to be added later. 1.705 + if (aOptions.staged) { 1.706 + // An ulterior commit operation will ignore any specified index, so 1.707 + // no reason to keep it around. 1.708 + aOptions.index = undefined; 1.709 + return void this._stagedItems.push({ item: item, options: aOptions }); 1.710 + } 1.711 + // Find the target position in this container and insert the item there. 1.712 + if (!("index" in aOptions)) { 1.713 + return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions); 1.714 + } 1.715 + // Insert the item at the specified index. If negative or out of bounds, 1.716 + // the item will be simply appended. 1.717 + return this._insertItemAt(aOptions.index, item, aOptions); 1.718 + }, 1.719 + 1.720 + /** 1.721 + * Flushes all the prepared items into this container. 1.722 + * Any specified index on the items will be ignored. Everything is appended. 1.723 + * 1.724 + * @param object aOptions [optional] 1.725 + * Additional options or flags supported by this operation: 1.726 + * - sorted: true to sort all the items before adding them 1.727 + */ 1.728 + commit: function(aOptions = {}) { 1.729 + let stagedItems = this._stagedItems; 1.730 + 1.731 + // Sort the items before adding them to this container, if preferred. 1.732 + if (aOptions.sorted) { 1.733 + stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item)); 1.734 + } 1.735 + // Append the prepared items to this container. 1.736 + for (let { item, options } of stagedItems) { 1.737 + this._insertItemAt(-1, item, options); 1.738 + } 1.739 + // Recreate the temporary items list for ulterior pushes. 1.740 + this._stagedItems.length = 0; 1.741 + }, 1.742 + 1.743 + /** 1.744 + * Immediately removes the specified item from this container. 1.745 + * 1.746 + * @param Item aItem 1.747 + * The item associated with the element to remove. 1.748 + */ 1.749 + remove: function(aItem) { 1.750 + if (!aItem) { 1.751 + return; 1.752 + } 1.753 + this._widget.removeChild(aItem._target); 1.754 + this._untangleItem(aItem); 1.755 + 1.756 + if (!this._itemsByElement.size) { 1.757 + this._preferredValue = this.selectedValue; 1.758 + this._widget.selectedItem = null; 1.759 + this._widget.setAttribute("emptyText", this._emptyText); 1.760 + } 1.761 + }, 1.762 + 1.763 + /** 1.764 + * Removes the item at the specified index from this container. 1.765 + * 1.766 + * @param number aIndex 1.767 + * The index of the item to remove. 1.768 + */ 1.769 + removeAt: function(aIndex) { 1.770 + this.remove(this.getItemAtIndex(aIndex)); 1.771 + }, 1.772 + 1.773 + /** 1.774 + * Removes all items from this container. 1.775 + */ 1.776 + empty: function() { 1.777 + this._preferredValue = this.selectedValue; 1.778 + this._widget.selectedItem = null; 1.779 + this._widget.removeAllItems(); 1.780 + this._widget.setAttribute("emptyText", this._emptyText); 1.781 + 1.782 + for (let [, item] of this._itemsByElement) { 1.783 + this._untangleItem(item); 1.784 + } 1.785 + 1.786 + this._itemsByValue.clear(); 1.787 + this._itemsByElement.clear(); 1.788 + this._stagedItems.length = 0; 1.789 + }, 1.790 + 1.791 + /** 1.792 + * Ensures the specified item is visible in this container. 1.793 + * 1.794 + * @param Item aItem 1.795 + * The item to bring into view. 1.796 + */ 1.797 + ensureItemIsVisible: function(aItem) { 1.798 + this._widget.ensureElementIsVisible(aItem._target); 1.799 + }, 1.800 + 1.801 + /** 1.802 + * Ensures the item at the specified index is visible in this container. 1.803 + * 1.804 + * @param number aIndex 1.805 + * The index of the item to bring into view. 1.806 + */ 1.807 + ensureIndexIsVisible: function(aIndex) { 1.808 + this.ensureItemIsVisible(this.getItemAtIndex(aIndex)); 1.809 + }, 1.810 + 1.811 + /** 1.812 + * Sugar for ensuring the selected item is visible in this container. 1.813 + */ 1.814 + ensureSelectedItemIsVisible: function() { 1.815 + this.ensureItemIsVisible(this.selectedItem); 1.816 + }, 1.817 + 1.818 + /** 1.819 + * If supported by the widget, the label string temporarily added to this 1.820 + * container when there are no child items present. 1.821 + */ 1.822 + set emptyText(aValue) { 1.823 + this._emptyText = aValue; 1.824 + 1.825 + // Apply the emptyText attribute right now if there are no child items. 1.826 + if (!this._itemsByElement.size) { 1.827 + this._widget.setAttribute("emptyText", aValue); 1.828 + } 1.829 + }, 1.830 + 1.831 + /** 1.832 + * If supported by the widget, the label string permanently added to this 1.833 + * container as a header. 1.834 + * @param string aValue 1.835 + */ 1.836 + set headerText(aValue) { 1.837 + this._headerText = aValue; 1.838 + this._widget.setAttribute("headerText", aValue); 1.839 + }, 1.840 + 1.841 + /** 1.842 + * Toggles all the items in this container hidden or visible. 1.843 + * 1.844 + * This does not change the default filtering predicate, so newly inserted 1.845 + * items will always be visible. Use WidgetMethods.filterContents if you care. 1.846 + * 1.847 + * @param boolean aVisibleFlag 1.848 + * Specifies the intended visibility. 1.849 + */ 1.850 + toggleContents: function(aVisibleFlag) { 1.851 + for (let [element, item] of this._itemsByElement) { 1.852 + element.hidden = !aVisibleFlag; 1.853 + } 1.854 + }, 1.855 + 1.856 + /** 1.857 + * Toggles all items in this container hidden or visible based on a predicate. 1.858 + * 1.859 + * @param function aPredicate [optional] 1.860 + * Items are toggled according to the return value of this function, 1.861 + * which will become the new default filtering predicate in this container. 1.862 + * If unspecified, all items will be toggled visible. 1.863 + */ 1.864 + filterContents: function(aPredicate = this._currentFilterPredicate) { 1.865 + this._currentFilterPredicate = aPredicate; 1.866 + 1.867 + for (let [element, item] of this._itemsByElement) { 1.868 + element.hidden = !aPredicate(item); 1.869 + } 1.870 + }, 1.871 + 1.872 + /** 1.873 + * Sorts all the items in this container based on a predicate. 1.874 + * 1.875 + * @param function aPredicate [optional] 1.876 + * Items are sorted according to the return value of the function, 1.877 + * which will become the new default sorting predicate in this container. 1.878 + * If unspecified, all items will be sorted by their value. 1.879 + */ 1.880 + sortContents: function(aPredicate = this._currentSortPredicate) { 1.881 + let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate); 1.882 + 1.883 + for (let i = 0, len = sortedItems.length; i < len; i++) { 1.884 + this.swapItems(this.getItemAtIndex(i), sortedItems[i]); 1.885 + } 1.886 + }, 1.887 + 1.888 + /** 1.889 + * Visually swaps two items in this container. 1.890 + * 1.891 + * @param Item aFirst 1.892 + * The first item to be swapped. 1.893 + * @param Item aSecond 1.894 + * The second item to be swapped. 1.895 + */ 1.896 + swapItems: function(aFirst, aSecond) { 1.897 + if (aFirst == aSecond) { // We're just dandy, thank you. 1.898 + return; 1.899 + } 1.900 + let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = aFirst; 1.901 + let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = aSecond; 1.902 + 1.903 + // If the two items were constructed with prebuilt nodes as DocumentFragments, 1.904 + // then those DocumentFragments are now empty and need to be reassembled. 1.905 + if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { 1.906 + for (let node of firstTarget.childNodes) { 1.907 + firstPrebuiltTarget.appendChild(node.cloneNode(true)); 1.908 + } 1.909 + } 1.910 + if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { 1.911 + for (let node of secondTarget.childNodes) { 1.912 + secondPrebuiltTarget.appendChild(node.cloneNode(true)); 1.913 + } 1.914 + } 1.915 + 1.916 + // 1. Get the indices of the two items to swap. 1.917 + let i = this._indexOfElement(firstTarget); 1.918 + let j = this._indexOfElement(secondTarget); 1.919 + 1.920 + // 2. Remeber the selection index, to reselect an item, if necessary. 1.921 + let selectedTarget = this._widget.selectedItem; 1.922 + let selectedIndex = -1; 1.923 + if (selectedTarget == firstTarget) { 1.924 + selectedIndex = i; 1.925 + } else if (selectedTarget == secondTarget) { 1.926 + selectedIndex = j; 1.927 + } 1.928 + 1.929 + // 3. Silently nuke both items, nobody needs to know about this. 1.930 + this._widget.removeChild(firstTarget); 1.931 + this._widget.removeChild(secondTarget); 1.932 + this._unlinkItem(aFirst); 1.933 + this._unlinkItem(aSecond); 1.934 + 1.935 + // 4. Add the items again, but reversing their indices. 1.936 + this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]); 1.937 + this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]); 1.938 + 1.939 + // 5. Restore the previous selection, if necessary. 1.940 + if (selectedIndex == i) { 1.941 + this._widget.selectedItem = aFirst._target; 1.942 + } else if (selectedIndex == j) { 1.943 + this._widget.selectedItem = aSecond._target; 1.944 + } 1.945 + 1.946 + // 6. Let the outside world know that these two items were swapped. 1.947 + ViewHelpers.dispatchEvent(aFirst.target, "swap", [aSecond, aFirst]); 1.948 + }, 1.949 + 1.950 + /** 1.951 + * Visually swaps two items in this container at specific indices. 1.952 + * 1.953 + * @param number aFirst 1.954 + * The index of the first item to be swapped. 1.955 + * @param number aSecond 1.956 + * The index of the second item to be swapped. 1.957 + */ 1.958 + swapItemsAtIndices: function(aFirst, aSecond) { 1.959 + this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond)); 1.960 + }, 1.961 + 1.962 + /** 1.963 + * Checks whether an item with the specified value is among the elements 1.964 + * shown in this container. 1.965 + * 1.966 + * @param string aValue 1.967 + * The item's value. 1.968 + * @return boolean 1.969 + * True if the value is known, false otherwise. 1.970 + */ 1.971 + containsValue: function(aValue) { 1.972 + return this._itemsByValue.has(aValue) || 1.973 + this._stagedItems.some(({ item }) => item._value == aValue); 1.974 + }, 1.975 + 1.976 + /** 1.977 + * Gets the "preferred value". This is the latest selected item's value, 1.978 + * remembered just before emptying this container. 1.979 + * @return string 1.980 + */ 1.981 + get preferredValue() { 1.982 + return this._preferredValue; 1.983 + }, 1.984 + 1.985 + /** 1.986 + * Retrieves the item associated with the selected element. 1.987 + * @return Item | null 1.988 + */ 1.989 + get selectedItem() { 1.990 + let selectedElement = this._widget.selectedItem; 1.991 + if (selectedElement) { 1.992 + return this._itemsByElement.get(selectedElement); 1.993 + } 1.994 + return null; 1.995 + }, 1.996 + 1.997 + /** 1.998 + * Retrieves the selected element's index in this container. 1.999 + * @return number 1.1000 + */ 1.1001 + get selectedIndex() { 1.1002 + let selectedElement = this._widget.selectedItem; 1.1003 + if (selectedElement) { 1.1004 + return this._indexOfElement(selectedElement); 1.1005 + } 1.1006 + return -1; 1.1007 + }, 1.1008 + 1.1009 + /** 1.1010 + * Retrieves the value of the selected element. 1.1011 + * @return string 1.1012 + */ 1.1013 + get selectedValue() { 1.1014 + let selectedElement = this._widget.selectedItem; 1.1015 + if (selectedElement) { 1.1016 + return this._itemsByElement.get(selectedElement)._value; 1.1017 + } 1.1018 + return ""; 1.1019 + }, 1.1020 + 1.1021 + /** 1.1022 + * Retrieves the attachment of the selected element. 1.1023 + * @return object | null 1.1024 + */ 1.1025 + get selectedAttachment() { 1.1026 + let selectedElement = this._widget.selectedItem; 1.1027 + if (selectedElement) { 1.1028 + return this._itemsByElement.get(selectedElement).attachment; 1.1029 + } 1.1030 + return null; 1.1031 + }, 1.1032 + 1.1033 + /** 1.1034 + * Selects the element with the entangled item in this container. 1.1035 + * @param Item | function aItem 1.1036 + */ 1.1037 + set selectedItem(aItem) { 1.1038 + // A predicate is allowed to select a specific item. 1.1039 + // If no item is matched, then the current selection is removed. 1.1040 + if (typeof aItem == "function") { 1.1041 + aItem = this.getItemForPredicate(aItem); 1.1042 + } 1.1043 + 1.1044 + // A falsy item is allowed to invalidate the current selection. 1.1045 + let targetElement = aItem ? aItem._target : null; 1.1046 + let prevElement = this._widget.selectedItem; 1.1047 + 1.1048 + // Make sure the selected item's target element is focused and visible. 1.1049 + if (this.autoFocusOnSelection && targetElement) { 1.1050 + targetElement.focus(); 1.1051 + } 1.1052 + if (this.maintainSelectionVisible && targetElement) { 1.1053 + if ("ensureElementIsVisible" in this._widget) { 1.1054 + this._widget.ensureElementIsVisible(targetElement); 1.1055 + } 1.1056 + } 1.1057 + 1.1058 + // Prevent selecting the same item again and avoid dispatching 1.1059 + // a redundant selection event, so return early. 1.1060 + if (targetElement != prevElement) { 1.1061 + this._widget.selectedItem = targetElement; 1.1062 + let dispTarget = targetElement || prevElement; 1.1063 + let dispName = this.suppressSelectionEvents ? "suppressed-select" : "select"; 1.1064 + ViewHelpers.dispatchEvent(dispTarget, dispName, aItem); 1.1065 + } 1.1066 + }, 1.1067 + 1.1068 + /** 1.1069 + * Selects the element at the specified index in this container. 1.1070 + * @param number aIndex 1.1071 + */ 1.1072 + set selectedIndex(aIndex) { 1.1073 + let targetElement = this._widget.getItemAtIndex(aIndex); 1.1074 + if (targetElement) { 1.1075 + this.selectedItem = this._itemsByElement.get(targetElement); 1.1076 + return; 1.1077 + } 1.1078 + this.selectedItem = null; 1.1079 + }, 1.1080 + 1.1081 + /** 1.1082 + * Selects the element with the specified value in this container. 1.1083 + * @param string aValue 1.1084 + */ 1.1085 + set selectedValue(aValue) { 1.1086 + this.selectedItem = this._itemsByValue.get(aValue); 1.1087 + }, 1.1088 + 1.1089 + /** 1.1090 + * Specifies if this container should try to keep the selected item visible. 1.1091 + * (For example, when new items are added the selection is brought into view). 1.1092 + */ 1.1093 + maintainSelectionVisible: true, 1.1094 + 1.1095 + /** 1.1096 + * Specifies if "select" events dispatched from the elements in this container 1.1097 + * when their respective items are selected should be suppressed or not. 1.1098 + * 1.1099 + * If this flag is set to true, then consumers of this container won't 1.1100 + * be normally notified when items are selected. 1.1101 + */ 1.1102 + suppressSelectionEvents: false, 1.1103 + 1.1104 + /** 1.1105 + * Focus this container the first time an element is inserted? 1.1106 + * 1.1107 + * If this flag is set to true, then when the first item is inserted in 1.1108 + * this container (and thus it's the only item available), its corresponding 1.1109 + * target element is focused as well. 1.1110 + */ 1.1111 + autoFocusOnFirstItem: true, 1.1112 + 1.1113 + /** 1.1114 + * Focus on selection? 1.1115 + * 1.1116 + * If this flag is set to true, then whenever an item is selected in 1.1117 + * this container (e.g. via the selectedIndex or selectedItem setters), 1.1118 + * its corresponding target element is focused as well. 1.1119 + * 1.1120 + * You can disable this flag, for example, to maintain a certain node 1.1121 + * focused but visually indicate a different selection in this container. 1.1122 + */ 1.1123 + autoFocusOnSelection: true, 1.1124 + 1.1125 + /** 1.1126 + * Focus on input (e.g. mouse click)? 1.1127 + * 1.1128 + * If this flag is set to true, then whenever an item receives user input in 1.1129 + * this container, its corresponding target element is focused as well. 1.1130 + */ 1.1131 + autoFocusOnInput: true, 1.1132 + 1.1133 + /** 1.1134 + * When focusing on input, allow right clicks? 1.1135 + * @see WidgetMethods.autoFocusOnInput 1.1136 + */ 1.1137 + allowFocusOnRightClick: false, 1.1138 + 1.1139 + /** 1.1140 + * The number of elements in this container to jump when Page Up or Page Down 1.1141 + * keys are pressed. If falsy, then the page size will be based on the 1.1142 + * number of visible items in the container. 1.1143 + */ 1.1144 + pageSize: 0, 1.1145 + 1.1146 + /** 1.1147 + * Focuses the first visible item in this container. 1.1148 + */ 1.1149 + focusFirstVisibleItem: function() { 1.1150 + this.focusItemAtDelta(-this.itemCount); 1.1151 + }, 1.1152 + 1.1153 + /** 1.1154 + * Focuses the last visible item in this container. 1.1155 + */ 1.1156 + focusLastVisibleItem: function() { 1.1157 + this.focusItemAtDelta(+this.itemCount); 1.1158 + }, 1.1159 + 1.1160 + /** 1.1161 + * Focuses the next item in this container. 1.1162 + */ 1.1163 + focusNextItem: function() { 1.1164 + this.focusItemAtDelta(+1); 1.1165 + }, 1.1166 + 1.1167 + /** 1.1168 + * Focuses the previous item in this container. 1.1169 + */ 1.1170 + focusPrevItem: function() { 1.1171 + this.focusItemAtDelta(-1); 1.1172 + }, 1.1173 + 1.1174 + /** 1.1175 + * Focuses another item in this container based on the index distance 1.1176 + * from the currently focused item. 1.1177 + * 1.1178 + * @param number aDelta 1.1179 + * A scalar specifying by how many items should the selection change. 1.1180 + */ 1.1181 + focusItemAtDelta: function(aDelta) { 1.1182 + // Make sure the currently selected item is also focused, so that the 1.1183 + // command dispatcher mechanism has a relative node to work with. 1.1184 + // If there's no selection, just select an item at a corresponding index 1.1185 + // (e.g. the first item in this container if aDelta <= 1). 1.1186 + let selectedElement = this._widget.selectedItem; 1.1187 + if (selectedElement) { 1.1188 + selectedElement.focus(); 1.1189 + } else { 1.1190 + this.selectedIndex = Math.max(0, aDelta - 1); 1.1191 + return; 1.1192 + } 1.1193 + 1.1194 + let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; 1.1195 + let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); 1.1196 + while (distance--) { 1.1197 + if (!this._focusChange(direction)) { 1.1198 + break; // Out of bounds. 1.1199 + } 1.1200 + } 1.1201 + 1.1202 + // Synchronize the selected item as being the currently focused element. 1.1203 + this.selectedItem = this.getItemForElement(this._focusedElement); 1.1204 + }, 1.1205 + 1.1206 + /** 1.1207 + * Focuses the next or previous item in this container. 1.1208 + * 1.1209 + * @param string aDirection 1.1210 + * Either "advanceFocus" or "rewindFocus". 1.1211 + * @return boolean 1.1212 + * False if the focus went out of bounds and the first or last item 1.1213 + * in this container was focused instead. 1.1214 + */ 1.1215 + _focusChange: function(aDirection) { 1.1216 + let commandDispatcher = this._commandDispatcher; 1.1217 + let prevFocusedElement = commandDispatcher.focusedElement; 1.1218 + let currFocusedElement; 1.1219 + 1.1220 + do { 1.1221 + commandDispatcher.suppressFocusScroll = true; 1.1222 + commandDispatcher[aDirection](); 1.1223 + currFocusedElement = commandDispatcher.focusedElement; 1.1224 + 1.1225 + // Make sure the newly focused item is a part of this container. If the 1.1226 + // focus goes out of bounds, revert the previously focused item. 1.1227 + if (!this.getItemForElement(currFocusedElement)) { 1.1228 + prevFocusedElement.focus(); 1.1229 + return false; 1.1230 + } 1.1231 + } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName)); 1.1232 + 1.1233 + // Focus remained within bounds. 1.1234 + return true; 1.1235 + }, 1.1236 + 1.1237 + /** 1.1238 + * Gets the command dispatcher instance associated with this container's DOM. 1.1239 + * If there are no items displayed in this container, null is returned. 1.1240 + * @return nsIDOMXULCommandDispatcher | null 1.1241 + */ 1.1242 + get _commandDispatcher() { 1.1243 + if (this._cachedCommandDispatcher) { 1.1244 + return this._cachedCommandDispatcher; 1.1245 + } 1.1246 + let someElement = this._widget.getItemAtIndex(0); 1.1247 + if (someElement) { 1.1248 + let commandDispatcher = someElement.ownerDocument.commandDispatcher; 1.1249 + return this._cachedCommandDispatcher = commandDispatcher; 1.1250 + } 1.1251 + return null; 1.1252 + }, 1.1253 + 1.1254 + /** 1.1255 + * Gets the currently focused element in this container. 1.1256 + * 1.1257 + * @return nsIDOMNode 1.1258 + * The focused element, or null if nothing is found. 1.1259 + */ 1.1260 + get _focusedElement() { 1.1261 + let commandDispatcher = this._commandDispatcher; 1.1262 + if (commandDispatcher) { 1.1263 + return commandDispatcher.focusedElement; 1.1264 + } 1.1265 + return null; 1.1266 + }, 1.1267 + 1.1268 + /** 1.1269 + * Gets the item in the container having the specified index. 1.1270 + * 1.1271 + * @param number aIndex 1.1272 + * The index used to identify the element. 1.1273 + * @return Item 1.1274 + * The matched item, or null if nothing is found. 1.1275 + */ 1.1276 + getItemAtIndex: function(aIndex) { 1.1277 + return this.getItemForElement(this._widget.getItemAtIndex(aIndex)); 1.1278 + }, 1.1279 + 1.1280 + /** 1.1281 + * Gets the item in the container having the specified value. 1.1282 + * 1.1283 + * @param string aValue 1.1284 + * The value used to identify the element. 1.1285 + * @return Item 1.1286 + * The matched item, or null if nothing is found. 1.1287 + */ 1.1288 + getItemByValue: function(aValue) { 1.1289 + return this._itemsByValue.get(aValue); 1.1290 + }, 1.1291 + 1.1292 + /** 1.1293 + * Gets the item in the container associated with the specified element. 1.1294 + * 1.1295 + * @param nsIDOMNode aElement 1.1296 + * The element used to identify the item. 1.1297 + * @param object aFlags [optional] 1.1298 + * Additional options for showing the source. Supported options: 1.1299 + * - noSiblings: if siblings shouldn't be taken into consideration 1.1300 + * when searching for the associated item. 1.1301 + * @return Item 1.1302 + * The matched item, or null if nothing is found. 1.1303 + */ 1.1304 + getItemForElement: function(aElement, aFlags = {}) { 1.1305 + while (aElement) { 1.1306 + let item = this._itemsByElement.get(aElement); 1.1307 + 1.1308 + // Also search the siblings if allowed. 1.1309 + if (!aFlags.noSiblings) { 1.1310 + item = item || 1.1311 + this._itemsByElement.get(aElement.nextElementSibling) || 1.1312 + this._itemsByElement.get(aElement.previousElementSibling); 1.1313 + } 1.1314 + if (item) { 1.1315 + return item; 1.1316 + } 1.1317 + aElement = aElement.parentNode; 1.1318 + } 1.1319 + return null; 1.1320 + }, 1.1321 + 1.1322 + /** 1.1323 + * Gets a visible item in this container validating a specified predicate. 1.1324 + * 1.1325 + * @param function aPredicate 1.1326 + * The first item which validates this predicate is returned 1.1327 + * @return Item 1.1328 + * The matched item, or null if nothing is found. 1.1329 + */ 1.1330 + getItemForPredicate: function(aPredicate, aOwner = this) { 1.1331 + // Recursively check the items in this widget for a predicate match. 1.1332 + for (let [element, item] of aOwner._itemsByElement) { 1.1333 + let match; 1.1334 + if (aPredicate(item) && !element.hidden) { 1.1335 + match = item; 1.1336 + } else { 1.1337 + match = this.getItemForPredicate(aPredicate, item); 1.1338 + } 1.1339 + if (match) { 1.1340 + return match; 1.1341 + } 1.1342 + } 1.1343 + // Also check the staged items. No need to do this recursively since 1.1344 + // they're not even appended to the view yet. 1.1345 + for (let { item } of this._stagedItems) { 1.1346 + if (aPredicate(item)) { 1.1347 + return item; 1.1348 + } 1.1349 + } 1.1350 + return null; 1.1351 + }, 1.1352 + 1.1353 + /** 1.1354 + * Shortcut function for getItemForPredicate which works on item attachments. 1.1355 + * @see getItemForPredicate 1.1356 + */ 1.1357 + getItemForAttachment: function(aPredicate, aOwner = this) { 1.1358 + return this.getItemForPredicate(e => aPredicate(e.attachment)); 1.1359 + }, 1.1360 + 1.1361 + /** 1.1362 + * Finds the index of an item in the container. 1.1363 + * 1.1364 + * @param Item aItem 1.1365 + * The item get the index for. 1.1366 + * @return number 1.1367 + * The index of the matched item, or -1 if nothing is found. 1.1368 + */ 1.1369 + indexOfItem: function(aItem) { 1.1370 + return this._indexOfElement(aItem._target); 1.1371 + }, 1.1372 + 1.1373 + /** 1.1374 + * Finds the index of an element in the container. 1.1375 + * 1.1376 + * @param nsIDOMNode aElement 1.1377 + * The element get the index for. 1.1378 + * @return number 1.1379 + * The index of the matched element, or -1 if nothing is found. 1.1380 + */ 1.1381 + _indexOfElement: function(aElement) { 1.1382 + for (let i = 0; i < this._itemsByElement.size; i++) { 1.1383 + if (this._widget.getItemAtIndex(i) == aElement) { 1.1384 + return i; 1.1385 + } 1.1386 + } 1.1387 + return -1; 1.1388 + }, 1.1389 + 1.1390 + /** 1.1391 + * Gets the total number of items in this container. 1.1392 + * @return number 1.1393 + */ 1.1394 + get itemCount() { 1.1395 + return this._itemsByElement.size; 1.1396 + }, 1.1397 + 1.1398 + /** 1.1399 + * Returns a list of items in this container, in the displayed order. 1.1400 + * @return array 1.1401 + */ 1.1402 + get items() { 1.1403 + let store = []; 1.1404 + let itemCount = this.itemCount; 1.1405 + for (let i = 0; i < itemCount; i++) { 1.1406 + store.push(this.getItemAtIndex(i)); 1.1407 + } 1.1408 + return store; 1.1409 + }, 1.1410 + 1.1411 + /** 1.1412 + * Returns a list of values in this container, in the displayed order. 1.1413 + * @return array 1.1414 + */ 1.1415 + get values() { 1.1416 + return this.items.map(e => e._value); 1.1417 + }, 1.1418 + 1.1419 + /** 1.1420 + * Returns a list of attachments in this container, in the displayed order. 1.1421 + * @return array 1.1422 + */ 1.1423 + get attachments() { 1.1424 + return this.items.map(e => e.attachment); 1.1425 + }, 1.1426 + 1.1427 + /** 1.1428 + * Returns a list of all the visible (non-hidden) items in this container, 1.1429 + * in the displayed order 1.1430 + * @return array 1.1431 + */ 1.1432 + get visibleItems() { 1.1433 + return this.items.filter(e => !e._target.hidden); 1.1434 + }, 1.1435 + 1.1436 + /** 1.1437 + * Checks if an item is unique in this container. If an item's value is an 1.1438 + * empty string, "undefined" or "null", it is considered unique. 1.1439 + * 1.1440 + * @param Item aItem 1.1441 + * The item for which to verify uniqueness. 1.1442 + * @return boolean 1.1443 + * True if the item is unique, false otherwise. 1.1444 + */ 1.1445 + isUnique: function(aItem) { 1.1446 + let value = aItem._value; 1.1447 + if (value == "" || value == "undefined" || value == "null") { 1.1448 + return true; 1.1449 + } 1.1450 + return !this._itemsByValue.has(value); 1.1451 + }, 1.1452 + 1.1453 + /** 1.1454 + * Checks if an item is eligible for this container. By default, this checks 1.1455 + * whether an item is unique and has a prebuilt target node. 1.1456 + * 1.1457 + * @param Item aItem 1.1458 + * The item for which to verify eligibility. 1.1459 + * @return boolean 1.1460 + * True if the item is eligible, false otherwise. 1.1461 + */ 1.1462 + isEligible: function(aItem) { 1.1463 + return this.isUnique(aItem) && aItem._prebuiltNode; 1.1464 + }, 1.1465 + 1.1466 + /** 1.1467 + * Finds the expected item index in this container based on the default 1.1468 + * sort predicate. 1.1469 + * 1.1470 + * @param Item aItem 1.1471 + * The item for which to get the expected index. 1.1472 + * @return number 1.1473 + * The expected item index. 1.1474 + */ 1.1475 + _findExpectedIndexFor: function(aItem) { 1.1476 + let itemCount = this.itemCount; 1.1477 + for (let i = 0; i < itemCount; i++) { 1.1478 + if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) { 1.1479 + return i; 1.1480 + } 1.1481 + } 1.1482 + return itemCount; 1.1483 + }, 1.1484 + 1.1485 + /** 1.1486 + * Immediately inserts an item in this container at the specified index. 1.1487 + * 1.1488 + * @param number aIndex 1.1489 + * The position in the container intended for this item. 1.1490 + * @param Item aItem 1.1491 + * The item describing a target element. 1.1492 + * @param object aOptions [optional] 1.1493 + * Additional options or flags supported by this operation: 1.1494 + * - attributes: a batch of attributes set to the displayed element 1.1495 + * - finalize: function when the item is untangled (removed) 1.1496 + * @return Item 1.1497 + * The item associated with the displayed element, null if rejected. 1.1498 + */ 1.1499 + _insertItemAt: function(aIndex, aItem, aOptions = {}) { 1.1500 + if (!this.isEligible(aItem)) { 1.1501 + return null; 1.1502 + } 1.1503 + 1.1504 + // Entangle the item with the newly inserted node. 1.1505 + // Make sure this is done with the value returned by insertItemAt(), 1.1506 + // to avoid storing a potential DocumentFragment. 1.1507 + let node = aItem._prebuiltNode; 1.1508 + let attachment = aItem.attachment; 1.1509 + this._entangleItem(aItem, this._widget.insertItemAt(aIndex, node, attachment)); 1.1510 + 1.1511 + // Handle any additional options after entangling the item. 1.1512 + if (!this._currentFilterPredicate(aItem)) { 1.1513 + aItem._target.hidden = true; 1.1514 + } 1.1515 + if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) { 1.1516 + aItem._target.focus(); 1.1517 + } 1.1518 + if (aOptions.attributes) { 1.1519 + aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1])); 1.1520 + } 1.1521 + if (aOptions.finalize) { 1.1522 + aItem.finalize = aOptions.finalize; 1.1523 + } 1.1524 + 1.1525 + // Hide the empty text if the selection wasn't lost. 1.1526 + this._widget.removeAttribute("emptyText"); 1.1527 + 1.1528 + // Return the item associated with the displayed element. 1.1529 + return aItem; 1.1530 + }, 1.1531 + 1.1532 + /** 1.1533 + * Entangles an item (model) with a displayed node element (view). 1.1534 + * 1.1535 + * @param Item aItem 1.1536 + * The item describing a target element. 1.1537 + * @param nsIDOMNode aElement 1.1538 + * The element displaying the item. 1.1539 + */ 1.1540 + _entangleItem: function(aItem, aElement) { 1.1541 + this._itemsByValue.set(aItem._value, aItem); 1.1542 + this._itemsByElement.set(aElement, aItem); 1.1543 + aItem._target = aElement; 1.1544 + }, 1.1545 + 1.1546 + /** 1.1547 + * Untangles an item (model) from a displayed node element (view). 1.1548 + * 1.1549 + * @param Item aItem 1.1550 + * The item describing a target element. 1.1551 + */ 1.1552 + _untangleItem: function(aItem) { 1.1553 + if (aItem.finalize) { 1.1554 + aItem.finalize(aItem); 1.1555 + } 1.1556 + for (let childItem of aItem) { 1.1557 + aItem.remove(childItem); 1.1558 + } 1.1559 + 1.1560 + this._unlinkItem(aItem); 1.1561 + aItem._target = null; 1.1562 + }, 1.1563 + 1.1564 + /** 1.1565 + * Deletes an item from the its parent's storage maps. 1.1566 + * 1.1567 + * @param Item aItem 1.1568 + * The item describing a target element. 1.1569 + */ 1.1570 + _unlinkItem: function(aItem) { 1.1571 + this._itemsByValue.delete(aItem._value); 1.1572 + this._itemsByElement.delete(aItem._target); 1.1573 + }, 1.1574 + 1.1575 + /** 1.1576 + * The keyPress event listener for this container. 1.1577 + * @param string aName 1.1578 + * @param KeyboardEvent aEvent 1.1579 + */ 1.1580 + _onWidgetKeyPress: function(aName, aEvent) { 1.1581 + // Prevent scrolling when pressing navigation keys. 1.1582 + ViewHelpers.preventScrolling(aEvent); 1.1583 + 1.1584 + switch (aEvent.keyCode) { 1.1585 + case aEvent.DOM_VK_UP: 1.1586 + case aEvent.DOM_VK_LEFT: 1.1587 + this.focusPrevItem(); 1.1588 + return; 1.1589 + case aEvent.DOM_VK_DOWN: 1.1590 + case aEvent.DOM_VK_RIGHT: 1.1591 + this.focusNextItem(); 1.1592 + return; 1.1593 + case aEvent.DOM_VK_PAGE_UP: 1.1594 + this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); 1.1595 + return; 1.1596 + case aEvent.DOM_VK_PAGE_DOWN: 1.1597 + this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); 1.1598 + return; 1.1599 + case aEvent.DOM_VK_HOME: 1.1600 + this.focusFirstVisibleItem(); 1.1601 + return; 1.1602 + case aEvent.DOM_VK_END: 1.1603 + this.focusLastVisibleItem(); 1.1604 + return; 1.1605 + } 1.1606 + }, 1.1607 + 1.1608 + /** 1.1609 + * The mousePress event listener for this container. 1.1610 + * @param string aName 1.1611 + * @param MouseEvent aEvent 1.1612 + */ 1.1613 + _onWidgetMousePress: function(aName, aEvent) { 1.1614 + if (aEvent.button != 0 && !this.allowFocusOnRightClick) { 1.1615 + // Only allow left-click to trigger this event. 1.1616 + return; 1.1617 + } 1.1618 + 1.1619 + let item = this.getItemForElement(aEvent.target); 1.1620 + if (item) { 1.1621 + // The container is not empty and we clicked on an actual item. 1.1622 + this.selectedItem = item; 1.1623 + // Make sure the current event's target element is also focused. 1.1624 + this.autoFocusOnInput && item._target.focus(); 1.1625 + } 1.1626 + }, 1.1627 + 1.1628 + /** 1.1629 + * The predicate used when filtering items. By default, all items in this 1.1630 + * view are visible. 1.1631 + * 1.1632 + * @param Item aItem 1.1633 + * The item passing through the filter. 1.1634 + * @return boolean 1.1635 + * True if the item should be visible, false otherwise. 1.1636 + */ 1.1637 + _currentFilterPredicate: function(aItem) { 1.1638 + return true; 1.1639 + }, 1.1640 + 1.1641 + /** 1.1642 + * The predicate used when sorting items. By default, items in this view 1.1643 + * are sorted by their label. 1.1644 + * 1.1645 + * @param Item aFirst 1.1646 + * The first item used in the comparison. 1.1647 + * @param Item aSecond 1.1648 + * The second item used in the comparison. 1.1649 + * @return number 1.1650 + * -1 to sort aFirst to a lower index than aSecond 1.1651 + * 0 to leave aFirst and aSecond unchanged with respect to each other 1.1652 + * 1 to sort aSecond to a lower index than aFirst 1.1653 + */ 1.1654 + _currentSortPredicate: function(aFirst, aSecond) { 1.1655 + return +(aFirst._value.toLowerCase() > aSecond._value.toLowerCase()); 1.1656 + }, 1.1657 + 1.1658 + /** 1.1659 + * Call a method on this widget named `aMethodName`. Any further arguments are 1.1660 + * passed on to the method. Returns the result of the method call. 1.1661 + * 1.1662 + * @param String aMethodName 1.1663 + * The name of the method you want to call. 1.1664 + * @param aArgs 1.1665 + * Optional. Any arguments you want to pass through to the method. 1.1666 + */ 1.1667 + callMethod: function(aMethodName, ...aArgs) { 1.1668 + return this._widget[aMethodName].apply(this._widget, aArgs); 1.1669 + }, 1.1670 + 1.1671 + _widget: null, 1.1672 + _emptyText: "", 1.1673 + _headerText: "", 1.1674 + _preferredValue: "", 1.1675 + _cachedCommandDispatcher: null 1.1676 +}; 1.1677 + 1.1678 +/** 1.1679 + * A generator-iterator over all the items in this container. 1.1680 + */ 1.1681 +Item.prototype["@@iterator"] = 1.1682 +WidgetMethods["@@iterator"] = function*() { 1.1683 + yield* this._itemsByElement.values(); 1.1684 +};