Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 in video block
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
CalloutBlock,
PdfBlock,
UploadLoaderBlock,
VideoBlock,
} from './custom-blocks';
const AIMenu = BlockNoteAI?.AIMenu;
const AIMenuController = BlockNoteAI?.AIMenuController;
Expand All @@ -68,6 +69,7 @@ const baseBlockNoteSchema = withPageBreak(
image: AccessibleImageBlock(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
video: VideoBlock(),
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
Expand Down
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
Expand Up @@ -2,3 +2,4 @@ export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';
export * from './VideoBlock';
112 changes: 112 additions & 0 deletions src/frontend/apps/impress/src/utils/__tests__/embed.test.tsx
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',
});
});
});
});
81 changes: 81 additions & 0 deletions src/frontend/apps/impress/src/utils/embed.ts
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})(?:[?&].*)?$/;

Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Dailymotion regex silently fails for URLs with hash fragments.

(?:[?_].*)?$ handles query strings (?) and title slugs (_) but not #. A browser-copied URL like https://www.dailymotion.com/video/x9zyxwv#start=30 won't match — it falls through to { kind: 'video', src: url }, causing a silent broken <video> attempting to load an HTML page.

🐛 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const DAILYMOTION_RE =
/^(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/(?:video|embed\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:[?_].*)?$/;
const DAILYMOTION_RE =
/^(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/(?:video|embed\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:[?_#].*)?$/;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/apps/impress/src/utils/embed.ts` around lines 56 - 57, The
DAILYMOTION_RE constant in embed.ts doesn't match URLs with hash fragments (e.g.
`#start`=30); update the regex DAILYMOTION_RE to include '#' in the optional
trailing group so hashes are accepted (e.g. change the trailing character class
from [?_] to include # such as [?_#] or [?#] as appropriate) so URLs like
https://www.dailymotion.com/video/x9zyxwv#start=30 correctly match and return
the video id.


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 };
}