修复画布返回前封面持久化
返回项目页前立即刷新并等待权威画布布局保存 等待本地封面缓存、OSS 上传和项目资源创建完成 离屏视口生成纯背景封面并补充回归测试 同步图片画布封面技术文档和共享记忆
This commit is contained in:
@@ -556,8 +556,9 @@
|
||||
## 2026-06-24 图片画布项目封面使用静态快照资源
|
||||
|
||||
- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。
|
||||
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
|
||||
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
|
||||
- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。
|
||||
- 2026-07-29 补充:常规编辑仍沿用防抖保存;用户从画布返回项目页时必须取消待执行 timer,以最新权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存和正式项目资源后再导航。当前视口存在图层但全部位于取景外时仍生成纯背景封面,不沿用旧缩略图。
|
||||
- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/project/ProjectGalleryView.tsx`、`src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。
|
||||
- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
|
||||
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
|
||||
- 封面是弱一致的展示派生物,不是 layout 真相。只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独生成封面,也不为封面增加强一致的返回阻塞。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。
|
||||
- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。
|
||||
- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount`、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- 新增 `editor_showcase_asset`、`editor_showcase_asset_like` 和 `editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。封面只是弱一致的展示派生物,可以滞后于最新 layout;它不是项目真相,不得为了追求强一致而监听尺寸变化、增加保存频率或阻塞离开画布。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
|
||||
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景;存在 drawable 图层但当前取景全部离屏时仍输出纯背景封面。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。常规编辑允许封面滞后于最新 layout,但用户主动返回项目页时必须取消待执行防抖,以当前权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存、上传 OSS 和创建项目资源后再导航;不得用 `beforeunload` 代替受控返回。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
|
||||
- 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。
|
||||
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
|
||||
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。
|
||||
|
||||
@@ -458,14 +458,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}));
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -506,14 +508,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('opens the requested canvas startup tool from the project query once', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}));
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -529,7 +533,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(window.location.search).toBe('?projectid=editor-project-music');
|
||||
});
|
||||
|
||||
it('shows the loaded project title and a topbar entry back to projects', async () => {
|
||||
it('flushes project persistence before returning from the topbar', async () => {
|
||||
const onPopState = vi.fn();
|
||||
window.addEventListener('popstate', onPopState);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(
|
||||
@@ -539,6 +545,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(projectLink.getAttribute('href')).toBe('/project');
|
||||
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
|
||||
fireEvent.click(projectLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/project');
|
||||
expect(onPopState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
it('opens login modal when the asset library is unauthorized', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
createEditorAsset,
|
||||
@@ -310,9 +311,9 @@ export function ImageCanvasEditorView({
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<
|
||||
(action: CanvasHistoryAction) => void
|
||||
>(() => {});
|
||||
const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>(
|
||||
() => {},
|
||||
);
|
||||
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
|
||||
(matchesLayer: (layer: CanvasLayer) => boolean) => void
|
||||
>(() => {});
|
||||
@@ -790,10 +791,8 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
const canvasHistoryEffects = useMemo(
|
||||
() => ({
|
||||
persistRestoredAssetKind: (
|
||||
layer: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
|
||||
persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1159,6 +1158,7 @@ export function ImageCanvasEditorView({
|
||||
isProjectReady,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
@@ -1173,6 +1173,11 @@ export function ImageCanvasEditorView({
|
||||
openEditorLoginModal,
|
||||
onProjectAccessLost,
|
||||
});
|
||||
const returnToProjects = useCallback(async () => {
|
||||
await flushProjectPersistence();
|
||||
replaceAppHistoryPath('/project');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, [flushProjectPersistence]);
|
||||
const setEditorProjectContextId = useImageCanvasContextStore(
|
||||
(state) => state.setProjectId,
|
||||
);
|
||||
@@ -1369,11 +1374,7 @@ export function ImageCanvasEditorView({
|
||||
const rollbackLayer = layersRef.current.find(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
);
|
||||
persistUpdatedLayerResource(
|
||||
layer,
|
||||
rollbackLayer ?? layer,
|
||||
historyEntryId,
|
||||
);
|
||||
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
|
||||
};
|
||||
const {
|
||||
assetExportStatus,
|
||||
@@ -1478,7 +1479,9 @@ export function ImageCanvasEditorView({
|
||||
if (!projectId || tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
|
||||
const warning = tasks
|
||||
.find((task) => task.warning?.trim())
|
||||
?.warning?.trim();
|
||||
if (warning) {
|
||||
showGenerationWarning(warning);
|
||||
}
|
||||
@@ -2002,9 +2005,7 @@ export function ImageCanvasEditorView({
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({
|
||||
type: targetLayerIds.length
|
||||
? 'move-image'
|
||||
: 'move-generation-result',
|
||||
type: targetLayerIds.length ? 'move-image' : 'move-generation-result',
|
||||
count: targetLayerIds.length || targetDialogIds.length,
|
||||
});
|
||||
if (targetLayerIds.length) {
|
||||
@@ -2310,6 +2311,7 @@ export function ImageCanvasEditorView({
|
||||
onRequestWalletDetails: loadRechargeCenter,
|
||||
onRecharge: openAccountPaymentModal,
|
||||
onOpenWalletLedger: openWalletLedgerPanel,
|
||||
onReturnToProjects: returnToProjects,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
|
||||
@@ -153,7 +153,10 @@ export function resolveProjectCoverDrawableLayers(
|
||||
x: numberOrFallback(layer.x, 0),
|
||||
y: numberOrFallback(layer.y, 0),
|
||||
width: Math.max(1, numberOrFallback(layer.width, layer.originalWidth)),
|
||||
height: Math.max(1, numberOrFallback(layer.height, layer.originalHeight)),
|
||||
height: Math.max(
|
||||
1,
|
||||
numberOrFallback(layer.height, layer.originalHeight),
|
||||
),
|
||||
zIndex: numberOrFallback(layer.zIndex, 0),
|
||||
} satisfies ProjectCoverDrawableLayer;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
|
||||
|
||||
function layer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-offscreen',
|
||||
resourceId: 'resource-offscreen',
|
||||
title: '离屏图层',
|
||||
src: '/generated/offscreen.webp',
|
||||
x: 10_000,
|
||||
y: 10_000,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasProjectCoverSnapshotRenderer', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exports the canvas background when every drawable layer is outside the cover frame', async () => {
|
||||
const fillRect = vi.fn();
|
||||
const drawImage = vi.fn();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
fillRect,
|
||||
drawImage,
|
||||
fillStyle: '',
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback) => callback(new Blob(['cover'], { type: 'image/webp' })),
|
||||
);
|
||||
|
||||
const blob = await createProjectCoverSnapshotBlob({
|
||||
layers: [layer()],
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
backgroundColor: '#f8fafc',
|
||||
});
|
||||
|
||||
expect(blob?.type).toBe('image/webp');
|
||||
expect(fillRect).toHaveBeenCalledWith(0, 0, 320, 240);
|
||||
expect(drawImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,9 @@ function placementIntersectsCover(
|
||||
|
||||
async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) {
|
||||
if (layer.objectKey) {
|
||||
return getSignedAssetReadUrl(
|
||||
{ objectKey: layer.objectKey },
|
||||
undefined,
|
||||
{ cacheVersion: layer.cacheVersion },
|
||||
);
|
||||
return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, {
|
||||
cacheVersion: layer.cacheVersion,
|
||||
});
|
||||
}
|
||||
if (shouldResolveAssetReadUrl(layer.src)) {
|
||||
return resolveAssetReadUrl(layer.src, {
|
||||
@@ -113,15 +111,16 @@ export async function createProjectCoverSnapshotBlob({
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const drawItems = resolveProjectCoverDrawableLayers(layers)
|
||||
const drawableLayers = resolveProjectCoverDrawableLayers(layers);
|
||||
if (drawableLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const drawItems = drawableLayers
|
||||
.map((layer) => ({
|
||||
layer,
|
||||
placement: resolveProjectCoverPlacement(viewport, viewportSize, layer),
|
||||
}))
|
||||
.filter(({ placement }) => placementIntersectsCover(placement));
|
||||
if (drawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width;
|
||||
canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height;
|
||||
@@ -132,6 +131,10 @@ export async function createProjectCoverSnapshotBlob({
|
||||
context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor);
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (drawItems.length === 0) {
|
||||
return canvasToBlob(canvas);
|
||||
}
|
||||
|
||||
const loadedImages = await loadProjectCoverDrawItems(drawItems);
|
||||
let drawnLayerCount = 0;
|
||||
drawItems.forEach(({ placement }, index) => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -67,6 +73,7 @@ function renderTopbar(
|
||||
onRecharge: vi.fn(),
|
||||
onOpenWalletLedger: vi.fn(),
|
||||
onOpenAccount: vi.fn(),
|
||||
onReturnToProjects: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -123,6 +130,29 @@ describe('ImageCanvasTopbarView', () => {
|
||||
expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for project persistence before allowing another return request', async () => {
|
||||
let resolveReturn!: () => void;
|
||||
const pendingReturn = new Promise<void>((resolve) => {
|
||||
resolveReturn = resolve;
|
||||
});
|
||||
const onReturnToProjects = vi.fn(() => pendingReturn);
|
||||
renderTopbar({ onReturnToProjects });
|
||||
const projectLink = screen.getByRole('link', {
|
||||
name: '返回项目页面',
|
||||
});
|
||||
|
||||
fireEvent.click(projectLink);
|
||||
fireEvent.click(projectLink);
|
||||
|
||||
expect(onReturnToProjects).toHaveBeenCalledTimes(1);
|
||||
expect(projectLink.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
resolveReturn();
|
||||
await waitFor(() => {
|
||||
expect(projectLink.getAttribute('aria-disabled')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the current user avatar beside the mud point balance', () => {
|
||||
const props = renderTopbar({
|
||||
currentUser: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Pencil,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
|
||||
|
||||
@@ -51,6 +52,7 @@ export type ImageCanvasTopbarViewProps = {
|
||||
onRecharge: () => void;
|
||||
onOpenWalletLedger: () => void;
|
||||
onOpenAccount: () => void;
|
||||
onReturnToProjects?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) {
|
||||
@@ -88,7 +90,9 @@ export function ImageCanvasTopbarView({
|
||||
onRecharge,
|
||||
onOpenWalletLedger,
|
||||
onOpenAccount,
|
||||
onReturnToProjects,
|
||||
}: ImageCanvasTopbarViewProps) {
|
||||
const [isReturningToProjects, setIsReturningToProjects] = useState(false);
|
||||
const projectId = useImageCanvasContextStore((state) => state.projectId);
|
||||
const hasExportableLayer = layers.some(
|
||||
(layer) => layer.src.trim().length > 0,
|
||||
@@ -107,7 +111,21 @@ export function ImageCanvasTopbarView({
|
||||
className="image-canvas-editor__project-back-button"
|
||||
href="/project"
|
||||
aria-label="返回项目页面"
|
||||
aria-disabled={isReturningToProjects}
|
||||
title="返回项目"
|
||||
onClick={(event) => {
|
||||
if (!onReturnToProjects) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (isReturningToProjects) {
|
||||
return;
|
||||
}
|
||||
setIsReturningToProjects(true);
|
||||
void Promise.resolve(onReturnToProjects()).finally(() => {
|
||||
setIsReturningToProjects(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
@@ -113,6 +113,7 @@ function ProjectPersistenceHarness({
|
||||
const [isViewportInteracting, setIsViewportInteracting] = useState(false);
|
||||
const [projectTitle, setProjectTitle] = useState('');
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||
const [flushCompleted, setFlushCompleted] = useState(false);
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||
@@ -209,6 +210,18 @@ function ProjectPersistenceHarness({
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="background">{canvasBackgroundColor}</span>
|
||||
<span data-testid="flush-completed">{String(flushCompleted)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFlushCompleted(false);
|
||||
void persistence.flushProjectPersistence().then(() => {
|
||||
setFlushCompleted(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
flush project persistence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -985,7 +998,9 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(staleReload.promise);
|
||||
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
|
||||
staleReload.promise,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(<ProjectPersistenceHarness />);
|
||||
await Promise.resolve();
|
||||
@@ -1723,6 +1738,59 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes the latest layout and waits for the local cover cache before returning', async () => {
|
||||
const coverBlob = new Blob(['cover'], { type: 'image/webp' });
|
||||
const coverCacheWrite = createDeferred<void>();
|
||||
createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob);
|
||||
putEditorProjectCoverCacheMock.mockReturnValue(coverCacheWrite.promise);
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append server resource' }).click();
|
||||
screen.getByRole('button', { name: 'move viewport' }).click();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5');
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'flush project persistence' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
viewport: { x: 10, y: 5, scale: 1 },
|
||||
}),
|
||||
);
|
||||
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
viewport: { x: 200, y: 165, scale: 0.5 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
}),
|
||||
);
|
||||
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
|
||||
projectId: 'editor-project-default',
|
||||
blob: coverBlob,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('flush-completed').textContent).toBe('false');
|
||||
|
||||
coverCacheWrite.resolve();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
|
||||
});
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({ assetKind: 'project-cover-snapshot' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads and saves viewport scale with display zoom semantics', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
|
||||
@@ -123,10 +123,7 @@ type ImageCanvasProjectPersistenceOptions = {
|
||||
};
|
||||
|
||||
function isEditorAuthError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 401
|
||||
);
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
|
||||
function isEditorProjectAccessError(error: unknown) {
|
||||
@@ -420,11 +417,16 @@ export function useImageCanvasProjectPersistence({
|
||||
null,
|
||||
);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const projectTitleRef = useRef('未命名画布');
|
||||
const coverSnapshotSignatureRef = useRef<string | null>(null);
|
||||
const coverSnapshotUploadRequestRef = useRef(0);
|
||||
const activeCoverSnapshotPersistenceRef = useRef<{
|
||||
signature: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const coverSnapshotViewportSizeRef = useRef(canvasSize);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
@@ -482,7 +484,7 @@ export function useImageCanvasProjectPersistence({
|
||||
hasAuthoritativeProjectSnapshotRef.current &&
|
||||
authoritativeProjectIdRef.current === pendingSave.projectId;
|
||||
let runNextSave = false;
|
||||
void saveEditorProjectLayout(pendingSave.projectId, {
|
||||
const savePromise = saveEditorProjectLayout(pendingSave.projectId, {
|
||||
...pendingSave.input,
|
||||
expectedRevision,
|
||||
})
|
||||
@@ -586,6 +588,9 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
|
||||
activeProjectLayoutSavePromiseRef.current = null;
|
||||
}
|
||||
isProjectLayoutSaveRunningRef.current = false;
|
||||
if (
|
||||
runNextSave &&
|
||||
@@ -595,6 +600,8 @@ export function useImageCanvasProjectPersistence({
|
||||
runPendingProjectLayoutSave();
|
||||
}
|
||||
});
|
||||
activeProjectLayoutSavePromiseRef.current = savePromise;
|
||||
void savePromise;
|
||||
},
|
||||
[openEditorLoginModal],
|
||||
);
|
||||
@@ -606,7 +613,7 @@ export function useImageCanvasProjectPersistence({
|
||||
coverLayers: readonly CanvasLayer[],
|
||||
) => {
|
||||
if (!canAccessProtectedData) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
const rawCoverViewport =
|
||||
canvasDisplayViewportToViewport(coverDisplayViewport);
|
||||
@@ -622,14 +629,20 @@ export function useImageCanvasProjectPersistence({
|
||||
viewportSize,
|
||||
backgroundColor,
|
||||
});
|
||||
if (!signature || coverSnapshotSignatureRef.current === signature) {
|
||||
return;
|
||||
if (!signature) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (coverSnapshotSignatureRef.current === signature) {
|
||||
return activeCoverSnapshotPersistenceRef.current?.signature ===
|
||||
signature
|
||||
? activeCoverSnapshotPersistenceRef.current.promise
|
||||
: Promise.resolve();
|
||||
}
|
||||
coverSnapshotSignatureRef.current = signature;
|
||||
coverSnapshotUploadRequestRef.current += 1;
|
||||
const requestId = coverSnapshotUploadRequestRef.current;
|
||||
|
||||
void createProjectCoverSnapshotBlob({
|
||||
const persistencePromise = createProjectCoverSnapshotBlob({
|
||||
layers: coverLayers,
|
||||
viewport: coverViewport,
|
||||
viewportSize,
|
||||
@@ -645,7 +658,7 @@ export function useImageCanvasProjectPersistence({
|
||||
if (coverSnapshotUploadRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
void putEditorProjectCoverCache({
|
||||
await putEditorProjectCoverCache({
|
||||
projectId: nextProjectId,
|
||||
blob,
|
||||
});
|
||||
@@ -693,7 +706,20 @@ export function useImageCanvasProjectPersistence({
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
activeCoverSnapshotPersistenceRef.current?.promise ===
|
||||
persistencePromise
|
||||
) {
|
||||
activeCoverSnapshotPersistenceRef.current = null;
|
||||
}
|
||||
});
|
||||
activeCoverSnapshotPersistenceRef.current = {
|
||||
signature,
|
||||
promise: persistencePromise,
|
||||
};
|
||||
return persistencePromise;
|
||||
},
|
||||
[canAccessProtectedData, openEditorLoginModal, refs],
|
||||
);
|
||||
@@ -705,7 +731,7 @@ export function useImageCanvasProjectPersistence({
|
||||
Parameters<typeof saveEditorProjectLayout>[1],
|
||||
'expectedRevision'
|
||||
>,
|
||||
options: { delayMs?: number } = {},
|
||||
options: { delayMs?: number; persistCover?: boolean } = {},
|
||||
) => {
|
||||
const revision = projectRevisionRef.current;
|
||||
if (
|
||||
@@ -739,24 +765,29 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
|
||||
const delayMs = options.delayMs ?? 0;
|
||||
const shouldPersistCover = options.persistCover !== false;
|
||||
if (delayMs > 0) {
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
if (shouldPersistCover) {
|
||||
void persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
if (shouldPersistCover) {
|
||||
void persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
},
|
||||
[
|
||||
@@ -767,6 +798,69 @@ export function useImageCanvasProjectPersistence({
|
||||
],
|
||||
);
|
||||
|
||||
const flushProjectPersistence = useCallback(async () => {
|
||||
while (activeProjectLayoutSavePromiseRef.current) {
|
||||
await activeProjectLayoutSavePromiseRef.current;
|
||||
}
|
||||
|
||||
const nextProjectId = projectIdRef.current;
|
||||
if (
|
||||
!nextProjectId ||
|
||||
!canAccessProtectedDataRef.current ||
|
||||
!hasAuthoritativeProjectSnapshotRef.current ||
|
||||
authoritativeProjectIdRef.current !== nextProjectId ||
|
||||
projectRevisionRef.current === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coverDisplayViewport = viewportToCanvasDisplayViewport(
|
||||
refs.viewportRef.current,
|
||||
);
|
||||
queueProjectLayoutSave(
|
||||
nextProjectId,
|
||||
{
|
||||
viewport: coverDisplayViewport,
|
||||
layers: serializeCanvasLayout({
|
||||
layers: refs.layersRef.current,
|
||||
canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current,
|
||||
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
|
||||
}),
|
||||
},
|
||||
{ persistCover: false },
|
||||
);
|
||||
|
||||
const coverSave = persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
coverDisplayViewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
while (
|
||||
activeProjectLayoutSavePromiseRef.current ||
|
||||
pendingProjectLayoutSaveRef.current
|
||||
) {
|
||||
const activeLayoutSave = activeProjectLayoutSavePromiseRef.current;
|
||||
if (activeLayoutSave) {
|
||||
await activeLayoutSave;
|
||||
continue;
|
||||
}
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
if (!activeProjectLayoutSavePromiseRef.current) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await coverSave;
|
||||
}, [
|
||||
persistProjectCoverSnapshot,
|
||||
queueProjectLayoutSave,
|
||||
refs,
|
||||
runPendingProjectLayoutSave,
|
||||
]);
|
||||
|
||||
const applyCreatedProjectResourceLayer = useCallback(
|
||||
(pendingLayer: PendingCreatedProjectResourceLayer) => {
|
||||
if (
|
||||
@@ -861,8 +955,7 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingLayer: PendingCreatedProjectResourceLayer = {
|
||||
projectId: readyProjectId,
|
||||
ownerUserId: requestOwnerUserId,
|
||||
authoritativeSnapshotSequence:
|
||||
requestAuthoritativeSnapshotSequence,
|
||||
authoritativeSnapshotSequence: requestAuthoritativeSnapshotSequence,
|
||||
layer,
|
||||
options,
|
||||
resourceId: resource.resourceId,
|
||||
@@ -1115,7 +1208,8 @@ export function useImageCanvasProjectPersistence({
|
||||
allowProjectSwitch: true,
|
||||
});
|
||||
if (!projectIsAuthoritative) {
|
||||
const activeAuthoritativeProjectId = authoritativeProjectIdRef.current;
|
||||
const activeAuthoritativeProjectId =
|
||||
authoritativeProjectIdRef.current;
|
||||
if (
|
||||
hasAuthoritativeProjectSnapshotRef.current &&
|
||||
activeAuthoritativeProjectId !== null &&
|
||||
@@ -1232,5 +1326,6 @@ export function useImageCanvasProjectPersistence({
|
||||
createProjectResourceForLayer,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user