返回 presentation-ai
image-placeholder.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / image-placeholder.tsx
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
259 lines Plain Text