🐛(frontend) keep divider children and trim slide-break spacing

Dividers with children are kept as structural parents so their slide
keeps its indentation; the divider's own rule is hidden. Empty
paragraphs around a divider are dropped so habitual spacing no longer
offsets slides.

Closes #2398
This commit is contained in:
Nathan Panchout
2026-07-01 13:09:35 +02:00
parent 963ff44845
commit dd844e90b6
2 changed files with 186 additions and 43 deletions
@@ -19,6 +19,10 @@ const slideContentCss = css`
.bn-slash-menu {
display: none !important;
}
.bn-block-content[data-content-type='divider'] {
display: none;
}
`;
const setRefValue = (
@@ -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 = <T extends Block>(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 = <T extends Block>(blocks: T[]): T[][] => {
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
type SlideGroup<T extends PresenterBlock> = {
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 = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(
group: SlideGroup<T>,
): SlideGroup<T> => {
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 = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(
blocks: T[],
options: { stripBeforeDividers?: boolean } = {},
): SlideGroup<T>[] => {
const groups: SlideGroup<T>[] = [{ 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 = <T extends PresenterBlock>(
blocks: T[],
): SlideGroup<T>[] =>
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 = <T extends PresenterBlock>(
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 = <T extends PresenterBlock>(blocks: T[]): T[][] => {
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
};
const getBlocksInReadingOrder = <T extends PresenterBlock>(
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() : '';
};