diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2aafeb4ab..d4516c8c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index 052416722..8b9afa11d 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -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');
+ });
});
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 5c9b2b1e2..7e4681ee8 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
@@ -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) => {
)}
+
{showComments && }
{showComments && !isCommentSideBarOpen && }
{threadsSidebarTarget &&
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx
index 805377f69..24127e00b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx
@@ -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) : [],
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CustomBlockStatus.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CustomBlockStatus.tsx
new file mode 100644
index 000000000..fa8646874
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CustomBlockStatus.tsx
@@ -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 (
+
+ {type === 'warning' ? (
+
+ ) : (
+
+ }
+ $shrink={0}
+ />
+ )}
+ {children}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DocsFilePanel.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DocsFilePanel.tsx
new file mode 100644
index 000000000..6c694c9fb
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DocsFilePanel.tsx
@@ -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(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: (
+
+ ),
+ },
+ ]
+ : []),
+ {
+ name: dict.file_panel.embed.title,
+ tabPanel: ,
+ },
+ ];
+
+ const [openTab, setOpenTab] = useState(
+ props.defaultOpenTab || tabs[0].name,
+ );
+
+ if (!Components) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx
new file mode 100644
index 000000000..b63515bdb
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx
@@ -0,0 +1,190 @@
+/**
+ * EmbedBlock — embeds an external web page in a document via a sandboxed
+ * `