From 23e2d099ff3af46a50921f89ecc18233612e5f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 24 Jul 2026 14:27:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A6=96=E9=A1=B5=E3=80=8C=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E9=A1=B9=E7=9B=AE=E3=80=8D=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=E4=B8=8E=E9=80=80=E5=87=BA=E5=89=8D=E8=A7=86=E8=A7=92=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=8C=E5=87=BA=E7=8E=B0=E5=81=8F=E7=A7=BB?= =?UTF-8?q?=E6=88=96=E8=A3=81=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 封面使用真实画布尺寸(clientWidth/Height)及最终 viewport.x/y/scale。 - 320×240 封面改为居中 cover 裁切,不再从左上角取景。 - 正确绘制画布背景色。 --- .../shared-memory/decision-log.md | 1 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 1 + ...ageCanvasProjectCoverSnapshotModel.test.ts | 27 ++++ .../ImageCanvasProjectCoverSnapshotModel.ts | 124 ++++++++++++++---- .../useImageCanvasProjectPersistence.test.tsx | 37 ++++-- .../useImageCanvasProjectPersistence.ts | 31 ++++- 7 files changed, 184 insertions(+), 39 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8604b6bdc..aedadcd76 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -539,6 +539,7 @@ - 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。 - 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。 - 影响范围:`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/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 50ab585e2..c75be9180 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 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG:取景必须使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale` 和当前工作区尺寸,先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得按固定工作区尺寸或左上角锚定取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 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 或临时图片源。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 2e4db9067..dc4512b39 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1116,6 +1116,7 @@ export function ImageCanvasEditorView({ () => ({ layersRef, viewportRef, + canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 063be7378..a2eb0c9d0 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -4,6 +4,7 @@ import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, resolveProjectCoverDrawableLayers, + resolveProjectCoverSnapshotFrame, } from './ImageCanvasProjectCoverSnapshotModel'; function layer(overrides: Partial = {}): CanvasLayer { @@ -28,6 +29,8 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { it('builds a stable cover signature from visible drawable layers', () => { const signature = buildProjectCoverSnapshotSignature({ viewport: { x: 12, y: -8, scale: 0.75 }, + viewportSize: { width: 900, height: 640 }, + backgroundColor: '#f8fafc', layers: [ layer({ id: 'hidden', hidden: true }), layer({ @@ -49,12 +52,36 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { }); expect(signature).toContain('"scale":0.75'); + expect(signature).toContain('"width":900'); + expect(signature).toContain('"backgroundColor":"#f8fafc"'); expect(signature).toContain('"id":"back"'); expect(signature).toContain('"id":"front"'); expect(signature).not.toContain('"id":"hidden"'); expect(signature).not.toContain('"id":"audio"'); }); + it('center-crops the live canvas viewport to fill the 4:3 cover', () => { + const landscapeFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: -260, y: 70, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + }); + + expect(landscapeFrame.renderScale).toBe(0.5); + expect(landscapeFrame.frameScale).toBeCloseTo(0.375); + expect(landscapeFrame.offsetX).toBeCloseTo(-8.75); + expect(landscapeFrame.offsetY).toBeCloseTo(0); + + const portraitFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: 30, y: -40, scale: 1.25 }, + viewportSize: { width: 640, height: 900 }, + }); + + expect(portraitFrame.renderScale).toBe(1.25); + expect(portraitFrame.frameScale).toBeCloseTo(0.5); + expect(portraitFrame.offsetX).toBeCloseTo(0); + expect(portraitFrame.offsetY).toBeCloseTo(-105); + }); + it('uses the first image-sequence frame as the drawable source', () => { expect( resolveProjectCoverDrawableLayers([ diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index c56250b31..5b661ba3d 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -1,14 +1,19 @@ import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; +import { + DEFAULT_CANVAS_BACKGROUND_COLOR, + normalizeCanvasBackgroundHex, +} from './ImageCanvasEditorModel'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot'; export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND = 'editor_project_cover_snapshot'; export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const; -export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = { - width: 900, - height: 640, -} as const; + +export type ProjectCoverSnapshotViewportSize = { + width: number; + height: number; +}; type ProjectCoverDrawableLayer = { id: string; @@ -25,6 +30,32 @@ function numberOrFallback(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback; } +function positiveNumberOrFallback(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function normalizeProjectCoverViewportSize( + viewportSize: ProjectCoverSnapshotViewportSize, +) { + return { + width: positiveNumberOrFallback( + viewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.width, + ), + height: positiveNumberOrFallback( + viewportSize.height, + PROJECT_COVER_SNAPSHOT_SIZE.height, + ), + }; +} + +function normalizeProjectCoverBackgroundColor(backgroundColor: string) { + return ( + normalizeCanvasBackgroundHex(backgroundColor) ?? + DEFAULT_CANVAS_BACKGROUND_COLOR + ); +} + function normalizeObjectKey(value: string | null | undefined) { const normalized = value?.trim().replace(/^\/+/u, '') ?? ''; return normalized || null; @@ -83,51 +114,79 @@ export function resolveProjectCoverDrawableLayers( export function buildProjectCoverSnapshotSignature({ layers, viewport, + viewportSize, + backgroundColor, }: { layers: readonly CanvasLayer[]; + // we may call it camera viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; }) { const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); return JSON.stringify({ viewport: { x: numberOrFallback(viewport.x, 0), y: numberOrFallback(viewport.y, 0), scale: numberOrFallback(viewport.scale, 1), }, + viewportSize: normalizedViewportSize, + backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor), layers: coverLayers, }); } +export function resolveProjectCoverSnapshotFrame({ + viewport, + viewportSize, +}: { + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; +}) { + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); + const renderScale = + viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; + const frameScale = Math.max( + PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height, + ); + return { + viewportX: numberOrFallback(viewport.x, 0), + viewportY: numberOrFallback(viewport.y, 0), + renderScale, + frameScale, + offsetX: + (PROJECT_COVER_SNAPSHOT_SIZE.width - + normalizedViewportSize.width * frameScale) / + 2, + offsetY: + (PROJECT_COVER_SNAPSHOT_SIZE.height - + normalizedViewportSize.height * frameScale) / + 2, + }; +} + function resolveProjectCoverPlacement( viewport: CanvasViewport, + viewportSize: ProjectCoverSnapshotViewportSize, layer: ProjectCoverDrawableLayer, ) { - const safeScale = - viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; - const viewportCenterX = - (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale; - const viewportCenterY = - (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale; - const worldPreviewWidth = - PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale; - const worldPreviewHeight = - PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale; - const previewMinX = viewportCenterX - worldPreviewWidth / 2; - const previewMinY = viewportCenterY - worldPreviewHeight / 2; - const scale = Math.min( - PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth, - PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight, - ); - const offsetX = -previewMinX * scale; - const offsetY = -previewMinY * scale; + const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize }); return { - x: offsetX + layer.x * scale, - y: offsetY + layer.y * scale, - width: layer.width * scale, - height: layer.height * scale, + x: + frame.offsetX + + (frame.viewportX + layer.x * frame.renderScale) * frame.frameScale, + y: + frame.offsetY + + (frame.viewportY + layer.y * frame.renderScale) * frame.frameScale, + width: layer.width * frame.renderScale * frame.frameScale, + height: layer.height * frame.renderScale * frame.frameScale, }; } @@ -153,9 +212,13 @@ function canvasToBlob(canvas: HTMLCanvasElement) { export async function createProjectCoverSnapshotBlob({ layers, viewport, + viewportSize, + backgroundColor, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; }) { if (typeof document === 'undefined') { return null; @@ -171,13 +234,18 @@ export async function createProjectCoverSnapshotBlob({ if (!context || typeof canvas.toBlob !== 'function') { return null; } - context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); + context.fillRect(0, 0, canvas.width, canvas.height); let drawnLayerCount = 0; for (const layer of coverLayers) { try { const image = await loadProjectCoverImage(layer); - const placement = resolveProjectCoverPlacement(viewport, layer); + const placement = resolveProjectCoverPlacement( + viewport, + viewportSize, + layer, + ); context.drawImage( image, placement.x, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index c433c4597..8ebb77d8f 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -118,6 +118,7 @@ function ProjectPersistenceHarness({ const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); const viewportRef = useRef(viewport); + const canvasViewportRef = useRef(null); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); const selectedLayerRef = useRef(null); @@ -146,6 +147,7 @@ function ProjectPersistenceHarness({ () => ({ layersRef, viewportRef, + canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), @@ -949,6 +951,15 @@ describe('useImageCanvasProjectPersistence', () => { render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith( + expect.objectContaining({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR, + }), + ); + }); await waitFor(() => { expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -1073,14 +1084,12 @@ describe('useImageCanvasProjectPersistence', () => { }); it('does not serialize or write session cache while viewport interactions are active', async () => { - const sessionSetItemSpy = vi.spyOn( - Storage.prototype, - 'setItem', - ); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); - sessionSetItemSpy.mockClear(); + const cachedBeforeInteraction = globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ); saveEditorProjectLayoutMock.mockClear(); vi.useFakeTimers(); @@ -1097,7 +1106,11 @@ describe('useImageCanvasProjectPersistence', () => { }); expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); - expect(sessionSetItemSpy).not.toHaveBeenCalled(); + expect( + globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ), + ).toBe(cachedBeforeInteraction); act(() => { screen.getByRole('button', { name: 'end viewport interaction' }).click(); @@ -1115,9 +1128,17 @@ describe('useImageCanvasProjectPersistence', () => { viewport: { x: 20, y: 10, scale: 1 }, }), ); - expect(sessionSetItemSpy).toHaveBeenCalled(); + const cachedAfterInteraction = JSON.parse( + globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ) ?? '{}', + ) as { project?: EditorProjectSnapshot }; + expect(cachedAfterInteraction.project?.viewport).toEqual({ + x: 20, + y: 10, + scale: 1, + }); vi.useRealTimers(); - sessionSetItemSpy.mockRestore(); }); it('does not create a project resource again for backend-persisted generated layers', async () => { diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 3adecfecf..b1617927e 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -22,6 +22,7 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, + DEFAULT_CANVAS_SIZE, hydrateLayer, isInlineEditorMediaSource, serializeCanvasLayout, @@ -65,6 +66,7 @@ type CachedEditorProjectSnapshot = { type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; + canvasViewportRef: RefObject; canvasGenerationDialogsRef: RefObject; canvasBackgroundColorRef: RefObject; }; @@ -114,6 +116,21 @@ function isEditorProjectRevisionConflict(error: unknown) { return error instanceof ApiClientError && error.status === 409; } +function resolveProjectCoverViewportSize( + viewportElement: HTMLDivElement | null, +) { + const width = viewportElement?.clientWidth ?? 0; + const height = viewportElement?.clientHeight ?? 0; + return { + width: + width > 0 && Number.isFinite(width) ? width : DEFAULT_CANVAS_SIZE.width, + height: + height > 0 && Number.isFinite(height) + ? height + : DEFAULT_CANVAS_SIZE.height, + }; +} + const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([ 408, 425, 429, 502, 503, 504, ]); @@ -453,15 +470,23 @@ export function useImageCanvasProjectPersistence({ const persistProjectCoverSnapshot = useCallback( ( nextProjectId: string, - coverViewport: CanvasViewport, + coverDisplayViewport: CanvasViewport, coverLayers: readonly CanvasLayer[], ) => { if (!canAccessProtectedData) { return; } + const coverViewport = + canvasDisplayViewportToViewport(coverDisplayViewport); + const viewportSize = resolveProjectCoverViewportSize( + refs.canvasViewportRef.current, + ); + const backgroundColor = refs.canvasBackgroundColorRef.current; const signature = buildProjectCoverSnapshotSignature({ layers: coverLayers, viewport: coverViewport, + viewportSize, + backgroundColor, }); if (!signature || coverSnapshotSignatureRef.current === signature) { return; @@ -473,6 +498,8 @@ export function useImageCanvasProjectPersistence({ void createProjectCoverSnapshotBlob({ layers: coverLayers, viewport: coverViewport, + viewportSize, + backgroundColor, }) .then(async (blob) => { if (!blob) { @@ -530,7 +557,7 @@ export function useImageCanvasProjectPersistence({ } }); }, - [canAccessProtectedData, openEditorLoginModal], + [canAccessProtectedData, openEditorLoginModal, refs], ); const queueProjectLayoutSave = useCallback(