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

608 lines (508 loc) 18.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(); } } var styles = ".observer-bounds {\n position: absolute;\n left: 0;\n top: 0;\n background-color: rgba(0, 55, 150, 0.1);\n z-index: -1;\n}\n\n.observer-top {\n max-width: 100vw;\n border-bottom: 1px solid #3083c9;\n}\n\n.observer-right {\n max-height: 100vh;\n border-left: 1px solid #3083c9;\n}\n\n.observer-bottom {\n max-width: 100vw;\n border-top: 1px solid #3083c9;\n}\n\n.observer-left {\n max-height: 100vh;;\n border-right: 1px solid #3083c9;\n}\n\n.recreated {\n background-color: rgba(14, 43, 62, 0.5);\n border-color: #47fcff;\n border-width: 3px;\n}\n\n.observed-element {\n width: max(115px, 40%);\n height: max(24px, 10%);\n text-align: center;\n}"; let timeout; let delay = 500; const styleElement = document.createElement('style'); styleElement.innerText = styles; styleElement.dataset.for = 'PositionObserver'; document.head.appendChild(styleElement); class PositionObserverDebug extends PositionObserver { observerTopRect; observerRightRect; observerBottomRect; observerLeftRect; constructor(...args) { super(...args); console.warn('PositionObserver is in debug mode'); this.observerTopRect = document.createElement('div'); this.observerRightRect = document.createElement('div'); this.observerBottomRect = document.createElement('div'); this.observerLeftRect = document.createElement('div'); this.observerTopRect.classList.add('observer-bounds', 'observer-top'); this.observerRightRect.classList.add('observer-bounds', 'observer-right'); this.observerBottomRect.classList.add('observer-bounds', 'observer-bottom'); this.observerLeftRect.classList.add('observer-bounds', 'observer-left'); document.body.append( this.observerTopRect, this.observerRightRect, this.observerBottomRect, this.observerLeftRect, ); } } const originalFn = PositionObserver.prototype.__observerCallback; const flashed = (ctx) => { ctx.observerTopRect.classList.remove('recreated'); ctx.observerRightRect.classList.remove('recreated'); ctx.observerBottomRect.classList.remove('recreated'); ctx.observerLeftRect.classList.remove('recreated'); }; const resizeObserverRect = (observerRectElement, rootBoundsRect) => { const { left, top, width, height } = rootBoundsRect; const { scrollLeft, scrollTop } = document.documentElement; const { style, classList } = observerRectElement; const scaleFactor = PositionObserver.__scaleFactor; const scaledTop = top / scaleFactor; const scaledLeft = left / scaleFactor; const scaledWidth = width / scaleFactor; const scaledHeight = height / scaleFactor; style.transform = `translate(${scaledLeft + scrollLeft}px, ${scaledTop + scrollTop}px)`; style.height = `${scaledHeight}px`; if (Math.round(scaledLeft - 1)) { // -1 margin is used to determine scale factor for WebKit style.right = `${scaledLeft}px`; } else { style.width = `${scaledWidth}px`; } classList.add('recreated'); }; const runIfNew = ( (instances = new Map()) => (key, value, fn, ...args) => { if (instances.get(key) !== value) { fn(...args); instances.set(key, value); } } )(); PositionObserverDebug.prototype.__observerCallback = function debug(entries) { const self = this.ctx; const entry = entries[entries.length - 1]; const { target } = entry; const observers = self.__observers.get(target); const takeTopRecords = observers.top.takeRecords; const takeRightRecords = observers.right.takeRecords; const takeBottomRecords = observers.bottom.takeRecords; const takeLeftRecords = observers.left.takeRecords; let topRecords; let rightRecords; let bottomRecords; let leftRecords; if (this === observers.top) { runIfNew( 'top', observers.top, resizeObserverRect, self.observerTopRect, entry.rootBounds, ); } if (this === observers.right) { runIfNew( 'right', observers.right, resizeObserverRect, self.observerRightRect, entry.rootBounds, ); } if (this === observers.bottom) { runIfNew( 'bottom', observers.bottom, resizeObserverRect, self.observerBottomRect, entry.rootBounds, ); } if (this === observers.left) { runIfNew( 'left', observers.left, resizeObserverRect, self.observerLeftRect, entry.rootBounds, ); } observers.top.takeRecords = () => { topRecords = takeTopRecords.call(observers.top); if (topRecords.length) { runIfNew( 'top', observers.top, resizeObserverRect, self.observerTopRect, topRecords[topRecords.length - 1].rootBounds, ); } return topRecords; }; observers.right.takeRecords = () => { rightRecords = takeRightRecords.call(observers.right); if (rightRecords.length) { runIfNew( 'right', observers.right, resizeObserverRect, self.observerRightRect, rightRecords[rightRecords.length - 1].rootBounds, ); } return rightRecords; }; observers.bottom.takeRecords = () => { bottomRecords = takeBottomRecords.call(observers.bottom); if (bottomRecords.length) { runIfNew( 'bottom', observers.bottom, resizeObserverRect, self.observerBottomRect, bottomRecords[bottomRecords.length - 1].rootBounds, ); } return bottomRecords; }; observers.left.takeRecords = () => { leftRecords = takeLeftRecords.call(observers.left); if (leftRecords.length) { runIfNew( 'left', observers.left, resizeObserverRect, self.observerLeftRect, leftRecords[leftRecords.length - 1].rootBounds, ); } return leftRecords; }; clearTimeout(timeout); timeout = setTimeout(flashed, delay, self); originalFn.call(this, entries, self); }; export { PositionObserverDebug as default };