@v4fire/client
Version:
V4Fire client core library
368 lines (289 loc) • 8.03 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
import symbolGenerator from 'core/symbol';
import type { AsyncOptions } from 'core/async';
import type {
InViewObservableElement,
InViewObservableElementRect,
InViewObservableElementsThresholdMap,
InViewInitOptions,
InViewUnobserveOptions
} from 'core/dom/in-view/interface';
import {
hasMutationObserver,
getRootRect,
getElementRect,
isElementInView,
isElementVisible
} from 'core/dom/in-view/mutation/helpers';
import Super from 'core/dom/in-view/super';
import { isInView } from 'core/dom/in-view/helpers';
export const
$$ = symbolGenerator();
export type AdapteeType = 'mutation';
export default class InView extends Super {
/**
* Adaptee type
*/
readonly type: AdapteeType = 'mutation';
/**
* Deferred version of the recalculate function
* @see recalculate
*/
readonly recalculateDeffer: Function;
/**
* Deferred version of the check function
* @see recalculate
*/
readonly checkDeffer: Function;
/**
* True if the current adaptee can be used
*/
static readonly acceptable: boolean = hasMutationObserver;
/**
* Mutation observer
*/
protected readonly mutationObserver: MutationObserver;
/**
* Map of elements that needs to be polled
*/
protected readonly pollingElements: InViewObservableElementsThresholdMap = new Map();
/**
* Map of element positions
*/
protected map: Dictionary<InViewObservableElementRect[]> = {};
/**
* Initializes an observer
*/
constructor() {
super();
const
RECALCULATE_TIMEOUT = 100,
POLL_INTERVAL = 75,
CHECK_TIMEOUT = 50;
const
{async: $a} = this;
const checkDeffer = () => $a.setTimeout(() => this.check(), CHECK_TIMEOUT, {
group: 'inView',
label: $$.check,
join: true
});
const recalculateDeffer = (opts?: AsyncOptions) => $a.setTimeout(() => this.recalculate(), RECALCULATE_TIMEOUT, {
group: 'inView',
label: $$.recalculate,
join: true,
...opts
});
this.checkDeffer = checkDeffer;
this.recalculateDeffer = recalculateDeffer;
this.mutationObserver = new MutationObserver(() => {
this.recalculateDeffer();
});
this.async.wait(() => Boolean(document.body), {label: $$.waitBody}).then(() => {
this.mutationObserver.observe(document.body, {
childList: true,
attributes: true,
subtree: true,
characterData: true
});
$a.setInterval(this.poll.bind(this), POLL_INTERVAL, {
group: 'inView',
label: $$.poll,
join: true
});
$a.on(document, 'scroll', checkDeffer);
$a.on(globalThis, 'resize', () => recalculateDeffer({
join: false
}));
}).catch(stderr);
}
override observe(el: Element, opts: InViewInitOptions): InViewObservableElement | false {
const
observable = super.observe(el, opts);
if (observable === false) {
return false;
}
const
{async: $a} = this;
if (observable.handleTransitionEnd) {
$a.on(el, 'transitionend', this.recalculateDeffer, {
group: 'inView',
label: `transitionend-${el.id}`
});
}
return observable;
}
override unobserve(el: Element, unobserveOptsOrThreshold?: InViewUnobserveOptions | number): boolean {
const
res = super.unobserve(el, unobserveOptsOrThreshold);
if (!this.pollingElements.has(el)) {
this.recalculateDeffer();
}
return res;
}
/**
* Polls elements
*/
poll(): void {
this.pollingElements.forEach((map) => {
map.forEach((observable) => {
const
root = Object.isFunction(observable.root) ? observable.root() : observable.root,
elRect = observable.node.getBoundingClientRect(),
isElementIn = isInView(elRect, observable.threshold, root);
this.setObservableSize(observable, elRect);
if (isElementIn && !observable.isLeaving) {
this.onObservableIn(observable);
} else if (!isElementIn && observable.isLeaving) {
this.onObservableOut(observable);
}
});
});
}
/**
* Checks if elements is in view
*/
check(): void {
const
rootRect = getRootRect();
const
checkRangeTo = Math.ceil((rootRect.height + rootRect.scrollTop) / 100) + 1,
checkRangeFrom = Math.ceil(rootRect.scrollTop / 100);
let
start = checkRangeFrom - 1 >= 0 ? 0 : checkRangeFrom - 1;
while (start !== checkRangeTo) {
const
elements = this.map[start];
if (elements) {
for (let i = 0; i < elements.length; i++) {
const
el = elements[i],
{observable} = el;
const
// An old chromium does not support isConnected
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
isConnected = observable.node.isConnected ?? true,
isElementIn = isElementInView(el, rootRect, observable.threshold);
if (isConnected && isElementIn && !observable.isLeaving) {
this.onObservableIn(observable);
} else if (!isConnected || !isElementIn) {
this.onObservableOut(observable);
}
}
}
start++;
}
}
/**
* Recalculates all elements
*/
recalculate(): void {
this.createMap();
this.check();
}
/**
* Creates a position map of elements
*/
protected createMap(): void {
const
map: Dictionary<InViewObservableElementRect[]> = {},
rootRect = getRootRect();
this.elements.forEach((thresholdMap) => {
thresholdMap.forEach((observable) => {
const
rect = getElementRect(rootRect, observable.node);
let listNum = Math.ceil(rect.top / 100);
listNum = listNum === 0 ? 0 : listNum - 1;
this.setObservableSize(observable, rect);
if (!isElementVisible(rect)) {
this.clearAllAsync(observable);
return;
}
// eslint-disable-next-line no-multi-assign
const tile = map[listNum] = map[listNum] ?? [];
tile.push({...rect, observable});
});
});
this.map = map;
}
protected override maps(): InViewObservableElementsThresholdMap {
return new Map([
...super.maps(),
...this.pollingElements
]);
}
protected override initObserve(observable: InViewObservableElement): InViewObservableElement {
if (!observable.polling) {
this.putInMap(this.elements, observable);
this.recalculateDeffer();
} else {
this.putInMap(this.pollingElements, observable);
}
return observable;
}
protected override getElMap(el: Element): InViewObservableElementsThresholdMap {
const res = super.getElMap(el);
if (res.has(el)) {
return res;
}
return this.pollingElements;
}
protected override clearAllAsync(el: InViewObservableElement): void {
const
{async: $a} = this;
$a.clearAll({
group: 'inView',
label: `transitionend-${el.id}`
});
super.clearAllAsync(el);
}
/**
* Handler: element becomes visible on viewport
* @param observable
*/
protected onObservableIn(observable: InViewObservableElement): void {
const asyncOptions = {
group: 'inView',
label: observable.id,
join: true
};
const highResTimeStamp = performance.now();
observable.time = highResTimeStamp;
observable.timeIn = highResTimeStamp;
// eslint-disable-next-line @typescript-eslint/unbound-method
if (Object.isFunction(observable.onEnter)) {
observable.onEnter(observable);
}
observable.isLeaving = true;
if (observable.delay != null && observable.delay > 0) {
this.async.setTimeout(() => this.call(observable), observable.delay, asyncOptions);
} else {
this.call(observable);
}
}
/**
* Handler: element leaves viewport
* @param observable
*/
protected onObservableOut(observable: InViewObservableElement): void {
const asyncOptions = {
group: 'inView',
label: observable.id,
join: true
};
const highResTimeStamp = performance.now();
observable.time = highResTimeStamp;
observable.timeOut = highResTimeStamp;
// eslint-disable-next-line @typescript-eslint/unbound-method
if (Object.isFunction(observable.onLeave)) {
observable.onLeave(observable);
}
observable.isLeaving = false;
this.async.clearAll(asyncOptions);
}
}