Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/Dailymotion in video block

### 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';
117 changes: 117 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,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 <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',
});
});
});
});
Loading