diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx index 3b31d69c3..5612c7cc1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx @@ -19,6 +19,10 @@ const slideContentCss = css` .bn-slash-menu { display: none !important; } + + .bn-block-content[data-content-type='divider'] { + display: none; + } `; const setRefValue = ( 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 8e2ef6724..2b7c10e5d 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 @@ -1,44 +1,11 @@ import { useMemo } from 'react'; -type Block = { - type: string; - content?: unknown; - children?: Block[]; -}; +import { PresenterBlock } from '../types'; -/** - * Split a flat list of top-level blocks into slide groups. - * - * - Each `divider` block separates two slides; the divider itself is dropped. - * - Blocks are otherwise preserved verbatim — including empty paragraphs - * (intentional spacing) and custom blocks (interlinks, embeds, ...). The - * presenter renders whatever the editor holds; it does not second-guess - * the author's content. - * - Groups with no blocks at all are removed (handles leading, trailing or - * consecutive dividers). - * - The returned array is never empty: an empty doc yields one empty group. - */ -export const splitBlocksIntoSlides = (blocks: T[]): T[][] => { - const groups: T[][] = []; - let current: T[] = []; - - for (const block of blocks) { - if (block.type === 'divider') { - groups.push(current); - current = []; - continue; - } - current.push(block); - } - groups.push(current); - - const nonEmpty = groups.filter((group) => group.length > 0); - - return nonEmpty.length > 0 ? nonEmpty : [[]]; -}; - -export const useSlides = (blocks: T[]): T[][] => { - return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]); +type SlideGroup = { + blocks: T[]; + dividerId?: string; + startsAfterDivider?: boolean; }; // Extract text from a node's inline content for summarization or accessibility. @@ -68,14 +35,186 @@ const extractInlineText = (content: unknown): string => { .join(''); }; +const isEmptyParagraphBlock = (block: PresenterBlock): boolean => + block.type === 'paragraph' && + extractInlineText(block.content).trim().length === 0 && + (block.children ?? []).length === 0; + +type TrimSide = 'leading' | 'trailing'; + +// Drop empty paragraph blocks from one end of the list. The opposite end and +// any non-empty block (content paragraphs, headings, custom blocks) are left +// untouched. +const trimEmptyParagraphs = ( + blocks: T[], + side: TrimSide, +): T[] => { + if (side === 'leading') { + const firstContentIndex = blocks.findIndex( + (block) => !isEmptyParagraphBlock(block), + ); + + return firstContentIndex === -1 ? [] : blocks.slice(firstContentIndex); + } + + const reversedLastContentIndex = [...blocks] + .reverse() + .findIndex((block) => !isEmptyParagraphBlock(block)); + + return reversedLastContentIndex === -1 + ? [] + : blocks.slice(0, blocks.length - reversedLastContentIndex); +}; + +// A divider carrying children is kept as the structural parent of the +// following slide; trim the empty paragraphs from the relevant end of its +// children. Returns null when nothing renderable remains, and the block +// unchanged when it is not such a structural divider or needs no trimming. +const trimStructuralDividerBoundary = ( + block: T, + side: TrimSide, +): T | null => { + if (block.type !== 'divider' || !Array.isArray(block.children)) { + return block; + } + + const children = trimEmptyParagraphs(block.children, side); + if (children.length === 0) { + return null; + } + + if (children.length === block.children.length) { + return block; + } + + return { ...block, children }; +}; + +const stripLeadingEmptyParagraphsAfterDivider = ( + group: SlideGroup, +): SlideGroup => { + if (!group.startsAfterDivider) { + return group; + } + + const [firstBlock, ...remainingBlocks] = group.blocks; + const firstRenderableBlock = firstBlock + ? trimStructuralDividerBoundary(firstBlock, 'leading') + : null; + const blocks = firstRenderableBlock + ? [firstRenderableBlock, ...remainingBlocks] + : remainingBlocks; + + return { + ...group, + blocks: trimEmptyParagraphs(blocks, 'leading'), + }; +}; + +const stripTrailingEmptyParagraphsBeforeDivider = ( + blocks: T[], +): T[] => { + const lastBlock = blocks[blocks.length - 1]; + const lastRenderableBlock = lastBlock + ? trimStructuralDividerBoundary(lastBlock, 'trailing') + : null; + const trimmedBlocks = lastRenderableBlock + ? [...blocks.slice(0, -1), lastRenderableBlock] + : blocks.slice(0, -1); + + return trimEmptyParagraphs(trimmedBlocks, 'trailing'); +}; + +const getRawSlideGroups = ( + blocks: T[], + options: { stripBeforeDividers?: boolean } = {}, +): SlideGroup[] => { + const groups: SlideGroup[] = [{ blocks: [] }]; + let current = groups[0]; + + for (const block of blocks) { + if (block.type === 'divider') { + if (options.stripBeforeDividers) { + current.blocks = stripTrailingEmptyParagraphsBeforeDivider( + current.blocks, + ); + } + current = { + blocks: + Array.isArray(block.children) && block.children.length > 0 + ? [block] + : [], + dividerId: block.id, + startsAfterDivider: true, + }; + groups.push(current); + continue; + } + + current.blocks.push(block); + } + + return groups; +}; + +const getRenderedSlideGroups = ( + blocks: T[], +): SlideGroup[] => + getRawSlideGroups(blocks, { stripBeforeDividers: true }).map( + stripLeadingEmptyParagraphsAfterDivider, + ); + +/** + * Split a flat list of top-level blocks into slide groups. + * + * - Each `divider` block separates two slides. Dividers without children are + * dropped. + * - A divider with children is kept as a structural parent in the following + * slide so BlockNote can preserve the visual indentation line. The presenter + * hides the divider's own horizontal rule when rendering the slide. + * - Empty paragraphs immediately before or after a divider are dropped so + * habitual spacing around slide breaks does not offset the slides. Empty + * paragraphs elsewhere are preserved as intentional spacing, and custom + * blocks (interlinks, embeds, ...) are kept verbatim. + * - Groups with no blocks at all are removed (handles leading, trailing or + * consecutive dividers). + * - The returned array is never empty: an empty doc yields one empty group. + */ +export const splitBlocksIntoSlides = ( + blocks: T[], +): T[][] => { + const nonEmpty = getRenderedSlideGroups(blocks) + .map((group) => group.blocks) + .filter((group) => group.length > 0); + + return nonEmpty.length > 0 ? nonEmpty : [[]]; +}; + +/** Memoized {@link splitBlocksIntoSlides} for use during render. */ +export const useSlides = (blocks: T[]): T[][] => { + return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]); +}; + +const getBlocksInReadingOrder = ( + blocks: T[], +): PresenterBlock[] => { + return blocks.flatMap((block) => [ + block, + ...getBlocksInReadingOrder(block.children ?? []), + ]); +}; + /** First heading text, or first block with text, for SR announcements. */ -export const getSlideTitle = ( - blocks: { type: string; content?: unknown }[], -): string => { - const heading = blocks.find((block) => block.type === 'heading'); +export const getSlideTitle = (blocks: PresenterBlock[]): string => { + const blocksInReadingOrder = getBlocksInReadingOrder(blocks); + const heading = blocksInReadingOrder.find( + (block) => block.type === 'heading', + ); const source = heading ?? - blocks.find((block) => extractInlineText(block.content).trim().length > 0); + blocksInReadingOrder.find( + (block) => extractInlineText(block.content).trim().length > 0, + ); return source ? extractInlineText(source.content).trim() : ''; };