Files
Genarrative/src/components/creation-home/showcaseMasonryLayout.ts
T
kdletters 080dca8c4e
Project CI / Repository checks (push) Successful in 46s
Project CI / Frontend tests (push) Successful in 49s
Project CI / Native shell tests (push) Successful in 2m35s
Project CI / Backend tests (push) Successful in 3m7s
实现精选顺序分列瀑布流
按容器宽度循环分配精选卡片到三列、两列或单列
使用 ResizeObserver 动态重排并保留 Grid 安全回退
补充布局临界值、分页追加和样式回归测试
同步创作主页文档与项目共享决策
2026-07-22 20:30:53 +08:00

191 lines
5.6 KiB
TypeScript

export const SHOWCASE_MASONRY_MAX_COLUMNS = 3;
export const SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX = 288;
export const SHOWCASE_MASONRY_FALLBACK_GAP_PX = 14.72;
export type ShowcaseMasonryItemLayout = {
columnIndex: number;
height: number;
left: number;
top: number;
width: number;
};
export type ShowcaseMasonryLayout = {
columnCount: number;
columnHeights: number[];
columnWidth: number;
containerHeight: number;
items: ShowcaseMasonryItemLayout[];
};
type CreateShowcaseMasonryLayoutOptions = {
containerWidth: number;
gap: number;
itemHeights: number[];
maxColumns?: number;
minColumnWidth?: number;
};
function normalizeFiniteNumber(value: number, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
export function resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: Omit<CreateShowcaseMasonryLayoutOptions, 'itemHeights'>) {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const safeMaxColumns = Math.max(
1,
Math.floor(normalizeFiniteNumber(maxColumns, 1)),
);
const safeMinColumnWidth = Math.max(
1,
normalizeFiniteNumber(minColumnWidth, 1),
);
const fittingColumns = Math.max(
1,
Math.floor((safeContainerWidth + safeGap) / (safeMinColumnWidth + safeGap)),
);
return Math.min(safeMaxColumns, fittingColumns);
}
export function createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: CreateShowcaseMasonryLayoutOptions): ShowcaseMasonryLayout {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth: safeContainerWidth,
gap: safeGap,
maxColumns,
minColumnWidth,
});
const columnWidth = Math.max(
0,
(safeContainerWidth - safeGap * (columnCount - 1)) / columnCount,
);
const occupiedColumnHeights = Array.from<number>({
length: columnCount,
}).fill(0);
const items = itemHeights.map((rawHeight, index) => {
const height = Math.max(0, normalizeFiniteNumber(rawHeight));
const columnIndex = index % columnCount;
const top = occupiedColumnHeights[columnIndex] ?? 0;
const item = {
columnIndex,
height,
left: columnIndex * (columnWidth + safeGap),
top,
width: columnWidth,
};
occupiedColumnHeights[columnIndex] = top + height + safeGap;
return item;
});
const columnHeights = occupiedColumnHeights.map((height) =>
height > 0 ? height - safeGap : 0,
);
return {
columnCount,
columnHeights,
columnWidth,
containerHeight: Math.max(0, ...columnHeights),
items,
};
}
function resolveContainerGap(container: HTMLElement) {
const view = container.ownerDocument.defaultView;
const rawGap = view?.getComputedStyle(container).columnGap ?? '';
const gap = Number.parseFloat(rawGap);
return Number.isFinite(gap) ? gap : SHOWCASE_MASONRY_FALLBACK_GAP_PX;
}
function getMasonryCards(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
),
);
}
export function resetSequentialShowcaseMasonry(container: HTMLElement) {
container.classList.remove('creation-landing__asset-waterfall--masonry');
container.style.removeProperty('height');
delete container.dataset.masonryColumns;
for (const card of getMasonryCards(container)) {
card.style.removeProperty('position');
card.style.removeProperty('width');
card.style.removeProperty('left');
card.style.removeProperty('top');
delete card.dataset.masonryColumn;
}
}
export function applySequentialShowcaseMasonry(
container: HTMLElement,
{ expectedItemCount }: { expectedItemCount?: number } = {},
) {
const containerWidth = container.getBoundingClientRect().width;
const cards = getMasonryCards(container);
if (
!Number.isFinite(containerWidth) ||
containerWidth <= 0 ||
!cards.length ||
(expectedItemCount !== undefined && cards.length !== expectedItemCount)
) {
resetSequentialShowcaseMasonry(container);
return null;
}
const gap = resolveContainerGap(container);
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
});
const columnWidth = Math.max(
0,
(containerWidth - gap * (columnCount - 1)) / columnCount,
);
container.classList.add('creation-landing__asset-waterfall--masonry');
cards.forEach((card, index) => {
const columnIndex = index % columnCount;
card.style.position = 'absolute';
card.style.width = `${columnWidth}px`;
card.style.left = `${columnIndex * (columnWidth + gap)}px`;
card.style.top = '0px';
});
const itemHeights = cards.map((card) => card.getBoundingClientRect().height);
if (itemHeights.some((height) => !Number.isFinite(height) || height <= 0)) {
resetSequentialShowcaseMasonry(container);
return null;
}
const layout = createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
});
cards.forEach((card, index) => {
const item = layout.items[index];
if (!item) {
return;
}
card.style.left = `${item.left}px`;
card.style.top = `${item.top}px`;
card.dataset.masonryColumn = String(item.columnIndex + 1);
});
container.style.height = `${layout.containerHeight}px`;
container.dataset.masonryColumns = String(layout.columnCount);
return layout;
}