Skip to content

Commit d0030b4

Browse files
✨(frontend) start the presentation from a block
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
1 parent 9c3bc8a commit d0030b4

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { DocsBlockNoteEditor } from '../types';
5151
import { randomColor, sanitizeColor } from '../utils';
5252

5353
import BlockNoteAI from './AI';
54+
import { BlockNoteSideMenu } from './BlockNoteSideMenu';
5455
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
5556
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
5657
import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks';
@@ -292,6 +293,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
292293
editor={editor}
293294
formattingToolbar={false}
294295
slashMenu={false}
296+
sideMenu={false}
295297
theme="light"
296298
comments={false}
297299
aria-label={t('Document editor')}
@@ -303,6 +305,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
303305
)}
304306
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
305307
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
308+
<BlockNoteSideMenu />
306309
{showComments && <FloatingComposerController />}
307310
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
308311
{threadsSidebarTarget &&
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { SideMenuExtension } from '@blocknote/core/extensions';
2+
import {
3+
BlockColorsItem,
4+
RemoveBlockItem,
5+
SideMenu,
6+
SideMenuController,
7+
TableColumnHeaderItem,
8+
TableRowHeaderItem,
9+
useBlockNoteEditor,
10+
useComponentsContext,
11+
useDictionary,
12+
useExtensionState,
13+
} from '@blocknote/react';
14+
import { useTranslation } from 'react-i18next';
15+
16+
import { getContentSlideIndexForBlock } from '@/docs/doc-presenter/hooks/useSlides';
17+
import { usePresenterStore } from '@/docs/doc-presenter/stores';
18+
import type { PresenterBlock } from '@/docs/doc-presenter/types';
19+
import { useResponsiveStore } from '@/stores';
20+
21+
import type { DocsBlockNoteEditor } from '../types';
22+
23+
const PresentBlockItem = () => {
24+
const { t } = useTranslation();
25+
const Components = useComponentsContext();
26+
const editor: DocsBlockNoteEditor = useBlockNoteEditor();
27+
const block = useExtensionState(SideMenuExtension, {
28+
editor,
29+
selector: (state) => state?.block,
30+
});
31+
const openPresenter = usePresenterStore((state) => state.open);
32+
const { isMobile } = useResponsiveStore();
33+
34+
// Hidden on mobile (no presenter there) and until a block is targeted
35+
// (no drag handle hovered yet).
36+
if (Components === undefined || block === undefined || isMobile) {
37+
return null;
38+
}
39+
40+
return (
41+
<Components.Generic.Menu.Item
42+
className="bn-menu-item"
43+
onClick={() => {
44+
const contentSlideIndex = getContentSlideIndexForBlock(
45+
editor.document as PresenterBlock[],
46+
block.id,
47+
);
48+
49+
// Overlay slide 0 is the generated title slide; content slides start
50+
// at index 1, hence the +1 on the 0-based content-slide index.
51+
openPresenter(contentSlideIndex + 1);
52+
}}
53+
>
54+
{t('Present')}
55+
</Components.Generic.Menu.Item>
56+
);
57+
};
58+
59+
const DocsDragHandleMenu = () => {
60+
const Components = useComponentsContext();
61+
const dict = useDictionary();
62+
63+
if (Components === undefined) {
64+
return null;
65+
}
66+
67+
return (
68+
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
69+
<RemoveBlockItem>{dict.drag_handle.delete_menuitem}</RemoveBlockItem>
70+
<PresentBlockItem />
71+
<BlockColorsItem>{dict.drag_handle.colors_menuitem}</BlockColorsItem>
72+
<TableRowHeaderItem>
73+
{dict.drag_handle.header_row_menuitem}
74+
</TableRowHeaderItem>
75+
<TableColumnHeaderItem>
76+
{dict.drag_handle.header_column_menuitem}
77+
</TableColumnHeaderItem>
78+
</Components.Generic.Menu.Dropdown>
79+
);
80+
};
81+
82+
const DocsSideMenu = () => <SideMenu dragHandleMenu={DocsDragHandleMenu} />;
83+
84+
export const BlockNoteSideMenu = () => (
85+
<SideMenuController sideMenu={DocsSideMenu} />
86+
);

src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ const getRenderedSlideGroups = <T extends PresenterBlock>(
163163
stripLeadingEmptyParagraphsAfterDivider,
164164
);
165165

166+
const hasBlockId = (blocks: PresenterBlock[], blockId: string): boolean =>
167+
blocks.some(
168+
(block) =>
169+
block.id === blockId || hasBlockId(block.children ?? [], blockId),
170+
);
171+
166172
/**
167173
* Split a flat list of top-level blocks into slide groups.
168174
*
@@ -189,6 +195,67 @@ export const splitBlocksIntoSlides = <T extends PresenterBlock>(
189195
return nonEmpty.length > 0 ? nonEmpty : [[]];
190196
};
191197

198+
/**
199+
* Map a block id to the index of the rendered (non-empty) content slide it
200+
* belongs to. Accepts a regular block id, a block nested under a divider, or a
201+
* divider's own id. When the matching group is dropped at render time (e.g. an
202+
* empty slide), falls back to the closest rendered slide: the one directly
203+
* containing the block, else the following content slide, else the previous
204+
* one. Returns 0 when the block is absent (and as the ultimate fallback).
205+
*/
206+
export const getContentSlideIndexForBlock = <T extends PresenterBlock>(
207+
blocks: T[],
208+
blockId: string,
209+
): number => {
210+
const rawGroups = getRawSlideGroups(blocks);
211+
const renderedGroups = getRenderedSlideGroups(blocks);
212+
const nonEmptyGroups = renderedGroups
213+
.map((group, index) => ({ group, index }))
214+
.filter(({ group }) => group.blocks.length > 0);
215+
216+
const getClosestRenderedSlideIndex = (groupIndex: number) => {
217+
const directSlideIndex = nonEmptyGroups.findIndex(
218+
({ index }) => index === groupIndex,
219+
);
220+
221+
if (directSlideIndex !== -1) {
222+
return directSlideIndex;
223+
}
224+
225+
const followingSlideIndex = nonEmptyGroups.findIndex(
226+
({ index }) => index > groupIndex,
227+
);
228+
229+
if (followingSlideIndex !== -1) {
230+
return followingSlideIndex;
231+
}
232+
233+
const previousSlideIndex = nonEmptyGroups.findLastIndex(
234+
({ index }) => index < groupIndex,
235+
);
236+
237+
return previousSlideIndex !== -1 ? previousSlideIndex : 0;
238+
};
239+
240+
const directGroupIndex = rawGroups.findIndex((group) =>
241+
hasBlockId(group.blocks, blockId),
242+
);
243+
244+
if (directGroupIndex !== -1) {
245+
return getClosestRenderedSlideIndex(directGroupIndex);
246+
}
247+
248+
const dividerGroupIndex = rawGroups.findIndex(
249+
(group) => group.dividerId === blockId,
250+
);
251+
252+
if (dividerGroupIndex === -1) {
253+
return 0;
254+
}
255+
256+
return getClosestRenderedSlideIndex(dividerGroupIndex);
257+
};
258+
192259
/** Memoized {@link splitBlocksIntoSlides} for use during render. */
193260
export const useSlides = <T extends PresenterBlock>(blocks: T[]): T[][] => {
194261
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);

0 commit comments

Comments
 (0)