返回 presentation-ai
media-placeholder-node.tsx
根目录 / src / components / plate / ui / media-placeholder-node.tsx
1 /** biome-ignore-all lint/performance/noImgElement: This is a valid use case */
2 "use client";
3
4 import {
5 PlaceholderPlugin,
6 PlaceholderProvider,
7 updateUploadHistory,
8 } from "@platejs/media/react";
9 import { AudioLines, FileUp, Film, ImageIcon, Loader2Icon } from "lucide-react";
10 import Image from "next/image";
11 import { KEYS, type TPlaceholderElement } from "platejs";
12 import {
13 PlateElement,
14 useEditorPlugin,
15 withHOC,
16 type PlateElementProps,
17 } from "platejs/react";
18 import * as React from "react";
19 import { useFilePicker } from "use-file-picker";
20
21 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
22 import { cn } from "@/lib/utils";
23
24 const CONTENT: Record<
25 string,
26 {
27 accept: string[];
28 content: React.ReactNode;
29 icon: React.ReactNode;
30 }
31 > = {
32 [KEYS.audio]: {
33 accept: ["audio/*"],
34 content: "Add an audio file",
35 icon: <AudioLines />,
36 },
37 [KEYS.file]: {
38 accept: ["*"],
39 content: "Add a file",
40 icon: <FileUp />,
41 },
42 [KEYS.img]: {
43 accept: ["image/*"],
44 content: "Add an image",
45 icon: <ImageIcon />,
46 },
47 [KEYS.video]: {
48 accept: ["video/*"],
49 content: "Add a video",
50 icon: <Film />,
51 },
52 };
53
54 export const PlaceholderElement = withHOC(
55 PlaceholderProvider,
56 function PlaceholderElement(props: PlateElementProps<TPlaceholderElement>) {
57 const { editor, element } = props;
58
59 const { api } = useEditorPlugin(PlaceholderPlugin);
60
61 const { isUploading, progress, uploadedFile, uploadFile, uploadingFile } =
62 useUploadFile();
63
64 const loading = isUploading && uploadingFile;
65
66 const currentContent = CONTENT[element.mediaType];
67
68 const isImage = element.mediaType === KEYS.img;
69
70 const imageRef = React.useRef<HTMLImageElement>(null);
71
72 const { openFilePicker } = useFilePicker({
73 accept: currentContent!.accept,
74 multiple: true,
75 onFilesSelected: (data) => {
76 const updatedFiles = data.plainFiles ?? [];
77 const firstFile = updatedFiles[0];
78
79 if (!firstFile) return;
80
81 const restFiles = updatedFiles.slice(1);
82
83 replaceCurrentPlaceholder(firstFile);
84
85 if (restFiles.length > 0) {
86 editor.getTransforms(PlaceholderPlugin).insert.media(restFiles);
87 }
88 },
89 });
90
91 const replaceCurrentPlaceholder = React.useCallback(
92 (file: File) => {
93 void uploadFile(file);
94 api.placeholder.addUploadingFile(element.id as string, file);
95 },
96 [api.placeholder, element.id, uploadFile],
97 );
98
99 React.useEffect(() => {
100 if (!uploadedFile) return;
101
102 const path = editor.api.findPath(element);
103
104 editor.tf.withoutSaving(() => {
105 editor.tf.removeNodes({ at: path });
106
107 const node = {
108 children: [{ text: "" }],
109 initialHeight: imageRef.current?.height,
110 initialWidth: imageRef.current?.width,
111 isUpload: true,
112 name: element.mediaType === KEYS.file ? uploadedFile.name : "",
113 placeholderId: element.id as string,
114 type: element.mediaType!,
115 url: uploadedFile.url,
116 };
117
118 editor.tf.insertNodes(node, { at: path });
119
120 updateUploadHistory(editor, node);
121 });
122
123 api.placeholder.removeUploadingFile(element.id as string);
124 }, [uploadedFile, element.id]);
125
126 // React dev mode will call React.useEffect twice
127 const isReplaced = React.useRef(false);
128
129 /** Paste and drop */
130 React.useEffect(() => {
131 if (isReplaced.current) return;
132
133 isReplaced.current = true;
134 const currentFiles = api.placeholder.getUploadingFile(
135 element.id as string,
136 );
137
138 if (!currentFiles) return;
139
140 replaceCurrentPlaceholder(currentFiles);
141 }, [isReplaced]);
142
143 return (
144 <PlateElement className="my-1" {...props}>
145 {(!loading || !isImage) && (
146 <div
147 className={cn(
148 "flex cursor-pointer items-center rounded-sm bg-muted p-3 pr-9 select-none hover:bg-primary/10",
149 )}
150 onClick={() => !loading && openFilePicker()}
151 contentEditable={false}
152 >
153 <div className="relative mr-3 flex text-muted-foreground/80 [&_svg]:size-6">
154 {currentContent!.icon}
155 </div>
156 <div className="text-sm whitespace-nowrap text-muted-foreground">
157 <div>
158 {loading ? uploadingFile?.name : currentContent!.content}
159 </div>
160
161 {loading && !isImage && (
162 <div className="mt-1 flex items-center gap-1.5">
163 <div>{formatBytes(uploadingFile?.size ?? 0)}</div>
164 <div>–</div>
165 <div className="flex items-center">
166 <Loader2Icon className="mr-1 size-3.5 animate-spin text-muted-foreground" />
167 {progress ?? 0}%
168 </div>
169 </div>
170 )}
171 </div>
172 </div>
173 )}
174
175 {isImage && loading && (
176 <ImageProgress
177 file={uploadingFile}
178 imageRef={imageRef}
179 progress={progress}
180 />
181 )}
182
183 {props.children}
184 </PlateElement>
185 );
186 },
187 );
188
189 function ImageProgress({
190 className,
191 file,
192 imageRef,
193 progress = 0,
194 }: {
195 file: File;
196 className?: string;
197 imageRef?: React.RefObject<HTMLImageElement | null>;
198 progress?: number;
199 }) {
200 const [objectUrl, setObjectUrl] = React.useState<string | null>(null);
201
202 React.useEffect(() => {
203 const url = URL.createObjectURL(file);
204 setObjectUrl(url);
205
206 return () => {
207 URL.revokeObjectURL(url);
208 };
209 }, [file]);
210
211 if (!objectUrl) {
212 return null;
213 }
214
215 return (
216 <div className={cn("relative", className)} contentEditable={false}>
217 <Image
218 unoptimized
219 width={400}
220 height={300}
221 ref={imageRef}
222 className="h-auto w-full rounded-sm object-cover"
223 alt={file.name}
224 src={objectUrl}
225 />
226 {progress < 100 && (
227 <div className="absolute right-1 bottom-1 flex items-center gap-x-2 rounded-full bg-black/50 px-1 py-0.5">
228 <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
229 <span className="text-xs font-medium text-white">
230 {Math.round(progress)}%
231 </span>
232 </div>
233 )}
234 </div>
235 );
236 }
237
238 function formatBytes(
239 bytes: number,
240 opts: {
241 decimals?: number;
242 sizeType?: "accurate" | "normal";
243 } = {},
244 ) {
245 const { decimals = 0, sizeType = "normal" } = opts;
246
247 const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
248 const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
249
250 if (bytes === 0) return "0 Byte";
251
252 const i = Math.floor(Math.log(bytes) / Math.log(1024));
253
254 return `${(bytes / 1024 ** i).toFixed(decimals)} ${
255 sizeType === "accurate"
256 ? (accurateSizes[i] ?? "Bytest")
257 : (sizes[i] ?? "Bytes")
258 }`;
259 }
260
260 lines Plain Text