UNPKG

@itihon/position-observer

Version:

Observes position change of an element within viewport as a result of resize, scroll, left or top coordinates change, or zooming in and out

409 lines (337 loc) 12.3 kB
import RequestAnimationFrameLoop from 'request-animation-frame-loop'; /** * A wrapper for intersection observer * needed to have access to more than one context inside a callback */ /** * @callback IntersectionObserverHFCallback * @param {Array<IntersectionObserverEntry>} entries * @returns {void} */ class IntersectionObserverHF extends IntersectionObserver { /** * @param {IntersectionObserverHFCallback} callback * @param {IntersectionObserverInit} options * @param {*} ctx */ constructor(callback, options, ctx) { super(callback, options); this.ctx = ctx; } } /** * @callback PositionObserverCallback * @param {HTMLElement} target * @param {DOMRect} targetRect * @param {*} ctx * @returns {void} */ /** * @typedef RAFLContext * @property {HTMLElement} target * @property {FourSideObserver} observers * @property {DOMRect} rect * @property {PositionObserver} self * @property {typeof PositionObserver} staticSelf * @property {Set<"top" | "right" | "bottom" | "left">} recreationList */ /** * @callback RAFLCallback * @param {RAFLContext} ctx * @param {RequestAnimationFrameLoop} loop * @param {number} timestamp * @returns {void} */ /** * @typedef FourSideObserver * @property {IntersectionObserverHF} top * @property {IntersectionObserverHF} right * @property {IntersectionObserverHF} bottom * @property {IntersectionObserverHF} left */ /** * This function is used to normalize top and left coordinates of `rootBounds` * that are used to determine scale factor which is needed for `rootMargin` calculation for WebKit. * Most browsers will report a number close to 1, mobile Firefox sometimes gives a negative number. * In order to avoid miscalculations in viewport width and height, * this number is clamped to 1 for Blink and Gecko. For WebKit the number itself is returned. * @param {number} number * @returns {number} */ const normalizeTo1 = (number) => Math.abs(1 - number) < 0.005 || number < 0.5 ? 1 : number; class PositionObserver { /* dependencies */ static #IntersectionObserverHF = IntersectionObserverHF; static #RequestAnimationFrameLoop = RequestAnimationFrameLoop; static #vw = window.innerWidth; static #vh = window.innerHeight; static #scaleFactor = 1; /** @type {IntersectionObserverInit} */ static #options = { rootMargin: undefined, threshold: Array.from({ length: 101 }, (_, idx) => idx * 0.01), root: document, }; /** * @type {RAFLCallback} */ static #unobserve(ctx) { const { observers, target } = ctx; observers.top.unobserve(target); observers.right.unobserve(target); observers.bottom.unobserve(target); observers.left.unobserve(target); } /** * @type {RAFLCallback} */ static #positionChanging(ctx, loop) { const { target, rect, self } = ctx; const targetRect = target.getBoundingClientRect(); const { left, top, right, bottom } = targetRect; if ( left === rect.left && top === rect.top && right === rect.right && bottom === rect.bottom ) { loop.stop(); } else { rect.left = left; rect.top = top; rect.right = right; rect.bottom = bottom; self.#callback(target, targetRect, self.#ctx); } } /** * @type {RAFLCallback} */ static #createObserver(ctx) { const { target, rect, observers, self, staticSelf, recreationList } = ctx; const cb = self.#observerCallback; const { top, right, bottom, left } = rect; const IntersectionObserverHF = staticSelf.#IntersectionObserverHF; const options = staticSelf.#options; const vw = staticSelf.#vw; const vh = staticSelf.#vh; /* +--------------------+-------------------------+-------------------------+ | Browser / Platform | visualViewport.height | rootBounds.height | +--------------------+-------------------------+-------------------------+ | Desktop — Chrome | + | + | +--------------------+-------------------------+-------------------------+ | Desktop — Firefox | + | + | +--------------------+-------------------------+-------------------------+ | Desktop — Epiphany | + | - | - needed scale factor calculation for Epiphany +--------------------+-------------------------+-------------------------+ | Mobile — Chrome | - | + | - mismatches when elements larger than viewport are attached to the DOM +--------------------+-------------------------+-------------------------+ | Mobile — Firefox | + | + | +--------------------+-------------------------+-------------------------+ WebKit based browsers (Epiphany in particular) report IntersectionObserver rootBounds unscaled coordinates. Here top and left margins are used to determine scale factor. */ if (recreationList.has('top')) { // recreate the top observer options.rootMargin = `-1px 0px ${-Math.min(vh - top - 2, vh - 2)}px 0px`; if (observers.top) observers.top.unobserve(target); observers.top = new IntersectionObserverHF(cb, options, self); } if (recreationList.has('right')) { // recreate the right observer options.rootMargin = `0px 0px 0px ${-Math.min(right - 2, vw - 1)}px`; if (observers.right) observers.right.unobserve(target); observers.right = new IntersectionObserverHF(cb, options, self); } if (recreationList.has('bottom')) { // recreate the bottom observer options.rootMargin = `${-Math.min(bottom - 2, vh - 1)}px 0px 0px 0px`; if (observers.bottom) observers.bottom.unobserve(target); observers.bottom = new IntersectionObserverHF(cb, options, self); } if (recreationList.has('left')) { // recreate the left observer options.rootMargin = `0px ${-Math.min(vw - left - 2, vw - 2)}px 0px -1px`; if (observers.left) observers.left.unobserve(target); observers.left = new IntersectionObserverHF(cb, options, self); } recreationList.clear(); observers.top.observe(target); observers.right.observe(target); observers.bottom.observe(target); observers.left.observe(target); } #callback; #ctx; /** @type {Map<HTMLElement, FourSideObserver>} */ #observers = new Map(); /** @type {Map<HTMLElement, RequestAnimationFrameLoop>} */ #rafLoops = new Map(); /** @type {Map<HTMLElement, RAFLContext>} */ #rafCtxs = new Map(); /** * @param {Array<IntersectionObserverEntry>} entries */ #observerCallback(entries) { // the "this" here is an instance of IntersectionObserverHF /** * @type {PositionObserver} */ const self = this.ctx; const { target, boundingClientRect } = entries[entries.length - 1]; const { top: targetTop, right: targetRight, bottom: targetBottom, left: targetLeft, } = boundingClientRect; const observers = self.#observers.get(target); const recreationList = self.#rafCtxs.get(target).recreationList; const topRecords = this === observers.top ? entries : observers.top.takeRecords(); const rightRecords = this === observers.right ? entries : observers.right.takeRecords(); const bottomRecords = this === observers.bottom ? entries : observers.bottom.takeRecords(); const leftRecords = this === observers.left ? entries : observers.left.takeRecords(); if (topRecords.length) { const { top: scaleFactor, bottom: rootBottom, width: rootWidth, } = topRecords[topRecords.length - 1].rootBounds; const normalizedScaleFactor = normalizeTo1(scaleFactor); PositionObserver.#scaleFactor = normalizedScaleFactor; PositionObserver.#vw = rootWidth / normalizedScaleFactor; const intersectionHeight = rootBottom / normalizedScaleFactor - targetTop; if (!(0.1 < intersectionHeight && intersectionHeight < 3)) { if (targetTop >= 0) { recreationList.add('top'); } } } if (rightRecords.length) { const { right: rootRight, left: rootLeft, height: rootHeight, } = rightRecords[rightRecords.length - 1].rootBounds; const normalizedScaleFactor = PositionObserver.#scaleFactor; PositionObserver.#vh = rootHeight / normalizedScaleFactor; PositionObserver.#vw = rootRight / normalizedScaleFactor; const intersectionWidth = targetRight - rootLeft / normalizedScaleFactor; if (!(0.1 < intersectionWidth && intersectionWidth < 3)) { if (targetRight <= rootRight / normalizedScaleFactor) { recreationList.add('right'); } } } if (bottomRecords.length) { const { top: rootTop, bottom: rootBottom, width: rootWidth, } = bottomRecords[bottomRecords.length - 1].rootBounds; const normalizedScaleFactor = PositionObserver.#scaleFactor; PositionObserver.#vw = rootWidth / normalizedScaleFactor; PositionObserver.#vh = rootBottom / normalizedScaleFactor; const intersectionHeight = targetBottom - rootTop / normalizedScaleFactor; if (!(0.1 < intersectionHeight && intersectionHeight < 3)) { if (targetBottom <= rootBottom / normalizedScaleFactor) { recreationList.add('bottom'); } } } if (leftRecords.length) { const { left: scaleFactor, right: rootRight, height: rootHeight, } = leftRecords[leftRecords.length - 1].rootBounds; const normalizedScaleFactor = normalizeTo1(scaleFactor); PositionObserver.#scaleFactor = normalizedScaleFactor; PositionObserver.#vh = rootHeight / normalizedScaleFactor; const intersectionWidth = rootRight / normalizedScaleFactor - targetLeft; if (!(0.1 < intersectionWidth && intersectionWidth < 3)) { if (targetLeft >= 0) { recreationList.add('left'); } } } // display: none; if (!target.offsetParent) { self.#callback(target, boundingClientRect, self.#ctx); return; // protection against an infinite loop } if (recreationList.size) { self.#callback(target, boundingClientRect, self.#ctx); self.#rafLoops.get(target).start(); } } /** * @param {PositionObserverCallback} callback * @param {*} ctx */ constructor(callback, ctx) { this.#callback = callback; this.#ctx = ctx; } /** * @param {HTMLElement} target */ observe(target) { if (this.#observers.has(target)) return; const observers = { top: null, right: null, bottom: null, left: null }; const targetRect = target.getBoundingClientRect(); /** * @type {RAFLContext} */ const ctx = { target, observers, rect: { top: targetRect.top, right: targetRect.right, bottom: targetRect.bottom, left: targetRect.left, }, self: this, staticSelf: PositionObserver, recreationList: new Set(['top', 'right', 'bottom', 'left']), }; const rafLoop = new PositionObserver.#RequestAnimationFrameLoop(ctx) .started(PositionObserver.#unobserve) .each(PositionObserver.#positionChanging) .stopped(PositionObserver.#createObserver); PositionObserver.#createObserver(ctx); this.#observers.set(target, observers); this.#rafLoops.set(target, rafLoop); this.#rafCtxs.set(target, ctx); this.#callback(target, targetRect, this.#ctx); // initial callback invokation } /** * @param {HTMLElement} target */ unobserve(target) { if (!this.#observers.has(target)) return; const observers = this.#observers.get(target); const ctx = { target, observers }; PositionObserver.#unobserve(ctx); this.#observers.delete(target); this.#rafLoops.delete(target); this.#rafCtxs.delete(target); } disconnect() { this.#observers.forEach((_, target) => this.unobserve(target)); } /** * @returns {MapIterator<HTMLElement>} */ getTargets() { return this.#observers.keys(); } } export { PositionObserver as default };