固定生成规范参数并支持删除生成中占位

生成规范类图片固定提交 16:9、2K 和 gpt-image-2,并在面板底部只读展示。

生成中占位支持通过 Delete 或 Backspace 删除,不新增可见删除按钮。

异步生成回写前按生成对象 ID 校验,避免已删除占位重新落回画布。

更新编辑器生成面板与图片画布技术文档及相关测试。
This commit is contained in:
2026-06-18 16:53:59 +08:00
parent 9eab60e427
commit 6f1ae6caef
18 changed files with 183 additions and 26 deletions
@@ -224,6 +224,8 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
@@ -456,6 +458,7 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -615,6 +618,11 @@ export function ImageCanvasEditorView() {
redoCanvasChange,
undoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
removeCanvasGenerationDialogById: (dialogId: string) => {
captureCanvasHistory();
removeCanvasGenerationDialogById(dialogId);
setActiveTool('select');
},
setActiveTool,
setGenerateDialog,
setImageContextMenu,
@@ -46,6 +46,9 @@ export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
@@ -116,6 +116,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: 'AI 生成图片',
input: {
size: '2048x1152',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
kind: 'spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
@@ -20,6 +20,9 @@ import {
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildIconGenerationInputs,
@@ -210,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({
Boolean(dialog.specReference?.src),
),
size: SPEC_GENERATION_SIZE,
model: DEFAULT_IMAGE_MODEL,
model: SPEC_GENERATION_MODEL,
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
@@ -89,6 +89,15 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(true);
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成5泥点');
expect(screen.getByLabelText('生成规范固定参数').textContent).toBe(
'16:92Kgpt-image-2',
);
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
});
it('renders UI design as a single borderless prompt below the first reference row', () => {
@@ -29,7 +29,10 @@ import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
CHARACTER_SPEC_VIEW_OPTIONS,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_COST,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -82,6 +85,7 @@ export function ImageCanvasSpecGenerationPanelView({
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
const isGenerating = dialog.status === 'generating';
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
@@ -127,7 +131,7 @@ export function ImageCanvasSpecGenerationPanelView({
label={referenceSlotLabel}
ariaLabel={referenceLabel}
title={reference?.label ?? referenceLabel}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
isAdd={!reference}
onClick={openReferenceMenu}
onRemove={
@@ -178,7 +182,7 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label="UI设计要求"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
placeholder="你希望这个 UI 长什么样?"
size="sm"
density="compact"
@@ -220,7 +224,7 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label="自定义规范提示词"
value={dialog.specValues?.customPrompt ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
@@ -241,7 +245,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformTextField
aria-label="玩法设定"
value={dialog.specValues?.playSetting ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -260,7 +264,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformTextField
aria-label="美术风格"
value={dialog.specValues?.artStyle ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -281,7 +285,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformSelectField
aria-label="头身比"
value={dialog.specValues?.bodyRatio ?? '3'}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -306,7 +310,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformSelectField
aria-label="角色视角"
value={dialog.specValues?.characterView ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -353,26 +357,33 @@ export function ImageCanvasSpecGenerationPanelView({
/>
) : (
<>
<span aria-hidden="true" />
<PlatformActionButton
<div
className="image-canvas-editor__readonly-generation-options"
aria-label="生成规范固定参数"
>
<span>{SPEC_GENERATION_ASPECT_RATIO}</span>
<span>{SPEC_GENERATION_IMAGE_SIZE}</span>
<span>{SPEC_GENERATION_MODEL}</span>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={dialog.status === 'generating'}
disabled={isGenerating}
aria-label="提交生成规范"
>
{dialog.status === 'generating' ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{SPEC_GENERATION_COST}
</span>
</>
)}
{isGenerating ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{SPEC_GENERATION_COST}
</span>
</>
)}
</PlatformActionButton>
</>
)}
@@ -100,6 +100,14 @@ export function useCanvasGenerationDialogs({
[updateCanvasGenerationDialogById],
);
const hasCanvasGenerationDialogById = useCallback((dialogId: string) => {
const currentDialog = generateDialogRef.current;
return [
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
...inactiveGenerateDialogsRef.current,
].some((dialog) => dialog.id === dialogId);
}, []);
const activateCanvasGenerationDialog = useCallback(
(targetDialog: CanvasGenerationDialogState) => {
setInactiveGenerateDialogs((currentDialogs) => {
@@ -216,6 +224,7 @@ export function useCanvasGenerationDialogs({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
@@ -133,6 +133,7 @@ function SubmissionWorkflowHarness({
setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
@@ -69,6 +69,7 @@ type GenerationSubmissionWorkflowOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
@@ -94,6 +95,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
@@ -127,6 +129,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createGeneratedResultLayer({
@@ -170,6 +175,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveTool,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -219,6 +225,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
generated,
@@ -256,6 +265,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -268,6 +278,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createVideoResultLayer({
@@ -302,6 +315,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -314,6 +328,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createAudioResultLayer({
@@ -348,6 +365,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -90,6 +90,7 @@ function GenerationSurfaceHarness() {
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -64,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -132,6 +133,7 @@ export function useImageCanvasGenerationSurface({
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -158,6 +160,7 @@ export function useImageCanvasGenerationSurface({
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -110,6 +110,7 @@ function GenerationWorkflowHarness({
setGenerateDialog: dialogs.setGenerateDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -459,7 +460,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
}),
@@ -79,6 +79,7 @@ type GenerationWorkflowOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -104,6 +105,7 @@ export function useImageCanvasGenerationWorkflow({
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -485,6 +487,7 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
@@ -59,6 +59,7 @@ function KeyboardShortcutsHarness({
undoCanvasChange = vi.fn(),
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
removeCanvasGenerationDialogById = vi.fn(),
closeEditorChromePanels = vi.fn(),
}: {
selectedLayerId?: string | null;
@@ -68,6 +69,7 @@ function KeyboardShortcutsHarness({
undoCanvasChange?: () => void;
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
removeCanvasGenerationDialogById?: (dialogId: string) => void;
closeEditorChromePanels?: () => void;
}) {
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
@@ -108,6 +110,7 @@ function KeyboardShortcutsHarness({
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
removeCanvasGenerationDialogById,
setActiveTool: (tool) => setActiveTool(tool),
setGenerateDialog: (updater) =>
setGenerateDialogState((currentDialog) =>
@@ -227,10 +230,12 @@ describe('useImageCanvasKeyboardShortcuts', () => {
});
it('removes editable generation placeholders with Backspace', () => {
const removeCanvasGenerationDialogById = vi.fn();
render(
<KeyboardShortcutsHarness
initialTool="character"
initialGenerateDialog={createPlaceholderDialog('character')}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
/>,
);
@@ -238,7 +243,10 @@ describe('useImageCanvasKeyboardShortcuts', () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.getByTestId('generate-dialog').textContent).toBe('none');
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'character:idle:true',
);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
expect(screen.getByTestId('character-menu').textContent).toBe('false');
expect(screen.getByTestId('character-picking').textContent).toBe('false');
@@ -246,6 +254,26 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(screen.getByTestId('icon-picking').textContent).toBe('false');
});
it('removes generating placeholders with Delete', () => {
const removeCanvasGenerationDialogById = vi.fn();
const generatingDialog = createPlaceholderDialog('generate');
generatingDialog.status = 'generating';
render(
<KeyboardShortcutsHarness
initialTool="generate"
initialGenerateDialog={generatingDialog}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
expect(screen.getByTestId('active-tool').textContent).toBe('select');
});
it('closes transient editor panels with Escape and collapses idle composers', () => {
const closeEditorChromePanels = vi.fn();
render(
@@ -1,6 +1,7 @@
import { type RefObject, useEffect } from 'react';
import type {
CanvasGenerationDialogState,
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
@@ -11,6 +12,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
redoCanvasChange: () => void;
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
removeCanvasGenerationDialogById?: (dialogId: string) => void;
setActiveTool: (tool: 'select') => void;
setGenerateDialog: (
updater:
@@ -61,7 +63,6 @@ function isCanvasGenerationPlaceholderDialog(
) {
return (
Boolean(dialog?.placeholder) &&
dialog?.status !== 'generating' &&
(dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
@@ -71,12 +72,19 @@ function isCanvasGenerationPlaceholderDialog(
);
}
function isCanvasGenerationDialogState(
dialog: GenerateDialogState | null,
): dialog is CanvasGenerationDialogState {
return Boolean(dialog?.id && isCanvasGenerationPlaceholderDialog(dialog));
}
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
removeCanvasGenerationDialogById,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
@@ -171,7 +179,15 @@ export function useImageCanvasKeyboardShortcuts({
}
if (isCanvasGenerationPlaceholderDialog(generateDialogRef.current)) {
event.preventDefault();
setGenerateDialog(null);
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialogState(currentDialog) &&
removeCanvasGenerationDialogById
) {
removeCanvasGenerationDialogById(currentDialog.id);
} else {
setGenerateDialog(null);
}
setActiveTool('select');
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
@@ -217,6 +233,7 @@ export function useImageCanvasKeyboardShortcuts({
closeEditorChromePanels,
deleteLayerById,
generateDialogRef,
removeCanvasGenerationDialogById,
redoCanvasChange,
selectedLayerIdRef,
setActiveTool,
+29
View File
@@ -5917,6 +5917,30 @@ button.image-canvas-editor__reference-chip:disabled {
gap: 0.24rem;
}
.image-canvas-editor__readonly-generation-options {
display: inline-flex;
min-width: 0;
min-height: 2.25rem;
align-items: center;
gap: 0.28rem;
justify-self: start;
color: #475569;
font-size: 0.78rem;
font-weight: 820;
}
.image-canvas-editor__readonly-generation-options span {
display: inline-flex;
min-height: 1.72rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
background: #f8fafc;
padding: 0 0.58rem;
white-space: nowrap;
}
.image-canvas-editor__context-menu {
position: fixed;
z-index: 35;
@@ -6325,6 +6349,7 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__readonly-generation-options,
.image-canvas-editor__generation-submit,
.image-canvas-editor__character-animation-submit {
grid-column: auto;
@@ -6337,6 +6362,10 @@ button.image-canvas-editor__reference-chip:disabled {
width: 100%;
}
.image-canvas-editor__readonly-generation-options {
flex-wrap: wrap;
}
.image-canvas-editor__character-composer {
position: fixed;
left: 0.75rem !important;