/**
* egjs-grid
* Copyright (c) 2021-present NAVER Corp.
* MIT license
*/
import Grid from "../Grid";
import { PROPERTY_TYPE, UPDATE_STATE } from "../consts";
import { GridOptions, Properties, GridOutlines, GridAlign, MasonryGridVerticalAlign } from "../types";
import { range, GetterSetter } from "../utils";
import { GridItem } from "../GridItem";
function getColumnPoint(
outline: number[],
columnIndex: number,
columnCount: number,
pointCaculationName: "max" | "min",
) {
return Math[pointCaculationName](...outline.slice(columnIndex, columnIndex + columnCount));
}
function getColumnIndex(
outline: number[],
columnCount: number,
nearestCalculationName: "max" | "min",
startPos: number,
) {
const length = outline.length - columnCount + 1;
const pointCaculationName = nearestCalculationName === "max" ? "min" : "max";
const indexCaculationName = nearestCalculationName === "max" ? "lastIndexOf" : "indexOf";
const points = range(length).map((index) => {
const point = getColumnPoint(outline, index, columnCount, pointCaculationName);
return Math[pointCaculationName](startPos, point);
});
return points[indexCaculationName](Math[nearestCalculationName](...points));
}
/**
* @typedef
* @memberof Grid.MasonryGrid
* @extends Grid.GridOptions
*/
export interface MasonryGridOptions extends GridOptions {
/**
* The number of columns. If the number of columns is 0, it is automatically calculated according to the size of the container. Can be used instead of outlineLength.
* <ko>열의 개수. 열의 개수가 0이라면, 컨테이너의 사이즈에 의해 계산이 된다. outlineLength 대신 사용할 수 있다.</ko>
* @default 0
*/
column?: number;
/**
* The size of the columns. If it is 0, it is calculated as the size of the first item in items. Can be used instead of outlineSize.
* <ko>열의 사이즈. 만약 열의 사이즈가 0이면, 아이템들의 첫번째 아이템의 사이즈로 계산이 된다. outlineSize 대신 사용할 수 있다.</ko>
* @default 0
*/
columnSize?: number;
/**
* The size ratio(inlineSize / contentSize) of the columns. 0 is not set.
* <ko>열의 사이즈 비율(inlineSize / contentSize). 0은 미설정이다.</ko>
* @default 0
*/
columnSizeRatio?: number;
/**
* Align of the position of the items. If you want to use `stretch`, be sure to set `column`, `columnSize` or `maxStretchColumnSize` option. ("start", "center", "end", "justify", "stretch")
* <ko>아이템들의 위치의 정렬. `stretch`를 사용하고 싶다면 `column`, `columnSize` 또는 `maxStretchColumnSize` 옵션을 설정해라. ("start", "center", "end", "justify", "stretch")</ko>
* @default "justify"
*/
align?: GridAlign;
/**
* Content direction alignment of items. “Masonry” is sorted in the form of masonry. Others are applied as content direction alignment, similar to vertical-align of inline-block.
* If you set multiple columns (`data-grid-column`), the screen may look strange.
* <ko>아이템들의 Content 방향의 정렬. "masonry"는 masonry 형태로 정렬이 된다. 그 외는 inline-block의 vertical-align과 유사하게 content 방향 정렬로 적용이 된다.칼럼(`data-grid-column` )을 여러개 설정하면 화면이 이상하게 보일 수 있다. </ko>
* @default "masonry"
*/
contentAlign?: MasonryGridVerticalAlign;
/**
* Difference Threshold for Counting Columns. Since offsetSize is calculated by rounding, the number of columns may not be accurate.
* <ko>칼럼 개수를 계산하기 위한 차이 임계값. offset 사이즈는 반올림으로 게산하기 때문에 정확하지 않을 수 있다.</ko>
* @default 1
*/
columnCalculationThreshold?: number;
/**
* If stretch is used, the column can be automatically calculated by setting the maximum size of the column that can be stretched.
* <ko>stretch를 사용한 경우 최대로 늘릴 수 있는 column의 사이즈를 설정하여 column을 자동 계산할 수 있다.</ko>
* @default Infinity
*/
maxStretchColumnSize?: number;
}
/**
* MasonryGrid is a grid that stacks items with the same width as a stack of bricks. Adjust the width of all images to the same size, find the lowest height column, and insert a new item.
* @ko MasonryGrid는 벽돌을 쌓아 올린 모양처럼 동일한 너비를 가진 아이템를 쌓는 레이아웃이다. 모든 이미지의 너비를 동일한 크기로 조정하고, 가장 높이가 낮은 열을 찾아 새로운 이미지를 삽입한다. 따라서 배치된 아이템 사이에 빈 공간이 생기지는 않지만 배치된 레이아웃의 아래쪽은 울퉁불퉁해진다.
* @memberof Grid
* @param {HTMLElement | string} container - A base element for a module <ko>모듈을 적용할 기준 엘리먼트</ko>
* @param {Grid.MasonryGrid.MasonryGridOptions} options - The option object of the MasonryGrid module <ko>MasonryGrid 모듈의 옵션 객체</ko>
*/
@GetterSetter
export class MasonryGrid extends Grid<MasonryGridOptions> {
public static propertyTypes = {
...Grid.propertyTypes,
column: PROPERTY_TYPE.RENDER_PROPERTY,
columnSize: PROPERTY_TYPE.RENDER_PROPERTY,
columnSizeRatio: PROPERTY_TYPE.RENDER_PROPERTY,
align: PROPERTY_TYPE.RENDER_PROPERTY,
columnCalculationThreshold: PROPERTY_TYPE.RENDER_PROPERTY,
maxStretchColumnSize: PROPERTY_TYPE.RENDER_PROPERTY,
contentAlign: PROPERTY_TYPE.RENDER_PROPERTY,
};
public static defaultOptions: Required<MasonryGridOptions> = {
...Grid.defaultOptions,
align: "justify",
column: 0,
columnSize: 0,
columnSizeRatio: 0,
columnCalculationThreshold: 0.5,
maxStretchColumnSize: Infinity,
contentAlign: "masonry",
};
public applyGrid(items: GridItem[], direction: "start" | "end", outline: number[]): GridOutlines {
items.forEach((item) => {
item.isRestoreOrgCSSText = false;
});
const columnSize = this.getComputedOutlineSize(items);
const column = this.getComputedOutlineLength(items);
const {
align,
observeChildren,
columnSizeRatio,
contentAlign,
} = this.options;
const inlineGap = this.getContentGap();
const contentGap = this.getContentGap();
const outlineLength = outline.length;
const itemsLength = items.length;
const alignPoses = this._getAlignPoses(column, columnSize);
const isEndDirection = direction === "end";
const nearestCalculationName = isEndDirection ? "min" : "max";
const pointCalculationName = isEndDirection ? "max" : "min";
let startOutline = [0];
if (outlineLength === column) {
startOutline = outline.slice();
} else {
const point = outlineLength ? Math[pointCalculationName](...outline) : 0;
startOutline = range(column).map(() => point);
}
let endOutline = startOutline.slice();
const columnDist = column > 1 ? alignPoses[1] - alignPoses[0] : 0;
const isStretch = align === "stretch";
const isStartContentAlign = isEndDirection && contentAlign === "start";
let startPos = isEndDirection ? -Infinity : Infinity;
if (isStartContentAlign) {
// support only end direction
startPos = Math.min(...endOutline);
}
for (let i = 0; i < itemsLength; ++i) {
const item = items[isEndDirection ? i : itemsLength - 1 - i];
const columnAttribute = parseInt(item.attributes.column || "1", 10);
const maxColumnAttribute = parseInt(item.attributes.maxColumn || "1", 10);
let contentSize = item.contentSize;
let columnCount = Math.min(
column,
columnAttribute || Math.max(1, Math.ceil((item.inlineSize + inlineGap) / columnDist)),
);
const maxColumnCount = Math.min(column, Math.max(columnCount, maxColumnAttribute));
let columnIndex = getColumnIndex(endOutline, columnCount, nearestCalculationName, startPos);
let contentPos = getColumnPoint(endOutline, columnIndex, columnCount, pointCalculationName);
if (isStartContentAlign && startPos !== contentPos) {
startPos = Math.max(...endOutline);
endOutline = endOutline.map(() => startPos);
contentPos = startPos;
columnIndex = 0;
}
while (columnCount < maxColumnCount) {
const nextEndColumnIndex = columnIndex + columnCount;
const nextColumnIndex = columnIndex - 1;
if (isEndDirection && (nextEndColumnIndex >= column || endOutline[nextEndColumnIndex] > contentPos)) {
break;
}
if (!isEndDirection && (nextColumnIndex < 0 || endOutline[nextColumnIndex] < contentPos)) {
break;
}
if (!isEndDirection) {
--columnIndex;
}
++columnCount;
}
columnIndex = Math.max(0, columnIndex);
columnCount = Math.min(column - columnIndex, columnCount);
// stretch mode or data-grid-column > "1"
if ((columnAttribute > 0 && columnCount > 1) || isStretch) {
const nextInlineSize = (columnCount - 1) * columnDist + columnSize;
if ((!this._isObserverEnabled() || !observeChildren) && item.cssInlineSize !== nextInlineSize) {
item.shouldReupdate = true;
}
item.cssInlineSize = nextInlineSize;
}
if (columnSizeRatio > 0) {
contentSize = item.computedInlineSize / columnSizeRatio;
item.cssContentSize = contentSize;
}
const inlinePos = alignPoses[columnIndex];
contentPos = isEndDirection ? contentPos : contentPos - contentGap - contentSize;
item.cssInlinePos = inlinePos;
item.cssContentPos = contentPos;
const nextOutlinePoint = isEndDirection ? contentPos + contentSize + contentGap : contentPos;
range(columnCount).forEach((indexOffset) => {
endOutline[columnIndex + indexOffset] = nextOutlinePoint;
});
}
// Finally, check whether startPos and min of the outline match.
// If different, endOutline is updated.
if (isStartContentAlign && startPos !== Math.min(...endOutline)) {
startPos = Math.max(...endOutline);
endOutline = endOutline.map(() => startPos);
}
// if end items, startOutline is low, endOutline is high
// if start items, startOutline is high, endOutline is low
return {
start: isEndDirection ? startOutline : endOutline,
end: isEndDirection ? endOutline : startOutline,
};
}
public getComputedOutlineSize(items = this.items) {
const { align } = this.options;
const inlineGap = this.getInlineGap();
const containerInlineSize = this.getContainerInlineSize();
const columnSizeOption = this.columnSize || this.outlineSize;
const columnOption = this.column || this.outlineLength;
let column = columnOption || 1;
let columnSize = 0;
if (align === "stretch") {
if (!columnOption) {
const maxStretchColumnSize = this.maxStretchColumnSize || Infinity;
column = Math.max(1, Math.ceil((containerInlineSize + inlineGap) / (maxStretchColumnSize + inlineGap)));
}
columnSize = (containerInlineSize + inlineGap) / (column || 1) - inlineGap;
} else if (columnSizeOption) {
columnSize = columnSizeOption;
} else if (items.length) {
let checkedItem = items[0];
for (const item of items) {
const attributes = item.attributes;
const columnAttribute = parseInt(attributes.column || "1", 10);
const maxColumnAttribute = parseInt(attributes.maxColumn || "1", 10);
if (
item.updateState !== UPDATE_STATE.UPDATED
|| !item.inlineSize
|| columnAttribute !== 1
|| maxColumnAttribute !== 1
) {
continue;
}
checkedItem = item;
break;
}
const inlineSize = checkedItem.inlineSize || 0;
columnSize = inlineSize;
} else {
columnSize = containerInlineSize;
}
return columnSize || 0;
}
public getComputedOutlineLength(items = this.items) {
const inlineGap = this.getInlineGap();
const columnOption = this.column || this.outlineLength;
const columnCalculationThreshold = this.columnCalculationThreshold;
let column = 1;
if (columnOption) {
column = columnOption;
} else {
const columnSize = this.getComputedOutlineSize(items);
column = Math.min(
items.length,
Math.max(
1,
Math.floor(
(this.getContainerInlineSize() + inlineGap) /
(columnSize - columnCalculationThreshold + inlineGap)
)
)
);
}
return column;
}
private _getAlignPoses(column: number, columnSize: number) {
const { align } = this.options;
const inlineGap = this.getInlineGap();
const containerSize = this.getContainerInlineSize();
const indexes = range(column);
let offset = 0;
let dist = 0;
if (align === "justify" || align === "stretch") {
const countDist = column - 1;
dist = countDist ? Math.max((containerSize - columnSize) / countDist, columnSize + inlineGap) : 0;
offset = Math.min(0, containerSize / 2 - (countDist * dist + columnSize) / 2);
} else {
dist = columnSize + inlineGap;
const totalColumnSize = (column - 1) * dist + columnSize;
if (align === "center") {
offset = (containerSize - totalColumnSize) / 2;
} else if (align === "end") {
offset = containerSize - totalColumnSize;
}
}
return indexes.map((i) => {
return offset + i * dist;
});
}
}
export interface MasonryGrid extends Properties<typeof MasonryGrid> {
}
/**
* Align of the position of the items. If you want to use `stretch`, be sure to set `column` or `columnSize` option. ("start", "center", "end", "justify", "stretch")
* @ko 아이템들의 위치의 정렬. `stretch`를 사용하고 싶다면 `column` 또는 `columnSize` 옵션을 설정해라. ("start", "center", "end", "justify", "stretch")
* @name Grid.MasonryGrid#align
* @type {$ts:Grid.MasonryGrid.MasonryGridOptions["align"]}
* @default "justify"
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* align: "start",
* });
*
* grid.align = "justify";
* ```
*/
/**
* The number of columns. If the number of columns is 0, it is automatically calculated according to the size of the container. Can be used instead of outlineLength.
* @ko 열의 개수. 열의 개수가 0이라면, 컨테이너의 사이즈에 의해 계산이 된다. outlineLength 대신 사용할 수 있다.
* @name Grid.MasonryGrid#column
* @type {$ts:Grid.MasonryGrid.MasonryGridOptions["column"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* column: 0,
* });
*
* grid.column = 4;
* ```
*/
/**
* The size of the columns. If it is 0, it is calculated as the size of the first item in items. Can be used instead of outlineSize.
* @ko 열의 사이즈. 만약 열의 사이즈가 0이면, 아이템들의 첫번째 아이템의 사이즈로 계산이 된다. outlineSize 대신 사용할 수 있다.
* @name Grid.MasonryGrid#columnSize
* @type {$ts:Grid.MasonryGrid.MasonryGridOptions["columnSize"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* columnSize: 0,
* });
*
* grid.columnSize = 200;
* ```
*/
/**
* The size ratio(inlineSize / contentSize) of the columns. 0 is not set.
* @ko 열의 사이즈 비율(inlineSize / contentSize). 0은 미설정이다.
* @name Grid.MasonryGrid#columnSizeRatio
* @type {$ts:Grid.MasonryGrid.MasonryGridOptions["columnSizeRatio"]}
* @default 0
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* columnSizeRatio: 0,
* });
*
* grid.columnSizeRatio = 0.5;
* ```
*/
/**
* If stretch is used, the column can be automatically calculated by setting the maximum size of the column that can be stretched.
* @ko stretch를 사용한 경우 최대로 늘릴 수 있는 column의 사이즈를 설정하여 column을 자동 계산할 수 있다.
* @name Grid.MasonryGrid#maxStretchColumnSize
* @type {$ts:Grid.MasonryGrid.MasonryGridOptions["maxStretchColumnSize"]}
* @default Infinity
* @example
* ```js
* import { MasonryGrid } from "@egjs/grid";
*
* const grid = new MasonryGrid(container, {
* align: "stretch",
* maxStretchColumnSize: 0,
* });
*
* grid.maxStretchColumnSize = 400;
* ```
*/