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-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/__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)] }); 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]);