返回 presentation-ai
EmbedControls.tsx
1 "use client";
2
3 import {
4 AlertTriangle,
5 ExternalLink,
6 Loader2,
7 Trash2,
8 Upload,
9 } from "lucide-react";
10 import Image from "next/image";
11 import { type TElement } from "platejs";
12 import { useEffect, useRef, useState } from "react";
13
14 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
15 import {
16 detectEmbedType,
17 getAllEmbedTypes,
18 isValidEmbedUrl,
19 } from "@/components/plate/ui/media-embeds";
20 import { Alert, AlertDescription } from "@/components/ui/alert";
21 import { Button } from "@/components/ui/button";
22 import { Input } from "@/components/ui/input";
23 import { Label } from "@/components/ui/label";
24 import {
25 Select,
26 SelectContent,
27 SelectItem,
28 SelectTrigger,
29 SelectValue,
30 } from "@/components/ui/select";
31 import { type RootImage as RootImageType } from "../../../utils/parser";
32 import { type ImageCropSettings } from "../../../utils/types";
33 import { EmbedRenderer } from "../embeds/EmbedRenderer";
34 import { ActionButtons } from "./ActionButtons";
35 import { ImagePreview } from "./ImagePreview";
36 import { type ImageDimensions } from "./useImageDimensions";
37
38 interface EmbedControlsProps {
39 embedType?: string;
40 embedUrl?: string;
41 onEmbedChange: (embedType: string, url: string) => void;
42 onClearEmbed: () => void;
43 imageDimensions?: ImageDimensions;
44 // Additional props for full image functionality
45 element?: TElement & RootImageType;
46 slideId?: string;
47 isRootImage?: boolean;
48 onOpenCrop?: () => void;
49 cropSettings?: ImageCropSettings;
50 onCropSettingsChange?: (settings: ImageCropSettings) => void;
51 }
52
53 export function EmbedControls({
54 embedType,
55 embedUrl,
56 onEmbedChange,
57 onClearEmbed,
58 imageDimensions,
59 element,
60 slideId,
61 isRootImage = true,
62 onOpenCrop,
63 cropSettings,
64 onCropSettingsChange,
65 }: EmbedControlsProps) {
66 const [selectedType, setSelectedType] = useState(embedType || "image");
67 const [url, setUrl] = useState(embedUrl || "");
68 const [error, setError] = useState<string | null>(null);
69 const [isValid, setIsValid] = useState(false);
70 const fileInputRef = useRef<HTMLInputElement>(null);
71
72 const embedTypes = getAllEmbedTypes();
73
74 // Upload hook for image upload
75 const { uploadFile, isUploading, progress } = useUploadFile({
76 onUploadComplete: (file) => {
77 const uploadedUrl = file.ufsUrl ?? file.ufsUrl;
78 setUrl(uploadedUrl);
79 setSelectedType("image");
80 setIsValid(true);
81 setError(null);
82 // Automatically apply the embed with the uploaded image URL
83 onEmbedChange("image", uploadedUrl);
84 },
85 onUploadError: (error) => {
86 setError("Failed to upload image");
87 console.error(error);
88 },
89 });
90
91 const handleUploadClick = () => {
92 fileInputRef.current?.click();
93 };
94
95 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
96 const file = e.target.files?.[0];
97 if (file) void uploadFile(file);
98 // Reset the input so the same file can be re-selected
99 e.target.value = "";
100 };
101
102 useEffect(() => {
103 if (url && selectedType) {
104 const valid = isValidEmbedUrl(url, selectedType);
105 setIsValid(valid);
106 setError(valid ? null : "Invalid URL for selected embed type");
107 } else {
108 setIsValid(false);
109 setError(null);
110 }
111 }, [url, selectedType]);
112
113 const handleUrlChange = (newUrl: string) => {
114 setUrl(newUrl);
115
116 // Auto-detect embed type if not selected
117 if (!selectedType && newUrl) {
118 const detectedType = detectEmbedType(newUrl);
119 if (detectedType) {
120 setSelectedType(detectedType);
121 }
122 }
123 };
124
125 const handleApply = () => {
126 if (selectedType && url && isValid) {
127 onEmbedChange(selectedType, url);
128 }
129 };
130
131 const handleClear = () => {
132 setSelectedType("");
133 setUrl("");
134 setError(null);
135 setIsValid(false);
136 onClearEmbed();
137 };
138
139 // Calculate thumbnail scale to fit in 90x90 box
140 const thumbnailDimensions = imageDimensions
141 ? {
142 ...imageDimensions,
143 scale: Math.min(
144 360 / imageDimensions.width,
145 360 / imageDimensions.height,
146 ),
147 }
148 : { width: 360, height: 360, scale: 1 };
149
150 // Create element for ImagePreview when type is "image"
151 const imageElement = element ?? {
152 type: "rootImage" as const,
153 children: [] as never[],
154 url: url,
155 query: "",
156 };
157
158 // Local crop settings for image preview
159 const localCropSettings = cropSettings ?? {
160 objectFit: "cover" as const,
161 objectPosition: { x: 50, y: 50 },
162 zoom: 1,
163 };
164
165 const isImageType = selectedType === "image";
166
167 return (
168 <div className="flex w-90 flex-col gap-y-6 overflow-clip">
169 {/* Mini Preview */}
170 <div className="flex flex-col gap-2">
171 <div className="flex justify-center">
172 <div className="relative flex size-90 items-center justify-center overflow-hidden rounded-md border bg-muted shadow">
173 {isImageType ? (
174 imageDimensions ? (
175 <ImagePreview
176 element={
177 {
178 ...imageElement,
179 url: url || imageElement.url,
180 } as TElement & RootImageType
181 }
182 currentMode="embed"
183 localCropSettings={localCropSettings}
184 imageDimensions={thumbnailDimensions}
185 onCropSettingsChange={onCropSettingsChange ?? (() => {})}
186 hideControls={true}
187 />
188 ) : url ? (
189 <Image
190 unoptimized
191 width={400}
192 height={300}
193 src={url}
194 alt="Preview"
195 className="size-full object-cover"
196 />
197 ) : (
198 <div className="flex items-center justify-center text-sm text-muted-foreground">
199 Enter an image URL
200 </div>
201 )
202 ) : (
203 <div
204 style={{
205 width: thumbnailDimensions.width * thumbnailDimensions.scale,
206 height:
207 thumbnailDimensions.height * thumbnailDimensions.scale,
208 }}
209 >
210 <EmbedRenderer
211 embedType={selectedType}
212 url={url}
213 className="pointer-events-none size-full"
214 />
215 </div>
216 )}
217 </div>
218 </div>
219
220 {/* Action Buttons - Only show for image type when we have a URL */}
221 {isImageType && (url || embedUrl) && element && (
222 <div className="flex justify-center">
223 <ActionButtons
224 element={element}
225 slideId={slideId}
226 isRootImage={isRootImage}
227 imageUrl={url || embedUrl}
228 onOpenCrop={onOpenCrop}
229 cropSettings={localCropSettings}
230 onCropSettingsChange={onCropSettingsChange}
231 showInOverlay={true}
232 />
233 </div>
234 )}
235 </div>
236
237 <div className="max-w-full space-y-3">
238 <Label className="text-sm font-medium">Embed Type</Label>
239 <Select value={selectedType} onValueChange={setSelectedType}>
240 <SelectTrigger>
241 <SelectValue placeholder="Select embed type" />
242 </SelectTrigger>
243 <SelectContent>
244 {embedTypes.map(({ type, config }) => (
245 <SelectItem key={type} value={type}>
246 {config.name}
247 </SelectItem>
248 ))}
249 </SelectContent>
250 </Select>
251 </div>
252
253 <div className="max-w-full space-y-3">
254 <Label className="text-sm font-medium">URL</Label>
255 <div className="flex gap-2">
256 <Input
257 placeholder={
258 isImageType
259 ? "Paste your image URL here..."
260 : "Paste your embed URL here..."
261 }
262 value={url}
263 onChange={(e) => handleUrlChange(e.target.value)}
264 className="flex-1 font-mono text-sm"
265 disabled={isUploading}
266 />
267 {isImageType && (
268 <Button
269 type="button"
270 variant="outline"
271 size="icon"
272 onClick={handleUploadClick}
273 disabled={isUploading}
274 title="Upload image"
275 className="shrink-0"
276 >
277 {isUploading ? (
278 <Loader2 className="size-4 animate-spin" />
279 ) : (
280 <Upload className="size-4" />
281 )}
282 </Button>
283 )}
284 {/* Hidden file input */}
285 <input
286 aria-label="embed controls control"
287 ref={fileInputRef}
288 type="file"
289 accept="image/*"
290 onChange={handleFileChange}
291 className="hidden"
292 />
293 </div>
294 {isUploading && (
295 <div className="text-xs text-muted-foreground">
296 Uploading… {Math.round(progress)}%
297 </div>
298 )}
299 {error && (
300 <Alert variant="destructive">
301 <AlertTriangle className="size-4" />
302 <AlertDescription>{error}</AlertDescription>
303 </Alert>
304 )}
305 </div>
306
307 <div className="space-y-3 pt-2">
308 <Button
309 onClick={handleApply}
310 disabled={!selectedType || !url || !isValid}
311 className="w-full"
312 >
313 {isImageType ? "Apply Image URL" : "Apply Embed"}
314 </Button>
315
316 {embedType && (
317 <Button
318 variant="outline"
319 onClick={handleClear}
320 className="w-full text-destructive hover:text-destructive"
321 >
322 <Trash2 className="mr-2 size-4" />
323 Remove Embed
324 </Button>
325 )}
326 </div>
327
328 {url && (
329 <div className="flex items-center gap-2 overflow-hidden rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
330 <ExternalLink className="size-3 shrink-0" />
331 <a
332 href={url}
333 target="_blank"
334 rel="noopener noreferrer"
335 className="line-clamp-1 max-w-full flex-1 text-ellipsis hover:underline"
336 >
337 {url}
338 </a>
339 </div>
340 )}
341 </div>
342 );
343 }
344
344 lines Plain Text