保存图片画布生成器快照
将生成器对话框作为画布布局项序列化和恢复 生成成功后保留生成器快照并锚定到成品图层 图片类生成结果同步写入账号素材库 补充生成器持久化测试和浏览器回归相关文档
This commit is contained in:
@@ -2326,3 +2326,11 @@
|
||||
- 影响范围:图片画布生成工作流、前端 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`。
|
||||
- 关联文档:`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`。
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
|
||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||
- 当前 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 读写。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
@@ -85,6 +86,7 @@
|
||||
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
|
||||
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
|
||||
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
||||
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
|
||||
|
||||
@@ -62,6 +62,14 @@
|
||||
- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。
|
||||
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
|
||||
|
||||
## 画布保存
|
||||
|
||||
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
|
||||
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。
|
||||
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
|
||||
- 图片类生成结果还要写入账号级素材库;视频结果先只作为画布资源和视频图层保存。
|
||||
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
|
||||
|
||||
## 第一版计费配置
|
||||
|
||||
```text
|
||||
@@ -99,3 +107,4 @@
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
|
||||
|
||||
@@ -80,6 +80,75 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
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 () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
@@ -151,6 +220,26 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
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
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button')!;
|
||||
|
||||
@@ -7,9 +7,15 @@ import {
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
resolveSnappedLayerPosition,
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
splitCanvasLayoutItems,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
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', () => {
|
||||
const movingLayer: CanvasLayer = {
|
||||
id: 'moving',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
CanvasLayer,
|
||||
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(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resourcesById: Map<string, { imageSrc: string }>,
|
||||
@@ -298,6 +438,10 @@ export function stringOrNull(value: unknown) {
|
||||
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) {
|
||||
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[]) {
|
||||
if (targetLayers.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasContextMenuState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
@@ -43,6 +45,7 @@ export function ImageCanvasEditorView() {
|
||||
const hasRequestedLoginRef = useRef(false);
|
||||
const layerCounterRef = useRef(0);
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
@@ -219,13 +222,14 @@ export function ImageCanvasEditorView() {
|
||||
canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
activateCanvasGenerationDialog,
|
||||
restoreCanvasGenerationDialogs,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
} = useCanvasGenerationDialogs({
|
||||
onActivate: handleActivateCanvasGenerationDialog,
|
||||
});
|
||||
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
|
||||
const canvasHistoryRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
@@ -277,16 +281,98 @@ export function ImageCanvasEditorView() {
|
||||
isCanvasGenerationComposerVisible(currentDialog)
|
||||
? {
|
||||
...currentDialog,
|
||||
composerOpen: false,
|
||||
composerOpen: currentDialog.generatedLayerId === layerId,
|
||||
}
|
||||
: 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(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -300,14 +386,16 @@ export function ImageCanvasEditorView() {
|
||||
setLayerCounter: (value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
restoreCanvasGenerationDialogs,
|
||||
}),
|
||||
[selectSingleLayer],
|
||||
[restoreCanvasGenerationDialogs, selectSingleLayer],
|
||||
);
|
||||
const { projectId, appendCanvasLayersWithResources } =
|
||||
useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
openEditorLoginModal,
|
||||
@@ -364,7 +452,6 @@ export function ImageCanvasEditorView() {
|
||||
canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
@@ -375,6 +462,7 @@ export function ImageCanvasEditorView() {
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
persistGeneratedAsset,
|
||||
});
|
||||
const {
|
||||
quickEditPanel,
|
||||
|
||||
@@ -260,7 +260,10 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
EDITOR_VIDEO_MODEL_OPTIONS[
|
||||
(Math.max(currentIndex, 0) + 1) %
|
||||
EDITOR_VIDEO_MODEL_OPTIONS.length
|
||||
];
|
||||
] ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
|
||||
if (!nextModel) {
|
||||
return currentDialog;
|
||||
}
|
||||
return {
|
||||
...currentDialog,
|
||||
videoModel: nextModel.value,
|
||||
|
||||
@@ -218,4 +218,28 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,8 +216,12 @@ export function ImageCanvasWorldView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{canvasGenerationDialogs.map((dialog) =>
|
||||
dialog.placeholder ? (
|
||||
{canvasGenerationDialogs.map((dialog) => {
|
||||
const hasGeneratedLayer = Boolean(
|
||||
dialog.generatedLayerId &&
|
||||
layers.some((layer) => layer.id === dialog.generatedLayerId),
|
||||
);
|
||||
return dialog.placeholder && !hasGeneratedLayer ? (
|
||||
<div
|
||||
key={dialog.id}
|
||||
className={`image-canvas-editor__generation-frame ${
|
||||
@@ -276,8 +280,8 @@ export function ImageCanvasWorldView({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
) : null;
|
||||
})}
|
||||
{(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'spec' ||
|
||||
generateDialog?.mode === 'character' ||
|
||||
|
||||
@@ -128,6 +128,53 @@ function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) {
|
||||
>
|
||||
remove layer dialogs
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -174,4 +221,40 @@ describe('useCanvasGenerationDialogs', () => {
|
||||
expect(screen.getByTestId('active-prompt').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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,41 @@ export function useCanvasGenerationDialogs({
|
||||
[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(
|
||||
(targetLayerId: string) => {
|
||||
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
||||
@@ -182,6 +217,7 @@ export function useCanvasGenerationDialogs({
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
activateCanvasGenerationDialog,
|
||||
restoreCanvasGenerationDialogs,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
};
|
||||
|
||||
@@ -133,7 +133,6 @@ function SubmissionWorkflowHarness({
|
||||
setCharacterAnimationPanel,
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources: (nextLayers) =>
|
||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||||
@@ -412,7 +411,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
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',
|
||||
},
|
||||
iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
|
||||
placeholder: {
|
||||
x: 140,
|
||||
y: 160,
|
||||
width: 360,
|
||||
height: 360,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -493,12 +500,57 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
'layer-icon-2:设置按钮:-:icon',
|
||||
);
|
||||
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(
|
||||
'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 () => {
|
||||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||||
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
|
||||
|
||||
@@ -66,7 +66,6 @@ type GenerationSubmissionWorkflowOptions = {
|
||||
dialogId: string,
|
||||
updater: CanvasGenerationDialogUpdater,
|
||||
) => void;
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
getGeneratingDialogPlaceholder: (
|
||||
dialog: GenerateDialogState,
|
||||
) => GenerateDialogState['placeholder'];
|
||||
@@ -76,6 +75,7 @@ type GenerationSubmissionWorkflowOptions = {
|
||||
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||
rememberImageModel: (imageModel: string) => void;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
@@ -91,7 +91,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
setCharacterAnimationPanel,
|
||||
setGenerateDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
@@ -99,7 +98,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
rememberImageModel,
|
||||
persistGeneratedAsset,
|
||||
}: GenerationSubmissionWorkflowOptions) {
|
||||
const addGeneratedLayersToCanvas = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
appendCanvasLayersWithResources(nextLayers);
|
||||
nextLayers
|
||||
.filter((layer) => layer.mediaType !== 'video')
|
||||
.forEach((layer) => persistGeneratedAsset?.(layer));
|
||||
},
|
||||
[appendCanvasLayersWithResources, persistGeneratedAsset],
|
||||
);
|
||||
|
||||
const addGeneratedResultLayer = useCallback(
|
||||
(
|
||||
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
|
||||
@@ -126,32 +136,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: options.generationInputs,
|
||||
});
|
||||
|
||||
appendCanvasLayersWithResources([nextLayer]);
|
||||
addGeneratedLayersToCanvas([nextLayer]);
|
||||
selectSingleLayer(nextLayer.id);
|
||||
setActiveSidebarPanel('layers');
|
||||
if (options.sourceLayer) {
|
||||
setGenerateDialog(null);
|
||||
setActiveTool('select');
|
||||
} else if (options.dialogId) {
|
||||
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
|
||||
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
|
||||
? null
|
||||
: {
|
||||
...currentDialog,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: nextLayer.id,
|
||||
placeholder: undefined,
|
||||
errorMessage: undefined,
|
||||
},
|
||||
);
|
||||
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
|
||||
...currentDialog,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: nextLayer.id,
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
}
|
||||
if (options.sourceLayer) {
|
||||
fitLayers([options.sourceLayer, nextLayer]);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendCanvasLayersWithResources,
|
||||
addGeneratedLayersToCanvas,
|
||||
canvasSize,
|
||||
fitLayers,
|
||||
layerCounterRef,
|
||||
@@ -179,7 +184,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs,
|
||||
});
|
||||
|
||||
appendCanvasLayersWithResources([nextLayer]);
|
||||
addGeneratedLayersToCanvas([nextLayer]);
|
||||
selectSingleLayer(nextLayer.id);
|
||||
setActiveSidebarPanel('layers');
|
||||
setQuickEditPanel(null);
|
||||
@@ -187,7 +192,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
fitLayers([sourceLayer, nextLayer]);
|
||||
},
|
||||
[
|
||||
appendCanvasLayersWithResources,
|
||||
addGeneratedLayersToCanvas,
|
||||
fitLayers,
|
||||
layerCounterRef,
|
||||
selectSingleLayer,
|
||||
@@ -224,22 +229,28 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
return;
|
||||
}
|
||||
layerCounterRef.current += nextLayers.length;
|
||||
appendCanvasLayersWithResources(nextLayers);
|
||||
addGeneratedLayersToCanvas(nextLayers);
|
||||
selectSingleLayer(nextLayers[0]?.id ?? null);
|
||||
setActiveSidebarPanel('layers');
|
||||
if (dialogId) {
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
||||
...currentDialog,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: nextLayers[0]?.id,
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
}
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
appendCanvasLayersWithResources,
|
||||
addGeneratedLayersToCanvas,
|
||||
canvasSize,
|
||||
layerCounterRef,
|
||||
removeCanvasGenerationDialogById,
|
||||
selectSingleLayer,
|
||||
setActiveSidebarPanel,
|
||||
setActiveTool,
|
||||
updateCanvasGenerationDialogById,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
@@ -264,7 +275,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
frame,
|
||||
});
|
||||
|
||||
appendCanvasLayersWithResources([nextLayer]);
|
||||
addGeneratedLayersToCanvas([nextLayer]);
|
||||
selectSingleLayer(nextLayer.id);
|
||||
setActiveSidebarPanel('layers');
|
||||
if (dialogId) {
|
||||
@@ -273,14 +284,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: nextLayer.id,
|
||||
placeholder: undefined,
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
}
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
appendCanvasLayersWithResources,
|
||||
addGeneratedLayersToCanvas,
|
||||
canvasSize,
|
||||
layerCounterRef,
|
||||
selectSingleLayer,
|
||||
|
||||
@@ -88,7 +88,6 @@ function GenerationSurfaceHarness() {
|
||||
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId:
|
||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||
|
||||
@@ -52,7 +52,6 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
dialogId: string,
|
||||
updater: CanvasGenerationDialogUpdater,
|
||||
) => void;
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||
getGeneratingDialogPlaceholder: (
|
||||
dialog: GenerateDialogState,
|
||||
@@ -65,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationSurface({
|
||||
@@ -84,7 +84,6 @@ export function useImageCanvasGenerationSurface({
|
||||
canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
@@ -95,6 +94,7 @@ export function useImageCanvasGenerationSurface({
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
persistGeneratedAsset,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
||||
layers,
|
||||
@@ -107,7 +107,6 @@ export function useImageCanvasGenerationSurface({
|
||||
setGenerateDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
@@ -117,6 +116,7 @@ export function useImageCanvasGenerationSurface({
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
persistGeneratedAsset,
|
||||
});
|
||||
|
||||
const activeGenerationLayer =
|
||||
|
||||
@@ -110,7 +110,6 @@ function GenerationWorkflowHarness({
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId:
|
||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||
@@ -383,7 +382,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'layer-generated-1',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:layer-generated-1:-',
|
||||
'generate:idle:open:layer-generated-1:placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ type GenerationWorkflowOptions = {
|
||||
dialogId: string,
|
||||
updater: CanvasGenerationDialogUpdater,
|
||||
) => void;
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||
getGeneratingDialogPlaceholder: (
|
||||
dialog: GenerateDialogState,
|
||||
@@ -89,6 +88,7 @@ type GenerationWorkflowOptions = {
|
||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationWorkflow({
|
||||
@@ -102,7 +102,6 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setGenerateDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
@@ -112,6 +111,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
persistGeneratedAsset,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
@@ -476,7 +476,6 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setCharacterAnimationPanel,
|
||||
setGenerateDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
@@ -484,6 +483,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
rememberImageModel: setLastImageModel,
|
||||
persistGeneratedAsset,
|
||||
});
|
||||
const {
|
||||
submitCharacterAnimation,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
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 type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
@@ -45,10 +49,15 @@ function createLayer(id: string): CanvasLayer {
|
||||
|
||||
function ProjectPersistenceHarness({
|
||||
canAccessProtectedData = true,
|
||||
initialGenerationDialogs = [],
|
||||
}: {
|
||||
canAccessProtectedData?: boolean;
|
||||
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||
const [generationDialogs, setGenerationDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
>(initialGenerationDialogs);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -58,33 +67,49 @@ function ProjectPersistenceHarness({
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||
const selectedLayerRef = useRef<string | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
const openEditorLoginModalRef = useRef(vi.fn());
|
||||
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
refs: {
|
||||
canvasGenerationDialogsRef.current = generationDialogs;
|
||||
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
}, []);
|
||||
const setLayerCounter = useCallback((value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
}, []);
|
||||
const persistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
},
|
||||
setters: {
|
||||
canvasGenerationDialogsRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const persistenceSetters = useMemo(
|
||||
() => ({
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
selectSingleLayer: (layerId) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
},
|
||||
setLayerCounter: (value) => {
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
},
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
||||
}),
|
||||
[selectSingleLayer, setLayerCounter],
|
||||
);
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
refs: persistenceRefs,
|
||||
setters: persistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs: generationDialogs,
|
||||
viewport,
|
||||
canAccessProtectedData,
|
||||
openEditorLoginModal: vi.fn(),
|
||||
openEditorLoginModal: openEditorLoginModalRef.current,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -97,6 +122,11 @@ function ProjectPersistenceHarness({
|
||||
</span>
|
||||
<span data-testid="selected">{selectedLayerRef.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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -105,6 +135,42 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
append
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||
|
||||
|
||||
@@ -7,8 +7,16 @@ import {
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
hydrateLayer,
|
||||
serializeCanvasLayout,
|
||||
splitCanvasLayoutItems,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type ProjectResourceOptions = {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
@@ -23,6 +31,7 @@ type PendingProjectResourceLayer = {
|
||||
type ImageCanvasProjectPersistenceRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceSetters = {
|
||||
@@ -32,6 +41,9 @@ type ImageCanvasProjectPersistenceSetters = {
|
||||
setLayers: (layers: CanvasLayer[]) => void;
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
setLayerCounter: (value: number) => void;
|
||||
restoreCanvasGenerationDialogs: (
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
function isEditorAuthError(error: unknown) {
|
||||
@@ -45,6 +57,7 @@ export function useImageCanvasProjectPersistence({
|
||||
refs,
|
||||
setters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canAccessProtectedData,
|
||||
openEditorLoginModal,
|
||||
@@ -52,6 +65,7 @@ export function useImageCanvasProjectPersistence({
|
||||
refs: ImageCanvasProjectPersistenceRefs;
|
||||
setters: ImageCanvasProjectPersistenceSetters;
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
viewport: CanvasViewport;
|
||||
canAccessProtectedData: boolean;
|
||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
@@ -63,6 +77,15 @@ export function useImageCanvasProjectPersistence({
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
const {
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs,
|
||||
} = setters;
|
||||
|
||||
const createProjectResourceForLayer = useCallback(
|
||||
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
|
||||
@@ -110,11 +133,15 @@ export function useImageCanvasProjectPersistence({
|
||||
)
|
||||
: currentLayers;
|
||||
refs.layersRef.current = nextLayers;
|
||||
setters.setLayers(nextLayers);
|
||||
setLayers(nextLayers);
|
||||
if (nextLayers.length) {
|
||||
void saveEditorProjectLayout(readyProjectId, {
|
||||
viewport: refs.viewportRef.current,
|
||||
layers: nextLayers.map(serializeLayer),
|
||||
layers: serializeCanvasLayout({
|
||||
layers: nextLayers,
|
||||
canvasGenerationDialogs:
|
||||
refs.canvasGenerationDialogsRef.current,
|
||||
}),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
@@ -128,7 +155,7 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
});
|
||||
},
|
||||
[openEditorLoginModal, refs, setters],
|
||||
[openEditorLoginModal, refs, setLayers],
|
||||
);
|
||||
|
||||
const appendCanvasLayersWithResources = useCallback(
|
||||
@@ -138,12 +165,12 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
|
||||
refs.layersRef.current = snapshotLayers;
|
||||
setters.setLayers(snapshotLayers);
|
||||
setLayers(snapshotLayers);
|
||||
nextLayers.forEach((layer) =>
|
||||
createProjectResourceForLayer(layer, { snapshotLayers }),
|
||||
);
|
||||
},
|
||||
[createProjectResourceForLayer, refs, setters],
|
||||
[createProjectResourceForLayer, refs, setLayers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,26 +197,30 @@ export function useImageCanvasProjectPersistence({
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
setters.setProjectTitle(nextProjectTitle);
|
||||
setters.setProjectRenameValue(nextProjectTitle);
|
||||
setProjectTitle(nextProjectTitle);
|
||||
setProjectRenameValue(nextProjectTitle);
|
||||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||||
pendingLayers.forEach(({ layer, options }) => {
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setters.setViewport(project.viewport);
|
||||
setViewport(project.viewport);
|
||||
const resourcesById = new Map(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{ imageSrc: resource.imageSrc },
|
||||
]),
|
||||
);
|
||||
const hydratedLayers = project.layers
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||
project.layers,
|
||||
);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
if (hydratedLayers.length > 0) {
|
||||
setters.setLayerCounter(hydratedLayers.length);
|
||||
setters.setLayers(hydratedLayers);
|
||||
setters.selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
setLayerCounter(hydratedLayers.length);
|
||||
setLayers(hydratedLayers);
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
}
|
||||
setIsProjectReady(true);
|
||||
})
|
||||
@@ -212,7 +243,13 @@ export function useImageCanvasProjectPersistence({
|
||||
canAccessProtectedData,
|
||||
createProjectResourceForLayer,
|
||||
openEditorLoginModal,
|
||||
setters,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
setLayers,
|
||||
setProjectRenameValue,
|
||||
setProjectTitle,
|
||||
setViewport,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -226,7 +263,10 @@ export function useImageCanvasProjectPersistence({
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveEditorProjectLayout(projectId, {
|
||||
viewport,
|
||||
layers: layers.map(serializeLayer),
|
||||
layers: serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
}),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
@@ -239,7 +279,15 @@ export function useImageCanvasProjectPersistence({
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
|
||||
}, [
|
||||
isProjectReady,
|
||||
canvasGenerationDialogs,
|
||||
layers,
|
||||
openEditorLoginModal,
|
||||
projectId,
|
||||
refs,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
|
||||
Reference in New Issue
Block a user