返回 presentation-ai
media-toolbar-button.tsx
根目录 / src / components / plate / ui / media-toolbar-button.tsx
1 "use client";
2
3 import { PlaceholderPlugin } from "@platejs/media/react";
4 import { type DropdownMenuProps } from "@radix-ui/react-dropdown-menu";
5 import {
6 AudioLinesIcon,
7 FileUpIcon,
8 FilmIcon,
9 ImageIcon,
10 LinkIcon,
11 } from "lucide-react";
12 import { isUrl, KEYS } from "platejs";
13 import { useEditorRef } from "platejs/react";
14 import * as React from "react";
15 import { toast } from "sonner";
16 import { useFilePicker } from "use-file-picker";
17
18 import {
19 AlertDialog,
20 AlertDialogAction,
21 AlertDialogCancel,
22 AlertDialogContent,
23 AlertDialogDescription,
24 AlertDialogFooter,
25 AlertDialogHeader,
26 AlertDialogTitle,
27 } from "@/components/plate/ui/alert-dialog";
28 import {
29 DropdownMenu,
30 DropdownMenuContent,
31 DropdownMenuGroup,
32 DropdownMenuItem,
33 DropdownMenuTrigger,
34 } from "@/components/plate/ui/dropdown-menu";
35 import { Input } from "@/components/plate/ui/input";
36 import {
37 ToolbarSplitButton,
38 ToolbarSplitButtonPrimary,
39 ToolbarSplitButtonSecondary,
40 } from "./toolbar";
41
42 const MEDIA_CONFIG: Record<
43 string,
44 {
45 accept: string[];
46 icon: React.ReactNode;
47 title: string;
48 tooltip: string;
49 }
50 > = {
51 [KEYS.audio]: {
52 accept: ["audio/*"],
53 icon: <AudioLinesIcon className="size-4" />,
54 title: "Insert Audio",
55 tooltip: "Audio",
56 },
57 [KEYS.file]: {
58 accept: ["*"],
59 icon: <FileUpIcon className="size-4" />,
60 title: "Insert File",
61 tooltip: "File",
62 },
63 [KEYS.img]: {
64 accept: ["image/*"],
65 icon: <ImageIcon className="size-4" />,
66 title: "Insert Image",
67 tooltip: "Image",
68 },
69 [KEYS.video]: {
70 accept: ["video/*"],
71 icon: <FilmIcon className="size-4" />,
72 title: "Insert Video",
73 tooltip: "Video",
74 },
75 };
76
77 const UPLOADTHING_HOSTS = ["utfs.io", "ufs.sh"] as const;
78
79 function isUploadThingHostname(hostname: string): boolean {
80 return UPLOADTHING_HOSTS.some(
81 (host) => hostname === host || hostname.endsWith(`.${host}`),
82 );
83 }
84
85 function resolveFileNameFromUrl(url: string): string | undefined {
86 try {
87 const parsedUrl = new URL(url);
88 if (isUploadThingHostname(parsedUrl.hostname)) {
89 return undefined;
90 }
91
92 const urlSegments = parsedUrl.pathname.split("/");
93 const lastSegment = urlSegments.at(-1);
94 if (!lastSegment) {
95 return undefined;
96 }
97
98 const decodedName = decodeURIComponent(lastSegment).trim();
99 return decodedName.length > 0 ? decodedName : undefined;
100 } catch {
101 return undefined;
102 }
103 }
104
105 export function MediaToolbarButton({
106 nodeType,
107 ...props
108 }: DropdownMenuProps & { nodeType: string }) {
109 const currentConfig = MEDIA_CONFIG[nodeType];
110
111 const editor = useEditorRef();
112 const [open, setOpen] = React.useState(false);
113 const [dialogOpen, setDialogOpen] = React.useState(false);
114
115 const { openFilePicker } = useFilePicker({
116 accept: currentConfig!.accept,
117 multiple: true,
118 onFilesSelected: (data) => {
119 const updatedFiles = data.plainFiles ?? [];
120 if (updatedFiles.length === 0) return;
121
122 editor.getTransforms(PlaceholderPlugin).insert.media(updatedFiles);
123 },
124 });
125
126 return (
127 <>
128 <ToolbarSplitButton
129 onClick={() => {
130 openFilePicker();
131 }}
132 onKeyDown={(e) => {
133 if (e.key === "ArrowDown") {
134 e.preventDefault();
135 setOpen(true);
136 }
137 }}
138 pressed={open}
139 >
140 <ToolbarSplitButtonPrimary>
141 {currentConfig!.icon}
142 </ToolbarSplitButtonPrimary>
143
144 <DropdownMenu
145 open={open}
146 onOpenChange={setOpen}
147 modal={false}
148 {...props}
149 >
150 <DropdownMenuTrigger asChild>
151 <ToolbarSplitButtonSecondary />
152 </DropdownMenuTrigger>
153
154 <DropdownMenuContent
155 onClick={(e) => e.stopPropagation()}
156 align="start"
157 alignOffset={-32}
158 >
159 <DropdownMenuGroup>
160 <DropdownMenuItem onSelect={() => openFilePicker()}>
161 {currentConfig!.icon}
162 Upload from computer
163 </DropdownMenuItem>
164 <DropdownMenuItem onSelect={() => setDialogOpen(true)}>
165 <LinkIcon />
166 Insert via URL
167 </DropdownMenuItem>
168 </DropdownMenuGroup>
169 </DropdownMenuContent>
170 </DropdownMenu>
171 </ToolbarSplitButton>
172
173 <AlertDialog
174 open={dialogOpen}
175 onOpenChange={(value) => {
176 setDialogOpen(value);
177 }}
178 >
179 <AlertDialogContent className="gap-6">
180 <MediaUrlDialogContent
181 currentConfig={currentConfig!}
182 nodeType={nodeType}
183 setOpen={setDialogOpen}
184 />
185 </AlertDialogContent>
186 </AlertDialog>
187 </>
188 );
189 }
190
191 function MediaUrlDialogContent({
192 currentConfig,
193 nodeType,
194 setOpen,
195 }: {
196 currentConfig: (typeof MEDIA_CONFIG)[string];
197 nodeType: string;
198 setOpen: (value: boolean) => void;
199 }) {
200 const editor = useEditorRef();
201 const [url, setUrl] = React.useState("");
202
203 const embedMedia = React.useCallback(() => {
204 if (!isUrl(url)) return toast.error("Invalid URL");
205
206 setOpen(false);
207 editor.tf.insertNodes({
208 children: [{ text: "" }],
209 name: nodeType === KEYS.file ? resolveFileNameFromUrl(url) : undefined,
210 type: nodeType,
211 url,
212 });
213 }, [url, editor, nodeType, setOpen]);
214
215 return (
216 <>
217 <AlertDialogHeader>
218 <AlertDialogTitle>{currentConfig.title}</AlertDialogTitle>
219 </AlertDialogHeader>
220
221 <AlertDialogDescription className="group relative w-full">
222 <label
223 className="absolute top-1/2 block -translate-y-1/2 cursor-text px-1 text-sm text-muted-foreground/70 transition-all group-focus-within:pointer-events-none group-focus-within:top-0 group-focus-within:cursor-default group-focus-within:text-xs group-focus-within:font-medium group-focus-within:text-foreground has-[+input:not(:placeholder-shown)]:pointer-events-none has-[+input:not(:placeholder-shown)]:top-0 has-[+input:not(:placeholder-shown)]:cursor-default has-[+input:not(:placeholder-shown)]:text-xs has-[+input:not(:placeholder-shown)]:font-medium has-[+input:not(:placeholder-shown)]:text-foreground"
224 htmlFor="url"
225 >
226 <span className="inline-flex bg-background px-2">URL</span>
227 </label>
228 <Input
229 id="url"
230 className="w-full"
231 value={url}
232 onChange={(e) => setUrl(e.target.value)}
233 onKeyDown={(e) => {
234 if (e.key === "Enter") embedMedia();
235 }}
236 placeholder=""
237 type="url"
238 autoFocus
239 />
240 </AlertDialogDescription>
241
242 <AlertDialogFooter>
243 <AlertDialogCancel>Cancel</AlertDialogCancel>
244 <AlertDialogAction
245 onClick={(e) => {
246 e.preventDefault();
247 embedMedia();
248 }}
249 >
250 Accept
251 </AlertDialogAction>
252 </AlertDialogFooter>
253 </>
254 );
255 }
256
256 lines Plain Text