保存图片画布生成器快照

将生成器对话框作为画布布局项序列化和恢复

生成成功后保留生成器快照并锚定到成品图层

图片类生成结果同步写入账号素材库

补充生成器持久化测试和浏览器回归相关文档
This commit is contained in:
2026-06-17 23:54:18 +08:00
parent 17768119ea
commit 946308b75e
20 changed files with 1044 additions and 80 deletions

View File

@@ -2326,3 +2326,11 @@
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts``api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。 - 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts``api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"``npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx``npm run test -- src/services/image-editor/editorProjectClient.test.ts``cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check` - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"``npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx``npm run test -- src/services/image-editor/editorProjectClient.test.ts``cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-17 图片画布生成器快照纳入画布布局
- 背景:生成占位图和生成器对话框里包含用户输入、参数、参考图、占位框位置和生成结果绑定,刷新后丢失会让已生成图片无法回到 Lovart 式跟随编辑状态。
- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类生成结果同步写入账号级素材库;视频结果当前只作为画布视频资源保存。
- 影响范围:图片画布 layout 序列化 / hydrate、生成工作流、生成器渲染、项目自动保存、素材库回填和编辑器技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`、浏览器刷新 smoke。
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`

View File

@@ -41,7 +41,8 @@
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource图层只引用 resourceId。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource图层只引用 resourceId。
- 图片文件本体继续走 OSS浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 图片文件本体继续走 OSS浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。
- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。
- 前端不直接订阅 SpacetimeDB统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 前端不直接订阅 SpacetimeDB统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
@@ -85,6 +86,7 @@
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt不再展示独立 Size 字段。 - 生成资源显示元数据按钮元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt不再展示独立 Size 字段。

View File

