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

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

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

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

- 补充图片编辑器交互测试与技术文档说明。
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

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