| 1 | "use client"; |
| 2 | |
| 3 | import { DRAG_ITEM_BLOCK } from "@platejs/dnd"; |
| 4 | import { |
| 5 | BarChart3, |
| 6 | ImageIcon, |
| 7 | Loader2, |
| 8 | Search, |
| 9 | Sparkles, |
| 10 | Upload, |
| 11 | } from "lucide-react"; |
| 12 | import { type TElement } from "platejs"; |
| 13 | import type React from "react"; |
| 14 | import { useRef } from "react"; |
| 15 | import { useDrop } from "react-dnd"; |
| 16 | import { toast } from "sonner"; |
| 17 | |
| 18 | import { isChartType } from "@/components/notebook/presentation/editor/lib"; |
| 19 | import { useUploadFile } from "@/components/plate/hooks/use-upload-file"; |
| 20 | import { Button } from "@/components/ui/button"; |
| 21 | import { |
| 22 | Empty, |
| 23 | EmptyContent, |
| 24 | EmptyDescription, |
| 25 | EmptyHeader, |
| 26 | EmptyMedia, |
| 27 | EmptyTitle, |
| 28 | } from "@/components/ui/empty"; |
| 29 | import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave"; |
| 30 | import { cn } from "@/lib/utils"; |
| 31 | import { |
| 32 | usePresentationState, |
| 33 | type ImageEditorMode, |
| 34 | } from "@/states/presentation-state"; |
| 35 | |
| 36 | export interface ImagePlaceholderProps { |
| 37 | isStatic?: boolean; |
| 38 | className?: string; |
| 39 | slideId: string; |
| 40 | imageNotFound?: boolean; |
| 41 | onOpenEditor?: (mode: ImageEditorMode) => void; |
| 42 | } |
| 43 | |
| 44 | export default function ImagePlaceholder({ |
| 45 | isStatic = false, |
| 46 | className, |
| 47 | slideId, |
| 48 | imageNotFound = false, |
| 49 | onOpenEditor, |
| 50 | }: ImagePlaceholderProps) { |
| 51 | const fileInputRef = useRef<HTMLInputElement>(null); |
| 52 | const setSlides = usePresentationState((s) => s.setSlides); |
| 53 | const setCurrentSlideId = usePresentationState((s) => s.setCurrentSlideId); |
| 54 | const { saveImmediately } = useDebouncedSave(); |
| 55 | const { uploadFile, isUploading, progress } = useUploadFile({ |
| 56 | onUploadComplete: (file) => { |
| 57 | const { clearRootImageGeneration } = usePresentationState.getState(); |
| 58 | if (slideId) { |
| 59 | clearRootImageGeneration(slideId); |
| 60 | setSlides((slides) => |
| 61 | slides.map((slide) => |
| 62 | slide.id === slideId |
| 63 | ? { |
| 64 | ...slide, |
| 65 | rootImage: { |
| 66 | ...slide.rootImage!, |
| 67 | url: file.ufsUrl, |
| 68 | imageSource: "upload", |
| 69 | embedType: undefined, |
| 70 | chartType: undefined, |
| 71 | chartData: undefined, |
| 72 | chartOptions: undefined, |
| 73 | }, |
| 74 | } |
| 75 | : slide, |
| 76 | ), |
| 77 | ); |
| 78 | void saveImmediately(); |
| 79 | } |
| 80 | }, |
| 81 | onUploadError: (error) => { |
| 82 | toast.error("Failed to upload image"); |
| 83 | console.error(error); |
| 84 | }, |
| 85 | }); |
| 86 | const uploadProgress = Math.trunc(progress); |
| 87 | |
| 88 | const handleUploadClick = () => { |
| 89 | if (!isStatic && fileInputRef.current) { |
| 90 | setCurrentSlideId(slideId); |
| 91 | fileInputRef.current.click(); |
| 92 | } |
| 93 | }; |
| 94 | |
| 95 | const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
| 96 | const file = event.target.files?.[0]; |
| 97 | if (file && !isStatic) { |
| 98 | setCurrentSlideId(slideId); |
| 99 | void uploadFile(file); |
| 100 | } |
| 101 | }; |
| 102 | |
| 103 | const handleDoubleClick = (e: React.MouseEvent) => { |
| 104 | e.stopPropagation(); |
| 105 | if (!isStatic) { |
| 106 | setCurrentSlideId(slideId); |
| 107 | onOpenEditor?.("generate"); |
| 108 | } |
| 109 | }; |
| 110 | |
| 111 | // Drop handler for charts |
| 112 | const [{ isOver, canDrop }, drop] = useDrop< |
| 113 | { element?: TElement }, |
| 114 | void, |
| 115 | { isOver: boolean; canDrop: boolean } |
| 116 | >( |
| 117 | () => ({ |
| 118 | accept: DRAG_ITEM_BLOCK, |
| 119 | canDrop: (item: { element?: TElement }) => { |
| 120 | // Only accept chart elements |
| 121 | return Boolean(item.element && isChartType(item.element.type)); |
| 122 | }, |
| 123 | drop: (item: { element?: TElement }) => { |
| 124 | if (!item.element || isStatic) return; |
| 125 | |
| 126 | const chartType = item.element.type; |
| 127 | const chartData = (item.element as unknown as { data?: unknown }).data; |
| 128 | const chartOptions = { |
| 129 | variant: (item.element as unknown as { variant?: string }).variant, |
| 130 | disableAnimation: true, |
| 131 | }; |
| 132 | |
| 133 | // Update the slide's rootImage with the chart data |
| 134 | setSlides((slides) => |
| 135 | slides.map((slide) => |
| 136 | slide.id === slideId |
| 137 | ? { |
| 138 | ...slide, |
| 139 | rootImage: { |
| 140 | ...slide.rootImage!, |
| 141 | chartType, |
| 142 | chartData, |
| 143 | chartOptions, |
| 144 | // Clear any existing image/embed data |
| 145 | url: undefined, |
| 146 | embedType: undefined, |
| 147 | imageSource: undefined, |
| 148 | }, |
| 149 | } |
| 150 | : slide, |
| 151 | ), |
| 152 | ); |
| 153 | toast.success("Chart added to slide"); |
| 154 | }, |
| 155 | collect: (monitor) => ({ |
| 156 | isOver: monitor.isOver(), |
| 157 | canDrop: monitor.canDrop(), |
| 158 | }), |
| 159 | }), |
| 160 | [isStatic, setSlides, slideId], |
| 161 | ); |
| 162 | |
| 163 | return ( |
| 164 | <div |
| 165 | ref={(el) => { |
| 166 | if (el && !isStatic) drop(el); |
| 167 | }} |
| 168 | className={cn( |
| 169 | "flex min-h-full items-center justify-center bg-background transition-colors", |
| 170 | isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset", |
| 171 | className, |
| 172 | )} |
| 173 | onDoubleClick={handleDoubleClick} |
| 174 | > |
| 175 | {isOver && canDrop ? ( |
| 176 | <div className="flex flex-col items-center gap-2 text-primary"> |
| 177 | <BarChart3 className="size-12" /> |
| 178 | <p className="text-sm font-medium">Drop chart here</p> |
| 179 | </div> |
| 180 | ) : ( |
| 181 | <div> |
| 182 | <Empty className="w-full bg-card"> |
| 183 | <EmptyHeader> |
| 184 | <EmptyMedia variant="icon"> |
| 185 | <ImageIcon /> |
| 186 | </EmptyMedia> |
| 187 | <EmptyTitle className="text-primary"> |
| 188 | {imageNotFound ? "Image not found" : "No image yet"} |
| 189 | </EmptyTitle> |
| 190 | <EmptyDescription> |
| 191 | {imageNotFound |
| 192 | ? "Try uploading, generating, or searching for another image" |
| 193 | : "Upload or generate an image"} |
| 194 | </EmptyDescription> |
| 195 | </EmptyHeader> |
| 196 | |
| 197 | <EmptyContent className="flex-row gap-3 text-primary"> |
| 198 | <Button |
| 199 | variant="outline" |
| 200 | onClick={handleUploadClick} |
| 201 | className="gap-2" |
| 202 | disabled={isStatic || isUploading} |
| 203 | > |
| 204 | {isUploading ? ( |
| 205 | <> |
| 206 | <Loader2 className="mr-2 size-4 animate-spin" /> |
| 207 | {uploadProgress}% |
| 208 | </> |
| 209 | ) : ( |
| 210 | <> |
| 211 | <Upload className="size-4" /> |
| 212 | Upload |
| 213 | </> |
| 214 | )} |
| 215 | </Button> |
| 216 | |
| 217 | <Button |
| 218 | variant="outline" |
| 219 | onClick={() => { |
| 220 | setCurrentSlideId(slideId); |
| 221 | onOpenEditor?.("generate"); |
| 222 | }} |
| 223 | disabled={isStatic} |
| 224 | className="gap-2" |
| 225 | > |
| 226 | <Sparkles className="size-4" /> |
| 227 | AI Image |
| 228 | </Button> |
| 229 | |
| 230 | <Button |
| 231 | variant="outline" |
| 232 | onClick={() => { |
| 233 | setCurrentSlideId(slideId); |
| 234 | onOpenEditor?.("search"); |
| 235 | }} |
| 236 | disabled={isStatic} |
| 237 | className="gap-2" |
| 238 | > |
| 239 | <Search className="size-4" /> |
| 240 | Search for Image |
| 241 | </Button> |
| 242 | </EmptyContent> |
| 243 | </Empty> |
| 244 | </div> |
| 245 | )} |
| 246 | |
| 247 | {/* Hidden file input */} |
| 248 | <input |
| 249 | aria-label="image placeholder control" |
| 250 | ref={fileInputRef} |
| 251 | type="file" |
| 252 | accept="image/*" |
| 253 | className="hidden" |
| 254 | onChange={handleFileChange} |
| 255 | /> |
| 256 | </div> |
| 257 | ); |
| 258 | } |
| 259 |