-
Notifications
You must be signed in to change notification settings - Fork 592
✨(frontend) detect and embed YouTube/Vimeo/Loom in video block #2260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
4f9c80f
27d8e8d
bd4ae28
c5838c4
2d1d14a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { | ||
| BlockConfig, | ||
| BlockNoDefaults, | ||
| BlockNoteEditor, | ||
| InlineContentSchema, | ||
| StyleSchema, | ||
| defaultProps, | ||
| } from '@blocknote/core'; | ||
| import { | ||
| AddFileButton, | ||
| ResizableFileBlockWrapper, | ||
| createReactBlockSpec, | ||
| } from '@blocknote/react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { createGlobalStyle } from 'styled-components'; | ||
|
|
||
| import { Box } from '@/components'; | ||
| import { parseEmbedUrl } from '@/utils/embed'; | ||
| import { isSafeUrl } from '@/utils/url'; | ||
|
|
||
| import Warning from '../../assets/warning.svg'; | ||
|
|
||
| const VideoBlockStyle = createGlobalStyle` | ||
| .bn-block-content[data-content-type="video"] .bn-file-block-content-wrapper { | ||
| width: fit-content; | ||
| } | ||
| .bn-block-content[data-content-type="video"] .bn-file-block-content-wrapper[style*="fit-content"] { | ||
| width: 100% !important; | ||
| } | ||
| `; | ||
|
|
||
| type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor']; | ||
| type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block']; | ||
|
|
||
| type CreateVideoBlockConfig = BlockConfig< | ||
| 'video', | ||
| { | ||
| textAlignment: typeof defaultProps.textAlignment; | ||
| backgroundColor: typeof defaultProps.backgroundColor; | ||
| name: { default: '' }; | ||
| url: { default: '' }; | ||
| caption: { default: '' }; | ||
| showPreview: { default: true }; | ||
| previewWidth: { default: undefined; type: 'number' }; | ||
| }, | ||
| 'none' | ||
| >; | ||
|
|
||
| interface VideoBlockComponentProps { | ||
| block: BlockNoDefaults< | ||
| Record<'video', CreateVideoBlockConfig>, | ||
| InlineContentSchema, | ||
| StyleSchema | ||
| >; | ||
| editor: BlockNoteEditor< | ||
| Record<'video', CreateVideoBlockConfig>, | ||
| InlineContentSchema, | ||
| StyleSchema | ||
| >; | ||
| } | ||
|
|
||
| const VideoBlockComponent = ({ editor, block }: VideoBlockComponentProps) => { | ||
| const { t } = useTranslation(); | ||
| const url = block.props.url; | ||
|
|
||
| // Only flag a URL as invalid once one has actually been entered. An empty | ||
| // URL is the freshly-inserted state and should fall through to the wrapper | ||
| // so BlockNote shows its built-in file/URL/embed picker. | ||
| if (url && !isSafeUrl(url)) { | ||
| return ( | ||
| <Box | ||
| $direction="row" | ||
| $gap="0.5rem" | ||
| $width="inherit" | ||
| $css="pointer-events: none;" | ||
| contentEditable={false} | ||
| draggable={false} | ||
| > | ||
| <Warning /> | ||
| {t('Invalid or missing video URL.')} | ||
| </Box> | ||
| ); | ||
| } | ||
|
|
||
| const { kind, src } = parseEmbedUrl(url); | ||
|
|
||
| return ( | ||
| <> | ||
| <VideoBlockStyle /> | ||
| <ResizableFileBlockWrapper | ||
| block={block as unknown as FileBlockBlock} | ||
| editor={editor as unknown as FileBlockEditor} | ||
| > | ||
| {url && | ||
| (kind === 'iframe' ? ( | ||
| <Box | ||
| as="iframe" | ||
| className="bn-visual-media" | ||
| $width="100%" | ||
| $css="aspect-ratio: 16 / 9; border: 0;" | ||
| src={src} | ||
| // Sandbox + allow attributes match what major embed providers | ||
| // (YouTube, Vimeo, Loom) require to play inline. `allow-popups` | ||
| // is needed for "Watch on YouTube" links opening in a new tab. | ||
| sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox" | ||
| allow="autoplay; fullscreen; picture-in-picture; encrypted-media" | ||
| loading="lazy" | ||
| referrerPolicy="strict-origin-when-cross-origin" | ||
| title={block.props.name || t('Embedded video')} | ||
| contentEditable={false} | ||
| draggable={false} | ||
| /> | ||
| ) : ( | ||
| <Box | ||
| as="video" | ||
| className="bn-visual-media" | ||
| $width="100%" | ||
| src={src} | ||
| controls | ||
| aria-label={block.props.name || t('Video')} | ||
| contentEditable={false} | ||
| draggable={false} | ||
| /> | ||
| ))} | ||
| </ResizableFileBlockWrapper> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| export const VideoBlock = createReactBlockSpec( | ||
| { | ||
| type: 'video', | ||
| content: 'none', | ||
| propSchema: { | ||
| textAlignment: defaultProps.textAlignment, | ||
| backgroundColor: defaultProps.backgroundColor, | ||
| name: { default: '' as const }, | ||
| url: { default: '' as const }, | ||
| caption: { default: '' as const }, | ||
| showPreview: { default: true }, | ||
| previewWidth: { default: undefined, type: 'number' }, | ||
| }, | ||
| }, | ||
| { | ||
| meta: { | ||
| fileBlockAccept: ['video/*'], | ||
| }, | ||
| render: (props) => <VideoBlockComponent {...props} />, | ||
| }, | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { parseEmbedUrl } from '@/utils/embed'; | ||
|
|
||
| describe('parseEmbedUrl', () => { | ||
| describe('YouTube', () => { | ||
| const cases: [string, string][] = [ | ||
| ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://m.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://youtu.be/dQw4w9WgXcQ?si=token123', 'dQw4w9WgXcQ'], | ||
| ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], | ||
| ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=60s', 'dQw4w9WgXcQ'], | ||
| ]; | ||
|
|
||
| it.each(cases)('rewrites %s to embed iframe', (input, expectedId) => { | ||
| expect(parseEmbedUrl(input)).toEqual({ | ||
| kind: 'iframe', | ||
| src: `https://www.youtube.com/embed/${expectedId}`, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Vimeo', () => { | ||
| it('rewrites vimeo.com/{id} to player URL', () => { | ||
| expect(parseEmbedUrl('https://vimeo.com/123456789')).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://player.vimeo.com/video/123456789', | ||
| }); | ||
| }); | ||
|
|
||
| it('keeps already-embed Vimeo URLs', () => { | ||
| expect(parseEmbedUrl('https://player.vimeo.com/video/123456789')).toEqual( | ||
| { | ||
| kind: 'iframe', | ||
| src: 'https://player.vimeo.com/video/123456789', | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Loom', () => { | ||
| it('rewrites loom.com/share/{id} to embed URL', () => { | ||
| expect(parseEmbedUrl('https://www.loom.com/share/abcdef123456')).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://www.loom.com/embed/abcdef123456', | ||
| }); | ||
| }); | ||
|
|
||
| it('keeps already-embed Loom URLs', () => { | ||
| expect(parseEmbedUrl('https://www.loom.com/embed/abcdef123456')).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://www.loom.com/embed/abcdef123456', | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Dailymotion', () => { | ||
| it('rewrites dailymotion.com/video/{id} to embed URL', () => { | ||
| expect( | ||
| parseEmbedUrl('https://www.dailymotion.com/video/x9zyxwv'), | ||
| ).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://www.dailymotion.com/embed/video/x9zyxwv', | ||
| }); | ||
| }); | ||
|
|
||
| it('rewrites dai.ly short URLs', () => { | ||
| expect(parseEmbedUrl('https://dai.ly/x9zyxwv')).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://www.dailymotion.com/embed/video/x9zyxwv', | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Direct video files', () => { | ||
| it.each([ | ||
| 'https://example.com/clip.mp4', | ||
| 'https://example.com/clip.webm', | ||
| 'https://example.com/clip.ogg', | ||
| 'https://example.com/clip.ogv', | ||
| 'https://example.com/clip.mov', | ||
| 'https://example.com/clip.m4v', | ||
| 'https://example.com/path/to/clip.mp4?token=abc&exp=123', | ||
| 'https://example.com/path/to/clip.mp4#t=10', | ||
| ])('renders %s as <video>', (url) => { | ||
| expect(parseEmbedUrl(url)).toEqual({ kind: 'video', src: url }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Fallback (unrecognised URLs)', () => { | ||
| it('renders unknown URLs as <video> for backwards compatibility', () => { | ||
| expect(parseEmbedUrl('https://cdn.example.com/signed/abc123')).toEqual({ | ||
| kind: 'video', | ||
| src: 'https://cdn.example.com/signed/abc123', | ||
| }); | ||
| }); | ||
|
|
||
| it('handles empty string', () => { | ||
| expect(parseEmbedUrl('')).toEqual({ kind: 'video', src: '' }); | ||
| }); | ||
|
|
||
| it('trims whitespace before parsing', () => { | ||
| expect(parseEmbedUrl(' https://youtu.be/dQw4w9WgXcQ ')).toEqual({ | ||
| kind: 'iframe', | ||
| src: 'https://www.youtube.com/embed/dQw4w9WgXcQ', | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||
| /** | ||||||||||
| * Helpers for converting URLs from common video platforms into iframe-friendly | ||||||||||
| * `src` URLs. Unrecognised URLs fall back to direct-video rendering, matching | ||||||||||
| * the behaviour of BlockNote's default video block so existing documents keep | ||||||||||
| * working unchanged. | ||||||||||
| */ | ||||||||||
|
|
||||||||||
| export type EmbedKind = 'iframe' | 'video'; | ||||||||||
|
|
||||||||||
| export interface ParsedEmbed { | ||||||||||
| kind: EmbedKind; | ||||||||||
| src: string; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const YOUTUBE_RE = | ||||||||||
| /^(?:https?:\/\/)?(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&].*)?$/; | ||||||||||
|
|
||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||
| const VIMEO_RE = | ||||||||||
| /^(?:https?:\/\/)?(?:www\.)?(?:vimeo\.com|player\.vimeo\.com\/video)\/(\d+)(?:[?/].*)?$/; | ||||||||||
|
|
||||||||||
| const LOOM_RE = | ||||||||||
| /^(?:https?:\/\/)?(?:www\.)?loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)(?:[?/].*)?$/; | ||||||||||
|
|
||||||||||
| const DAILYMOTION_RE = | ||||||||||
| /^(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/(?:video|embed\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:[?_].*)?$/; | ||||||||||
|
Comment on lines
+56
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dailymotion regex silently fails for URLs with hash fragments.
🐛 Proposed fix- /^(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/(?:video|embed\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:[?_].*)?$/;
+ /^(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/(?:video|embed\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:[?_#].*)?$/;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| const VIDEO_FILE_EXT_RE = | ||||||||||
| /\.(mp4|webm|ogv|ogg|mov|m4v|avi|mkv)(?:\?.*)?(?:#.*)?$/i; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Detects whether a URL points at a known embed-style platform (YouTube, | ||||||||||
| * Vimeo, Loom, Dailymotion) and returns an iframe-ready src. URLs that look | ||||||||||
| * like direct video files, or are unrecognised, are returned as-is and | ||||||||||
| * rendered through the native HTML5 `<video>` element. | ||||||||||
| */ | ||||||||||
| export function parseEmbedUrl(url: string): ParsedEmbed { | ||||||||||
| const trimmed = (url || '').trim(); | ||||||||||
| if (!trimmed) { | ||||||||||
| return { kind: 'video', src: '' }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const ytMatch = trimmed.match(YOUTUBE_RE); | ||||||||||
| if (ytMatch) { | ||||||||||
| return { | ||||||||||
| kind: 'iframe', | ||||||||||
| src: `https://www.youtube.com/embed/${ytMatch[1]}`, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const vmMatch = trimmed.match(VIMEO_RE); | ||||||||||
| if (vmMatch) { | ||||||||||
| return { | ||||||||||
| kind: 'iframe', | ||||||||||
| src: `https://player.vimeo.com/video/${vmMatch[1]}`, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const loomMatch = trimmed.match(LOOM_RE); | ||||||||||
| if (loomMatch) { | ||||||||||
| return { | ||||||||||
| kind: 'iframe', | ||||||||||
| src: `https://www.loom.com/embed/${loomMatch[1]}`, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const dmMatch = trimmed.match(DAILYMOTION_RE); | ||||||||||
| if (dmMatch) { | ||||||||||
| return { | ||||||||||
| kind: 'iframe', | ||||||||||
| src: `https://www.dailymotion.com/embed/video/${dmMatch[1]}`, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (VIDEO_FILE_EXT_RE.test(trimmed)) { | ||||||||||
| return { kind: 'video', src: trimmed }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Fallback: render as <video>. Keeps backwards compatibility with existing | ||||||||||
| // documents that store signed/extension-less direct video URLs. | ||||||||||
| return { kind: 'video', src: trimmed }; | ||||||||||
| } | ||||||||||
Uh oh!
There was an error while loading. Please reload this page.