Skip to content

Commit a8fcb2f

Browse files
fix(dashboard): correctly detect videos on upload and URL embeds (#4635)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sarah Bawabe <[email protected]>
1 parent bcc3e06 commit a8fcb2f

File tree

6 files changed

+224
-32
lines changed

6 files changed

+224
-32
lines changed

packages/fern-dashboard/src/components/editor/floating-menu-options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const slashMenuItems: (SuggestionItem & { aliases?: string[] })[] = [
6363
group: "Media",
6464
keywords: ["image", "img", "picture", "media"],
6565
onSelect: ({ editor }) => {
66-
editor.chain().focus().setMediaUploadNode().run();
66+
editor.chain().focus().setMediaUploadNode({ mediaType: "image", accept: "image/*" }).run();
6767
}
6868
},
6969
{
@@ -74,7 +74,7 @@ export const slashMenuItems: (SuggestionItem & { aliases?: string[] })[] = [
7474
group: "Media",
7575
keywords: ["video", "embed", "iframe", "media"],
7676
onSelect: ({ editor }) => {
77-
editor.chain().focus().setMediaUploadNode().run();
77+
editor.chain().focus().setMediaUploadNode({ mediaType: "video", accept: "video/*" }).run();
7878
}
7979
},
8080
{

packages/fern-dashboard/src/components/editor/tiptap-node/media-upload-node/media-upload-node-extension.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export interface MediaUploadNodeOptions {
1818
* @default 'image'
1919
*/
2020
type?: string | NodeType | undefined;
21+
/**
22+
* The media type to upload (image, video, or auto for both).
23+
* @default 'auto'
24+
*/
25+
mediaType?: "image" | "video" | "auto";
2126
/**
2227
* Acceptable file types for upload.
2328
* @default 'image/*,video/*'
@@ -78,6 +83,7 @@ export const MediaUploadNode = Node.create<MediaUploadNodeOptions>({
7883
addOptions() {
7984
return {
8085
type: "media",
86+
mediaType: "auto",
8187
accept: "image/*,video/*",
8288
limit: 1,
8389
maxSize: 0,
@@ -90,6 +96,9 @@ export const MediaUploadNode = Node.create<MediaUploadNodeOptions>({
9096

9197
addAttributes() {
9298
return {
99+
mediaType: {
100+
default: this.options.mediaType
101+
},
93102
accept: {
94103
default: this.options.accept
95104
},
@@ -103,11 +112,22 @@ export const MediaUploadNode = Node.create<MediaUploadNodeOptions>({
103112
},
104113

105114
parseHTML() {
106-
return [{ tag: "div", attrs: { "data-type": "image-upload" } }];
115+
return [
116+
{
117+
tag: 'div[data-type="image-upload"]',
118+
getAttrs: () => ({ mediaType: "image" })
119+
},
120+
{
121+
tag: 'div[data-type="video-upload"]',
122+
getAttrs: () => ({ mediaType: "video" })
123+
}
124+
];
107125
},
108126

109127
renderHTML({ HTMLAttributes }) {
110-
return ["div", mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes)];
128+
const mediaType = (HTMLAttributes.mediaType ?? "auto") as "image" | "video" | "auto";
129+
const dataType = mediaType === "video" ? "video-upload" : "image-upload";
130+
return ["div", mergeAttributes({ "data-type": dataType }, HTMLAttributes)];
111131
},
112132

113133
addNodeView() {

packages/fern-dashboard/src/components/editor/tiptap-node/media-upload-node/media-upload-node.tsx

Lines changed: 176 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -308,27 +308,59 @@ const MediaUploadPreview: React.FC<MediaUploadPreviewProps> = ({ fileItem }) =>
308308
};
309309

310310
export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
311-
const { accept, limit, maxSize } = props.node.attrs;
311+
const { mediaType = "auto", accept, limit, maxSize } = props.node.attrs;
312312
const inputRef = React.useRef<HTMLInputElement>(null);
313313
const extension = props.extension;
314314
const [imageUrl, setImageUrl] = React.useState("");
315315

316+
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".avi", ".mov", ".wmv", ".flv", ".mkv"];
317+
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff"];
318+
319+
const computedAccept =
320+
mediaType === "image" ? "image/*" : mediaType === "video" ? "video/*" : accept || "image/*,video/*";
321+
316322
const uploadOptions: UploadOptions = {
317323
maxSize,
318324
limit,
319-
accept,
325+
accept: computedAccept,
320326
upload: extension.options.upload,
321327
onSuccess: extension.options.onSuccess,
322328
onError: extension.options.onError
323329
};
324330

325331
const { fileItems, uploadFiles } = useFileUpload(uploadOptions);
326332

333+
const isImage = (file: File): boolean => {
334+
if (file.type && file.type.startsWith("image/")) {
335+
return true;
336+
}
337+
const fileName = (file.name || "").toLowerCase();
338+
return IMAGE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
339+
};
340+
327341
const isVideo = (file: File): boolean => {
328-
return file.type.startsWith("video/");
342+
if (file.type && file.type.startsWith("video/")) {
343+
return true;
344+
}
345+
const fileName = (file.name || "").toLowerCase();
346+
return VIDEO_EXTENSIONS.some((ext) => fileName.endsWith(ext));
329347
};
330348

331349
const handleUpload = async (files: File[]) => {
350+
if (mediaType === "image") {
351+
const nonImageFiles = files.filter((file) => !isImage(file));
352+
if (nonImageFiles.length > 0) {
353+
extension.options.onError?.(new Error("Expected an image file"));
354+
return;
355+
}
356+
} else if (mediaType === "video") {
357+
const nonVideoFiles = files.filter((file) => !isVideo(file));
358+
if (nonVideoFiles.length > 0) {
359+
extension.options.onError?.(new Error("Expected a video file"));
360+
return;
361+
}
362+
}
363+
332364
const urls = await uploadFiles(files);
333365

334366
if (urls.length > 0) {
@@ -339,7 +371,7 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
339371
const file = files[index];
340372
const filename = file?.name.replace(/\.[^/.]+$/, "") || "unknown";
341373

342-
if (file && isVideo(file)) {
374+
if (mediaType === "video" || (mediaType === "auto" && file && isVideo(file))) {
343375
return createCustomElementNode(`<video src="${url}" title="${filename}" controls></video>`);
344376
} else {
345377
return createCustomElementNode(`<img src="${url}" alt="${filename}" title="${filename}" />`);
@@ -365,10 +397,87 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
365397
void handleUpload(Array.from(files));
366398
};
367399

400+
const isImageUrl = (url: string): boolean => {
401+
try {
402+
const pathname = new URL(url).pathname.toLowerCase();
403+
return IMAGE_EXTENSIONS.some((ext) => pathname.endsWith(ext));
404+
} catch {
405+
const lowerUrl = url.toLowerCase();
406+
return IMAGE_EXTENSIONS.some((ext) => lowerUrl.endsWith(ext));
407+
}
408+
};
409+
410+
const isDirectVideoFileUrl = (url: string): boolean => {
411+
try {
412+
const pathname = new URL(url).pathname.toLowerCase();
413+
return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));
414+
} catch {
415+
const lowerUrl = url.toLowerCase();
416+
return VIDEO_EXTENSIONS.some((ext) => lowerUrl.endsWith(ext));
417+
}
418+
};
419+
420+
const getEmbedInfo = (url: string): string | null => {
421+
try {
422+
const urlObj = new URL(url);
423+
const hostname = urlObj.hostname.toLowerCase();
424+
const pathname = urlObj.pathname;
425+
426+
if (hostname.includes("youtube.com") || hostname.includes("youtu.be")) {
427+
if (hostname.includes("youtu.be")) {
428+
const videoId = pathname.split("/")[1]?.split("?")[0];
429+
if (videoId) {
430+
return `https://www.youtube.com/embed/${videoId}`;
431+
}
432+
} else if (pathname.includes("/watch")) {
433+
const videoId = urlObj.searchParams.get("v");
434+
if (videoId) {
435+
return `https://www.youtube.com/embed/${videoId}`;
436+
}
437+
} else if (pathname.includes("/shorts/")) {
438+
const videoId = pathname.split("/shorts/")[1]?.split("?")[0];
439+
if (videoId) {
440+
return `https://www.youtube.com/embed/${videoId}`;
441+
}
442+
} else if (pathname.includes("/embed/")) {
443+
return url;
444+
}
445+
}
446+
447+
if (hostname.includes("vimeo.com")) {
448+
if (hostname.includes("player.vimeo.com") && pathname.includes("/video/")) {
449+
return url;
450+
}
451+
const videoId = pathname.split("/").filter(Boolean)[0];
452+
if (videoId && /^\d+$/.test(videoId)) {
453+
return `https://player.vimeo.com/video/${videoId}`;
454+
}
455+
}
456+
457+
if (hostname.includes("loom.com")) {
458+
if (pathname.includes("/embed/")) {
459+
return url;
460+
}
461+
if (hostname.includes("share.loom.com") && pathname.includes("/share/")) {
462+
const videoId = pathname.split("/share/")[1]?.split("?")[0];
463+
if (videoId) {
464+
return `https://www.loom.com/embed/${videoId}`;
465+
}
466+
}
467+
}
468+
469+
return null;
470+
} catch {
471+
return null;
472+
}
473+
};
474+
475+
const isKnownVideoEmbed = (url: string): boolean => {
476+
return getEmbedInfo(url) !== null;
477+
};
478+
368479
const isVideoUrl = (url: string): boolean => {
369-
const videoExtensions = [".mp4", ".webm", ".ogg", ".avi", ".mov", ".wmv", ".flv", ".mkv"];
370-
const lowerUrl = url.toLowerCase();
371-
return videoExtensions.some((ext) => lowerUrl.includes(ext));
480+
return isDirectVideoFileUrl(url) || isKnownVideoEmbed(url);
372481
};
373482

374483
// Handles URL submission via input field in URL tab
@@ -383,9 +492,55 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
383492
return;
384493
}
385494

386-
const newMediaNode = isVideoUrl(imageUrl)
387-
? createCustomElementNode(`<video src="${imageUrl}" controls></video>`)
388-
: createCustomElementNode(`<img src="${imageUrl}" />`);
495+
let parsedUrl: URL;
496+
try {
497+
parsedUrl = new URL(imageUrl);
498+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
499+
extension.options.onError?.(new Error("Invalid URL protocol"));
500+
return;
501+
}
502+
} catch {
503+
extension.options.onError?.(new Error("Invalid URL"));
504+
return;
505+
}
506+
507+
let newMediaNode;
508+
509+
if (mediaType === "image") {
510+
if (!isImageUrl(imageUrl)) {
511+
extension.options.onError?.(new Error("Expected an image URL"));
512+
return;
513+
}
514+
newMediaNode = createCustomElementNode(`<img src="${imageUrl}" />`);
515+
} else if (mediaType === "video") {
516+
if (isDirectVideoFileUrl(imageUrl)) {
517+
newMediaNode = createCustomElementNode(`<video src="${imageUrl}" controls></video>`);
518+
} else if (isKnownVideoEmbed(imageUrl)) {
519+
const embedUrl = getEmbedInfo(imageUrl) || imageUrl;
520+
newMediaNode = createCustomElementNode(
521+
`<iframe src="${embedUrl}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="Video" width="100%" height="315"></iframe>`
522+
);
523+
} else {
524+
newMediaNode = createCustomElementNode(
525+
`<iframe src="${imageUrl}" sandbox="allow-same-origin allow-scripts allow-presentation" referrerpolicy="strict-origin-when-cross-origin" frameborder="0" allowfullscreen title="Media" width="100%" height="315"></iframe>`
526+
);
527+
}
528+
} else {
529+
if (isDirectVideoFileUrl(imageUrl)) {
530+
newMediaNode = createCustomElementNode(`<video src="${imageUrl}" controls></video>`);
531+
} else if (isKnownVideoEmbed(imageUrl)) {
532+
const embedUrl = getEmbedInfo(imageUrl) || imageUrl;
533+
newMediaNode = createCustomElementNode(
534+
`<iframe src="${embedUrl}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="Video" width="100%" height="315"></iframe>`
535+
);
536+
} else if (isImageUrl(imageUrl)) {
537+
newMediaNode = createCustomElementNode(`<img src="${imageUrl}" />`);
538+
} else {
539+
newMediaNode = createCustomElementNode(
540+
`<iframe src="${imageUrl}" sandbox="allow-same-origin allow-scripts allow-presentation" referrerpolicy="strict-origin-when-cross-origin" frameborder="0" allowfullscreen title="Media" width="100%" height="315"></iframe>`
541+
);
542+
}
543+
}
389544

390545
props.editor
391546
.chain()
@@ -397,6 +552,12 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
397552

398553
const hasFiles = fileItems.length > 0;
399554

555+
const mediaTypeLabel = mediaType === "image" ? "image" : mediaType === "video" ? "video" : "media";
556+
const uploadButtonLabel = `Upload ${mediaTypeLabel}`;
557+
const urlPlaceholder = `Paste ${mediaTypeLabel} URL...`;
558+
const embedButtonLabel = `Embed ${mediaTypeLabel}`;
559+
const addMediaLabel = `Add ${mediaTypeLabel}`;
560+
400561
return (
401562
<NodeViewWrapper className="tiptap-image-upload" tabIndex={0}>
402563
{!hasFiles && (
@@ -406,7 +567,7 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
406567
<div className="flex w-full items-center justify-center rounded-lg border-2 border-dashed border-gray-500 p-3">
407568
<div className="tiptap-image-upload-text flex items-center gap-2">
408569
<CloudArrowUpIcon className="size-8" />
409-
<p>Add media</p>
570+
<p>{addMediaLabel}</p>
410571
</div>
411572
</div>
412573
</MediaUploadDragArea>
@@ -416,10 +577,10 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
416577
<Tab title="Upload">
417578
<Button asChild className="-mt-6">
418579
<button className="relative w-full">
419-
Upload file
580+
{uploadButtonLabel}
420581
<input
421582
type="file"
422-
accept="image/*,video/*"
583+
accept={computedAccept}
423584
onChange={(e) => {
424585
const files = e.target.files;
425586
if (!files || files.length === 0) {
@@ -437,13 +598,13 @@ export const MediaUploadNode: React.FC<NodeViewProps> = (props) => {
437598
<div className="-mt-3 flex flex-col gap-2">
438599
<Input
439600
type="url"
440-
placeholder="Paste image or video URL..."
601+
placeholder={urlPlaceholder}
441602
value={imageUrl}
442603
onChange={(e) => setImageUrl(e.target.value)}
443604
className="w-full"
444605
/>
445606
<Button onClick={handleUrlSubmit} disabled={!imageUrl.trim()} className="w-full">
446-
Embed media
607+
{embedButtonLabel}
447608
</Button>
448609
</div>
449610
</Tab>

packages/fern-dashboard/src/components/tiptap-ui/slash-dropdown-menu/use-slash-dropdown-menu.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,7 @@ const getItemImplementations = () => {
290290
image: {
291291
check: (editor: Editor) => isNodeInSchema("image", editor),
292292
action: ({ editor }: { editor: Editor }) => {
293-
editor
294-
.chain()
295-
.focus()
296-
.insertContent({
297-
type: "imageUpload"
298-
})
299-
.run();
293+
editor.chain().focus().setMediaUploadNode({ mediaType: "image", accept: "image/*" }).run();
300294
}
301295
}
302296
};

packages/fern-docs/mdx/src/__test__/convert.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("mdxToHtml and htmlToMdx", () => {
99
const mdxWithCustom = `# Hello <Custom value="foo" />`;
1010
const mdxWithImage = `# Document\n\n![Alt text](image.png "Title")\n\nSome text.`;
1111
const mdxWithImageUpload = `# Document\n\n<div data-type="image-upload" />\n\nSome text.`;
12+
const mdxWithVideoUpload = `# Document\n\n<div data-type="video-upload" />\n\nSome text.`;
1213
it("mdxToHtml: simple mdx", () => {
1314
const result = mdxToHtml(simpleMdx);
1415
expect(result.html).toMatch(
@@ -97,6 +98,20 @@ describe("mdxToHtml and htmlToMdx", () => {
9798
expect(mdxResult.mdx).toContain("Some text.");
9899
});
99100

101+
it("mdxToHtml: with video-upload div", () => {
102+
const result = mdxToHtml(mdxWithVideoUpload);
103+
expect(result.html).toContain('<div data-type="video-upload"');
104+
expect(result.frontmatter).toEqual(null);
105+
});
106+
107+
it("htmlToMdx: round-trip with video-upload div", () => {
108+
const { html, frontmatter } = mdxToHtml(mdxWithVideoUpload);
109+
const mdxResult = htmlToMdx(html, { frontmatter });
110+
expect(mdxResult.mdx).toContain("# Document");
111+
expect(mdxResult.mdx).toContain('<div data-type="video-upload"');
112+
expect(mdxResult.mdx).toContain("Some text.");
113+
});
114+
100115
// File-based snapshot for a larger/complex case
101116
const complexMdx = `---\ntitle: Complex\n---\n\n# Title\n\n- List item 1\n- List item 2\n\n<Custom value="bar" />\n\n\n## Subheading\n\n\n\n\nAnother paragraph.`;
102117
it("mdxToHtml: complex file snapshot", async () => {

0 commit comments

Comments
 (0)