@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
JavaScript
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 };