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; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; 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>; setQuickEditPanel: Dispatch>; setCharacterAnimationPanel: Dispatch< SetStateAction >; setIsCharacterSpecMenuOpen: Dispatch>; setIsCharacterReferenceMenuOpen: Dispatch>; setIsIconSpecMenuOpen: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; 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( 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>; includeDimensions: boolean; onRememberImageModel: (model: string) => void; }) { const selection = normalizeImageDialogSelection(dialog); const updateDialog = (patch: Partial) => { setGenerateDialog((currentDialog) => currentDialog && currentDialog.mode === dialog.mode ? { ...resetFailedDialogStatus(currentDialog), ...patch, } : currentDialog, ); }; return ( <> {includeDimensions ? ( <>
画面比例
{selection.options.aspectRatios.map((aspectRatio) => ( updateDialog({ aspectRatio })} > {aspectRatio} ))}
大小尺寸
{selection.options.imageSizes.map((imageSize) => ( updateDialog({ imageSize })} > {imageSize} ))}
) : null}
模型
{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 ( { 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} ); })}
); } 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( {(['character', 'ui', 'custom'] as const).map((specType) => ( onOpenSpecDialog(specType)} > {SPEC_TYPE_LABEL[specType]} ))} , ) : null} {generateDialog?.mode === 'generate' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { onSubmitImageGeneration(generateDialog); } }} > onRequestUpload('asset')} icon={} > 参考图 setGenerateDialog((currentDialog) => currentDialog ? { ...resetFailedDialogStatus(currentDialog), prompt: event.target.value, } : currentDialog, ) } />
triggerPlaceholderAction('生成参数')} trailingIcon={} > 中 · 1:1(2k) · 1张 triggerPlaceholderAction('模型选择')} trailingIcon={} > GPT Im... {generateDialog.status === 'generating' ? '生成中' : '12'}
{generateDialog.status === 'generating' ? ( 生成中 ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} ) : null} {generateDialog?.mode === 'spec' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { onSubmitImageGeneration(generateDialog); } }} >
{generateDialog.specType === 'custom' ? ( ) : ( <> {generateDialog.specType === 'character' ? ( <> ) : null} )} {generateDialog.specType !== 'icon' ? (
参考图
) : null}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
{generateDialog.status === 'generating' ? '生成中' : `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
) : null} {generateDialog?.mode === 'character' && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { onSubmitImageGeneration(generateDialog); } }} >
角色形象规范
{isCharacterSpecMenuOpen ? renderEditorPortal( { setIsPickingCharacterSpecFromCanvas(true); setIsCharacterSpecMenuOpen(false); }} > 从画布中选择 { setIsCharacterSpecMenuOpen(false); onOpenSpecDialog('character'); }} > 新建角色形象规范 { setIsCharacterSpecMenuOpen(false); onRequestUpload('character-spec'); }} > 上传图片 , ) : null}
常规参考图
{(generateDialog.characterReferences ?? []).map( (reference, index) => ( {reference.label} {index + 1} ), )} {isCharacterReferenceMenuOpen ? renderEditorPortal( { setIsPickingCharacterReferenceFromCanvas(true); setIsCharacterReferenceMenuOpen(false); }} > 从画布中选择 { setIsCharacterReferenceMenuOpen(false); onRequestUpload('character-reference'); }} > 上传图片 , ) : null}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
{renderImageOptionButtons({ dialog: generateDialog, setGenerateDialog, includeDimensions: true, onRememberImageModel, })} {generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {generateDialog?.mode === 'icon' && generateDialog.composerOpen !== false && iconComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { onSubmitIconSpritesheetGeneration(generateDialog); } }} >
图标素材规范
{isIconSpecMenuOpen ? renderEditorPortal( { setIsPickingIconSpecFromCanvas(true); setIsIconSpecMenuOpen(false); }} > 从画布中选择 { setIsIconSpecMenuOpen(false); onOpenSpecDialog('icon'); }} > 新建图标素材规范 { setIsIconSpecMenuOpen(false); onRequestUpload('icon-spec'); }} > 上传图片 , ) : null}
素材描述
{(generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).map( (description, index) => ( ), )}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
{renderImageOptionButtons({ dialog: generateDialog, setGenerateDialog, includeDimensions: true, onRememberImageModel, })} {generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {isPickingCharacterSpecFromCanvas ? (
请选择画布中的图片作为角色形象规范,按 Esc 退出
) : null} {isPickingCharacterReferenceFromCanvas ? (
请选择画布中的图片作为常规参考图,按 Esc 退出
) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标素材规范,按 Esc 退出
) : null} {quickEditPanel && quickEditPanel.status !== 'generating' && quickEditSourceLayer && quickEditPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); onSubmitQuickEdit(); }} >
{`${quickEditSourceLayer.title}参考图`} {quickEditSourceLayer.title}
setQuickEditPanel(null)} />
setQuickEditPanel((currentPanel) => currentPanel ? { ...resetFailedPanelStatus(currentPanel), prompt: event.target.value, } : currentPanel, ) } />
setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, size: event.target.value } : currentPanel, ) } > {quickEditSizeOptions.map((size) => ( ))} setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, model: event.target.value } : currentPanel, ) } > {quickEditModelOptions.map((option) => ( ))}
{quickEditPanel.status === 'failed' ? ( {quickEditPanel.errorMessage} ) : null} 生成 ) : null} {characterAnimationPanel && characterAnimationSourceLayer && characterAnimationPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (characterAnimationPanel.status !== 'generating') { onSubmitCharacterAnimation(); } }} >
角色动画 setCharacterAnimationPanel(null)} />
setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...resetFailedPanelStatus(currentPanel), promptText: event.target.value.slice(0, 4000), } : currentPanel, ) } />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( ))}
setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...resetFailedPanelStatus(currentPanel), resolution: event.target.value === '720p' ? '720p' : '480p', } : currentPanel, ) } > 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) => ( ))} onUpdateCharacterAnimationDuration(event.target.value) } > {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( ))}
{characterAnimationPanel.promptText.trim() ? characterAnimationPanel.promptText.trim() : '动画描述'} {characterAnimationPrice}泥点
{characterAnimationPanel.status === 'completed' && characterAnimationPanel.result ? ( 已生成 {characterAnimationPanel.result.frameCount} 帧 ) : null} {characterAnimationPanel.status === 'failed' ? ( {characterAnimationPanel.errorMessage} ) : null} {characterAnimationPanel.status === 'generating' ? '生成中' : '生成'} ) : null} setGenerateDialog(null)} panelClassName="image-canvas-editor__generate-dialog" bodyClassName="image-canvas-editor__generate-dialog-body" > {generateDialog?.mode === 'edit' ? (
{ event.preventDefault(); if (generateDialog.status !== 'generating') { onSubmitImageGeneration(generateDialog); } }} >
setGenerateDialog((currentDialog) => currentDialog ? { ...currentDialog, prompt: event.target.value, } : currentDialog, ) } /> {generateDialog.status === 'generating' ? ( 修改中 ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} {generateDialog.status === 'generating' ? '修改中' : '修改'}
) : null}
); }