diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
index e7271748a..6222fb73f 100644
--- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
@@ -474,6 +474,158 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBe('刷新后恢复生成器');
});
+ it('restores generated image composer from layer metadata when the dialog snapshot is missing', async () => {
+ loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
+ projectId: 'editor-project-generated-layer-only',
+ title: '已生成画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [
+ {
+ layerId: 'layer-generated-metadata',
+ resourceId: 'resource-generated-metadata',
+ title: '生成图片 1',
+ x: 236,
+ y: 66,
+ width: 512,
+ height: 512,
+ originalWidth: 512,
+ originalHeight: 512,
+ zIndex: 11,
+ sourceType: 'generated',
+ },
+ ],
+ resources: [
+ {
+ resourceId: 'resource-generated-metadata',
+ projectId: 'editor-project-generated-layer-only',
+ imageSrc: 'data:image/png;base64,bWV0YWRhdGE=',
+ width: 512,
+ height: 512,
+ sourceType: 'generated',
+ prompt: '图层提示词',
+ model: 'gpt-image-2',
+ generationInputs: {
+ fields: [{ title: '生成提示词', value: '元数据恢复生成器' }],
+ references: [],
+ },
+ },
+ ],
+ updatedAt: '2026-06-17T00:00:00.000Z',
+ });
+
+ render();
+
+ const generatedImage = await screen.findByAltText('画布图片:生成图片 1');
+ expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
+
+ fireEvent.click(generatedImage.closest('button')!);
+
+ expect(await screen.findByRole('dialog', { name: '生成图片' })).toBeTruthy();
+ expect(
+ (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
+ ).toBe('元数据恢复生成器');
+ });
+
+ it('does not open a generator on direct click for tagged uploaded images', async () => {
+ loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
+ projectId: 'editor-project-uploaded-tagged-layer',
+ title: '标签素材画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [
+ {
+ layerId: 'layer-uploaded-character',
+ resourceId: 'resource-uploaded-character',
+ title: '上传角色',
+ src: 'data:image/png;base64,dXBsb2FkZWQ=',
+ x: 236,
+ y: 66,
+ width: 512,
+ height: 512,
+ originalWidth: 512,
+ originalHeight: 512,
+ zIndex: 11,
+ sourceType: 'uploaded',
+ assetKind: 'character',
+ generationInputs: {
+ fields: [{ title: '角色设定', value: '黑衣剑士' }],
+ references: [],
+ },
+ },
+ ],
+ resources: [],
+ updatedAt: '2026-06-17T00:00:00.000Z',
+ });
+
+ render();
+
+ const uploadedImage = await screen.findByAltText('画布图片:上传角色');
+ fireEvent.click(uploadedImage.closest('button')!);
+
+ expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
+
+ expect(
+ await screen.findByRole('dialog', { name: '生成角色形象' }),
+ ).toBeTruthy();
+ expect(
+ (screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
+ ).toBe('黑衣剑士');
+ });
+
+ it('opens the matching generator from image context quick edit for tagged uploaded images', async () => {
+ loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
+ projectId: 'editor-project-context-tagged-layer',
+ title: '右键标签素材画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [
+ {
+ layerId: 'layer-context-character',
+ resourceId: 'resource-context-character',
+ title: '右键上传角色',
+ src: 'data:image/png;base64,Y29udGV4dA==',
+ x: 236,
+ y: 66,
+ width: 512,
+ height: 512,
+ originalWidth: 512,
+ originalHeight: 512,
+ zIndex: 11,
+ sourceType: 'uploaded',
+ assetKind: 'character',
+ generationInputs: {
+ fields: [{ title: '角色设定', value: '红披风骑士' }],
+ references: [],
+ },
+ },
+ ],
+ resources: [],
+ updatedAt: '2026-06-17T00:00:00.000Z',
+ });
+
+ render();
+
+ const uploadedImage = await screen.findByAltText('画布图片:右键上传角色');
+ fireEvent.contextMenu(uploadedImage.closest('button')!, {
+ clientX: 260,
+ clientY: 220,
+ });
+ fireEvent.click(
+ within(screen.getByRole('menu', { name: '图片功能面板' })).getByRole(
+ 'menuitem',
+ { name: '快速编辑' },
+ ),
+ );
+
+ expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
+ expect(
+ await screen.findByRole('dialog', { name: '生成角色形象' }),
+ ).toBeTruthy();
+ expect(
+ (screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
+ ).toBe('红披风骑士');
+ });
+
it('restores publication material composer fields and references from saved generator snapshots', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-publication-dialog',
@@ -950,64 +1102,6 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
- it('hides quick edit and redraw panels when clicking the canvas outside generation controls', () => {
- render();
- const viewport = screen.getByLabelText('画布工作区');
- const sourceLayer = screen
- .getByAltText('画布图片:拼图素材')
- .closest('button')!;
-
- fireEvent.pointerDown(sourceLayer, {
- button: 0,
- pointerId: 631,
- clientX: 120,
- clientY: 120,
- });
- fireEvent.pointerUp(viewport, {
- pointerId: 631,
- clientX: 120,
- clientY: 120,
- });
- fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
- expect(screen.getByRole('dialog', { name: '快速编辑图片' })).toBeTruthy();
- expect(
- screen.queryByRole('button', { name: '关闭快速编辑图片' }),
- ).toBeNull();
-
- fireEvent.pointerDown(viewport, {
- button: 0,
- pointerId: 632,
- clientX: 260,
- clientY: 180,
- });
-
- expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
-
- fireEvent.pointerDown(sourceLayer, {
- button: 0,
- pointerId: 633,
- clientX: 120,
- clientY: 120,
- });
- fireEvent.pointerUp(viewport, {
- pointerId: 633,
- clientX: 120,
- clientY: 120,
- });
- fireEvent.click(screen.getByRole('button', { name: '改造' }));
- expect(screen.getByRole('dialog', { name: '改造图片' })).toBeTruthy();
- expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy();
-
- fireEvent.pointerDown(viewport, {
- button: 0,
- pointerId: 634,
- clientX: 280,
- clientY: 190,
- });
-
- expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
- });
-
it('does not render a standalone close button on the generation composer', () => {
render();
@@ -3158,360 +3252,7 @@ describe('ImageCanvasEditorView generation integration', () => {
});
});
- it('opens quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => {
- loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
- projectId: 'editor-project-quick-edit',
- title: '快速编辑画布',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: [
- {
- layerId: 'layer-quick-source',
- resourceId: 'resource-quick-source',
- title: '魔法森林',
- src: 'data:image/png;base64,c291cmNl',
- x: 120,
- y: 140,
- width: 320,
- height: 240,
- originalWidth: 1536,
- originalHeight: 1024,
- zIndex: 2,
- sourceType: 'generated',
- prompt: '魔法森林原始提示词',
- actualPrompt: '魔法森林原始提示词',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'source-task-1',
- assetKind: 'spec',
- },
- {
- layerId: 'layer-quick-extra-ref',
- resourceId: 'resource-quick-extra-ref',
- title: '星空参考',
- src: 'data:image/png;base64,ZXh0cmEtcmVm',
- x: -420,
- y: 100,
- width: 260,
- height: 260,
- originalWidth: 512,
- originalHeight: 512,
- zIndex: 1,
- sourceType: 'uploaded',
- },
- ],
- resources: [],
- updatedAt: '2026-06-15T00:00:00.000Z',
- });
- editEditorImageMock.mockImplementationOnce(async (input) =>
- createGeneratedImageResponse(input, {
- imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
- width: 1536,
- height: 1024,
- sourceType: 'generated',
- prompt: '增加萤火虫',
- actualPrompt: '增加萤火虫',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'quick-edit-task-1',
- }),
- );
- render();
-
- const sourceImage = await screen.findByAltText('画布图片:魔法森林');
- fireEvent.pointerDown(sourceImage.closest('button')!, {
- button: 0,
- pointerId: 151,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
- pointerId: 151,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
-
- const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
- expect(quickPanel.className).toContain(
- 'image-canvas-editor__quick-edit-panel',
- );
- expect(within(quickPanel).queryByText('魔法森林')).toBeNull();
- expect(
- (
- within(quickPanel).getByRole('button', {
- name: '快速编辑尺寸 3:2·1K',
- }) as HTMLButtonElement
- ).disabled,
- ).toBe(false);
- expect(
- (
- within(quickPanel).getByRole('button', {
- name: '快速编辑模型 gpt-image-2',
- }) as HTMLButtonElement
- ).disabled,
- ).toBe(false);
- expect(within(quickPanel).queryByLabelText('原图 图1')).toBeNull();
-
- fireEvent.click(
- within(quickPanel).getByRole('button', {
- name: '添加快速编辑参考图',
- }),
- );
- fireEvent.click(
- within(
- screen.getByRole('menu', { name: '快速编辑参考图来源' }),
- ).getByRole('menuitem', { name: '从画布中选择' }),
- );
- fireEvent.pointerDown(
- screen.getByAltText('画布图片:星空参考').closest('button')!,
- {
- button: 0,
- pointerId: 152,
- clientX: 140,
- clientY: 120,
- },
- );
-
- const updatedQuickPanel = screen.getByRole('dialog', {
- name: '快速编辑图片',
- });
- expect(within(updatedQuickPanel).getByText('星空参考')).toBeTruthy();
- expect(within(updatedQuickPanel).queryByLabelText('原图 图2')).toBeNull();
-
- fireEvent.change(
- within(updatedQuickPanel).getByLabelText('快速编辑提示词'),
- {
- target: { value: '参考原图增加萤火虫' },
- },
- );
- fireEvent.click(
- within(updatedQuickPanel).getByRole('button', { name: '生成' }),
- );
-
- await waitFor(() => {
- expect(editEditorImageMock).toHaveBeenCalledWith(
- expect.objectContaining({
- prompt: '参考原图增加萤火虫',
- sourceImageSrc: 'data:image/png;base64,c291cmNl',
- size: '1024x683',
- model: 'gpt-image-2',
- projectId: 'editor-project-quick-edit',
- assetKind: 'spec',
- assetFolderId: 'project',
- assetLabel: '魔法森林 快速编辑',
- sourceResourceId: 'resource-quick-source',
- generationInputs: expect.objectContaining({
- fields: expect.arrayContaining([
- expect.objectContaining({
- title: '快速编辑提示词',
- value: '参考原图增加萤火虫',
- }),
- ]),
- }),
- }),
- );
- });
- expect(generateEditorImageMock).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
- });
- const generatedLayer = screen
- .getByAltText('画布图片:魔法森林 快速编辑')
- .closest('button') as HTMLElement;
- expect(Number.parseFloat(generatedLayer.style.left)).toBe(376);
- expect(Number.parseFloat(generatedLayer.style.top)).toBe(1196);
- expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
- expect(Number.parseFloat(generatedLayer.style.height)).toBe(683);
- await waitFor(() => {
- expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
- 'editor-project-quick-edit',
- expect.objectContaining({
- layers: expect.arrayContaining([
- expect.objectContaining({
- title: '魔法森林 快速编辑',
- resourceId: 'resource-editor-project-quick-edit-1024',
- width: 1024,
- height: 683,
- originalWidth: 1024,
- originalHeight: 683,
- x: 376,
- y: 1196,
- }),
- ]),
- }),
- );
- });
- });
-
- it('opens quick edit from the image context menu', async () => {
- loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
- projectId: 'editor-project-context-quick-edit',
- title: '右键快速编辑画布',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: [
- {
- layerId: 'layer-context-source',
- resourceId: 'resource-context-source',
- title: '右键图片',
- src: 'data:image/png;base64,Y29udGV4dA==',
- x: 80,
- y: 90,
- width: 260,
- height: 260,
- originalWidth: 1024,
- originalHeight: 1024,
- zIndex: 1,
- sourceType: 'uploaded',
- model: 'gpt-image-2',
- },
- ],
- resources: [],
- updatedAt: '2026-06-15T00:00:00.000Z',
- });
- editEditorImageMock.mockImplementationOnce(async (input) =>
- createGeneratedImageResponse(input, {
- imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
- width: 1024,
- height: 1024,
- sourceType: 'generated',
- prompt: '换成夜晚',
- actualPrompt: '换成夜晚',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'context-quick-task-1',
- }),
- );
- render();
-
- const contextImage = await screen.findByAltText('画布图片:右键图片');
- fireEvent.contextMenu(contextImage.closest('button')!, {
- clientX: 260,
- clientY: 220,
- });
-
- const menu = screen.getByRole('menu', { name: '图片功能面板' });
- expect(
- within(menu).getByRole('menuitem', { name: '快速编辑' }),
- ).toBeTruthy();
- fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
-
- const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
- expect(within(panel).queryByText('右键图片')).toBeNull();
- fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
- target: { value: '换成夜晚' },
- });
- fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
-
- await waitFor(() => {
- expect(editEditorImageMock).toHaveBeenCalledWith(
- expect.objectContaining({
- prompt: '换成夜晚',
- sourceImageSrc: 'data:image/png;base64,Y29udGV4dA==',
- size: '1024x1024',
- model: 'gpt-image-2',
- projectId: 'editor-project-context-quick-edit',
- assetFolderId: 'project',
- assetLabel: '右键图片 快速编辑',
- sourceResourceId: 'resource-context-source',
- }),
- );
- });
- expect(generateEditorImageMock).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
- });
- });
-
- it('converts non-data-url quick edit source images before submitting references', async () => {
- loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
- projectId: 'editor-project-public-quick-edit',
- title: '公开素材快速编辑画布',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: [
- {
- layerId: 'layer-public-source',
- resourceId: 'resource-public-source',
- title: '公开拼图素材',
- src: '/creation-type-references/puzzle.webp',
- x: 120,
- y: 140,
- width: 320,
- height: 240,
- originalWidth: 640,
- originalHeight: 640,
- zIndex: 2,
- sourceType: 'uploaded',
- },
- ],
- resources: [],
- updatedAt: '2026-06-16T00:00:00.000Z',
- });
- vi.spyOn(globalThis, 'fetch').mockImplementation(
- async () =>
- new Response(new Uint8Array([104, 101, 108, 108, 111]), {
- status: 200,
- headers: {
- 'Content-Type': 'image/webp',
- },
- }),
- );
- editEditorImageMock.mockImplementationOnce(async (input) =>
- createGeneratedImageResponse(input, {
- imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
- width: 640,
- height: 640,
- sourceType: 'generated',
- prompt: '改成陶泥风格',
- actualPrompt: '改成陶泥风格',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'public-quick-edit-task-1',
- }),
- );
- render();
-
- const sourceImage = await screen.findByAltText('画布图片:公开拼图素材');
- fireEvent.pointerDown(sourceImage.closest('button')!, {
- button: 0,
- pointerId: 161,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
- pointerId: 161,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
-
- const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
- fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
- target: { value: '改成陶泥风格' },
- });
- fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
-
- await waitFor(() => {
- expect(editEditorImageMock).toHaveBeenCalledWith(
- expect.objectContaining({
- prompt: '改成陶泥风格',
- sourceImageSrc: 'data:image/webp;base64,aGVsbG8=',
- projectId: 'editor-project-public-quick-edit',
- assetFolderId: 'project',
- assetLabel: '公开拼图素材 快速编辑',
- sourceResourceId: 'resource-public-source',
- }),
- );
- });
- expect(generateEditorImageMock).not.toHaveBeenCalled();
- expect(globalThis.fetch).toHaveBeenCalledWith(
- '/creation-type-references/puzzle.webp',
- expect.objectContaining({
- signal: undefined,
- }),
- );
- });
-
- it('opens generated image info from the corner button and creates a real right-side edit result', async () => {
+ it('opens generated image info from the corner button', async () => {
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
@@ -3525,19 +3266,6 @@ describe('ImageCanvasEditorView generation integration', () => {
taskId: 'editor-real-task-2',
}),
);
- editEditorImageMock.mockImplementationOnce(async (input) =>
- createGeneratedImageResponse(input, {
- imageSrc: 'data:image/png;base64,ZWRhd24tcmVkcmF3',
- width: 1024,
- height: 1024,
- sourceType: 'generated',
- prompt: '把画面改成黄昏光线',
- actualPrompt: '把画面改成黄昏光线',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'editor-real-redraw-1',
- }),
- );
render();
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
@@ -3584,186 +3312,8 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: '改造' }));
- const editDialog = screen.getByRole('dialog', { name: '改造图片' });
- expect(editDialog).toBeTruthy();
- const editPrompt = within(editDialog).getByLabelText('改造提示词');
- expect(editPrompt.className).toContain('platform-text-field');
- expect(editPrompt.className).toContain(
- 'image-canvas-editor__generation-prompt',
- );
- fireEvent.change(editPrompt, {
- target: { value: '把画面改成黄昏光线' },
- });
- fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
-
- await waitFor(() => {
- expect(editEditorImageMock).toHaveBeenCalledWith(
- expect.objectContaining({
- prompt: '把画面改成黄昏光线',
- sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
- size: '1024x1024',
- model: 'gpt-image-2',
- }),
- );
- });
-
- await waitFor(() => {
- expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
- });
- expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
- fireEvent.click(
- screen.getAllByRole('button', {
- name: /查看生成图片 .* 修改结果图片信息/u,
- })[0]!,
- );
- const editedMetadataDialog = screen.getByRole('dialog', {
- name: '图片信息',
- });
- expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
- expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
- expect(
- within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
- ).toBeTruthy();
expect(
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
).toBeTruthy();
});
-
- it('shows the quick edit generator after redraw starts while keeping the source preview visible', async () => {
- loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
- projectId: 'editor-project-edit-generating',
- title: '重绘生成中画布',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: [
- {
- layerId: 'layer-edit-generating-source',
- resourceId: 'resource-edit-generating-source',
- title: '待重绘',
- src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
- x: 120,
- y: 140,
- width: 320,
- height: 240,
- originalWidth: 1024,
- originalHeight: 768,
- zIndex: 2,
- sourceType: 'generated',
- prompt: '原始提示词',
- actualPrompt: '原始提示词',
- model: 'gpt-image-2',
- provider: 'VectorEngine',
- taskId: 'edit-generating-source-task',
- },
- ],
- resources: [],
- updatedAt: '2026-06-16T00:00:00.000Z',
- });
- generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
- render();
-
- const sourceImage = await screen.findByAltText('画布图片:待重绘');
- const sourceLayer = sourceImage.closest('button')!;
- fireEvent.pointerDown(sourceLayer, {
- button: 0,
- pointerId: 171,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
- pointerId: 171,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.click(screen.getByRole('button', { name: '改造' }));
- const editDialog = screen.getByRole('dialog', { name: '改造图片' });
- fireEvent.change(within(editDialog).getByLabelText('改造提示词'), {
- target: { value: '改成雨夜灯光' },
- });
- fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
-
- expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
- expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy();
- expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy();
- expect(sourceLayer.className).not.toContain(
- 'image-canvas-editor__layer--generating',
- );
- });
-
- it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
- loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
- projectId: 'editor-project-quick-edit-generating',
- title: '快速编辑生成中画布',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: [
- {
- layerId: 'layer-quick-edit-generating-source',
- resourceId: 'resource-quick-edit-generating-source',
- title: '快速编辑源图',
- src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n',
- x: 120,
- y: 140,
- width: 320,
- height: 240,
- originalWidth: 1024,
- originalHeight: 768,
- zIndex: 2,
- sourceType: 'uploaded',
- model: 'gpt-image-2',
- },
- ],
- resources: [],
- updatedAt: '2026-06-16T00:00:00.000Z',
- });
- generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
- render();
-
- const sourceImage = await screen.findByAltText('画布图片:快速编辑源图');
- const sourceLayer = sourceImage.closest('button')!;
- fireEvent.pointerDown(sourceLayer, {
- button: 0,
- pointerId: 172,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
- pointerId: 172,
- clientX: 180,
- clientY: 180,
- });
- fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
- const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
- fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
- target: { value: '加一层暖光' },
- });
- fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
-
- expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
- expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy();
- expect(sourceLayer.className).not.toContain(
- 'image-canvas-editor__layer--generating',
- );
- const quickEditFrame = screen.getByLabelText('快速编辑生成占位图');
- expect(quickEditFrame).toBeTruthy();
- expect(quickEditFrame.className).toContain(
- 'image-canvas-editor__generation-frame--generating',
- );
- expect(within(quickEditFrame).getByRole('status').textContent).toContain(
- '生成中',
- );
-
- await act(async () => {
- fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
- });
-
- const confirmDialog = screen.getByRole('dialog', {
- name: '删除生成中的占位图',
- });
- expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy();
- fireEvent.click(
- within(confirmDialog).getByRole('button', { name: '确认删除' }),
- );
-
- expect(screen.queryByLabelText('快速编辑生成占位图')).toBeNull();
- });
});
diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts
index 43dfd49c8..dc772ff27 100644
--- a/src/components/image-editor/ImageCanvasEditorModel.ts
+++ b/src/components/image-editor/ImageCanvasEditorModel.ts
@@ -645,11 +645,12 @@ export function hydrateLayer(
? imageSequenceFrames
: undefined,
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
- prompt: stringOrNull(snapshot.prompt),
- actualPrompt: stringOrNull(snapshot.actualPrompt),
- model: stringOrNull(snapshot.model),
- provider: stringOrNull(snapshot.provider),
- taskId: stringOrNull(snapshot.taskId),
+ prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
+ actualPrompt:
+ stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
+ model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model),
+ provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
+ taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
assetObjectId:
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index 7f589ccfc..b7d341e02 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -48,6 +48,7 @@ import type {
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
+import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import {
@@ -88,6 +89,25 @@ function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
return layer.src;
}
+function findSourceAnimationLayer(
+ layers: CanvasLayer[],
+ sourceLayer: CanvasLayer,
+) {
+ if (sourceLayer.assetKind !== 'character-animation') {
+ return null;
+ }
+ const sourceResourceId = sourceLayer.sourceResourceId?.trim();
+ if (!sourceResourceId) {
+ return null;
+ }
+ return (
+ layers.find(
+ (layer) =>
+ layer.resourceId === sourceResourceId || layer.id === sourceResourceId,
+ ) ?? null
+ );
+}
+
function isEditablePasteTarget(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
if (!element) {
@@ -892,7 +912,6 @@ export function ImageCanvasEditorView() {
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
- openQuickEditPanel,
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
@@ -960,6 +979,51 @@ export function ImageCanvasEditorView() {
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
});
+ const openLayerGenerationDialog = useCallback(
+ (layer: CanvasLayer) => {
+ const existingDialog = [...canvasGenerationDialogsRef.current]
+ .reverse()
+ .find((dialog) => dialog.generatedLayerId === layer.id);
+ if (existingDialog) {
+ activateCanvasGenerationDialog(existingDialog);
+ setSelectedLayerId(layer.id);
+ setSelectedLayerIds([layer.id]);
+ return true;
+ }
+ const restoredDraft = createLayerGenerationDialogDraft({
+ sourceLayer: layer,
+ canvasSize,
+ viewport: viewportRef.current,
+ sourceAnimationLayer: findSourceAnimationLayer(
+ layersRef.current,
+ layer,
+ ),
+ });
+ if (!restoredDraft) {
+ return false;
+ }
+ openCanvasGenerationDialog(restoredDraft);
+ setSelectedLayerId(layer.id);
+ setSelectedLayerIds([layer.id]);
+ return true;
+ },
+ [
+ activateCanvasGenerationDialog,
+ canvasSize,
+ openCanvasGenerationDialog,
+ setSelectedLayerId,
+ setSelectedLayerIds,
+ ],
+ );
+ const openGeneratedLayerGenerationDialog = useCallback(
+ (layer: CanvasLayer) => {
+ if (layer.sourceType !== 'generated') {
+ return false;
+ }
+ return openLayerGenerationDialog(layer);
+ },
+ [openLayerGenerationDialog],
+ );
const contextMenuLayer =
contextMenu?.kind === 'layer'
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
@@ -1046,6 +1110,7 @@ export function ImageCanvasEditorView() {
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
+ openLayerGenerationDialog: openGeneratedLayerGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
moveViewportFromMinimapPointer,
@@ -1549,7 +1614,7 @@ export function ImageCanvasEditorView() {
onActivateGenerationDialog: activateCanvasGenerationDialog,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
- onOpenQuickEditPanel: openQuickEditPanel,
+ onOpenQuickEditPanel: openLayerGenerationDialog,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
index 0ad137e09..2c0a5752e 100644
--- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
+++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
@@ -20,6 +20,7 @@ import {
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
+ createLayerGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditPanelDraft,
createRedrawPanelDraft,
@@ -593,6 +594,61 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
+ it('restores a plain generated layer from its own metadata', () => {
+ const canvasSize = { width: 960, height: 720 };
+ const viewport = { x: 0, y: 0, scale: 1 };
+
+ expect(
+ createLayerGenerationDialogDraft({
+ sourceLayer: createLayer({
+ sourceType: 'generated',
+ prompt: '图层提示词',
+ model: 'gpt-image-2',
+ generationInputs: {
+ fields: [{ title: '生成提示词', value: '元数据提示词' }],
+ references: [],
+ },
+ }),
+ canvasSize,
+ viewport,
+ }),
+ ).toMatchObject({
+ mode: 'generate',
+ prompt: '元数据提示词',
+ generatedLayerId: 'layer-source',
+ imageModel: 'gpt-image-2',
+ placeholder: {
+ x: 120,
+ y: 140,
+ width: 320,
+ height: 240,
+ originalWidth: 1024,
+ originalHeight: 768,
+ },
+ });
+ });
+
+ it('restores a tagged uploaded layer from its current asset kind', () => {
+ expect(
+ createLayerGenerationDialogDraft({
+ sourceLayer: createLayer({
+ sourceType: 'uploaded',
+ assetKind: 'character',
+ generationInputs: {
+ fields: [{ title: '角色设定', value: '黑衣剑士' }],
+ references: [],
+ },
+ }),
+ canvasSize: { width: 960, height: 720 },
+ viewport: { x: 0, y: 0, scale: 1 },
+ }),
+ ).toMatchObject({
+ mode: 'character',
+ prompt: '黑衣剑士',
+ generatedLayerId: 'layer-source',
+ });
+ });
+
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
expect(
createQuickEditPanelDraft(
diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts
index 1ff8c9ef4..bae1bcb01 100644
--- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts
+++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts
@@ -649,9 +649,55 @@ function resolveGeneratedSourceDialogMode({
if (sourceLayer.assetKind === 'character-animation') {
return 'character-animation';
}
+ if (sourceLayer.sourceType === 'generated') {
+ return 'generate';
+ }
return null;
}
+function buildLayerGenerationPlaceholder(sourceLayer: CanvasLayer) {
+ return {
+ x: sourceLayer.x,
+ y: sourceLayer.y,
+ width: sourceLayer.width,
+ height: sourceLayer.height,
+ originalWidth: sourceLayer.originalWidth,
+ originalHeight: sourceLayer.originalHeight,
+ };
+}
+
+export function createLayerGenerationDialogDraft({
+ sourceLayer,
+ canvasSize,
+ viewport,
+ sourceDialog,
+ sourceAnimationLayer,
+}: Omit): Omit<
+ CanvasGenerationDialogState,
+ 'id'
+> | null {
+ const draft = createSameSourceGenerationDialogDraft({
+ sourceLayer,
+ canvasSize,
+ viewport,
+ sourceDialog,
+ sourceAnimationLayer,
+ mode: 'redraw',
+ });
+ if (!draft) {
+ return null;
+ }
+ return {
+ ...draft,
+ prompt: draft.prompt || sourceLayer.prompt?.trim() || '',
+ imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
+ status: 'idle',
+ composerOpen: true,
+ generatedLayerId: sourceLayer.id,
+ placeholder: buildLayerGenerationPlaceholder(sourceLayer),
+ };
+}
+
function getSourceDraftPrompt({
sourceLayer,
mode,
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts
index 26a6366f7..7421d4368 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.ts
+++ b/src/components/image-editor/useImageCanvasStageInteractions.ts
@@ -70,6 +70,7 @@ type UseImageCanvasStageInteractionsOptions = {
pickIconSpecFromLayer: (layer: CanvasLayer) => void;
pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void;
pickPublicationReferenceFromLayer: (layer: CanvasLayer) => void;
+ openLayerGenerationDialog?: (layer: CanvasLayer) => boolean;
activateCanvasGenerationDialog: (
dialog: CanvasGenerationDialogState,
) => void;
@@ -120,6 +121,7 @@ export function useImageCanvasStageInteractions({
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
pickPublicationReferenceFromLayer,
+ openLayerGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
moveViewportFromMinimapPointer,
@@ -389,9 +391,11 @@ export function useImageCanvasStageInteractions({
}
setSelectedLayerId(layer.id);
setSelectedLayerIds([layer.id]);
- setGenerateDialog((currentDialog) =>
- updateGenerateDialogForLayerClick(currentDialog),
- );
+ if (!openLayerGenerationDialog?.(layer)) {
+ setGenerateDialog((currentDialog) =>
+ updateGenerateDialogForLayerClick(currentDialog),
+ );
+ }
onCloseImageContextMenu();
},
[
@@ -402,6 +406,7 @@ export function useImageCanvasStageInteractions({
isPickingIconSpecFromCanvas,
isPickingPublicationReferenceFromCanvas,
onCloseImageContextMenu,
+ openLayerGenerationDialog,
setGenerateDialog,
setSelectedLayerId,
setSelectedLayerIds,