✨(frontend) new custom block "embed"

We introduce a new custom block called "embed" that
allows users to embed external content into their
documents. This block can be used to include
videos, interactive widgets, or other web content
directly within the document editor.
It is a powerfull custom block that can make
your document much more interactive.
This commit is contained in:
Anthony LC
2026-07-20 10:51:53 +02:00
parent 708fa837cc
commit 8f51f23d66
15 changed files with 439 additions and 36 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to
### Added
- ✨(frontend) new custom block "embed" #2513
- ♿️(frontend) restore skip to content link after header redesign #2510
## [v5.4.1] - 2026-07-09
@@ -544,7 +544,7 @@ test.describe('Doc Editor', () => {
});
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await createDoc(page, 'doc-embed-pdf', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
@@ -637,4 +637,52 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
test('it embeds a web page', async ({ page, browserName }) => {
await createDoc(page, 'doc-embed-web', browserName, 1);
await openSuggestionMenu({ page, suggestion: 'Embed a web page' });
const embedBlock = page.locator('div[data-content-type="embed"]').last();
await expect(embedBlock).toBeVisible();
// Try with same domain first
await page
.getByText(/Add embed/)
.first()
.click();
await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3000/');
await page.locator('[data-test="embed-input-button"]').click();
await expect(page.getByText('Invalid or unsafe URL.')).toBeVisible();
await openSuggestionMenu({ page, suggestion: 'Embed a web page' });
// Now with a valid URL
await page
.getByText(/Add embed/)
.first()
.click();
await page
.locator('[data-test="embed-input"]')
.fill('http://127.0.0.1:3000/');
await page.locator('[data-test="embed-input-button"]').click();
const embedIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();
// Check src of embed iframe
expect(await embedIframe.getAttribute('src')).toMatch(
/http:\/\/127.0.0.1:3000\//,
);
await expect(embedIframe).toHaveAttribute('role', 'presentation');
});
});
@@ -12,6 +12,7 @@ import * as localesBN from '@blocknote/core/locales';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import {
FilePanelController,
FloatingComposerController,
FloatingThreadController,
ThreadsSidebar,
@@ -53,7 +54,13 @@ import { randomColor, sanitizeColor } from '../utils';
import BlockNoteAI from './AI';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks';
import {
CalloutBlock,
DocsFilePanel,
EmbedBlock,
PdfBlock,
UploadLoaderBlock,
} from './custom-blocks';
const AIMenu = BlockNoteAI?.AIMenu;
const AIMenuController = BlockNoteAI?.AIMenuController;
const useAI = BlockNoteAI?.useAI;
@@ -70,6 +77,7 @@ const baseBlockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock(),
codeBlock: createCodeBlockSpec(codeBlockOptions),
embed: EmbedBlock(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
},
@@ -292,6 +300,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
filePanel={false}
theme="light"
comments={false}
aria-label={t('Document editor')}
@@ -303,6 +312,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
)}
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
<FilePanelController filePanel={DocsFilePanel} />
{showComments && <FloatingComposerController />}
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget &&
@@ -20,6 +20,7 @@ import {
import BlockNoteAI from './AI';
import {
getCalloutReactSlashMenuItems,
getEmbedReactSlashMenuItems,
getPdfReactSlashMenuItems,
} from './custom-blocks';
import { useGetInterlinkingMenuItems } from './custom-inline-content';
@@ -56,6 +57,7 @@ export const BlockNoteSuggestionMenu = ({
getPageBreakReactSlashMenuItems(editor),
getMultiColumnSlashMenuItems?.(editor) || [],
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
getEmbedReactSlashMenuItems(editor, t, fileBlocksName),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [],
);
@@ -0,0 +1,43 @@
import { Box, BoxType, Icon } from '@/components';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
interface CustomBlockStatusProps extends BoxType {
type?: 'loading' | 'warning';
}
export const CustomBlockStatus = ({
type = 'warning',
children,
...props
}: CustomBlockStatusProps) => {
return (
<Box
$direction="row"
$gap="0.5rem"
$width="inherit"
$css="pointer-events: none;"
contentEditable={false}
draggable={false}
{...props}
>
{type === 'warning' ? (
<Warning style={{ flexShrink: 0 }} aria-hidden="true" />
) : (
<Icon
$theme="brand"
$layer="border"
icon={
<Loader
style={{ animation: 'spin 1.5s linear infinite' }}
aria-hidden="true"
/>
}
$shrink={0}
/>
)}
{children}
</Box>
);
};
@@ -0,0 +1,81 @@
/**
* Copy of Blocknote's default FilePanel, restricted to hide the "Upload" tab
* for blocks whose props explicitly set `uploadDisabled: true` (e.g. the
* "embed" block, which only supports embedding an existing URL).
*
* Original source:
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FilePanel/FilePanel.tsx
*/
import {
EmbedTab,
FilePanelProps,
UploadTab,
useBlockNoteEditor,
useComponentsContext,
useDictionary,
} from '@blocknote/react';
import { useState } from 'react';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
export const DocsFilePanel = (
props: FilePanelProps & Partial<{ defaultOpenTab: string }>,
) => {
const Components = useComponentsContext();
const dict = useDictionary();
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
const [loading, setLoading] = useState<boolean>(false);
const block = editor.getBlock(props.blockId);
const uploadDisabled =
!!block &&
'uploadDisabled' in block.props &&
block.props.uploadDisabled === true;
const allowUpload = editor.uploadFile !== undefined && !uploadDisabled;
const tabs = [
...(allowUpload
? [
{
name: dict.file_panel.upload.title,
tabPanel: (
<UploadTab blockId={props.blockId} setLoading={setLoading} />
),
},
]
: []),
{
name: dict.file_panel.embed.title,
tabPanel: <EmbedTab blockId={props.blockId} />,
},
];
const [openTab, setOpenTab] = useState<string>(
props.defaultOpenTab || tabs[0].name,
);
if (!Components) {
return null;
}
return (
<Components.FilePanel.Root
className="bn-panel"
defaultOpenTab={openTab}
openTab={openTab}
setOpenTab={setOpenTab}
tabs={tabs}
loading={loading}
/>
);
};
@@ -0,0 +1,190 @@
/**
* EmbedBlock — embeds an external web page in a document via a sandboxed
* `<iframe>`. The URL is stored in the collaborative document and rendered on
* every member's machine, so it is treated as untrusted content.
*
* ⚠️ To keep it secure, the block enforces two invariants:
* 1. The URL must be safe (https, no javascript: or data:).
* 2. The URL must be cross-origin (not same-origin with the app).
*
* Two layers keep it cross-origin, where the
* Same-Origin Policy blocks parent access:
* 1. `isSameOriginUrl` rejects same-origin URLs on every render (re-checked
* per viewer, not just at insert time).
* 2. `X-Frame-Options: DENY` (nginx) and `frame-ancestors 'none'` (backend
* CSP) stop the embed redirecting its frame back into the app origin.
*
* When editing, keep these invariants: don't weaken `isSameOriginUrl`; keep the
* anti-framing headers on every same-origin route.
*/
import {
BlockConfig,
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core';
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
import * as locales from '@blocknote/core/locales';
import {
AddFileButton,
ResizableFileBlockWrapper,
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Icon } from '@/components';
import { isSafeUrl } from '@/utils/url';
import { DocsBlockNoteEditor } from '../../types';
import { CustomBlockStatus } from './CustomBlockStatus';
const EmbedBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="embed"] .bn-file-block-content-wrapper {
width: fit-content;
}
.bn-block-content[data-content-type="embed"] .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'];
export const isSameOriginUrl = (url: string): boolean => {
try {
return (
new URL(url, window.location.origin).origin === window.location.origin
);
} catch {
return true;
}
};
type CreateEmbedBlockConfig = BlockConfig<
'embed',
{
backgroundColor: { default: 'default' };
caption: { default: '' };
name: { default: '' };
previewWidth: { default: undefined; type: 'number' };
showPreview: { default: true };
textAlignment: { default: 'left' };
uploadDisabled: { default: true };
url: { default: '' };
},
'none'
>;
interface EmbedBlockComponentProps {
block: BlockNoDefaults<
Record<'embed', CreateEmbedBlockConfig>,
InlineContentSchema,
StyleSchema
>;
editor: BlockNoteEditor<
Record<'embed', CreateEmbedBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}
const EmbedBlockComponent = ({ editor, block }: EmbedBlockComponentProps) => {
const embedUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
useEffect(() => {
if (lang && locales[lang as keyof typeof locales]) {
locales[lang as keyof typeof locales].file_blocks.add_button_text[
'embed'
] = t('Add embed');
(
locales[lang as keyof typeof locales].file_panel.embed
.embed_button as Record<string, string>
)['embed'] = t('Embed');
}
}, [lang, t]);
const isInvalidEmbed =
!!embedUrl && (!isSafeUrl(embedUrl) || isSameOriginUrl(embedUrl));
if (isInvalidEmbed) {
return <CustomBlockStatus>{t('Invalid or unsafe URL.')}</CustomBlockStatus>;
}
return (
<>
<EmbedBlockStyle />
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="public" $size="24px" $css="line-height: normal;" />
}
block={block as unknown as FileBlockBlock}
editor={editor as unknown as FileBlockEditor}
>
{!!embedUrl && (
<Box
as="iframe"
className="bn-visual-media"
role="presentation"
$width="100%"
$height="450px"
src={embedUrl}
title={block.props.name || t('Embedded content')}
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
contentEditable={false}
draggable={false}
/>
)}
</ResizableFileBlockWrapper>
</>
);
};
export const EmbedBlock = createReactBlockSpec(
{
type: 'embed',
content: 'none',
propSchema: {
backgroundColor: { default: 'default' as const },
caption: { default: '' as const },
name: { default: '' as const },
previewWidth: { default: undefined, type: 'number' },
showPreview: { default: true },
textAlignment: { default: 'left' as const },
uploadDisabled: { default: true },
url: { default: '' as const },
},
},
{
meta: {
fileBlockAccept: [],
},
render: (props) => <EmbedBlockComponent {...props} />,
},
);
export const getEmbedReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Embed'),
onItemClick: () => {
insertOrUpdateBlockForSlashMenu(editor, { type: 'embed' });
},
aliases: [t('embed'), t('iframe'), t('website'), t('link')],
group,
icon: <Icon iconName="public" $size="18px" />,
subtext: t('Embed a web page'),
},
];
@@ -20,10 +20,11 @@ import { createGlobalStyle } from 'styled-components';
import { Box, Icon, Loading } from '@/components';
import { isSafeUrl } from '@/utils/url';
import Warning from '../../assets/warning.svg';
import { ANALYZE_URL } from '../../conf';
import { DocsBlockNoteEditor } from '../../types';
import { CustomBlockStatus } from './CustomBlockStatus';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper {
width: fit-content;
@@ -120,17 +121,7 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
if (isInvalidPDF) {
return (
<Box
$direction="row"
$gap="0.5rem"
$width="inherit"
$css="pointer-events: none;"
contentEditable={false}
draggable={false}
>
<Warning />
{t('Invalid or missing PDF file.')}
</Box>
<CustomBlockStatus>{t('Invalid or missing PDF file.')}</CustomBlockStatus>
);
}
@@ -9,13 +9,13 @@ import { createReactBlockSpec } from '@blocknote/react';
import { t } from 'i18next';
import { useEffect } from 'react';
import { Box, Icon, Text } from '@/components';
import { Text } from '@/components';
import { useMediaUrl } from '@/core';
import { isSafeUrl } from '@/utils/url';
import { loopCheckDocMediaStatus } from '../../api';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
import { CustomBlockStatus } from './CustomBlockStatus';
type UploadLoaderPropSchema = {
readonly information: { readonly default: '' };
@@ -130,18 +130,12 @@ const UploadLoaderBlockComponent = ({
}, [block, editor, mediaUrl, isEditable]);
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Icon
$theme="brand"
$layer="border"
icon={<Loader style={{ animation: 'spin 1.5s linear infinite' }} />}
/>
)}
<CustomBlockStatus
className="bn-visual-media-wrapper"
type={block.props.type}
>
<Text>{block.props.information}</Text>
</Box>
</CustomBlockStatus>
);
};
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { isSameOriginUrl } from '../EmbedBlock';
describe('EmbedBlock', () => {
describe('isSameOriginUrl', () => {
it('returns true for a URL with the same origin as the app', () => {
expect(isSameOriginUrl(window.location.origin + '/some/path')).toBe(true);
});
it('returns true for a relative URL (resolves to same origin)', () => {
expect(isSameOriginUrl('/relative/path')).toBe(true);
});
it('returns false for a cross-origin https URL', () => {
expect(isSameOriginUrl('https://example.com/page')).toBe(false);
});
it('returns false for a cross-origin URL with a different subdomain', () => {
expect(isSameOriginUrl('https://sub.example.com/')).toBe(false);
});
it('returns false for a cross-origin URL on a different port', () => {
expect(isSameOriginUrl('https://localhost:9999/')).toBe(false);
});
it('returns true for an unparseable URL (treated as unsafe)', () => {
expect(isSameOriginUrl('not a valid url %%')).toBe(true);
});
});
});
@@ -1,3 +1,5 @@
export * from './CalloutBlock';
export * from './DocsFilePanel';
export * from './EmbedBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';
@@ -14,10 +14,12 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
// We're reusing the file block mapping for PDF blocks; both share the same
// We're reusing the file block mapping for PDF/embed blocks; both share the same
// implementation signature, so we can reuse the handler directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
embed: docxDefaultSchemaMappings.blockMapping.file as any,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
uploadLoader: blockMappingUploadLoaderDocx,
@@ -18,10 +18,12 @@ export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
...odtDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutODT,
image: blockMappingImageODT,
// We're reusing the file block mapping for PDF blocks
// We're reusing the file block mapping for PDF/embed blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
embed: odtDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderODT,
},
@@ -22,10 +22,12 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
paragraph: blockMappingParagraphPDF,
quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
// We're using the file block mapping for PDF blocks
// We're using the file block mapping for PDF/embed blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
embed: pdfDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderPDF,
},
inlineContentMapping: {
@@ -101,8 +101,9 @@ const PRINT_ONLY_CONTENT_CSS = `
/* Hide media/embed placeholders and render their URLs */
[data-content-type="file"] .bn-file-block-content-wrapper,
[data-content-type="pdf"] .bn-file-block-content-wrapper,
[data-content-type="audio"] .bn-file-block-content-wrapper,
[data-content-type="pdf"] .bn-file-block-content-wrapper,
[data-content-type="embed"] .bn-file-block-content-wrapper,
[data-content-type="audio"] .bn-file-block-content-wrapper,
[data-content-type="video"] .bn-file-block-content-wrapper {
display: none !important;
}
@@ -182,7 +183,7 @@ function wrapMediaWithLink() {
el: Element,
url: string | null,
name: string | null,
type: 'file' | 'audio' | 'video' | 'pdf',
type: 'file' | 'audio' | 'video' | 'pdf' | 'embed',
) => {
if (!url || !isSafeUrl(url)) {
return;
@@ -201,6 +202,8 @@ function wrapMediaWithLink() {
label.textContent = '📹: ';
} else if (type === 'pdf') {
label.textContent = '📑: ';
} else if (type === 'embed') {
label.textContent = '🌐: ';
} else {
label.textContent = '🔗: ';
}
@@ -224,7 +227,7 @@ function wrapMediaWithLink() {
document
.querySelectorAll(
'[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
'[data-content-type="pdf"], [data-content-type="embed"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
)
.forEach((el) => {
const url = el?.getAttribute('data-url');
@@ -233,7 +236,8 @@ function wrapMediaWithLink() {
| 'file'
| 'audio'
| 'video'
| 'pdf';
| 'pdf'
| 'embed';
if (type) {
prependLink(el, url, name, type);
}