@@ -62,6 +62,14 @@
- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12` - `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。 - 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
## 画布保存
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
- 图片类生成结果还要写入账号级素材库;视频结果先只作为画布资源和视频图层保存。
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
## 第一版计费配置 ## 第一版计费配置
```text ```text
@@ -99,3 +107,4 @@
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。 - 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N` - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。

View File

@@ -80,6 +80,75 @@ describe('ImageCanvasEditorView generation integration', () => {
saveEditorProjectLayoutMock, saveEditorProjectLayoutMock,
}); });
it('restores generated image composer from saved generator snapshots', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-generated-dialog',
title: '已生成画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-restored',
resourceId: 'resource-generated-restored',
title: '生成图片 1',
x: 236,
y: 66,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 11,
sourceType: 'generated',
generationInputs: {
fields: [{ title: '生成提示词', value: '刷新后恢复生成器' }],
references: [],
},
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '刷新后恢复生成器',
status: 'idle',
composerOpen: true,
generatedLayerId: 'layer-generated-restored',
placeholder: {
x: 282,
y: 112,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
},
},
],
resources: [
{
resourceId: 'resource-generated-restored',
projectId: 'editor-project-generated-dialog',
imageSrc: 'data:image/png;base64,cmVzdG9yZWQ=',
width: 512,
height: 512,
sourceType: 'generated',
},
],
updatedAt: '2026-06-17T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
expect(await screen.findByAltText('画布图片:生成图片 1')).toBeTruthy();
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(generateDialog).toBeTruthy();
expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe(
'刷新后恢复生成器',
);
});
it('opens a canvas generation frame and composer before creating a generated layer', async () => { it('opens a canvas generation frame and composer before creating a generated layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({ generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
@@ -151,6 +220,26 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
}); });
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
folderId: 'project',
label: expect.stringMatching(//u),
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张明亮的拼图主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-task-1',
}),
);
});
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
const generatedLayer = screen const generatedLayer = screen
.getByAltText(/画布图片:生成图片/) .getByAltText(/画布图片:生成图片/)
.closest('button')!; .closest('button')!;

View File

@@ -7,9 +7,15 @@ import {
normalizeAssetLibrary, normalizeAssetLibrary,
normalizeCanvasBackgroundHex, normalizeCanvasBackgroundHex,
resolveSnappedLayerPosition, resolveSnappedLayerPosition,
serializeCanvasLayout,
serializeLayer, serializeLayer,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel'; } from './ImageCanvasEditorModel';
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; import type {
CanvasGenerationDialogState,
CanvasLayer,
EditorAsset,
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => { describe('ImageCanvasEditorModel', () => {
it('normalizes valid canvas background hex values and rejects invalid input', () => { it('normalizes valid canvas background hex values and rejects invalid input', () => {
@@ -122,6 +128,72 @@ describe('ImageCanvasEditorModel', () => {
}); });
}); });
it('serializes generation dialogs beside layers and splits them on load', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: 'data:image/png;base64,heavy',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
};
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
generationReferences: [
{
id: 'reference-1',
label: '参考图',
src: 'data:image/png;base64,ref',
},
],
};
const layout = serializeCanvasLayout({
layers: [layer],
canvasGenerationDialogs: [dialog],
});
const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout);
expect(layerItems).toHaveLength(1);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
},
});
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
label: '参考图',
});
});
it('snaps moving layers to nearby canvas and layer guides', () => { it('snaps moving layers to nearby canvas and layer guides', () => {
const movingLayer: CanvasLayer = { const movingLayer: CanvasLayer = {
id: 'moving', id: 'moving',

View File

@@ -4,6 +4,7 @@ import type {
} from '../../services/image-editor/editorProjectClient'; } from '../../services/image-editor/editorProjectClient';
import type { import type {
CanvasAssetKind, CanvasAssetKind,
CanvasGenerationDialogState,
CanvasGenerationInputs, CanvasGenerationInputs,
CanvasLayer, CanvasLayer,
CanvasContextMenuState, CanvasContextMenuState,
@@ -170,6 +171,145 @@ export function serializeLayer(
}; };
} }
type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
itemType: 'generation-dialog';
dialog: CanvasGenerationDialogState;
};
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
export function serializeCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogSnapshot {
return {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialog.id}`,
resourceId: `generation-dialog:${dialog.id}`,
dialog,
};
}
export function serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}: {
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
}): CanvasLayoutItems {
return [
...layers.map(serializeLayer),
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
];
}
export function isCanvasGenerationDialogLayoutItem(
item: EditorProjectLayerSnapshot,
): item is CanvasGenerationDialogSnapshot {
return (
item.itemType === 'generation-dialog' &&
Boolean(
hydrateCanvasGenerationDialog(
(item as { dialog?: unknown }).dialog,
),
)
);
}
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
} {
const layerItems: EditorProjectLayerSnapshot[] = [];
const generationDialogs: CanvasGenerationDialogState[] = [];
items.forEach((item) => {
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(item.dialog);
if (dialog) {
generationDialogs.push(dialog);
}
return;
}
layerItems.push(item);
});
return { layerItems, generationDialogs };
}
export function hydrateCanvasGenerationDialog(
value: unknown,
): CanvasGenerationDialogState | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as Partial<CanvasGenerationDialogState>;
const id = stringOrNull(snapshot.id);
const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : '';
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
return null;
}
return {
id,
mode: snapshot.mode,
prompt,
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
composerOpen:
typeof snapshot.composerOpen === 'boolean'
? snapshot.composerOpen
: true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
? snapshot.specType
: undefined,
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(snapshot.specReference),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
),
iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference),
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
? snapshot.iconDescriptions.filter(
(description): description is string =>
typeof description === 'string',
)
: undefined,
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
),
imageModel: stringOrUndefined(snapshot.imageModel),
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
: undefined,
videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio),
videoResolution:
snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p'
? snapshot.videoResolution
: undefined,
videoDurationSeconds:
snapshot.videoDurationSeconds === 4 ||
snapshot.videoDurationSeconds === 5
? snapshot.videoDurationSeconds
: undefined,
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
videoSound: snapshot.videoSound === 'off' ? 'off' : undefined,
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: stringOrUndefined(snapshot.errorMessage),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
};
}
export function hydrateLayer( export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot, snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>, resourcesById: Map<string, { imageSrc: string }>,
@@ -298,6 +438,10 @@ export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null; return typeof value === 'string' && value.trim() ? value : null;
} }
export function stringOrUndefined(value: unknown) {
return typeof value === 'string' && value.trim() ? value : undefined;
}
export function booleanFromSnapshot(value: unknown) { export function booleanFromSnapshot(value: unknown) {
return value === true; return value === true;
} }
@@ -429,6 +573,94 @@ export function isGeneratedLayer(layer: CanvasLayer) {
); );
} }
function isCanvasGenerationDialogMode(
value: unknown,
): value is CanvasGenerationDialogState['mode'] {
return (
value === 'generate' ||
value === 'spec' ||
value === 'character' ||
value === 'icon' ||
value === 'ui-design' ||
value === 'video'
);
}
function isGenerationStatus(
value: unknown,
): value is CanvasGenerationDialogState['status'] {
return value === 'idle' || value === 'generating' || value === 'failed';
}
function isSpecGenerationType(
value: unknown,
): value is NonNullable<CanvasGenerationDialogState['specType']> {
return (
value === 'character' ||
value === 'ui' ||
value === 'icon' ||
value === 'custom'
);
}
function hydrateSpecFormValues(
value: unknown,
): CanvasGenerationDialogState['specValues'] {
if (!value || typeof value !== 'object') {
return undefined;
}
const snapshot = value as Record<string, unknown>;
return {
playSetting:
typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '',
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
characterView:
typeof snapshot.characterView === 'string'
? snapshot.characterView
: '',
customPrompt:
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
};
}
function hydrateCharacterReference(value: unknown) {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as Record<string, unknown>;
const id = stringOrNull(snapshot.id);
const label = stringOrNull(snapshot.label);
const src = stringOrNull(snapshot.src);
return id && label && src ? { id, label, src } : null;
}
function hydrateCharacterReferences(value: unknown) {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(reference);
return hydrated ? [hydrated] : [];
})
: undefined;
}
function hydrateGenerationPlaceholder(
value: unknown,
): CanvasGenerationDialogState['placeholder'] {
if (!value || typeof value !== 'object') {
return undefined;
}
const snapshot = value as Record<string, unknown>;
return {
x: numberFromSnapshot(snapshot.x, 0),
y: numberFromSnapshot(snapshot.y, 0),
width: numberFromSnapshot(snapshot.width, 320),
height: numberFromSnapshot(snapshot.height, 320),
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
};
}
export function getLayerBounds(targetLayers: CanvasLayer[]) { export function getLayerBounds(targetLayers: CanvasLayer[]) {
if (targetLayers.length === 0) { if (targetLayers.length === 0) {
return null; return null;

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import type { import type {
AssetPointerDragState, AssetPointerDragState,
CanvasGenerationDialogState,
CanvasContextMenuState, CanvasContextMenuState,
CanvasLayer, CanvasLayer,
CanvasTool, CanvasTool,
@@ -43,6 +45,7 @@ export function ImageCanvasEditorView() {
const hasRequestedLoginRef = useRef(false); const hasRequestedLoginRef = useRef(false);
const layerCounterRef = useRef(0); const layerCounterRef = useRef(0);
const layersRef = useRef<CanvasLayer[]>([]); const layersRef = useRef<CanvasLayer[]>([]);
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT); const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const captureCanvasHistoryRef = useRef<() => void>(() => {}); const captureCanvasHistoryRef = useRef<() => void>(() => {});
const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
@@ -219,13 +222,14 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs, canvasGenerationDialogs,
openCanvasGenerationDialog, openCanvasGenerationDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
activateCanvasGenerationDialog, activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
} = useCanvasGenerationDialogs({ } = useCanvasGenerationDialogs({
onActivate: handleActivateCanvasGenerationDialog, onActivate: handleActivateCanvasGenerationDialog,
}); });
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
const canvasHistoryRefs = useMemo( const canvasHistoryRefs = useMemo(
() => ({ () => ({
layersRef, layersRef,
@@ -277,16 +281,98 @@ export function ImageCanvasEditorView() {
isCanvasGenerationComposerVisible(currentDialog) isCanvasGenerationComposerVisible(currentDialog)
? { ? {
...currentDialog, ...currentDialog,
composerOpen: false, composerOpen: currentDialog.generatedLayerId === layerId,
} }
: currentDialog, : currentDialog,
); );
} }
}, []); }, []);
const persistGeneratedAsset = useCallback(
(layer: CanvasLayer) => {
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
openEditorLoginModal();
return;
}
createEditorAsset({
folderId: activeUploadFolderId,
label: layer.title,
imageSrc: layer.src,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: 'generated',
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
})
.then((asset) => {
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
),
{
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
},
]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === asset.folderId
? {
...folder,
collapsed: false,
}
: folder,
),
);
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
sourceAssetId: asset.assetId,
objectKey: asset.objectKey ?? currentLayer.objectKey,
assetObjectId:
asset.assetObjectId ?? currentLayer.assetObjectId,
}
: currentLayer,
),
);
})
.catch((error: unknown) => {
if (
error instanceof Error &&
'status' in error &&
(error.status === 401 || error.status === 403)
) {
openEditorLoginModal();
}
});
},
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
);
const projectPersistenceRefs = useMemo( const projectPersistenceRefs = useMemo(
() => ({ () => ({
layersRef, layersRef,
viewportRef, viewportRef,
canvasGenerationDialogsRef,
}), }),
[], [],
); );
@@ -300,14 +386,16 @@ export function ImageCanvasEditorView() {
setLayerCounter: (value: number) => { setLayerCounter: (value: number) => {
layerCounterRef.current = value; layerCounterRef.current = value;
}, },
restoreCanvasGenerationDialogs,
}), }),
[selectSingleLayer], [restoreCanvasGenerationDialogs, selectSingleLayer],
); );
const { projectId, appendCanvasLayersWithResources } = const { projectId, appendCanvasLayersWithResources } =
useImageCanvasProjectPersistence({ useImageCanvasProjectPersistence({
refs: projectPersistenceRefs, refs: projectPersistenceRefs,
setters: projectPersistenceSetters, setters: projectPersistenceSetters,
layers, layers,
canvasGenerationDialogs,
viewport, viewport,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal, openEditorLoginModal,
@@ -364,7 +452,6 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs, canvasGenerationDialogs,
openCanvasGenerationDialog, openCanvasGenerationDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
@@ -375,6 +462,7 @@ export function ImageCanvasEditorView() {
setMetadataLayer, setMetadataLayer,
setImageContextMenu, setImageContextMenu,
requestUpload, requestUpload,
persistGeneratedAsset,
}); });
const { const {
quickEditPanel, quickEditPanel,

View File

@@ -260,7 +260,10 @@ function ImageCanvasVideoGenerationComposerView({
EDITOR_VIDEO_MODEL_OPTIONS[ EDITOR_VIDEO_MODEL_OPTIONS[
(Math.max(currentIndex, 0) + 1) % (Math.max(currentIndex, 0) + 1) %
EDITOR_VIDEO_MODEL_OPTIONS.length EDITOR_VIDEO_MODEL_OPTIONS.length
]; ] ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
if (!nextModel) {
return currentDialog;
}
return { return {
...currentDialog, ...currentDialog,
videoModel: nextModel.value, videoModel: nextModel.value,

View File

@@ -218,4 +218,28 @@ describe('ImageCanvasWorldView', () => {
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog); expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
}); });
it('keeps saved generator placeholders hidden after a result layer exists', () => {
renderWorldView({
layers: [createLayer({ id: 'layer-generated', title: '生成图片' })],
canvasGenerationDialogs: [
createGenerationDialog({
generatedLayerId: 'layer-generated',
placeholder: {
x: 80,
y: 90,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}),
],
});
expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '图像生成占位图' }),
).toBeNull();
});
}); });

