/**
* egjs-grid
* Copyright (c) 2021-present NAVER Corp.
* MIT license
*/
import Component from "@egjs/component";
import { DEFAULT_GRID_OPTIONS, GRID_PROPERTY_TYPES, MOUNT_STATE, UPDATE_STATE } from "./consts";
import { ContainerManager } from "./ContainerManager";
import {
DestroyOptions, GridEvents, GridOptions,
GridOutlines, GridStatus, Properties, RenderOptions,
OnRenderComplete,
} from "./types";
import ImReady from "@egjs/imready";
import { ItemRenderer } from "./ItemRenderer";
import { GetterSetter, getMountedElements, isNumber, isString, getUpdatedItems } from "./utils";
import { diff } from "@egjs/children-differ";
import { GridItem } from "./GridItem";
import { ResizeWatcherResizeEvent } from "./ResizeWatcher";
/**
* @extends eg.Component
*/
@GetterSetter
abstract class Grid<Options extends GridOptions = GridOptions> extends Component<GridEvents> {
public static defaultOptions: Required<GridOptions> = DEFAULT_GRID_OPTIONS;
public static propertyTypes = GRID_PROPERTY_TYPES;
public options: Required<Options>;
protected containerElement: HTMLElement;
protected containerManager: ContainerManager;
protected itemRenderer!: ItemRenderer;
protected items: GridItem[] = [];
protected outlines: GridOutlines = {
start: [],
end: [],
};
private _renderTimer = 0;
private _im: ImReady;
/**
* Apply the CSS rect of items to fit the Grid and calculate the outline.
* @ko Grid에 맞게 아이템들의 CSS rect를 적용하고 outline을 계산한다.
* @abstract
* @method Grid#applyGrid
* @param {"start" | "end"} direcion - The direction to apply the Grid. ("end": start to end, "start": end to start) <ko>Grid를 적용할 방향. ("end": 시작에서 끝 방향, "start": 끝에서 시작 방향)</ko>
* @param {number[]} outline - The start outline to apply the Grid. <ko>Grid를 적용할 시작 outline.</ko>
*/
public abstract applyGrid(items: GridItem[], direction: "start" | "end", outline: number[]): GridOutlines;
/**
* @param - A base element for a module <ko>모듈을 적용할 기준 엘리먼트</ko>
* @param - The option object of the Grid module <ko>Grid 모듈의 옵션 객체</ko>
*/
constructor(containerElement: HTMLElement | string, options: Partial<Options> = {}) {
super();
this.options = {
...((this.constructor as typeof Grid)
.defaultOptions as Required<Options>),
...options,
};
this.containerElement = isString(containerElement)
? document.querySelector<HTMLElement>(containerElement)!
: containerElement;
const {
isEqualSize,
isConstantSize,
useTransform,
horizontal,
percentage,
externalContainerManager,
externalItemRenderer,
resizeDebounce,
maxResizeDebounce,
autoResize,
useRoundedSize,
useResizeObserver,
} = this.options;
// TODO: 테스트용 설정
this.containerManager = externalContainerManager!
|| new ContainerManager(this.containerElement, {
horizontal,
resizeDebounce,
maxResizeDebounce,
autoResize,
useResizeObserver,
}).on("resize", this._onResize);
this.itemRenderer = externalItemRenderer!
|| new ItemRenderer({
useTransform,
isEqualSize,
isConstantSize,
percentage,
useRoundedSize,
});
this._init();
}
/**
* Return Container Element.
* @ko 컨테이너 엘리먼트를 반환한다.
*/
public getContainerElement(): HTMLElement {
return this.containerElement;
}
/**
* Return items.
* @ko 아이템들을 반환한다.
*/
public getItems(): GridItem[] {
return this.items;
}
/**
* Returns the children of the container element.
* @ko 컨테이너 엘리먼트의 children을 반환한다.
*/
public getChildren(): HTMLElement[] {
return [].slice.call(this.containerElement.children);
}
/**
* Set items.
* @ko 아이템들을 설정한다.
* @param items - The items to set. <ko>설정할 아이템들</ko>
*/
public setItems(items: GridItem[]): this {
items.forEach((item, i) => {
item.index = i;
});
const options = this.options;
if (options.useResizeObserver && options.observeChildren) {
const containerManager = this.containerManager;
containerManager.unobserveChildren(getMountedElements(this.items));
containerManager.observeChildren(getMountedElements(items));
}
this.items = items;
return this;
}
/**
* Gets the container's inline size. ("width" if horizontal is false, otherwise "height")
* @ko container의 inline 사이즈를 가져온다. (horizontal이 false면 "width", 아니면 "height")
*/
public getContainerInlineSize(): number {
return this.containerManager.getInlineSize()!;
}
/**
* Returns the outlines of the start and end of the Grid.
* @ko Grid의 처음과 끝의 outline을 반환한다.
*/
public getOutlines(): GridOutlines {
return this.outlines;
}
/**
* Set outlines.
* @ko 아웃라인을 설정한다.
* @param outlines - The outlines to set. <ko>설정할 아웃라인.</ko>
*/
public setOutlines(outlines: GridOutlines) {
this.outlines = outlines;
return this;
}
/**
* When elements change, it synchronizes and renders items.
* @ko elements가 바뀐 경우 동기화를 하고 렌더링을 한다.
* @param - Options for rendering. <ko>렌더링을 하기 위한 옵션.</ko>
*/
public syncElements(options: RenderOptions = {}) {
const items = this.items;
const { horizontal } = this.options;
const elements: HTMLElement[] = this.getChildren();
const { added, maintained, changed, removed } = diff(this.items.map((item) => item.element!), elements);
const nextItems: GridItem[] = [];
maintained.forEach(([beforeIndex, afterIndex]) => {
nextItems[afterIndex] = items[beforeIndex];
});
added.forEach((index) => {
nextItems[index] = new GridItem(horizontal!, {
element: elements[index],
});
});
this.setItems(nextItems);
if (added.length || removed.length || changed.length) {
this.renderItems(options);
}
return this;
}
/**
* Update the size of the items and render them.
* @ko 아이템들의 사이즈를 업데이트하고 렌더링을 한다.
* @param - Items to be updated. <ko>업데이트할 아이템들.</ko>
* @param - Options for rendering. <ko>렌더링을 하기 위한 옵션.</ko>
*/
public updateItems(items: GridItem[] = this.items, options: RenderOptions = {}) {
const useOrgResize = options.useOrgResize;
items.forEach((item) => {
if (useOrgResize) {
const orgRect = item.orgRect;
orgRect.width = 0;
orgRect.height = 0;
}
item.updateState = UPDATE_STATE.NEED_UPDATE;
});
this.checkReady(options);
return this;
}
/**
* Rearrange items to fit the grid and render them. When rearrange is complete, the `renderComplete` event is fired.
* @ko grid에 맞게 아이템을 재배치하고 렌더링을 한다. 배치가 완료되면 `renderComplete` 이벤트가 발생한다.
* @param - Options for rendering. <ko>렌더링을 하기 위한 옵션.</ko>
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
* const grid = new MasonryGrid();
*
* grid.on("renderComplete", e => {
* console.log(e);
* });
* grid.renderItems();
* ```
*/
public renderItems(options: RenderOptions = {}) {
this._renderItems(options);
return this;
}
/**
* Returns current status such as item's position, size. The returned status can be restored with the setStatus() method.
* @ko 아이템의 위치, 사이즈 등 현재 상태를 반환한다. 반환한 상태는 setStatus() 메서드로 복원할 수 있다.
* @param - Whether to minimize the status of the item. (default: false) <ko>item의 status를 최소화할지 여부. (default: false)</ko>
*/
public getStatus(minimize?: boolean): GridStatus {
return {
outlines: this.outlines,
items: this.items.map((item) => minimize ? item.getMinimizedStatus() : item.getStatus()),
containerManager: this.containerManager.getStatus(),
itemRenderer: this.itemRenderer.getStatus(),
};
}
/**
* Set status of the Grid module with the status returned through a call to the getStatus() method.
* @ko getStatus() 메서드에 대한 호출을 통해 반환된 상태로 Grid 모듈의 상태를 설정한다.
*/
public setStatus(status: GridStatus) {
const horizontal = this.options.horizontal;
const containerManager = this.containerManager;
const prevInlineSize = containerManager.getInlineSize();
const children = this.getChildren();
this.itemRenderer.setStatus(status.itemRenderer);
containerManager.setStatus(status.containerManager);
this.outlines = status.outlines;
this.items = status.items.map((item, i) => new GridItem(horizontal!, {
...item,
element: children[i],
}));
this.itemRenderer.renderItems(this.items);
if (prevInlineSize !== containerManager.getInlineSize()) {
this.renderItems({
useResize: true,
});
} else {
window.setTimeout(() => {
this._renderComplete({
direction: this.defaultDirection,
mounted: this.items,
updated: [],
isResize: false,
});
});
}
return this;
}
/**
* Get the inline size corresponding to outline.
* @ko outline에 해당하는 inline 사이즈를 구한다.
* @param items - Items to get outline size. <ko>outline 사이즈를 구하기 위한 아이템들.</ko>
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getComputedOutlineSize(items: GridItem[] = this.items) {
return this.options.outlineSize! || this.getContainerInlineSize();
}
/**
* Get the length corresponding to outline.
* @ko outline에 해당하는 length를 가져온다.
* @param items - Items to get outline length. <ko>outline length를 구하기 위한 아이템들.</ko>
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getComputedOutlineLength(items: GridItem[] = this.items): number {
return this.options.outlineLength! || 1;
}
/**
* Releases the instnace and events and returns the CSS of the container and elements.
* @ko 인스턴스와 이벤트를 해제하고 컨테이너와 엘리먼트들의 CSS를 되돌린다.
* @param Options for destroy. <ko>destory()를 위한 옵션</ko>
*/
public destroy(options: DestroyOptions = {}) {
const {
preserveUI = this.options.preserveUIOnDestroy,
} = options;
this.containerManager.destroy({
preserveUI,
});
if (!preserveUI) {
this.items.forEach(({ element, orgCSSText }) => {
if (element) {
element.style.cssText = orgCSSText;
}
});
}
this._im?.destroy();
}
protected getInlineGap(): number {
return this._getDirectionalGap('inline');
}
protected getContentGap(): number {
return this._getDirectionalGap('content');
}
protected checkReady(options: RenderOptions = {}) {
// Grid: renderItems => checkReady => readyItems => applyGrid
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);
const moreUpdated: GridItem[] = [];
mounted.filter((item) => {
if (item.hasTransition) {
return true;
} else {
const element = item.element!;
const transitionDuration = parseFloat(getComputedStyle(element).transitionDuration);
if (transitionDuration > 0) {
item.hasTransition = true;
item.transitionDuration = element.style.transitionDuration;
return true;
}
}
return false;
}).forEach((item) => {
item.element!.style.transitionDuration = "0s";
});
this._im?.destroy();
this._im = new ImReady({
prefix: this.options.attributePrefix,
}).on("preReadyElement", (e) => {
updated[e.index].updateState = UPDATE_STATE.WAIT_LOADING;
}).on("preReady", () => {
// reset org size
updated.forEach((item) => {
const hasOrgSize = item.orgRect.width && item.orgRect.height;
const hasCSSSize = item.cssRect.width || item.cssRect.height;
if (!hasOrgSize && hasCSSSize) {
item.element!.style.cssText = item.orgCSSText;
}
});
this._updateItems(updated);
this.readyItems(mounted, updated, options);
}).on("readyElement", (e) => {
const item = updated[e.index];
item.updateState = UPDATE_STATE.NEED_UPDATE;
// after preReady
if (e.isPreReadyOver) {
if (item.isRestoreOrgCSSText) {
item.element!.style.cssText = item.orgCSSText;
}
this._updateItems([item]);
this.readyItems([], [item], options);
}
}).on("error", (e) => {
const item = updated[e.index];
/**
* This event is fired when an error occurs in the content.
* @ko 콘텐츠 로드에 에러가 날 때 발생하는 이벤트.
* @event Grid#contentError
* @param {Grid.OnContentError} e - The object of data to be sent to an event <ko>이벤트에 전달되는 데이터 객체</ko>
* @example
* ```js
* grid.on("contentError", e => {
* e.update();
* });
* ```
*/
this.trigger("contentError", {
element: e.element,
target: e.target,
item,
update: () => {
moreUpdated.push(item);
},
});
}).on("ready", () => {
if (moreUpdated.length) {
this.updateItems(moreUpdated);
}
}).check(updated.map((item) => item.element!));
}
protected scheduleRender() {
this._clearRenderTimer();
this._renderTimer = window.setTimeout(() => {
this.renderItems();
});
}
protected fitOutlines(useFit = this.useFit) {
const outlines = this.outlines;
const startOutline = outlines.start;
const endOutline = outlines.end;
const outlineOffset = startOutline.length ? Math.min(...startOutline) : 0;
// If the outline is less than 0, a fit occurs forcibly.
if (!useFit && outlineOffset > 0) {
return;
}
outlines.start = startOutline.map((point) => point - outlineOffset);
outlines.end = endOutline.map((point) => point - outlineOffset);
this.items.forEach((item) => {
const contentPos = item.cssContentPos;
if (!isNumber(contentPos)) {
return;
}
item.cssContentPos = contentPos - outlineOffset;
});
}
protected readyItems(mounted: GridItem[], updated: GridItem[], options: RenderOptions) {
const prevOutlines = this.outlines;
const direction = options.direction || this.options.defaultDirection!;
const prevOutline = options.outline || prevOutlines[direction === "end" ? "start" : "end"];
const items = this.items;
let nextOutlines = {
start: [...prevOutline],
end: [...prevOutline],
};
mounted.forEach((item) => {
item.mountState = MOUNT_STATE.MOUNTED;
});
updated.forEach((item) => {
item.isUpdating = true;
});
if (items.length) {
nextOutlines = this.applyGrid(this.items, direction, prevOutline);
}
updated.forEach((item) => {
item.isUpdating = false;
});
this.setOutlines(nextOutlines);
this.fitOutlines();
this.itemRenderer.renderItems(this.items);
this._refreshContainerContentSize();
const transitionMounted = mounted.filter((item) => item.hasTransition);
if (transitionMounted.length) {
this.containerManager.resize();
transitionMounted.forEach((item) => {
const element = item.element!;
element.style.transitionDuration = item.transitionDuration;
});
}
this._renderComplete({
direction,
mounted,
updated,
isResize: !!options.useResize,
});
const shouldReupdateItems = updated.filter((item) => item.shouldReupdate);
if (shouldReupdateItems.length) {
this.updateItems(shouldReupdateItems);
}
}
protected _isObserverEnabled() {
return this.containerManager.isObserverEnabled();
}
protected _updateItems(items: GridItem[]) {
this.itemRenderer.updateEqualSizeItems(items, this.getItems());
}
private _getDirectionalGap(direction: 'inline' | 'content'): number {
const horizontal = this.options.horizontal!;
const gap = this.options.gap!;
if (typeof gap === 'number') return gap;
const isVerticalGap = horizontal && direction === 'inline' || !horizontal && direction === 'content';
return (isVerticalGap ? (gap as any).vertical : (gap as any).horizontal) ?? (DEFAULT_GRID_OPTIONS["gap"] as number);
}
private _renderComplete(e: OnRenderComplete) {
/**
* This event is fired when the Grid has completed rendering.
* @ko Grid가 렌더링이 완료됐을 때 발생하는 이벤트이다.
* @event Grid#renderComplete
* @param {Grid.OnRenderComplete} e - The object of data to be sent to an event <ko>이벤트에 전달되는 데이터 객체</ko>
* @example
* ```js
* grid.on("renderComplete", e => {
* console.log(e.mounted, e.updated, e.useResize);
* });
* ```
*/
this.trigger("renderComplete", e);
}
private _clearRenderTimer() {
clearTimeout(this._renderTimer);
this._renderTimer = 0;
}
private _refreshContainerContentSize() {
const {
start: startOutline,
end: endOutline,
} = this.outlines;
const contentGap = this.getContentGap();
const endPoint = endOutline.length ? Math.max(...endOutline) : 0;
const startPoint = startOutline.length ? Math.max(...startOutline) : 0;
const contentSize = Math.max(startPoint, endPoint - contentGap);
this.containerManager.setContentSize(contentSize);
}
private _resizeContainer() {
this.containerManager.resize();
this.itemRenderer.setContainerRect(this.containerManager.getRect());
}
private _onResize = (e: ResizeWatcherResizeEvent) => {
if (e.isResizeContainer) {
this._renderItems({
useResize: true,
}, true);
} else {
const updatedItems = getUpdatedItems(this.items, e.childEntries);
if (updatedItems.length > 0) {
this.updateItems(updatedItems);
}
}
}
private _init() {
this._resizeContainer();
}
private _renderItems(options: RenderOptions = {}, isTrusted?: boolean) {
this._clearRenderTimer();
const isResize = options.useResize || options.useOrgResize;
if (isResize && !isTrusted) {
// Resize container
// isTrusted has already been resized internally.
this._resizeContainer();
this.itemRenderer.resize();
}
if (!this.getItems().length && this.getChildren().length) {
this.syncElements(options);
} else if (isResize) {
// Update all items
this.updateItems(this.items, options);
} else {
// Update only items that need to be updated.
this.checkReady(options);
}
}
}
interface Grid extends Properties<typeof Grid> { }
export default Grid;
/**
* Gap used to create space around items.
* @ko 아이템들 사이의 공간.
* @name Grid#gap
* @type {$ts:Grid.GridOptions["gap"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* gap: 0,
* });
*
* grid.gap = 5;
* ```
*/
/**
* The default direction value when direction is not set in the render option.
* @ko render옵션에서 direction을 미설정시의 기본 방향값.
* @name Grid#defaultDirection
* @type {$ts:Grid.GridOptions["defaultDirection"]}
* @default "end"
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* defaultDirection: "end",
* });
*
* grid.defaultDirection = "start";
* ```
*/
/**
* Whether to move the outline to 0 when the top is empty when rendering. However, if it overflows above the top, the outline is forced to 0. (default: true)
* @ko 렌더링시 상단이 비어있을 때 아웃라인을 0으로 이동시킬지 여부. 하지만 상단보다 넘치는 경우 아웃라인을 0으로 강제 이동한다. (default: true)
* @name Grid#useFit
* @type {$ts:Grid.GridOptions["useFit"]}
* @default true
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* useFit: true,
* });
*
* grid.useFit = false;
* ```
*/
/**
* Whether to preserve the UI of the existing container or item when destroying.
* @ko destroy 시 기존 컨테이너, 아이템의 UI를 보존할지 여부.
* @name Grid#preserveUIOnDestroy
* @type {$ts:Grid.GridOptions["preserveUIOnDestroy"]}
* @default false
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* preserveUIOnDestroy: false,
* });
*
* grid.preserveUIOnDestroy = true;
* ```
*/
/**
* The number of outlines. If the number of outlines is 0, it is calculated according to the type of grid.
* @ko outline의 개수. 아웃라인의 개수가 0이라면 grid의 종류에 따라 계산이 된다.
* @name Grid#outlineLength
* @type {$ts:Grid.GridOptions["outlineLength"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* outlineLength: 0,
* outlineSize: 0,
* });
*
* grid.outlineLength = 3;
* ```
*/
/**
* The size of the outline. If the outline size is 0, it is calculated according to the grid type.
* @ko outline의 사이즈. 만약 outline의 사이즈가 0이면, grid의 종류에 따라 계산이 된다.
* @name Grid#outlineSize
* @type {$ts:Grid.GridOptions["outlineSize"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* outlineLength: 0,
* outlineSize: 0,
* });
*
* grid.outlineSize = 300;
* ```
*/