实现精选顺序分列瀑布流
按容器宽度循环分配精选卡片到三列、两列或单列 使用 ResizeObserver 动态重排并保留 Grid 安全回退 补充布局临界值、分页追加和样式回归测试 同步创作主页文档与项目共享决策
This commit is contained in:
@@ -4389,3 +4389,12 @@
|
||||
- 运维与回滚:用 `scripts/gitea-ci-job-image.sh build|verify|export|load-runner` 管理镜像,按 `build/verify -> export 仓库外镜像归档和 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份 config -> 增加或替换 label -> docker restart --timeout 660` 切换。config 与镜像归档只放仓库外受控位置;共享文档只记录通用备份规则,不记录宿主绝对路径、注册信息或 token。重启后先验证真实 CI 再清理旧镜像;回滚先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
|
||||
- 影响范围:`.gitea/workflows/project-ci.yml`、`deploy/container/gitea-ci-job.Dockerfile`、`scripts/gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-runtime.sh`、runner label/config 和 Gitea CI 运维文档。
|
||||
- 验证方式:构建脚本校验宿主与 runner 内层 Image ID 一致;环境脚本校验 Node、Rust、`rustfmt`、Chrome、bwrap、原生命令与 pkg-config 依赖;runtime 脚本执行完整 bwrap 和 Chrome headless canary;真实 PR 的四个 job 全部通过,同时复核 `Privileged=false`、`Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]` 和独立网络。
|
||||
|
||||
## 2026-07-22 陶泥儿精选改为顺序循环分列 Masonry
|
||||
|
||||
- 背景:CSS multi-column 会按纵向高度平衡卡片,少量素材或活动卡与普通素材高度差较大时,桌面首行会只放一到两张,后续卡片提前从左侧下一段开始,无法满足“每行填满三张再换行”。
|
||||
- 决策:`/creation` 陶泥儿精选保持平面 DOM 顺序,按当前列数将第 `index` 张循环分配到 `index % columns` 列。三列下第 1/2/3 张分别进入第 1/2/3 列,第 4/5/6 张再分别接续三列;不采用最短列贪心排序,避免同一组多张连续进入同一列。
|
||||
- 宽度与高度边界:列数由精选容器实际宽度、0.92rem computed gap 和 288px 首选最小列宽共同决定,最多三列。卡片先获得目标列宽,再按真实 preview aspect ratio 和内容测量高度;同列紧凑堆叠,不拉伸、裁切或等待其它列高卡。循环分列只消除列内空洞,较短列在整个容器底部仍可有尾部高度差。
|
||||
- 动态与可用性边界:`useLayoutEffect` 首次同步测量,`ResizeObserver + requestAnimationFrame` 在容器变宽、卡高变化、筛选重排和 cursor 追加后全量重排。只有当宽度、卡数和所有高度完整时才进入 absolute ready;否则保留 Grid fallback,防止卡片重叠和分页 sentinel 提前触发。DOM/Tab/读屏顺序始终不变,容器与卡片显式为 list/listitem。
|
||||
- 兼容边界:保留现有 `.creation-landing__asset-waterfall` 类名、筛选、排序、cursor 分页、预览与点赞链路;只替换布局算法。该决策覆盖 2026-07-07 multi-column 及本日早先 row-major Grid 的布局部分,不改变精选仍是动态素材流的产品定位。
|
||||
- 验证方式:纯函数测试锁定容器临界宽度、循环列序、列内 top 和容器高度;`src/index.test.ts` 锁定 Grid fallback 与 Masonry ready。Playwright 在同一 viewport 中变更容器宽度,核对 3/2/1 列、每列 gap、容器高度、DOM 顺序、无重叠/横溢出和 console/page error。
|
||||
|
||||
@@ -119,13 +119,14 @@
|
||||
- 验证:`creationShowcaseModel.test.ts` 覆盖展示名优先、陶泥号兜底和内部 owner/user id 不展示;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留公开作者字段;若改动 SpacetimeDB read model,再运行 `npm run spacetime:generate`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:spacetime-schema`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/components/creation-home/creationShowcaseModel.ts`。
|
||||
|
||||
## 陶泥儿精选瀑布流变宽先查 multi-column 容器宽度
|
||||
## 陶泥儿精选顺序分列不要用 multi-column 或共享 Grid 行高
|
||||
|
||||
- 现象:release `/creation` 桌面端精选卡片明显变宽,第三列被裁到屏幕外,页面内部可横向滑动;dev 看起来正常。
|
||||
- 原因:精选瀑布流使用 `column-count`,当它作为 grid item 时如果没有显式 `width: 100%` / `min-width: 0`,Chrome 会用多列内容的 intrinsic width 反向撑开 grid track。线上实测 1920 视口下 section 为 `1296px`,waterfall 被撑到约 `2048px`,单卡宽约 `672px`。
|
||||
- 处理:保留 multi-column 瀑布流时,`.creation-landing__asset-waterfall` 必须显式约束 `width: 100%` 和 `min-width: 0`;不要只看单张图片天然尺寸或改卡片宽度。
|
||||
- 验证:Playwright / CSSOM 检查 `.creation-landing__section`、`.creation-landing__asset-waterfall`、首张 `.creation-landing__asset-card` 的 `getBoundingClientRect()`,waterfall 宽度应等于 section 宽度。
|
||||
- 关联:`src/index.css`、`src/components/creation-home/CreationLandingView.tsx`。
|
||||
- 现象:`/creation` 桌面端主内容区明明能放下三张卡,首行却只出现一到两张,后续素材提前回到左侧下一段;活动卡与普通素材高度差较大时尤其明显。
|
||||
- 原因:`column-count` 按纵向文章列流入并平衡,三张卡可排成 `2 + 1 + 0` 列;标准 CSS Grid 虽会横向先填三张,但整行共用最高卡行轨,短卡下会留下高度差。`align-items: start` 只是不拉伸短卡,不能消除行轨空白;`grid-auto-flow: dense` 也不能填单个网格项内的剩余高度。
|
||||
- 处理:保留平面 DOM 和 `.creation-landing__asset-waterfall` 旧类名,按当前列数将第 `index` 张显式放入 `index % columns` 列;三列时第 1/2/3 张分别进入三列,第 4/5/6 张再分别接到三列下方。列数以容器实际宽度和 288px 首选最小列宽计算,不以 viewport 硬切;先设目标卡宽再测真实卡高,列内用 computed gap 紧凑堆叠。
|
||||
- 动态边界:只有当容器宽度、卡数和所有卡高有效时才进入 absolute Masonry ready;否则保留 Grid fallback。`ResizeObserver + requestAnimationFrame` 在容器变宽、图片/字体/文本改变高度、筛选重排和 cursor 追加后全量重算;cleanup 必须兼容 StrictMode,避免分页 sentinel 因容器短暂零高提前触发。
|
||||
- 验证:纯函数测试锁定容器宽度临界值、`index % columns` 和最高列容器高;样式契约同时锁定 Grid fallback 与 Masonry ready。Playwright 需在同一 viewport 内改容器宽度验证 3/2/1 列,检查每列相邻卡间距等于 gap、容器高等于最高列底、DOM 顺序不变且无重叠/横向溢出。
|
||||
- 关联:`src/index.css`、`src/index.test.ts`、`src/components/creation-home/CreationLandingView.tsx`、`src/components/creation-home/showcaseMasonryLayout.ts`。
|
||||
|
||||
## 画板外部生成排队超时不是失败
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
|
||||
### 陶泥儿精选
|
||||
|
||||
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
`陶泥儿精选` 是页面底部的全站公开画布生成素材流,不承载玩法入口列表。列表按 DOM 顺序做循环分列:当前为三列时,第 1 / 2 / 3 张分别进入第 1 / 2 / 3 列,第 4 / 5 / 6 张再分别接到这三列下方,后续持续循环;不用最短列贪心分配改变每组的列归属。列数根据精选容器真实可用宽度自动在三、二、一列之间切换,不以整个 viewport 宽度硬判断。卡片仍按真实素材宽高设置预览比例,每张紧贴本列上一张且保留正常 gap,不拉伸、裁成等高卡,也不使用 multi-column 的纵向平衡或标准 Grid 的共享行高。
|
||||
|
||||
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId` 或 `objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm。
|
||||
精选素材流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有列表,而不是固定只展示首屏数量。追加、筛选、分类和容器/卡片尺寸变化都必须按当前完整 DOM 顺序重算循环分列;位置未完整时保留正常文档流 fallback,不能让分页 sentinel 因容器短暂零高提前触发。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选;活动卡作为第一张进入第一列,普通素材从第二张继续同一循环列序。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm。
|
||||
|
||||
Tab:
|
||||
|
||||
@@ -164,7 +164,7 @@ Tab:
|
||||
### 创作主页
|
||||
|
||||
- 新增 `CreationLandingView`。
|
||||
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。
|
||||
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选顺序循环分列素材流。
|
||||
- 九大功能区卡片必须是可点击按钮;已开放卡片复用 `createEditorProject` 和画布现有生成器,不新增入口系统;暂未开放卡片复用移动端欢迎弹窗样式。
|
||||
- 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。
|
||||
- 主页整体采用浅色布局,避免外站品牌文案。
|
||||
@@ -223,8 +223,8 @@ git diff --check
|
||||
- 移动端进入 `/` 首页时显示 `欢迎` 弹窗,正文包含移动端仅支持作品展示和电脑端访问提示,按钮为 `好`。
|
||||
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。
|
||||
- “我的”页没有项目快捷入口。
|
||||
- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
|
||||
- 陶泥儿精选素材流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选按 DOM 索引循环分列,每组卡片依次占用当前所有列,列内不因其它列的高卡留空;卡片按真实素材宽高呈现,不强制固定等高。列数必须根据精选容器实际宽度自动变为三/二/一列,不以 viewport 断点代替容器宽度。
|
||||
- 陶泥儿精选不出现玩法入口卡或公开作品图片补充展示。
|
||||
|
||||
## 非目标
|
||||
|
||||
@@ -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;
|
||||
@@ -804,7 +808,7 @@ describe('CreationLandingView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -898,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,
|
||||
@@ -910,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', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
minThreads: 1,
|
||||
maxThreads: 8,
|
||||
include: [
|
||||
'src/index.test.ts',
|
||||
'src/routing/activeAppPageRoutes.test.ts',
|
||||
'src/routing/activeAppRoutes.test.ts',
|
||||
'src/services/activeAppTitle.test.ts',
|
||||
|
||||
Reference in New Issue
Block a user