返回 presentation-ai
media-embed-node.tsx
根目录 / src / components / plate / ui / media-embed-node.tsx
1 "use client";
2
3 import {
4 ResizableProvider,
5 ResizeHandle,
6 useResizableValue,
7 } from "@platejs/resizable";
8 import { BlockSelectionPlugin } from "@platejs/selection/react";
9 import { nanoid } from "nanoid";
10 import Image from "next/image";
11 import { type TMediaEmbedElement } from "platejs";
12 import {
13 PlateElement,
14 useElement,
15 useFocused,
16 useReadOnly,
17 useSelected,
18 withHOC,
19 type PlateElementProps,
20 } from "platejs/react";
21 import { useEffect } from "react";
22 import LiteYouTubeEmbed from "react-lite-youtube-embed";
23 import { Tweet } from "react-tweet";
24
25 import { InfographicEmbedPlaceholder } from "@/components/notebook/presentation/editor/custom-elements/infographic-embed-placeholder";
26 import { cn } from "@/lib/utils";
27 import { usePresentationState } from "@/states/presentation-state";
28 import { Caption, CaptionTextarea } from "./caption";
29 import { MediaEmbedPlaceholder } from "./media-embed-placeholder";
30 import { extractEmbedId, generateEmbedUrl } from "./media-embeds";
31 import { Resizable } from "./resize-handle";
32 import { mediaResizeHandleVariants } from "./resize-handle";
33
34 const shouldSkipBlockSelect = (target: EventTarget | null) =>
35 target instanceof Element &&
36 !!target.closest("[data-media-resize-handle], [data-infographic-edit]");
37
38 // Extended properties for media embed elements
39 interface MediaEmbedProps {
40 width?: number | string;
41 alignment?: "center" | "left" | "right";
42 align?: "center" | "left" | "right";
43 provider?: string;
44 url?: string;
45 id?: string;
46 }
47
48 function getEmbedAspectRatioClass(provider: string): string {
49 switch (provider) {
50 case "vimeo":
51 return "pb-[75%]";
52 case "coub":
53 return "pb-[51.25%]";
54 case "dailymotion":
55 return "pb-[56.0417%]";
56 default:
57 return "pb-[56.25%]";
58 }
59 }
60
61 export const MediaEmbedElement = withHOC(
62 ResizableProvider,
63 function MediaEmbedElement(props: PlateElementProps<TMediaEmbedElement>) {
64 const element = useElement<TMediaEmbedElement>();
65 const focused = useFocused();
66 const selected = useSelected();
67 const readOnly = useReadOnly();
68 const width = useResizableValue("width");
69 const openInfographicGenerationEditor = usePresentationState(
70 (s) => s.openInfographicGenerationEditor,
71 );
72
73 // Get properties directly from element
74 const elementWithProps = element as TMediaEmbedElement & MediaEmbedProps;
75 const align =
76 elementWithProps.alignment ?? elementWithProps.align ?? "center";
77 const provider = elementWithProps.provider || "";
78 const url = elementWithProps.url || "";
79 const id = elementWithProps.id || "";
80
81 // Determine embed type and if we should show placeholder
82 const shouldShowPlaceholder = !url || url.trim() === "";
83 const isTweet = provider === "twitter";
84 const isVideo = provider && ["youtube", "vimeo", "loom"].includes(provider);
85 const isYoutube = provider === "youtube";
86 const isInfographic = provider === "infographic";
87
88 useEffect(() => {
89 if (id) return;
90
91 const editor = props.editor;
92 const path = props.path;
93 if (!editor || !path) return;
94
95 editor.tf.setNodes({ id: nanoid() }, { at: path });
96 }, [id, props.editor, props.path]);
97
98 const selectEmbedBlock = () => {
99 const currentId = (element as { id?: unknown }).id;
100 if (typeof currentId !== "string" || !currentId) return;
101
102 props.editor.getApi(BlockSelectionPlugin).blockSelection.set([currentId]);
103 props.editor.getApi(BlockSelectionPlugin).blockSelection.focus();
104 };
105
106 // Handle URL submission from placeholder
107 const handleUrlSubmit = (inputUrl: string) => {
108 // Convert URL to embed format using the existing logic
109 const embedUrl = generateEmbedUrl(inputUrl, provider || "");
110 const extractedId = extractEmbedId(inputUrl, provider || "");
111 console.log("embedUrl", embedUrl);
112 console.log("extractedId", extractedId);
113 // Update the element with the converted URL and extracted ID
114 const editor = props.editor;
115 const path = props.path;
116 if (editor && path) {
117 // Update the entire element with the converted URL, provider, and ID
118 editor.tf.setNodes(
119 {
120 ...element,
121 url: embedUrl,
122 provider: provider || "",
123 id: extractedId || "",
124 },
125 { at: path },
126 );
127 }
128 };
129
130 const handleInfographicEdit = () => {
131 const editor = props.editor;
132 const path = props.path;
133 if (!editor || !path) return;
134
135 openInfographicGenerationEditor((updates) => {
136 editor.tf.setNodes(
137 {
138 ...element,
139 ...updates,
140 provider: "infographic",
141 },
142 { at: path },
143 );
144 });
145 };
146
147 return (
148 <PlateElement className="py-2.5" {...props}>
149 <figure
150 className="group relative m-0 w-full cursor-default"
151 contentEditable={false}
152 onPointerDown={(event) => {
153 if (event.button !== 0) return;
154 if (shouldSkipBlockSelect(event.target)) return;
155 selectEmbedBlock();
156 }}
157 >
158 <Resizable
159 align={align}
160 options={{
161 align,
162 maxWidth: isTweet ? 550 : "100%",
163 minWidth: isTweet ? 300 : 100,
164 readOnly,
165 }}
166 className={cn("flex", !elementWithProps.width && "w-full")}
167 >
168 <ResizeHandle
169 className={mediaResizeHandleVariants({ direction: "left" })}
170 options={{ direction: "left" }}
171 data-media-resize-handle="true"
172 />
173
174 <div className="min-w-0 flex-1">
175 {shouldShowPlaceholder && isInfographic ? (
176 <InfographicEmbedPlaceholder
177 className="w-full"
178 onEdit={handleInfographicEdit}
179 />
180 ) : shouldShowPlaceholder ? (
181 <MediaEmbedPlaceholder
182 embedType={provider || "youtube"}
183 onUrlSubmit={handleUrlSubmit}
184 className="w-full"
185 />
186 ) : isVideo ? (
187 isYoutube ? (
188 <LiteYouTubeEmbed
189 id={id}
190 title="youtube"
191 wrapperClass={cn(
192 "rounded-sm",
193 focused && selected && "ring-2 ring-ring ring-offset-2",
194 "relative block w-full cursor-pointer bg-black bg-cover bg-center contain-content",
195 "[&.lyt-activated]:before:absolute [&.lyt-activated]:before:top-0 [&.lyt-activated]:before:h-15 [&.lyt-activated]:before:w-full [&.lyt-activated]:before:bg-top [&.lyt-activated]:before:bg-repeat-x [&.lyt-activated]:before:pb-12.5 [&.lyt-activated]:before:[transition:all_0.2s_cubic-bezier(0,0,0.2,1)]",
196 "[&.lyt-activated]:before:bg-[url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==)]",
197 'after:block after:pb-(--aspect-ratio) after:content-[""]',
198 "[&_>_iframe]:absolute [&_>_iframe]:top-0 [&_>_iframe]:left-0 [&_>_iframe]:size-full",
199 "[&_>_.lty-playbtn]:z-1 [&_>_.lty-playbtn]:h-11.5 [&_>_.lty-playbtn]:w-17.5 [&_>_.lty-playbtn]:rounded-[14%] [&_>_.lty-playbtn]:bg-[#212121] [&_>_.lty-playbtn]:opacity-80 [&_>_.lty-playbtn]:[transition:all_0.2s_cubic-bezier(0,0,0.2,1)]",
200 "[&:hover_>_.lty-playbtn]:bg-[red] [&:hover_>_.lty-playbtn]:opacity-100",
201 '[&_>_.lty-playbtn]:before:border-y-11 [&_>_.lty-playbtn]:before:border-r-0 [&_>_.lty-playbtn]:before:border-l-19 [&_>_.lty-playbtn]:before:border-[transparent_transparent_transparent_#fff] [&_>_.lty-playbtn]:before:content-[""]',
202 "[&_>_.lty-playbtn]:absolute [&_>_.lty-playbtn]:top-1/2 [&_>_.lty-playbtn]:left-1/2 [&_>_.lty-playbtn]:transform-[translate3d(-50%,-50%,0)]",
203 "[&_>_.lty-playbtn]:before:absolute [&_>_.lty-playbtn]:before:top-1/2 [&_>_.lty-playbtn]:before:left-1/2 [&_>_.lty-playbtn]:before:transform-[translate3d(-50%,-50%,0)]",
204 "[&.lyt-activated]:cursor-[unset]",
205 "[&.lyt-activated]:before:pointer-events-none [&.lyt-activated]:before:opacity-0",
206 "[&.lyt-activated_>_.lty-playbtn]:pointer-events-none [&.lyt-activated_>_.lty-playbtn]:opacity-0!",
207 )}
208 />
209 ) : (
210 <div
211 className={cn(
212 "relative w-full",
213 getEmbedAspectRatioClass(provider),
214 )}
215 >
216 <iframe
217 className={cn(
218 "absolute top-0 left-0 size-full rounded-sm border-0",
219 focused && selected && "ring-2 ring-ring ring-offset-2",
220 )}
221 title="embed"
222 src={url}
223 sandbox="allow-forms allow-scripts allow-popups allow-popups-to-escape-sandbox allow-presentation"
224 allowFullScreen
225 />
226 </div>
227 )
228 ) : isTweet ? (
229 <div
230 className={cn(
231 "w-full [&_.react-tweet-theme]:my-0",
232 !readOnly &&
233 selected &&
234 "[&_.react-tweet-theme]:ring-2 [&_.react-tweet-theme]:ring-ring [&_.react-tweet-theme]:ring-offset-2",
235 )}
236 >
237 <Tweet id={id} />
238 </div>
239 ) : provider === "image" || isInfographic ? (
240 // Image embed - display as img with proper sizing
241 <Image
242 unoptimized
243 width={400}
244 height={300}
245 src={url}
246 alt={
247 isInfographic ? "Embedded infographic" : "Embedded image"
248 }
249 className={cn(
250 "h-auto w-full rounded-sm object-contain",
251 focused && selected && "ring-2 ring-ring ring-offset-2",
252 )}
253 />
254 ) : (
255 // Fallback for other embed types (figma, maps, codepen, website)
256 <div className="relative w-full pb-[56.25%]">
257 <iframe
258 className={cn(
259 "absolute top-0 left-0 size-full rounded-sm border-0",
260 focused && selected && "ring-2 ring-ring ring-offset-2",
261 )}
262 title={`${provider || "embed"} embed`}
263 src={url}
264 allowFullScreen
265 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
266 sandbox="allow-forms allow-scripts allow-popups allow-popups-to-escape-sandbox allow-presentation"
267 />
268 </div>
269 )}
270 </div>
271
272 <ResizeHandle
273 className={mediaResizeHandleVariants({ direction: "right" })}
274 options={{ direction: "right" }}
275 data-media-resize-handle="true"
276 />
277 </Resizable>
278
279 <Caption style={{ width }} align={align}>
280 <CaptionTextarea placeholder="Write a caption..." />
281 </Caption>
282 </figure>
283
284 {props.children}
285 </PlateElement>
286 );
287 },
288 );
289
289 lines Plain Text