@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
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();
}
}
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 };