@v4fire/client
Version:
V4Fire client core library
410 lines (333 loc) • 8.92 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 { InViewAdapter, InViewInitOptions, inViewFactory } from 'core/dom/in-view';
import iBlock, { Friend } from 'super/i-block/i-block';
import type bVirtualScroll from 'base/b-virtual-scroll/b-virtual-scroll';
import type ComponentRender from 'base/b-virtual-scroll/modules/component-render';
import type ChunkRequest from 'base/b-virtual-scroll/modules/chunk-request';
import type { RenderItem, VirtualItemEl } from 'base/b-virtual-scroll/interface';
export const
$$ = symbolGenerator();
export default class ChunkRender extends Friend {
override readonly C!: bVirtualScroll;
/**
* Render items
*/
items: RenderItem[] = [];
/**
* Index of the last element that intersects the viewport
*/
lastIntersectsItem: number = 0;
/**
* Chunk number of the current render
*/
chunk: number = 0;
/**
* Last rendered range
*/
lastRenderRange: number[] = [0, 0];
/**
* Async group
*/
readonly asyncGroup: string = 'scroll-render:';
/**
* Number of items
*/
get itemsCount(): number {
return this.items.length;
}
/**
* Async in-view label prefix
*/
protected readonly asyncInViewPrefix: string = 'in-view:';
/**
* Local in-view instance
*/
protected readonly InView: InViewAdapter = inViewFactory();
/**
* Refs state update map
*/
protected refsUpdateMap: Map<keyof bVirtualScroll['$refs'], boolean> = new Map();
/**
* API for dynamic component rendering
*/
protected get componentRender(): ComponentRender {
return this.ctx.componentRender;
}
/**
* API for scroll data requests
*/
protected get chunkRequest(): ChunkRequest {
return this.ctx.chunkRequest;
}
/**
* Returns a random threshold number
*/
protected get randomThreshold(): number {
return Math.floor((Math.random() * (0.06 - 0.01) + 0.01) * 100) / 100;
}
constructor(component: iBlock) {
super(component);
this.component.on('componentHook:mounted', this.initEventHandlers.bind(this));
}
/**
* Re-initializes the rendering process
*/
reInit(): void {
this.lastIntersectsItem = 0;
this.lastRenderRange = [0, 0];
this.chunk = 0;
this.items = [];
this.refsUpdateMap = new Map();
this.async.clearAll({group: new RegExp(this.asyncGroup)});
this.setLoadersVisibility(true, true);
this.setRefVisibility('retry', false, true);
this.setRefVisibility('done', false, true);
this.setRefVisibility('empty', false, true);
this.setRefVisibility('renderNext', false, true);
this.initEventHandlers();
}
/**
* Initializes render items
* @param data
*/
initItems(data: unknown[]): void {
this.items = this.items.concat(data.map(this.createRenderItem.bind(this)));
}
/**
* Renders the component content
*
* @emits `chunkRender:renderStart(chunkNumber: number)`
* @emits `chunkRender:renderComplete(chunkNumber: number)`
* @emits `chunkRender:beforeMount(chunkNumber: number)`
* @emits `chunkRender:mounted(renderItems:` [[RenderItem]]`[], chunkNumber: number)`
*/
render(): void {
if (this.ctx.localState !== 'ready') {
return;
}
const
{ctx, chunk, items} = this;
const
renderFrom = (chunk - 1) * ctx.chunkSize,
renderTo = chunk * ctx.chunkSize,
renderItems = items.slice(renderFrom, renderTo);
if (
renderFrom === this.lastRenderRange[0] &&
renderTo === this.lastRenderRange[1] ||
renderItems.length === 0
) {
return;
}
const
currentChunk = this.chunk;
this.chunk++;
this.lastRenderRange = [renderFrom, renderTo];
ctx.emit('chunkRender:renderStart', currentChunk);
const
nodes = this.renderItems(renderItems);
ctx.emit('chunkRender:renderComplete', currentChunk);
ctx.emit('chunkRender:beforeMount', currentChunk);
if (nodes.length === 0) {
return;
}
const
fragment = document.createDocumentFragment();
for (let i = 0; i < nodes.length; i++) {
this.dom.appendChild(fragment, nodes[i], {
group: this.asyncGroup,
destroyIfComponent: true
});
}
this.async.requestAnimationFrame(() => {
this.refs.container.appendChild(fragment);
ctx.emit('chunkRender:mounted', renderItems, currentChunk);
}, {group: this.asyncGroup});
}
/**
* Hides or shows the specified ref
*
* @param ref
* @param show
* @param [immediate] - if settled as `true` will immediately update a DOM tree
*/
setRefVisibility(ref: keyof bVirtualScroll['$refs'], show: boolean, immediate: boolean = false): void {
const
refEl = this.refs[ref];
if (!refEl) {
return;
}
if (immediate) {
refEl.style.display = show ? '' : 'none';
return;
}
this.refsUpdateMap.set(ref, show);
this.performRefsVisibilityUpdate();
}
/**
* Hides or shows refs of the loader and tombstones
*
* @param show
* @param [immediate] - if settled as `true` will immediately update a DOM tree
*/
setLoadersVisibility(show: boolean, immediate: boolean = false): void {
this.setRefVisibility('tombstones', show, immediate);
this.setRefVisibility('loader', show, immediate);
}
/**
* Tries to show the `renderNext` slot
*/
tryShowRenderNextSlot(): void {
const
{ctx, chunkRequest} = this;
if (ctx.dataProvider == null && ctx.options.length === 0) {
return;
}
if (chunkRequest.isDone) {
return;
}
this.setRefVisibility('renderNext', true);
}
/**
* Updates visibility of refs by using `requestAnimationFrame`
*/
protected performRefsVisibilityUpdate(): void {
this.async.requestAnimationFrame(() => {
this.refsUpdateMap.forEach((show, ref) => {
const
state = show ? '' : 'none',
refEl = this.refs[ref];
if (!refEl) {
return;
}
refEl.style.display = state;
});
this.refsUpdateMap.clear();
}, {label: $$.updateRefsVisibility, group: this.asyncGroup, join: true});
}
/**
* Event handlers initialization
*/
protected initEventHandlers(): void {
const label = {label: $$.reInit};
this.ctx.localEmitter.once('localState.ready', this.onReady.bind(this), label);
this.ctx.localEmitter.once('localState.error', this.onError.bind(this), label);
}
/**
* Renders the specified items
* @param items
*/
protected renderItems(items: RenderItem[]): HTMLElement[] {
const
nodes = this.componentRender.render(items);
for (let i = 0; i < nodes.length; i++) {
const
node = nodes[i],
item = items[i];
item.node = node;
const itemsData = {
current: item.data,
prev: items[i - 1]?.data,
next: items[i + 1]?.data
};
if (!Object.isFunction(node[$$.inView])) {
this.wrapInView(item, itemsData);
}
}
return nodes;
}
/**
* Wraps the specified item node with the `in-view` directive
* @param item
* @param itemData
*/
protected wrapInView(item: RenderItem, itemData: VirtualItemEl): void {
const
{ctx} = this,
{node} = item;
if (ctx.loadStrategy === 'manual') {
return;
}
const
label = `${this.asyncGroup}:${this.asyncInViewPrefix}${ctx.getItemKey(itemData, item.index)}`;
if (!node) {
return;
}
const
inViewOptions = this.getInViewOptions(item.index);
this.InView
.observe(node, inViewOptions);
node[$$.inView] = this.async.worker(() => this.InView.remove(node, inViewOptions.threshold), {
group: this.asyncGroup,
label
});
}
/**
* Returns a render item by the specified parameters
*
* @param data - data to render in item
* @param index - index of the item
*/
protected createRenderItem(data: object, index: number): RenderItem {
return {
data,
index: this.itemsCount + index,
node: undefined,
destructor: undefined
};
}
/**
* Returns options to initialize the `in-view` directive
* @param index
*/
protected getInViewOptions(index: number): InViewInitOptions {
return {
delay: 0,
threshold: this.randomThreshold,
once: !this.ctx.clearNodes,
onEnter: () => this.onNodeIntersect(index)
};
}
/**
* Handler: element becomes visible in the viewport
* @param index
*/
protected onNodeIntersect(index: number): void {
const
{ctx, items, lastIntersectsItem} = this,
{chunkSize, renderGap} = ctx;
const
currentRender = (this.chunk - 1) * chunkSize;
this.lastIntersectsItem = index;
if (index + renderGap + chunkSize >= items.length) {
this.chunkRequest.try().catch(stderr);
}
if (index >= lastIntersectsItem) {
if (currentRender - index <= renderGap) {
this.render();
}
}
}
/**
* Handler: component ready
*/
protected onReady(): void {
this.setLoadersVisibility(false);
this.chunk++;
this.render();
}
/**
* Handler: error occurred
*/
protected onError(): void {
this.setLoadersVisibility(false);
this.setRefVisibility('renderNext', false);
this.setRefVisibility('retry', true);
}
}