调整图片编辑器参考图选择交互

- 常规参考图入口改为先弹出来源菜单,支持从画布选择和上传图片。

- 角色规范、图标规范和常规参考图来源菜单统一向上弹出。

- 画布参考图选择拦截普通图层选中逻辑,保持生成面板不隐藏。

- 补充图片编辑器交互测试与技术文档说明。
This commit is contained in:
2026-06-17 14:08:26 +08:00
parent d0ad8402de
commit e970d34574
12 changed files with 602 additions and 76 deletions

View File

@@ -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';

View File

@@ -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,15 +3062,104 @@ 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(
expect.objectContaining({
aspectRatio: expect.any(String),
imageSize: expect.any(String),
}),
});
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({
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', () => {
@@ -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(() => {

View File

@@ -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>

View File

@@ -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 退

View File

@@ -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({

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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)}

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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,