080dca8c4e
按容器宽度循环分配精选卡片到三列、两列或单列 使用 ResizeObserver 动态重排并保留 Grid 安全回退 补充布局临界值、分页追加和样式回归测试 同步创作主页文档与项目共享决策
191 lines
5.6 KiB
TypeScript
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;
|
|
}
|