Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
高物 05a47816b0 支持规范参考图输入
为角色形象规范、UI素材规范、自定义规范面板新增参考图上传入口。

生成规范时携带参考图并自动追加参考图生成规范语义。

补充生成流程和上传流程回归测试。

更新画板角色形象生成入口设计文档。
2026-06-17 14:20:23 +08:00

1516 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ChevronDown,
ClipboardList,
ImageIcon,
ImagePlus,
X,
} from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { createPortal } from 'react-dom';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import {
CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
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';
import type {
CharacterAnimationPanelState,
CanvasLayer,
GenerateDialogState,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
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;
iconComposerStyle: CSSProperties | null;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditPanelStyle: CSSProperties | null;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
characterAnimationPanelStyle: CSSProperties | null;
characterAnimationPrice: number;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
setCharacterAnimationPanel: Dispatch<
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;
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
onSubmitQuickEdit: () => void;
onSubmitCharacterAnimation: () => void;
onCloseGenerateComposer: () => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescription: (index: number, value: string) => void;
onAddIconDescription: () => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
onRememberImageModel: (model: string) => void;
};
function triggerPlaceholderAction(label: string) {
window.alert(`${label}功能建设中`);
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
): CSSProperties {
const rect = anchor?.getBoundingClientRect();
if (!rect) {
return {
position: 'fixed',
left: 0,
top: 0,
right: 'auto',
bottom: 'auto',
zIndex: 70,
};
}
return {
position: 'fixed',
left: Math.round(rect.left),
top:
placement === 'above'
? Math.round(rect.top)
: Math.round(rect.bottom + 8),
right: 'auto',
bottom: 'auto',
zIndex: 70,
transform:
placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
};
}
function renderEditorPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
return createPortal(node, document.body);
}
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function resetFailedPanelStatus<T extends { status: string; errorMessage?: string }>(
panel: T,
) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
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,
iconComposerStyle,
quickEditPanel,
quickEditSourceLayer,
quickEditPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
characterAnimationPrice,
setGenerateDialog,
setQuickEditPanel,
setCharacterAnimationPanel,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCharacterAnimation,
onCloseGenerateComposer,
onUpdateSpecFormValue,
onUpdateIconDescription,
onAddIconDescription,
onUpdateCharacterAnimationDuration,
onRememberImageModel,
}: ImageCanvasGenerationComposerViewProps) {
return (
<>
{isSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="生成规范类型"
placement="top-start"
style={buildPortalMenuStyle(specToolWrapRef.current, 'above')}
>
{(['character', 'ui', 'custom'] as const).map((specType) => (
<PlatformFloatingMenuItem
key={specType}
className="image-canvas-editor__spec-menu-item"
onClick={() => onOpenSpecDialog(specType)}
>
{SPEC_TYPE_LABEL[specType]}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{generateDialog?.mode === 'generate' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<form
className="image-canvas-editor__generation-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
onSubmitImageGeneration(generateDialog);
}
}}
>
<PlatformIconButton
variant="surfaceFloating"
className="image-canvas-editor__generation-ref"
label="添加参考图"
disabled={generateDialog.status === 'generating'}
onClick={() => onRequestUpload('asset')}
icon={<ImageIcon className="h-4 w-4" />}
>
<span></span>
</PlatformIconButton>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
placeholder="今天我们要创作什么"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
}
: currentDialog,
)
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
aria-label="生成比例 1:1 2k 1张"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('生成参数')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
· 1:1(2k) · 1
</PlatformInlineOptionButton>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
aria-label="生成模型 GPT Image"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('模型选择')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
GPT Im...
</PlatformInlineOptionButton>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
aria-label="生成"
>
{generateDialog.status === 'generating' ? '生成中' : '12'}
</PlatformActionButton>
</div>
{generateDialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
</PlatformStatusMessage>
) : null}
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<EditorIconButton
className="image-canvas-editor__generation-close"
label="关闭生成图片"
icon={X}
variant="surfaceFloating"
disabled={generateDialog.status === 'generating'}
onClick={onCloseGenerateComposer}
/>
</form>
) : null}
{generateDialog?.mode === 'spec' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__spec-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成规范"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
onSubmitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__spec-fields">
{generateDialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="自定义规范提示词"
value={generateDialog.specValues?.customPrompt ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
onChange={(event) =>
onUpdateSpecFormValue('customPrompt', event.target.value)
}
/>
</label>
) : (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label="玩法设定"
value={generateDialog.specValues?.playSetting ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('playSetting', event.target.value)
}
/>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label="美术风格"
value={generateDialog.specValues?.artStyle ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('artStyle', event.target.value)
}
/>
</label>
{generateDialog.specType === 'character' ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformSelectField
aria-label="头身比"
value={generateDialog.specValues?.bodyRatio ?? '3'}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('bodyRatio', event.target.value)
}
>
{['2', '3', '4', '5', '6'].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformSelectField
aria-label="角色视角"
value={generateDialog.specValues?.characterView ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue(
'characterView',
event.target.value,
)
}
>
{CHARACTER_SPEC_VIEW_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
</>
) : null}
</>
)}
{generateDialog.specType !== 'icon' ? (
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<button
type="button"
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
disabled={generateDialog.status === 'generating'}
onClick={() => onRequestUpload('spec-reference')}
>
<span className="image-canvas-editor__reference-tile-visual">
{generateDialog.specReference ? (
<img
src={generateDialog.specReference.src}
alt=""
aria-hidden="true"
/>
) : (
<ImagePlus className="h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="image-canvas-editor__reference-tile-copy">
{generateDialog.specReference?.label ?? '添加参考图'}
</span>
</button>
</div>
) : null}
</div>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__spec-footer">
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__spec-submit"
disabled={generateDialog.status === 'generating'}
aria-label="提交生成规范"
>
{generateDialog.status === 'generating'
? '生成中'
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
</PlatformActionButton>
</div>
</form>
) : null}
{generateDialog?.mode === 'character' && generationComposerStyle ? (
<form
className="image-canvas-editor__character-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成角色形象"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
onSubmitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__character-reference-row">
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<span className="image-canvas-editor__character-spec-wrap">
<button
ref={characterSpecButtonRef}
type="button"
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
disabled={generateDialog.status === 'generating'}
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__reference-tile-visual">
{generateDialog.characterSpecReference ? (
<img
src={generateDialog.characterSpecReference.src}
alt=""
aria-hidden="true"
/>
) : (
<ClipboardList className="h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="image-canvas-editor__reference-tile-copy">
{generateDialog.characterSpecReference?.label ??
'角色形象规范'}
</span>
</button>
</span>
</div>
{isCharacterSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="角色形象规范来源"
placement="top-start"
style={buildPortalMenuStyle(
characterSpecButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingCharacterSpecFromCanvas(true);
setIsCharacterSpecMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsCharacterSpecMenuOpen(false);
onOpenSpecDialog('character');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsCharacterSpecMenuOpen(false);
onRequestUpload('character-spec');
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__character-reference-list">
{(generateDialog.characterReferences ?? []).map(
(reference, index) => (
<span
key={reference.id}
className="image-canvas-editor__character-ref-thumb"
title={reference.label}
>
<img src={reference.src} alt={reference.label} />
<span className="image-canvas-editor__character-ref-index">
{index + 1}
</span>
</span>
),
)}
<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={() =>
setIsCharacterReferenceMenuOpen((open) => !open)
}
>
<span className="image-canvas-editor__reference-tile-visual">
<ImagePlus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-tile-copy">
</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>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="角色设定"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
}
: currentDialog,
)
}
/>
</label>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{renderImageOptionButtons({
dialog: generateDialog,
setGenerateDialog,
includeDimensions: true,
onRememberImageModel,
})}
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
>
{generateDialog.status === 'generating' ? '生成中' : '生成'}
</PlatformActionButton>
</div>
</form>
) : null}
{generateDialog?.mode === 'icon' &&
generateDialog.composerOpen !== false &&
iconComposerStyle ? (
<form
className="image-canvas-editor__icon-composer"
style={iconComposerStyle}
role="dialog"
aria-label="生成图标素材"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
onSubmitIconSpritesheetGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__icon-spec-row">
<span className="image-canvas-editor__character-spec-wrap">
<button
ref={iconSpecButtonRef}
type="button"
className="image-canvas-editor__icon-spec-card"
disabled={generateDialog.status === 'generating'}
aria-label={
generateDialog.iconSpecReference?.label ?? '图标素材规范'
}
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
>
<span
className="image-canvas-editor__icon-spec-preview"
aria-hidden="true"
>
{generateDialog.iconSpecReference?.src ? (
<img src={generateDialog.iconSpecReference.src} alt="" />
) : (
<ImageIcon className="h-5 w-5" />
)}
</span>
<span className="image-canvas-editor__icon-spec-copy">
<span className="image-canvas-editor__icon-spec-eyebrow">
</span>
<span className="image-canvas-editor__icon-spec-title">
{generateDialog.iconSpecReference?.label ?? '待选择'}
</span>
</span>
<span className="image-canvas-editor__icon-spec-state">
{generateDialog.iconSpecReference ? '已绑定' : '待绑定'}
</span>
</button>
</span>
{isIconSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标素材规范来源"
placement="top-start"
style={buildPortalMenuStyle(
iconSpecButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingIconSpecFromCanvas(true);
setIsIconSpecMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsIconSpecMenuOpen(false);
onOpenSpecDialog('icon');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsIconSpecMenuOpen(false);
onRequestUpload('icon-spec');
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
<div
className="image-canvas-editor__icon-spec-actions"
aria-label="图标素材规范操作"
>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setIsPickingIconSpecFromCanvas(true);
setIsIconSpecMenuOpen(false);
}}
>
</button>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setIsIconSpecMenuOpen(false);
onOpenSpecDialog('icon');
}}
>
</button>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setIsIconSpecMenuOpen(false);
onRequestUpload('icon-spec');
}}
>
</button>
</div>
</div>
</div>
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__icon-description-list">
{(generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).map(
(description, index) => (
<label
key={index}
className="image-canvas-editor__icon-description-card"
>
<span className="image-canvas-editor__icon-description-index">
{String(index + 1).padStart(2, '0')}
</span>
<span className="image-canvas-editor__icon-description-title">
{index + 1}
</span>
<PlatformTextField
aria-label={`素材描述${index + 1}`}
value={description}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__icon-description-input"
onChange={(event) =>
onUpdateIconDescription(index, event.target.value)
}
/>
</label>
),
)}
</div>
</div>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__icon-footer">
<button
type="button"
className="image-canvas-editor__character-reference-add"
disabled={
generateDialog.status === 'generating' ||
(generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
.length >= ICON_DESCRIPTION_LIMIT
}
onClick={onAddIconDescription}
>
</button>
{renderImageOptionButtons({
dialog: generateDialog,
setGenerateDialog,
includeDimensions: true,
onRememberImageModel,
})}
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
aria-label="生成"
>
{generateDialog.status === 'generating' ? '生成中' : '生成'}
</PlatformActionButton>
</div>
</form>
) : null}
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
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 退
</div>
) : null}
{quickEditPanel &&
quickEditPanel.status !== 'generating' &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
<form
className="image-canvas-editor__quick-edit-panel"
style={quickEditPanelStyle}
role="dialog"
aria-label="快速编辑图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
onSubmitQuickEdit();
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img
src={quickEditSourceLayer.src}
alt={`${quickEditSourceLayer.title}参考图`}
/>
<span>{quickEditSourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭快速编辑图片"
title="关闭"
icon={X}
onClick={() => setQuickEditPanel(null)}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label="快速编辑提示词"
value={quickEditPanel.prompt}
size="sm"
density="compact"
className="image-canvas-editor__quick-edit-prompt"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
prompt: event.target.value,
}
: currentPanel,
)
}
/>
<div className="image-canvas-editor__quick-edit-controls">
<PlatformSelectField
aria-label="快速编辑尺寸"
value={quickEditPanel.size}
size="xs"
density="compact"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? { ...currentPanel, size: event.target.value }
: currentPanel,
)
}
>
{quickEditSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</PlatformSelectField>
<PlatformSelectField
aria-label="快速编辑模型"
value={quickEditPanel.model}
size="xs"
density="compact"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? { ...currentPanel, model: event.target.value }
: currentPanel,
)
}
>
{quickEditModelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</PlatformSelectField>
</div>
{quickEditPanel.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
role="alert"
>
{quickEditPanel.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
>
</PlatformActionButton>
</form>
) : null}
{characterAnimationPanel &&
characterAnimationSourceLayer &&
characterAnimationPanelStyle ? (
<form
className="image-canvas-editor__character-animation-panel"
style={characterAnimationPanelStyle}
role="dialog"
aria-label="角色动画生成面板"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (characterAnimationPanel.status !== 'generating') {
onSubmitCharacterAnimation();
}
}}
>
<div className="image-canvas-editor__character-animation-head">
<strong></strong>
<EditorIconButton
label="关闭角色动画生成面板"
title="关闭"
icon={X}
onClick={() => setCharacterAnimationPanel(null)}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label="动画描述"
value={characterAnimationPanel.promptText}
maxLength={4000}
disabled={characterAnimationPanel.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__character-animation-textarea"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
promptText: event.target.value.slice(0, 4000),
}
: currentPanel,
)
}
/>
<div className="image-canvas-editor__character-animation-presets">
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
<button
key={preset.label}
type="button"
className="image-canvas-editor__character-animation-preset"
disabled={characterAnimationPanel.status === 'generating'}
onClick={() =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
promptText: preset.text,
status: 'idle',
errorMessage: undefined,
}
: currentPanel,
)
}
>
{preset.label}
</button>
))}
</div>
<div className="image-canvas-editor__character-animation-grid">
<PlatformSelectField
aria-label="分辨率"
value={characterAnimationPanel.resolution}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
resolution:
event.target.value === '720p' ? '720p' : '480p',
}
: currentPanel,
)
}
>
<option value="480p">480p</option>
<option value="720p">720p</option>
</PlatformSelectField>
<PlatformSelectField
aria-label="画面比例"
value={characterAnimationPanel.ratio}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
ratio:
CHARACTER_ANIMATION_RATIO_OPTIONS.find(
(item) => item.value === event.target.value,
)?.value ?? 'same',
}
: currentPanel,
)
}
>
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</PlatformSelectField>
<PlatformSelectField
aria-label="时长"
value={String(characterAnimationPanel.frameCount)}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
onUpdateCharacterAnimationDuration(event.target.value)
}
>
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
<option
key={option.frameCount}
value={String(option.frameCount)}
>
{option.label}
</option>
))}
</PlatformSelectField>
</div>
<div className="image-canvas-editor__character-animation-summary">
<span
className="image-canvas-editor__character-animation-summary-text"
title={characterAnimationPanel.promptText.trim() || undefined}
aria-label={`生成文本:${
characterAnimationPanel.promptText.trim() || '动画描述'
}`}
>
{characterAnimationPanel.promptText.trim()
? characterAnimationPanel.promptText.trim()
: '动画描述'}
</span>
<strong>{characterAnimationPrice}</strong>
</div>
{characterAnimationPanel.status === 'completed' &&
characterAnimationPanel.result ? (
<PlatformStatusMessage
tone="success"
surface="platform"
size="xs"
role="status"
>
{characterAnimationPanel.result.frameCount}
</PlatformStatusMessage>
) : null}
{characterAnimationPanel.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
role="alert"
>
{characterAnimationPanel.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__character-animation-submit"
disabled={characterAnimationPanel.status === 'generating'}
>
{characterAnimationPanel.status === 'generating'
? '生成中'
: '生成'}
</PlatformActionButton>
</form>
) : null}
<UnifiedModal
open={
generateDialog?.mode === 'edit' &&
generateDialog.status !== 'generating'
}
title={generateDialog?.mode === 'edit' ? '修改图片' : '生成图片'}
size="sm"
closeLabel={
generateDialog?.mode === 'edit' ? '关闭修改图片' : '关闭生成图片'
}
closeDisabled={generateDialog?.status === 'generating'}
onClose={() => setGenerateDialog(null)}
panelClassName="image-canvas-editor__generate-dialog"
bodyClassName="image-canvas-editor__generate-dialog-body"
>
{generateDialog?.mode === 'edit' ? (
<form
className="image-canvas-editor__generate-form"
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
onSubmitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__generate-body">
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
size="sm"
density="roomy"
className="image-canvas-editor__generate-prompt"
placeholder="描述你想如何修改这张图片"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
}
: currentDialog,
)
}
/>
{generateDialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
</PlatformStatusMessage>
) : null}
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
size="sm"
className="image-canvas-editor__generate-submit"
disabled={generateDialog.status === 'generating'}
>
{generateDialog.status === 'generating' ? '修改中' : '修改'}
</PlatformActionButton>
</div>
</form>
) : null}
</UnifiedModal>
</>
);
}