Source code

Revision control

Copy as Markdown

Other Tools

/* 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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
const PANE_APPEARANCE_DELAY = 50;
var namedTimeoutsStore = new Map();
/**
* Helper for draining a rapid succession of events and invoking a callback
* once everything settles down.
*
* @param string id
* A string identifier for the named timeout.
* @param number wait
* The amount of milliseconds to wait after no more events are fired.
* @param function callback
* Invoked when no more events are fired after the specified time.
*/
const setNamedTimeout = function setNamedTimeout(id, wait, callback) {
clearNamedTimeout(id);
namedTimeoutsStore.set(
id,
setTimeout(() => namedTimeoutsStore.delete(id) && callback(), wait)
);
};
exports.setNamedTimeout = setNamedTimeout;
/**
* Clears a named timeout.
* @see setNamedTimeout
*
* @param string id
* A string identifier for the named timeout.
*/
const clearNamedTimeout = function clearNamedTimeout(id) {
if (!namedTimeoutsStore) {
return;
}
clearTimeout(namedTimeoutsStore.get(id));
namedTimeoutsStore.delete(id);
};
exports.clearNamedTimeout = clearNamedTimeout;
/**
* Helpers for creating and messaging between UI components.
*/
exports.ViewHelpers = {
/**
* Convenience method, dispatching a custom event.
*
* @param Node target
* A custom target element to dispatch the event from.
* @param string type
* The name of the event.
* @param any detail
* The data passed when initializing the event.
* @return boolean
* True if the event was cancelled or a registered handler
* called preventDefault.
*/
dispatchEvent(target, type, detail) {
if (!(target instanceof Node)) {
// Event cancelled.
return true;
}
const document = target.ownerDocument || target;
const dispatcher = target.ownerDocument ? target : document.documentElement;
const event = document.createEvent("CustomEvent");
event.initCustomEvent(type, true, true, detail);
return dispatcher.dispatchEvent(event);
},
/**
* Helper delegating some of the DOM attribute methods of a node to a widget.
*
* @param object widget
* The widget to assign the methods to.
* @param Node node
* A node to delegate the methods to.
*/
delegateWidgetAttributeMethods(widget, node) {
widget.getAttribute = widget.getAttribute || node.getAttribute.bind(node);
widget.setAttribute = widget.setAttribute || node.setAttribute.bind(node);
widget.removeAttribute =
widget.removeAttribute || node.removeAttribute.bind(node);
},
/**
* Helper delegating some of the DOM event methods of a node to a widget.
*
* @param object widget
* The widget to assign the methods to.
* @param Node node
* A node to delegate the methods to.
*/
delegateWidgetEventMethods(widget, node) {
widget.addEventListener =
widget.addEventListener || node.addEventListener.bind(node);
widget.removeEventListener =
widget.removeEventListener || node.removeEventListener.bind(node);
},
/**
* 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(object) {
return object?.on && object?.off && object?.once && object?.emit;
},
/**
* Checks if the specified object is an instance of a DOM node.
*
* @return boolean
* True if it's a node, false otherwise.
*/
isNode(object) {
return (
object instanceof Node ||
object instanceof Element ||
Cu.getClassName(object) == "DocumentFragment"
);
},
/**
* Prevents event propagation when navigation keys are pressed.
*
* @param Event e
* The event to be prevented.
*/
preventScrolling(e) {
switch (e.keyCode) {
case KeyCodes.DOM_VK_UP:
case KeyCodes.DOM_VK_DOWN:
case KeyCodes.DOM_VK_LEFT:
case KeyCodes.DOM_VK_RIGHT:
case KeyCodes.DOM_VK_PAGE_UP:
case KeyCodes.DOM_VK_PAGE_DOWN:
case KeyCodes.DOM_VK_HOME:
case KeyCodes.DOM_VK_END:
e.preventDefault();
e.stopPropagation();
}
},
/**
* Check if the enter key or space was pressed
*
* @param event event
* The event triggered by a keydown or keypress on an element
*/
isSpaceOrReturn(event) {
return (
event.keyCode === KeyCodes.DOM_VK_SPACE ||
event.keyCode === KeyCodes.DOM_VK_RETURN
);
},
/**
* Sets a toggled pane hidden or visible. The pane can either be displayed on
* the side (right or left depending on the locale) or at the bottom.
*
* @param object flags
* 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 Node pane
* The element representing the pane to toggle.
*/
togglePane(flags, pane) {
// Make sure a pane is actually available first.
if (!pane) {
return;
}
// Hiding is always handled via margins, not the hidden attribute.
pane.removeAttribute("hidden");
// Add a class to the pane to handle min-widths, margins and animations.
pane.classList.add("generic-toggled-pane");
// Avoid toggles in the middle of animation.
if (pane.hasAttribute("animated")) {
return;
}
// Avoid useless toggles.
if (flags.visible == !pane.classList.contains("pane-collapsed")) {
if (flags.callback) {
flags.callback();
}
return;
}
// The "animated" attributes enables animated toggles (slide in-out).
if (flags.animated) {
pane.setAttribute("animated", "");
} else {
pane.removeAttribute("animated");
}
// Computes and sets the pane margins in order to hide or show it.
const doToggle = () => {
// Negative margins are applied to "right" and "left" to support RTL and
// LTR directions, as well as to "bottom" to support vertical layouts.
// Unnecessary negative margins are forced to 0 via CSS in widgets.css.
if (flags.visible) {
pane.style.marginLeft = "0";
pane.style.marginRight = "0";
pane.style.marginBottom = "0";
pane.classList.remove("pane-collapsed");
} else {
const width = Math.floor(pane.getAttribute("width")) + 1;
const height = Math.floor(pane.getAttribute("height")) + 1;
pane.style.marginLeft = -width + "px";
pane.style.marginRight = -width + "px";
pane.style.marginBottom = -height + "px";
}
// Wait for the animation to end before calling afterToggle()
if (flags.animated) {
const options = {
useCapture: false,
once: true,
};
pane.addEventListener(
"transitionend",
() => {
// Prevent unwanted transitions: if the panel is hidden and the layout
// changes margins will be updated and the panel will pop out.
pane.removeAttribute("animated");
if (!flags.visible) {
pane.classList.add("pane-collapsed");
}
if (flags.callback) {
flags.callback();
}
},
options
);
} else {
if (!flags.visible) {
pane.classList.add("pane-collapsed");
}
// Invoke the callback immediately since there's no transition.
if (flags.callback) {
flags.callback();
}
}
};
// Sometimes it's useful delaying the toggle a few ticks to ensure
// a smoother slide in-out animation.
if (flags.delayed) {
pane.ownerDocument.defaultView.setTimeout(
doToggle,
PANE_APPEARANCE_DELAY
);
} else {
doToggle();
}
},
};
/**
* A generic Item is used to describe children present in a Widget.
*
* This is basically a very thin wrapper around a Node, 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 ownerView
* The owner view creating this item.
* @param Node element
* A prebuilt node to be wrapped.
* @param string value
* A string identifying the node.
* @param any attachment
* Some attached primitive/object.
*/
function Item(ownerView, element, value, attachment) {
this.ownerView = ownerView;
this.attachment = attachment;
this._value = value + "";
this._prebuiltNode = element;
this._itemsByElement = new Map();
}
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 Node element
* A Node representing the child element to append.
* @param object options [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(element, options = {}) {
const item = new Item(this, element, "", options.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(element));
// Handle any additional options after entangling the item.
if (options.attributes) {
options.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
}
if (options.finalize) {
item.finalize = options.finalize;
}
// Return the item associated with the displayed element.
return item;
},
/**
* Immediately removes the specified child item from this item.
*
* @param Item item
* The item associated with the element to remove.
*/
remove(item) {
if (!item) {
return;
}
this._target.removeChild(item._target);
this._untangleItem(item);
},
/**
* Entangles an item (model) with a displayed node element (view).
*
* @param Item item
* The item describing a target element.
* @param Node element
* The element displaying the item.
*/
_entangleItem(item, element) {
this._itemsByElement.set(element, item);
item._target = element;
},
/**
* Untangles an item (model) from a displayed node element (view).
*
* @param Item item
* The item describing a target element.
*/
_untangleItem(item) {
if (item.finalize) {
item.finalize(item);
}
for (const childItem of item) {
item.remove(childItem);
}
this._unlinkItem(item);
item._target = null;
},
/**
* Deletes an item from the its parent's storage maps.
*
* @param Item item
* The item describing a target element.
*/
_unlinkItem(item) {
this._itemsByElement.delete(item._target);
},
/**
* Returns a string representing the object.
* Avoid using `toString` to avoid accidental JSONification.
* @return string
*/
stringify() {
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,
};