mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-02 07:05:24 +00:00
efe328f1b2
In a following patch, all DevTools moz.build files will use DevToolsModules to install JS modules at a path that corresponds directly to their source tree location. Here we rewrite all require and import calls to match the new location that these files are installed to. --HG-- extra : commitid : F2ItGm8ptRz extra : rebase_source : b082fe4bf77e22e297e303fc601165ceff1c4cbc
1818 lines
56 KiB
JavaScript
1818 lines
56 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
"use strict";
|
|
|
|
const Cc = Components.classes;
|
|
const Ci = Components.interfaces;
|
|
const Cu = Components.utils;
|
|
|
|
const PANE_APPEARANCE_DELAY = 50;
|
|
const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
|
|
const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]);
|
|
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
Cu.import("resource://gre/modules/Timer.jsm");
|
|
const { require } = Cu.import("resource://gre/modules/devtools/shared/Loader.jsm", {});
|
|
const DevToolsUtils = require("devtools/shared/DevToolsUtils");
|
|
Cu.import("resource://gre/modules/devtools/shared/event-emitter.js");
|
|
|
|
this.EXPORTED_SYMBOLS = [
|
|
"Heritage", "ViewHelpers", "WidgetMethods",
|
|
"setNamedTimeout", "clearNamedTimeout",
|
|
"setConditionalTimeout", "clearConditionalTimeout",
|
|
];
|
|
|
|
/**
|
|
* Inheritance helpers from the addon SDK's core/heritage.
|
|
* Remove these when all devtools are loadered.
|
|
*/
|
|
this.Heritage = {
|
|
/**
|
|
* @see extend in sdk/core/heritage.
|
|
*/
|
|
extend: function(aPrototype, aProperties = {}) {
|
|
return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties));
|
|
},
|
|
|
|
/**
|
|
* @see getOwnPropertyDescriptors in sdk/core/heritage.
|
|
*/
|
|
getOwnPropertyDescriptors: function(aObject) {
|
|
return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => {
|
|
aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName);
|
|
return aDescriptor;
|
|
}, {});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Helper for draining a rapid succession of events and invoking a callback
|
|
* once everything settles down.
|
|
*
|
|
* @param string aId
|
|
* A string identifier for the named timeout.
|
|
* @param number aWait
|
|
* The amount of milliseconds to wait after no more events are fired.
|
|
* @param function aCallback
|
|
* Invoked when no more events are fired after the specified time.
|
|
*/
|
|
this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) {
|
|
clearNamedTimeout(aId);
|
|
|
|
namedTimeoutsStore.set(aId, setTimeout(() =>
|
|
namedTimeoutsStore.delete(aId) && aCallback(), aWait));
|
|
};
|
|
|
|
/**
|
|
* Clears a named timeout.
|
|
* @see setNamedTimeout
|
|
*
|
|
* @param string aId
|
|
* A string identifier for the named timeout.
|
|
*/
|
|
this.clearNamedTimeout = function clearNamedTimeout(aId) {
|
|
if (!namedTimeoutsStore) {
|
|
return;
|
|
}
|
|
clearTimeout(namedTimeoutsStore.get(aId));
|
|
namedTimeoutsStore.delete(aId);
|
|
};
|
|
|
|
/**
|
|
* Same as `setNamedTimeout`, but invokes the callback only if the provided
|
|
* predicate function returns true. Otherwise, the timeout is re-triggered.
|
|
*
|
|
* @param string aId
|
|
* A string identifier for the conditional timeout.
|
|
* @param number aWait
|
|
* The amount of milliseconds to wait after no more events are fired.
|
|
* @param function aPredicate
|
|
* The predicate function used to determine whether the timeout restarts.
|
|
* @param function aCallback
|
|
* Invoked when no more events are fired after the specified time, and
|
|
* the provided predicate function returns true.
|
|
*/
|
|
this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) {
|
|
setNamedTimeout(aId, aWait, function maybeCallback() {
|
|
if (aPredicate()) {
|
|
aCallback();
|
|
return;
|
|
}
|
|
setConditionalTimeout(aId, aWait, aPredicate, aCallback);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Clears a conditional timeout.
|
|
* @see setConditionalTimeout
|
|
*
|
|
* @param string aId
|
|
* A string identifier for the conditional timeout.
|
|
*/
|
|
this.clearConditionalTimeout = function clearConditionalTimeout(aId) {
|
|
clearNamedTimeout(aId);
|
|
};
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map());
|
|
|
|
/**
|
|
* Helpers for creating and messaging between UI components.
|
|
*/
|
|
this.ViewHelpers = {
|
|
/**
|
|
* Convenience method, dispatching a custom event.
|
|
*
|
|
* @param nsIDOMNode aTarget
|
|
* A custom target element to dispatch the event from.
|
|
* @param string aType
|
|
* The name of the event.
|
|
* @param any aDetail
|
|
* The data passed when initializing the event.
|
|
* @return boolean
|
|
* True if the event was cancelled or a registered handler
|
|
* called preventDefault.
|
|
*/
|
|
dispatchEvent: function(aTarget, aType, aDetail) {
|
|
if (!(aTarget instanceof Ci.nsIDOMNode)) {
|
|
return true; // Event cancelled.
|
|
}
|
|
let document = aTarget.ownerDocument || aTarget;
|
|
let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement;
|
|
|
|
let event = document.createEvent("CustomEvent");
|
|
event.initCustomEvent(aType, true, true, aDetail);
|
|
return dispatcher.dispatchEvent(event);
|
|
},
|
|
|
|
/**
|
|
* Helper delegating some of the DOM attribute methods of a node to a widget.
|
|
*
|
|
* @param object aWidget
|
|
* The widget to assign the methods to.
|
|
* @param nsIDOMNode aNode
|
|
* A node to delegate the methods to.
|
|
*/
|
|
delegateWidgetAttributeMethods: function(aWidget, aNode) {
|
|
aWidget.getAttribute =
|
|
aWidget.getAttribute || aNode.getAttribute.bind(aNode);
|
|
aWidget.setAttribute =
|
|
aWidget.setAttribute || aNode.setAttribute.bind(aNode);
|
|
aWidget.removeAttribute =
|
|
aWidget.removeAttribute || aNode.removeAttribute.bind(aNode);
|
|
},
|
|
|
|
/**
|
|
* Helper delegating some of the DOM event methods of a node to a widget.
|
|
*
|
|
* @param object aWidget
|
|
* The widget to assign the methods to.
|
|
* @param nsIDOMNode aNode
|
|
* A node to delegate the methods to.
|
|
*/
|
|
delegateWidgetEventMethods: function(aWidget, aNode) {
|
|
aWidget.addEventListener =
|
|
aWidget.addEventListener || aNode.addEventListener.bind(aNode);
|
|
aWidget.removeEventListener =
|
|
aWidget.removeEventListener || aNode.removeEventListener.bind(aNode);
|
|
},
|
|
|
|
/**
|
|
* Checks if the specified object looks like it's been decorated by an
|
|
* event emitter.
|
|
*
|
|
* @return boolean
|
|
* True if it looks, walks and quacks like an event emitter.
|
|
*/
|
|
isEventEmitter: function(aObject) {
|
|
return aObject && aObject.on && aObject.off && aObject.once && aObject.emit;
|
|
},
|
|
|
|
/**
|
|
* Checks if the specified object is an instance of a DOM node.
|
|
*
|
|
* @return boolean
|
|
* True if it's a node, false otherwise.
|
|
*/
|
|
isNode: function(aObject) {
|
|
return aObject instanceof Ci.nsIDOMNode ||
|
|
aObject instanceof Ci.nsIDOMElement ||
|
|
aObject instanceof Ci.nsIDOMDocumentFragment;
|
|
},
|
|
|
|
/**
|
|
* Prevents event propagation when navigation keys are pressed.
|
|
*
|
|
* @param Event e
|
|
* The event to be prevented.
|
|
*/
|
|
preventScrolling: function(e) {
|
|
switch (e.keyCode) {
|
|
case e.DOM_VK_UP:
|
|
case e.DOM_VK_DOWN:
|
|
case e.DOM_VK_LEFT:
|
|
case e.DOM_VK_RIGHT:
|
|
case e.DOM_VK_PAGE_UP:
|
|
case e.DOM_VK_PAGE_DOWN:
|
|
case e.DOM_VK_HOME:
|
|
case e.DOM_VK_END:
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sets a side pane hidden or visible.
|
|
*
|
|
* @param object aFlags
|
|
* An object containing some of the following properties:
|
|
* - visible: true if the pane should be shown, false to hide
|
|
* - animated: true to display an animation on toggle
|
|
* - delayed: true to wait a few cycles before toggle
|
|
* - callback: a function to invoke when the toggle finishes
|
|
* @param nsIDOMNode aPane
|
|
* The element representing the pane to toggle.
|
|
*/
|
|
togglePane: function(aFlags, aPane) {
|
|
// Make sure a pane is actually available first.
|
|
if (!aPane) {
|
|
return;
|
|
}
|
|
|
|
// Hiding is always handled via margins, not the hidden attribute.
|
|
aPane.removeAttribute("hidden");
|
|
|
|
// Add a class to the pane to handle min-widths, margins and animations.
|
|
if (!aPane.classList.contains("generic-toggled-side-pane")) {
|
|
aPane.classList.add("generic-toggled-side-pane");
|
|
}
|
|
|
|
// Avoid useless toggles.
|
|
if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) {
|
|
if (aFlags.callback) aFlags.callback();
|
|
return;
|
|
}
|
|
|
|
// The "animated" attributes enables animated toggles (slide in-out).
|
|
if (aFlags.animated) {
|
|
aPane.setAttribute("animated", "");
|
|
} else {
|
|
aPane.removeAttribute("animated");
|
|
}
|
|
|
|
// Computes and sets the pane margins in order to hide or show it.
|
|
let doToggle = () => {
|
|
if (aFlags.visible) {
|
|
aPane.style.marginLeft = "0";
|
|
aPane.style.marginRight = "0";
|
|
aPane.removeAttribute("pane-collapsed");
|
|
} else {
|
|
let margin = ~~(aPane.getAttribute("width")) + 1;
|
|
aPane.style.marginLeft = -margin + "px";
|
|
aPane.style.marginRight = -margin + "px";
|
|
aPane.setAttribute("pane-collapsed", "");
|
|
}
|
|
|
|
// Invoke the callback when the transition ended.
|
|
if (aFlags.animated) {
|
|
aPane.addEventListener("transitionend", function onEvent() {
|
|
aPane.removeEventListener("transitionend", onEvent, false);
|
|
if (aFlags.callback) aFlags.callback();
|
|
}, false);
|
|
}
|
|
// Invoke the callback immediately since there's no transition.
|
|
else {
|
|
if (aFlags.callback) aFlags.callback();
|
|
}
|
|
}
|
|
|
|
// Sometimes it's useful delaying the toggle a few ticks to ensure
|
|
// a smoother slide in-out animation.
|
|
if (aFlags.delayed) {
|
|
aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY);
|
|
} else {
|
|
doToggle();
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Localization convenience methods.
|
|
*
|
|
* @param string aStringBundleName
|
|
* The desired string bundle's name.
|
|
*/
|
|
ViewHelpers.L10N = function(aStringBundleName) {
|
|
XPCOMUtils.defineLazyGetter(this, "stringBundle", () =>
|
|
Services.strings.createBundle(aStringBundleName));
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "ellipsis", () =>
|
|
Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data);
|
|
};
|
|
|
|
ViewHelpers.L10N.prototype = {
|
|
stringBundle: null,
|
|
|
|
/**
|
|
* L10N shortcut function.
|
|
*
|
|
* @param string aName
|
|
* @return string
|
|
*/
|
|
getStr: function(aName) {
|
|
return this.stringBundle.GetStringFromName(aName);
|
|
},
|
|
|
|
/**
|
|
* L10N shortcut function.
|
|
*
|
|
* @param string aName
|
|
* @param array aArgs
|
|
* @return string
|
|
*/
|
|
getFormatStr: function(aName, ...aArgs) {
|
|
return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length);
|
|
},
|
|
|
|
/**
|
|
* L10N shortcut function for numeric arguments that need to be formatted.
|
|
* All numeric arguments will be fixed to 2 decimals and given a localized
|
|
* decimal separator. Other arguments will be left alone.
|
|
*
|
|
* @param string aName
|
|
* @param array aArgs
|
|
* @return string
|
|
*/
|
|
getFormatStrWithNumbers: function(aName, ...aArgs) {
|
|
let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x);
|
|
return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length);
|
|
},
|
|
|
|
/**
|
|
* Converts a number to a locale-aware string format and keeps a certain
|
|
* number of decimals.
|
|
*
|
|
* @param number aNumber
|
|
* The number to convert.
|
|
* @param number aDecimals [optional]
|
|
* Total decimals to keep.
|
|
* @return string
|
|
* The localized number as a string.
|
|
*/
|
|
numberWithDecimals: function(aNumber, aDecimals = 0) {
|
|
// If this is an integer, don't do anything special.
|
|
if (aNumber == (aNumber | 0)) {
|
|
return aNumber;
|
|
}
|
|
if (isNaN(aNumber) || aNumber == null) {
|
|
return "0";
|
|
}
|
|
let localized = aNumber.toLocaleString(); // localize
|
|
|
|
// If no grouping or decimal separators are available, bail out, because
|
|
// padding with zeros at the end of the string won't make sense anymore.
|
|
if (!localized.match(/[^\d]/)) {
|
|
return localized;
|
|
}
|
|
|
|
return aNumber.toLocaleString(undefined, {
|
|
maximumFractionDigits: aDecimals,
|
|
minimumFractionDigits: aDecimals
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A helper for having the same interface as ViewHelpers.L10N, but for
|
|
* more than one file. Useful for abstracting l10n string locations.
|
|
*/
|
|
ViewHelpers.MultiL10N = function(aStringBundleNames) {
|
|
let l10ns = aStringBundleNames.map(bundle => new ViewHelpers.L10N(bundle));
|
|
let proto = ViewHelpers.L10N.prototype;
|
|
|
|
Object.getOwnPropertyNames(proto)
|
|
.map(name => ({
|
|
name: name,
|
|
desc: Object.getOwnPropertyDescriptor(proto, name)
|
|
}))
|
|
.filter(property => property.desc.value instanceof Function)
|
|
.forEach(method => {
|
|
this[method.name] = function(...args) {
|
|
for (let l10n of l10ns) {
|
|
try { return method.desc.value.apply(l10n, args) } catch (e) {}
|
|
}
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Shortcuts for lazily accessing and setting various preferences.
|
|
* Usage:
|
|
* let prefs = new ViewHelpers.Prefs("root.path.to.branch", {
|
|
* myIntPref: ["Int", "leaf.path.to.my-int-pref"],
|
|
* myCharPref: ["Char", "leaf.path.to.my-char-pref"],
|
|
* myJsonPref: ["Json", "leaf.path.to.my-json-pref"],
|
|
* myFloatPref: ["Float", "leaf.path.to.my-float-pref"]
|
|
* ...
|
|
* });
|
|
*
|
|
* Get/set:
|
|
* prefs.myCharPref = "foo";
|
|
* let aux = prefs.myCharPref;
|
|
*
|
|
* Observe:
|
|
* prefs.registerObserver();
|
|
* prefs.on("pref-changed", (prefName, prefValue) => {
|
|
* ...
|
|
* });
|
|
*
|
|
* @param string aPrefsRoot
|
|
* The root path to the required preferences branch.
|
|
* @param object aPrefsBlueprint
|
|
* An object containing { accessorName: [prefType, prefName] } keys.
|
|
* @param object aOptions
|
|
* Additional options for this constructor. Currently supported:
|
|
* - monitorChanges: true to update the stored values if they changed
|
|
* when somebody edits about:config or the prefs
|
|
* change somewhere else.
|
|
*/
|
|
ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsBlueprint = {}, aOptions = {}) {
|
|
EventEmitter.decorate(this);
|
|
|
|
this._cache = new Map();
|
|
let self = this;
|
|
|
|
for (let [accessorName, [prefType, prefName]] of Iterator(aPrefsBlueprint)) {
|
|
this._map(accessorName, prefType, aPrefsRoot, prefName);
|
|
}
|
|
|
|
let observer = {
|
|
register: function() {
|
|
this.branch = Services.prefs.getBranch(aPrefsRoot + ".");
|
|
this.branch.addObserver("", this, false);
|
|
},
|
|
unregister: function() {
|
|
this.branch.removeObserver("", this);
|
|
},
|
|
observe: function(_, __, aPrefName) {
|
|
// If this particular pref isn't handled by the blueprint object,
|
|
// even though it's in the specified branch, ignore it.
|
|
let accessor = self._accessor(aPrefsBlueprint, aPrefName);
|
|
if (!(accessor in self)) {
|
|
return;
|
|
}
|
|
self._cache.delete(aPrefName);
|
|
self.emit("pref-changed", accessor, self[accessor]);
|
|
}
|
|
};
|
|
|
|
this.registerObserver = () => observer.register();
|
|
this.unregisterObserver = () => observer.unregister();
|
|
|
|
if (aOptions.monitorChanges) {
|
|
this.registerObserver();
|
|
}
|
|
};
|
|
|
|
ViewHelpers.Prefs.prototype = {
|
|
/**
|
|
* Helper method for getting a pref value.
|
|
*
|
|
* @param string aType
|
|
* @param string aPrefsRoot
|
|
* @param string aPrefName
|
|
* @return any
|
|
*/
|
|
_get: function(aType, aPrefsRoot, aPrefName) {
|
|
let cachedPref = this._cache.get(aPrefName);
|
|
if (cachedPref !== undefined) {
|
|
return cachedPref;
|
|
}
|
|
let value = Services.prefs["get" + aType + "Pref"]([aPrefsRoot, aPrefName].join("."));
|
|
this._cache.set(aPrefName, value);
|
|
return value;
|
|
},
|
|
|
|
/**
|
|
* Helper method for setting a pref value.
|
|
*
|
|
* @param string aType
|
|
* @param string aPrefsRoot
|
|
* @param string aPrefName
|
|
* @param any aValue
|
|
*/
|
|
_set: function(aType, aPrefsRoot, aPrefName, aValue) {
|
|
Services.prefs["set" + aType + "Pref"]([aPrefsRoot, aPrefName].join("."), aValue);
|
|
this._cache.set(aPrefName, aValue);
|
|
},
|
|
|
|
/**
|
|
* Maps a property name to a pref, defining lazy getters and setters.
|
|
* Supported types are "Bool", "Char", "Int", "Float" (sugar around "Char" type and casting),
|
|
* and "Json" (which is basically just sugar for "Char" using the standard JSON serializer).
|
|
*
|
|
* @param string aAccessorName
|
|
* @param string aType
|
|
* @param string aPrefsRoot
|
|
* @param string aPrefName
|
|
* @param array aSerializer
|
|
*/
|
|
_map: function(aAccessorName, aType, aPrefsRoot, aPrefName, aSerializer = { in: e => e, out: e => e }) {
|
|
if (aPrefName in this) {
|
|
throw new Error(`Can't use ${aPrefName} because it's already a property.`);
|
|
}
|
|
if (aType == "Json") {
|
|
this._map(aAccessorName, "Char", aPrefsRoot, aPrefName, { in: JSON.parse, out: JSON.stringify });
|
|
return;
|
|
}
|
|
if (aType == "Float") {
|
|
this._map(aAccessorName, "Char", aPrefsRoot, aPrefName, { in: Number.parseFloat, out: (n) => n + ""});
|
|
return;
|
|
}
|
|
|
|
Object.defineProperty(this, aAccessorName, {
|
|
get: () => aSerializer.in(this._get(aType, aPrefsRoot, aPrefName)),
|
|
set: (e) => this._set(aType, aPrefsRoot, aPrefName, aSerializer.out(e))
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Finds the accessor in this object for the provided property name,
|
|
* based on the blueprint object used in the constructor.
|
|
*/
|
|
_accessor: function(aPrefsBlueprint, aPrefName) {
|
|
for (let [accessorName, [, prefName]] of Iterator(aPrefsBlueprint)) {
|
|
if (prefName == aPrefName) {
|
|
return accessorName;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A generic Item is used to describe children present in a Widget.
|
|
*
|
|
* This is basically a very thin wrapper around an nsIDOMNode, with a few
|
|
* characteristics, like a `value` and an `attachment`.
|
|
*
|
|
* The characteristics are optional, and their meaning is entirely up to you.
|
|
* - The `value` should be a string, passed as an argument.
|
|
* - The `attachment` is any kind of primitive or object, passed as an argument.
|
|
*
|
|
* Iterable via "for (let childItem of parentItem) { }".
|
|
*
|
|
* @param object aOwnerView
|
|
* The owner view creating this item.
|
|
* @param nsIDOMNode aElement
|
|
* A prebuilt node to be wrapped.
|
|
* @param string aValue
|
|
* A string identifying the node.
|
|
* @param any aAttachment
|
|
* Some attached primitive/object.
|
|
*/
|
|
function Item(aOwnerView, aElement, aValue, aAttachment) {
|
|
this.ownerView = aOwnerView;
|
|
this.attachment = aAttachment;
|
|
this._value = aValue + "";
|
|
this._prebuiltNode = aElement;
|
|
};
|
|
|
|
Item.prototype = {
|
|
get value() { return this._value; },
|
|
get target() { return this._target; },
|
|
get prebuiltNode() { return this._prebuiltNode; },
|
|
|
|
/**
|
|
* Immediately appends a child item to this item.
|
|
*
|
|
* @param nsIDOMNode aElement
|
|
* An nsIDOMNode representing the child element to append.
|
|
* @param object aOptions [optional]
|
|
* Additional options or flags supported by this operation:
|
|
* - attachment: some attached primitive/object for the item
|
|
* - attributes: a batch of attributes set to the displayed element
|
|
* - finalize: function invoked when the child item is removed
|
|
* @return Item
|
|
* The item associated with the displayed element.
|
|
*/
|
|
append: function(aElement, aOptions = {}) {
|
|
let item = new Item(this, aElement, "", aOptions.attachment);
|
|
|
|
// Entangle the item with the newly inserted child node.
|
|
// Make sure this is done with the value returned by appendChild(),
|
|
// to avoid storing a potential DocumentFragment.
|
|
this._entangleItem(item, this._target.appendChild(aElement));
|
|
|
|
// Handle any additional options after entangling the item.
|
|
if (aOptions.attributes) {
|
|
aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
|
|
}
|
|
if (aOptions.finalize) {
|
|
item.finalize = aOptions.finalize;
|
|
}
|
|
|
|
// Return the item associated with the displayed element.
|
|
return item;
|
|
},
|
|
|
|
/**
|
|
* Immediately removes the specified child item from this item.
|
|
*
|
|
* @param Item aItem
|
|
* The item associated with the element to remove.
|
|
*/
|
|
remove: function(aItem) {
|
|
if (!aItem) {
|
|
return;
|
|
}
|
|
this._target.removeChild(aItem._target);
|
|
this._untangleItem(aItem);
|
|
},
|
|
|
|
/**
|
|
* Entangles an item (model) with a displayed node element (view).
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
* @param nsIDOMNode aElement
|
|
* The element displaying the item.
|
|
*/
|
|
_entangleItem: function(aItem, aElement) {
|
|
this._itemsByElement.set(aElement, aItem);
|
|
aItem._target = aElement;
|
|
},
|
|
|
|
/**
|
|
* Untangles an item (model) from a displayed node element (view).
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
*/
|
|
_untangleItem: function(aItem) {
|
|
if (aItem.finalize) {
|
|
aItem.finalize(aItem);
|
|
}
|
|
for (let childItem of aItem) {
|
|
aItem.remove(childItem);
|
|
}
|
|
|
|
this._unlinkItem(aItem);
|
|
aItem._target = null;
|
|
},
|
|
|
|
/**
|
|
* Deletes an item from the its parent's storage maps.
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
*/
|
|
_unlinkItem: function(aItem) {
|
|
this._itemsByElement.delete(aItem._target);
|
|
},
|
|
|
|
/**
|
|
* Returns a string representing the object.
|
|
* Avoid using `toString` to avoid accidental JSONification.
|
|
* @return string
|
|
*/
|
|
stringify: function() {
|
|
return JSON.stringify({
|
|
value: this._value,
|
|
target: this._target + "",
|
|
prebuiltNode: this._prebuiltNode + "",
|
|
attachment: this.attachment
|
|
}, null, 2);
|
|
},
|
|
|
|
_value: "",
|
|
_target: null,
|
|
_prebuiltNode: null,
|
|
finalize: null,
|
|
attachment: null
|
|
};
|
|
|
|
// Creating maps thousands of times for widgets with a large number of children
|
|
// fills up a lot of memory. Make sure these are instantiated only if needed.
|
|
DevToolsUtils.defineLazyPrototypeGetter(Item.prototype, "_itemsByElement", () => new Map());
|
|
|
|
/**
|
|
* Some generic Widget methods handling Item instances.
|
|
* Iterable via "for (let childItem of wrappedView) { }".
|
|
*
|
|
* Usage:
|
|
* function MyView() {
|
|
* this.widget = new MyWidget(document.querySelector(".my-node"));
|
|
* }
|
|
*
|
|
* MyView.prototype = Heritage.extend(WidgetMethods, {
|
|
* myMethod: function() {},
|
|
* ...
|
|
* });
|
|
*
|
|
* See https://gist.github.com/victorporof/5749386 for more details.
|
|
* The devtools/shared/widgets/SimpleListWidget.jsm is an implementation example.
|
|
*
|
|
* Language:
|
|
* - An "item" is an instance of an Item.
|
|
* - An "element" or "node" is a nsIDOMNode.
|
|
*
|
|
* The supplied widget can be any object implementing the following methods:
|
|
* - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, aValue:string)
|
|
* - function:nsIDOMNode getItemAtIndex(aIndex:number)
|
|
* - function removeChild(aChild:nsIDOMNode)
|
|
* - function removeAllItems()
|
|
* - get:nsIDOMNode selectedItem()
|
|
* - set selectedItem(aChild:nsIDOMNode)
|
|
* - function getAttribute(aName:string)
|
|
* - function setAttribute(aName:string, aValue:string)
|
|
* - function removeAttribute(aName:string)
|
|
* - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
|
|
* - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
|
|
*
|
|
* Optional methods that can be implemented by the widget:
|
|
* - function ensureElementIsVisible(aChild:nsIDOMNode)
|
|
*
|
|
* Optional attributes that may be handled (when calling get/set/removeAttribute):
|
|
* - "emptyText": label temporarily added when there are no items present
|
|
* - "headerText": label permanently added as a header
|
|
*
|
|
* For automagical keyboard and mouse accessibility, the widget should be an
|
|
* event emitter with the following events:
|
|
* - "keyPress" -> (aName:string, aEvent:KeyboardEvent)
|
|
* - "mousePress" -> (aName:string, aEvent:MouseEvent)
|
|
*/
|
|
this.WidgetMethods = {
|
|
/**
|
|
* Sets the element node or widget associated with this container.
|
|
* @param nsIDOMNode | object aWidget
|
|
*/
|
|
set widget(aWidget) {
|
|
this._widget = aWidget;
|
|
|
|
|
|
// Can't use a WeakMap for _itemsByValue because keys are strings, and
|
|
// can't use one for _itemsByElement either, since it needs to be iterable.
|
|
XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map());
|
|
XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map());
|
|
XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []);
|
|
|
|
// Handle internal events emitted by the widget if necessary.
|
|
if (ViewHelpers.isEventEmitter(aWidget)) {
|
|
aWidget.on("keyPress", this._onWidgetKeyPress.bind(this));
|
|
aWidget.on("mousePress", this._onWidgetMousePress.bind(this));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Gets the element node or widget associated with this container.
|
|
* @return nsIDOMNode | object
|
|
*/
|
|
get widget() {
|
|
return this._widget;
|
|
},
|
|
|
|
/**
|
|
* Prepares an item to be added to this container. This allows, for example,
|
|
* for a large number of items to be batched up before being sorted & added.
|
|
*
|
|
* If the "staged" flag is *not* set to true, the item will be immediately
|
|
* inserted at the correct position in this container, so that all the items
|
|
* still remain sorted. This can (possibly) be much slower than batching up
|
|
* multiple items.
|
|
*
|
|
* By default, this container assumes that all the items should be displayed
|
|
* sorted by their value. This can be overridden with the "index" flag,
|
|
* specifying on which position should an item be appended. The "staged" and
|
|
* "index" flags are mutually exclusive, meaning that all staged items
|
|
* will always be appended.
|
|
*
|
|
* @param nsIDOMNode aElement
|
|
* A prebuilt node to be wrapped.
|
|
* @param string aValue
|
|
* A string identifying the node.
|
|
* @param object aOptions [optional]
|
|
* Additional options or flags supported by this operation:
|
|
* - attachment: some attached primitive/object for the item
|
|
* - staged: true to stage the item to be appended later
|
|
* - index: specifies on which position should the item be appended
|
|
* - attributes: a batch of attributes set to the displayed element
|
|
* - finalize: function invoked when the item is removed
|
|
* @return Item
|
|
* The item associated with the displayed element if an unstaged push,
|
|
* undefined if the item was staged for a later commit.
|
|
*/
|
|
push: function([aElement, aValue], aOptions = {}) {
|
|
let item = new Item(this, aElement, aValue, aOptions.attachment);
|
|
|
|
// Batch the item to be added later.
|
|
if (aOptions.staged) {
|
|
// An ulterior commit operation will ignore any specified index, so
|
|
// no reason to keep it around.
|
|
aOptions.index = undefined;
|
|
return void this._stagedItems.push({ item: item, options: aOptions });
|
|
}
|
|
// Find the target position in this container and insert the item there.
|
|
if (!("index" in aOptions)) {
|
|
return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions);
|
|
}
|
|
// Insert the item at the specified index. If negative or out of bounds,
|
|
// the item will be simply appended.
|
|
return this._insertItemAt(aOptions.index, item, aOptions);
|
|
},
|
|
|
|
/**
|
|
* Flushes all the prepared items into this container.
|
|
* Any specified index on the items will be ignored. Everything is appended.
|
|
*
|
|
* @param object aOptions [optional]
|
|
* Additional options or flags supported by this operation:
|
|
* - sorted: true to sort all the items before adding them
|
|
*/
|
|
commit: function(aOptions = {}) {
|
|
let stagedItems = this._stagedItems;
|
|
|
|
// Sort the items before adding them to this container, if preferred.
|
|
if (aOptions.sorted) {
|
|
stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item));
|
|
}
|
|
// Append the prepared items to this container.
|
|
for (let { item, options } of stagedItems) {
|
|
this._insertItemAt(-1, item, options);
|
|
}
|
|
// Recreate the temporary items list for ulterior pushes.
|
|
this._stagedItems.length = 0;
|
|
},
|
|
|
|
/**
|
|
* Immediately removes the specified item from this container.
|
|
*
|
|
* @param Item aItem
|
|
* The item associated with the element to remove.
|
|
*/
|
|
remove: function(aItem) {
|
|
if (!aItem) {
|
|
return;
|
|
}
|
|
this._widget.removeChild(aItem._target);
|
|
this._untangleItem(aItem);
|
|
|
|
if (!this._itemsByElement.size) {
|
|
this._preferredValue = this.selectedValue;
|
|
this._widget.selectedItem = null;
|
|
this._widget.setAttribute("emptyText", this._emptyText);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Removes the item at the specified index from this container.
|
|
*
|
|
* @param number aIndex
|
|
* The index of the item to remove.
|
|
*/
|
|
removeAt: function(aIndex) {
|
|
this.remove(this.getItemAtIndex(aIndex));
|
|
},
|
|
|
|
/**
|
|
* Removes the items in this container based on a predicate.
|
|
*/
|
|
removeForPredicate: function(aPredicate) {
|
|
let item;
|
|
while ((item = this.getItemForPredicate(aPredicate))) {
|
|
this.remove(item);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Removes all items from this container.
|
|
*/
|
|
empty: function() {
|
|
this._preferredValue = this.selectedValue;
|
|
this._widget.selectedItem = null;
|
|
this._widget.removeAllItems();
|
|
this._widget.setAttribute("emptyText", this._emptyText);
|
|
|
|
for (let [, item] of this._itemsByElement) {
|
|
this._untangleItem(item);
|
|
}
|
|
|
|
this._itemsByValue.clear();
|
|
this._itemsByElement.clear();
|
|
this._stagedItems.length = 0;
|
|
},
|
|
|
|
/**
|
|
* Ensures the specified item is visible in this container.
|
|
*
|
|
* @param Item aItem
|
|
* The item to bring into view.
|
|
*/
|
|
ensureItemIsVisible: function(aItem) {
|
|
this._widget.ensureElementIsVisible(aItem._target);
|
|
},
|
|
|
|
/**
|
|
* Ensures the item at the specified index is visible in this container.
|
|
*
|
|
* @param number aIndex
|
|
* The index of the item to bring into view.
|
|
*/
|
|
ensureIndexIsVisible: function(aIndex) {
|
|
this.ensureItemIsVisible(this.getItemAtIndex(aIndex));
|
|
},
|
|
|
|
/**
|
|
* Sugar for ensuring the selected item is visible in this container.
|
|
*/
|
|
ensureSelectedItemIsVisible: function() {
|
|
this.ensureItemIsVisible(this.selectedItem);
|
|
},
|
|
|
|
/**
|
|
* If supported by the widget, the label string temporarily added to this
|
|
* container when there are no child items present.
|
|
*/
|
|
set emptyText(aValue) {
|
|
this._emptyText = aValue;
|
|
|
|
// Apply the emptyText attribute right now if there are no child items.
|
|
if (!this._itemsByElement.size) {
|
|
this._widget.setAttribute("emptyText", aValue);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* If supported by the widget, the label string permanently added to this
|
|
* container as a header.
|
|
* @param string aValue
|
|
*/
|
|
set headerText(aValue) {
|
|
this._headerText = aValue;
|
|
this._widget.setAttribute("headerText", aValue);
|
|
},
|
|
|
|
/**
|
|
* Toggles all the items in this container hidden or visible.
|
|
*
|
|
* This does not change the default filtering predicate, so newly inserted
|
|
* items will always be visible. Use WidgetMethods.filterContents if you care.
|
|
*
|
|
* @param boolean aVisibleFlag
|
|
* Specifies the intended visibility.
|
|
*/
|
|
toggleContents: function(aVisibleFlag) {
|
|
for (let [element, item] of this._itemsByElement) {
|
|
element.hidden = !aVisibleFlag;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Toggles all items in this container hidden or visible based on a predicate.
|
|
*
|
|
* @param function aPredicate [optional]
|
|
* Items are toggled according to the return value of this function,
|
|
* which will become the new default filtering predicate in this container.
|
|
* If unspecified, all items will be toggled visible.
|
|
*/
|
|
filterContents: function(aPredicate = this._currentFilterPredicate) {
|
|
this._currentFilterPredicate = aPredicate;
|
|
|
|
for (let [element, item] of this._itemsByElement) {
|
|
element.hidden = !aPredicate(item);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sorts all the items in this container based on a predicate.
|
|
*
|
|
* @param function aPredicate [optional]
|
|
* Items are sorted according to the return value of the function,
|
|
* which will become the new default sorting predicate in this container.
|
|
* If unspecified, all items will be sorted by their value.
|
|
*/
|
|
sortContents: function(aPredicate = this._currentSortPredicate) {
|
|
let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate);
|
|
|
|
for (let i = 0, len = sortedItems.length; i < len; i++) {
|
|
this.swapItems(this.getItemAtIndex(i), sortedItems[i]);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Visually swaps two items in this container.
|
|
*
|
|
* @param Item aFirst
|
|
* The first item to be swapped.
|
|
* @param Item aSecond
|
|
* The second item to be swapped.
|
|
*/
|
|
swapItems: function(aFirst, aSecond) {
|
|
if (aFirst == aSecond) { // We're just dandy, thank you.
|
|
return;
|
|
}
|
|
let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = aFirst;
|
|
let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = aSecond;
|
|
|
|
// If the two items were constructed with prebuilt nodes as DocumentFragments,
|
|
// then those DocumentFragments are now empty and need to be reassembled.
|
|
if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
|
|
for (let node of firstTarget.childNodes) {
|
|
firstPrebuiltTarget.appendChild(node.cloneNode(true));
|
|
}
|
|
}
|
|
if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
|
|
for (let node of secondTarget.childNodes) {
|
|
secondPrebuiltTarget.appendChild(node.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
// 1. Get the indices of the two items to swap.
|
|
let i = this._indexOfElement(firstTarget);
|
|
let j = this._indexOfElement(secondTarget);
|
|
|
|
// 2. Remeber the selection index, to reselect an item, if necessary.
|
|
let selectedTarget = this._widget.selectedItem;
|
|
let selectedIndex = -1;
|
|
if (selectedTarget == firstTarget) {
|
|
selectedIndex = i;
|
|
} else if (selectedTarget == secondTarget) {
|
|
selectedIndex = j;
|
|
}
|
|
|
|
// 3. Silently nuke both items, nobody needs to know about this.
|
|
this._widget.removeChild(firstTarget);
|
|
this._widget.removeChild(secondTarget);
|
|
this._unlinkItem(aFirst);
|
|
this._unlinkItem(aSecond);
|
|
|
|
// 4. Add the items again, but reversing their indices.
|
|
this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]);
|
|
this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]);
|
|
|
|
// 5. Restore the previous selection, if necessary.
|
|
if (selectedIndex == i) {
|
|
this._widget.selectedItem = aFirst._target;
|
|
} else if (selectedIndex == j) {
|
|
this._widget.selectedItem = aSecond._target;
|
|
}
|
|
|
|
// 6. Let the outside world know that these two items were swapped.
|
|
ViewHelpers.dispatchEvent(aFirst.target, "swap", [aSecond, aFirst]);
|
|
},
|
|
|
|
/**
|
|
* Visually swaps two items in this container at specific indices.
|
|
*
|
|
* @param number aFirst
|
|
* The index of the first item to be swapped.
|
|
* @param number aSecond
|
|
* The index of the second item to be swapped.
|
|
*/
|
|
swapItemsAtIndices: function(aFirst, aSecond) {
|
|
this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond));
|
|
},
|
|
|
|
/**
|
|
* Checks whether an item with the specified value is among the elements
|
|
* shown in this container.
|
|
*
|
|
* @param string aValue
|
|
* The item's value.
|
|
* @return boolean
|
|
* True if the value is known, false otherwise.
|
|
*/
|
|
containsValue: function(aValue) {
|
|
return this._itemsByValue.has(aValue) ||
|
|
this._stagedItems.some(({ item }) => item._value == aValue);
|
|
},
|
|
|
|
/**
|
|
* Gets the "preferred value". This is the latest selected item's value,
|
|
* remembered just before emptying this container.
|
|
* @return string
|
|
*/
|
|
get preferredValue() {
|
|
return this._preferredValue;
|
|
},
|
|
|
|
/**
|
|
* Retrieves the item associated with the selected element.
|
|
* @return Item | null
|
|
*/
|
|
get selectedItem() {
|
|
let selectedElement = this._widget.selectedItem;
|
|
if (selectedElement) {
|
|
return this._itemsByElement.get(selectedElement);
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Retrieves the selected element's index in this container.
|
|
* @return number
|
|
*/
|
|
get selectedIndex() {
|
|
let selectedElement = this._widget.selectedItem;
|
|
if (selectedElement) {
|
|
return this._indexOfElement(selectedElement);
|
|
}
|
|
return -1;
|
|
},
|
|
|
|
/**
|
|
* Retrieves the value of the selected element.
|
|
* @return string
|
|
*/
|
|
get selectedValue() {
|
|
let selectedElement = this._widget.selectedItem;
|
|
if (selectedElement) {
|
|
return this._itemsByElement.get(selectedElement)._value;
|
|
}
|
|
return "";
|
|
},
|
|
|
|
/**
|
|
* Retrieves the attachment of the selected element.
|
|
* @return object | null
|
|
*/
|
|
get selectedAttachment() {
|
|
let selectedElement = this._widget.selectedItem;
|
|
if (selectedElement) {
|
|
return this._itemsByElement.get(selectedElement).attachment;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Selects the element with the entangled item in this container.
|
|
* @param Item | function aItem
|
|
*/
|
|
set selectedItem(aItem) {
|
|
// A predicate is allowed to select a specific item.
|
|
// If no item is matched, then the current selection is removed.
|
|
if (typeof aItem == "function") {
|
|
aItem = this.getItemForPredicate(aItem);
|
|
}
|
|
|
|
// A falsy item is allowed to invalidate the current selection.
|
|
let targetElement = aItem ? aItem._target : null;
|
|
let prevElement = this._widget.selectedItem;
|
|
|
|
// Make sure the selected item's target element is focused and visible.
|
|
if (this.autoFocusOnSelection && targetElement) {
|
|
targetElement.focus();
|
|
}
|
|
if (this.maintainSelectionVisible && targetElement) {
|
|
// Some methods are optional. See the WidgetMethods object documentation
|
|
// for a comprehensive list.
|
|
if ("ensureElementIsVisible" in this._widget) {
|
|
this._widget.ensureElementIsVisible(targetElement);
|
|
}
|
|
}
|
|
|
|
// Prevent selecting the same item again and avoid dispatching
|
|
// a redundant selection event, so return early.
|
|
if (targetElement != prevElement) {
|
|
this._widget.selectedItem = targetElement;
|
|
let dispTarget = targetElement || prevElement;
|
|
let dispName = this.suppressSelectionEvents ? "suppressed-select" : "select";
|
|
ViewHelpers.dispatchEvent(dispTarget, dispName, aItem);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Selects the element at the specified index in this container.
|
|
* @param number aIndex
|
|
*/
|
|
set selectedIndex(aIndex) {
|
|
let targetElement = this._widget.getItemAtIndex(aIndex);
|
|
if (targetElement) {
|
|
this.selectedItem = this._itemsByElement.get(targetElement);
|
|
return;
|
|
}
|
|
this.selectedItem = null;
|
|
},
|
|
|
|
/**
|
|
* Selects the element with the specified value in this container.
|
|
* @param string aValue
|
|
*/
|
|
set selectedValue(aValue) {
|
|
this.selectedItem = this._itemsByValue.get(aValue);
|
|
},
|
|
|
|
/**
|
|
* Deselects and re-selects an item in this container.
|
|
*
|
|
* Useful when you want a "select" event to be emitted, even though
|
|
* the specified item was already selected.
|
|
*
|
|
* @param Item | function aItem
|
|
* @see `set selectedItem`
|
|
*/
|
|
forceSelect: function(aItem) {
|
|
this.selectedItem = null;
|
|
this.selectedItem = aItem;
|
|
},
|
|
|
|
/**
|
|
* Specifies if this container should try to keep the selected item visible.
|
|
* (For example, when new items are added the selection is brought into view).
|
|
*/
|
|
maintainSelectionVisible: true,
|
|
|
|
/**
|
|
* Specifies if "select" events dispatched from the elements in this container
|
|
* when their respective items are selected should be suppressed or not.
|
|
*
|
|
* If this flag is set to true, then consumers of this container won't
|
|
* be normally notified when items are selected.
|
|
*/
|
|
suppressSelectionEvents: false,
|
|
|
|
/**
|
|
* Focus this container the first time an element is inserted?
|
|
*
|
|
* If this flag is set to true, then when the first item is inserted in
|
|
* this container (and thus it's the only item available), its corresponding
|
|
* target element is focused as well.
|
|
*/
|
|
autoFocusOnFirstItem: true,
|
|
|
|
/**
|
|
* Focus on selection?
|
|
*
|
|
* If this flag is set to true, then whenever an item is selected in
|
|
* this container (e.g. via the selectedIndex or selectedItem setters),
|
|
* its corresponding target element is focused as well.
|
|
*
|
|
* You can disable this flag, for example, to maintain a certain node
|
|
* focused but visually indicate a different selection in this container.
|
|
*/
|
|
autoFocusOnSelection: true,
|
|
|
|
/**
|
|
* Focus on input (e.g. mouse click)?
|
|
*
|
|
* If this flag is set to true, then whenever an item receives user input in
|
|
* this container, its corresponding target element is focused as well.
|
|
*/
|
|
autoFocusOnInput: true,
|
|
|
|
/**
|
|
* When focusing on input, allow right clicks?
|
|
* @see WidgetMethods.autoFocusOnInput
|
|
*/
|
|
allowFocusOnRightClick: false,
|
|
|
|
/**
|
|
* The number of elements in this container to jump when Page Up or Page Down
|
|
* keys are pressed. If falsy, then the page size will be based on the
|
|
* number of visible items in the container.
|
|
*/
|
|
pageSize: 0,
|
|
|
|
/**
|
|
* Focuses the first visible item in this container.
|
|
*/
|
|
focusFirstVisibleItem: function() {
|
|
this.focusItemAtDelta(-this.itemCount);
|
|
},
|
|
|
|
/**
|
|
* Focuses the last visible item in this container.
|
|
*/
|
|
focusLastVisibleItem: function() {
|
|
this.focusItemAtDelta(+this.itemCount);
|
|
},
|
|
|
|
/**
|
|
* Focuses the next item in this container.
|
|
*/
|
|
focusNextItem: function() {
|
|
this.focusItemAtDelta(+1);
|
|
},
|
|
|
|
/**
|
|
* Focuses the previous item in this container.
|
|
*/
|
|
focusPrevItem: function() {
|
|
this.focusItemAtDelta(-1);
|
|
},
|
|
|
|
/**
|
|
* Focuses another item in this container based on the index distance
|
|
* from the currently focused item.
|
|
*
|
|
* @param number aDelta
|
|
* A scalar specifying by how many items should the selection change.
|
|
*/
|
|
focusItemAtDelta: function(aDelta) {
|
|
// Make sure the currently selected item is also focused, so that the
|
|
// command dispatcher mechanism has a relative node to work with.
|
|
// If there's no selection, just select an item at a corresponding index
|
|
// (e.g. the first item in this container if aDelta <= 1).
|
|
let selectedElement = this._widget.selectedItem;
|
|
if (selectedElement) {
|
|
selectedElement.focus();
|
|
} else {
|
|
this.selectedIndex = Math.max(0, aDelta - 1);
|
|
return;
|
|
}
|
|
|
|
let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
|
|
let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
|
|
while (distance--) {
|
|
if (!this._focusChange(direction)) {
|
|
break; // Out of bounds.
|
|
}
|
|
}
|
|
|
|
// Synchronize the selected item as being the currently focused element.
|
|
this.selectedItem = this.getItemForElement(this._focusedElement);
|
|
},
|
|
|
|
/**
|
|
* Focuses the next or previous item in this container.
|
|
*
|
|
* @param string aDirection
|
|
* Either "advanceFocus" or "rewindFocus".
|
|
* @return boolean
|
|
* False if the focus went out of bounds and the first or last item
|
|
* in this container was focused instead.
|
|
*/
|
|
_focusChange: function(aDirection) {
|
|
let commandDispatcher = this._commandDispatcher;
|
|
let prevFocusedElement = commandDispatcher.focusedElement;
|
|
let currFocusedElement;
|
|
|
|
do {
|
|
commandDispatcher.suppressFocusScroll = true;
|
|
commandDispatcher[aDirection]();
|
|
currFocusedElement = commandDispatcher.focusedElement;
|
|
|
|
// Make sure the newly focused item is a part of this container. If the
|
|
// focus goes out of bounds, revert the previously focused item.
|
|
if (!this.getItemForElement(currFocusedElement)) {
|
|
prevFocusedElement.focus();
|
|
return false;
|
|
}
|
|
} while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName));
|
|
|
|
// Focus remained within bounds.
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Gets the command dispatcher instance associated with this container's DOM.
|
|
* If there are no items displayed in this container, null is returned.
|
|
* @return nsIDOMXULCommandDispatcher | null
|
|
*/
|
|
get _commandDispatcher() {
|
|
if (this._cachedCommandDispatcher) {
|
|
return this._cachedCommandDispatcher;
|
|
}
|
|
let someElement = this._widget.getItemAtIndex(0);
|
|
if (someElement) {
|
|
let commandDispatcher = someElement.ownerDocument.commandDispatcher;
|
|
return this._cachedCommandDispatcher = commandDispatcher;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Gets the currently focused element in this container.
|
|
*
|
|
* @return nsIDOMNode
|
|
* The focused element, or null if nothing is found.
|
|
*/
|
|
get _focusedElement() {
|
|
let commandDispatcher = this._commandDispatcher;
|
|
if (commandDispatcher) {
|
|
return commandDispatcher.focusedElement;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Gets the item in the container having the specified index.
|
|
*
|
|
* @param number aIndex
|
|
* The index used to identify the element.
|
|
* @return Item
|
|
* The matched item, or null if nothing is found.
|
|
*/
|
|
getItemAtIndex: function(aIndex) {
|
|
return this.getItemForElement(this._widget.getItemAtIndex(aIndex));
|
|
},
|
|
|
|
/**
|
|
* Gets the item in the container having the specified value.
|
|
*
|
|
* @param string aValue
|
|
* The value used to identify the element.
|
|
* @return Item
|
|
* The matched item, or null if nothing is found.
|
|
*/
|
|
getItemByValue: function(aValue) {
|
|
return this._itemsByValue.get(aValue);
|
|
},
|
|
|
|
/**
|
|
* Gets the item in the container associated with the specified element.
|
|
*
|
|
* @param nsIDOMNode aElement
|
|
* The element used to identify the item.
|
|
* @param object aFlags [optional]
|
|
* Additional options for showing the source. Supported options:
|
|
* - noSiblings: if siblings shouldn't be taken into consideration
|
|
* when searching for the associated item.
|
|
* @return Item
|
|
* The matched item, or null if nothing is found.
|
|
*/
|
|
getItemForElement: function(aElement, aFlags = {}) {
|
|
while (aElement) {
|
|
let item = this._itemsByElement.get(aElement);
|
|
|
|
// Also search the siblings if allowed.
|
|
if (!aFlags.noSiblings) {
|
|
item = item ||
|
|
this._itemsByElement.get(aElement.nextElementSibling) ||
|
|
this._itemsByElement.get(aElement.previousElementSibling);
|
|
}
|
|
if (item) {
|
|
return item;
|
|
}
|
|
aElement = aElement.parentNode;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Gets a visible item in this container validating a specified predicate.
|
|
*
|
|
* @param function aPredicate
|
|
* The first item which validates this predicate is returned
|
|
* @return Item
|
|
* The matched item, or null if nothing is found.
|
|
*/
|
|
getItemForPredicate: function(aPredicate, aOwner = this) {
|
|
// Recursively check the items in this widget for a predicate match.
|
|
for (let [element, item] of aOwner._itemsByElement) {
|
|
let match;
|
|
if (aPredicate(item) && !element.hidden) {
|
|
match = item;
|
|
} else {
|
|
match = this.getItemForPredicate(aPredicate, item);
|
|
}
|
|
if (match) {
|
|
return match;
|
|
}
|
|
}
|
|
// Also check the staged items. No need to do this recursively since
|
|
// they're not even appended to the view yet.
|
|
for (let { item } of this._stagedItems) {
|
|
if (aPredicate(item)) {
|
|
return item;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Shortcut function for getItemForPredicate which works on item attachments.
|
|
* @see getItemForPredicate
|
|
*/
|
|
getItemForAttachment: function(aPredicate, aOwner = this) {
|
|
return this.getItemForPredicate(e => aPredicate(e.attachment));
|
|
},
|
|
|
|
/**
|
|
* Finds the index of an item in the container.
|
|
*
|
|
* @param Item aItem
|
|
* The item get the index for.
|
|
* @return number
|
|
* The index of the matched item, or -1 if nothing is found.
|
|
*/
|
|
indexOfItem: function(aItem) {
|
|
return this._indexOfElement(aItem._target);
|
|
},
|
|
|
|
/**
|
|
* Finds the index of an element in the container.
|
|
*
|
|
* @param nsIDOMNode aElement
|
|
* The element get the index for.
|
|
* @return number
|
|
* The index of the matched element, or -1 if nothing is found.
|
|
*/
|
|
_indexOfElement: function(aElement) {
|
|
for (let i = 0; i < this._itemsByElement.size; i++) {
|
|
if (this._widget.getItemAtIndex(i) == aElement) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
},
|
|
|
|
/**
|
|
* Gets the total number of items in this container.
|
|
* @return number
|
|
*/
|
|
get itemCount() {
|
|
return this._itemsByElement.size;
|
|
},
|
|
|
|
/**
|
|
* Returns a list of items in this container, in the displayed order.
|
|
* @return array
|
|
*/
|
|
get items() {
|
|
let store = [];
|
|
let itemCount = this.itemCount;
|
|
for (let i = 0; i < itemCount; i++) {
|
|
store.push(this.getItemAtIndex(i));
|
|
}
|
|
return store;
|
|
},
|
|
|
|
/**
|
|
* Returns a list of values in this container, in the displayed order.
|
|
* @return array
|
|
*/
|
|
get values() {
|
|
return this.items.map(e => e._value);
|
|
},
|
|
|
|
/**
|
|
* Returns a list of attachments in this container, in the displayed order.
|
|
* @return array
|
|
*/
|
|
get attachments() {
|
|
return this.items.map(e => e.attachment);
|
|
},
|
|
|
|
/**
|
|
* Returns a list of all the visible (non-hidden) items in this container,
|
|
* in the displayed order
|
|
* @return array
|
|
*/
|
|
get visibleItems() {
|
|
return this.items.filter(e => !e._target.hidden);
|
|
},
|
|
|
|
/**
|
|
* Checks if an item is unique in this container. If an item's value is an
|
|
* empty string, "undefined" or "null", it is considered unique.
|
|
*
|
|
* @param Item aItem
|
|
* The item for which to verify uniqueness.
|
|
* @return boolean
|
|
* True if the item is unique, false otherwise.
|
|
*/
|
|
isUnique: function(aItem) {
|
|
let value = aItem._value;
|
|
if (value == "" || value == "undefined" || value == "null") {
|
|
return true;
|
|
}
|
|
return !this._itemsByValue.has(value);
|
|
},
|
|
|
|
/**
|
|
* Checks if an item is eligible for this container. By default, this checks
|
|
* whether an item is unique and has a prebuilt target node.
|
|
*
|
|
* @param Item aItem
|
|
* The item for which to verify eligibility.
|
|
* @return boolean
|
|
* True if the item is eligible, false otherwise.
|
|
*/
|
|
isEligible: function(aItem) {
|
|
return this.isUnique(aItem) && aItem._prebuiltNode;
|
|
},
|
|
|
|
/**
|
|
* Finds the expected item index in this container based on the default
|
|
* sort predicate.
|
|
*
|
|
* @param Item aItem
|
|
* The item for which to get the expected index.
|
|
* @return number
|
|
* The expected item index.
|
|
*/
|
|
_findExpectedIndexFor: function(aItem) {
|
|
let itemCount = this.itemCount;
|
|
for (let i = 0; i < itemCount; i++) {
|
|
if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) {
|
|
return i;
|
|
}
|
|
}
|
|
return itemCount;
|
|
},
|
|
|
|
/**
|
|
* Immediately inserts an item in this container at the specified index.
|
|
*
|
|
* @param number aIndex
|
|
* The position in the container intended for this item.
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
* @param object aOptions [optional]
|
|
* Additional options or flags supported by this operation:
|
|
* - attributes: a batch of attributes set to the displayed element
|
|
* - finalize: function when the item is untangled (removed)
|
|
* @return Item
|
|
* The item associated with the displayed element, null if rejected.
|
|
*/
|
|
_insertItemAt: function(aIndex, aItem, aOptions = {}) {
|
|
if (!this.isEligible(aItem)) {
|
|
return null;
|
|
}
|
|
|
|
// Entangle the item with the newly inserted node.
|
|
// Make sure this is done with the value returned by insertItemAt(),
|
|
// to avoid storing a potential DocumentFragment.
|
|
let node = aItem._prebuiltNode;
|
|
let attachment = aItem.attachment;
|
|
this._entangleItem(aItem, this._widget.insertItemAt(aIndex, node, attachment));
|
|
|
|
// Handle any additional options after entangling the item.
|
|
if (!this._currentFilterPredicate(aItem)) {
|
|
aItem._target.hidden = true;
|
|
}
|
|
if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) {
|
|
aItem._target.focus();
|
|
}
|
|
if (aOptions.attributes) {
|
|
aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1]));
|
|
}
|
|
if (aOptions.finalize) {
|
|
aItem.finalize = aOptions.finalize;
|
|
}
|
|
|
|
// Hide the empty text if the selection wasn't lost.
|
|
this._widget.removeAttribute("emptyText");
|
|
|
|
// Return the item associated with the displayed element.
|
|
return aItem;
|
|
},
|
|
|
|
/**
|
|
* Entangles an item (model) with a displayed node element (view).
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
* @param nsIDOMNode aElement
|
|
* The element displaying the item.
|
|
*/
|
|
_entangleItem: function(aItem, aElement) {
|
|
this._itemsByValue.set(aItem._value, aItem);
|
|
this._itemsByElement.set(aElement, aItem);
|
|
aItem._target = aElement;
|
|
},
|
|
|
|
/**
|
|
* Untangles an item (model) from a displayed node element (view).
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
*/
|
|
_untangleItem: function(aItem) {
|
|
if (aItem.finalize) {
|
|
aItem.finalize(aItem);
|
|
}
|
|
for (let childItem of aItem) {
|
|
aItem.remove(childItem);
|
|
}
|
|
|
|
this._unlinkItem(aItem);
|
|
aItem._target = null;
|
|
},
|
|
|
|
/**
|
|
* Deletes an item from the its parent's storage maps.
|
|
*
|
|
* @param Item aItem
|
|
* The item describing a target element.
|
|
*/
|
|
_unlinkItem: function(aItem) {
|
|
this._itemsByValue.delete(aItem._value);
|
|
this._itemsByElement.delete(aItem._target);
|
|
},
|
|
|
|
/**
|
|
* The keyPress event listener for this container.
|
|
* @param string aName
|
|
* @param KeyboardEvent aEvent
|
|
*/
|
|
_onWidgetKeyPress: function(aName, aEvent) {
|
|
// Prevent scrolling when pressing navigation keys.
|
|
ViewHelpers.preventScrolling(aEvent);
|
|
|
|
switch (aEvent.keyCode) {
|
|
case aEvent.DOM_VK_UP:
|
|
case aEvent.DOM_VK_LEFT:
|
|
this.focusPrevItem();
|
|
return;
|
|
case aEvent.DOM_VK_DOWN:
|
|
case aEvent.DOM_VK_RIGHT:
|
|
this.focusNextItem();
|
|
return;
|
|
case aEvent.DOM_VK_PAGE_UP:
|
|
this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
|
|
return;
|
|
case aEvent.DOM_VK_PAGE_DOWN:
|
|
this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
|
|
return;
|
|
case aEvent.DOM_VK_HOME:
|
|
this.focusFirstVisibleItem();
|
|
return;
|
|
case aEvent.DOM_VK_END:
|
|
this.focusLastVisibleItem();
|
|
return;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* The mousePress event listener for this container.
|
|
* @param string aName
|
|
* @param MouseEvent aEvent
|
|
*/
|
|
_onWidgetMousePress: function(aName, aEvent) {
|
|
if (aEvent.button != 0 && !this.allowFocusOnRightClick) {
|
|
// Only allow left-click to trigger this event.
|
|
return;
|
|
}
|
|
|
|
let item = this.getItemForElement(aEvent.target);
|
|
if (item) {
|
|
// The container is not empty and we clicked on an actual item.
|
|
this.selectedItem = item;
|
|
// Make sure the current event's target element is also focused.
|
|
this.autoFocusOnInput && item._target.focus();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* The predicate used when filtering items. By default, all items in this
|
|
* view are visible.
|
|
*
|
|
* @param Item aItem
|
|
* The item passing through the filter.
|
|
* @return boolean
|
|
* True if the item should be visible, false otherwise.
|
|
*/
|
|
_currentFilterPredicate: function(aItem) {
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* The predicate used when sorting items. By default, items in this view
|
|
* are sorted by their label.
|
|
*
|
|
* @param Item aFirst
|
|
* The first item used in the comparison.
|
|
* @param Item aSecond
|
|
* The second item used in the comparison.
|
|
* @return number
|
|
* -1 to sort aFirst to a lower index than aSecond
|
|
* 0 to leave aFirst and aSecond unchanged with respect to each other
|
|
* 1 to sort aSecond to a lower index than aFirst
|
|
*/
|
|
_currentSortPredicate: function(aFirst, aSecond) {
|
|
return +(aFirst._value.toLowerCase() > aSecond._value.toLowerCase());
|
|
},
|
|
|
|
/**
|
|
* Call a method on this widget named `aMethodName`. Any further arguments are
|
|
* passed on to the method. Returns the result of the method call.
|
|
*
|
|
* @param String aMethodName
|
|
* The name of the method you want to call.
|
|
* @param aArgs
|
|
* Optional. Any arguments you want to pass through to the method.
|
|
*/
|
|
callMethod: function(aMethodName, ...aArgs) {
|
|
return this._widget[aMethodName].apply(this._widget, aArgs);
|
|
},
|
|
|
|
_widget: null,
|
|
_emptyText: "",
|
|
_headerText: "",
|
|
_preferredValue: "",
|
|
_cachedCommandDispatcher: null
|
|
};
|
|
|
|
/**
|
|
* A generator-iterator over all the items in this container.
|
|
*/
|
|
Item.prototype[Symbol.iterator] =
|
|
WidgetMethods[Symbol.iterator] = function*() {
|
|
yield* this._itemsByElement.values();
|
|
};
|