Skip to content

Commit d849479

Browse files
committed
✨(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.
1 parent c647cb6 commit d849479

9 files changed

Lines changed: 295 additions & 9 deletions

File tree

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as localesBN from '@blocknote/core/locales';
1212
import { BlockNoteView } from '@blocknote/mantine';
1313
import '@blocknote/mantine/style.css';
1414
import {
15+
FilePanelController,
1516
FloatingComposerController,
1617
FloatingThreadController,
1718
ThreadsSidebar,
@@ -53,7 +54,13 @@ import { randomColor, sanitizeColor } from '../utils';
5354
import BlockNoteAI from './AI';
5455
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
5556
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
56-
import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks';
57+
import {
58+
CalloutBlock,
59+
DocsFilePanel,
60+
EmbedBlock,
61+
PdfBlock,
62+
UploadLoaderBlock,
63+
} from './custom-blocks';
5764
const AIMenu = BlockNoteAI?.AIMenu;
5865
const AIMenuController = BlockNoteAI?.AIMenuController;
5966
const useAI = BlockNoteAI?.useAI;
@@ -70,6 +77,7 @@ const baseBlockNoteSchema = withPageBreak(
7077
...defaultBlockSpecs,
7178
callout: CalloutBlock(),
7279
codeBlock: createCodeBlockSpec(codeBlockOptions),
80+
embed: EmbedBlock(),
7381
pdf: PdfBlock(),
7482
uploadLoader: UploadLoaderBlock(),
7583
},
@@ -292,6 +300,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
292300
editor={editor}
293301
formattingToolbar={false}
294302
slashMenu={false}
303+
filePanel={false}
295304
theme="light"
296305
comments={false}
297306
aria-label={t('Document editor')}
@@ -303,6 +312,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
303312
)}
304313
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
305314
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
315+
<FilePanelController filePanel={DocsFilePanel} />
306316
{showComments && <FloatingComposerController />}
307317
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
308318
{threadsSidebarTarget &&

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import BlockNoteAI from './AI';
2121
import {
2222
getCalloutReactSlashMenuItems,
23+
getEmbedReactSlashMenuItems,
2324
getPdfReactSlashMenuItems,
2425
} from './custom-blocks';
2526
import { useGetInterlinkingMenuItems } from './custom-inline-content';
@@ -56,6 +57,7 @@ export const BlockNoteSuggestionMenu = ({
5657
getPageBreakReactSlashMenuItems(editor),
5758
getMultiColumnSlashMenuItems?.(editor) || [],
5859
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
60+
getEmbedReactSlashMenuItems(editor, t, fileBlocksName),
5961
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
6062
aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [],
6163
);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Copy of Blocknote's default FilePanel, restricted to hide the "Upload" tab
3+
* for blocks whose props explicitly set `uploadDisabled: true` (e.g. the
4+
* "embed" block, which only supports embedding an existing URL).
5+
*
6+
* Original source:
7+
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FilePanel/FilePanel.tsx
8+
*/
9+
import {
10+
EmbedTab,
11+
FilePanelProps,
12+
UploadTab,
13+
useBlockNoteEditor,
14+
useComponentsContext,
15+
useDictionary,
16+
} from '@blocknote/react';
17+
import { useState } from 'react';
18+
19+
import {
20+
DocsBlockSchema,
21+
DocsInlineContentSchema,
22+
DocsStyleSchema,
23+
} from '../../types';
24+
25+
export const DocsFilePanel = (
26+
props: FilePanelProps & Partial<{ defaultOpenTab: string }>,
27+
) => {
28+
const Components = useComponentsContext();
29+
const dict = useDictionary();
30+
31+
const editor = useBlockNoteEditor<
32+
DocsBlockSchema,
33+
DocsInlineContentSchema,
34+
DocsStyleSchema
35+
>();
36+
37+
const [loading, setLoading] = useState<boolean>(false);
38+
39+
const block = editor.getBlock(props.blockId);
40+
const uploadDisabled =
41+
!!block &&
42+
'uploadDisabled' in block.props &&
43+
block.props.uploadDisabled === true;
44+
const allowUpload = editor.uploadFile !== undefined && !uploadDisabled;
45+
46+
const tabs = [
47+
...(allowUpload
48+
? [
49+
{
50+
name: dict.file_panel.upload.title,
51+
tabPanel: (
52+
<UploadTab blockId={props.blockId} setLoading={setLoading} />
53+
),
54+
},
55+
]
56+
: []),
57+
{
58+
name: dict.file_panel.embed.title,
59+
tabPanel: <EmbedTab blockId={props.blockId} />,
60+
},
61+
];
62+
63+
const [openTab, setOpenTab] = useState<string>(
64+
props.defaultOpenTab || tabs[0].name,
65+
);
66+
67+
if (!Components) {
68+
return null;
69+
}
70+
71+
return (
72+
<Components.FilePanel.Root
73+
className="bn-panel"
74+
defaultOpenTab={openTab}
75+
openTab={openTab}
76+
setOpenTab={setOpenTab}
77+
tabs={tabs}
78+
loading={loading}
79+
/>
80+
);
81+
};
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import {
2+
BlockConfig,
3+
BlockNoDefaults,
4+
BlockNoteEditor,
5+
InlineContentSchema,
6+
StyleSchema,
7+
} from '@blocknote/core';
8+
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
9+
import * as locales from '@blocknote/core/locales';
10+
import {
11+
AddFileButton,
12+
ResizableFileBlockWrapper,
13+
createReactBlockSpec,
14+
} from '@blocknote/react';
15+
import { TFunction } from 'i18next';
16+
import { useEffect } from 'react';
17+
import { useTranslation } from 'react-i18next';
18+
import { createGlobalStyle } from 'styled-components';
19+
20+
import { Box, Icon } from '@/components';
21+
import { isSafeUrl } from '@/utils/url';
22+
23+
import Warning from '../../assets/warning.svg';
24+
import { DocsBlockNoteEditor } from '../../types';
25+
26+
const EmbedBlockStyle = createGlobalStyle`
27+
.bn-block-content[data-content-type="embed"] .bn-file-block-content-wrapper {
28+
width: fit-content;
29+
}
30+
.bn-block-content[data-content-type="embed"] .bn-file-block-content-wrapper[style*="fit-content"] {
31+
width: 100% !important;
32+
}
33+
`;
34+
35+
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
36+
type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block'];
37+
38+
const isSameOriginUrl = (url: string): boolean => {
39+
try {
40+
return (
41+
new URL(url, window.location.origin).origin === window.location.origin
42+
);
43+
} catch {
44+
return true;
45+
}
46+
};
47+
48+
type CreateEmbedBlockConfig = BlockConfig<
49+
'embed',
50+
{
51+
backgroundColor: { default: 'default' };
52+
caption: { default: '' };
53+
name: { default: '' };
54+
previewWidth: { default: undefined; type: 'number' };
55+
showPreview: { default: true };
56+
textAlignment: { default: 'left' };
57+
uploadDisabled: { default: true };
58+
url: { default: '' };
59+
},
60+
'none'
61+
>;
62+
63+
interface EmbedBlockComponentProps {
64+
block: BlockNoDefaults<
65+
Record<'embed', CreateEmbedBlockConfig>,
66+
InlineContentSchema,
67+
StyleSchema
68+
>;
69+
editor: BlockNoteEditor<
70+
Record<'embed', CreateEmbedBlockConfig>,
71+
InlineContentSchema,
72+
StyleSchema
73+
>;
74+
}
75+
76+
const EmbedBlockComponent = ({ editor, block }: EmbedBlockComponentProps) => {
77+
const embedUrl = block.props.url;
78+
const { i18n, t } = useTranslation();
79+
const lang = i18n.resolvedLanguage;
80+
81+
useEffect(() => {
82+
if (lang && locales[lang as keyof typeof locales]) {
83+
locales[lang as keyof typeof locales].file_blocks.add_button_text[
84+
'embed'
85+
] = t('Add embed');
86+
(
87+
locales[lang as keyof typeof locales].file_panel.embed
88+
.embed_button as Record<string, string>
89+
)['embed'] = t('Embed');
90+
}
91+
}, [lang, t]);
92+
93+
const isInvalidEmbed =
94+
!!embedUrl && (!isSafeUrl(embedUrl) || isSameOriginUrl(embedUrl));
95+
96+
if (isInvalidEmbed) {
97+
return (
98+
<Box
99+
$direction="row"
100+
$gap="0.5rem"
101+
$width="inherit"
102+
$css="pointer-events: none;"
103+
contentEditable={false}
104+
draggable={false}
105+
>
106+
<Warning />
107+
{t('Invalid or unsafe URL.')}
108+
</Box>
109+
);
110+
}
111+
112+
return (
113+
<>
114+
<EmbedBlockStyle />
115+
<ResizableFileBlockWrapper
116+
buttonIcon={
117+
<Icon iconName="public" $size="24px" $css="line-height: normal;" />
118+
}
119+
block={block as unknown as FileBlockBlock}
120+
editor={editor as unknown as FileBlockEditor}
121+
>
122+
{!!embedUrl && (
123+
<Box
124+
as="iframe"
125+
className="bn-visual-media"
126+
role="presentation"
127+
$width="100%"
128+
$height="450px"
129+
src={embedUrl}
130+
title={block.props.name || t('Embedded content')}
131+
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
132+
referrerPolicy="no-referrer"
133+
loading="lazy"
134+
contentEditable={false}
135+
draggable={false}
136+
/>
137+
)}
138+
</ResizableFileBlockWrapper>
139+
</>
140+
);
141+
};
142+
143+
export const EmbedBlock = createReactBlockSpec(
144+
{
145+
type: 'embed',
146+
content: 'none',
147+
propSchema: {
148+
backgroundColor: { default: 'default' as const },
149+
caption: { default: '' as const },
150+
name: { default: '' as const },
151+
previewWidth: { default: undefined, type: 'number' },
152+
showPreview: { default: true },
153+
textAlignment: { default: 'left' as const },
154+
uploadDisabled: { default: true },
155+
url: { default: '' as const },
156+
},
157+
},
158+
{
159+
meta: {
160+
fileBlockAccept: [],
161+
},
162+
render: (props) => <EmbedBlockComponent {...props} />,
163+
},
164+
);
165+
166+
export const getEmbedReactSlashMenuItems = (
167+
editor: DocsBlockNoteEditor,
168+
t: TFunction<'translation', undefined>,
169+
group: string,
170+
) => [
171+
{
172+
title: t('Embed'),
173+
onItemClick: () => {
174+
insertOrUpdateBlockForSlashMenu(editor, { type: 'embed' });
175+
},
176+
aliases: [t('embed'), t('iframe'), t('website'), t('link')],
177+
group,
178+
icon: <Icon iconName="public" $size="18px" />,
179+
subtext: t('Embed a web page'),
180+
},
181+
];
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './CalloutBlock';
2+
export * from './DocsFilePanel';
3+
export * from './EmbedBlock';
24
export * from './PdfBlock';
35
export * from './UploadLoaderBlock';

src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
1414
blockMapping: {
1515
...docxDefaultSchemaMappings.blockMapping,
1616
callout: blockMappingCalloutDocx,
17-
// We're reusing the file block mapping for PDF blocks; both share the same
17+
// We're reusing the file block mapping for PDF/embed blocks; both share the same
1818
// implementation signature, so we can reuse the handler directly.
1919
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
embed: docxDefaultSchemaMappings.blockMapping.file as any,
2123
quote: blockMappingQuoteDocx,
2224
image: blockMappingImageDocx,
2325
uploadLoader: blockMappingUploadLoaderDocx,

src/frontend/apps/impress/src/features/docs/doc-export/mappingODT.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
1818
...odtDefaultSchemaMappings.blockMapping,
1919
callout: blockMappingCalloutODT,
2020
image: blockMappingImageODT,
21-
// We're reusing the file block mapping for PDF blocks
21+
// We're reusing the file block mapping for PDF/embed blocks
2222
// The types don't match exactly but the implementation is compatible
2323
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2424
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
25+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26+
embed: odtDefaultSchemaMappings.blockMapping.file as any,
2527
uploadLoader: blockMappingUploadLoaderODT,
2628
},
2729

src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
2222
paragraph: blockMappingParagraphPDF,
2323
quote: blockMappingQuotePDF,
2424
table: blockMappingTablePDF,
25-
// We're using the file block mapping for PDF blocks
25+
// We're using the file block mapping for PDF/embed blocks
2626
// The types don't match exactly but the implementation is compatible
2727
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2828
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
29+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
30+
embed: pdfDefaultSchemaMappings.blockMapping.file as any,
2931
uploadLoader: blockMappingUploadLoaderPDF,
3032
},
3133
inlineContentMapping: {

0 commit comments

Comments
 (0)