From 8ba86f9c5e441722e35b6e8dd100950854e6b3bc Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Fri, 7 Feb 2020 09:14:03 -0500 Subject: [PATCH] common: add utils abbreviateNumber, anchorScroll, Evented, ScrollListener Evented is now a class instead of an object - it can be extended from a class now. Object.assign can still be used with it, but instead of `evented` with `Evented.prototype` --- js/src/common/utils/Evented.ts | 81 ++++++++++++++++++++++++ js/src/common/utils/ScrollListener.ts | 70 ++++++++++++++++++++ js/src/common/utils/abbreviateNumber.tsx | 17 +++++ js/src/common/utils/anchorScroll.ts | 27 ++++++++ 4 files changed, 195 insertions(+) create mode 100644 js/src/common/utils/Evented.ts create mode 100644 js/src/common/utils/ScrollListener.ts create mode 100644 js/src/common/utils/abbreviateNumber.tsx create mode 100644 js/src/common/utils/anchorScroll.ts diff --git a/js/src/common/utils/Evented.ts b/js/src/common/utils/Evented.ts new file mode 100644 index 000000000..7170d1216 --- /dev/null +++ b/js/src/common/utils/Evented.ts @@ -0,0 +1,81 @@ +export type EventHandler = (...args: any) => any; + +export default class Evented { + /** + * Arrays of registered event handlers, grouped by the event name. + */ + protected handlers: { [key: string]: EventHandler[] } = {}; + + /** + * Get all of the registered handlers for an event. + * + * @param event The name of the event. + */ + protected getHandlers(event: string): EventHandler[] { + this.handlers = this.handlers || {}; + + this.handlers[event] = this.handlers[event] || []; + + return this.handlers[event]; + } + + /** + * Trigger an event. + * + * @param event The name of the event. + * @param args Arguments to pass to event handlers. + */ + public trigger(event: string, ...args: any): this { + this.getHandlers(event).forEach(handler => handler.apply(this, args)); + + return this; + } + + /** + * Register an event handler. + * + * @param event The name of the event. + * @param handler The function to handle the event. + */ + on(event: string, handler: EventHandler): this { + this.getHandlers(event).push(handler); + + return this; + } + + /** + * Register an event handler so that it will run only once, and then + * unregister itself. + * + * @param event The name of the event. + * @param handler The function to handle the event. + */ + one(event: string, handler: EventHandler): this { + const wrapper = function() { + handler.apply(this, arguments); + + this.off(event, wrapper); + }; + + this.getHandlers(event).push(wrapper); + + return this; + } + + /** + * Unregister an event handler. + * + * @param event The name of the event. + * @param handler The function that handles the event. + */ + off(event: string, handler: EventHandler): this { + const handlers = this.getHandlers(event); + const index = handlers.indexOf(handler); + + if (index !== -1) { + handlers.splice(index, 1); + } + + return this; + } +} diff --git a/js/src/common/utils/ScrollListener.ts b/js/src/common/utils/ScrollListener.ts new file mode 100644 index 000000000..44a149469 --- /dev/null +++ b/js/src/common/utils/ScrollListener.ts @@ -0,0 +1,70 @@ +const later = + window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.msRequestAnimationFrame || + window.oRequestAnimationFrame || + (callback => window.setTimeout(callback, 1000 / 60)); + +/** + * The `ScrollListener` class sets up a listener that handles window scroll + * events. + */ +export default class ScrollListener { + ticking: boolean = false; + + callback: Function; + active: EventListener; + + /** + * @param callback The callback to run when the scroll position + * changes. + */ + public constructor(callback: Function) { + this.callback = callback; + } + + /** + * On each animation frame, as long as the listener is active, run the + * `update` method. + */ + protected loop() { + // THROTTLE: If the callback is still running (or hasn't yet run), we ignore + // further scroll events. + if (this.ticking) return; + + // Schedule the callback to be executed soon (TM), and stop throttling once + // the callback is done. + later(() => { + this.update(); + this.ticking = false; + }); + + this.ticking = true; + } + + /** + * Run the callback, whether there was a scroll event or not. + */ + public update() { + this.callback(window.pageYOffset); + } + + /** + * Start listening to and handling the window's scroll position. + */ + public start() { + if (!this.active) { + window.addEventListener('scroll', (this.active = this.loop.bind(this))); + } + } + + /** + * Stop listening to and handling the window's scroll position. + */ + public stop() { + window.removeEventListener('scroll', this.active); + + this.active = null; + } +} diff --git a/js/src/common/utils/abbreviateNumber.tsx b/js/src/common/utils/abbreviateNumber.tsx new file mode 100644 index 000000000..1969d1d44 --- /dev/null +++ b/js/src/common/utils/abbreviateNumber.tsx @@ -0,0 +1,17 @@ +/** + * The `abbreviateNumber` utility converts a number to a shorter localized form. + * + * @example + * abbreviateNumber(1234); + * // "1.2K" + */ +export default (number: number): string => { + // TODO: translation + if (number >= 1000000) { + return Math.floor(number / 1000000) + app.translator.transText('core.lib.number_suffix.mega_text'); + } else if (number >= 1000) { + return Math.floor(number / 1000) + app.translator.transText('core.lib.number_suffix.kilo_text'); + } else { + return number.toString(); + } +}; diff --git a/js/src/common/utils/anchorScroll.ts b/js/src/common/utils/anchorScroll.ts new file mode 100644 index 000000000..bbfe5e2ab --- /dev/null +++ b/js/src/common/utils/anchorScroll.ts @@ -0,0 +1,27 @@ +/** + * The `anchorScroll` utility saves the scroll position relative to an element, + * and then restores it after a callback has been run. + * + * This is useful if a redraw will change the page's content above the viewport. + * Normally doing this will result in the content in the viewport being pushed + * down or pulled up. By wrapping the redraw with this utility, the scroll + * position can be anchor to an element that is in or below the viewport, so + * the content in the viewport will stay the same. + * + * @param element The element to anchor the scroll position to. + * @param callback The callback to run that will change page content. + */ +export default function anchorScroll(element: Element, callback: Function) { + const $window = $(window); + const $el = $(element); + + if (!element || !$el.length) { + return callback(); + } + + const relativeScroll = $el.offset().top - $window.scrollTop(); + + callback(); + + $window.scrollTop($el.offset().top - relativeScroll); +}