mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
✨(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:
+80
@@ -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%;');
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -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 (
|
||||
|
||||
+17
@@ -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}
|
||||
/>
|
||||
|
||||
+169
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user