View File

@@ -216,8 +216,12 @@ export function ImageCanvasWorldView({
}} }}
/> />
) : null} ) : null}
{canvasGenerationDialogs.map((dialog) => {canvasGenerationDialogs.map((dialog) => {
dialog.placeholder ? ( const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer ? (
<div <div
key={dialog.id} key={dialog.id}
className={`image-canvas-editor__generation-frame ${ className={`image-canvas-editor__generation-frame ${
@@ -276,8 +280,8 @@ export function ImageCanvasWorldView({
</span> </span>
) : null} ) : null}
</div> </div>
) : null, ) : null;
)} })}
{(generateDialog?.mode === 'generate' || {(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'spec' || generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' || generateDialog?.mode === 'character' ||

View File

@@ -128,6 +128,53 @@ function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) {
> >
remove layer dialogs remove layer dialogs
</button> </button>
<button
type="button"
onClick={() =>
dialogs.restoreCanvasGenerationDialogs([
{
id: 'generation-dialog-12',
mode: 'generate',
prompt: 'restored',
status: 'idle',
composerOpen: true,
placeholder: {
x: 12,
y: 24,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
])
}
>
restore saved
</button>
<button
type="button"
onClick={() =>
dialogs.restoreCanvasGenerationDialogs([
{
id: 'generation-dialog-2',
mode: 'generate',
prompt: 'inactive saved',
status: 'idle',
composerOpen: false,
},
{
id: 'generation-dialog-3',
mode: 'character',
prompt: 'active saved',
status: 'idle',
composerOpen: true,
},
])
}
>
restore active
</button>
</div> </div>
); );
} }
@@ -174,4 +221,40 @@ describe('useCanvasGenerationDialogs', () => {
expect(screen.getByTestId('active-prompt').textContent).toBe('-'); expect(screen.getByTestId('active-prompt').textContent).toBe('-');
expect(screen.getByTestId('inactive').textContent).toBe(''); expect(screen.getByTestId('inactive').textContent).toBe('');
}); });
it('restores saved dialogs and continues ids after the saved maximum', () => {
const onActivate = vi.fn();
render(<GenerationDialogsHarness onActivate={onActivate} />);
act(() => screen.getByRole('button', { name: 'restore saved' }).click());
expect(screen.getByTestId('active-id').textContent).toBe(
'generation-dialog-12',
);
expect(screen.getByTestId('active-prompt').textContent).toBe('restored');
act(() => screen.getByRole('button', { name: 'open second' }).click());
expect(screen.getByTestId('active-id').textContent).toBe(
'generation-dialog-13',
);
expect(screen.getByTestId('inactive').textContent).toContain(
'generation-dialog-12:restored:false',
);
});
it('keeps the saved open dialog active when restoring multiple dialogs', () => {
const onActivate = vi.fn();
render(<GenerationDialogsHarness onActivate={onActivate} />);
act(() => screen.getByRole('button', { name: 'restore active' }).click());
expect(screen.getByTestId('active-id').textContent).toBe(
'generation-dialog-3',
);
expect(screen.getByTestId('active-prompt').textContent).toBe(
'active saved',
);
expect(screen.getByTestId('inactive').textContent).toContain(
'generation-dialog-2:inactive saved:false',
);
});
}); });

