@v4fire/client
Version:
V4Fire client core library
450 lines (363 loc) • 12.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
*/
/**
* [[include:base/b-virtual-scroll-new/README.md]]
* @packageDocumentation
*/
import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';
import type { AsyncOptions } from 'core/async';
import type iItems from 'traits/i-items/i-items';
import { iVirtualScrollHandlers } from 'base/b-virtual-scroll-new/handlers';
import { bVirtualScrollAsyncGroup, bVirtualScrollDomInsertAsyncGroup, componentModes, renderGuardRejectionReason } from 'base/b-virtual-scroll-new/const';
import type { VirtualScrollState, RenderGuardResult, $ComponentRefs, UnsafeBVirtualScroll, ItemsProcessors, ComponentMode } from 'base/b-virtual-scroll-new/interface';
import { ComponentTypedEmitter, componentTypedEmitter } from 'base/b-virtual-scroll-new/modules/emitter';
import { ComponentInternalState } from 'base/b-virtual-scroll-new/modules/state';
import { SlotsStateController } from 'base/b-virtual-scroll-new/modules/slots';
import { ComponentFactory } from 'base/b-virtual-scroll-new/modules/factory';
import { Observer } from 'base/b-virtual-scroll-new/modules/observer';
import iData, { component, system, watch, wait, RequestParams, UnsafeGetter } from 'super/i-data/i-data';
export * from 'base/b-virtual-scroll-new/interface';
export * from 'base/b-virtual-scroll-new/const';
export * from 'super/i-data/i-data';
const $$ = symbolGenerator();
export default class bVirtualScrollNew extends iVirtualScrollHandlers implements iItems {
/** {@link componentTypedEmitter} */
<bVirtualScrollNew>((ctx) => componentTypedEmitter(ctx))
protected readonly componentEmitter!: ComponentTypedEmitter;
/** {@link SlotsStateController} */
<bVirtualScrollNew>((ctx) => new SlotsStateController(ctx))
protected readonly slotsStateController!: SlotsStateController;
/** {@link ComponentInternalState} */
<bVirtualScrollNew>((ctx) => new ComponentInternalState(ctx))
protected readonly componentInternalState!: ComponentInternalState;
/** {@link ComponentFactory} */
<bVirtualScrollNew>((ctx) => new ComponentFactory(ctx))
protected readonly componentFactory!: ComponentFactory;
/** {@link Observer} */
<bVirtualScrollNew>((ctx) => new Observer(ctx))
protected readonly observer!: Observer;
/**
* `itemsProcessors` involved in the current rendering lifecycle
*/
protected currentItemsProcessors?: ItemsProcessors;
protected override readonly $refs!: iData['$refs'] & $ComponentRefs;
// @ts-ignore (getter instead readonly)
override get requestParams(): iData['requestParams'] {
return {
get: {
...this.requestQuery?.(this.getVirtualScrollState())?.get,
...Object.isDictionary(this.request?.get) ? this.request?.get : undefined
}
};
}
override set requestParams(_value: RequestParams) {
// ...
}
override get unsafe(): UnsafeGetter<UnsafeBVirtualScroll<this>> {
return Object.cast(this);
}
/**
* {@link ComponentMode}
*/
get componentMode(): ComponentMode {
return this.items ? componentModes.items : componentModes.dataProvider;
}
/**
* Initializes the loading of the next data chunk
* @throws {@link ReferenceError} if there is no `dataProvider` set.
*/
initLoadNext(): CanUndef<CanPromise<void>> {
if (!this.dp) {
throw ReferenceError('Missing dataProvider');
}
const
state = this.getVirtualScrollState();
if (state.isLoadingInProgress) {
return;
}
if (this.db == null) {
return this.initLoad();
}
this.onDataLoadStart(false);
const
params = this.getRequestParams(),
get = this.get(params[0], {...params[1], showProgress: false});
return get
.then((res) => {
this.onDataLoadSuccess(false, this.convertDataToDB(res));
})
.catch(stderr);
}
/**
* Returns the internal component state
* {@link VirtualScrollState}
*/
getVirtualScrollState(): Readonly<VirtualScrollState> {
return this.componentInternalState.compile();
}
/**
* Returns the next slice of data that should be rendered
*
* @param state
* @param chunkSize
*/
getNextDataSlice(state: VirtualScrollState, chunkSize: number): object[] {
const
nextDataSliceStartIndex = this.componentInternalState.getDataOffset(),
nextDataSliceEndIndex = nextDataSliceStartIndex + chunkSize;
return state.data.slice(nextDataSliceStartIndex, nextDataSliceEndIndex);
}
/**
* Returns the chunk size that should be rendered
* @param state - current lifecycle state.
*/
getChunkSize(state: VirtualScrollState): number {
return Object.isFunction(this.chunkSize) ?
this.chunkSize(state, this) :
this.chunkSize;
}
/**
* Returns the amount of data that should be preloaded
* @param state - current lifecycle state
*/
getPreloadAmount(state: VirtualScrollState): number {
return Object.isFunction(this.preloadAmount) ?
this.preloadAmount(state, this) :
this.preloadAmount;
}
/**
* Returns an items processors
* @returns
*/
getItemsProcessors(): CanUndef<ItemsProcessors> {
return this.itemsProcessors;
}
override reload(...args: Parameters<iData['reload']>): ReturnType<iData['reload']> {
this.componentStatus = 'loading';
return super.reload(...args);
}
override initLoad(...args: Parameters<iData['initLoad']>): ReturnType<iData['initLoad']> {
if (!this.lfc.isBeforeCreate()) {
this.reset();
}
this.componentInternalState.setIsLoadingInProgress(true);
const
initLoadResult = super.initLoad(...args),
initLoadPromise = Object.isPromise(initLoadResult) ? initLoadResult : SyncPromise.resolve(),
wrappedInitLoadPromise = this.async.promise(initLoadPromise, {
label: $$.initLoad,
group: bVirtualScrollAsyncGroup,
join: 'replace'
});
if (this.componentMode === componentModes.items) {
return wrappedInitLoadPromise
.then(() => this.initItems())
.catch(stderr);
}
this.onDataLoadStart(true);
wrappedInitLoadPromise
.then(() => {
if (this.db == null) {
return;
}
this.onDataLoadSuccess(true, this.db);
})
.catch(stderr);
}
/**
* Initializes the data passed through the items prop
*/
protected initItems(): CanPromise<void> {
if (
this.componentMode !== componentModes.items ||
!this.items
) {
return;
}
this.onItemsInit(this.items);
}
protected override convertDataToDB<O>(data: unknown): O | this['DB'] {
this.onConvertDataToDB(data);
const result = super.convertDataToDB(data);
return <O | this['DB']>result;
}
/**
* Merges all request parameters from the component fields `requestProp` and `requestQuery`
* {@link RequestParams}
*/
protected getRequestParams(): RequestParams {
const label: AsyncOptions = {
label: $$.initLoadNext,
group: bVirtualScrollAsyncGroup,
join: 'replace'
};
const defParams = this.getDefaultRequestParams('get');
if (Array.isArray(defParams)) {
Object.assign(defParams[1], label);
}
return <RequestParams>defParams;
}
/**
* Short-hand wrapper for calling {@link bVirtualScrollNew.shouldStopRequestingData}, which also caches the
* result of the call and, if {@link bVirtualScrollNew.shouldStopRequestingData} returns `true`, does not call
* this function again until the life cycle is updated and the state is reset.
*/
protected shouldStopRequestingDataWrapper(): boolean {
if (this.componentMode === componentModes.items) {
this.componentInternalState.setIsRequestsStopped(true);
return true;
}
const state = this.getVirtualScrollState();
if (state.areRequestsStopped) {
return state.areRequestsStopped;
}
const newVal = this.shouldStopRequestingData(state, this);
this.componentInternalState.setIsRequestsStopped(newVal);
return newVal;
}
/**
* Resets the component state to its initial state
*/
protected reset(): void {
this.currentItemsProcessors = undefined;
this.onReset();
}
/**
* This function asks the client whether rendering can be performed.
* It is called after successful data load or when the child component enters the visible area.
* The client responds with an object indicating whether rendering is allowed or the reason for denial.
*
* Based on the result of this function, the component takes appropriate actions. For example,
* it may load data if it is not sufficient for rendering, or perform rendering if all conditions are met.
*
* @param state
*/
protected renderGuard(state: VirtualScrollState): RenderGuardResult {
const
chunkSize = this.getChunkSize(state),
dataSlice = this.getNextDataSlice(state, chunkSize);
if (dataSlice.length < chunkSize) {
if (state.areRequestsStopped && state.isLastRender) {
return {
result: false,
reason: renderGuardRejectionReason.done
};
}
const
clientResponse = this.shouldPerformDataRender?.(state, this) ?? true;
if (clientResponse) {
return {
result: false,
reason: renderGuardRejectionReason.notEnoughData
};
}
return {
result: clientResponse,
reason: renderGuardRejectionReason.noPermission
};
}
if (state.isInitialRender) {
return {
result: true
};
}
const
clientResponse = this.shouldPerformDataRender?.(state, this),
result = clientResponse || state.isTombstonesInView;
return {
result,
reason: !result ? renderGuardRejectionReason.noPermission : undefined
};
}
/**
* A function that performs actions (data loading/rendering) depending
* on the result of the {@link bVirtualScrollNew.renderGuard} method.
*
* This function is the "starting point" for rendering components and is called after successful data loading
* or when rendered items enter the viewport.
*/
protected loadDataOrPerformRender(): void {
const
state = this.getVirtualScrollState();
if (state.isLastErrored) {
return;
}
const
{result, reason} = this.renderGuard(state);
if (result) {
this.performRender();
}
switch (reason) {
case renderGuardRejectionReason.done:
this.onLifecycleDone();
break;
case renderGuardRejectionReason.notEnoughData:
if (state.areRequestsStopped) {
this.performRender();
this.onLifecycleDone();
return;
}
void this.initLoadNext();
break;
default: {
const
preloadAmount = this.getPreloadAmount(state),
dataOffset = this.componentInternalState.getDataOffset();
if (
!state.areRequestsStopped &&
state.data.length - dataOffset - preloadAmount < 0
) {
void this.initLoadNext();
}
}
}
}
/**
* Renders components using {@link bVirtualScrollNew.componentFactory} and inserts them into the DOM tree
*/
protected performRender(): void {
this.onRenderStart();
const
items = this.componentFactory.produceComponentItems(),
nodes = this.componentFactory.produceNodes(items),
mounted = this.componentFactory.produceMounted(items, nodes);
if (mounted.length === 0) {
return this.onRenderDone();
}
this.observer.observe(mounted);
this.onDomInsertStart(mounted);
const
fragment = document.createDocumentFragment(),
{renderPage} = this.getVirtualScrollState(),
asyncGroup = `${bVirtualScrollDomInsertAsyncGroup}:${renderPage}`;
nodes.forEach((node) => {
this.dom.appendChild(fragment, node, {
group: asyncGroup,
destroyIfComponent: true
});
});
this.componentInternalState.setIsDomInsertInProgress(true);
this.async.requestAnimationFrame(() => {
const
state = this.getVirtualScrollState();
this.slotsStateController.loadingSuccessState(true);
if (state.isLoadingInProgress) {
this.slotsStateController.loadingProgressState();
}
this.$refs.container.appendChild(fragment);
this.componentInternalState.setIsDomInsertInProgress(false);
this.onDomInsertDone();
this.onRenderDone();
}, {label: $$.insertDomRaf, group: asyncGroup});
}
}