@egjs/infinitegrid
Version:
A module used to arrange elements including content infinitely according to grid type. With this module, you can implement various grids composed of different card elements whose sizes vary. It guarantees performance by maintaining the number of DOMs the
807 lines (682 loc) • 24 kB
text/typescript
import Grid, {
GetterSetter,
GridFunction, GridItem, GridOptions,
GridOutlines, MOUNT_STATE, Properties, PROPERTY_TYPE,
RenderOptions, UPDATE_STATE,
} from "@egjs/grid";
import { GROUP_TYPE, ITEM_TYPE, STATUS_TYPE } from "./consts";
import { InfiniteGridItem, InfiniteGridItemStatus } from "./InfiniteGridItem";
import { LoadingGrid, LOADING_GROUP_KEY } from "./LoadingGrid";
import { CategorizedGroup, InfiniteGridGroup, InfiniteGridItemInfo } from "./types";
import {
categorize, filterVirtuals, findIndex, findLastIndex,
flat,
flatGroups, getItemInfo, isNumber, makeKey,
range,
setPlaceholder,
splitGridOptions, splitOptions, splitVirtualGroups,
} from "./utils";
export interface InfiniteGridGroupStatus {
type: GROUP_TYPE;
groupKey: string | number;
items: InfiniteGridItemStatus[];
outlines: GridOutlines;
}
export interface GroupManagerOptions extends GridOptions {
appliedItemChecker?: (item: InfiniteGridItem, grid: Grid) => boolean;
gridConstructor: GridFunction | null;
gridOptions: Record<string, any>;
}
export interface GroupManagerStatus {
cursors: [number, number];
orgCursors: [number, number];
itemCursors: [number, number];
startGroupKey: number | string;
endGroupKey: number | string;
groups: InfiniteGridGroupStatus[];
outlines: GridOutlines;
}
export class GroupManager extends Grid<GroupManagerOptions> {
public static defaultOptions: Required<GroupManagerOptions> = {
...Grid.defaultOptions,
appliedItemChecker: () => false,
gridConstructor: null,
gridOptions: {},
};
public static propertyTypes = {
...Grid.propertyTypes,
gridConstructor: PROPERTY_TYPE.PROPERTY,
gridOptions: PROPERTY_TYPE.PROPERTY,
} as const;
protected items: InfiniteGridItem[];
protected groupItems: InfiniteGridItem[] = [];
protected groups: InfiniteGridGroup[] = [];
protected itemKeys: Record<string | number, InfiniteGridItem> = {};
protected groupKeys: Record<string | number, InfiniteGridGroup> = {};
protected startCursor = 0;
protected endCursor = 0;
private _placeholder: Partial<InfiniteGridItemStatus> | null = null;
private _loadingGrid!: LoadingGrid;
private _mainGrid!: Grid;
constructor(container: HTMLElement, options: GroupManagerOptions) {
super(container, splitOptions(options));
this._loadingGrid = new LoadingGrid(container, {
externalContainerManager: this.containerManager,
useFit: false,
autoResize: false,
renderOnPropertyChange: false,
gap: this.gap,
});
this._mainGrid = this._makeGrid();
}
public set gridOptions(options: Record<string, any>) {
const {
gridOptions,
...otherOptions
} = splitGridOptions(options);
const shouldRender = this._checkShouldRender(options);
this.options.gridOptions = {
...this.options.gridOptions,
...gridOptions,
};
[this._mainGrid, ...this.groups.map(({ grid }) => grid)].forEach((grid) => {
for (const name in options) {
(grid as any)[name] = options[name];
}
});
for (const name in otherOptions) {
this[name] = otherOptions[name];
}
this._loadingGrid.gap = this.gap;
if (shouldRender) {
this.scheduleRender();
}
}
public getItemByKey(key: string | number): InfiniteGridItem | null {
return this.itemKeys[key] || null;
}
public getGroupItems(includePlaceholders?: boolean) {
return filterVirtuals(this.groupItems, includePlaceholders);
}
public getVisibleItems(includePlaceholders?: boolean) {
return filterVirtuals(this.items, includePlaceholders);
}
public getRenderingItems() {
if (this.hasPlaceholder()) {
return this.items;
} else {
return this.items.filter((item) => item.type !== ITEM_TYPE.VIRTUAL);
}
}
public getGroups(includePlaceholders?: boolean): InfiniteGridGroup[] {
return filterVirtuals(this.groups, includePlaceholders);
}
public hasVisibleVirtualGroups() {
return this.getVisibleGroups(true).some((group) => group.type === GROUP_TYPE.VIRTUAL);
}
public hasPlaceholder() {
return !!this._placeholder;
}
public hasLoadingItem() {
return !!this._getLoadingItem();
}
public updateItems(items = this.groupItems, options?: RenderOptions) {
return super.updateItems(items, options);
}
public setPlaceholder(placeholder: Partial<InfiniteGridItemStatus> | null) {
this._placeholder = placeholder;
this._updatePlaceholder();
}
public getLoadingType() {
return this._loadingGrid.type;
}
public startLoading(type: "start" | "end") {
this._loadingGrid.type = type;
this.items = this._getRenderingItems();
return true;
}
public waitEndLoading() {
if (!this._loadingGrid.isWaitEnd && this._loadingGrid.type) {
this._loadingGrid.isWaitEnd = true;
return true;
}
return false;
}
public endLoading() {
if (this._loadingGrid.isWaitEnd) {
const prevType = this._loadingGrid.type;
this._loadingGrid.type = "";
this._loadingGrid.endLoading();
this.items = this._getRenderingItems();
return !!prevType;
}
return false;
}
public setLoading(loading: Partial<InfiniteGridItemStatus> | null) {
this._loadingGrid.setLoadingItem(loading);
this.items = this._getRenderingItems();
}
public getVisibleGroups(includePlaceholders?: boolean): InfiniteGridGroup[] {
const groups = this.groups.slice(this.startCursor, this.endCursor + 1);
return filterVirtuals(groups, includePlaceholders);
}
public getComputedOutlineLength(items = this.items) {
return this._mainGrid.getComputedOutlineLength(items);
}
public getComputedOutlineSize(items = this.items) {
return this._mainGrid.getComputedOutlineSize(items);
}
public applyGrid(items: InfiniteGridItem[], direction: "end" | "start", outline: number[]): GridOutlines {
const renderingGroups = this.groups.slice();
if (!renderingGroups.length) {
return {
start: [],
end: [],
};
}
const loadingGrid = this._loadingGrid;
if (loadingGrid.getLoadingItem()) {
if (loadingGrid.type === "start") {
renderingGroups.unshift(this._getLoadingGroup());
} else if (loadingGrid.type === "end") {
renderingGroups.push(this._getLoadingGroup());
}
}
const groups = renderingGroups.slice();
let nextOutline = outline;
if (direction === "start") {
groups.reverse();
}
const appliedItemChecker = this.options.appliedItemChecker;
const groupItems = this.groupItems;
const outlineLength = this.getComputedOutlineLength(groupItems);
const outlineSize = this.getComputedOutlineSize(groupItems);
const itemRenderer = this.itemRenderer;
let passedItems: InfiniteGridItem[] = [];
groups.forEach((group) => {
const grid = group.grid;
const gridItems = grid.getItems() as InfiniteGridItem[];
const isVirtual = group.type === GROUP_TYPE.VIRTUAL && !gridItems[0];
passedItems = direction === "end" ? [...passedItems, ...gridItems] : [...gridItems, ...passedItems];
grid.outlineLength = outlineLength;
grid.outlineSize = outlineSize;
const appliedItems = passedItems.filter((item) => {
if (item.mountState === MOUNT_STATE.UNCHECKED || !item.rect.width) {
itemRenderer.updateItem(item, true);
}
return (item.orgRect.width && item.rect.width) || appliedItemChecker(item, grid);
});
let gridOutlines: GridOutlines;
if (isVirtual) {
gridOutlines = this._applyVirtualGrid(grid, direction, nextOutline);
} else if (appliedItems.length) {
gridOutlines = grid.applyGrid(appliedItems, direction, nextOutline);
} else {
gridOutlines = {
start: [...nextOutline],
end: [...nextOutline],
};
}
grid.setOutlines(gridOutlines);
nextOutline = gridOutlines.passed || gridOutlines[direction];
passedItems = gridOutlines.passedItems?.map((index) => passedItems[index]) ?? [];
});
return {
start: renderingGroups[0].grid.getOutlines().start,
end: renderingGroups[renderingGroups.length - 1].grid.getOutlines().end,
};
}
public syncItems(nextItemInfos: InfiniteGridItemInfo[]) {
const prevItemKeys = this.itemKeys;
this.itemKeys = {};
const nextItems = this._syncItemInfos(nextItemInfos.map((info) => getItemInfo(info)), prevItemKeys);
const prevGroupKeys = this.groupKeys;
let nextManagerGroups = categorize(nextItems);
const startVirtualGroups = this._splitVirtualGroups("start", nextManagerGroups);
const endVirtualGroups = this._splitVirtualGroups("end", nextManagerGroups);
nextManagerGroups = [...startVirtualGroups, ...this._mergeVirtualGroups(nextManagerGroups), ...endVirtualGroups];
const nextGroups: InfiniteGridGroup[] = nextManagerGroups.map(({ groupKey, items }) => {
const isVirtual = !items[0] || items[0].type === ITEM_TYPE.VIRTUAL;
const grid = prevGroupKeys[groupKey]?.grid ?? this._makeGrid();
const gridItems = isVirtual ? items : items.filter(({ type }) => type === ITEM_TYPE.NORMAL);
grid.setItems(gridItems);
return {
type: isVirtual ? GROUP_TYPE.VIRTUAL : GROUP_TYPE.NORMAL,
groupKey,
grid,
items: gridItems,
renderItems: items,
};
});
this._registerGroups(nextGroups);
}
public renderItems(options: RenderOptions = {}) {
if (options.useResize) {
this.groupItems.forEach((item) => {
item.updateState = UPDATE_STATE.NEED_UPDATE;
});
const loadingItem = this._getLoadingItem();
if (loadingItem) {
loadingItem.updateState = UPDATE_STATE.NEED_UPDATE;
}
}
return super.renderItems(options);
}
public setCursors(startCursor: number, endCursor: number) {
this.startCursor = startCursor;
this.endCursor = endCursor;
this.items = this._getRenderingItems();
}
public getStartCursor() {
return this.startCursor;
}
public getEndCursor() {
return this.endCursor;
}
public getGroupStatus(type?: STATUS_TYPE, includePlaceholders?: boolean): GroupManagerStatus {
const orgStartCursor = this.startCursor;
const orgEndCursor = this.endCursor;
const orgGroups = this.groups;
const startGroup = orgGroups[orgStartCursor];
const endGroup = orgGroups[orgEndCursor];
let startCursor = orgStartCursor;
let endCursor = orgEndCursor;
const isMinimizeItems = type === STATUS_TYPE.MINIMIZE_INVISIBLE_ITEMS;
const isMinimizeGroups = type === STATUS_TYPE.MINIMIZE_INVISIBLE_GROUPS;
let groups: InfiniteGridGroup[];
if (type === STATUS_TYPE.REMOVE_INVISIBLE_GROUPS) {
groups = this.getVisibleGroups(includePlaceholders);
endCursor = groups.length - 1;
startCursor = 0;
} else {
groups = this.getGroups(includePlaceholders);
if (!includePlaceholders) {
startCursor = -1;
endCursor = -1;
for (let orgIndex = orgStartCursor; orgIndex <= orgEndCursor; ++orgIndex) {
const orgGroup = orgGroups[orgIndex];
if (orgGroup && orgGroup.type !== GROUP_TYPE.VIRTUAL) {
startCursor = groups.indexOf(orgGroup);
break;
}
}
for (let orgIndex = orgEndCursor; orgIndex >= orgStartCursor; --orgIndex) {
const orgGroup = orgGroups[orgIndex];
if (orgGroup && orgGroup.type !== GROUP_TYPE.VIRTUAL) {
endCursor = groups.lastIndexOf(orgGroup);
break;
}
}
}
}
const groupStatus: InfiniteGridGroupStatus[] = groups.map(({ grid, groupKey }, i) => {
const isOutsideCursor = i < startCursor || endCursor < i;
const isVirtualItems = isMinimizeItems && isOutsideCursor;
const isVirtualGroup = isMinimizeGroups && isOutsideCursor;
const gridItems = grid.getItems() as InfiniteGridItem[];
const items = isVirtualGroup
? []
: gridItems.map((item) => isVirtualItems ? item.getVirtualStatus() : item.getMinimizedStatus());
return {
type: isVirtualGroup || isVirtualItems ? GROUP_TYPE.VIRTUAL : GROUP_TYPE.NORMAL,
groupKey: groupKey,
outlines: grid.getOutlines(),
items,
};
});
const totalItems = this.getGroupItems();
const itemStartCursor = totalItems.indexOf(startGroup?.items[0]);
const itemEndCursor = totalItems.indexOf(endGroup?.items.slice().reverse()[0]);
return {
cursors: [startCursor, endCursor],
orgCursors: [orgStartCursor, orgEndCursor],
itemCursors: [itemStartCursor, itemEndCursor],
startGroupKey: startGroup?.groupKey,
endGroupKey: endGroup?.groupKey,
groups: groupStatus,
outlines: this.outlines,
};
}
protected fitOutlines(useFit = this.useFit) {
const groups = this.groups;
if (!groups[0]) {
return;
}
const outlines = this.outlines;
const startOutline = outlines.start;
const outlineOffset = startOutline.length ? Math.min(...startOutline) : 0;
// If the outline is less than 0, a fit occurs forcibly.
if (!useFit && outlineOffset > 0) {
return;
}
groups.forEach(({ grid }) => {
const { start, end } = grid.getOutlines();
grid.setOutlines({
start: start.map((point) => point - outlineOffset),
end: end.map((point) => point - outlineOffset),
});
});
this.groupItems.forEach((item) => {
const contentPos = item.cssContentPos;
if (!isNumber(contentPos)) {
return;
}
item.cssContentPos = contentPos - outlineOffset;
});
}
public setGroupStatus(status: GroupManagerStatus) {
this.itemKeys = {};
this.groupItems = [];
this.items = [];
const prevGroupKeys = this.groupKeys;
const nextGroups: InfiniteGridGroup[] = status.groups.map(({
type,
groupKey,
items,
outlines,
}) => {
const nextItems = this._syncItemInfos(items);
const grid = prevGroupKeys[groupKey]?.grid ?? this._makeGrid();
grid.setOutlines(outlines);
grid.setItems(nextItems);
return {
type,
groupKey,
grid,
items: nextItems,
renderItems: nextItems,
};
});
this.setOutlines(status.outlines);
this._registerGroups(nextGroups);
this._updatePlaceholder();
this.setCursors(status.cursors[0], status.cursors[1]);
}
public appendPlaceholders(items: number | InfiniteGridItemStatus[], groupKey?: string | number) {
return this.insertPlaceholders("end", items, groupKey);
}
public prependPlaceholders(items: number | InfiniteGridItemStatus[], groupKey?: string | number) {
return this.insertPlaceholders("start", items, groupKey);
}
public removePlaceholders(type: "start" | "end" | { groupKey: string | number }) {
const groups = this.groups;
const length = groups.length;
if (type === "start") {
const index = findIndex(groups, (group) => group.type === GROUP_TYPE.NORMAL);
groups.splice(0, index);
} else if (type === "end") {
const index = findLastIndex(groups, (group) => group.type === GROUP_TYPE.NORMAL);
groups.splice(index + 1, length - index - 1);
} else {
const groupKey = type.groupKey;
const index = findIndex(groups, (group) => group.groupKey === groupKey);
if (index > -1) {
groups.splice(index, 1);
}
}
this.syncItems(flatGroups(this.getGroups()));
}
public insertPlaceholders(
direction: "start" | "end",
items: number | InfiniteGridItemStatus[],
groupKey: string | number = makeKey(this.groupKeys, "virtual_"),
) {
let infos: InfiniteGridItemInfo[] = [];
if (isNumber(items)) {
infos = range(items).map(() => ({ type: ITEM_TYPE.VIRTUAL, groupKey }));
} else if (Array.isArray(items)) {
infos = items.map((status) => ({
groupKey,
...status,
type: ITEM_TYPE.VIRTUAL,
}));
}
const grid = this._makeGrid();
const nextItems = this._syncItemInfos(infos, this.itemKeys);
this._updatePlaceholder(nextItems);
grid.setItems(nextItems);
const group = {
type: GROUP_TYPE.VIRTUAL,
groupKey,
grid,
items: nextItems,
renderItems: nextItems,
};
this.groupKeys[groupKey] = group;
if (direction === "end") {
this.groups.push(group);
this.groupItems.push(...nextItems);
} else {
this.groups.splice(0, 0, group);
this.groupItems.splice(0, 0, ...nextItems);
if (this.startCursor > -1) {
++this.startCursor;
++this.endCursor;
}
}
return {
group,
items: nextItems,
};
}
public shouldRerenderItems() {
let isRerender = false;
this.getVisibleGroups().forEach((group) => {
const items = group.items;
if (
items.length === group.renderItems.length
|| items.every((item) => item.mountState === MOUNT_STATE.UNCHECKED)
) {
return;
}
isRerender = true;
group.renderItems = [...items];
});
if (isRerender) {
this.items = this._getRenderingItems();
}
return isRerender;
}
// protected checkReady(options: RenderOptions = {}) {
// const items = this.items;
// const updated = items.filter((item) => item.element?.parentNode && item.updateState !== UPDATE_STATE.UPDATED);
// const mounted = items.filter((item) => item.element?.parentNode && item.mountState !== MOUNT_STATE.MOUNTED);
// if (updated.length && updated.every((item) => item.type != ITEM_TYPE.NORMAL)) {
// this._updateItems(updated);
// this.readyItems(mounted, updated, options);
// } else {
// super.checkReady(options);
// }
// }
protected _updateItems(items: GridItem[]): void {
this.itemRenderer.updateEqualSizeItems(items, this.groupItems);
}
private _getGroupItems() {
return flatGroups(this.getGroups(true));
}
private _getRenderingItems() {
const items = flat(this.getVisibleGroups(true).map((item) => item.renderItems));
const loadingGrid = this._loadingGrid;
const loadingItem = loadingGrid.getLoadingItem();
if (loadingItem) {
if (loadingGrid.type === "end") {
items.push(loadingItem);
} else if (loadingGrid.type === "start") {
items.unshift(loadingItem);
}
}
return items;
}
private _checkShouldRender(options: Record<string, any>) {
const GridConstructor = this.options.gridConstructor!;
const prevOptions = this.gridOptions;
const propertyTypes = GridConstructor.propertyTypes;
for (const name in prevOptions) {
if (!(name in options) && propertyTypes[name] === PROPERTY_TYPE.RENDER_PROPERTY) {
return true;
}
}
for (const name in options) {
if (prevOptions[name] !== options[name] && propertyTypes[name] === PROPERTY_TYPE.RENDER_PROPERTY) {
return true;
}
}
return false;
}
private _applyVirtualGrid(grid: Grid, direction: "start" | "end", outline: number[]) {
const startOutline = outline.length ? [...outline] : [0];
const prevOutlines = grid.getOutlines();
const prevOutline = prevOutlines[direction === "end" ? "start" : "end"];
if (
prevOutline.length !== startOutline.length
|| prevOutline.some((value, i) => value !== startOutline[i])
) {
return {
start: [...startOutline],
end: [...startOutline],
};
}
return prevOutlines;
}
private _syncItemInfos(
nextItemInfos: InfiniteGridItemStatus[],
prevItemKeys: Record<string | number, InfiniteGridItem> = {},
) {
const horizontal = this.options.horizontal;
const nextItemKeys = this.itemKeys;
nextItemInfos.filter((info) => info.key != null).forEach((info) => {
const key = info.key!;
const prevItem = prevItemKeys[key];
if (!prevItem) {
nextItemKeys[key] = new InfiniteGridItem(horizontal, {
...info,
});
} else if (prevItem.type === ITEM_TYPE.VIRTUAL && info.type !== ITEM_TYPE.VIRTUAL) {
nextItemKeys[key] = new InfiniteGridItem(horizontal, {
orgRect: prevItem.orgRect,
rect: prevItem.rect,
...info,
});
} else {
if (info.data) {
prevItem.data = info.data;
}
if (info.groupKey != null) {
prevItem.groupKey = info.groupKey!;
}
if (info.element) {
prevItem.element = info.element;
}
nextItemKeys[key] = prevItem;
}
});
const nextItems = nextItemInfos.map((info) => {
let key = info.key!;
if (info.key == null) {
key = makeKey(nextItemKeys, info.type === ITEM_TYPE.VIRTUAL ? "virtual_" : "");
}
let item = nextItemKeys[key];
if (!item) {
const prevItem = prevItemKeys[key];
if (prevItem) {
item = prevItem;
if (info.data) {
item.data = info.data;
}
if (info.element) {
item.element = info.element;
}
} else {
item = new InfiniteGridItem(horizontal, {
...info,
key,
});
}
nextItemKeys[key] = item;
}
return item;
});
return nextItems;
}
private _registerGroups(groups: InfiniteGridGroup[]) {
const nextGroupKeys: Record<string | number, InfiniteGridGroup> = {};
groups.forEach((group) => {
nextGroupKeys[group.groupKey] = group;
});
this.groups = groups;
this.groupKeys = nextGroupKeys;
this.groupItems = this._getGroupItems();
}
private _splitVirtualGroups(direction: "start" | "end", nextGroups: CategorizedGroup[]) {
const groups = splitVirtualGroups(this.groups, direction, nextGroups);
const itemKeys = this.itemKeys;
groups.forEach(({ renderItems }) => {
renderItems.forEach((item) => {
itemKeys[item.key] = item;
});
});
return groups;
}
private _mergeVirtualGroups(groups: Array<CategorizedGroup<InfiniteGridItem>>) {
const itemKeys = this.itemKeys;
const groupKeys = this.groupKeys;
groups.forEach((group) => {
const prevGroup = groupKeys[group.groupKey];
if (!prevGroup) {
return;
}
const items = group.items;
if (items.every((item) => item.mountState === MOUNT_STATE.UNCHECKED)) {
prevGroup.renderItems.forEach((item) => {
if (item.type === ITEM_TYPE.VIRTUAL && !itemKeys[item.key]) {
items.push(item);
itemKeys[item.key] = item;
}
});
}
});
return groups;
}
private _updatePlaceholder(items = this.groupItems) {
const placeholder = this._placeholder;
if (!placeholder) {
return;
}
items.filter((item) => item.type === ITEM_TYPE.VIRTUAL).forEach((item) => {
setPlaceholder(item, placeholder);
});
}
private _makeGrid() {
const GridConstructor = this.options.gridConstructor!;
const gridOptions = this.gridOptions;
const container = this.containerElement;
return new GridConstructor(container, {
...gridOptions,
useFit: false,
autoResize: false,
useResizeObserver: false,
observeChildren: false,
renderOnPropertyChange: false,
externalContainerManager: this.containerManager,
externalItemRenderer: this.itemRenderer,
});
}
private _getLoadingGroup(): InfiniteGridGroup {
const loadingGrid = this._loadingGrid;
const items = loadingGrid.getItems() as InfiniteGridItem[];
return {
groupKey: LOADING_GROUP_KEY,
type: GROUP_TYPE.NORMAL,
grid: loadingGrid,
items,
renderItems: items,
};
}
private _getLoadingItem() {
return this._loadingGrid.getLoadingItem();
}
}
export interface GroupManager extends Properties<typeof GroupManager> {
getItems(): InfiniteGridItem[];
}