Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')}
Expand All @@ -303,6 +305,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
)}
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
<BlockNoteSideMenu />
{showComments && <FloatingComposerController />}
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget &&
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => {
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')}
</Components.Generic.Menu.Item>
);
};

const DocsDragHandleMenu = () => {
const Components = useComponentsContext();
const dict = useDictionary();

if (Components === undefined) {
return null;
}

return (
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
<RemoveBlockItem>{dict.drag_handle.delete_menuitem}</RemoveBlockItem>
<PresentBlockItem />
<BlockColorsItem>{dict.drag_handle.colors_menuitem}</BlockColorsItem>
<TableRowHeaderItem>
{dict.drag_handle.header_row_menuitem}
</TableRowHeaderItem>
<TableColumnHeaderItem>
{dict.drag_handle.header_column_menuitem}
</TableColumnHeaderItem>
</Components.Generic.Menu.Dropdown>
);
};

const DocsSideMenu = () => <SideMenu dragHandleMenu={DocsDragHandleMenu} />;

export const BlockNoteSideMenu = () => (
<SideMenuController sideMenu={DocsSideMenu} />
);
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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' } });

Expand Down Expand Up @@ -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)] });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ const getRenderedSlideGroups = <T extends PresenterBlock>(
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.
*
Expand All @@ -189,6 +195,67 @@ export const splitBlocksIntoSlides = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(blocks: T[]): T[][] => {
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
Expand Down
Loading