修复画布生成面板关闭行为

移除普通生图、快速编辑和改造面板的独立关闭按钮

统一空白画布点击后的生成面板收起逻辑

避免快速编辑和改造误移除最近生成器

补充生成面板交互回归测试
This commit is contained in:
2026-06-19 16:22:30 +08:00
parent 95d6bc2ae7
commit 0502c5c5df
14 changed files with 162 additions and 52 deletions
@@ -29,12 +29,10 @@ function BasicGenerationHarness({
initialDialog = createDialog(),
onRequestUpload = vi.fn(),
onSubmit = vi.fn(),
onClose = vi.fn(),
}: {
initialDialog?: GenerateDialogState;
onRequestUpload?: (target: UploadTarget) => void;
onSubmit?: (dialog: GenerateDialogState) => void;
onClose?: () => void;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
@@ -58,7 +56,6 @@ function BasicGenerationHarness({
onRequestUpload={onRequestUpload}
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
onSubmit={onSubmit}
onClose={onClose}
/>
<output aria-label="当前提示词">{dialog.prompt}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
@@ -206,12 +203,11 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
).toBeNull();
});
it('closes through its interface', () => {
const closeComposer = vi.fn();
render(<BasicGenerationHarness onClose={closeComposer} />);
it('does not render a standalone close button', () => {
render(<BasicGenerationHarness />);
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(closeComposer).toHaveBeenCalledTimes(1);
expect(
screen.queryByRole('button', { name: '关闭生成图片' }),
).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { ImageIcon, X } from 'lucide-react';
import { ImageIcon } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -13,7 +13,6 @@ import {
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CharacterReferenceImage,
GenerateDialogState,
@@ -42,7 +41,6 @@ type ImageCanvasBasicGenerationComposerViewProps = {
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
onClose: () => void;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
@@ -90,7 +88,6 @@ export function ImageCanvasBasicGenerationComposerView({
onToggleReferenceMenu,
onRememberImageModel = () => {},
onSubmit,
onClose,
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
useImageCanvasFloatingOptionDismiss({
@@ -203,14 +200,6 @@ export function ImageCanvasBasicGenerationComposerView({
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<EditorIconButton
className="image-canvas-editor__generation-close"
label="关闭生成图片"
icon={X}
variant="surfaceFloating"
disabled={dialog.status === 'generating'}
onClick={onClose}
/>
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
? renderEditorPortal(
@@ -608,13 +608,75 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('closes the generation composer without removing the placeholder frame', () => {
it('hides quick edit and redraw panels when clicking the canvas outside generation controls', () => {
render(<ImageCanvasEditorView />);
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.queryByRole('button', { name: '关闭重绘图片' }),
).toBeNull();
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(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(
screen.queryByRole('button', { name: '关闭生成图片' }),
).toBeNull();
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
@@ -246,6 +246,7 @@ export function ImageCanvasEditorView() {
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
@@ -530,6 +531,7 @@ export function ImageCanvasEditorView() {
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -546,6 +548,8 @@ export function ImageCanvasEditorView() {
});
const {
setQuickEditPanel,
setCropExpandPanel,
setCharacterAnimationPanel,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
@@ -586,6 +590,11 @@ export function ImageCanvasEditorView() {
switchGenerationTool,
} = generationSurface;
setQuickEditPanelRef.current = setQuickEditPanel;
const clearGenerationPanelsAfterBlur = useCallback(() => {
setQuickEditPanel(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
}, [setCharacterAnimationPanel, setCropExpandPanel, setQuickEditPanel]);
const {
selectedLayer,
selectedToolbarStyle,
@@ -607,6 +616,7 @@ export function ImageCanvasEditorView() {
canvasSize,
selectSingleLayer,
hideGeneratedLayerPanelAfterBlur,
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
});
const {
@@ -97,7 +97,6 @@ function createComposerProps(
onSubmitQuickEdit: vi.fn(),
onSubmitCropExpand: vi.fn(),
onSubmitCharacterAnimation: vi.fn(),
onCloseGenerateComposer: vi.fn(),
onUpdateSpecFormValue: vi.fn(),
onUpdateIconDescriptionText: vi.fn(),
onUpdateCharacterAnimationDuration: vi.fn(),
@@ -118,7 +118,6 @@ type ImageCanvasGenerationComposerViewProps = {
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => void;
onCloseGenerateComposer: () => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescriptionText: (value: string) => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
@@ -883,7 +882,6 @@ export function ImageCanvasGenerationComposerView({
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onCloseGenerateComposer,
onUpdateSpecFormValue,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
@@ -939,7 +937,6 @@ export function ImageCanvasGenerationComposerView({
}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
onClose={onCloseGenerateComposer}
/>
) : null}
@@ -114,7 +114,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
).toBe(true);
});
it('submits and closes through its interface', () => {
it('submits without rendering a standalone close button', () => {
const submitQuickEdit = vi.fn();
render(
<QuickEditPanelHarness
@@ -130,10 +130,11 @@ describe('ImageCanvasQuickEditPanelView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑图片' }));
expect(submitQuickEdit).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('面板状态').textContent).toBe('closed');
expect(
screen.queryByRole('button', { name: '关闭快速编辑图片' }),
).toBeNull();
});
it('shows configured mud point price inside redraw submit button', () => {
@@ -1,4 +1,4 @@
import { Cpu, ImageIcon, ImagePlus, X } from 'lucide-react';
import { Cpu, ImageIcon, ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -15,7 +15,6 @@ import {
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasLayer,
CharacterReferenceImage,
@@ -235,15 +234,6 @@ export function ImageCanvasQuickEditPanelView({
)}
</PlatformActionButton>
</div>
<EditorIconButton
className="image-canvas-editor__generation-close"
label={`关闭${panelLabel}`}
title="关闭"
icon={X}
variant="surfaceFloating"
disabled={isGenerating}
onClick={() => setQuickEditPanel(null)}
/>
</form>
{!isRedraw && isReferenceMenuOpen && referenceButtonRef
? renderEditorPortal(
@@ -69,6 +69,7 @@ function renderWorldView(
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
@@ -145,7 +146,7 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('角色')).toBeTruthy();
});
it('shows kind tag for tagged layers and omits the tag for untagged layers', () => {
it('shows editable kind tags for tagged and untagged layers', () => {
const layer = createLayer({ assetKind: null });
renderWorldView({
layers: [
@@ -164,7 +165,7 @@ describe('ImageCanvasWorldView', () => {
name: '选择角色标签图层',
});
expect(within(layerButton).queryByText('未知')).toBeNull();
expect(within(layerButton).getByText('未知')).toBeTruthy();
expect(within(characterButton).getByText('角色')).toBeTruthy();
});
@@ -95,6 +95,8 @@ function GenerationSurfaceHarness() {
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -208,18 +210,17 @@ describe('useImageCanvasGenerationSurface', () => {
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
});
it('derives composer position and closes the generated image composer', () => {
it('derives composer position without rendering a standalone close button', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
expect(screen.getByTestId('composer-left').textContent).toBe('450');
expect(screen.getByTestId('composer-top').textContent).toBe('1354');
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(
screen.queryByRole('button', { name: '关闭生成图片' }),
).toBeNull();
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:closed:placeholder',
'generate:open:placeholder',
);
});
@@ -71,6 +71,7 @@ type ImageCanvasGenerationSurfaceOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -145,6 +146,7 @@ export function useImageCanvasGenerationSurface({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -175,6 +177,7 @@ export function useImageCanvasGenerationSurface({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -426,7 +429,6 @@ export function useImageCanvasGenerationSurface({
onSubmitCharacterAnimation={() =>
void generationWorkflow.submitCharacterAnimation()
}
onCloseGenerateComposer={generationWorkflow.closeGenerateComposer}
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
onUpdateIconDescriptionText={
generationWorkflow.updateIconDescriptionsText
@@ -128,6 +128,8 @@ function GenerationWorkflowHarness({
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: setSelectedLayerId,
@@ -171,6 +173,14 @@ function GenerationWorkflowHarness({
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
: '-'}
</span>
<span data-testid="generation-dialogs">
{dialogs.canvasGenerationDialogs
.map(
(dialog) =>
`${dialog.id}:${dialog.mode}:${dialog.composerOpen !== false ? 'open' : 'closed'}`,
)
.join('|') || '-'}
</span>
<span data-testid="generation-references">
{activeDialog?.generationReferences
?.map((reference) => reference.label)
@@ -254,6 +264,9 @@ function GenerationWorkflowHarness({
>
</button>
<button type="button" onClick={() => workflow.openRedrawPanel(layers[0]!)}>
</button>
<button
type="button"
onClick={() => workflow.openCropExpandPanel(layers[0]!)}
@@ -504,6 +517,36 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('keeps the active canvas generator when opening quick edit for another layer', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:generate:closed',
);
});
it('keeps the active canvas generator when opening redraw for another layer', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:generate:closed',
);
});
it('submits audio remodel with restored sound parameters and appends the new audio layer', async () => {
generateEditorSoundEffectMock.mockResolvedValueOnce({
audioSrc: 'data:audio/mpeg;base64,generated',
@@ -168,6 +168,7 @@ type GenerationWorkflowOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -197,6 +198,7 @@ export function useImageCanvasGenerationWorkflow({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -528,16 +530,24 @@ export function useImageCanvasGenerationWorkflow({
setImageContextMenu(null);
setQuickEditPanel(null);
setCropExpandPanel(null);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(createEditDialogDraft(sourceLayer));
setActiveTool('generate');
},
[setActiveTool, setGenerateDialog, setImageContextMenu, setMetadataLayer],
[
archiveActiveCanvasGenerationDialog,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setMetadataLayer,
],
);
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
@@ -547,6 +557,7 @@ export function useImageCanvasGenerationWorkflow({
},
[
selectSingleLayer,
archiveActiveCanvasGenerationDialog,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
@@ -601,12 +612,14 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool('music');
return;
}
archiveActiveCanvasGenerationDialog();
setGenerateDialog(null);
setQuickEditPanel(createRedrawPanelDraft(sourceLayer));
selectSingleLayer(sourceLayer.id);
setActiveTool('generate');
},
[
archiveActiveCanvasGenerationDialog,
openCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
@@ -620,6 +633,7 @@ export function useImageCanvasGenerationWorkflow({
(sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(null);
@@ -638,6 +652,7 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool('select');
},
[
archiveActiveCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
setGenerateDialog,
@@ -30,6 +30,7 @@ type UseImageCanvasStageControllerOptions = {
canvasSize: { width: number; height: number };
selectSingleLayer: (layerId: string | null) => void;
hideGeneratedLayerPanelAfterBlur: () => void;
clearGenerationPanelsAfterBlur?: () => void;
getCanvasPointFromClient: (
clientX: number,
clientY: number,
@@ -48,6 +49,7 @@ export function useImageCanvasStageController({
canvasSize,
selectSingleLayer,
hideGeneratedLayerPanelAfterBlur,
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
}: UseImageCanvasStageControllerOptions) {
const model = useMemo(
@@ -75,9 +77,11 @@ export function useImageCanvasStageController({
const clearCanvasFocus = useCallback(() => {
selectSingleLayer(null);
hideGeneratedLayerPanelAfterBlur();
clearGenerationPanelsAfterBlur?.();
setImageContextMenu(null);
setContextMenu(null);
}, [
clearGenerationPanelsAfterBlur,
hideGeneratedLayerPanelAfterBlur,
selectSingleLayer,
setContextMenu,