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 8ed190253..0785c65d6 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, @@ -1546,7 +1611,7 @@ export function ImageCanvasEditorView() { onGenerationFramePointerDown: handleGenerationFramePointerDown, onActivateGenerationDialog: activateCanvasGenerationDialog, 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,