返回 presentation-ai
media-embeds.tsx
根目录 / src / components / plate / ui / media-embeds.tsx
1 "use client";
2
3 import {
4 ChartNoAxesColumnIncreasing,
5 Code,
6 Figma,
7 Image as ImageIcon,
8 Link,
9 MapPin,
10 Play,
11 Twitter,
12 Video,
13 Youtube,
14 } from "lucide-react";
15 import { nanoid } from "nanoid";
16 import { KEYS, type TElement } from "platejs";
17 // NOTE: These are React components now!
18 import { type ReactElement } from "react";
19
20 export interface EmbedConfig {
21 name: string;
22 urlPattern: RegExp;
23 embedUrlGenerator: (url: string) => string;
24 icon?: string;
25 }
26
27 export type EmbedTypeConfigItem = {
28 name: string;
29 icon: ReactElement;
30 description: string;
31 placeholder: string;
32 urlPattern: RegExp;
33 embedUrlGenerator: (url: string) => string;
34 };
35
36 // Filtered EMBED_CONFIGS that only includes embed types defined in mediaEmbedItems
37 const EMBED_CONFIGS: Record<string, EmbedConfig> = {
38 youtube: {
39 name: "YouTube",
40 // Supports: youtube.com/watch?v=, youtu.be/, youtube.com/embed/, youtube.com/v/, youtube.com/shorts/
41 urlPattern:
42 /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|.*[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/i,
43 embedUrlGenerator: (url: string) => {
44 const match = url.match(
45 /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|.*[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/i,
46 );
47 const videoId = match?.[1];
48 return videoId ? `https://www.youtube.com/embed/${videoId}` : url;
49 },
50 },
51 vimeo: {
52 name: "Vimeo",
53 // Supports: vimeo.com/123456, vimeo.com/video/123456, player.vimeo.com/video/123456
54 urlPattern: /vimeo\.com\/(?:video\/)?(\d+)/i,
55 embedUrlGenerator: (url: string) => {
56 const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
57 const videoId = match?.[1];
58 return videoId ? `https://player.vimeo.com/video/${videoId}` : url;
59 },
60 },
61 loom: {
62 name: "Loom",
63 // Supports: loom.com/share/, loom.com/embed/
64 urlPattern: /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/i,
65 embedUrlGenerator: (url: string) => {
66 const match = url.match(/loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/i);
67 const videoId = match?.[1];
68 return videoId ? `https://www.loom.com/embed/${videoId}` : url;
69 },
70 },
71 twitter: {
72 name: "Twitter/X",
73 // Supports both twitter.com and x.com
74 urlPattern: /(?:twitter\.com|x\.com)\/([^/]+)\/status\/(\d+)/i,
75 embedUrlGenerator: (url: string) => {
76 // Normalize to x.com format
77 return url.replace("twitter.com", "x.com");
78 },
79 },
80 figma: {
81 name: "Figma",
82 // Supports: figma.com/file/, figma.com/proto/, figma.com/design/
83 urlPattern: /figma\.com\/(?:file|proto|design)\/([a-zA-Z0-9]+)/i,
84 embedUrlGenerator: (url: string) => {
85 return `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(url)}`;
86 },
87 },
88 maps: {
89 name: "Google Maps",
90 urlPattern: /google\.com\/maps/i,
91 embedUrlGenerator: (url: string) => {
92 // Extract coordinates or place info if available
93 const placeMatch = url.match(/place\/([^/]+)/);
94 const coordMatch = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
95
96 if (placeMatch) {
97 return `https://www.google.com/maps/embed/v1/place?key=&q=${encodeURIComponent(placeMatch[1] || "")}`;
98 } else if (coordMatch) {
99 return `https://www.google.com/maps/embed/v1/view?key=&center=${coordMatch[1]},${coordMatch[2]}&zoom=14`;
100 }
101
102 // Fallback: try to convert to embed format
103 return url.replace("/maps/", "/maps/embed/");
104 },
105 },
106 codepen: {
107 name: "CodePen",
108 urlPattern: /codepen\.io\/([^/]+)\/(?:pen|embed)\/([a-zA-Z0-9]+)/i,
109 embedUrlGenerator: (url: string) => {
110 const match = url.match(
111 /codepen\.io\/([^/]+)\/(?:pen|embed)\/([a-zA-Z0-9]+)/i,
112 );
113 const username = match?.[1];
114 const penId = match?.[2];
115 return username && penId
116 ? `https://codepen.io/${username}/embed/${penId}?default-tab=result`
117 : url;
118 },
119 },
120 website: {
121 name: "Website",
122 urlPattern: /^https?:\/\/.+/i,
123 embedUrlGenerator: (url: string) => url,
124 },
125 image: {
126 name: "Image",
127 urlPattern: /^https?:\/\/.+/i,
128 embedUrlGenerator: (url: string) => url,
129 },
130 infographic: {
131 name: "Infographic",
132 urlPattern: /^https?:\/\/.+/i,
133 embedUrlGenerator: (url: string) => url,
134 },
135 };
136
137 export type MediaEmbedItem = {
138 key: string;
139 label: string;
140 embedType: string;
141 icon: ReactElement;
142 description: string;
143 };
144
145 export const mediaEmbedItems: MediaEmbedItem[] = [
146 {
147 key: "youtube",
148 label: "YouTube",
149 embedType: "youtube",
150 icon: <Youtube className="size-7" />,
151 description: "Embed YouTube videos",
152 },
153 {
154 key: "vimeo",
155 label: "Vimeo",
156 embedType: "vimeo",
157 icon: <Video className="size-7" />,
158 description: "Embed Vimeo videos",
159 },
160 {
161 key: "loom",
162 label: "Loom",
163 embedType: "loom",
164 icon: <Play className="size-7" />,
165 description: "Embed Loom recordings",
166 },
167 {
168 key: "twitter",
169 label: "Twitter",
170 embedType: "twitter",
171 icon: <Twitter className="size-7" />,
172 description: "Embed Twitter posts",
173 },
174 {
175 key: "figma",
176 label: "Figma",
177 embedType: "figma",
178 icon: <Figma className="size-7" />,
179 description: "Embed Figma designs",
180 },
181 {
182 key: "maps",
183 label: "Google Maps",
184 embedType: "maps",
185 icon: <MapPin className="size-7" />,
186 description: "Embed Google Maps",
187 },
188 {
189 key: "codepen",
190 label: "CodePen",
191 embedType: "codepen",
192 icon: <Code className="size-7" />,
193 description: "Embed CodePen demos",
194 },
195 {
196 key: "image",
197 label: "Image",
198 embedType: "image",
199 icon: <ImageIcon className="size-7" />,
200 description: "Embed an image from a URL",
201 },
202 {
203 key: "infographic",
204 label: "Infographic",
205 embedType: "infographic",
206 icon: <ChartNoAxesColumnIncreasing className="size-7" />,
207 description: "Generate an AI infographic",
208 },
209 {
210 key: "website",
211 label: "Website",
212 embedType: "website",
213 icon: <Link className="size-7" />,
214 description: "Embed any website link",
215 },
216 ];
217
218 export function createMediaEmbedNode(embedType: string): TElement {
219 return {
220 type: KEYS.mediaEmbed,
221 url: "",
222 provider: embedType,
223 id: nanoid(),
224 children: [{ text: "" }],
225 } as TElement;
226 }
227
228 // Create embedTypeConfig that combines mediaEmbedItems with EMBED_CONFIGS
229 export const embedTypeConfig = mediaEmbedItems.reduce(
230 (config, item) => {
231 const embedConfig = EMBED_CONFIGS[item.embedType];
232 if (embedConfig) {
233 config[item.embedType] = {
234 name: item.label,
235 icon: item.icon,
236 description: item.description,
237 placeholder: `Enter ${item.label} URL...`,
238 urlPattern: embedConfig.urlPattern,
239 embedUrlGenerator: embedConfig.embedUrlGenerator,
240 };
241 }
242 return config;
243 },
244 {} as Record<string, EmbedTypeConfigItem>,
245 );
246
247 // Utility functions for embed handling
248 export function detectEmbedType(url: string): string | null {
249 for (const [type, config] of Object.entries(EMBED_CONFIGS)) {
250 if (config.urlPattern.test(url)) {
251 return type;
252 }
253 }
254 return null;
255 }
256
257 export function generateEmbedUrl(url: string, embedType?: string): string {
258 // Auto-detect if type not provided
259 const type = embedType || detectEmbedType(url);
260 if (!type) return url;
261
262 const config = EMBED_CONFIGS[type];
263 if (!config) return url;
264
265 return config.embedUrlGenerator(url);
266 }
267
268 export function isValidEmbedUrl(url: string, embedType?: string): boolean {
269 // Auto-detect if type not provided
270 const type = embedType || detectEmbedType(url);
271 if (!type) return false;
272
273 const config = EMBED_CONFIGS[type];
274 if (!config) return false;
275
276 return config.urlPattern.test(url);
277 }
278
279 export function getEmbedConfig(embedType: string): EmbedConfig | null {
280 return EMBED_CONFIGS[embedType] || null;
281 }
282
283 export function getAllEmbedTypes(): Array<{
284 type: string;
285 config: EmbedConfig;
286 }> {
287 return Object.entries(EMBED_CONFIGS).map(([type, config]) => ({
288 type,
289 config,
290 }));
291 }
292
293 export function extractYouTubeVideoId(url: string): string | null {
294 const match = url.match(
295 /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|.*[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/i,
296 );
297 return match?.[1] || null;
298 }
299
300 function extractTwitterTweetId(url: string): string | null {
301 const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)\/status\/(\d+)/i);
302 return match?.[2] || null;
303 }
304
305 function extractLoomVideoId(url: string): string | null {
306 const match = url.match(/loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/i);
307 return match?.[1] || null;
308 }
309
310 function extractVimeoVideoId(url: string): string | null {
311 const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
312 return match?.[1] || null;
313 }
314
315 function extractCodePenId(url: string): string | null {
316 const match = url.match(
317 /codepen\.io\/([^/]+)\/(?:pen|embed)\/([a-zA-Z0-9]+)/i,
318 );
319 return match?.[2] || null;
320 }
321
322 export function extractEmbedId(url: string, embedType?: string): string | null {
323 const type = embedType || detectEmbedType(url);
324 if (!type) return null;
325
326 switch (type) {
327 case "youtube":
328 return extractYouTubeVideoId(url);
329 case "twitter":
330 return extractTwitterTweetId(url);
331 case "loom":
332 return extractLoomVideoId(url);
333 case "vimeo":
334 return extractVimeoVideoId(url);
335 case "codepen":
336 return extractCodePenId(url);
337 default:
338 return null;
339 }
340 }
341
341 lines Plain Text