From d0030b4d71094bb5d36b4f84c7f37e60ce27bb66 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Tue, 30 Jun 2026 16:06:38 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(frontend)=20start=20the=20present?= =?UTF-8?q?ation=20from=20a=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Present" item to the block side menu that opens the presenter on the slide containing that block. Map any block id (incl. nested or divider ids) to its rendered content slide. Closes #2470 --- .../doc-editor/components/BlockNoteEditor.tsx | 3 + .../components/BlockNoteSideMenu.tsx | 86 +++++++++++++++++++ .../docs/doc-presenter/hooks/useSlides.ts | 67 +++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx 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..e47ea82d4a 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 @@ -51,6 +51,7 @@ import { DocsBlockNoteEditor } from '../types'; import { randomColor, sanitizeColor } from '../utils'; import BlockNoteAI from './AI'; +import { BlockNoteSideMenu } from './BlockNoteSideMenu'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks'; @@ -292,6 +293,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editor={editor} formattingToolbar={false} slashMenu={false} + sideMenu={false} theme="light" comments={false} aria-label={t('Document editor')} @@ -303,6 +305,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { )} + {showComments && } {showComments && !isCommentSideBarOpen && } {threadsSidebarTarget && diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx new file mode 100644 index 0000000000..ce658b5a4a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx @@ -0,0 +1,86 @@ +import { SideMenuExtension } from '@blocknote/core/extensions'; +import { + BlockColorsItem, + RemoveBlockItem, + SideMenu, + SideMenuController, + TableColumnHeaderItem, + TableRowHeaderItem, + useBlockNoteEditor, + useComponentsContext, + useDictionary, + useExtensionState, +} from '@blocknote/react'; +import { useTranslation } from 'react-i18next'; + +import { getContentSlideIndexForBlock } from '@/docs/doc-presenter/hooks/useSlides'; +import { usePresenterStore } from '@/docs/doc-presenter/stores'; +import type { PresenterBlock } from '@/docs/doc-presenter/types'; +import { useResponsiveStore } from '@/stores'; + +import type { DocsBlockNoteEditor } from '../types'; + +const PresentBlockItem = () => { + const { t } = useTranslation(); + const Components = useComponentsContext(); + const editor: DocsBlockNoteEditor = useBlockNoteEditor(); + const block = useExtensionState(SideMenuExtension, { + editor, + selector: (state) => state?.block, + }); + const openPresenter = usePresenterStore((state) => state.open); + const { isMobile } = useResponsiveStore(); + + // Hidden on mobile (no presenter there) and until a block is targeted + // (no drag handle hovered yet). + if (Components === undefined || block === undefined || isMobile) { + return null; + } + + return ( + { + const contentSlideIndex = getContentSlideIndexForBlock( + editor.document as PresenterBlock[], + block.id, + ); + + // Overlay slide 0 is the generated title slide; content slides start + // at index 1, hence the +1 on the 0-based content-slide index. + openPresenter(contentSlideIndex + 1); + }} + > + {t('Present')} + + ); +}; + +const DocsDragHandleMenu = () => { + const Components = useComponentsContext(); + const dict = useDictionary(); + + if (Components === undefined) { + return null; + } + + return ( + + {dict.drag_handle.delete_menuitem} + + {dict.drag_handle.colors_menuitem} + + {dict.drag_handle.header_row_menuitem} + + + {dict.drag_handle.header_column_menuitem} + + + ); +}; + +const DocsSideMenu = () => ; + +export const BlockNoteSideMenu = () => ( + +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts index 2642721cee..c19c3ff244 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts @@ -163,6 +163,12 @@ const getRenderedSlideGroups = ( stripLeadingEmptyParagraphsAfterDivider, ); +const hasBlockId = (blocks: PresenterBlock[], blockId: string): boolean => + blocks.some( + (block) => + block.id === blockId || hasBlockId(block.children ?? [], blockId), + ); + /** * Split a flat list of top-level blocks into slide groups. * @@ -189,6 +195,67 @@ export const splitBlocksIntoSlides = ( return nonEmpty.length > 0 ? nonEmpty : [[]]; }; +/** + * Map a block id to the index of the rendered (non-empty) content slide it + * belongs to. Accepts a regular block id, a block nested under a divider, or a + * divider's own id. When the matching group is dropped at render time (e.g. an + * empty slide), falls back to the closest rendered slide: the one directly + * containing the block, else the following content slide, else the previous + * one. Returns 0 when the block is absent (and as the ultimate fallback). + */ +export const getContentSlideIndexForBlock = ( + blocks: T[], + blockId: string, +): number => { + const rawGroups = getRawSlideGroups(blocks); + const renderedGroups = getRenderedSlideGroups(blocks); + const nonEmptyGroups = renderedGroups + .map((group, index) => ({ group, index })) + .filter(({ group }) => group.blocks.length > 0); + + const getClosestRenderedSlideIndex = (groupIndex: number) => { + const directSlideIndex = nonEmptyGroups.findIndex( + ({ index }) => index === groupIndex, + ); + + if (directSlideIndex !== -1) { + return directSlideIndex; + } + + const followingSlideIndex = nonEmptyGroups.findIndex( + ({ index }) => index > groupIndex, + ); + + if (followingSlideIndex !== -1) { + return followingSlideIndex; + } + + const previousSlideIndex = nonEmptyGroups.findLastIndex( + ({ index }) => index < groupIndex, + ); + + return previousSlideIndex !== -1 ? previousSlideIndex : 0; + }; + + const directGroupIndex = rawGroups.findIndex((group) => + hasBlockId(group.blocks, blockId), + ); + + if (directGroupIndex !== -1) { + return getClosestRenderedSlideIndex(directGroupIndex); + } + + const dividerGroupIndex = rawGroups.findIndex( + (group) => group.dividerId === blockId, + ); + + if (dividerGroupIndex === -1) { + return 0; + } + + return getClosestRenderedSlideIndex(dividerGroupIndex); +}; + /** Memoized {@link splitBlocksIntoSlides} for use during render. */ export const useSlides = (blocks: T[]): T[][] => { return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]); From f1a8ad98adec28f5bfa9102c3eda7a5d4c44803a Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Wed, 1 Jul 2026 14:23:28 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85(frontend)=20cover=20present=20fro?= =?UTF-8?q?m=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise block-to-slide mapping and the editor side-menu action. Keep coverage scoped to starting the presenter from a block. --- .../app-impress/presenter-mode.spec.ts | 21 +++ .../doc-presenter/__tests__/useSlides.spec.ts | 141 +++++++++++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index a6bbb82ad1..f4d0608d98 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -382,6 +382,27 @@ test.describe('Presenter Mode', () => { ); }); + test('opens the presenter at the targeted slide from a block side menu', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-side-menu', browserName, 1); + await writeMultiSlideDoc(page); + + await page + .locator('.bn-block-outer') + .filter({ hasText: 'Slide two' }) + .first() + .hover(); + await page.locator('.bn-side-menu > button').last().click(); + await page.getByRole('menuitem', { name: 'Present' }).click(); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible(); + await expect(overlay.getByText('3 / 4')).toBeVisible(); + await expect(overlay.getByText('Slide two')).toBeVisible(); + }); + test('opens presenter deep-links and normalizes slide params', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts index c5cd534696..07d3740b13 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { getSlideTitle, splitBlocksIntoSlides } from '../hooks/useSlides'; +import { + getContentSlideIndexForBlock, + getSlideTitle, + splitBlocksIntoSlides, +} from '../hooks/useSlides'; import type { PresenterBlock } from '../types'; type TestBlock = PresenterBlock; @@ -23,13 +27,14 @@ const textContent = (text: string) => ({ type: 'text' as const, }); -const para = (text = 'hello'): TestBlock => +const para = (text = 'hello', id?: string): TestBlock => block('paragraph', { content: text === '' ? [] : [textContent(text)], + ...(id ? { id } : {}), }); -const divider = (children: TestBlock[] = []): TestBlock => - block('divider', { children }); +const divider = (children: TestBlock[] = [], id?: string): TestBlock => + block('divider', { children, ...(id ? { id } : {}) }); const image = (): TestBlock => block('image', { props: { url: 'x' } }); @@ -201,6 +206,134 @@ describe('splitBlocksIntoSlides', () => { }); }); +describe('getContentSlideIndexForBlock', () => { + test('returns the slide containing a regular block', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(), para('b', 'b'), para('c', 'c')], + 'c', + ), + ).toBe(1); + }); + + test('returns the following slide for a divider', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(undefined, 'divider'), para('b', 'b')], + 'divider', + ), + ).toBe(1); + }); + + test('returns the first non-empty following slide for consecutive dividers', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'first-divider', + ), + ).toBe(1); + }); + + test('skips empty-only slides after dividers when mapping divider links', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + para('', 'empty-after-divider'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'first-divider', + ), + ).toBe(1); + }); + + test('maps a stripped empty block after a divider to the following content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'divider'), + para('', 'empty-after-divider'), + para('b', 'b'), + ], + 'empty-after-divider', + ), + ).toBe(1); + }); + + test('maps a stripped empty block before a divider to the previous content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + para('', 'empty-before-divider'), + divider(undefined, 'divider'), + para('b', 'b'), + ], + 'empty-before-divider', + ), + ).toBe(0); + }); + + test('maps a stripped empty-only slide to the next rendered content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + para('', 'empty-between-dividers'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'empty-between-dividers', + ), + ).toBe(1); + }); + + test('maps a leading divider to the first content slide', () => { + expect( + getContentSlideIndexForBlock( + [divider(undefined, 'divider'), para('a', 'a')], + 'divider', + ), + ).toBe(0); + }); + + test('maps a trailing divider to the last rendered content slide', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(undefined, 'divider')], + 'divider', + ), + ).toBe(0); + }); + + test('finds blocks nested under a structural divider', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider([para('nested', 'nested')], 'divider')], + 'nested', + ), + ).toBe(1); + }); + + test('returns the first content slide when the block is missing', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(), para('b', 'b')], + 'x', + ), + ).toBe(0); + }); +}); + const heading = (text: string): TestBlock => block('heading', { content: [textContent(text)] });