"use client"; import { ArrowUp, ChevronDown, ImagePlus, Info, LoaderCircle, RectangleHorizontal, RectangleVertical, Square, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type RefObject } from "react"; import { ImageLightbox } from "@/components/image-lightbox"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import type { ImageModel } from "@/lib/api"; import { cn } from "@/lib/utils"; type ImageComposerProps = { prompt: string; imageCount: string; imageRatio: string; imageTier: string; imageWidth: string; imageHeight: string; imageQuality: string; imageModel: ImageModel; imageModels: ImageModel[]; availableQuota: string; activeTaskCount: number; referenceImages: Array<{ name: string; dataUrl: string }>; textareaRef: RefObject; fileInputRef: RefObject; onPromptChange: (value: string) => void; onImageCountChange: (value: string) => void; onImageRatioChange: (value: string) => void; onImageTierChange: (value: string) => void; onImageWidthChange: (value: string) => void; onImageHeightChange: (value: string) => void; onImageQualityChange: (value: string) => void; onImageModelChange: (value: ImageModel) => void; onSubmit: () => void | Promise; onPickReferenceImage: () => void; onReferenceImageChange: (files: File[]) => void | Promise; onRemoveReferenceImage: (index: number) => void; }; const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i; function isImageFile(file: File) { return file.type.startsWith("image/") || (!file.type && imageFileNamePattern.test(file.name)); } function hasDraggedImages(dataTransfer: DataTransfer) { const items = Array.from(dataTransfer.items || []); if (items.length > 0) { return items.some((item) => item.kind === "file" && (item.type.startsWith("image/") || !item.type)); } return Array.from(dataTransfer.files || []).some(isImageFile); } function getDraggedImageFiles(dataTransfer: DataTransfer) { return Array.from(dataTransfer.files || []).filter(isImageFile); } const qualityOptions = [ { value: "auto", label: "自动" }, { value: "low", label: "低" }, { value: "medium", label: "中" }, { value: "high", label: "高" }, ]; const aspectOptions = [ { ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square }, { ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical }, { ratio: "3:2", tier: "1k", width: "1536", height: "1024", label: "3:2", icon: RectangleHorizontal }, { ratio: "3:4", tier: "1k", width: "1024", height: "1365", label: "3:4", icon: RectangleVertical }, { ratio: "4:3", tier: "1k", width: "1365", height: "1024", label: "4:3", icon: RectangleHorizontal }, { ratio: "9:16", tier: "1k", width: "1088", height: "1920", label: "9:16", icon: RectangleVertical }, { ratio: "16:9", tier: "1k", width: "1920", height: "1088", label: "16:9", icon: RectangleHorizontal }, { ratio: "1:1", tier: "2k", width: "2048", height: "2048", label: "1:1(2k)", icon: Square }, { ratio: "16:9", tier: "2k", width: "2560", height: "1440", label: "16:9(2k)", icon: RectangleHorizontal }, { ratio: "9:16", tier: "2k", width: "1440", height: "2560", label: "9:16(2k)", icon: RectangleVertical }, { ratio: "16:9", tier: "4k", width: "3840", height: "2160", label: "16:9(4k)", icon: RectangleHorizontal }, { ratio: "9:16", tier: "4k", width: "2160", height: "3840", label: "9:16(4k)", icon: RectangleVertical }, { ratio: "auto", tier: "auto", width: "1024", height: "1024", label: "auto", icon: null }, ]; const countOptions = Array.from({ length: 10 }, (_, index) => String(index + 1)); export function ImageComposer({ prompt, imageCount, imageRatio, imageTier, imageWidth, imageHeight, imageQuality, imageModel, imageModels, availableQuota, activeTaskCount, referenceImages, textareaRef, fileInputRef, onPromptChange, onImageCountChange, onImageRatioChange, onImageTierChange, onImageWidthChange, onImageHeightChange, onImageQualityChange, onImageModelChange, onSubmit, onPickReferenceImage, onReferenceImageChange, onRemoveReferenceImage, }: ImageComposerProps) { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [isSizeMenuOpen, setIsSizeMenuOpen] = useState(false); const [isDraggingImage, setIsDraggingImage] = useState(false); const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const sizeMenuRef = useRef(null); const sizeMenuBtnRef = useRef(null); const lightboxImages = useMemo( () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })), [referenceImages], ); const modelOptions = useMemo( () => imageModels.map((model) => ({ value: model, label: model })), [imageModels], ); const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动"; const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`; const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`; const selectedModelLabel = modelOptions.find((option) => option.value === imageModel)?.label || imageModel; const isCodexModel = imageModel.toLowerCase().includes("codex"); useEffect(() => { if (!isSizeMenuOpen) { return; } const handlePointerDown = (event: MouseEvent) => { const target = event.target; if ( target instanceof Element && target.closest('[data-slot="select-content"], [data-slot="select-trigger"]') ) { return; } if (!sizeMenuRef.current?.contains(target as Node)) { setIsSizeMenuOpen(false); } }; window.addEventListener("mousedown", handlePointerDown); return () => { window.removeEventListener("mousedown", handlePointerDown); }; }, [isSizeMenuOpen]); const handleTextareaPaste = (event: ClipboardEvent) => { const imageFiles = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) { return; } event.preventDefault(); void onReferenceImageChange(imageFiles); }; const handleComposerDragEnter = (event: DragEvent) => { if (!hasDraggedImages(event.dataTransfer)) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsSizeMenuOpen(false); setIsDraggingImage(true); }; const handleComposerDragOver = (event: DragEvent) => { if (!hasDraggedImages(event.dataTransfer)) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDraggingImage(true); }; const handleComposerDragLeave = (event: DragEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } setIsDraggingImage(false); }; const handleComposerDrop = (event: DragEvent) => { const imageFiles = getDraggedImageFiles(event.dataTransfer); if (event.dataTransfer.files.length > 0 || imageFiles.length > 0) { event.preventDefault(); event.stopPropagation(); } setIsDraggingImage(false); if (imageFiles.length === 0) { return; } void onReferenceImageChange(imageFiles); }; return (
{ void onReferenceImageChange(Array.from(event.target.files || [])); }} /> {referenceImages.length > 0 ? (
{referenceImages.map((image, index) => (
))}
) : null}
{ textareaRef.current?.focus(); }} >