UNPKG

lisn.js

Version:

Simply handle user gestures and actions. Includes widgets.

437 lines (408 loc) 16.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/LayoutWatcher */ // NOTES FOR DEVELOPERS // // For each layout (device or aspect ratio), we create an overlay that has a // a width that corresponds to the layout: // - for device layouts, it's a fixed width in pixels, equal to the minimum // width of the device // - for aspect ratio layouts, the overlay has a width that's relative to // the root's height, equal to the minimum width of the aspect ratio // // Then we observe each overlay with an IntersectionObserver whose root // is this Watcher's root and whose root margin is -100% from the left (i.e. // right-most edge of the root). // // If the root is null, i.e. the viewport, the overlays will have a "fixed" // position and be inserted in document.body. Otherwise, they'll be inserted // in the root element with an "absolute" position. The root element, if not // body must be positioned. It gets a default position of "relative" through // the class `.lisn-overlay-container`, which Overlays ensures it gets. // // If using custom root we track its size through SizeWatcher as the // aspectRatio overlays are relative to the height, and we can't rely on CSS // alone as the root may not have a fixed height through CSS. // // Whenever any overlay intersects the root, this means that the viewport // width is now equal to or narrower than the overlay. // // ~~~~ The current device or aspect ratio corresponds to the _widest_ // ~~~~ overlay that does not intersect. // // For example: // // | mobile // ========| mobile-wide // ======================| tablet // =========================================| desktop // // _________________________________| viewport width // // Here, mobile, mobile-wide and tablet overlays are _not_ intersecting, only // desktop intersects. The device layout is therefore tablet. // // Therefore: // - have the layout bit spaces ordered from narrowest layout at lowest bit // to widest layout at hightest bit // - keep a running bitmask of which overlays are not intersecting and update // it each time there is an IntersectionObserverEntry. // - get the highest device or aspect ratio bit in that bitmask to find out // the widest non-intersecting overlay // // For simplicity we create overlays also for layouts that have a 0-width. import * as MC from "../globals/minification-constants.js"; import * as MH from "../globals/minification-helpers.js"; import { settings } from "../globals/settings.js"; import { getData } from "../utils/css-alter.js"; import { getLayoutBitmask, NUM_LAYOUTS, ORDERED_DEVICES, ORDERED_ASPECTR } from "../utils/layout.js"; import { logError, logWarn } from "../utils/log.js"; import { omitKeys, copyExistingKeys } from "../utils/misc.js"; import { createOverlay } from "../utils/overlays.js"; import { objToStrKey } from "../utils/text.js"; import { wrapCallback } from "../modules/callback.js"; import { newXMap } from "../modules/x-map.js"; import { SizeWatcher } from "./size-watcher.js"; import debug from "../debug/debug.js"; /** * {@link LayoutWatcher} listens for changes in either the width or aspect * ratio of the viewport or the given {@link LayoutWatcherConfig.root | root}. * * It does not track resize events; rather it's built on top of * {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver | IntersectionObserver}. * * It manages registered callbacks globally and reuses IntersectionObservers * for more efficient performance. */ export class LayoutWatcher { /** * Creates a new instance of LayoutWatcher with the given * {@link LayoutWatcherConfig}. It does not save it for future reuse. */ static create(config) { return new LayoutWatcher(getConfig(config), CONSTRUCTOR_KEY); } /** * Returns an existing instance of LayoutWatcher with the given * {@link LayoutWatcherConfig}, 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) { var _instances$get; const myConfig = getConfig(config); const configStrKey = objToStrKey(omitKeys(myConfig, { _root: null })); let instance = (_instances$get = instances.get(myConfig._root)) === null || _instances$get === void 0 ? void 0 : _instances$get.get(configStrKey); if (!instance) { instance = new LayoutWatcher(myConfig, CONSTRUCTOR_KEY); instances.sGet(myConfig._root).set(configStrKey, instance); } return instance; } constructor(config, key) { /** * Call the given handler whenever the layout changes. * * Unless {@link OnLayoutOptions.skipInitial} is true, the handler is also * called (almost) immediately with the current layout. * * **IMPORTANT:** The same handler can _not_ be added multiple times, even if * the options differ. If the handler has already been added, it is removed * and re-added with the current options. * * @throws {@link Errors.LisnUsageError | LisnUsageError} * If the options are invalid. */ _defineProperty(this, "onLayout", void 0); /** * Removes a previously added handler. */ _defineProperty(this, "offLayout", void 0); /** * Get the current screen layout. */ _defineProperty(this, "fetchCurrentLayout", void 0); if (key !== CONSTRUCTOR_KEY) { throw MH.illegalConstructorError("LayoutWatcher.create"); } const logger = debug ? new debug.Logger({ name: "LayoutWatcher", logAtCreation: config }) : null; let nonIntersectingBitmask = 0; let currentLayoutData = { device: null, aspectRatio: null }; const allCallbacks = MH.newMap(); // ---------- const fetchCurrentLayout = async () => { await readyPromise; return MH.copyObject(currentLayoutData); }; // ---------- const setupOverlays = async () => { const { root, overlays } = await createOverlays(config._root, config._deviceBreakpoints, config._aspectRatioBreakpoints); return MH.newPromise(resolve => { let isReady = false; const intersectionHandler = entries => { const numEntries = MH.lengthOf(entries); debug: logger === null || logger === void 0 || logger.debug9(`Got ${numEntries} new entries`, entries); if (!isReady) { /* istanbul ignore next */ // shouldn't happen if (numEntries < NUM_LAYOUTS) { logWarn(MH.bugError(`Got IntersectionObserver ${numEntries}, ` + `expected >= ${NUM_LAYOUTS}`)); } } for (const entry of entries) { nonIntersectingBitmask = getNonIntersecting(nonIntersectingBitmask, entry); } // If this is the initial call from IntersectionObserver, skip callbacks. // Those that have skipInitial: false would be called elsewhere, by // setupOnLayout processLayoutChange(!isReady); isReady = true; resolve(); // ready after IntersectionObserver has called us the 1st time }; // ---------- const observeOptions = { root, rootMargin: "5px 0% 5px -100%" }; const observer = MH.newIntersectionObserver(intersectionHandler, observeOptions); for (const triggerOverlay of overlays) { observer.observe(triggerOverlay); } }); }; // ---------- const createCallback = (handler, layoutBitmask) => { var _allCallbacks$get; MH.remove((_allCallbacks$get = allCallbacks.get(handler)) === null || _allCallbacks$get === void 0 ? void 0 : _allCallbacks$get._callback); debug: logger === null || logger === void 0 || logger.debug5("Adding/updating handler", layoutBitmask); const callback = wrapCallback(handler); callback.onRemove(() => { deleteHandler(handler); }); allCallbacks.set(handler, { _callback: callback, _layoutBitmask: layoutBitmask }); return callback; }; const setupOnLayout = async (handler, options) => { const layoutBitmask = getLayoutBitmask(options); const callback = createCallback(handler, layoutBitmask); if (options !== null && options !== void 0 && options.skipInitial) { return; } const layoutData = await fetchCurrentLayout(); if (!callback.isRemoved() && changeMatches(layoutBitmask, layoutData, null)) { debug: logger === null || logger === void 0 || logger.debug5("Calling initially with", layoutData); await invokeCallback(callback, layoutData); } }; const deleteHandler = handler => { MH.deleteKey(allCallbacks, handler); // no need to unobserve the overlays }; const processLayoutChange = skipCallbacks => { const deviceBit = MH.floor(MH.log2(nonIntersectingBitmask & ORDERED_DEVICES.bitmask)); const aspectRatioBit = MH.floor(MH.log2(nonIntersectingBitmask & ORDERED_ASPECTR.bitmask)); const layoutData = { device: null, aspectRatio: null }; // -Infinity means all of the overlays are intersecting, which would only // happen if the narrowest overlay is not actually 0-width (which is not the // case by default and against the recommended settings). if (deviceBit !== -MC.INFINITY) { layoutData.device = ORDERED_DEVICES.nameOf(1 << deviceBit); } if (aspectRatioBit !== -MC.INFINITY) { layoutData.aspectRatio = ORDERED_ASPECTR.nameOf(1 << aspectRatioBit); } debug: logger === null || logger === void 0 || logger.debug8("New layout", layoutData); if (!skipCallbacks) { for (const entry of allCallbacks.values()) { const layoutBitmask = entry._layoutBitmask; if (!changeMatches(layoutBitmask, layoutData, currentLayoutData)) { debug: logger === null || logger === void 0 || logger.debug9(`Layout change does not match bitmask ${layoutBitmask}`); continue; } invokeCallback(entry._callback, layoutData); } } currentLayoutData = layoutData; }; const readyPromise = setupOverlays(); // no need to await // ---------- this.fetchCurrentLayout = fetchCurrentLayout; // ---------- this.onLayout = setupOnLayout; // ---------- this.offLayout = handler => { var _allCallbacks$get2; debug: logger === null || logger === void 0 || logger.debug5("Removing handler"); MH.remove((_allCallbacks$get2 = allCallbacks.get(handler)) === null || _allCallbacks$get2 === void 0 ? void 0 : _allCallbacks$get2._callback); }; } } /** * @interface */ /** * @interface */ /** * The handler is invoked with one argument: * * - the current {@link LayoutData} */ /** * Note that {@link device} or {@link aspectRatio} would only be null if the * viewport is narrower than the narrowest device/aspect ratio. This would only * happen if the narrowest device/aspect ratio is _not_ 0-width (which is not * the case with the default breakpoints and is against the recommendation for * setting breakpoints. */ // ---------------------------------------- const CONSTRUCTOR_KEY = MC.SYMBOL(); const instances = newXMap(() => MH.newMap()); const VAR_BORDER_HEIGHT = MH.prefixCssJsVar("border-height"); const PREFIX_DEVICE = MH.prefixName("device"); const PREFIX_ASPECTR = MH.prefixName("aspect-ratio"); const getConfig = config => { var _config$root; const deviceBreakpoints = MH.copyObject(settings.deviceBreakpoints); if (config !== null && config !== void 0 && config.deviceBreakpoints) { copyExistingKeys(config.deviceBreakpoints, deviceBreakpoints); } const aspectRatioBreakpoints = MH.copyObject(settings.aspectRatioBreakpoints); if (config !== null && config !== void 0 && config.aspectRatioBreakpoints) { copyExistingKeys(config.aspectRatioBreakpoints, aspectRatioBreakpoints); } return { _root: (_config$root = config === null || config === void 0 ? void 0 : config.root) !== null && _config$root !== void 0 ? _config$root : null, _deviceBreakpoints: deviceBreakpoints, _aspectRatioBreakpoints: aspectRatioBreakpoints }; }; // ---------------------------------------- const createOverlays = async (root, deviceBreakpoints, aspectRatioBreakpoints) => { const overlayPromises = []; let overlayParent; if (root) { overlayParent = root; } else { // Since modals remove the scrollbar on the body when active, the width of // the body changes upon open/close of a modal, which would create // glitching if it happens near a device breakpoint. So if the root is the // viewport, we create a fixed positioned container to hold the overlays // and set its width to be 100vw and use that as the root of overlayParent = await createOverlay({ style: { position: "fixed", [MC.S_WIDTH]: "100vw" } }); } let device; for (device in deviceBreakpoints) { overlayPromises.push(createOverlay({ parent: overlayParent, style: { position: "absolute", [MC.S_WIDTH]: deviceBreakpoints[device] + "px" }, data: { [PREFIX_DEVICE]: device } })); } const parentHeightCss = root ? `var(${VAR_BORDER_HEIGHT}, 0) * 1px` : "100vh"; if (root) { SizeWatcher.reuse().trackSize(null, { target: root }); } let aspectRatio; for (aspectRatio in aspectRatioBreakpoints) { overlayPromises.push(createOverlay({ parent: overlayParent, style: { position: "absolute", [MC.S_WIDTH]: `calc(${aspectRatioBreakpoints[aspectRatio]} ` + `* ${parentHeightCss})` }, data: { [PREFIX_ASPECTR]: aspectRatio } })); } const overlays = await MH.promiseAll(overlayPromises); return { root: overlayParent, overlays }; }; const getOverlayLayout = overlay => { const layout = getData(overlay, PREFIX_DEVICE) || getData(overlay, PREFIX_ASPECTR); /* istanbul ignore else */ if (layout && (ORDERED_DEVICES.has(layout) || ORDERED_ASPECTR.has(layout))) { return layout; } else { // shouldn't happen logError(MH.bugError("No device or aspectRatio data attribute")); return null; } }; const changeMatches = (layoutBitmask, thisLayoutData, prevLayoutData) => { // True if the callback is interested in a change of device and there's a // change of device and the new device is one of the ones it's interested in // (or it's null, i.e. device is undefined). // And the same for aspect ratios. if ((prevLayoutData === null || prevLayoutData === void 0 ? void 0 : prevLayoutData.device) !== thisLayoutData.device && (!thisLayoutData.device || ORDERED_DEVICES.bit[thisLayoutData.device] & layoutBitmask)) { return true; } if ((prevLayoutData === null || prevLayoutData === void 0 ? void 0 : prevLayoutData.aspectRatio) !== thisLayoutData.aspectRatio && (!thisLayoutData.aspectRatio || ORDERED_ASPECTR.bit[thisLayoutData.aspectRatio] & layoutBitmask)) { return true; } return false; }; const getNonIntersecting = (nonIntersectingBitmask, entry) => { const target = MH.targetOf(entry); /* istanbul ignore next */ // shouldn't happen if (!MH.isHTMLElement(target)) { logError(MH.bugError(`IntersectionObserver called us with '${MH.typeOrClassOf(target)}'`)); return nonIntersectingBitmask; } const layout = getOverlayLayout(target); let bit = 0; if (!layout) { // error already logged by getOverlayLayout } else if (ORDERED_DEVICES.has(layout)) { bit = ORDERED_DEVICES.bit[layout]; } else if (ORDERED_ASPECTR.has(layout)) { bit = ORDERED_ASPECTR.bit[layout]; } else { /* istanbul ignore next */ // shouldn't happen logError(MH.bugError(`Unknown device or aspectRatio data attribute: ${layout}`)); } if (entry.isIntersecting) { nonIntersectingBitmask &= ~bit; } else { nonIntersectingBitmask |= bit; } return nonIntersectingBitmask; }; const invokeCallback = (callback, layoutData) => callback.invoke(MH.copyObject(layoutData)).catch(logError); //# sourceMappingURL=layout-watcher.js.map