247 lines
6.7 KiB
TypeScript
247 lines
6.7 KiB
TypeScript
export interface ClientRectObserverEntry {
|
|
target: Element;
|
|
contentRect: DOMRectReadOnly;
|
|
}
|
|
|
|
export interface ClientRectObserverCallback {
|
|
(this: ClientRectObserver, entries: ClientRectObserverEntry[], observer: ClientRectObserver): void;
|
|
}
|
|
|
|
interface ClientRectObserverElement {
|
|
io: IntersectionObserver | null;
|
|
threshold: number;
|
|
isFirstUpdate: boolean;
|
|
}
|
|
|
|
/**
|
|
* Create an observer that notifies when an element is resized, moved, or added/removed from the DOM.
|
|
*/
|
|
export class ClientRectObserver {
|
|
#root = document.documentElement;
|
|
#rootRect = this.#root.getBoundingClientRect();
|
|
|
|
#entries: ClientRectObserverEntry[] = [];
|
|
#rafId = -1;
|
|
|
|
#callback: ClientRectObserverCallback;
|
|
|
|
constructor(callback: ClientRectObserverCallback) {
|
|
this.#callback = callback;
|
|
}
|
|
|
|
#elements = new Map<Element, ClientRectObserverElement>();
|
|
|
|
#resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
|
const rootEntry = entries.find((entry) => entry.target === this.#root);
|
|
// Any time the root element resizes we need to refresh all the observed elements.
|
|
if (rootEntry !== undefined) {
|
|
this.#rootRect = rootEntry.contentRect;
|
|
|
|
this.#elements.forEach((_, target) => {
|
|
// Why force a refresh? we really just need to reset the IntersectionObserver?
|
|
this.#appendEntry(this.#refreshElement(target));
|
|
});
|
|
} else {
|
|
for (const entry of entries) {
|
|
this.#appendEntry(this.#refreshElement(entry.target));
|
|
}
|
|
}
|
|
});
|
|
|
|
async #appendEntry(entry: ClientRectObserverEntry) {
|
|
if (this.#entries.length === 0) {
|
|
Promise.resolve().then(this.#flush);
|
|
}
|
|
|
|
// deduplicate the same target
|
|
this.#entries.push(entry);
|
|
}
|
|
|
|
#flush = () => {
|
|
const entries = this.#entries;
|
|
this.#entries = [];
|
|
this.#callback(entries, this);
|
|
};
|
|
|
|
// We should be guaranteed that each `IntersectionObserver` only observes one element.
|
|
#onIntersection = ([{ target, intersectionRatio, boundingClientRect }]: IntersectionObserverEntry[]) => {
|
|
const el = this.#elements.get(target);
|
|
|
|
if (el === undefined) return;
|
|
|
|
if (intersectionRatio !== el.threshold) {
|
|
// It's possible for the watched element to not be at perfect 1.0 visibility when we create
|
|
// the IntersectionObserver. This has a couple of causes:
|
|
// - elements being on partial pixels
|
|
// - elements being hidden offscreen (e.g., <html> has `overflow: hidden`)
|
|
// - delays: if your DOM change occurs due to e.g., page resize, you can see elements
|
|
// behind their actual position
|
|
//
|
|
// In all of these cases, refresh but with this lower ratio of threshold. When the element
|
|
// moves beneath _that_ new value, the user will get notified.
|
|
|
|
if (el.isFirstUpdate) {
|
|
el.threshold =
|
|
intersectionRatio === 0.0
|
|
? 0.0000001 // just needs to be non-zero
|
|
: intersectionRatio;
|
|
}
|
|
|
|
this.#appendEntry(this.#refreshElement(target, boundingClientRect));
|
|
}
|
|
|
|
el.isFirstUpdate = false;
|
|
};
|
|
|
|
#refreshElement(
|
|
target: Element,
|
|
contentRect: DOMRectReadOnly = target.getBoundingClientRect()
|
|
): ClientRectObserverEntry {
|
|
// Assume el exists
|
|
const el = this.#elements.get(target)!;
|
|
|
|
el.io?.disconnect();
|
|
el.io = null;
|
|
|
|
const { left, top, height, width } = contentRect;
|
|
|
|
// Don't create a IntersectionObserver until the target has a size.
|
|
if (width === 0 && height === 0) {
|
|
return {
|
|
target,
|
|
contentRect,
|
|
};
|
|
}
|
|
|
|
const root = this.#root;
|
|
const floor = Math.floor;
|
|
const x = left + root.scrollLeft;
|
|
const y = top + root.scrollTop;
|
|
|
|
// `${insetTop}px ${insetRight}px ${insetBottom}px ${insetLeft}px`;
|
|
const rootMargin = `${-floor(y)}px ${-floor(this.#rootRect.width - (x + width))}px ${-floor(
|
|
this.#rootRect.height - (y + height)
|
|
)}px ${-floor(x)}px`;
|
|
|
|
// Reset the threshold and isFirstUpdate before creating a new Intersection Observer.
|
|
const { threshold } = el;
|
|
el.threshold = 1;
|
|
el.isFirstUpdate = true;
|
|
|
|
el.io = new IntersectionObserver(this.#onIntersection, {
|
|
root,
|
|
rootMargin,
|
|
threshold,
|
|
});
|
|
|
|
el.io.observe(target);
|
|
|
|
return {
|
|
target,
|
|
contentRect: DOMRectReadOnly.fromRect({
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
}),
|
|
};
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.#elements.forEach((el) => el.io?.disconnect());
|
|
this.#elements.clear();
|
|
this.#resizeObserver.disconnect();
|
|
}
|
|
|
|
observe(target: Element): void {
|
|
if (this.#elements.has(target)) return;
|
|
|
|
if (this.#elements.size === 0) {
|
|
this.#resizeObserver.observe(this.#root);
|
|
}
|
|
|
|
this.#elements.set(target, {
|
|
io: null,
|
|
threshold: 1,
|
|
isFirstUpdate: true,
|
|
});
|
|
|
|
// The resize observer will be called immediately, so we don't have to manually refresh.
|
|
this.#resizeObserver.observe(target);
|
|
}
|
|
|
|
takeRecords(): ClientRectObserverEntry[] {
|
|
const entries = this.#entries;
|
|
this.#entries = [];
|
|
return entries;
|
|
}
|
|
|
|
unobserve(target: Element): void {
|
|
const el = this.#elements.get(target);
|
|
|
|
if (el === undefined) return;
|
|
|
|
this.#resizeObserver.unobserve(target);
|
|
|
|
el.io?.disconnect();
|
|
|
|
this.#elements.delete(target);
|
|
|
|
if (this.#elements.size === 0) {
|
|
this.#resizeObserver.disconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
export type ClientRectObserverEntryCallback = (entry: ClientRectObserverEntry) => void;
|
|
|
|
export class ClientRectObserverManager {
|
|
static #instance: ClientRectObserverManager | null = null;
|
|
|
|
// singleton so we only observe elements once
|
|
constructor() {
|
|
if (ClientRectObserverManager.#instance === null) {
|
|
ClientRectObserverManager.#instance = this;
|
|
}
|
|
return ClientRectObserverManager.#instance;
|
|
}
|
|
|
|
#elementMap = new WeakMap<Element, Set<ClientRectObserverEntryCallback>>();
|
|
|
|
#vo = new ClientRectObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const callbacks = this.#elementMap.get(entry.target);
|
|
|
|
if (callbacks) {
|
|
callbacks.forEach((callback) => callback(entry));
|
|
}
|
|
}
|
|
});
|
|
|
|
observe(target: Element, callback: ClientRectObserverEntryCallback): void {
|
|
let callbacks = this.#elementMap.get(target);
|
|
|
|
if (callbacks === undefined) {
|
|
this.#vo.observe(target);
|
|
this.#elementMap.set(target, (callbacks = new Set()));
|
|
} else {
|
|
callback({ target, contentRect: target.getBoundingClientRect() });
|
|
}
|
|
|
|
callbacks.add(callback);
|
|
}
|
|
|
|
unobserve(target: Element, callback: ClientRectObserverEntryCallback): void {
|
|
let callbacks = this.#elementMap.get(target);
|
|
|
|
if (callbacks === undefined) return;
|
|
|
|
callbacks.delete(callback);
|
|
|
|
if (callbacks.size === 0) {
|
|
this.#vo.unobserve(target);
|
|
this.#elementMap.delete(target);
|
|
}
|
|
}
|
|
}
|