diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a754ee23..24210f0e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to - ✨(backend) create a dedicated endpoint to update document content - ⚡️(backend) stream s3 file content with a dedicated endpoint - ✨(backend) allow to use new ai feature using mistral sdk +- ✨(frontend) detect and embed YouTube/Vimeo/Loom/Dailymotion in video block ### Changed diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index bf8372a278..77ca8e8664 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -48,6 +48,7 @@ import { CalloutBlock, PdfBlock, UploadLoaderBlock, + VideoBlock, } from './custom-blocks'; const AIMenu = BlockNoteAI?.AIMenu; const AIMenuController = BlockNoteAI?.AIMenuController; @@ -68,6 +69,7 @@ const baseBlockNoteSchema = withPageBreak( image: AccessibleImageBlock(), pdf: PdfBlock(), uploadLoader: UploadLoaderBlock(), + video: VideoBlock(), }, inlineContentSpecs: { ...defaultInlineContentSpecs, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/VideoBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/VideoBlock.tsx new file mode 100644 index 0000000000..7278c025cb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/VideoBlock.tsx @@ -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[0]['editor']; +type FileBlockBlock = Parameters[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 ( + + + {t('Invalid or missing video URL.')} + + ); + } + + const { kind, src } = parseEmbedUrl(url); + + return ( + <> + + + {url && + (kind === 'iframe' ? ( + + ) : ( + + ))} + + + ); +}; + +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) => , + }, +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts index 9614838837..dfd8096b65 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts @@ -2,3 +2,4 @@ export * from './AccessibleImageBlock'; export * from './CalloutBlock'; export * from './PdfBlock'; export * from './UploadLoaderBlock'; +export * from './VideoBlock'; diff --git a/src/frontend/apps/impress/src/utils/__tests__/embed.test.tsx b/src/frontend/apps/impress/src/utils/__tests__/embed.test.tsx new file mode 100644 index 0000000000..1b5b5ecb2a --- /dev/null +++ b/src/frontend/apps/impress/src/utils/__tests__/embed.test.tsx @@ -0,0 +1,117 @@ +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'], + ['https://www.youtube.com/watch?si=token123&v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + [ + 'https://www.youtube.com/watch?feature=share&si=token&v=dQw4w9WgXcQ&t=5s', + '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