"use client"; import React, { useEffect, useState, useRef, useMemo } from "react"; import { useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Wand2, Upload, Loader2, Delete, Trash, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { PresentationGenerationApi } from "../services/api/presentation-generation"; import { Skeleton } from "@/components/ui/skeleton"; import { notify } from "@/components/ui/sonner"; import { PreviousGeneratedImagesResponse } from "../services/api/params"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { ImagesApi } from "../services/api/images"; import { ImageAssetResponse } from "../services/api/types"; import { resolveBackendAssetSource } from "@/utils/api"; import { ImageEditorToolbar } from "./ImageEditorToolbar"; const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]); interface ImageEditorProps { initialImage: string | null; imageIdx?: number; slideIndex: number; className?: string; promptContent?: string; properties?: null | any; onClose?: () => void; onImageChange?: (newImageUrl: string, prompt?: string) => void; onFocusPointClick?: (propertiesData: any) => void; } const resolveEditorImageSource = ( image: | string | { file_url?: string | null; path?: string | null; url?: string | null } | null | undefined ) => resolveBackendAssetSource(image); const ImageEditor = ({ initialImage, imageIdx = 0, promptContent, properties, onClose, onFocusPointClick, onImageChange, }: ImageEditorProps) => { const llmConfig = useSelector((state: RootState) => state.userConfig.llm_config); const stockImageProvider = useMemo(() => { if (llmConfig?.DISABLE_IMAGE_GENERATION) return null; const id = (llmConfig?.IMAGE_PROVIDER || "").trim().toLowerCase(); return STOCK_IMAGE_PROVIDERS.has(id) ? id : null; }, [llmConfig?.DISABLE_IMAGE_GENERATION, llmConfig?.IMAGE_PROVIDER]); // State management const [previewImages, setPreviewImages] = useState( resolveEditorImageSource(initialImage) ); const [previousGeneratedImages, setPreviousGeneratedImages] = useState< PreviousGeneratedImagesResponse[] >([]); const [prompt, setPrompt] = useState(""); const [isGenerating, setIsGenerating] = useState(false); const [stockSearchResults, setStockSearchResults] = useState([]); const [isSearchingStock, setIsSearchingStock] = useState(false); const [error, setError] = useState(null); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadedImageUrl, setUploadedImageUrl] = useState(null); const [isOpen, setIsOpen] = useState(true); const [activeTab, setActiveTab] = useState<"generate" | "upload" | "edit">( "generate", ); const [uploadedImages, setUploadedImages] = useState([]); const [uploadedImagesLoading, setUploadedImagesLoading] = useState(false); // Focus point and object fit for image editing const [isFocusPointMode, setIsFocusPointMode] = useState(false); const [focusPoint, setFocusPoint] = useState( (properties && properties[imageIdx] && properties[imageIdx].initialFocusPoint) || { x: 50, y: 50, } ); const [objectFit, setObjectFit] = useState<"cover" | "contain" | "fill">( (properties && properties[imageIdx] && properties[imageIdx].initialObjectFit) || "cover" ); // Refs const imageRef = useRef(null); useEffect(() => { setPreviewImages(resolveEditorImageSource(initialImage)); }, [initialImage]); useEffect(() => { setPrompt((prev) => (prev.trim() ? prev : promptContent || "")); }, [promptContent]); useEffect(() => { if (stockImageProvider) { setStockSearchResults([]); } }, [stockImageProvider]); useEffect(() => { if (isOpen && !previousGeneratedImages.length && !stockImageProvider) { getPreviousGeneratedImage(); } }, [isOpen, stockImageProvider]); // Handle close with animation const handleClose = () => { setIsOpen(false); // Delay the actual close to allow animation to complete setTimeout(() => { onClose?.(); }, 300); // Match the Sheet animation duration }; const getPreviousGeneratedImage = async () => { try { trackEvent(MixpanelEvent.ImageEditor_GetPreviousGeneratedImages_API_Call); const response = await PresentationGenerationApi.getPreviousGeneratedImages(); setPreviousGeneratedImages(response); } catch (error: any) { notify.error("Could not load images", "Failed to get previous generated images. Please try again."); console.error("error in getting previous generated images", error); setError( error.message || "Failed to get previous generated images. Please try again." ); } }; /** * Handles image selection and calls the parent callback */ const handleImageChange = (newImage: string) => { if (onImageChange) { const promptForSlide = stockImageProvider ? (prompt.trim() || promptContent || "") : promptContent; onImageChange(newImage, promptForSlide); setPreviewImages(newImage); } }; /** * Handles focus point adjustment when clicking on the image */ const handleFocusPointClick = (e: React.MouseEvent) => { if (!isFocusPointMode || !imageRef.current) return; const rect = imageRef.current.getBoundingClientRect(); const x = Math.max( 0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100) ); const y = Math.max( 0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100) ); setFocusPoint({ x, y }); saveImageProperties(objectFit, { x, y }); // Apply the focus point in real-time if (imageRef.current) { imageRef.current.style.objectPosition = `${x}% ${y}%`; } }; /** * Toggles focus point adjustment mode */ const toggleFocusPointMode = () => { if (isFocusPointMode) { saveImageProperties(objectFit, focusPoint); } setIsFocusPointMode(!isFocusPointMode); }; /** * Handles object fit change */ const handleFitChange = (fit: "cover" | "contain" | "fill") => { setObjectFit(fit); if (imageRef.current) { imageRef.current.style.objectFit = fit; } saveImageProperties(fit, focusPoint); }; /** * Saves image properties (focus point and object fit) */ const saveImageProperties = ( fit: "cover" | "contain" | "fill", focusPoint: { x: number; y: number } ) => { const propertiesData = { initialObjectFit: fit, initialFocusPoint: focusPoint, }; // TODO: Save to Redux store if needed onFocusPointClick?.(propertiesData); }; /** * Stock image search (Pexels / Pixabay) — returns multiple URLs to pick from. */ const handleStockImageSearch = async () => { if (!prompt.trim()) { setError("Please enter search keywords"); return; } if (!stockImageProvider) return; const apiKey = stockImageProvider === "pexels" ? (llmConfig?.PEXELS_API_KEY || "").trim() : (llmConfig?.PIXABAY_API_KEY || "").trim(); if (!apiKey) { setError( `Add your ${stockImageProvider === "pexels" ? "Pexels" : "Pixabay"} API key in Settings to search stock images.` ); return; } try { setIsSearchingStock(true); setError(null); const urls = await ImagesApi.searchStockImages(prompt.trim(), 20, { provider: stockImageProvider, apiKey, }); setStockSearchResults(urls); if (urls.length === 0) { setError("No images found. Try different keywords."); } } catch (err: unknown) { console.error("Stock image search error", err); const message = err instanceof Error ? err.message : "Stock search failed. Please try again."; setError(message); setStockSearchResults([]); } finally { setIsSearchingStock(false); } }; /** * Generates new images using AI */ const handleGenerateImage = async () => { if (!prompt) { setError("Please enter a prompt"); return; } if (stockImageProvider) { await handleStockImageSearch(); return; } try { setIsGenerating(true); setError(null); trackEvent(MixpanelEvent.ImageEditor_GenerateImage_API_Call); const response = await PresentationGenerationApi.generateImage({ prompt: prompt, }); setPreviewImages(resolveEditorImageSource(response)); } catch (err: any) { console.error("Error in image generation", err); const message = err.message || "Failed to generate image. Please try again."; setError(message); notify.error("Image generation failed", message); } finally { setIsGenerating(false); } }; /** * Handles file upload */ const handleFileUpload = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0]; if (!file) return; // Validate file size (5MB limit) if (file.size > 5 * 1024 * 1024) { setUploadError("File size should be less than 5MB"); return; } // Validate file type if (!file.type.startsWith("image/")) { setUploadError("Please upload an image file"); return; } try { setIsUploading(true); setUploadError(null); trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call); const result = await ImagesApi.uploadImage(file); setUploadedImageUrl(resolveEditorImageSource(result)); } catch (err:any) { setUploadError("Failed to upload image. Please try again."); notify.error("Upload failed", err.message || "Failed to upload image. Please try again."); console.log("Upload error:", err.message); } finally { setIsUploading(false); } }; const getUploadedImages = async () => { try { setUploadedImagesLoading(true); const result = await ImagesApi.getUploadedImages(); setUploadedImages(result); } catch (err:any) { notify.error("Could not load images", err.message || "Failed to get uploaded images. Please try again."); console.log("Get uploaded images error:", err.message); } finally { setUploadedImagesLoading(false); } }; const handleTabChange = (value: string) => { if (value !== "generate" && value !== "upload" && value !== "edit") return; setActiveTab(value); if (value === "upload") { getUploadedImages(); } }; const handleDeleteImage = async (image_id: string) => { try { await ImagesApi.deleteImage(image_id); setUploadedImages(uploadedImages.filter((image) => image.id !== image_id)); notify.success("Image deleted", "The image was removed from your uploads."); } catch (err:any) { notify.error("Could not delete image", err.message || "Failed to delete image. Please try again."); } }; return (
handleClose()}> e.preventDefault()} onClick={(e) => e.stopPropagation()} > Update Image
{stockImageProvider ? "Stock search" : "AI Generate"} Upload Edit {/* Generate Tab */}

Current Prompt

{promptContent}

{stockImageProvider ? "Search keywords" : "Image Description"}