mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-29 04:55:11 +02:00
🐛(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:
+4
@@ -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() : '';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user