合并最新 master 分支
带入 SpacetimeDB 2.7.0(hotfix3)工具链升级、后台灰度发布控制面恢复、 精选顺序分列瀑布流及后台素材预览统一。冲突仅三处文档:decision-log 与 pitfalls 双方条目并存,运维文档更新时间取新值。BgFilter 分支代码与部署 脚本零冲突;依赖升级后 bgfilter 46 测试全过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -470,7 +470,7 @@ describe('CreationLandingView', () => {
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('likes a featured asset from the waterfall card action', async () => {
|
||||
it('likes a featured asset from the list card action', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
@@ -684,7 +684,7 @@ describe('CreationLandingView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
|
||||
it('uses Taonier featured as the generated project resource list instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -736,9 +736,13 @@ describe('CreationLandingView', () => {
|
||||
expect(await screen.findByText('精选素材 A')).toBeTruthy();
|
||||
expect(screen.getByText('精选素材 B')).toBeTruthy();
|
||||
expect(screen.queryByText('上传素材')).toBeNull();
|
||||
expect(screen.getByLabelText('用户素材瀑布流').className).toContain(
|
||||
const showcaseList = screen.getByRole('list', {
|
||||
name: '陶泥儿精选素材列表',
|
||||
});
|
||||
expect(showcaseList.className).toContain(
|
||||
'creation-landing__asset-waterfall',
|
||||
);
|
||||
expect(within(showcaseList).getAllByRole('listitem')).toHaveLength(2);
|
||||
const firstPreview = container.querySelector(
|
||||
'.creation-landing__asset-preview',
|
||||
) as HTMLElement | null;
|
||||
@@ -751,6 +755,22 @@ describe('CreationLandingView', () => {
|
||||
});
|
||||
|
||||
it('renders campaign card previews with centered contain media', async () => {
|
||||
const campaignObjectKey =
|
||||
'generated-character-drafts/editor/showcase-campaign/current/campaign.png';
|
||||
const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24=';
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
objectKey: campaignObjectKey,
|
||||
signedUrl: signedCampaignUrl,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
listEditorProjectsMock.mockResolvedValue(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue({
|
||||
resources: [],
|
||||
@@ -759,6 +779,7 @@ describe('CreationLandingView', () => {
|
||||
enabled: true,
|
||||
title: '活动精选',
|
||||
imageSrc: '/campaign.png',
|
||||
imageObjectKey: campaignObjectKey,
|
||||
imageWidth: 900,
|
||||
imageHeight: 1200,
|
||||
prompt: '活动提示词',
|
||||
@@ -777,9 +798,17 @@ describe('CreationLandingView', () => {
|
||||
'creation-landing__asset-preview--campaign',
|
||||
);
|
||||
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
|
||||
const campaignImage = await screen.findByRole('img', {
|
||||
name: '活动精选',
|
||||
});
|
||||
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads more featured resources when the waterfall reaches the end', async () => {
|
||||
it('loads more featured resources when the list reaches the end', async () => {
|
||||
const observers = installIntersectionObserverMock();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock
|
||||
@@ -873,10 +902,10 @@ describe('CreationLandingView', () => {
|
||||
await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('精选素材 A')).toBeTruthy();
|
||||
expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
|
||||
expect(screen.getByLabelText('陶泥儿精选素材列表')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the featured waterfall empty when project resources are empty', async () => {
|
||||
it('keeps the featured list empty when project resources are empty', async () => {
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
@@ -885,7 +914,7 @@ describe('CreationLandingView', () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
|
||||
expect(screen.queryByLabelText('陶泥儿精选素材列表')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -30,6 +37,10 @@ import {
|
||||
type ShowcaseAssetPreview,
|
||||
type ShowcaseTabId,
|
||||
} from './creationShowcaseModel';
|
||||
import {
|
||||
applySequentialShowcaseMasonry,
|
||||
resetSequentialShowcaseMasonry,
|
||||
} from './showcaseMasonryLayout';
|
||||
|
||||
type CreationLandingViewProps = {
|
||||
onOpenProject: (
|
||||
@@ -171,6 +182,59 @@ function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function useSequentialShowcaseMasonry(
|
||||
itemOrderKey: string,
|
||||
expectedItemCount: number,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = container.ownerDocument.defaultView;
|
||||
let animationFrame: number | null = null;
|
||||
const layout = () => {
|
||||
animationFrame = null;
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount });
|
||||
};
|
||||
const scheduleLayout = () => {
|
||||
if (animationFrame !== null) {
|
||||
return;
|
||||
}
|
||||
if (view?.requestAnimationFrame) {
|
||||
animationFrame = view.requestAnimationFrame(layout);
|
||||
return;
|
||||
}
|
||||
layout();
|
||||
};
|
||||
|
||||
layout();
|
||||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||
const resizeObserver = ResizeObserverConstructor
|
||||
? new ResizeObserverConstructor(scheduleLayout)
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
for (const card of container.querySelectorAll<HTMLElement>(
|
||||
':scope > .creation-landing__asset-card',
|
||||
)) {
|
||||
resizeObserver?.observe(card);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null && view?.cancelAnimationFrame) {
|
||||
view.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
};
|
||||
}, [expectedItemCount, itemOrderKey]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
function getPreviewIcon(preview: ShowcaseAssetPreview) {
|
||||
if (preview.mediaType === 'audio') {
|
||||
return <Music2 aria-hidden="true" />;
|
||||
@@ -747,6 +811,13 @@ export function CreationLandingView({
|
||||
),
|
||||
);
|
||||
}, [normalizedSearchKeyword, showcaseItems]);
|
||||
const showcaseMasonryOrderKey = JSON.stringify(
|
||||
visibleShowcaseItems.map((item) => item.id),
|
||||
);
|
||||
const showcaseMasonryRef = useSequentialShowcaseMasonry(
|
||||
showcaseMasonryOrderKey,
|
||||
visibleShowcaseItems.length,
|
||||
);
|
||||
const visibleRecentProjects = useMemo(() => {
|
||||
if (!normalizedSearchKeyword) {
|
||||
return recentProjects;
|
||||
@@ -811,8 +882,10 @@ export function CreationLandingView({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={showcaseMasonryRef}
|
||||
className="creation-landing__asset-waterfall"
|
||||
aria-label="用户素材瀑布流"
|
||||
aria-label="陶泥儿精选素材列表"
|
||||
role="list"
|
||||
>
|
||||
{visibleShowcaseItems.map((item) => {
|
||||
const showcaseId = item.showcaseId?.trim();
|
||||
@@ -823,7 +896,11 @@ export function CreationLandingView({
|
||||
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
|
||||
);
|
||||
return (
|
||||
<article key={item.id} className="creation-landing__asset-card">
|
||||
<article
|
||||
key={item.id}
|
||||
className="creation-landing__asset-card"
|
||||
role="listitem"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="creation-landing__asset-card-open"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applySequentialShowcaseMasonry,
|
||||
createSequentialShowcaseMasonryLayout,
|
||||
resetSequentialShowcaseMasonry,
|
||||
resolveShowcaseMasonryColumnCount,
|
||||
} from './showcaseMasonryLayout';
|
||||
|
||||
function createRect(width: number, height: number): DOMRect {
|
||||
return {
|
||||
bottom: height,
|
||||
height,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('showcase masonry layout', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('assigns every group to columns in stable round-robin order', () => {
|
||||
const layout = createSequentialShowcaseMasonryLayout({
|
||||
containerWidth: 1296,
|
||||
gap: 16,
|
||||
itemHeights: [600, 300, 450, 200, 400, 250],
|
||||
});
|
||||
|
||||
expect(layout.columnCount).toBe(3);
|
||||
expect(layout.items.map((item) => item.columnIndex)).toEqual([
|
||||
0, 1, 2, 0, 1, 2,
|
||||
]);
|
||||
expect(layout.items.map((item) => item.top)).toEqual([
|
||||
0, 0, 0, 616, 316, 466,
|
||||
]);
|
||||
expect(layout.columnHeights).toEqual([816, 716, 716]);
|
||||
expect(layout.containerHeight).toBe(816);
|
||||
});
|
||||
|
||||
it('derives three, two, or one column from the actual container width', () => {
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 590.71,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 590.72,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 893.43,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 893.44,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(3);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({ containerWidth: 1600, gap: 14.72 }),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('applies and resets absolute card positions without changing DOM order', () => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'creation-landing__asset-waterfall';
|
||||
container.style.columnGap = '16px';
|
||||
container.getBoundingClientRect = () => createRect(900, 0);
|
||||
const heights = [600, 300, 450, 200, 400, 250];
|
||||
const cards = heights.map((height, index) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'creation-landing__asset-card';
|
||||
card.textContent = `card-${index + 1}`;
|
||||
card.getBoundingClientRect = () =>
|
||||
createRect(Number.parseFloat(card.style.width) || 0, height);
|
||||
container.append(card);
|
||||
return card;
|
||||
});
|
||||
document.body.append(container);
|
||||
|
||||
const layout = applySequentialShowcaseMasonry(container);
|
||||
|
||||
expect(layout?.columnCount).toBe(3);
|
||||
expect(container.dataset.masonryColumns).toBe('3');
|
||||
expect(cards.map((card) => card.dataset.masonryColumn)).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
expect(cards.map((card) => card.style.top)).toEqual([
|
||||
'0px',
|
||||
'0px',
|
||||
'0px',
|
||||
'616px',
|
||||
'316px',
|
||||
'466px',
|
||||
]);
|
||||
expect(
|
||||
Array.from(container.children).map((card) => card.textContent),
|
||||
).toEqual(['card-1', 'card-2', 'card-3', 'card-4', 'card-5', 'card-6']);
|
||||
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
|
||||
expect(container.dataset.masonryColumns).toBeUndefined();
|
||||
expect(container.style.height).toBe('');
|
||||
expect(cards.every((card) => card.style.position === '')).toBe(true);
|
||||
expect(cards.every((card) => card.style.top === '')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the flow fallback until every expected card has a valid height', () => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'creation-landing__asset-waterfall';
|
||||
container.getBoundingClientRect = () => createRect(900, 0);
|
||||
const card = document.createElement('article');
|
||||
card.className = 'creation-landing__asset-card';
|
||||
card.getBoundingClientRect = () => createRect(288, 0);
|
||||
container.append(card);
|
||||
document.body.append(container);
|
||||
|
||||
expect(
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount: 2 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.classList.contains(
|
||||
'creation-landing__asset-waterfall--masonry',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount: 1 }),
|
||||
).toBeNull();
|
||||
expect(container.style.height).toBe('');
|
||||
expect(card.style.position).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
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;
|
||||
}
|
||||
+21
-7
@@ -3289,20 +3289,34 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
column-count: 3;
|
||||
column-gap: 0.92rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
gap: 0.92rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall--masonry {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall--masonry
|
||||
> .creation-landing__asset-card {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.creation-landing__asset-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0 0.92rem;
|
||||
break-inside: avoid;
|
||||
margin: 0;
|
||||
align-self: start;
|
||||
border: 1px solid rgba(220, 189, 163, 0.78);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 254, 251, 0.88);
|
||||
@@ -3802,7 +3816,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 2;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.creation-landing__showcase-modal-panel {
|
||||
@@ -3872,7 +3886,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 1;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.creation-landing__showcase-modal-overlay {
|
||||
|
||||
+32
-13
@@ -38,13 +38,21 @@ function getCssBlock(source: string, selector: string) {
|
||||
}
|
||||
|
||||
describe('index stylesheet unread dots', () => {
|
||||
it('keeps creation home featured assets in a column waterfall layout', () => {
|
||||
it('keeps creation home featured assets in a sequential responsive masonry layout', () => {
|
||||
const css = readIndexCss();
|
||||
const waterfallBlock = getCssBlock(
|
||||
css,
|
||||
'.creation-landing__asset-waterfall',
|
||||
);
|
||||
const cardBlock = getCssBlock(css, '.creation-landing__asset-card {');
|
||||
const masonryBlock = getCssBlock(
|
||||
css,
|
||||
'.creation-landing__asset-waterfall--masonry',
|
||||
);
|
||||
const masonryCardBlock = getCssBlock(
|
||||
css,
|
||||
'.creation-landing__asset-waterfall--masonry\n > .creation-landing__asset-card',
|
||||
);
|
||||
const cardBlock = getCssBlock(css, '\n.creation-landing__asset-card {');
|
||||
const tabletQueryIndex = css.indexOf('@media (max-width: 900px)');
|
||||
const mobileQueryIndex = css.indexOf('@media (max-width: 560px)');
|
||||
|
||||
@@ -59,17 +67,28 @@ describe('index stylesheet unread dots', () => {
|
||||
'.creation-landing__asset-waterfall',
|
||||
);
|
||||
|
||||
expect(waterfallBlock).toContain('column-count: 3;');
|
||||
expect(waterfallBlock).toContain('column-gap: 0.92rem;');
|
||||
expect(waterfallBlock).not.toContain('display: grid;');
|
||||
expect(waterfallBlock).not.toContain('grid-template-columns');
|
||||
expect(cardBlock).toContain('display: inline-block;');
|
||||
expect(cardBlock).toContain('margin: 0 0 0.92rem;');
|
||||
expect(cardBlock).toContain('break-inside: avoid;');
|
||||
expect(tabletWaterfallBlock).toContain('column-count: 2;');
|
||||
expect(tabletWaterfallBlock).not.toContain('grid-template-columns');
|
||||
expect(mobileWaterfallBlock).toContain('column-count: 1;');
|
||||
expect(mobileWaterfallBlock).not.toContain('grid-template-columns');
|
||||
expect(waterfallBlock).toContain('display: grid;');
|
||||
expect(waterfallBlock).toContain(
|
||||
'grid-template-columns: repeat(3, minmax(0, 1fr));',
|
||||
);
|
||||
expect(waterfallBlock).toContain('grid-auto-flow: row;');
|
||||
expect(waterfallBlock).toContain('gap: 0.92rem;');
|
||||
expect(waterfallBlock).toContain('align-items: start;');
|
||||
expect(waterfallBlock).not.toContain('column-count');
|
||||
expect(masonryBlock).toContain('position: relative;');
|
||||
expect(masonryBlock).toContain('display: block;');
|
||||
expect(masonryCardBlock).toContain('position: absolute;');
|
||||
expect(masonryCardBlock).toContain('box-sizing: border-box;');
|
||||
expect(cardBlock).toContain('display: block;');
|
||||
expect(cardBlock).toContain('margin: 0;');
|
||||
expect(cardBlock).toContain('align-self: start;');
|
||||
expect(cardBlock).not.toContain('break-inside');
|
||||
expect(tabletWaterfallBlock).toContain(
|
||||
'grid-template-columns: repeat(2, minmax(0, 1fr));',
|
||||
);
|
||||
expect(tabletWaterfallBlock).not.toContain('column-count');
|
||||
expect(mobileWaterfallBlock).toContain('grid-template-columns: 1fr;');
|
||||
expect(mobileWaterfallBlock).not.toContain('column-count');
|
||||
});
|
||||
|
||||
it('keeps icon generator decoration from overriding the shared generating animation', () => {
|
||||
|
||||
Reference in New Issue
Block a user