@@ -308,27 +308,59 @@ const MediaUploadPreview: React.FC<MediaUploadPreviewProps> = ({ fileItem }) =>
308308} ;
309309
310310export 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 >
0 commit comments