拆分图片画布生成工作流
新增 useImageCanvasGenerationWorkflow 承接生成入口、提交和结果落图 主视图改为通过生成工作流 hook 处理生成态清理和工具入口 补充生成工作流单测、拆分文档和 TRACKING 浏览器回归记录
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||||
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||||
generateEditorImage: generateEditorImageMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-source',
|
||||
resourceId: 'resource-source',
|
||||
title: '源图',
|
||||
src: 'data:image/png;base64,source',
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 768,
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGenerated(overrides = {}) {
|
||||
return {
|
||||
imageSrc: 'data:image/png;base64,generated',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated' as const,
|
||||
prompt: '生成提示词',
|
||||
actualPrompt: '生成提示词',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'task-generated',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function GenerationWorkflowHarness({
|
||||
initialLayers = [createLayer()],
|
||||
}: {
|
||||
initialLayers?: CanvasLayer[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||
const [imageContextMenu, setImageContextMenu] =
|
||||
useState<ImageContextMenuState | null>({
|
||||
layerId: 'layer-source',
|
||||
x: 1,
|
||||
y: 2,
|
||||
});
|
||||
const fitLayersMockRef = useRef(vi.fn());
|
||||
const layerCounterRef = useRef(0);
|
||||
const dialogs = useCanvasGenerationDialogs();
|
||||
const workflow = useImageCanvasGenerationWorkflow({
|
||||
layers,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
layerCounterRef,
|
||||
generateDialog: dialogs.generateDialog,
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId:
|
||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources: (nextLayers) =>
|
||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||||
selectSingleLayer: setSelectedLayerId,
|
||||
fitLayers: fitLayersMockRef.current,
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
});
|
||||
|
||||
const activeDialog = dialogs.generateDialog;
|
||||
const activeCanvasDialog =
|
||||
activeDialog && 'id' in activeDialog
|
||||
? (activeDialog as CanvasGenerationDialogState)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="tool">{activeTool}</span>
|
||||
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||||
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
||||
<span data-testid="metadata">{metadataLayer?.id ?? '-'}</span>
|
||||
<span data-testid="image-context">{imageContextMenu ? 'open' : '-'}</span>
|
||||
<span data-testid="layers">
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="dialog">
|
||||
{activeDialog
|
||||
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="quick-edit">
|
||||
{workflow.quickEditPanel
|
||||
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="character-animation">
|
||||
{workflow.characterAnimationPanel
|
||||
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="fit-count">
|
||||
{fitLayersMockRef.current.mock.calls.length}
|
||||
</span>
|
||||
<button type="button" onClick={workflow.openGenerateDialog}>
|
||||
打开生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.setGenerateDialog({
|
||||
mode: 'edit',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
sourceLayerId: 'layer-source',
|
||||
})
|
||||
}
|
||||
>
|
||||
打开修改状态
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
|
||||
>
|
||||
打开快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.openCharacterAnimationPanel(
|
||||
createLayer({ id: 'layer-character', assetKind: 'character' }),
|
||||
)
|
||||
}
|
||||
>
|
||||
打开角色动画
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.openCharacterAnimationPanel(createLayer({ id: 'layer-plain' }))
|
||||
}
|
||||
>
|
||||
打开普通动画
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? { ...currentDialog, prompt: '一张生成图' }
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
>
|
||||
填写生成提示词
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeDialog) {
|
||||
void workflow.submitImageGeneration(activeDialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
提交生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.setQuickEditPanel((currentPanel) =>
|
||||
currentPanel ? { ...currentPanel, prompt: '快速修图' } : currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
填写快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void workflow.submitQuickEdit()}
|
||||
>
|
||||
提交快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.clearDeletedLayerGenerationState('layer-source')}
|
||||
>
|
||||
清理源图状态
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeCanvasDialog) {
|
||||
dialogs.updateCanvasGenerationDialogById(
|
||||
activeCanvasDialog.id,
|
||||
(dialog) => ({
|
||||
...dialog,
|
||||
generatedLayerId: 'layer-source',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
绑定生成图层
|
||||
</button>
|
||||
<button type="button" onClick={workflow.hideGeneratedLayerPanelAfterBlur}>
|
||||
隐藏生成面板
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasGenerationWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens a movable canvas generation placeholder and keeps toolbar state active', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:-:placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '一张生成图' }),
|
||||
);
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
});
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:generating:closed:-:placeholder',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-1:生成图片 1',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||||
expect(screen.getByTestId('selected').textContent).toBe(
|
||||
'layer-generated-1',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:layer-generated-1:-',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits quick edits beside the source and fits source plus result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '快速修图' }),
|
||||
);
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:快速修图',
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '快速修图',
|
||||
size: '1024x768',
|
||||
kind: 'quick-edit',
|
||||
model: 'gpt-image-2',
|
||||
referenceImageSrcs: ['data:image/png;base64,source'],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-quick-edit-1:源图 快速编辑:resource-source',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开修改状态' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '清理源图状态' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('only opens the character animation panel for character layers', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开普通动画' }));
|
||||
expect(screen.getByTestId('character-animation').textContent).toBe('-');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色动画' }));
|
||||
expect(screen.getByTestId('character-animation').textContent).toBe(
|
||||
'layer-character:idle',
|
||||
);
|
||||
expect(screen.getByTestId('image-context').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('hides non-generating canvas generation composers without deleting placeholders', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:closed:-:placeholder',
|
||||
);
|
||||
});
|
||||
});
|
||||
1087
src/components/image-editor/useImageCanvasGenerationWorkflow.ts
Normal file
1087
src/components/image-editor/useImageCanvasGenerationWorkflow.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user