(frontend) export the presentation as a PDF

Render the slides off-screen as A4 landscape pages and print them via
the browser, one slide per page with the watermark. Add a "Download PDF"
action to the floating bar.

Closes #2446
This commit is contained in:
Nathan Panchout
2026-08-04 18:55:53 +02:00
parent 0c7ea70ab4
commit ebdc94b4bd
5 changed files with 428 additions and 1 deletions
@@ -0,0 +1,80 @@
import { render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
PRESENTER_PRINT_CONTENT_CLASS,
PRESENTER_PRINT_CSS,
PRESENTER_PRINT_LOGO_CLASS,
PRESENTER_PRINT_PAGE_CLASS,
PRESENTER_PRINT_PAGE_SELECTOR,
PRESENTER_PRINT_TITLE_CLASS,
PresenterPrintDocument,
} from '../components/PresenterPrintDocument';
import type { PresenterBlock } from '../types';
const para = (text: string): PresenterBlock => ({
children: [],
content: [{ type: 'text', text, styles: {} }],
id: 'paragraph',
props: {
backgroundColor: 'default',
textAlignment: 'left',
textColor: 'default',
},
type: 'paragraph',
});
describe('PresenterPrintDocument', () => {
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
})),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders one print page per slide', () => {
const { container } = render(
<PresenterPrintDocument
slides={[
{ kind: 'title', title: 'Deck title', showDividerHint: false },
{ kind: 'content', blocks: [para('Slide one')] },
{ kind: 'content', blocks: [para('Slide two')] },
{ kind: 'content', blocks: [] },
]}
/>,
);
expect(
container.querySelectorAll(PRESENTER_PRINT_PAGE_SELECTOR),
).toHaveLength(4);
expect(
container.querySelector(`.${PRESENTER_PRINT_TITLE_CLASS}`),
).toHaveTextContent('Deck title');
expect(
container.querySelectorAll(`.${PRESENTER_PRINT_LOGO_CLASS}`),
).toHaveLength(4);
});
test('centers short slide content vertically in print pages', () => {
expect(PRESENTER_PRINT_CSS).toContain(`.${PRESENTER_PRINT_PAGE_CLASS} {`);
expect(PRESENTER_PRINT_CSS).toContain('justify-content: safe center;');
expect(PRESENTER_PRINT_CSS).toContain(
`.${PRESENTER_PRINT_CONTENT_CLASS} {`,
);
expect(PRESENTER_PRINT_CSS).toContain('max-height: 100%;');
});
});
@@ -3,6 +3,7 @@ import { DropdownMenu, DropdownMenuItem } from '@gouvfr-lasuite/ui-kit';
import {
ChevronLeft,
ChevronRight,
Download,
Link,
Maximize,
Minimize,
@@ -22,8 +23,10 @@ interface PresenterFloatingBarProps {
onPrev: () => void;
onNext: () => void;
onCopyLink: () => void;
onExportPdf: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
isExportingPdf: boolean;
}
const barCss = css`
@@ -80,8 +83,10 @@ export const PresenterFloatingBar = ({
onPrev,
onNext,
onCopyLink,
onExportPdf,
onToggleFullscreen,
onClose,
isExportingPdf,
}: PresenterFloatingBarProps) => {
const { t } = useTranslation();
const isFirst = index <= 0;
@@ -118,8 +123,14 @@ export const PresenterFloatingBar = ({
icon: <Link aria-hidden="true" width="16" height="16" />,
callback: onCopyLink,
},
{
label: t('Download PDF'),
icon: <Download aria-hidden="true" width="16" height="16" />,
callback: onExportPdf,
isDisabled: isExportingPdf,
},
],
[onCopyLink, t],
[isExportingPdf, onCopyLink, onExportPdf, t],
);
return (
@@ -15,6 +15,7 @@ import { useCopyPresenterLink } from '../hooks/useCopyPresenterLink';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
import { getSlideTitle, useSlides } from '../hooks/useSlides';
import type { PresenterBlock, PresenterSlideData } from '../types';
import { printPresenterSlides } from '../utils_print';
import { PresenterDocsLogo } from './PresenterDocsLogo';
import { PresenterFloatingBar } from './PresenterFloatingBar';
@@ -86,6 +87,8 @@ export const PresenterOverlay = ({
],
[contentSlides, title],
);
const [isExportingPdf, setIsExportingPdf] = useState(false);
const total = slides.length;
const [currentIndex, setCurrentIndex] = useState(() =>
clampSlideIndex(initialSlideIndex, total),
@@ -128,6 +131,18 @@ export const PresenterOverlay = ({
() => setCurrentIndex(clamp(total - 1)),
[clamp, total],
);
const exportPdf = useCallback(async () => {
if (isExportingPdf) {
return;
}
setIsExportingPdf(true);
try {
await printPresenterSlides(slides);
} finally {
setIsExportingPdf(false);
}
}, [isExportingPdf, slides]);
const { isFullscreen, enter, exitIfOwned, toggle } = useBrowserFullscreen();
@@ -214,6 +229,8 @@ export const PresenterOverlay = ({
onPrev={goPrev}
onNext={goNext}
onCopyLink={() => copyPresenterLink(currentIndex)}
onExportPdf={() => void exportPdf()}
isExportingPdf={isExportingPdf}
onToggleFullscreen={() => void toggle()}
onClose={onClose}
/>
@@ -0,0 +1,169 @@
import { PRESENTER_SLIDE_DESIGN_WIDTH } from '../constants';
import { PresenterSlideData } from '../types';
import { PresenterDocsLogo } from './PresenterDocsLogo';
import { PresenterSlideContent } from './PresenterSlideContent';
interface PresenterPrintDocumentProps {
slides: PresenterSlideData[];
}
export const PRESENTER_PRINT_ROOT_ID = 'presenter-print-root';
export const PRESENTER_PRINT_STYLES_ID = 'presenter-print-styles';
export const PRESENTER_PRINT_PAGE_SELECTOR = '[data-presenter-print-page]';
export const PRESENTER_PRINT_PAGE_CLASS = '--docs--presenter-print-page';
export const PRESENTER_PRINT_CONTENT_CLASS = '--docs--presenter-print-content';
export const PRESENTER_PRINT_LOGO_CLASS = '--docs--presenter-print-logo';
export const PRESENTER_PRINT_TITLE_CLASS = '--docs--presenter-print-title';
export const PRESENTER_PRINT_CSS = `
@media screen {
#${PRESENTER_PRINT_ROOT_ID} {
position: fixed;
top: 0;
left: -100000px;
width: 297mm;
min-height: 210mm;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
}
@media print {
@page {
size: A4 landscape;
margin: 0;
}
html,
body {
width: auto !important;
height: auto !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
background: white !important;
}
body > *:not(#${PRESENTER_PRINT_ROOT_ID}) {
display: none !important;
}
#${PRESENTER_PRINT_ROOT_ID} {
display: block !important;
position: static !important;
width: auto !important;
min-height: 0 !important;
opacity: 1 !important;
overflow: visible !important;
pointer-events: auto !important;
background: white !important;
}
.${PRESENTER_PRINT_PAGE_CLASS} {
position: relative;
width: 297mm;
height: 210mm;
box-sizing: border-box;
padding: 18mm 24mm;
display: flex;
flex-direction: column;
justify-content: safe center;
overflow: hidden !important;
background: white !important;
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.${PRESENTER_PRINT_PAGE_CLASS}:last-child {
break-after: auto;
page-break-after: auto;
}
.${PRESENTER_PRINT_CONTENT_CLASS} {
width: ${PRESENTER_SLIDE_DESIGN_WIDTH}px;
max-width: 100%;
max-height: 100%;
margin: 0 auto;
overflow: hidden !important;
}
.${PRESENTER_PRINT_CONTENT_CLASS} .bn-editor,
.${PRESENTER_PRINT_CONTENT_CLASS} .bn-root,
.${PRESENTER_PRINT_CONTENT_CLASS} .ProseMirror {
min-height: 0 !important;
}
.${PRESENTER_PRINT_CONTENT_CLASS} .bn-editor {
padding: 0 !important;
}
.${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="file"] .bn-file-block-content-wrapper,
.${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="pdf"] .bn-file-block-content-wrapper,
.${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="audio"] .bn-file-block-content-wrapper,
.${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="video"] .bn-file-block-content-wrapper {
display: none !important;
}
.${PRESENTER_PRINT_CONTENT_CLASS} .print-url-label {
text-decoration: none !important;
}
.${PRESENTER_PRINT_CONTENT_CLASS} * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.${PRESENTER_PRINT_LOGO_CLASS} {
position: absolute;
bottom: 16px;
left: 16px;
width: 80px;
height: 32px;
}
.${PRESENTER_PRINT_TITLE_CLASS} {
width: ${PRESENTER_SLIDE_DESIGN_WIDTH}px;
max-width: 100%;
margin: 0 auto;
color: var(--c--contextuals--content--semantic--neutral--primary, #222631);
font-size: 40px;
font-weight: 700;
line-height: 48px;
text-align: center;
overflow-wrap: anywhere;
}
}
`;
export const PresenterPrintDocument = ({
slides,
}: PresenterPrintDocumentProps) => (
<div aria-hidden="true">
{slides.map((slide, index) => (
<section
key={index}
className={PRESENTER_PRINT_PAGE_CLASS}
data-presenter-print-page
>
{slide.kind === 'title' ? (
<h1 className={PRESENTER_PRINT_TITLE_CLASS}>{slide.title}</h1>
) : (
<PresenterSlideContent
blocks={slide.blocks}
className={PRESENTER_PRINT_CONTENT_CLASS}
/>
)}
<div className={PRESENTER_PRINT_LOGO_CLASS}>
<PresenterDocsLogo />
</div>
</section>
))}
</div>
);
@@ -0,0 +1,150 @@
import { Root, createRoot } from 'react-dom/client';
import {
wrapInterlinksWithAnchor,
wrapMediaWithLink,
} from '@/docs/doc-export/utils_print';
import {
PRESENTER_PRINT_CSS,
PRESENTER_PRINT_ROOT_ID,
PRESENTER_PRINT_STYLES_ID,
PresenterPrintDocument,
} from './components/PresenterPrintDocument';
import { PresenterSlideData } from './types';
const PRINT_CLEANUP_DELAY_MS = 10000;
const PRINT_IMAGE_WAIT_TIMEOUT_MS = 2000;
interface PresenterPrintMount {
cleanup: () => void;
container: HTMLDivElement;
root: Root;
}
const nextFrame = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
const timeout = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
const removeExistingPrintArtifacts = () => {
document.getElementById(PRESENTER_PRINT_ROOT_ID)?.remove();
document.getElementById(PRESENTER_PRINT_STYLES_ID)?.remove();
};
const createPrintStyleElement = () => {
const style = document.createElement('style');
style.id = PRESENTER_PRINT_STYLES_ID;
style.textContent = PRESENTER_PRINT_CSS;
return style;
};
const waitForImages = async (container: HTMLElement) => {
const images = Array.from(container.querySelectorAll('img'));
if (images.length === 0) {
return;
}
const imagePromises = images.map(
(image) =>
new Promise<void>((resolve) => {
if (image.complete) {
resolve();
return;
}
const done = () => {
image.removeEventListener('load', done);
image.removeEventListener('error', done);
resolve();
};
image.addEventListener('load', done, { once: true });
image.addEventListener('error', done, { once: true });
}),
);
await Promise.race([
Promise.all(imagePromises).then(() => undefined),
timeout(PRINT_IMAGE_WAIT_TIMEOUT_MS),
]);
};
export const mountPresenterPrintSlides = (
slides: PresenterSlideData[],
): PresenterPrintMount => {
removeExistingPrintArtifacts();
const style = createPrintStyleElement();
const container = document.createElement('div');
container.id = PRESENTER_PRINT_ROOT_ID;
document.head.appendChild(style);
document.body.appendChild(container);
const root = createRoot(container);
root.render(<PresenterPrintDocument slides={slides} />);
return {
container,
root,
cleanup: () => {
root.unmount();
container.remove();
style.remove();
},
};
};
// The slides are mounted off-screen via createRoot, then their content
// (BlockNote/ProseMirror) lays out asynchronously. We wait two paint frames so
// React commits the render and the editor settles before measuring images,
// then one more after images load. Do NOT collapse these into a single frame:
// printing too early yields blank pages. `printPresenterSlides` adds a final
// frame after the media/interlink wrapping for the same reason.
export const waitForPresenterPrintReady = async (container: HTMLElement) => {
await nextFrame();
await nextFrame();
await waitForImages(container);
await nextFrame();
};
export const printPresenterSlides = async (slides: PresenterSlideData[]) => {
if (typeof window === 'undefined') {
return;
}
const { cleanup, container } = mountPresenterPrintSlides(slides);
const scopedCleanups: Array<() => void> = [];
let cleaned = false;
let fallbackTimer: number | undefined;
const cleanupOnce = () => {
if (cleaned) {
return;
}
cleaned = true;
window.clearTimeout(fallbackTimer);
window.removeEventListener('afterprint', cleanupOnce);
scopedCleanups.splice(0).forEach((scopedCleanup) => scopedCleanup());
cleanup();
};
try {
window.addEventListener('afterprint', cleanupOnce, { once: true });
await waitForPresenterPrintReady(container);
scopedCleanups.push(wrapInterlinksWithAnchor(container));
scopedCleanups.push(wrapMediaWithLink(container));
await nextFrame();
window.print();
// Fallback in case `afterprint` never fires (some browsers/headless).
fallbackTimer = window.setTimeout(cleanupOnce, PRINT_CLEANUP_DELAY_MS);
} catch (error) {
cleanupOnce();
throw error;
}
};