返回 presentation-ai
ActionButtons.tsx
1 "use client";
2
3 import { Download, Loader2, Scan, Scissors, Trash, Upload } from "lucide-react";
4 import { type TElement } from "platejs";
5 import { useEditorRef } from "platejs/react";
6 import { useCallback, useRef } from "react";
7 import { toast } from "sonner";
8
9 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
10 import { Button } from "@/components/ui/button";
11 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
12 import { cn } from "@/lib/utils";
13 import { usePresentationState } from "@/states/presentation-state";
14 import { type RootImage as RootImageType } from "../../../utils/parser";
15 import { type ImageCropSettings } from "../../../utils/types";
16
17 interface ActionButtonsProps {
18 imageUrl?: string;
19 slideId?: string;
20 isRootImage: boolean;
21 element: TElement & RootImageType;
22 onOpenCrop?: () => void;
23 cropSettings?: ImageCropSettings;
24 onCropSettingsChange?: (settings: ImageCropSettings) => void;
25 showInOverlay?: boolean;
26 }
27
28 export function ActionButtons({
29 element,
30 slideId,
31 isRootImage,
32 imageUrl,
33 onOpenCrop,
34 cropSettings,
35 onCropSettingsChange,
36 showInOverlay = false,
37 }: ActionButtonsProps) {
38 const editor = useEditorRef(slideId);
39 const fileInputRef = useRef<HTMLInputElement>(null);
40 const { saveImmediately } = useDebouncedSave();
41
42 const { uploadFile, isUploading, progress } = useUploadFile({
43 onUploadComplete: (file) => {
44 const { clearRootImageGeneration, setSlides } =
45 usePresentationState.getState();
46 if (isRootImage) {
47 if (slideId) {
48 clearRootImageGeneration(slideId);
49 }
50 setSlides((slides) =>
51 slides.map((slide) =>
52 slide.id === slideId
53 ? {
54 ...slide,
55 rootImage: {
56 ...slide.rootImage!,
57 url: file.ufsUrl,
58 imageSource: "upload",
59 embedType: undefined,
60 chartType: undefined,
61 chartData: undefined,
62 chartOptions: undefined,
63 },
64 }
65 : slide,
66 ),
67 );
68 void saveImmediately();
69 } else {
70 editor.tf.setNodes(
71 {
72 url: file.ufsUrl,
73 imageSource: "upload",
74 embedType: undefined,
75 },
76 { at: editor.api.findPath(element) },
77 );
78 }
79 },
80 onUploadError: (error) => {
81 toast.error("Failed to upload image");
82 console.error(error);
83 },
84 });
85
86 const handleUploadClick = () => {
87 fileInputRef.current?.click();
88 };
89
90 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
91 const file = e.target.files?.[0];
92 if (file) void uploadFile(file);
93 };
94
95 const handleDownload = useCallback(async () => {
96 if (!imageUrl) return;
97 try {
98 const response = await fetch(imageUrl);
99 const blob = await response.blob();
100 const url = window.URL.createObjectURL(blob);
101 const a = document.createElement("a");
102 a.href = url;
103 a.download = `image-${Date.now()}.png`;
104 document.body.appendChild(a);
105 a.click();
106 document.body.removeChild(a);
107 window.URL.revokeObjectURL(url);
108 } catch (err) {
109 console.error("Failed to download image:", err);
110 toast.error("Failed to download image");
111 }
112 }, [imageUrl]);
113
114 const handleToggleFit = useCallback(() => {
115 if (!cropSettings || !onCropSettingsChange) return;
116 const newFit = cropSettings.objectFit === "cover" ? "contain" : "cover";
117 onCropSettingsChange({
118 ...cropSettings,
119 objectFit: newFit,
120 });
121 }, [cropSettings, onCropSettingsChange]);
122
123 const handleDelete = useCallback(() => {
124 const { setSlides, clearRootImageGeneration } =
125 usePresentationState.getState();
126 if (isRootImage) {
127 setSlides((slides) =>
128 slides.map((slide) =>
129 slide.id === slideId
130 ? {
131 ...slide,
132 rootImage: {
133 ...slide.rootImage!,
134 url: undefined,
135 embedType: undefined,
136 imageSource: undefined,
137 cropSettings: undefined,
138 },
139 }
140 : slide,
141 ),
142 );
143 if (slideId) {
144 clearRootImageGeneration(slideId);
145 }
146 void saveImmediately();
147 } else {
148 editor.tf.setNodes(
149 {
150 url: undefined,
151 query: undefined,
152 embedType: undefined,
153 cropSettings: undefined,
154 },
155 { at: editor.api.findPath(element) },
156 );
157 void saveImmediately();
158 }
159 toast.success("Image deleted");
160 }, [isRootImage, slideId, editor, element, saveImmediately]);
161
162 if (showInOverlay) {
163 return (
164 <>
165 <div className="flex items-center gap-2">
166 {imageUrl && (
167 <>
168 <Button
169 size="icon"
170 variant="secondary"
171 className="h-8 w-8 rounded-full"
172 onClick={handleDownload}
173 title="Download Image"
174 >
175 <Download className="h-4 w-4" />
176 </Button>
177
178 {onOpenCrop && (
179 <>
180 <div className="h-4 w-px bg-border/50" />
181 <Button
182 size="icon"
183 variant="secondary"
184 className="h-8 w-8 rounded-full"
185 onClick={onOpenCrop}
186 title="Crop Image"
187 >
188 <Scissors className="h-4 w-4" />
189 </Button>
190 </>
191 )}
192
193 {cropSettings && onCropSettingsChange && (
194 <Button
195 size="icon"
196 variant="secondary"
197 className={cn(
198 "h-8 w-8 rounded-full",
199 cropSettings.objectFit === "contain" &&
200 "bg-primary/20 text-primary hover:bg-primary/30",
201 )}
202 onClick={handleToggleFit}
203 title="Toggle Fit/Cover"
204 >
205 <Scan className="h-4 w-4" />
206 </Button>
207 )}
208
209 <div className="h-4 w-px bg-border/50" />
210 </>
211 )}
212
213 <Button
214 size="icon"
215 variant="secondary"
216 className="h-8 w-8 rounded-full"
217 onClick={handleUploadClick}
218 disabled={isUploading}
219 title="Upload Image"
220 >
221 {isUploading ? (
222 <Loader2 className="h-4 w-4 animate-spin" />
223 ) : (
224 <Upload className="h-4 w-4" />
225 )}
226 </Button>
227
228 {imageUrl && (
229 <>
230 <div className="h-4 w-px bg-border/50" />
231 <Button
232 size="icon"
233 variant="secondary"
234 className="h-8 w-8 rounded-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
235 onClick={handleDelete}
236 title="Delete Image"
237 >
238 <Trash className="h-4 w-4" />
239 </Button>
240 </>
241 )}
242 </div>
243
244 {/* Hidden file input */}
245 <input
246 ref={fileInputRef}
247 type="file"
248 accept="image/*"
249 onChange={handleFileChange}
250 className="hidden"
251 />
252 </>
253 );
254 }
255
256 return (
257 <div className="flex gap-2">
258 {/* Upload Button - Direct Action */}
259 <Button
260 variant="outline"
261 size="sm"
262 onClick={handleUploadClick}
263 className="gap-2"
264 disabled={isUploading}
265 >
266 {isUploading ? (
267 <Loader2 className="h-4 w-4 animate-spin" />
268 ) : (
269 <Upload className="h-4 w-4" />
270 )}
271 {isUploading ? `${progress}%` : "Upload"}
272 </Button>
273
274 {/* Hidden file input */}
275 <input
276 ref={fileInputRef}
277 type="file"
278 accept="image/*"
279 onChange={handleFileChange}
280 className="hidden"
281 />
282 </div>
283 );
284 }
285
285 lines Plain Text