♻️(frontend) extract reusable presenter slide content component

Pull the BlockNote rendering out of PresenterSlide into
PresenterSlideContent so it can be reused by the title slide and the PDF
export. Add a PresenterSlideData union (title | content) and a shared
PresenterBlock type.
This commit is contained in:
Nathan Panchout
2026-07-20 09:55:20 +02:00
parent 877109be79
commit d87b82ef37
2 changed files with 99 additions and 40 deletions
@@ -1,11 +1,8 @@
import { BlockNoteView } from '@blocknote/mantine';
import { useCreateBlockNote } from '@blocknote/react';
import { RefObject, useEffect, useRef } from 'react';
import { RefObject, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor';
import {
PRESENTER_FRAME_PADDING_Y,
@@ -14,6 +11,8 @@ import {
} from '../constants';
import { useFitScale } from '../hooks/useFitScale';
import { PresenterSlideContent } from './PresenterSlideContent';
interface PresenterSlideProps {
blocks: unknown[];
frameRef: RefObject<HTMLDivElement | null>;
@@ -47,12 +46,6 @@ const outerCss = css`
overflow-x: hidden;
background: white;
transition: opacity ${PRESENTER_SLIDE_FADE_MS}ms ease;
/* Hide editor chrome that may leak through despite editable={false} */
.bn-side-menu,
.bn-formatting-toolbar,
.bn-slash-menu {
display: none !important;
}
`;
// The stage absorbs the un-scaled inner's layout box. Its explicit height
@@ -85,28 +78,6 @@ export const PresenterSlide = ({
}: PresenterSlideProps) => {
const { t } = useTranslation();
const innerRef = useRef<HTMLDivElement>(null);
const editor = useCreateBlockNote({
initialContent:
// BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
blocks.length > 0
? (blocks as NonNullable<
Parameters<typeof useCreateBlockNote>[0]
>['initialContent'])
: undefined,
schema: blockNoteSchema,
});
// ProseMirror adds role="textbox" and contenteditable on its root even
// when editable=false, making the SR announce "editing, autocomplete" on
// focus. Strip those attributes so the slide reads as plain content.
useEffect(() => {
const pm = innerRef.current?.querySelector('.ProseMirror');
if (pm) {
pm.removeAttribute('role');
pm.removeAttribute('contenteditable');
pm.setAttribute('tabindex', '-1');
}
}, []);
const fit = useFitScale(innerRef, frameRef);
@@ -136,14 +107,7 @@ export const PresenterSlide = ({
>
<Box $css={stageCss} style={stageStyle}>
<Box ref={innerRef} $css={innerCss} style={innerStyle}>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
<PresenterSlideContent blocks={blocks} />
</Box>
</Box>
</Box>
@@ -0,0 +1,95 @@
import { BlockNoteView } from '@blocknote/mantine';
import { useCreateBlockNote } from '@blocknote/react';
import { CSSProperties, Ref, useEffect, useRef } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor';
import type { PresenterBlock } from '../types';
interface PresenterSlideContentProps {
blocks: PresenterBlock[];
className?: string;
innerRef?: Ref<HTMLDivElement>;
style?: CSSProperties;
}
const slideContentCss = css`
.bn-side-menu,
.bn-formatting-toolbar,
.bn-slash-menu {
display: none !important;
}
`;
const setRefValue = (
ref: Ref<HTMLDivElement> | undefined,
node: HTMLDivElement | null,
) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(node);
return;
}
(ref as { current: HTMLDivElement | null }).current = node;
};
export const PresenterSlideContent = ({
blocks,
className,
innerRef,
style,
}: PresenterSlideContentProps) => {
const contentRef = useRef<HTMLDivElement | null>(null);
const editor = useCreateBlockNote({
initialContent:
// BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
blocks.length > 0
? (blocks as NonNullable<
Parameters<typeof useCreateBlockNote>[0]
>['initialContent'])
: undefined,
schema: blockNoteSchema,
});
// Even with `editable={false}`, BlockNote/ProseMirror currently still renders
// the editor node with `role="textbox"` and `contenteditable`. For presenter
// slides that is wrong - the content is presentation, not an editable field -
// and it pollutes the accessibility tree. Strip those, but keep the content
// tabbable so keyboard users can reach and scroll long slides. (Observed
// BlockNote behaviour, not a guarantee; revisit on upgrades.)
useEffect(() => {
const pm = contentRef.current?.querySelector('.ProseMirror');
if (pm) {
pm.removeAttribute('role');
pm.removeAttribute('contenteditable');
pm.setAttribute('tabindex', '0');
}
}, []);
return (
<Box
ref={(node: HTMLDivElement | null) => {
contentRef.current = node;
setRefValue(innerRef, node);
}}
className={className}
$css={slideContentCss}
style={style}
>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
</Box>
);
};