调整画布生成输入与图集快速编辑

隐藏生成面板资源名称字段并恢复提示词输入边框

恢复图标图集快速编辑并仅对单图标禁用

补充工具栏右键菜单工作流回归测试与编辑器文档
This commit is contained in:
2026-07-14 23:26:35 +08:00
parent c8dd6cbd33
commit 9807295a7d
23 changed files with 126 additions and 294 deletions
@@ -58,7 +58,6 @@ function BasicGenerationHarness({
onSubmit={onSubmit}
/>
<output aria-label="当前提示词">{dialog.prompt}</output>
<output aria-label="当前资源名称">{dialog.assetLabel ?? '-'}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前模型">{dialog.imageModel}</output>
@@ -87,25 +86,21 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的提示' },
});
fireEvent.change(screen.getByLabelText('资源名称'), {
target: { value: '自定义主视觉' },
});
const panel = screen.getByRole('dialog', { name: '生成图片' });
expect(
within(panel).queryByRole('textbox', { name: '资源名称' }),
).toBeNull();
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
expect(screen.getByLabelText('当前资源名称').textContent).toBe(
'自定义主视觉',
);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '新的提示',
assetLabel: '自定义主视觉',
status: 'idle',
}),
);
@@ -18,7 +18,6 @@ import type {
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
@@ -268,20 +267,6 @@ export function ImageCanvasBasicGenerationComposerView({
)
}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
<div className={finalFooterClassName}>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
@@ -24,7 +24,6 @@ import type {
CanvasLayer,
CharacterAnimationPanelState,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import {
CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
@@ -207,16 +206,11 @@ export function ImageCanvasCharacterAnimationPanelView({
placeholder="你希望角色做什么动作?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless image-canvas-editor__character-animation-textarea"
className="image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
onChange={(event) =>
updatePanel({ promptText: event.target.value.slice(0, 4000) })
}
/>
<ImageCanvasGenerationAssetNameField
value={panel.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updatePanel({ assetLabel })}
/>
<div className="image-canvas-editor__character-animation-presets">
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
<button
@@ -93,7 +93,7 @@ function CharacterGenerationHarness({
}
describe('ImageCanvasCharacterGenerationComposerView', () => {
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
it('keeps the prompt as a bordered single text input with a question placeholder', () => {
render(<CharacterGenerationHarness />);
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
@@ -105,7 +105,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
.join(''),
).not.toContain('角色设定');
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
expect(prompt.className).toContain(
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
expect(prompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
});
@@ -20,7 +20,6 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -263,7 +262,7 @@ export function ImageCanvasCharacterGenerationComposerView({
placeholder="你希望角色如何设计?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
@@ -276,20 +275,6 @@ export function ImageCanvasCharacterGenerationComposerView({
}
/>
</label>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -266,21 +266,28 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
it.each(['icon', 'icon-spritesheet'] as const)(
'hides quick edit from the standalone menu for %s assets',
(assetKind) => {
const layer = createLayer({ assetKind });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
it('hides quick edit from the standalone menu for individual icons', () => {
const layer = createLayer({ assetKind: 'icon' });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
},
);
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
});
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
});
it('keeps quick edit in the standalone menu for icon specs', () => {
const layer = createLayer({ assetKind: 'icon-spec' });
@@ -2019,12 +2019,19 @@ describe('ImageCanvasEditorView generation integration', () => {
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
);
const iconPrompt = within(iconPanel).getByRole('textbox', {
name: '素材描述',
});
expect(iconPrompt.tagName).toBe('TEXTAREA');
expect(iconPrompt.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(iconPrompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
expect(
within(iconPanel).getByRole('textbox', { name: '素材描述' }).tagName,
).toBe('TEXTAREA');
expect(
within(iconPanel).getByRole('textbox', { name: '资源名称' }),
).toBeTruthy();
within(iconPanel).queryByRole('textbox', { name: '资源名称' }),
).toBeNull();
expect(
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
).toBeNull();
@@ -1,63 +0,0 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH,
ImageCanvasGenerationAssetNameField,
} from './ImageCanvasGenerationAssetNameField';
describe('ImageCanvasGenerationAssetNameField', () => {
it('renders as a controlled optional name input', () => {
const onChange = vi.fn();
const { rerender } = render(
<ImageCanvasGenerationAssetNameField
value="角色立绘"
onChange={onChange}
/>,
);
const input = screen.getByRole('textbox', {
name: '资源名称',
}) as HTMLInputElement;
expect(input.value).toBe('角色立绘');
expect(input.getAttribute('maxlength')).toBe(
String(IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH),
);
fireEvent.change(input, { target: { value: '新名称' } });
expect(onChange).toHaveBeenCalledWith('新名称');
expect(input.value).toBe('角色立绘');
rerender(
<ImageCanvasGenerationAssetNameField
value="新名称"
onChange={onChange}
/>,
);
expect(input.value).toBe('新名称');
});
it('limits callback values to 80 characters and supports disabled state', () => {
const onChange = vi.fn();
render(
<ImageCanvasGenerationAssetNameField
value=""
disabled
onChange={onChange}
/>,
);
const input = screen.getByRole('textbox', {
name: '资源名称',
}) as HTMLInputElement;
expect(input.disabled).toBe(true);
fireEvent.change(input, { target: { value: '名'.repeat(90) } });
expect(onChange).toHaveBeenCalledWith('名'.repeat(80));
fireEvent.change(input, { target: { value: '🎨'.repeat(90) } });
expect(onChange).toHaveBeenLastCalledWith('🎨'.repeat(80));
});
});
@@ -1,46 +0,0 @@
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformTextField } from '../common/PlatformTextField';
import { EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS } from './ImageCanvasGenerationSubmissionModel';
export const IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH =
EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS;
type ImageCanvasGenerationAssetNameFieldProps = {
value?: string | null;
disabled?: boolean;
onChange: (value: string) => void;
};
export function ImageCanvasGenerationAssetNameField({
value,
disabled = false,
onChange,
}: ImageCanvasGenerationAssetNameFieldProps) {
return (
<label className="image-canvas-editor__generation-asset-name-field">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
资源名称
</PlatformFieldLabel>
<PlatformTextField
aria-label="资源名称"
value={value ?? ''}
maxLength={IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH}
disabled={disabled}
placeholder="例如:勇者立绘"
size="sm"
density="compact"
className="image-canvas-editor__generation-asset-name-input"
onChange={(event) =>
onChange(
Array.from(event.target.value)
.slice(0, IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH)
.join(''),
)
}
/>
</label>
);
}
@@ -53,7 +53,6 @@ import {
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -402,11 +401,6 @@ function ImageCanvasVideoGenerationComposerView({
updateVideoDialog({ prompt: event.target.value })
}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updateVideoDialog({ assetLabel })}
/>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
@@ -793,11 +787,6 @@ function ImageCanvasAudioGenerationComposerView({
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updateAudioDialog({ assetLabel })}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -42,6 +42,7 @@ import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
isQuickEditUnsupportedAssetKind,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -392,6 +393,18 @@ describe('ImageCanvasGenerationModel', () => {
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditUnsupportedAssetKind({
...buildSourceLayer(),
assetKind: 'icon',
}),
).toBe(true);
expect(
isQuickEditUnsupportedAssetKind({
...buildSourceLayer(),
assetKind: 'icon-spritesheet',
}),
).toBe(false);
});
it('adds reference image semantics and snapshots for spec generation references', () => {
@@ -556,7 +556,7 @@ export function buildQuickEditModelOptions(currentModel: string) {
}
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
return layer.assetKind === 'icon' || layer.assetKind === 'icon-spritesheet';
return layer.assetKind === 'icon';
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
@@ -18,7 +18,6 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorIconSpritesheetPrice,
@@ -220,32 +219,10 @@ export function ImageCanvasIconSpritesheetComposerView({
placeholder="你需要哪些图标?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
className="image-canvas-editor__generation-prompt"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
/>
</label>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -21,7 +21,6 @@ import type {
PublicationMaterialsGameInfo,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
buildPublicationMaterialsPrompt,
@@ -287,21 +286,6 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
</label>
</div>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'publication'
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -137,6 +137,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'拆分图集',
@@ -258,16 +259,22 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
});
it.each(['icon', 'icon-spritesheet'] as const)(
'hides quick edit for %s assets',
(assetKind) => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind }),
});
it('hides quick edit only for individual icon assets', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon' }),
});
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
},
);
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
});
it('keeps quick edit and manual split available for icon spritesheets', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon-spritesheet' }),
});
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
expect(screen.getByRole('button', { name: '拆分图集' })).toBeTruthy();
});
it('keeps quick edit available for icon specs', () => {
renderSelectedToolbar({
@@ -138,7 +138,8 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(true);
expect(panel.textContent).not.toContain('UI设计要求');
expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?');
expect(prompt.className).toContain(
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
expect(prompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
@@ -25,7 +25,6 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
@@ -258,7 +257,7 @@ export function ImageCanvasSpecGenerationPanelView({
placeholder="你希望这个 UI 长什么样?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
className="image-canvas-editor__generation-prompt"
onChange={(event) => {
const nextPrompt = event.target.value;
if (setGenerateDialog) {
@@ -416,30 +415,6 @@ export function ImageCanvasSpecGenerationPanelView({
</>
)}
</div>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) =>
setGenerateDialog?.((currentDialog) =>
currentDialog &&
(currentDialog.mode === 'spec' ||
currentDialog.mode === 'ui-design')
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -639,6 +639,37 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('allows direct quick edit submission for icon spritesheets', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '调整图集布局' }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ assetKind: 'icon-spritesheet' }),
]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '调整图集布局',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '调整图集布局' }),
);
});
});
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
@@ -974,6 +974,20 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('opens quick edit for icon spritesheets', () => {
render(
<GenerationWorkflowHarness
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
});
it('calculates character animation button price from the model pricing config', () => {
render(<GenerationWorkflowHarness />);
-24
View File
@@ -6714,30 +6714,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: #94a3b8;
}
.image-canvas-editor__generation-asset-name-field {
display: grid;
grid-column: 1 / -1;
gap: 0.24rem;
min-width: 0;
}
.image-canvas-editor__generation-asset-name-input {
width: 100%;
min-width: 0;
}
.image-canvas-editor__generation-prompt--borderless {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.image-canvas-editor__generation-prompt--borderless:focus {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.image-canvas-editor__video-prompt {
grid-column: 1 / -1;
min-height: 4.1rem;