import { Check, ChevronDown, Cpu } from 'lucide-react'; import { type CSSProperties, type Dispatch, type ReactNode, type SetStateAction, useCallback, useRef, useState, } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; import { EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, getEditorImageModelDisplayName, IMAGE_MODEL_NANOBANANA2, normalizeEditorImageModel, QUICK_EDIT_MODEL_OPTIONS, resizeGenerationPlaceholderToImageSelection, resolveEditorImageSizeLabel, } from './ImageCanvasGenerationModel'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ImageCanvasGenerationImageOptionsViewProps = { dialog: GenerateDialogState; setGenerateDialog: Dispatch>; includeDimensions: boolean; includeModel?: boolean; onRememberImageModel: (model: string) => void; optionLabelPrefix?: string; dimensionRatioAriaLabelPrefix?: string; dimensionSizeAriaLabelPrefix?: string; cost?: number; submitLabel?: string; submitAriaLabel?: string; submitButtonClassName?: string; lockedModel?: string; renderEditorPortal?: (node: ReactNode) => ReactNode; buildPortalMenuStyle?: ( anchor: HTMLElement | null, placement: 'above' | 'below', ) => CSSProperties; }; type OpenPanel = 'dimensions' | 'model' | null; function resetFailedDialogStatus(dialog: GenerateDialogState) { return { ...dialog, status: dialog.status === 'failed' ? 'idle' : dialog.status, errorMessage: dialog.status === 'failed' ? undefined : dialog.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 = normalizeEditorImageModel( 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] ?? '1:1'), imageSize: dialog.imageSize && imageSizes.includes(dialog.imageSize) ? dialog.imageSize : (options.imageSizes.find((size) => size === '1K') ?? options.imageSizes[0] ?? '1K'), options, }; } function OptionChoice({ children, selected, className, ariaLabel, onClick, }: { children: ReactNode; selected: boolean; className?: string; ariaLabel?: string; onClick: () => void; }) { return ( ); } export function ImageCanvasGenerationImageOptionsView({ dialog, setGenerateDialog, includeDimensions, includeModel = true, onRememberImageModel, optionLabelPrefix, dimensionRatioAriaLabelPrefix, dimensionSizeAriaLabelPrefix, cost, submitLabel = '生成', submitAriaLabel = '生成', submitButtonClassName = 'image-canvas-editor__generation-submit', lockedModel, renderEditorPortal = (node) => node, buildPortalMenuStyle = () => ({}), }: ImageCanvasGenerationImageOptionsViewProps) { const [openPanel, setOpenPanel] = useState(null); const dimensionsButtonRef = useRef(null); const modelButtonRef = useRef(null); const isQuickEdit = dialog.mode === 'quick-edit'; const normalizedLockedModel = lockedModel ? normalizeEditorImageModel(lockedModel) : null; const selection = normalizeImageDialogSelection( normalizedLockedModel ? { ...dialog, imageModel: normalizedLockedModel } : dialog, ); const modelOptions = isQuickEdit ? [...QUICK_EDIT_MODEL_OPTIONS] : [...EDITOR_IMAGE_MODEL_OPTIONS]; const selectedModel = modelOptions.some( (option) => option.value === selection.model, ) ? selection.model : (modelOptions[0]?.value ?? selection.model); const selectedModelLabel = modelOptions.find((option) => option.value === selectedModel)?.label ?? getEditorImageModelDisplayName(selectedModel); const selectedSizeLabel = resolveEditorImageSizeLabel({ aspectRatio: selection.aspectRatio, imageSize: selection.imageSize, }); const isGenerating = dialog.status === 'generating'; const isModelLocked = Boolean(normalizedLockedModel) && includeModel; const labelPrefix = optionLabelPrefix ?? '生成图片'; const ratioAriaLabelPrefix = dimensionRatioAriaLabelPrefix?.trim() ?? ''; const sizeAriaLabelPrefix = dimensionSizeAriaLabelPrefix?.trim() ?? ''; const dismissOpenPanel = useCallback(() => setOpenPanel(null), []); const formatDimensionOptionLabel = (prefix: string, value: string) => prefix ? `${prefix} ${value}` : value; useImageCanvasFloatingOptionDismiss({ isOpen: openPanel !== null, boundaryRefs: [dimensionsButtonRef, modelButtonRef], onDismiss: dismissOpenPanel, }); const updateDialog = (patch: Partial) => { setGenerateDialog((currentDialog) => currentDialog && currentDialog.mode === dialog.mode ? resizeGenerationPlaceholderToImageSelection({ ...resetFailedDialogStatus(currentDialog), ...patch, }) : currentDialog, ); }; const updateImageModel = (model: string) => { onRememberImageModel(model); setGenerateDialog((currentDialog) => { if (!currentDialog || currentDialog.mode !== dialog.mode) { return currentDialog; } if (!includeDimensions) { return { ...resetFailedDialogStatus(currentDialog), imageModel: model, }; } const nextOptions = getImageDimensionOptions(model); const nextAspectRatios = nextOptions.aspectRatios as readonly string[]; const nextImageSizes = nextOptions.imageSizes as readonly string[]; const nextDialog = { ...resetFailedDialogStatus(currentDialog), imageModel: model, aspectRatio: currentDialog.aspectRatio && nextAspectRatios.includes(currentDialog.aspectRatio) ? currentDialog.aspectRatio : (nextOptions.aspectRatios[0] ?? '1:1'), imageSize: currentDialog.imageSize && nextImageSizes.includes(currentDialog.imageSize) ? currentDialog.imageSize : (nextOptions.imageSizes.find((size) => size === '1K') ?? nextOptions.imageSizes[0] ?? '1K'), }; return resizeGenerationPlaceholderToImageSelection(nextDialog); }); }; const togglePanel = (panel: Exclude) => { setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel)); }; return ( <> {includeDimensions ? (
} onClick={() => togglePanel('dimensions')} > {selectedSizeLabel} {openPanel === 'dimensions' ? renderEditorPortal( event.stopPropagation()} >
比例
{selection.options.aspectRatios.map((aspectRatio) => ( updateDialog({ aspectRatio })} > ))}
尺寸
{selection.options.imageSizes.map((imageSize) => ( updateDialog({ imageSize })} > {imageSize} ))}
, ) : null}
) : null} {includeModel ? (
} onClick={() => { if (!isModelLocked) { togglePanel('model'); } }} > {selectedModelLabel} {!isModelLocked && openPanel === 'model' ? renderEditorPortal( event.stopPropagation()} >
{modelOptions.map((option) => { const selected = selectedModel === option.value; return ( updateImageModel(option.value)} > {option.label} ); })}
, ) : null}
) : null} {typeof cost === 'number' ? ( {isGenerating ? ( '生成中' ) : ( <> {submitLabel} {cost}泥点 )} ) : null} ); }