import { Camera, History, ImagePlus, Loader2, Sparkles, Trash2, } from 'lucide-react'; import { type ReactNode, useEffect, useRef, useState } from 'react'; import { canUseNativeHostCapability, captureHostImageFile, type HostFileImportImageResult, importHostImageFile, subscribeHostImageDrop, } from '../../services/host-bridge/hostBridge'; import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformActionButton } from './PlatformActionButton'; import { PlatformFieldLabel } from './PlatformFieldLabel'; import { PlatformIconBadge } from './PlatformIconBadge'; import { PlatformIconButton } from './PlatformIconButton'; import { PlatformImagePreviewModal } from './PlatformImagePreviewModal'; import { PlatformPillBadge } from './PlatformPillBadge'; import { PlatformPillSwitch } from './PlatformPillSwitch'; import { PlatformStatusMessage } from './PlatformStatusMessage'; import { PlatformTextField } from './PlatformTextField'; import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard'; import { UnifiedConfirmDialog } from './UnifiedConfirmDialog'; export type CreativeImageInputReferenceImage = { id: string; label: string; imageSrc: string; assetObjectId?: string | null; }; export type CreativeImageInputPanelLabels = { imageField: string; uploadImage: string; replaceImage: string; emptyImageHint: string; removeImage: string; removeImageConfirmTitle: string; removeImageConfirmBody: string; promptReferenceUpload: string; promptReferencePreviewAlt: string; closePromptReferencePreview: string; previewMainImage?: string; closeMainImagePreview?: string; history?: string; }; export type CreativeImageInputPanelProps = { className?: string; fillHeight?: boolean; disabled?: boolean; isSubmitting?: boolean; mainImageMode?: 'edit' | 'preview'; mainImageClickMode?: 'upload' | 'preview'; canUploadMainImage?: boolean; canUseImageHistory?: boolean; canRemoveMainImage?: boolean; canToggleAiRedraw?: boolean; canUploadPromptReferences?: boolean; uploadedImageSrc: string; uploadedImageAlt: string; uploadedImageRefreshKey?: string | number | null; mainImagePreviewZIndexClassName?: string; mainImageMeta?: ReactNode; mainImageInputId: string; mainImageAccept?: string; promptTextareaId: string; prompt: string; promptLabel: string; promptAriaLabel?: string; promptRows?: number; aiRedraw: boolean; promptReferenceImages: CreativeImageInputReferenceImage[]; promptReferenceLimit?: number; imageLimitHint?: string | null; imageModelPicker?: ReactNode; error?: string | null; inputError?: string | null; showSubmitButton?: boolean; submitLabel: string; submitCostLabel?: string | null; submitDisabled: boolean; labels: CreativeImageInputPanelLabels; onMainImageFileSelect: (file: File) => void; onMainImageRemove: () => void; onAiRedrawChange: (enabled: boolean) => void; onPromptChange: (value: string) => void; onPromptReferenceFilesSelect?: (files: File[]) => void; onPromptReferenceRemove?: (referenceId: string) => void; onHistoryClick?: () => void; onSubmit: () => void; }; const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp'; const DEFAULT_PROMPT_REFERENCE_LIMIT = 5; function hostImageImportResultToFile(result: HostFileImportImageResult) { return puzzleReferenceImageDataUrlToFile( `data:${result.mimeType};base64,${result.base64Data}`, result.fileName, ); } export function CreativeImageInputPanel({ className = '', fillHeight = true, disabled = false, isSubmitting = false, mainImageMode = 'edit', mainImageClickMode = 'preview', canUploadMainImage = true, canUseImageHistory = true, canRemoveMainImage = true, canToggleAiRedraw = true, canUploadPromptReferences, uploadedImageSrc, uploadedImageAlt, uploadedImageRefreshKey = null, mainImagePreviewZIndexClassName = 'z-[82]', mainImageMeta = null, mainImageInputId, mainImageAccept = DEFAULT_IMAGE_ACCEPT, promptTextareaId, prompt, promptLabel, promptAriaLabel, promptRows = 2, aiRedraw, promptReferenceImages, promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT, imageLimitHint = null, imageModelPicker = null, error = null, inputError = null, showSubmitButton = true, submitLabel, submitCostLabel = null, submitDisabled, labels, onMainImageFileSelect, onMainImageRemove, onAiRedrawChange, onPromptChange, onPromptReferenceFilesSelect, onPromptReferenceRemove, onHistoryClick, onSubmit, }: CreativeImageInputPanelProps) { const mainImageCardRef = useRef(null); const mainImageInputRef = useRef(null); const promptReferenceInputRef = useRef(null); const [previewReferenceImage, setPreviewReferenceImage] = useState(null); const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false); const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] = useState(false); const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw; const shouldShowPromptReferences = canUploadPromptReferences ?? !uploadedImageSrc; const promptReferenceUploadDisabled = disabled || promptReferenceImages.length >= promptReferenceLimit; const canEditMainImage = mainImageMode === 'edit'; const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage; const shouldShowHistoryButton = canEditMainImage && canUseImageHistory && Boolean(onHistoryClick); const shouldPreviewMainImage = mainImageClickMode === 'preview' && Boolean(uploadedImageSrc); const shouldShowMainImageUploadButton = isMainImageUploadEnabled && shouldPreviewMainImage; const canImportHostImage = canUseNativeHostCapability('file.importImage'); const canCaptureHostImage = canUseNativeHostCapability('file.captureImage'); const canReceiveHostImageDrop = canUseNativeHostCapability('file.imageDropped'); const promptReferenceInputId = `${mainImageInputId}-prompt-reference`; useEffect(() => { if (uploadedImageSrc) { setPreviewReferenceImage(null); } else { setIsMainImagePreviewOpen(false); } }, [uploadedImageSrc]); useEffect(() => { if ( previewReferenceImage && !promptReferenceImages.some( (reference) => reference.id === previewReferenceImage.id, ) ) { setPreviewReferenceImage(null); } }, [previewReferenceImage, promptReferenceImages]); useEffect(() => { if (!canReceiveHostImageDrop || disabled || !isMainImageUploadEnabled) { return undefined; } return subscribeHostImageDrop((payload) => { const card = mainImageCardRef.current; const position = payload.position; if (!card || !position) { return; } const bounds = card.getBoundingClientRect(); const isInsideCard = position.x >= bounds.left && position.x <= bounds.right && position.y >= bounds.top && position.y <= bounds.bottom; if (!isInsideCard) { return; } const topElement = typeof document.elementFromPoint === 'function' ? document.elementFromPoint(position.x, position.y) : null; // 中文注释:桌面拖入是窗口级事件;只让坐标命中的最上层主图槽位消费,避免多个创作面板同时接收同一张图。 if (topElement && !card.contains(topElement)) { return; } try { onMainImageFileSelect(hostImageImportResultToFile(payload)); } catch { // 中文注释:宿主已校验图片类型和体积;这里仅兜住浏览器 File 构造异常,保持当前表单状态。 } }); }, [ canReceiveHostImageDrop, disabled, isMainImageUploadEnabled, onMainImageFileSelect, ]); const bodyClassName = fillHeight ? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1' : 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1'; const sectionClassName = fillHeight ? 'creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible' : 'creative-image-input-panel__section puzzle-creation-form-section flex flex-none flex-col overflow-visible'; const gridSizeClassName = fillHeight ? 'min-h-0 flex-1' : 'flex-none'; const imageFieldClassName = fillHeight ? 'creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col' : 'creative-image-input-panel__image-field puzzle-image-field flex min-w-0 flex-none flex-col'; const imageFrameClassName = fillHeight ? 'creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center' : 'creative-image-input-panel__image-frame puzzle-image-card-frame flex flex-none items-center justify-center'; const imageCardClassName = fillHeight ? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full' : 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem]'; const importHostImageAsFile = async () => { const result = await importHostImageFile(); if (!result) { return null; } return hostImageImportResultToFile(result); }; const captureHostImageAsFile = async () => { const result = await captureHostImageFile(); if (!result) { return null; } return hostImageImportResultToFile(result); }; const handleMainImageUploadClick = () => { if (disabled || !isMainImageUploadEnabled) { return; } if (!canImportHostImage) { mainImageInputRef.current?.click(); return; } void (async () => { try { const file = await importHostImageAsFile(); if (file) { onMainImageFileSelect(file); } } catch { // 中文注释:宿主导入失败时不再弹浏览器文件框,避免权限失败后重复打扰用户。 } })(); }; const handleMainImageCaptureClick = () => { if (disabled || !isMainImageUploadEnabled || !canCaptureHostImage) { return; } void (async () => { try { const file = await captureHostImageAsFile(); if (file) { onMainImageFileSelect(file); } } catch { // 中文注释:相机拍摄失败或用户取消时保持当前表单状态,不回落到相册或文件框。 } })(); }; const handlePromptReferenceUploadClick = () => { if ( promptReferenceUploadDisabled || !shouldShowPromptReferences || !onPromptReferenceFilesSelect ) { return; } if (!canImportHostImage) { promptReferenceInputRef.current?.click(); return; } void (async () => { try { const file = await importHostImageAsFile(); if (file) { onPromptReferenceFilesSelect([file]); } } catch { // 中文注释:宿主导入失败时保持当前表单状态,由外层错误通道继续承接后续重试。 } })(); }; const handlePromptReferenceCaptureClick = () => { if ( promptReferenceUploadDisabled || !shouldShowPromptReferences || !onPromptReferenceFilesSelect || !canCaptureHostImage ) { return; } void (async () => { try { const file = await captureHostImageAsFile(); if (file) { onPromptReferenceFilesSelect([file]); } } catch { // 中文注释:相机拍摄失败不打断当前创作输入,用户可继续选择上传或重试拍摄。 } })(); }; return (
{labels.imageField}
{isMainImageUploadEnabled ? ( { const file = event.currentTarget.files?.[0] ?? null; event.currentTarget.value = ''; if (file) { onMainImageFileSelect(file); } }} className="sr-only" /> ) : null} {shouldPreviewMainImage ? ( ) : null} {uploadedImageSrc ? ( ) : ( } size="xl" tone="soft" className="border border-[var(--platform-subpanel-border)] bg-white/92 sm:h-20 sm:w-20" /> )}
{shouldShowMainImageUploadButton ? ( } className="absolute bottom-3 right-3 z-10 h-10 w-10" /> ) : null} {shouldShowHistoryButton ? ( } className="absolute right-3 top-3 z-10 gap-1.5 px-3 py-2 text-[11px] font-black" > 历史 ) : null} {isMainImageUploadEnabled && canCaptureHostImage ? ( } className={`absolute top-3 z-10 h-10 w-10 ${ shouldShowHistoryButton ? 'right-[4.75rem]' : 'right-3' }`} /> ) : null} {canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? ( onAiRedrawChange(event.target.checked) } className="absolute bottom-3 left-3 z-10" /> ) : null} {canEditMainImage && uploadedImageSrc && canRemoveMainImage ? ( setIsRemoveImageConfirmOpen(true)} icon={} className="absolute left-3 top-3 z-10 h-10 w-10" /> ) : isMainImageUploadEnabled && !uploadedImageSrc ? ( ) : null}
{mainImageMeta ? (
{mainImageMeta}
) : null} {imageLimitHint ? (
{imageLimitHint}
) : null}
{showPrompt ? (
onPromptChange(event.target.value)} size="lg" density="roomy" className="h-[6rem] min-h-[6rem] rounded-[1.15rem] pb-14 font-normal placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]" aria-label={promptAriaLabel ?? promptLabel} /> {imageModelPicker} {shouldShowPromptReferences && onPromptReferenceFilesSelect ? ( <> { const files = Array.from( event.currentTarget.files ?? [], ); event.currentTarget.value = ''; if (files.length > 0) { onPromptReferenceFilesSelect(files); } }} className="sr-only" />
{canCaptureHostImage ? ( } className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]" /> ) : null} {canImportHostImage ? ( } className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]" /> ) : ( } className={`h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${ promptReferenceUploadDisabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer' }`} /> )}
) : null}
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
{promptReferenceImages.map((reference) => ( setPreviewReferenceImage(reference)} onRemove={ onPromptReferenceRemove ? () => onPromptReferenceRemove(reference.id) : undefined } disabled={disabled} resolveAsset className="h-12 w-12 rounded-[0.75rem] bg-white/90 shadow-sm" previewButtonProps={{ title: reference.label }} removeButtonProps={{ title: '移除参考图', className: 'right-0.5 top-0.5 bg-white/94 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]', }} /> ))}
) : null}
) : null}
{inputError ? ( {inputError} ) : null} {error ? ( {error} ) : null}
{showSubmitButton ? (
{isSubmitting ? ( ) : null} {submitLabel} {submitCostLabel ? ( {submitCostLabel} ) : null}
) : null} setPreviewReferenceImage(null)} /> setIsMainImagePreviewOpen(false)} /> setIsRemoveImageConfirmOpen(false)} confirmLabel="移除" cancelLabel="取消" showCancel onConfirm={() => { onMainImageRemove(); setIsRemoveImageConfirmOpen(false); }} size="sm" zIndexClassName="z-[80]" overlayClassName="px-4 py-6" panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]" />
); } export default CreativeImageInputPanel;