返回 presentation-ai
presentation-image-placeholder.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / presentation-image-placeholder.tsx
1 "use client";
2
3 import { ImageIcon, Loader2, Search, Sparkles, Upload } from "lucide-react";
4 import { type TImageElement } from "platejs";
5 import { useEditorReadOnly, useEditorRef } from "platejs/react";
6 import type React from "react";
7 import { useRef } from "react";
8 import { toast } from "sonner";
9
10 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
11 import { Button } from "@/components/ui/button";
12 import {
13 Empty,
14 EmptyContent,
15 EmptyDescription,
16 EmptyHeader,
17 EmptyMedia,
18 EmptyTitle,
19 } from "@/components/ui/empty";
20 import { cn } from "@/lib/utils";
21 import {
22 usePresentationState,
23 type ImageEditorMode,
24 } from "@/states/presentation-state";
25
26 export interface PresentationImagePlaceholderProps {
27 className?: string;
28 element: TImageElement & { query?: string; id?: string };
29 imageNotFound?: boolean;
30 onOpenEditor?: (mode: ImageEditorMode) => void;
31 }
32
33 export function PresentationImagePlaceholder({
34 className,
35 element,
36 imageNotFound = false,
37 onOpenEditor,
38 }: PresentationImagePlaceholderProps) {
39 const fileInputRef = useRef<HTMLInputElement>(null);
40 const editor = useEditorRef();
41 const readOnly = useEditorReadOnly();
42 const openPresentationImageEditor = usePresentationState(
43 (s) => s.openPresentationImageEditor,
44 );
45
46 const { uploadFile, isUploading, progress } = useUploadFile({
47 onUploadComplete: (file) => {
48 // Update the element's URL in the editor
49 editor.tf.setNodes(
50 {
51 url: file.ufsUrl,
52 } as Partial<TImageElement>,
53 { at: [], match: (n) => n === element },
54 );
55 },
56 onUploadError: (error) => {
57 toast.error("Failed to upload image");
58 console.error(error);
59 },
60 });
61 const uploadProgress = Math.trunc(progress);
62
63 const handleUploadClick = () => {
64 if (!readOnly && fileInputRef.current) {
65 fileInputRef.current.click();
66 }
67 };
68
69 const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
70 const file = event.target.files?.[0];
71 if (file && !readOnly) {
72 void uploadFile(file);
73 }
74 // Reset input
75 if (fileInputRef.current) {
76 fileInputRef.current.value = "";
77 }
78 };
79
80 const handleOpenEditor = (mode: ImageEditorMode) => {
81 if (readOnly) return;
82 if (onOpenEditor) {
83 onOpenEditor(mode);
84 } else if (element.id) {
85 // Create a bound updateElement function that captures the current editor and element
86 const boundUpdateElement = (props: Record<string, unknown>) => {
87 editor.tf.setNodes(props as Partial<TImageElement>, {
88 at: [],
89 match: (n) => n.id === element.id,
90 });
91 };
92 // Open the presentation image editor panel with the element ID, slide ID, and bound function
93 openPresentationImageEditor(mode, boundUpdateElement, element);
94 }
95 };
96
97 if (readOnly) {
98 return (
99 <div
100 className={cn(
101 "flex w-full items-center justify-center bg-muted/30 p-8",
102 className,
103 )}
104 >
105 <div className="flex flex-col items-center gap-2 text-muted-foreground">
106 <ImageIcon className="size-8" />
107 <span className="text-sm">No image</span>
108 </div>
109 </div>
110 );
111 }
112
113 return (
114 <div
115 className={cn(
116 "flex size-full items-center justify-center rounded-[inherit] border bg-background",
117 className,
118 )}
119 >
120 <Empty className="w-full bg-card">
121 <EmptyHeader>
122 <EmptyMedia variant="icon">
123 <ImageIcon />
124 </EmptyMedia>
125 <EmptyTitle className="text-primary">
126 {imageNotFound ? "Image not found" : "No image yet"}
127 </EmptyTitle>
128 <EmptyDescription>
129 {imageNotFound
130 ? "Try uploading, generating, or searching for another image"
131 : "Upload or generate an image"}
132 </EmptyDescription>
133 </EmptyHeader>
134
135 <EmptyContent className="flex-row gap-3 text-primary">
136 <Button
137 variant="outline"
138 onClick={handleUploadClick}
139 className="gap-2"
140 disabled={isUploading}
141 >
142 {isUploading ? (
143 <>
144 <Loader2 className="mr-2 size-4 animate-spin" />
145 {uploadProgress}%
146 </>
147 ) : (
148 <>
149 <Upload className="size-4" />
150 Upload
151 </>
152 )}
153 </Button>
154
155 <Button
156 variant="outline"
157 onClick={() => handleOpenEditor("generate")}
158 className="gap-2"
159 >
160 <Sparkles className="size-4" />
161 AI Image
162 </Button>
163
164 <Button
165 variant="outline"
166 onClick={() => handleOpenEditor("search")}
167 className="gap-2"
168 >
169 <Search className="size-4" />
170 Search
171 </Button>
172 </EmptyContent>
173 </Empty>
174
175 {/* Hidden file input */}
176 <input
177 aria-label="presentation image placeholder control"
178 ref={fileInputRef}
179 type="file"
180 accept="image/*"
181 className="hidden"
182 onChange={handleFileChange}
183 />
184 </div>
185 );
186 }
187
187 lines Plain Text