diff options
Diffstat (limited to 'devtools/client/shared/widgets/view-helpers.js')
-rw-r--r-- | devtools/client/shared/widgets/view-helpers.js | 430 |
1 files changed, 430 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/view-helpers.js b/devtools/client/shared/widgets/view-helpers.js new file mode 100644 index 0000000000..589d9c6299 --- /dev/null +++ b/devtools/client/shared/widgets/view-helpers.js @@ -0,0 +1,430 @@ +/* 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, +}; |