diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aafeb4ab4..d4516c8c25 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/documentation/env.md b/documentation/env.md index 6da1e30e86..78946940c9 100644 --- a/documentation/env.md +++ b/documentation/env.md @@ -80,6 +80,8 @@ These are the environment variables you can set for the `impress-backend` contai | FRONTEND_CSS_URL | To add a external css file to the app | | | FRONTEND_JS_URL | To add a external js file to the app | | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | +| FRONTEND_EMBED_BLOCK_ENABLED | Frontend feature flag to allow inserting the "embed" block | true | +| FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS | Allowlist mapping each embeddable host to its iframe sandbox, e.g. `{"excalidraw.com": "allow-scripts allow-same-origin"}`. Each key matches only that exact host; prefix with `*.` to also cover its subdomains, or use a bare `*` to allow any host | {"*": "allow-scripts allow-same-origin"} | | FRONTEND_THEME | Frontend theme to use | | | LANGUAGE_CODE | Default language | en-us | | LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d9991bcbf..eca09edd4e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -3087,6 +3087,8 @@ def get(self, request): "CONVERSION_UPLOAD_ENABLED", "ENVIRONMENT", "FRONTEND_CSS_URL", + "FRONTEND_EMBED_BLOCK_ENABLED", + "FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS", "FRONTEND_HOMEPAGE_FEATURE_ENABLED", "FRONTEND_JS_URL", "FRONTEND_SILENT_LOGIN_ENABLED", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 5f7fef4536..4138e5a0ae 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -29,6 +29,10 @@ COLLABORATION_WS_INACTIVITY_TIMEOUT=300, CONVERSION_UPLOAD_ENABLED=False, FRONTEND_CSS_URL="http://testcss/", + FRONTEND_EMBED_BLOCK_ENABLED=True, + FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS={ + "excalidraw.com": "allow-scripts allow-same-origin", + }, FRONTEND_JS_URL="http://testjs/", FRONTEND_THEME="test-theme", MEDIA_BASE_URL="http://testserver/", @@ -63,6 +67,10 @@ def test_api_config(is_authenticated): "CONVERSION_UPLOAD_ENABLED": False, "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", + "FRONTEND_EMBED_BLOCK_ENABLED": True, + "FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS": { + "excalidraw.com": "allow-scripts allow-same-origin", + }, "FRONTEND_HOMEPAGE_FEATURE_ENABLED": True, "FRONTEND_JS_URL": "http://testjs/", "FRONTEND_SILENT_LOGIN_ENABLED": False, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7c94fb9f53..d5072aa980 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -554,6 +554,28 @@ class Base(Configuration): FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue( default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None ) + FRONTEND_EMBED_BLOCK_ENABLED = values.BooleanValue( + default=True, environ_name="FRONTEND_EMBED_BLOCK_ENABLED", environ_prefix=None + ) + # Allowlist of origins that may be embedded through the "embed" custom block, + # mapping each allowed host to the sandbox attribute to apply to its iframe. + # A URL whose host is not covered here is refused by the frontend. + # Example: + # { + # "grist.numerique.gouv.fr": "allow-scripts", + # "*.numerique.gouv.fr": "allow-scripts allow-same-origin", + # } + FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS = values.DictValue( + default={ + "*": ( + "allow-scripts allow-same-origin allow-popups " + "allow-popups-to-escape-sandbox allow-forms" + ) + }, + environ_name="FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS", + environ_prefix=None, + ) + THEME_CUSTOMIZATION_FILE_PATH = values.Value( os.path.join(BASE_DIR, "impress/configuration/theme/default.json"), environ_name="THEME_CUSTOMIZATION_FILE_PATH", 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 0524167227..bbec001213 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 @@ -3,8 +3,18 @@ import path from 'path'; import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import { createDoc, goToGridDoc, verifyDocName } from './utils-common'; -import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor'; +import { + createDoc, + goToGridDoc, + overrideConfig, + verifyDocName, +} from './utils-common'; +import { + getEditor, + openSuggestionMenu, + tryFocusEditorContent, + writeInEditor, +} from './utils-editor'; import { updateShareLink } from './utils-share'; import { createRootSubPage, @@ -544,7 +554,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 +647,128 @@ 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://localhost:3001/'); + 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:\/\/localhost:3001\//, + ); + + await expect(embedIframe).toHaveAttribute('role', 'presentation'); + }); + + test('it controls the embeds of a web page with configuration', async ({ + page, + browserName, + }) => { + // Disable the embed block feature to test the configuration + await overrideConfig(page, { + FRONTEND_EMBED_BLOCK_ENABLED: false, + }); + + await createDoc(page, 'doc-embed-web-configuration', browserName, 1); + + await tryFocusEditorContent({ page }); + await page.keyboard.press('Enter'); + await page.keyboard.type('/'); + + await expect( + page.locator('.bn-suggestion-menu').getByText('Embed a web page'), + ).toBeHidden(); + + await overrideConfig(page, { + FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: { + 'http://localhost:3001': 'allow-scripts', + }, + }); + + await page.reload(); + + await openSuggestionMenu({ page, suggestion: 'Embed a web page' }); + + const embedBlock = page.locator('div[data-content-type="embed"]').last(); + + await expect(embedBlock).toBeVisible(); + + // Try with a domain that is not allowed first + await page + .getByText(/Add embed/) + .first() + .click(); + + await page + .locator('[data-test="embed-input"]') + .fill('http://localhost:3002/'); + + await page.locator('[data-test="embed-input-button"]').click(); + + await expect( + page.getByText( + 'This domain is not allowed for embedding. Please contact your administrator to have it added to the list of allowed domains.', + ), + ).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://localhost:3001/'); + 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:\/\/localhost:3001\//, + ); + await expect(embedIframe).toHaveAttribute('sandbox', 'allow-scripts'); + await expect(embedIframe).toHaveAttribute('role', 'presentation'); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index b42785719e..2c32649446 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -25,6 +25,10 @@ export const CONFIG = { CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], CONVERSION_FILE_MAX_SIZE: 20971520, ENVIRONMENT: 'development', + FRONTEND_EMBED_BLOCK_ENABLED: true, + FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: { + '*': 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms', + }, FRONTEND_CSS_URL: null, FRONTEND_JS_URL: null, FRONTEND_HOMEPAGE_FEATURE_ENABLED: true, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index b204e5a381..45018acbfe 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -42,6 +42,17 @@ interface ThemeCustomization { waffle?: LaGaufreV2Props; } +/** + * Map of an allowed embed host to the iframe `sandbox` attribute to apply. + * Record + * Example: + * { + * "excalidraw.com": "allow-scripts allow-same-origin", + * "www.tldraw.com": "allow-scripts allow-same-origin", + * } + */ +export type EmbedAllowedOrigins = Record; + export interface ConfigResponse { AI_BOT: { name: string; color: string }; AI_FEATURE_ENABLED?: boolean; @@ -56,6 +67,8 @@ export interface ConfigResponse { CONVERSION_UPLOAD_ENABLED?: boolean; ENVIRONMENT: string; FRONTEND_CSS_URL?: string; + FRONTEND_EMBED_BLOCK_ENABLED?: boolean; + FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS?: EmbedAllowedOrigins; FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean; FRONTEND_JS_URL?: string; FRONTEND_SILENT_LOGIN_ENABLED?: boolean; 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 5c9b2b1e29..7e4681ee89 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 805377f691..4e59c7ac7e 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 @@ -11,6 +11,8 @@ import { import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useConfig } from '@/core'; + import { DocsBlockSchema, DocsInlineContentSchema, @@ -20,6 +22,7 @@ import { import BlockNoteAI from './AI'; import { getCalloutReactSlashMenuItems, + getEmbedReactSlashMenuItems, getPdfReactSlashMenuItems, } from './custom-blocks'; import { useGetInterlinkingMenuItems } from './custom-inline-content'; @@ -44,6 +47,7 @@ export const BlockNoteSuggestionMenu = ({ const dictionaryDate = useDictionary(); const basicBlocksName = dictionaryDate.slash_menu.page_break.group; const fileBlocksName = dictionaryDate.slash_menu.file.group; + const embedEnabled = useConfig().data?.FRONTEND_EMBED_BLOCK_ENABLED ?? false; const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); @@ -56,6 +60,9 @@ export const BlockNoteSuggestionMenu = ({ getPageBreakReactSlashMenuItems(editor), getMultiColumnSlashMenuItems?.(editor) || [], getPdfReactSlashMenuItems(editor, t, fileBlocksName), + embedEnabled + ? getEmbedReactSlashMenuItems(editor, t, fileBlocksName) + : [], getCalloutReactSlashMenuItems(editor, t, basicBlocksName), aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [], ); @@ -80,6 +87,7 @@ export const BlockNoteSuggestionMenu = ({ fileBlocksName, basicBlocksName, aiAllowed, + embedEnabled, getInterlinkingMenuItems, ]); 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 0000000000..fa8646874e --- /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' ? ( + + ); +}; 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 0000000000..6c694c9fb0 --- /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 0000000000..c99019b31f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx @@ -0,0 +1,283 @@ +/** + * EmbedBlock — embeds an external web page in a document via a sandboxed + * `