保存图片画布生成器快照

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

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

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

补充生成器持久化测试和浏览器回归相关文档
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

@@ -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')!;

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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' ||

View File

@@ -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',
);
});
});

View File

@@ -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,
};

View File

@@ -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' }],

View File

@@ -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,

View File

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

View File

@@ -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 =

View File

@@ -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',
);
});

View File

@@ -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,

View File

@@ -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} />);

View File

@@ -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,