View File

@@ -127,6 +127,41 @@ export function useCanvasGenerationDialogs({
[onActivate], [onActivate],
); );
const restoreCanvasGenerationDialogs = useCallback(
(dialogs: CanvasGenerationDialogState[]) => {
const nextCounter = dialogs.reduce((maxCounter, dialog) => {
const match = /^generation-dialog-(\d+)$/u.exec(dialog.id);
const numericId = match ? Number.parseInt(match[1] ?? '0', 10) : 0;
return Math.max(maxCounter, Number.isFinite(numericId) ? numericId : 0);
}, 0);
generationDialogCounterRef.current = Math.max(
generationDialogCounterRef.current,
nextCounter,
);
const activeDialog =
[...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ??
dialogs[dialogs.length - 1] ??
null;
setGenerateDialog(
activeDialog
? {
...activeDialog,
composerOpen: activeDialog.composerOpen !== false,
}
: null,
);
setInactiveGenerateDialogs(
dialogs
.filter((dialog) => dialog.id !== activeDialog?.id)
.map((dialog) => ({
...dialog,
composerOpen: false,
})),
);
},
[],
);
const removeCanvasGenerationDialogsByLayerId = useCallback( const removeCanvasGenerationDialogsByLayerId = useCallback(
(targetLayerId: string) => { (targetLayerId: string) => {
const keepDialog = (dialog: CanvasGenerationDialogState) => const keepDialog = (dialog: CanvasGenerationDialogState) =>
@@ -182,6 +217,7 @@ export function useCanvasGenerationDialogs({
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById, removeCanvasGenerationDialogById,
activateCanvasGenerationDialog, activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
}; };

View File

@@ -133,7 +133,6 @@ function SubmissionWorkflowHarness({
setCharacterAnimationPanel, setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog, setGenerateDialog: dialogs.setGenerateDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) => appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]), setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
@@ -412,7 +411,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
); );
}); });
expect(screen.getByTestId('dialog').textContent).toBe( expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:-:-', 'generate:idle:open:layer-generated-1:placeholder:-',
); );
}); });
@@ -468,6 +467,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
src: 'data:image/png;base64,spec', src: 'data:image/png;base64,spec',
}, },
iconDescriptions: [' 返回按钮 ', '', '设置按钮'], iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
placeholder: {
x: 140,
y: 160,
width: 360,
height: 360,
originalWidth: 512,
originalHeight: 512,
},
}} }}
/>, />,
); );
@@ -493,12 +500,57 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'layer-icon-2:设置按钮:-:icon', 'layer-icon-2:设置按钮:-:icon',
); );
expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1'); expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1');
expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe(
'icon:idle:open:layer-icon-1:placeholder:-',
);
expect(screen.getByTestId('remembered-model').textContent).toBe( expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2', 'gpt-image-2',
); );
}); });
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '骑士角色',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
characterSpecReference: {
id: 'spec-character',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
placeholder: {
x: 120,
y: 140,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:角色形象 1:-:character',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:layer-generated-1:placeholder:-',
);
});
it('moves character animation panels from generating to completed', async () => { it('moves character animation panels from generating to completed', async () => {
generateEditorCharacterAnimationMock.mockResolvedValueOnce({ generateEditorCharacterAnimationMock.mockResolvedValueOnce({
frames: [{ imageSrc: 'data:image/png;base64,frame' }], frames: [{ imageSrc: 'data:image/png;base64,frame' }],

View File

@@ -66,7 +66,6 @@ type GenerationSubmissionWorkflowOptions = {
dialogId: string, dialogId: string,
updater: CanvasGenerationDialogUpdater, updater: CanvasGenerationDialogUpdater,
) => void; ) => void;
removeCanvasGenerationDialogById: (dialogId: string) => void;
getGeneratingDialogPlaceholder: ( getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState, dialog: GenerateDialogState,
) => GenerateDialogState['placeholder']; ) => GenerateDialogState['placeholder'];
@@ -76,6 +75,7 @@ type GenerationSubmissionWorkflowOptions = {
setActiveTool: Dispatch<SetStateAction<CanvasTool>>; setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>; setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void; rememberImageModel: (imageModel: string) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
}; };
export function useImageCanvasGenerationSubmissionWorkflow({ export function useImageCanvasGenerationSubmissionWorkflow({
@@ -91,7 +91,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setCharacterAnimationPanel, setCharacterAnimationPanel,
setGenerateDialog, setGenerateDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
selectSingleLayer, selectSingleLayer,
@@ -99,7 +98,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveTool, setActiveTool,
setActiveSidebarPanel, setActiveSidebarPanel,
rememberImageModel, rememberImageModel,
persistGeneratedAsset,
}: GenerationSubmissionWorkflowOptions) { }: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
appendCanvasLayersWithResources(nextLayers);
nextLayers
.filter((layer) => layer.mediaType !== 'video')
.forEach((layer) => persistGeneratedAsset?.(layer));
},
[appendCanvasLayersWithResources, persistGeneratedAsset],
);
const addGeneratedResultLayer = useCallback( const addGeneratedResultLayer = useCallback(
( (
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'], generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
@@ -126,32 +136,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: options.generationInputs, generationInputs: options.generationInputs,
}); });
appendCanvasLayersWithResources([nextLayer]); addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
if (options.sourceLayer) { if (options.sourceLayer) {
setGenerateDialog(null); setGenerateDialog(null);
setActiveTool('select'); setActiveTool('select');
} else if (options.dialogId) { } else if (options.dialogId) {
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
currentDialog.mode === 'character' || currentDialog.mode === 'icon' ...currentDialog,
? null status: 'idle',
: { composerOpen: true,
...currentDialog, generatedLayerId: nextLayer.id,
status: 'idle', errorMessage: undefined,
composerOpen: true, }));
generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined,
},
);
} }
if (options.sourceLayer) { if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]); fitLayers([options.sourceLayer, nextLayer]);
} }
}, },
[ [
appendCanvasLayersWithResources, addGeneratedLayersToCanvas,
canvasSize, canvasSize,
fitLayers, fitLayers,
layerCounterRef, layerCounterRef,
@@ -179,7 +184,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs, generationInputs,
}); });
appendCanvasLayersWithResources([nextLayer]); addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
setQuickEditPanel(null); setQuickEditPanel(null);
@@ -187,7 +192,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
fitLayers([sourceLayer, nextLayer]); fitLayers([sourceLayer, nextLayer]);
}, },
[ [
appendCanvasLayersWithResources, addGeneratedLayersToCanvas,
fitLayers, fitLayers,
layerCounterRef, layerCounterRef,
selectSingleLayer, selectSingleLayer,
@@ -224,22 +229,28 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return; return;
} }
layerCounterRef.current += nextLayers.length; layerCounterRef.current += nextLayers.length;
appendCanvasLayersWithResources(nextLayers); addGeneratedLayersToCanvas(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null); selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
if (dialogId) { if (dialogId) {
removeCanvasGenerationDialogById(dialogId); updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayers[0]?.id,
errorMessage: undefined,
}));
} }
setActiveTool('select'); setActiveTool('select');
}, },
[ [
appendCanvasLayersWithResources, addGeneratedLayersToCanvas,
canvasSize, canvasSize,
layerCounterRef, layerCounterRef,
removeCanvasGenerationDialogById,
selectSingleLayer, selectSingleLayer,
setActiveSidebarPanel, setActiveSidebarPanel,
setActiveTool, setActiveTool,
updateCanvasGenerationDialogById,
viewport, viewport,
], ],
); );
@@ -264,7 +275,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame, frame,
}); });
appendCanvasLayersWithResources([nextLayer]); addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
if (dialogId) { if (dialogId) {
@@ -273,14 +284,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
status: 'idle', status: 'idle',
composerOpen: true, composerOpen: true,
generatedLayerId: nextLayer.id, generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined, errorMessage: undefined,
})); }));
} }
setActiveTool('select'); setActiveTool('select');
}, },
[ [
appendCanvasLayersWithResources, addGeneratedLayersToCanvas,
canvasSize, canvasSize,
layerCounterRef, layerCounterRef,
selectSingleLayer, selectSingleLayer,

View File

@@ -88,7 +88,6 @@ function GenerationSurfaceHarness() {
canvasGenerationDialogs: dialogs.canvasGenerationDialogs, canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId: removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId, dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,

View File

@@ -52,7 +52,6 @@ type ImageCanvasGenerationSurfaceOptions = {
dialogId: string, dialogId: string,
updater: CanvasGenerationDialogUpdater, updater: CanvasGenerationDialogUpdater,
) => void; ) => void;
removeCanvasGenerationDialogById: (dialogId: string) => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void; removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: ( getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState, dialog: GenerateDialogState,
@@ -65,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = {
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>; setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>; setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
requestUpload: (target: UploadTarget) => void; requestUpload: (target: UploadTarget) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
}; };
export function useImageCanvasGenerationSurface({ export function useImageCanvasGenerationSurface({
@@ -84,7 +84,6 @@ export function useImageCanvasGenerationSurface({
canvasGenerationDialogs, canvasGenerationDialogs,
openCanvasGenerationDialog, openCanvasGenerationDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
@@ -95,6 +94,7 @@ export function useImageCanvasGenerationSurface({
setMetadataLayer, setMetadataLayer,
setImageContextMenu, setImageContextMenu,
requestUpload, requestUpload,
persistGeneratedAsset,
}: ImageCanvasGenerationSurfaceOptions) { }: ImageCanvasGenerationSurfaceOptions) {
const generationWorkflow = useImageCanvasGenerationWorkflow({ const generationWorkflow = useImageCanvasGenerationWorkflow({
layers, layers,
@@ -107,7 +107,6 @@ export function useImageCanvasGenerationSurface({
setGenerateDialog, setGenerateDialog,
openCanvasGenerationDialog, openCanvasGenerationDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
@@ -117,6 +116,7 @@ export function useImageCanvasGenerationSurface({
setActiveSidebarPanel, setActiveSidebarPanel,
setMetadataLayer, setMetadataLayer,
setImageContextMenu, setImageContextMenu,
persistGeneratedAsset,
}); });
const activeGenerationLayer = const activeGenerationLayer =

View File

@@ -110,7 +110,6 @@ function GenerationWorkflowHarness({
setGenerateDialog: dialogs.setGenerateDialog, setGenerateDialog: dialogs.setGenerateDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId: removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId, dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -383,7 +382,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
'layer-generated-1', 'layer-generated-1',
); );
expect(screen.getByTestId('dialog').textContent).toBe( expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:-', 'generate:idle:open:layer-generated-1:placeholder',
); );
}); });

View File

@@ -77,7 +77,6 @@ type GenerationWorkflowOptions = {
dialogId: string, dialogId: string,
updater: CanvasGenerationDialogUpdater, updater: CanvasGenerationDialogUpdater,
) => void; ) => void;
removeCanvasGenerationDialogById: (dialogId: string) => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void; removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: ( getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState, dialog: GenerateDialogState,
@@ -89,6 +88,7 @@ type GenerationWorkflowOptions = {
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>; setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>; setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>; setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
}; };
export function useImageCanvasGenerationWorkflow({ export function useImageCanvasGenerationWorkflow({
@@ -102,7 +102,6 @@ export function useImageCanvasGenerationWorkflow({
setGenerateDialog, setGenerateDialog,
openCanvasGenerationDialog, openCanvasGenerationDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId, removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
@@ -112,6 +111,7 @@ export function useImageCanvasGenerationWorkflow({
setActiveSidebarPanel, setActiveSidebarPanel,
setMetadataLayer, setMetadataLayer,
setImageContextMenu, setImageContextMenu,
persistGeneratedAsset,
}: GenerationWorkflowOptions) { }: GenerationWorkflowOptions) {
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
@@ -476,7 +476,6 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel, setCharacterAnimationPanel,
setGenerateDialog, setGenerateDialog,
updateCanvasGenerationDialogById, updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
getGeneratingDialogPlaceholder, getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources, appendCanvasLayersWithResources,
selectSingleLayer, selectSingleLayer,
@@ -484,6 +483,7 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool, setActiveTool,
setActiveSidebarPanel, setActiveSidebarPanel,
rememberImageModel: setLastImageModel, rememberImageModel: setLastImageModel,
persistGeneratedAsset,
}); });
const { const {
submitCharacterAnimation, submitCharacterAnimation,

View File

@@ -1,10 +1,14 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react'; import { act, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
@@ -45,10 +49,15 @@ function createLayer(id: string): CanvasLayer {
function ProjectPersistenceHarness({ function ProjectPersistenceHarness({
canAccessProtectedData = true, canAccessProtectedData = true,
initialGenerationDialogs = [],
}: { }: {
canAccessProtectedData?: boolean; canAccessProtectedData?: boolean;
initialGenerationDialogs?: CanvasGenerationDialogState[];
}) { }) {
const [layers, setLayers] = useState<CanvasLayer[]>([]); const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [generationDialogs, setGenerationDialogs] = useState<
CanvasGenerationDialogState[]
>(initialGenerationDialogs);
const [viewport, setViewport] = useState<CanvasViewport>({ const [viewport, setViewport] = useState<CanvasViewport>({
x: 0, x: 0,
y: 0, y: 0,
@@ -58,33 +67,49 @@ function ProjectPersistenceHarness({
const [projectRenameValue, setProjectRenameValue] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState('');
const layersRef = useRef(layers); const layersRef = useRef(layers);
const viewportRef = useRef(viewport); const viewportRef = useRef(viewport);
const canvasGenerationDialogsRef = useRef(generationDialogs);
const selectedLayerRef = useRef<string | null>(null); const selectedLayerRef = useRef<string | null>(null);
const layerCounterRef = useRef(0); const layerCounterRef = useRef(0);
const openEditorLoginModalRef = useRef(vi.fn());
layersRef.current = layers; layersRef.current = layers;
viewportRef.current = viewport; viewportRef.current = viewport;
canvasGenerationDialogsRef.current = generationDialogs;
const persistence = useImageCanvasProjectPersistence({ const selectSingleLayer = useCallback((layerId: string | null) => {
refs: { selectedLayerRef.current = layerId;
}, []);
const setLayerCounter = useCallback((value: number) => {
layerCounterRef.current = value;
}, []);
const persistenceRefs = useMemo(
() => ({
layersRef, layersRef,
viewportRef, viewportRef,
}, canvasGenerationDialogsRef,
setters: { }),
[],
);
const persistenceSetters = useMemo(
() => ({
setProjectTitle, setProjectTitle,
setProjectRenameValue, setProjectRenameValue,
setViewport, setViewport,
setLayers, setLayers,
selectSingleLayer: (layerId) => { selectSingleLayer,
selectedLayerRef.current = layerId; setLayerCounter,
}, restoreCanvasGenerationDialogs: setGenerationDialogs,
setLayerCounter: (value) => { }),
layerCounterRef.current = value; [selectSingleLayer, setLayerCounter],
}, );
},
const persistence = useImageCanvasProjectPersistence({
refs: persistenceRefs,
setters: persistenceSetters,
layers, layers,
canvasGenerationDialogs: generationDialogs,
viewport, viewport,
canAccessProtectedData, canAccessProtectedData,
openEditorLoginModal: vi.fn(), openEditorLoginModal: openEditorLoginModalRef.current,
}); });
return ( return (
@@ -97,6 +122,11 @@ function ProjectPersistenceHarness({
</span> </span>
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span> <span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
<span data-testid="counter">{layerCounterRef.current}</span> <span data-testid="counter">{layerCounterRef.current}</span>
<span data-testid="generation-dialogs">
{generationDialogs
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`)
.join(',')}
</span>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -105,6 +135,42 @@ function ProjectPersistenceHarness({
> >
append append
</button> </button>
<button
type="button"
onClick={() => {
const generatedLayer = createLayer('layer-generated');
persistence.appendCanvasLayersWithResources([
{
...generatedLayer,
title: '生成图片',
sourceType: 'generated',
sourceAssetId: undefined,
},
]);
}}
>
append generated
</button>
<button
type="button"
onClick={() => {
setGenerationDialogs((currentDialogs) =>
currentDialogs.map((dialog) =>
dialog.id === 'generation-dialog-1'
? {
...dialog,
prompt: '移动后的生成器',
placeholder: dialog.placeholder
? { ...dialog.placeholder, x: 88 }
: dialog.placeholder,
}
: dialog,
),
);
}}
>
move generation
</button>
</div> </div>
); );
} }
@@ -175,6 +241,146 @@ describe('useImageCanvasProjectPersistence', () => {
}); });
}); });
it('saves generation dialogs in the project layout and restores them on load', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '刷新后继续生成',
status: 'generating',
composerOpen: false,
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: savedDialog,
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:刷新后继续生成:42',
);
});
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move generation' }).click();
});
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:移动后的生成器:88',
);
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
id: 'generation-dialog-1',
prompt: '移动后的生成器',
placeholder: expect.objectContaining({ x: 88 }),
}),
}),
]),
}),
);
vi.useRealTimers();
});
it('saves generated layers together with their generator snapshots', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '已生成也要保存生成器',
status: 'idle',
composerOpen: true,
generatedLayerId: 'layer-generated',
placeholder: {
x: 70,
y: 80,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: savedDialog,
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:已生成也要保存生成器:70',
);
act(() => {
screen.getByRole('button', { name: 'append generated' }).click();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-generated',
sourceType: 'generated',
}),
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
id: 'generation-dialog-1',
generatedLayerId: 'layer-generated',
prompt: '已生成也要保存生成器',
placeholder: expect.objectContaining({
x: 70,
width: 420,
}),
}),
}),
]),
}),
);
});
});
it('does not load protected project data before login is available', () => { it('does not load protected project data before login is available', () => {
render(<ProjectPersistenceHarness canAccessProtectedData={false} />); render(<ProjectPersistenceHarness canAccessProtectedData={false} />);

View File

@@ -7,8 +7,16 @@ import {
loadOrCreateRecentEditorProject, loadOrCreateRecentEditorProject,
saveEditorProjectLayout, saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient'; } from '../../services/image-editor/editorProjectClient';
import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel'; import {
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; hydrateLayer,
serializeCanvasLayout,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
type ProjectResourceOptions = { type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void; onCreated?: (resourceId: string) => void;
@@ -23,6 +31,7 @@ type PendingProjectResourceLayer = {
type ImageCanvasProjectPersistenceRefs = { type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>; layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>; viewportRef: RefObject<CanvasViewport>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
}; };
type ImageCanvasProjectPersistenceSetters = { type ImageCanvasProjectPersistenceSetters = {
@@ -32,6 +41,9 @@ type ImageCanvasProjectPersistenceSetters = {
setLayers: (layers: CanvasLayer[]) => void; setLayers: (layers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void; selectSingleLayer: (layerId: string | null) => void;
setLayerCounter: (value: number) => void; setLayerCounter: (value: number) => void;
restoreCanvasGenerationDialogs: (
dialogs: CanvasGenerationDialogState[],
) => void;
}; };
function isEditorAuthError(error: unknown) { function isEditorAuthError(error: unknown) {
@@ -45,6 +57,7 @@ export function useImageCanvasProjectPersistence({
refs, refs,
setters, setters,
layers, layers,
canvasGenerationDialogs,
viewport, viewport,
canAccessProtectedData, canAccessProtectedData,
openEditorLoginModal, openEditorLoginModal,
@@ -52,6 +65,7 @@ export function useImageCanvasProjectPersistence({
refs: ImageCanvasProjectPersistenceRefs; refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters; setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[]; layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport; viewport: CanvasViewport;
canAccessProtectedData: boolean; canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
@@ -63,6 +77,15 @@ export function useImageCanvasProjectPersistence({
const saveTimerRef = useRef<number | null>(null); const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(null); const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false); const [isProjectReady, setIsProjectReady] = useState(false);
const {
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
} = setters;
const createProjectResourceForLayer = useCallback( const createProjectResourceForLayer = useCallback(
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => { (layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
@@ -110,11 +133,15 @@ export function useImageCanvasProjectPersistence({
) )
: currentLayers; : currentLayers;
refs.layersRef.current = nextLayers; refs.layersRef.current = nextLayers;
setters.setLayers(nextLayers); setLayers(nextLayers);
if (nextLayers.length) { if (nextLayers.length) {
void saveEditorProjectLayout(readyProjectId, { void saveEditorProjectLayout(readyProjectId, {
viewport: refs.viewportRef.current, viewport: refs.viewportRef.current,
layers: nextLayers.map(serializeLayer), layers: serializeCanvasLayout({
layers: nextLayers,
canvasGenerationDialogs:
refs.canvasGenerationDialogsRef.current,
}),
}).catch((error: unknown) => { }).catch((error: unknown) => {
if (isEditorAuthError(error)) { if (isEditorAuthError(error)) {
openEditorLoginModal(); openEditorLoginModal();
@@ -128,7 +155,7 @@ export function useImageCanvasProjectPersistence({
} }
}); });
}, },
[openEditorLoginModal, refs, setters], [openEditorLoginModal, refs, setLayers],
); );
const appendCanvasLayersWithResources = useCallback( const appendCanvasLayersWithResources = useCallback(
@@ -138,12 +165,12 @@ export function useImageCanvasProjectPersistence({
} }
const snapshotLayers = [...refs.layersRef.current, ...nextLayers]; const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
refs.layersRef.current = snapshotLayers; refs.layersRef.current = snapshotLayers;
setters.setLayers(snapshotLayers); setLayers(snapshotLayers);
nextLayers.forEach((layer) => nextLayers.forEach((layer) =>
createProjectResourceForLayer(layer, { snapshotLayers }), createProjectResourceForLayer(layer, { snapshotLayers }),
); );
}, },
[createProjectResourceForLayer, refs, setters], [createProjectResourceForLayer, refs, setLayers],
); );
useEffect(() => { useEffect(() => {
@@ -170,26 +197,30 @@ export function useImageCanvasProjectPersistence({
projectIdRef.current = project.projectId; projectIdRef.current = project.projectId;
setProjectId(project.projectId); setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布'; const nextProjectTitle = project.title?.trim() || '未命名画布';
setters.setProjectTitle(nextProjectTitle); setProjectTitle(nextProjectTitle);
setters.setProjectRenameValue(nextProjectTitle); setProjectRenameValue(nextProjectTitle);
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
pendingLayers.forEach(({ layer, options }) => { pendingLayers.forEach(({ layer, options }) => {
createProjectResourceForLayer(layer, options); createProjectResourceForLayer(layer, options);
}); });
setters.setViewport(project.viewport); setViewport(project.viewport);
const resourcesById = new Map( const resourcesById = new Map(
project.resources.map((resource) => [ project.resources.map((resource) => [
resource.resourceId, resource.resourceId,
{ imageSrc: resource.imageSrc }, { imageSrc: resource.imageSrc },
]), ]),
); );
const hydratedLayers = project.layers const { layerItems, generationDialogs } = splitCanvasLayoutItems(
project.layers,
);
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById)) .map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer)); .filter((layer): layer is CanvasLayer => Boolean(layer));
restoreCanvasGenerationDialogs(generationDialogs);
if (hydratedLayers.length > 0) { if (hydratedLayers.length > 0) {
setters.setLayerCounter(hydratedLayers.length); setLayerCounter(hydratedLayers.length);
setters.setLayers(hydratedLayers); setLayers(hydratedLayers);
setters.selectSingleLayer(hydratedLayers[0]?.id ?? null); selectSingleLayer(hydratedLayers[0]?.id ?? null);
} }
setIsProjectReady(true); setIsProjectReady(true);
}) })
@@ -212,7 +243,13 @@ export function useImageCanvasProjectPersistence({
canAccessProtectedData, canAccessProtectedData,
createProjectResourceForLayer, createProjectResourceForLayer,
openEditorLoginModal, openEditorLoginModal,
setters, restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayerCounter,
setLayers,
setProjectRenameValue,
setProjectTitle,
setViewport,
]); ]);
useEffect(() => { useEffect(() => {
@@ -226,7 +263,10 @@ export function useImageCanvasProjectPersistence({
saveTimerRef.current = window.setTimeout(() => { saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, { saveEditorProjectLayout(projectId, {
viewport, viewport,
layers: layers.map(serializeLayer), layers: serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}),
}).catch((error: unknown) => { }).catch((error: unknown) => {
if (isEditorAuthError(error)) { if (isEditorAuthError(error)) {
openEditorLoginModal(); openEditorLoginModal();
@@ -239,7 +279,15 @@ export function useImageCanvasProjectPersistence({
window.clearTimeout(saveTimerRef.current); window.clearTimeout(saveTimerRef.current);
} }
}; };
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); }, [
isProjectReady,
canvasGenerationDialogs,
layers,
openEditorLoginModal,
projectId,
refs,
viewport,
]);
return { return {
projectId, projectId,