UNPKG

lisn.js

Version:

Simply handle user gestures and actions. Includes widgets.

428 lines (389 loc) 15.8 kB
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } /** * @module Watchers/SizeWatcher */ import * as MC from "../globals/minification-constants.js"; import * as MH from "../globals/minification-helpers.js"; import { setNumericStyleJsVars } from "../utils/css-alter.js"; import { logError } from "../utils/log.js"; import { toNonNegNum } from "../utils/math.js"; import { isValidBox, isValidDimension, getEntryBorderBox, getEntryContentBox, tryGetViewportOverlay, fetchViewportOverlay } from "../utils/size.js"; import { objToStrKey } from "../utils/text.js"; import { wrapCallback } from "../modules/callback.js"; import { newXWeakMap } from "../modules/x-map.js"; import { XResizeObserver } from "../modules/x-resize-observer.js"; import debug from "../debug/debug.js"; /** * {@link SizeWatcher} monitors the size of a given target. It's built on top * of {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver | ResizeObserver}. * * It manages registered callbacks globally and reuses ResizeObservers. * * Each instance of SizeWatcher manages up to two ResizeObservers: one * for content-box size changes and one for border-box size changes. */ export class SizeWatcher { /** * Creates a new instance of SizeWatcher with the given * {@link SizeWatcherConfig}. It does not save it for future reuse. */ static create(config) { return new SizeWatcher(getConfig(config), CONSTRUCTOR_KEY); } /** * Returns an existing instance of SizeWatcher with the given * {@link SizeWatcherConfig}, or creates a new one. * * **NOTE:** It saves it for future reuse, so don't use this for temporary * short-lived watchers. */ static reuse(config) { const myConfig = getConfig(config); const configStrKey = objToStrKey(myConfig); let instance = instances.get(configStrKey); if (!instance) { instance = new SizeWatcher(myConfig, CONSTRUCTOR_KEY); instances.set(configStrKey, instance); } return instance; } constructor(config, key) { /** * Call the given handler whenever the target's size changes. * * Unless {@link OnResizeOptions.skipInitial} is true, the handler is also * called (almost) immediately with the latest size data. * * **IMPORTANT:** The same handler can _not_ be added multiple times for the * same target, even if the options differ. If the handler has already been * added for this target, either using {@link onResize} or {@link trackSize}, * then it will be removed and re-added with the current options. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the target or options are invalid. */ _defineProperty(this, "onResize", void 0); /** * Removes a previously added handler. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the target is invalid. */ _defineProperty(this, "offResize", void 0); /** * This is the same as {@link onResize} except that if `handler` is not given, * then it defaults to an handler that updates a set of CSS variables on the * target's style: * * - If {@link OnResizeOptions.target | options.target} is not given, or is * `window`, the following CSS variables are set on the root (`html`) * element and represent the viewport size: * - `--lisn-js--window-border-width` * - `--lisn-js--window-border-height` * - `--lisn-js--window-content-width` * - `--lisn-js--window-content-height` * * - Otherwise, the following variables are set on the target itself and * represent its visible size: * - `--lisn-js--border-width` * - `--lisn-js--border-height` * - `--lisn-js--content-width` * - `--lisn-js--content-height` * * If `target` is `document`, then it will use `document.documentElement`. * * **IMPORTANT:** The same handler can _not_ be added multiple times for the * same target, even if the options differ. If the handler has already been * added for this target, either using {@link onResize} or {@link trackSize}, * then it will be removed and re-added with the current options. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the target or options are invalid. */ _defineProperty(this, "trackSize", void 0); /** * Removes a previously added handler for {@link trackSize}. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the target is invalid. */ _defineProperty(this, "noTrackSize", void 0); /** * Get the size of the given target. It will get the size from a * ResizeObserverEntry and so it's always delayed by one frame at least. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the target is invalid. */ _defineProperty(this, "fetchCurrentSize", void 0); if (key !== CONSTRUCTOR_KEY) { throw MH.illegalConstructorError("SizeWatcher.create"); } const logger = debug ? new debug.Logger({ name: "SizeWatcher", logAtCreation: config }) : null; const allSizeData = MH.newWeakMap(); const allCallbacks = newXWeakMap(() => MH.newMap()); // ---------- const resizeHandler = entries => { for (const entry of entries) { processEntry(entry); } }; // Don't debounce the observer, only callbacks. const xObserver = new XResizeObserver(resizeHandler); // ---------- const fetchCurrentSize = async target => { const element = await fetchElement(target); const sizeData = allSizeData.get(element); if (sizeData) { return MH.copyObject(sizeData); } return MH.newPromise(resolve => { // Use a temp ResizeObserver const observer = MH.newResizeObserver(entries => { const sizeData = getSizeData(entries[0]); observer === null || observer === void 0 || observer.disconnect(); resolve(sizeData); // no need to copy or save it }); if (observer) { observer.observe(element); } else { // Warning would have already been logged by XResizeObserver resolve({ border: { [MC.S_WIDTH]: 0, [MC.S_HEIGHT]: 0 }, content: { [MC.S_WIDTH]: 0, [MC.S_HEIGHT]: 0 } }); } }); }; // ---------- const fetchOptions = async options => { var _options$box, _options$dimension, _options$MC$S_DEBOUNC; const box = (_options$box = options.box) !== null && _options$box !== void 0 ? _options$box : null; if (box && !isValidBox(box)) { throw MH.usageError(`Unknown box type: '${box}'`); } const dimension = (_options$dimension = options.dimension) !== null && _options$dimension !== void 0 ? _options$dimension : null; if (dimension && !isValidDimension(dimension)) { throw MH.usageError(`Unknown dimension: '${dimension}'`); } return { _element: await fetchElement(MH.targetOf(options)), _box: box, _dimension: dimension, // If threshold is 0, internally treat as 1 (pixel) _threshold: toNonNegNum(options.threshold, config._resizeThreshold) || 1, _debounceWindow: (_options$MC$S_DEBOUNC = options[MC.S_DEBOUNCE_WINDOW]) !== null && _options$MC$S_DEBOUNC !== void 0 ? _options$MC$S_DEBOUNC : config._debounceWindow }; }; // ---------- const createCallback = (handler, options) => { var _allCallbacks$get; const element = options._element; MH.remove((_allCallbacks$get = allCallbacks.get(element)) === null || _allCallbacks$get === void 0 || (_allCallbacks$get = _allCallbacks$get.get(handler)) === null || _allCallbacks$get === void 0 ? void 0 : _allCallbacks$get._callback); debug: logger === null || logger === void 0 || logger.debug5("Adding/updating handler", options); const callback = wrapCallback(handler, options._debounceWindow); callback.onRemove(() => { deleteHandler(handler, options); }); const entry = { _callback: callback, _options: options }; allCallbacks.sGet(element).set(handler, entry); return entry; }; // ---------- const setupOnResize = async (handler, userOptions) => { const options = await fetchOptions(userOptions !== null && userOptions !== void 0 ? userOptions : {}); const element = options._element; // Don't await for the size data before creating the callback so that // setupOnResize and removeOnResize have the same "timing" and therefore // calling onResize and offResize immediately without awaiting removes the // callback. const entry = createCallback(handler, options); const callback = entry._callback; const sizeData = await fetchCurrentSize(element); if (callback.isRemoved()) { return; } entry._data = sizeData; allSizeData.set(element, sizeData); // Always use observeLater. This is because the initial call to callback // shouldn't be debounced, and so we call it manually here, regardless if // it's a new target or not. Therefore we don't want the observer to also // call it in case it _is_ a new target. // It's ok if already observed, won't do anything. xObserver.observeLater(element); if (!(userOptions !== null && userOptions !== void 0 && userOptions.skipInitial)) { debug: logger === null || logger === void 0 || logger.debug5("Calling initially with", element, sizeData); // Use a one-off callback that's not debounced for the initial call. await invokeCallback(wrapCallback(handler), element, sizeData); } }; // ---------- const removeOnResize = async (handler, target) => { var _allCallbacks$get2; const options = await fetchOptions({ target }); const element = options._element; const currEntry = (_allCallbacks$get2 = allCallbacks.get(element)) === null || _allCallbacks$get2 === void 0 ? void 0 : _allCallbacks$get2.get(handler); if (currEntry) { MH.remove(currEntry._callback); if (handler === setSizeCssProps) { // delete the properties setSizeCssProps(element, null); } } }; // ---------- const deleteHandler = (handler, options) => { const element = options._element; MH.deleteKey(allCallbacks.get(element), handler); allCallbacks.prune(element); if (!allCallbacks.has(element)) { debug: logger === null || logger === void 0 || logger.debug4("No more callbacks for target; unobserving", element); xObserver.unobserve(element); MH.deleteKey(allSizeData, element); } }; // ---------- const processEntry = entry => { // In reality, it can't be just a base Element const element = MH.targetOf(entry); const latestData = getSizeData(entry); allSizeData.set(element, latestData); debug: logger === null || logger === void 0 || logger.debug9("New size data", element, latestData); for (const entry of ((_allCallbacks$get3 = allCallbacks.get(element)) === null || _allCallbacks$get3 === void 0 ? void 0 : _allCallbacks$get3.values()) || []) { var _allCallbacks$get3; const thresholdsExceeded = hasExceededThreshold(entry._options, latestData, entry._data); if (!thresholdsExceeded) { debug: logger === null || logger === void 0 || logger.debug9("Threshold not exceeded"); continue; } entry._data = latestData; invokeCallback(entry._callback, element, latestData); } }; // ---------- this.fetchCurrentSize = fetchCurrentSize; // ---------- this.trackSize = async (handler, options) => { if (!handler) { handler = setSizeCssProps; } return setupOnResize(handler, options); }; // ---------- this.noTrackSize = (handler, target) => { if (!handler) { handler = setSizeCssProps; } removeOnResize(handler, target); // no need to await }; // ---------- this.onResize = setupOnResize; // ---------- this.offResize = (handler, target) => { removeOnResize(handler, target); // no need to await }; } } /** * @interface */ /** * @interface */ /** * The handler is invoked with three arguments: * * - the element that has been resized: if the target you requested was the * viewport, then this will be a fixed positioned overlay that tracks the * size of the viewport * - the {@link SizeData} for the element */ // ---------------------------------------- const CONSTRUCTOR_KEY = MC.SYMBOL(); const instances = MH.newMap(); const getConfig = config => { config !== null && config !== void 0 ? config : config = {}; return { _debounceWindow: toNonNegNum(config[MC.S_DEBOUNCE_WINDOW], 75), // If threshold is 0, internally treat as 1 (pixel) _resizeThreshold: toNonNegNum(config.resizeThreshold, 50) || 1 }; }; // -------------------- const hasExceededThreshold = (options, latestData, lastThresholdData) => { if (!lastThresholdData) { /* istanbul ignore */ return false; } let box, dim; for (box in latestData) { if (options._box && options._box !== box) { continue; } for (dim in latestData[box]) { if (options._dimension && options._dimension !== dim) { continue; } const diff = MH.abs(latestData[box][dim] - lastThresholdData[box][dim]); if (diff >= options._threshold) { return true; } } } return false; }; const getSizeData = entry => { const borderBox = getEntryBorderBox(entry, true); const contentBox = getEntryContentBox(entry); return { border: borderBox, content: contentBox }; }; const setSizeCssProps = (element, sizeData) => { let prefix = ""; if (element === tryGetViewportOverlay()) { // Set the CSS vars on the root element element = MH.getDocElement(); prefix = "window-"; } const props = { borderWidth: sizeData === null || sizeData === void 0 ? void 0 : sizeData.border[MC.S_WIDTH], borderHeight: sizeData === null || sizeData === void 0 ? void 0 : sizeData.border[MC.S_HEIGHT], contentWidth: sizeData === null || sizeData === void 0 ? void 0 : sizeData.content[MC.S_WIDTH], contentHeight: sizeData === null || sizeData === void 0 ? void 0 : sizeData.content[MC.S_HEIGHT] }; setNumericStyleJsVars(element, props, { _prefix: prefix }); // don't await here }; const fetchElement = async target => { if (MH.isElement(target)) { return target; } if (!target || target === MH.getWindow()) { return fetchViewportOverlay(); } if (target === MH.getDoc()) { return MH.getDocElement(); } throw MH.usageError("Unsupported resize target"); }; const invokeCallback = (callback, element, sizeData) => callback.invoke(element, MH.copyObject(sizeData)).catch(logError); //# sourceMappingURL=size-watcher.js.map