UNPKG

@egjs/grid

Version:

A component that can arrange items according to the type of grids

692 lines (609 loc) 26.4 kB
/** * 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, between, isString, throttle } 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. `true` is automatically calculated as orgInlineSize / orgContentSize. * <ko>열의 사이즈 비율(inlineSize / contentSize). 0은 미설정이다. `true` 는 orgInlineSize / orgContentSize로 자동 계산이 된다.</ko> * @default 0 */ columnSizeRatio?: number | boolean; /** * 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; /** * Adjust the contentSize of the items to make the outlines equal. * "scale-down": Scales down to fit the start of the item's outline. * "scale-center": Scales down or up to fit the center of the item's outline. * "scale-up": Scales up to fit the end of the item's outline. * <ko>아이템들의 contentSize를 조절하여 outline을 동등하게 한다. * "scale-down": 아이템들의 아웃라인 시작 부분에 맞춰 축소시킨다. * "scale-center": 아이템들의 아웃라인 중간 부분에 맞춰 축소시키거나 확장시킨다. * "scale-up": 아이템들의 아웃라인 끝 부분에 맞춰 확장시킨다.</ko> * @default "" */ stretchOutline?: "scale-down" | "scale-up" | "scale-center" | ""; /** * When using stretchOutline, the contentSize of the items is adjusted to adjust the outline to a value between min and max. ([minSize, maxSize]) * <ko>stretchOutline를 사용하는 경우 아이템들의 contentSize를 조절하여 container의 outline을 min과 max 사이 값으로 조절한다. ([minSize, maxSize])</ko> * @default [0, Infinity] */ stretchContainerSize?: number[]; /** * adjust the minimum and maximum item size of the items. ([minSize, maxSize]) * <ko>아이템들의 item size 최소, 최대 크기를 조절한다. ([minSize, maxSize])</ko> * @default [0, Infinity] */ stretchItemSize?: Array<string | 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, stretchOutline: PROPERTY_TYPE.RENDER_PROPERTY, stretchContainerSize: PROPERTY_TYPE.RENDER_PROPERTY, stretchItemSize: 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", stretchOutline: "", stretchContainerSize: [0, Infinity], stretchItemSize: [0, Infinity], }; 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); // contentPos를 변경했는지에 따라 computedSize로 설정해야 함. let useComputedSize = false; const { align, observeChildren, columnSizeRatio, contentAlign, stretchOutline, stretchContainerSize, stretchItemSize, } = this.options; const inlineGap = this.getInlineGap(); 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"; const itemIndexes: number[][] = range(startOutline.length).map(() => []); let startPos = isEndDirection ? -Infinity : Infinity; if (isStartContentAlign) { // support only end direction startPos = Math.min(...endOutline); } for (let i = 0; i < itemsLength; ++i) { const itemIndex = isEndDirection ? i : itemsLength - 1 - i; const item = items[itemIndex]; 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); itemIndexes[columnIndex].push(itemIndex); // 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 === true) { const ratio = item.orgContentSize / item.orgInlineSize; contentSize = item.computedInlineSize * ratio; item.cssContentSize = contentSize; useComputedSize = true; } else if (columnSizeRatio && columnSizeRatio > 0) { contentSize = item.computedInlineSize / columnSizeRatio; item.cssContentSize = contentSize; useComputedSize = true; } // 배치가 조금 더 수월하기 위해서는 min, max scaling 1차 작업 let minStretchSize = 0; let maxStretchSize = Infinity; let useStretchItemSize = false; if (isString(stretchItemSize[0])) { const [inline, content] = stretchItemSize[0].split(":").map((value) => parseFloat(value)); minStretchSize = item.computedInlineSize * content / inline; useStretchItemSize = true; } else if (stretchItemSize[0]) { minStretchSize = stretchItemSize[0]; useStretchItemSize = true; } if (isString(stretchItemSize[1])) { const [inline, content] = stretchItemSize[1].split(":").map((value) => parseFloat(value)); maxStretchSize = item.computedInlineSize * content / inline; useStretchItemSize = true; } else if (stretchItemSize[1] && isFinite(stretchItemSize[1])) { // 유한한 숫자라면 stretch 대상으로 판단 maxStretchSize = stretchItemSize[1]; useStretchItemSize = true; } const nextContentSize = between(contentSize, minStretchSize, maxStretchSize); // stretchItemSize를 사용한 케이스라면 반영 if (useStretchItemSize) { contentSize = nextContentSize; item.cssContentSize = contentSize; useComputedSize = true; } 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; }); } if (stretchOutline && items.length) { let scalePoint = 0; if (stretchOutline === "scale-up") { scalePoint = Math.max(...endOutline); } else if (stretchOutline === "scale-center") { scalePoint = (Math.max(...endOutline) + Math.min(...endOutline)) / 2; } else { // scale-down scalePoint = Math.min(...endOutline); } if (isEndDirection) { // end 방향이면 endOutline이 gap만큼 더 추가되어 있다. scalePoint -= contentGap; // 높이 제한 const startPoint = Math.min(...startOutline); const nextHeight = between( // 높이 scalePoint - startPoint, stretchContainerSize[0], stretchContainerSize[1], ); scalePoint = nextHeight + startPoint; } else { // 높이 제한 const startPoint = Math.max(...startOutline); const nextHeight = between( // 반대 방향의 경우 startPoint(start)가 scalePoint(end)보다 큰 숫자다. startPoint - scalePoint, stretchContainerSize[0], stretchContainerSize[1], ); scalePoint = nextHeight - startPoint; } endOutline.forEach((point, i) => { const totalGap = (itemIndexes[i].length - 1) * contentGap; const startPoint = startOutline[i]; const prevSize = Math.abs(point - startPoint) - (isEndDirection ? contentGap : 0) - totalGap; const nextSize = (Math.abs(scalePoint - startPoint) - totalGap); const scale = nextSize / prevSize; if (!prevSize || scale === 1 || !isFinite(scale)) { return; } if (isEndDirection) { endOutline[i] = scalePoint + contentGap; } else { endOutline[i] = scalePoint; } const length = itemIndexes[i].length; let prevPoint = isEndDirection ? startOutline[i] : startOutline[i] - contentGap; const itemInfos = itemIndexes[i].map((itemIndex, j) => { const item = items[itemIndex]; const originalSize = useComputedSize ? item.computedContentSize : item.contentSize; let minStretchSize = 0; let maxStretchSize = Infinity; if (isString(stretchItemSize[0])) { const [inline, content] = stretchItemSize[0].split(":").map((value) => parseFloat(value)); minStretchSize = item.computedInlineSize * content / inline; } else { minStretchSize = stretchItemSize[0]; } if (isString(stretchItemSize[1])) { const [inline, content] = stretchItemSize[1].split(":").map((value) => parseFloat(value)); maxStretchSize = item.computedInlineSize * content / inline; } else { maxStretchSize = stretchItemSize[1]; } return { item, index: j, itemIndex, minSize: minStretchSize, originalSize, nextSize: originalSize, maxSize: maxStretchSize, }; }); if (scale > 1) { itemInfos.sort((a, b) => { return a.originalSize - b.originalSize; }); } else { itemInfos.sort((a, b) => { return b.originalSize - a.originalSize; }); } // minSize, maxSize 제한하여 scale을 적용한다. let sumPrevSize = 0; let sumNextSize = 0; itemInfos.forEach((itemInfo) => { // 이전까지의 합 scale은 점점 목표치(`scale`)에 다가가야 한다. // 그렇지 않는 경우는 max, min에 도달한 경우라고 판단. const nextScale = sumNextSize > nextSize ? scale : (nextSize - sumNextSize) / (prevSize - sumPrevSize); const prevItemSize = itemInfo.originalSize; const nextItemSize = throttle(between(prevItemSize * nextScale, itemInfo.minSize, itemInfo.maxSize), 0.01); sumPrevSize += prevItemSize; sumNextSize += nextItemSize; itemInfo.nextSize = nextItemSize; }); // minSize, maxSize 적용 이후 gap이 남는 경우 전부 강제 scale 한다. if (throttle(sumNextSize - nextSize, 0.01)) { const lastScale = throttle(nextSize / sumNextSize, 0.01); itemInfos.forEach((itemInfo) => { itemInfo.nextSize *= lastScale; }); } // 다시 index 순서로 정렬해야 포지션을 설정 가능함 itemInfos.sort((a, b) => { return a.index - b.index; }); itemInfos.forEach((itemInfo, j) => { const item = itemInfo.item; const nextItemSize = itemInfo.nextSize; item.addCSSGridRect({ contentPos: isEndDirection ? prevPoint : prevPoint - nextItemSize, contentSize: nextItemSize, }); if (isEndDirection) { prevPoint = item.cssContentPos! + item.cssContentSize! + contentGap; } else { prevPoint = item.cssContentPos! - contentGap; } if (j === length - 1) { let posOffset = 0; let sizeOffset = 0; if (isEndDirection) { sizeOffset = (item.cssContentPos! % 1 >= 0.5 ? 1 : 0) + (item.cssContentSize! % 1 >= 0.5 ? 1 : 0) + (scalePoint % 1 >= 0.5 ? -1 : 0); } else { posOffset = (item.cssContentPos! % 1 < 0.5 ? -1 : 0) + (scalePoint % 1 < 0.5 ? 1 : 0); } // Offset position and size need to be adjusted because they are rounded. item.addCSSGridRect({ contentPos: item.cssContentPos! - posOffset, contentSize: item.cssContentSize! - sizeOffset, }); } }); }); } // 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.max( 1, Math.floor( (this.getContainerInlineSize() + inlineGap) / (columnSize - columnCalculationThreshold + inlineGap) ) ); if (!this.maxStretchColumnSize) { column = Math.min( items.length, column, ); } } 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; * ``` */