@v4fire/client
Version:
V4Fire client core library
475 lines (385 loc) • 10.1 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 Async from 'core/async';
import type {
InViewGroup,
InViewInitOptions,
InViewObservableElement,
InViewObservableElementsThresholdMap,
InViewObservableThresholdMap,
InViewObservableElSize,
InViewUnobserveOptions,
InViewObservablesByGroup
} from 'core/dom/in-view/interface';
export const
$$ = symbolGenerator();
export default abstract class AbstractInView {
/**
* Map of observable elements
*/
protected readonly elements: InViewObservableElementsThresholdMap = new Map();
/**
* Map of observable elements sorted by a group parameter
*/
protected readonly observablesByGroup: InViewObservablesByGroup = new Map();
/**
* Map of elements that was suspended
*/
protected readonly suspendedElements: InViewObservableElementsThresholdMap = new Map();
/**
* Queue of elements that wait to become observable
*/
protected readonly awaitingElements: InViewObservableElementsThresholdMap = new Map();
/**
* Async instance
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-this
protected readonly async: Async<this> = new Async(this);
/**
* Initializes inView
*/
protected constructor() {
const
POP_AWAITING_INTERVAL = 75;
this.async.setInterval(() => {
if (this.awaitingElements.size === 0) {
return;
}
this.popAwaiting();
}, POP_AWAITING_INTERVAL, {
group: 'inView',
label: $$.queuePop,
join: true
});
}
/**
* Suspends the specified element or elements by the specified group
*
* @param groupOrElement
* @param [threshold]
*/
suspend(groupOrElement: InViewGroup | Element, threshold?: number): void {
if (groupOrElement instanceof Element) {
const
node = groupOrElement,
{suspendedElements} = this;
if (threshold == null) {
return;
}
const
observable = this.getEl(node, threshold);
if (observable == null) {
return;
}
let
map = suspendedElements.get(node);
if (map == null) {
suspendedElements.set(node, map = new Map());
}
map.set(threshold, observable);
this.unobserve(node, {threshold, suspend: true});
} else {
this.observablesByGroup.get(groupOrElement)?.forEach((elMap) => {
elMap.forEach((observable) => {
this.unobserve(observable.node, {threshold: observable.threshold, suspend: true});
});
});
}
}
/**
* Unsuspends the specified element or elements by the specified group
*
* @param groupOrElement
* @param [threshold]
*/
unsuspend(groupOrElement: InViewGroup | Element, threshold?: number): void {
const
fieldsToReject = ['id', 'isLeaving'];
if (groupOrElement instanceof Element) {
const
{suspendedElements} = this;
if (threshold == null) {
return;
}
const
map = suspendedElements.get(groupOrElement),
observable = map?.get(threshold);
if (observable == null || map == null) {
return;
}
map.delete(threshold);
this.observe(observable.node, <InViewInitOptions>Object.reject(observable, fieldsToReject));
} else {
this.observablesByGroup.get(groupOrElement)?.forEach((elMap) => {
elMap.forEach((observable) => {
this.observe(observable.node, <InViewInitOptions>Object.reject(observable, fieldsToReject));
});
});
}
}
/**
* Returns an observable element
*
* @param el
* @param threshold
*/
getEl(el: Element, threshold: number): CanUndef<InViewObservableElement> {
const map = this.getThresholdMap(el);
return map?.get(threshold);
}
/**
* Returns a threshold map of the specified element
* @param el
*/
getThresholdMap(el: Element): CanUndef<InViewObservableThresholdMap> {
return this.getElMap(el).get(el);
}
/**
* Calls an observer function from the specified object
* @param observable
*/
call(observable: InViewObservableElement): void {
const count = Object.isFunction(observable.count) ?
observable.count() :
observable.count;
if (!count) {
return;
}
if (observable.callback) {
observable.callback(observable);
}
if (observable.once) {
this.unobserve(observable.node, observable.threshold);
}
}
/**
* Starts to observe an element
*
* @param el
* @param opts
*/
observe(el: Element, opts: InViewInitOptions): InViewObservableElement | false {
if (this.getEl(el, opts.threshold)) {
return false;
}
const
{observablesByGroup} = this,
observable = this.createObservable(el, opts);
if (observable.group != null) {
let elMap: Map<Element, Map<number, InViewObservableElement>>;
if (observablesByGroup.has(observable.group)) {
elMap = observablesByGroup.get(observable.group)!;
} else {
observablesByGroup.set(observable.group, elMap = new Map());
}
const
initialMap = elMap.get(el),
thresholdMap = initialMap ?? new Map();
thresholdMap.set(observable.threshold, observable);
if (!initialMap) {
elMap.set(el, thresholdMap);
}
}
if (observable.wait) {
this.putInMap(this.awaitingElements, observable);
} else {
this.initObserve(observable);
}
return observable;
}
/**
* Re-initializes observation of the specified element or group
*
* @param groupOrElement
* @param [threshold]
*/
reObserve(groupOrElement: InViewGroup | Element, threshold?: number): void {
this.suspend(groupOrElement, threshold);
this.unsuspend(groupOrElement, threshold);
}
/**
* Removes or suspends the specified element
*
* @param el
* @param [unobserveOptsOrThreshold]
*/
unobserve(el: Element, unobserveOptsOrThreshold?: InViewUnobserveOptions | number): boolean {
let
threshold: CanUndef<number>,
suspend: CanUndef<boolean>;
if (Object.isNumber(unobserveOptsOrThreshold)) {
threshold = unobserveOptsOrThreshold;
} else if (Object.isPlainObject(unobserveOptsOrThreshold)) {
threshold = unobserveOptsOrThreshold.threshold;
suspend = unobserveOptsOrThreshold.suspend;
}
const
map = this.getElMap(el),
thresholdMap = this.getThresholdMap(el);
if (thresholdMap && threshold === undefined) {
thresholdMap.forEach((observable) => {
this.remove(observable, suspend);
});
return map.delete(el);
}
if (thresholdMap && threshold !== undefined) {
const
observable = thresholdMap.get(threshold);
if (!observable) {
return false;
}
this.remove(observable, suspend);
thresholdMap.delete(threshold);
if (thresholdMap.size === 0) {
map.delete(el);
}
return true;
}
return false;
}
/**
* Creates a new observable element
*
* @param el
* @param opts
*/
protected createObservable(el: Element, opts: InViewInitOptions): InViewObservableElement {
return {
id: String(Math.random()),
group: 'inView:base',
node: el,
count: true,
isLeaving: false,
// @ts-expect-error (override)
threshold: 1,
size: {
width: 0,
height: 0
},
...opts
};
}
/**
* Creates a threshold map
* @param observable
*/
protected createThresholdMap(observable: InViewObservableElement): InViewObservableThresholdMap {
return new Map([[observable.threshold, observable]]);
}
/**
* Puts an observable element into the specified map
*
* @param map
* @param observable
*/
protected putInMap(
map: InViewObservableElementsThresholdMap,
observable: InViewObservableElement
): boolean {
const
thresholdMap = map.get(observable.node);
if (thresholdMap && !thresholdMap.has(observable.threshold)) {
return Boolean(thresholdMap.set(observable.threshold, observable));
}
if (!thresholdMap) {
return Boolean(map.set(observable.node, this.createThresholdMap(observable)));
}
return false;
}
/**
* Returns all maps combined into one
*/
protected maps(): InViewObservableElementsThresholdMap {
return new Map([
...this.elements,
...this.awaitingElements
]);
}
/**
* Returns a map which contains the specified element
* @param el
*/
protected getElMap(el: Element): InViewObservableElementsThresholdMap {
return this.elements.has(el) ? this.elements : this.awaitingElements;
}
/**
* Initializes an element from the observable queue
*/
protected popAwaiting(): void {
const
{awaitingElements} = this;
awaitingElements.forEach((map, node) => {
map.forEach((observable, threshold) => {
const
isResolved = Boolean(observable.wait?.());
if (!isResolved) {
return;
}
this.initObserve(observable);
map.delete(threshold);
});
if (map.size === 0) {
awaitingElements.delete(node);
}
});
}
/**
* Sets a size of the specified observable element
*
* @param observable
* @param size
*/
protected setObservableSize(observable: InViewObservableElement, size: InViewObservableElSize): void {
observable.size.width = size.width;
observable.size.height = size.height;
}
/**
* Removes all async operations from the specified element
* @param el
*/
protected clearAllAsync(el: InViewObservableElement): void {
const
{async: $a} = this;
$a.clearAll({
group: 'inView',
label: el.id
});
}
/**
* Initializes observing for the specified element
* @param observable
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental
protected initObserve(observable: InViewObservableElement): CanUndef<InViewObservableElement> {
return undefined;
}
/**
* Removes an element from the observer data
*
* @param observable
* @param [suspend]
*/
protected remove(observable: InViewObservableElement, suspend?: boolean): boolean {
if (!suspend) {
if (observable.group != null) {
this.observablesByGroup.get(observable.group)?.delete(observable.node);
}
const
map = this.suspendedElements.get(observable.node);
if (map) {
map.delete(observable.threshold);
if (map.size === 0) {
this.suspendedElements.delete(observable.node);
}
}
}
this.clearAllAsync(observable);
return true;
}
}