From 2ced33a23254c61081af1fe9c84099e4cb80cf93 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 29 Jul 2026 15:59:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=89=8D=E5=B0=81=E9=9D=A2=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 返回项目页前立即刷新并等待权威画布布局保存 等待本地封面缓存、OSS 上传和项目资源创建完成 离屏视口生成纯背景封面并补充回归测试 同步图片画布封面技术文档和共享记忆 --- .../shared-memory/decision-log.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasEditorView.test.tsx | 50 +++--- .../image-editor/ImageCanvasEditorView.tsx | 34 +++-- .../ImageCanvasProjectCoverSnapshotModel.ts | 5 +- ...CanvasProjectCoverSnapshotRenderer.test.ts | 54 +++++++ ...ImageCanvasProjectCoverSnapshotRenderer.ts | 21 +-- .../ImageCanvasTopbarView.test.tsx | 32 +++- .../image-editor/ImageCanvasTopbarView.tsx | 18 +++ .../useImageCanvasProjectPersistence.test.tsx | 70 ++++++++- .../useImageCanvasProjectPersistence.ts | 143 +++++++++++++++--- 12 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 740ca6c4a..bc2d27490 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b540e084d..8bafd41c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 6fd0e61a4..a5536fdd7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `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 或临时图片源。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 4c4a56e21..9c171e9e1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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(); 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 () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 549d75dd7..85243ec91 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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([]); const viewportRef = useRef(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(); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index 80539acbf..4b4aabba3 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -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; }) diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts new file mode 100644 index 000000000..11ddbfd8b --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts index fb6493c94..0c790b8ec 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts @@ -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) => { diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2ff3eeaf5..d8b2a1ff1 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -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((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: { diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index ba20c2e4c..b2660d5d9 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -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; }; 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); + }); + }} >