调整图片编辑器参考图选择交互
- 常规参考图入口改为先弹出来源菜单,支持从画布选择和上传图片。 - 角色规范、图标规范和常规参考图来源菜单统一向上弹出。 - 画布参考图选择拦截普通图层选中逻辑,保持生成面板不隐藏。 - 补充图片编辑器交互测试与技术文档说明。
This commit is contained in:
@@ -83,7 +83,7 @@
|
||||
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
|
||||
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
|
||||
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
|
||||
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
|
||||
|
||||
@@ -132,10 +132,14 @@ export type GenerateDialogState = {
|
||||
generatedLayerId?: string;
|
||||
specType?: SpecGenerationType;
|
||||
specValues?: SpecFormValues;
|
||||
specReference?: CharacterReferenceImage | null;
|
||||
characterSpecReference?: CharacterReferenceImage | null;
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
iconDescriptions?: string[];
|
||||
imageModel?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
placeholder?: {
|
||||
x: number;
|
||||
@@ -215,6 +219,7 @@ export type CharacterAnimationPanelState = {
|
||||
|
||||
export type UploadTarget =
|
||||
| 'asset'
|
||||
| 'spec-reference'
|
||||
| 'character-spec'
|
||||
| 'character-reference'
|
||||
| 'icon-spec';
|
||||
|
||||
@@ -2914,7 +2914,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
size: '2048x1152',
|
||||
prompt: expect.stringContaining('玩法设计:平台跳跃玩法'),
|
||||
});
|
||||
@@ -3009,23 +3009,29 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
expect(within(characterPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: '1:1' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: 'GPT Image' }),
|
||||
within(characterPanel).getByRole('button', { name: '1K' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(within(iconPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('submits character generation without legacy dimension options', async () => {
|
||||
it('submits character generation with default model and dimension options', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,character-model-options',
|
||||
width: 1024,
|
||||
@@ -3056,17 +3062,106 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
kind: 'character',
|
||||
prompt: '高个子游侠',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(generateEditorImageMock.mock.calls[0]?.[0]).not.toEqual(
|
||||
});
|
||||
|
||||
it('remembers the last selected image model for character and icon generation', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,character-gpt-model',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '蓝衣剑士',
|
||||
actualPrompt: '蓝衣剑士',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'character-gpt-model-1',
|
||||
});
|
||||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
|
||||
spritesheetWidth: 1024,
|
||||
spritesheetHeight: 1024,
|
||||
iconImageSrcs: [
|
||||
{
|
||||
name: '返回按钮',
|
||||
imageSrc: 'data:image/png;base64,back',
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
],
|
||||
prompt: '图标 prompt',
|
||||
actualPrompt: '图标 prompt',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-gpt-model-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
);
|
||||
fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' }));
|
||||
fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' }));
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
target: { value: '蓝衣剑士' },
|
||||
});
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '生成' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
aspectRatio: expect.any(String),
|
||||
imageSize: expect.any(String),
|
||||
kind: 'character',
|
||||
prompt: '蓝衣剑士',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传图片文件'),
|
||||
new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||||
'button',
|
||||
{ name: '生成' },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the bottom AI toolbar visible while generation panels are open', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -3239,6 +3334,18 @@ describe('ImageCanvasEditorView', () => {
|
||||
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
|
||||
|
||||
expect(referenceRow?.contains(sourceMenu)).toBe(false);
|
||||
expect(sourceMenu.className).toContain('platform-floating-menu--top-start');
|
||||
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||||
);
|
||||
const regularReferenceMenu = screen.getByRole('menu', {
|
||||
name: '常规参考图来源',
|
||||
});
|
||||
expect(referenceRow?.contains(regularReferenceMenu)).toBe(false);
|
||||
expect(regularReferenceMenu.className).toContain(
|
||||
'platform-floating-menu--top-start',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses Lovart-style reference tiles in the character generation panel', () => {
|
||||
@@ -3395,7 +3502,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
size: '2048x1152',
|
||||
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
|
||||
});
|
||||
@@ -3442,7 +3549,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
size: '2048x1152',
|
||||
prompt: '生成一张武器图标规范展板',
|
||||
});
|
||||
@@ -3504,15 +3611,51 @@ describe('ImageCanvasEditorView', () => {
|
||||
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
|
||||
).toBeNull();
|
||||
|
||||
const canvasReferenceLayer = screen
|
||||
.getByAltText('画布图片:大鱼素材')
|
||||
.closest('button')!;
|
||||
expect(canvasReferenceLayer.className).not.toContain(
|
||||
'image-canvas-editor__layer--selected',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||||
);
|
||||
const regularReferenceMenu = screen.getByRole('menu', {
|
||||
name: '常规参考图来源',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(regularReferenceMenu).getByRole('menuitem', {
|
||||
name: '从画布中选择',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
|
||||
).toBeTruthy();
|
||||
fireEvent.pointerDown(canvasReferenceLayer, {
|
||||
button: 0,
|
||||
pointerId: 171,
|
||||
clientX: 180,
|
||||
clientY: 120,
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||||
expect(canvasReferenceLayer.className).not.toContain(
|
||||
'image-canvas-editor__layer--selected',
|
||||
);
|
||||
expect(within(characterPanel).getByText('1')).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传图片文件'),
|
||||
new File(['reference'], '常规参考.png', { type: 'image/png' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(characterPanel).getByText('1')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('2')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
@@ -3525,8 +3668,12 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
kind: 'character',
|
||||
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: [
|
||||
'/creation-type-references/puzzle.webp',
|
||||
'/creation-type-references/big-fish.webp',
|
||||
expect.stringMatching(/^data:image\/png;base64,/u),
|
||||
],
|
||||
});
|
||||
@@ -3554,6 +3701,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
@@ -3752,6 +3901,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
|
||||
referenceImageSrc: 'data:image/png;base64,icon-spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -82,6 +82,7 @@ export function ImageCanvasEditorView() {
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
@@ -466,8 +467,12 @@ export function ImageCanvasEditorView() {
|
||||
setIsSpecMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
isIconSpecMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
isPickingIconSpecFromCanvas,
|
||||
@@ -480,6 +485,7 @@ export function ImageCanvasEditorView() {
|
||||
openEditDialog,
|
||||
openQuickEditPanel,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
submitIconSpritesheetGeneration,
|
||||
submitQuickEdit,
|
||||
@@ -488,6 +494,7 @@ export function ImageCanvasEditorView() {
|
||||
updateIconDescription,
|
||||
addIconDescription,
|
||||
updateCharacterAnimationDuration,
|
||||
rememberImageModel,
|
||||
submitCharacterAnimation,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
closeGenerateComposer,
|
||||
@@ -605,9 +612,11 @@ export function ImageCanvasEditorView() {
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
clearCanvasFocus,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
@@ -636,7 +645,9 @@ export function ImageCanvasEditorView() {
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
@@ -1064,11 +1075,16 @@ export function ImageCanvasEditorView() {
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
isSpecMenuOpen={isSpecMenuOpen}
|
||||
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
||||
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
|
||||
isIconSpecMenuOpen={isIconSpecMenuOpen}
|
||||
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
|
||||
isPickingCharacterReferenceFromCanvas={
|
||||
isPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
|
||||
generateDialog={generateDialog}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
@@ -1086,10 +1102,14 @@ export function ImageCanvasEditorView() {
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
setCharacterAnimationPanel={setCharacterAnimationPanel}
|
||||
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
||||
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
|
||||
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
|
||||
setIsPickingCharacterSpecFromCanvas={
|
||||
setIsPickingCharacterSpecFromCanvas
|
||||
}
|
||||
setIsPickingCharacterReferenceFromCanvas={
|
||||
setIsPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
||||
onOpenSpecDialog={openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
@@ -1118,6 +1138,7 @@ export function ImageCanvasEditorView() {
|
||||
onUpdateCharacterAnimationDuration={
|
||||
updateCharacterAnimationDuration
|
||||
}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>
|
||||
</ImageCanvasStageView>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
CHARACTER_ANIMATION_RATIO_OPTIONS,
|
||||
CHARACTER_SPEC_VIEW_OPTIONS,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
SPEC_GENERATION_COST,
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -52,11 +55,14 @@ import type {
|
||||
type ImageCanvasGenerationComposerViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
isSpecMenuOpen: boolean;
|
||||
isCharacterSpecMenuOpen: boolean;
|
||||
isCharacterReferenceMenuOpen: boolean;
|
||||
isIconSpecMenuOpen: boolean;
|
||||
isPickingCharacterSpecFromCanvas: boolean;
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
@@ -76,8 +82,10 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
SetStateAction<CharacterAnimationPanelState | null>
|
||||
>;
|
||||
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
@@ -90,6 +98,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onUpdateIconDescription: (index: number, value: string) => void;
|
||||
onAddIconDescription: () => void;
|
||||
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
};
|
||||
|
||||
function triggerPlaceholderAction(label: string) {
|
||||
@@ -152,14 +161,161 @@ function resetFailedPanelStatus<T extends { status: string; errorMessage?: strin
|
||||
};
|
||||
}
|
||||
|
||||
function getImageDimensionOptions(model: string | null | undefined) {
|
||||
return (
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2;
|
||||
const options = getImageDimensionOptions(model);
|
||||
const aspectRatios = options.aspectRatios as readonly string[];
|
||||
const imageSizes = options.imageSizes as readonly string[];
|
||||
return {
|
||||
model,
|
||||
aspectRatio:
|
||||
dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio)
|
||||
? dialog.aspectRatio
|
||||
: options.aspectRatios[0],
|
||||
imageSize:
|
||||
dialog.imageSize && imageSizes.includes(dialog.imageSize)
|
||||
? dialog.imageSize
|
||||
: (options.imageSizes.find((size) => size === '1K') ?? options.imageSizes[0]),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function renderImageOptionButtons({
|
||||
dialog,
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
onRememberImageModel,
|
||||
}: {
|
||||
dialog: GenerateDialogState;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
}) {
|
||||
const selection = normalizeImageDialogSelection(dialog);
|
||||
const updateDialog = (patch: Partial<GenerateDialogState>) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog && currentDialog.mode === dialog.mode
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
...patch,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{includeDimensions ? (
|
||||
<>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
画面比例
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={aspectRatio}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.aspectRatio === aspectRatio}
|
||||
onClick={() => updateDialog({ aspectRatio })}
|
||||
>
|
||||
{aspectRatio}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
大小尺寸
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={imageSize}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.imageSize === imageSize}
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
模型
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const nextOptions = getImageDimensionOptions(option.value);
|
||||
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
|
||||
const nextImageSizes = nextOptions.imageSizes as readonly string[];
|
||||
return (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__generation-model"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.model === option.value}
|
||||
onClick={() => {
|
||||
onRememberImageModel(option.value);
|
||||
updateDialog({
|
||||
imageModel: option.value,
|
||||
aspectRatio:
|
||||
dialog.aspectRatio &&
|
||||
nextAspectRatios.includes(dialog.aspectRatio)
|
||||
? dialog.aspectRatio
|
||||
: nextOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
dialog.imageSize &&
|
||||
nextImageSizes.includes(dialog.imageSize)
|
||||
? dialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformInlineOptionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasGenerationComposerView({
|
||||
specToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
isSpecMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
isIconSpecMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
generateDialog,
|
||||
generationComposerStyle,
|
||||
@@ -177,8 +333,10 @@ export function ImageCanvasGenerationComposerView({
|
||||
setQuickEditPanel,
|
||||
setCharacterAnimationPanel,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
@@ -191,6 +349,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onUpdateIconDescription,
|
||||
onAddIconDescription,
|
||||
onUpdateCharacterAnimationDuration,
|
||||
onRememberImageModel,
|
||||
}: ImageCanvasGenerationComposerViewProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -544,10 +703,10 @@ export function ImageCanvasGenerationComposerView({
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="角色形象规范来源"
|
||||
placement="bottom-start"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterSpecButtonRef.current,
|
||||
'below',
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
@@ -603,10 +762,13 @@ export function ImageCanvasGenerationComposerView({
|
||||
),
|
||||
)}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||||
disabled={generateDialog.status === 'generating'}
|
||||
onClick={() => onRequestUpload('character-reference')}
|
||||
onClick={() =>
|
||||
setIsCharacterReferenceMenuOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -615,6 +777,38 @@ export function ImageCanvasGenerationComposerView({
|
||||
上传常规参考图
|
||||
</span>
|
||||
</button>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -657,36 +851,12 @@ export function ImageCanvasGenerationComposerView({
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
画面比例
|
||||
</PlatformFieldLabel>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={generateDialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('角色比例')}
|
||||
>
|
||||
1:1
|
||||
</PlatformInlineOptionButton>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
模型
|
||||
</PlatformFieldLabel>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-model"
|
||||
disabled={generateDialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('角色模型')}
|
||||
>
|
||||
GPT Image
|
||||
</PlatformInlineOptionButton>
|
||||
</div>
|
||||
{renderImageOptionButtons({
|
||||
dialog: generateDialog,
|
||||
setGenerateDialog,
|
||||
includeDimensions: true,
|
||||
onRememberImageModel,
|
||||
})}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
@@ -764,10 +934,10 @@ export function ImageCanvasGenerationComposerView({
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="图标素材规范来源"
|
||||
placement="bottom-start"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
iconSpecButtonRef.current,
|
||||
'below',
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
@@ -897,21 +1067,12 @@ export function ImageCanvasGenerationComposerView({
|
||||
>
|
||||
添加素材描述
|
||||
</button>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
模型
|
||||
</PlatformFieldLabel>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-model"
|
||||
disabled={generateDialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('图标模型')}
|
||||
>
|
||||
nanobanana2
|
||||
</PlatformInlineOptionButton>
|
||||
</div>
|
||||
{renderImageOptionButtons({
|
||||
dialog: generateDialog,
|
||||
setGenerateDialog,
|
||||
includeDimensions: true,
|
||||
onRememberImageModel,
|
||||
})}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
@@ -932,6 +1093,11 @@ export function ImageCanvasGenerationComposerView({
|
||||
请选择画布中的图片作为角色形象规范,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingCharacterReferenceFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图片作为常规参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingIconSpecFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图标素材规范,按 Esc 退出
|
||||
|
||||
@@ -104,10 +104,46 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
.toBe('自定义');
|
||||
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
|
||||
{ label: 'nano-banana', value: 'nano-banana' },
|
||||
{ label: 'GPT Image', value: DEFAULT_IMAGE_MODEL },
|
||||
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds reference image semantics and snapshots for spec generation references', () => {
|
||||
const prompt = buildSpecPrompt(
|
||||
'ui',
|
||||
{
|
||||
playSetting: '消除玩法',
|
||||
artStyle: '清爽卡通',
|
||||
bodyRatio: '3',
|
||||
characterView: '',
|
||||
customPrompt: '',
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(prompt).toContain('参考图生成规范');
|
||||
expect(prompt).toContain('参考图1');
|
||||
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
|
||||
expect(
|
||||
buildSpecPrompt(
|
||||
'custom',
|
||||
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
|
||||
true,
|
||||
),
|
||||
).toContain('生成一张怪兽规范图');
|
||||
expect(
|
||||
buildSpecGenerationInputs(
|
||||
'custom',
|
||||
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
|
||||
{ id: 'spec-ref', label: '参考.png', src: '/ref.png' },
|
||||
),
|
||||
).toEqual({
|
||||
fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }],
|
||||
references: [{ title: '参考图', label: '参考.png', src: '/ref.png' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses objectKey for character animation references before falling back to src', () => {
|
||||
expect(
|
||||
resolveCharacterAnimationSourceImageSrc({
|
||||
|
||||
@@ -23,7 +23,9 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
|
||||
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
|
||||
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
|
||||
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
|
||||
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
|
||||
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 ICON_DESCRIPTION_LIMIT = 100;
|
||||
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
|
||||
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
|
||||
@@ -44,8 +46,20 @@ export const QUICK_EDIT_SIZE_PRESETS = [
|
||||
'1024x1536',
|
||||
] as const;
|
||||
export const QUICK_EDIT_MODEL_OPTIONS = [
|
||||
{ label: 'GPT Image', value: DEFAULT_IMAGE_MODEL },
|
||||
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
|
||||
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
] as const;
|
||||
export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS;
|
||||
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
|
||||
imageSizes: ['0.5K', '1K', '2K'],
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
|
||||
imageSizes: ['1K', '2K'],
|
||||
},
|
||||
} as const;
|
||||
export const CHARACTER_ANIMATION_MODEL = 'seedance2.0';
|
||||
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
||||
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
||||
@@ -169,17 +183,23 @@ export function buildIconSpecPrompt(values: SpecFormValues) {
|
||||
export function buildSpecPrompt(
|
||||
type: SpecGenerationType,
|
||||
values: SpecFormValues,
|
||||
hasReferenceImage = false,
|
||||
) {
|
||||
if (type === 'character') {
|
||||
return buildCharacterSpecPrompt(values);
|
||||
const prompt =
|
||||
type === 'character'
|
||||
? buildCharacterSpecPrompt(values)
|
||||
: type === 'ui'
|
||||
? buildUiSpecPrompt(values)
|
||||
: type === 'icon'
|
||||
? buildIconSpecPrompt(values)
|
||||
: values.customPrompt.trim();
|
||||
if (!hasReferenceImage) {
|
||||
return prompt;
|
||||
}
|
||||
if (type === 'ui') {
|
||||
return buildUiSpecPrompt(values);
|
||||
}
|
||||
if (type === 'icon') {
|
||||
return buildIconSpecPrompt(values);
|
||||
}
|
||||
return values.customPrompt.trim();
|
||||
return [
|
||||
'参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
|
||||
prompt,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function getLayerKindLabel(layer: CanvasLayer) {
|
||||
@@ -254,11 +274,15 @@ export function buildImageGenerationInputs(prompt: string): CanvasGenerationInpu
|
||||
export function buildSpecGenerationInputs(
|
||||
specType: SpecGenerationType,
|
||||
values: SpecFormValues,
|
||||
reference?: CharacterReferenceImage | null,
|
||||
): CanvasGenerationInputs {
|
||||
const references = reference
|
||||
? [{ title: '参考图', label: reference.label, src: reference.src }]
|
||||
: [];
|
||||
if (specType === 'custom') {
|
||||
return {
|
||||
fields: createGenerationInputField('自定义规范提示词', values.customPrompt),
|
||||
references: [],
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +298,7 @@ export function buildSpecGenerationInputs(
|
||||
}
|
||||
return {
|
||||
fields: baseFields,
|
||||
references: [],
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
@@ -154,10 +155,16 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] =
|
||||
useState(false);
|
||||
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
] = useState(false);
|
||||
const [
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
] = useState(false);
|
||||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
|
||||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||||
useState(false);
|
||||
@@ -165,6 +172,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
useState<CharacterAnimationPanelState | null>(null);
|
||||
const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL);
|
||||
|
||||
const quickEditSourceLayer = quickEditPanel
|
||||
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
||||
@@ -278,7 +286,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const openCharacterGenerationDialog = useCallback(() => {
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
|
||||
openCanvasGenerationDialog({
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
@@ -286,6 +300,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
composerOpen: true,
|
||||
characterSpecReference: null,
|
||||
characterReferences: [],
|
||||
imageModel: lastImageModel,
|
||||
aspectRatio: dimensionOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||||
dimensionOptions.imageSizes[0],
|
||||
placeholder: {
|
||||
x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2,
|
||||
y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2,
|
||||
@@ -300,6 +319,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setQuickEditPanel(null);
|
||||
}, [
|
||||
canvasSize,
|
||||
lastImageModel,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
@@ -309,8 +329,14 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const openIconGenerationDialog = useCallback(() => {
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
|
||||
openCanvasGenerationDialog({
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
@@ -318,6 +344,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
composerOpen: true,
|
||||
iconSpecReference: null,
|
||||
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
|
||||
imageModel: lastImageModel,
|
||||
aspectRatio: dimensionOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||||
dimensionOptions.imageSizes[0],
|
||||
placeholder: {
|
||||
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
|
||||
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
|
||||
@@ -333,6 +364,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setCharacterAnimationPanel(null);
|
||||
}, [
|
||||
canvasSize,
|
||||
lastImageModel,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
@@ -543,6 +575,26 @@ export function useImageCanvasGenerationWorkflow({
|
||||
[setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const pickCharacterReferenceFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...setFailedCharacterGenerationIdle(currentDialog),
|
||||
characterReferences: [
|
||||
...(currentDialog.characterReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
composerOpen: true,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setImageContextMenu(null);
|
||||
},
|
||||
[setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const pickIconSpecFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (layer.assetKind !== 'icon-spec') {
|
||||
@@ -654,7 +706,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const generated = await generateEditorIconSpritesheet({
|
||||
referenceImageSrc: dialog.iconSpecReference.src,
|
||||
iconDescriptions,
|
||||
model: dialog.imageModel ?? DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
});
|
||||
setLastImageModel(dialog.imageModel ?? DEFAULT_IMAGE_MODEL);
|
||||
addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
generated.iconImageSrcs,
|
||||
@@ -795,8 +851,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const generated = await generateEditorImage({
|
||||
prompt: normalizedPrompt,
|
||||
kind: 'character',
|
||||
model: dialog.imageModel ?? DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
});
|
||||
setLastImageModel(dialog.imageModel ?? DEFAULT_IMAGE_MODEL);
|
||||
addGeneratedResultLayer(generated, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
assetKind: 'character',
|
||||
@@ -1021,8 +1081,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setIsSpecMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
isIconSpecMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
isPickingIconSpecFromCanvas,
|
||||
@@ -1035,6 +1099,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openEditDialog,
|
||||
openQuickEditPanel,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
submitIconSpritesheetGeneration,
|
||||
submitQuickEdit,
|
||||
@@ -1043,6 +1108,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
updateIconDescription,
|
||||
addIconDescription,
|
||||
updateCharacterAnimationDuration,
|
||||
rememberImageModel: setLastImageModel,
|
||||
submitCharacterAnimation,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
closeGenerateComposer,
|
||||
@@ -1057,8 +1123,10 @@ export function useImageCanvasGenerationWorkflow({
|
||||
closeGenerateComposer,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
iconDescriptionValues,
|
||||
isCharacterReferenceMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
isIconSpecMenuOpen,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isSpecMenuOpen,
|
||||
@@ -1069,6 +1137,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openIconGenerationDialog,
|
||||
openQuickEditPanel,
|
||||
openSpecDialog,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
quickEditModelOptions,
|
||||
|
||||
@@ -79,8 +79,14 @@ function KeyboardShortcutsHarness({
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(true);
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true);
|
||||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true);
|
||||
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
|
||||
useState(true);
|
||||
const [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] =
|
||||
useState(true);
|
||||
const [
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
] = useState(true);
|
||||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true);
|
||||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||||
useState(true);
|
||||
@@ -111,7 +117,9 @@ function KeyboardShortcutsHarness({
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
@@ -138,9 +146,15 @@ function KeyboardShortcutsHarness({
|
||||
<span data-testid="character-menu">
|
||||
{String(isCharacterSpecMenuOpen)}
|
||||
</span>
|
||||
<span data-testid="character-reference-menu">
|
||||
{String(isCharacterReferenceMenuOpen)}
|
||||
</span>
|
||||
<span data-testid="character-picking">
|
||||
{String(isPickingCharacterSpecFromCanvas)}
|
||||
</span>
|
||||
<span data-testid="character-reference-picking">
|
||||
{String(isPickingCharacterReferenceFromCanvas)}
|
||||
</span>
|
||||
<span data-testid="icon-menu">{String(isIconSpecMenuOpen)}</span>
|
||||
<span data-testid="icon-picking">
|
||||
{String(isPickingIconSpecFromCanvas)}
|
||||
|
||||
@@ -31,7 +31,9 @@ type UseImageCanvasKeyboardShortcutsOptions = {
|
||||
closeEditorChromePanels: () => void;
|
||||
setIsSpecMenuOpen: (open: boolean) => void;
|
||||
setIsCharacterSpecMenuOpen: (open: boolean) => void;
|
||||
setIsCharacterReferenceMenuOpen: (open: boolean) => void;
|
||||
setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void;
|
||||
setIsPickingCharacterReferenceFromCanvas: (picking: boolean) => void;
|
||||
setIsIconSpecMenuOpen: (open: boolean) => void;
|
||||
setIsPickingIconSpecFromCanvas: (picking: boolean) => void;
|
||||
setIsSpacePanning: (panning: boolean) => void;
|
||||
@@ -77,7 +79,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
@@ -93,7 +97,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
currentPanel?.status === 'generating' ? currentPanel : null,
|
||||
);
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
setGenerateDialog((currentDialog) => {
|
||||
@@ -149,7 +155,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setGenerateDialog(null);
|
||||
setActiveTool('select');
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
return;
|
||||
@@ -193,7 +201,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setGenerateDialog,
|
||||
setImageContextMenu,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
|
||||
@@ -175,6 +175,7 @@ function StageInteractionsHarness({
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
isPickingCharacterSpecFromCanvas: false,
|
||||
isPickingCharacterReferenceFromCanvas: false,
|
||||
isPickingIconSpecFromCanvas: false,
|
||||
clearCanvasFocus: () => {
|
||||
setSelectedLayerId(null);
|
||||
@@ -182,6 +183,7 @@ function StageInteractionsHarness({
|
||||
setClearCount((currentCount) => currentCount + 1);
|
||||
},
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer: vi.fn(),
|
||||
pickIconSpecFromLayer,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: (dialogId, updater) => {
|
||||
|
||||
@@ -45,9 +45,11 @@ type UseImageCanvasStageInteractionsOptions = {
|
||||
generateDialog: GenerateDialogState | null;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
isPickingCharacterSpecFromCanvas: boolean;
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
clearCanvasFocus: () => void;
|
||||
pickCharacterSpecFromLayer: (layer: CanvasLayer) => void;
|
||||
pickCharacterReferenceFromLayer: (layer: CanvasLayer) => void;
|
||||
pickIconSpecFromLayer: (layer: CanvasLayer) => void;
|
||||
activateCanvasGenerationDialog: (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
@@ -126,9 +128,11 @@ export function useImageCanvasStageInteractions({
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
clearCanvasFocus,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
@@ -139,6 +143,7 @@ export function useImageCanvasStageInteractions({
|
||||
}: UseImageCanvasStageInteractionsOptions) {
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const suppressNextLayerClickRef = useRef(false);
|
||||
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||
null,
|
||||
);
|
||||
@@ -230,12 +235,24 @@ export function useImageCanvasStageInteractions({
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressNextLayerClickRef.current = true;
|
||||
pickCharacterSpecFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isPickingCharacterReferenceFromCanvas &&
|
||||
generateDialog?.mode === 'character'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressNextLayerClickRef.current = true;
|
||||
pickCharacterReferenceFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressNextLayerClickRef.current = true;
|
||||
pickIconSpecFromLayer(layer);
|
||||
return;
|
||||
}
|
||||
@@ -302,8 +319,10 @@ export function useImageCanvasStageInteractions({
|
||||
effectiveTool,
|
||||
generateDialog?.mode,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
layers,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
selectedLayerIds,
|
||||
@@ -320,9 +339,16 @@ export function useImageCanvasStageInteractions({
|
||||
// 测试环境和辅助技术可能只触发 click;
|
||||
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
|
||||
event.stopPropagation();
|
||||
if (suppressNextLayerClickRef.current) {
|
||||
suppressNextLayerClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isPickingCharacterSpecFromCanvas) {
|
||||
return;
|
||||
}
|
||||
if (isPickingCharacterReferenceFromCanvas) {
|
||||
return;
|
||||
}
|
||||
if (isPickingIconSpecFromCanvas) {
|
||||
return;
|
||||
}
|
||||
@@ -346,6 +372,7 @@ export function useImageCanvasStageInteractions({
|
||||
},
|
||||
[
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
onCloseImageContextMenu,
|
||||
setGenerateDialog,
|
||||
|
||||
Reference in New Issue
Block a user