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]);