✨(frontend) print a doc with native browser
We can now print a doc with the native browser print dialog. This feature uses the browser's built-in print capabilities to generate a print preview and allows users to print directly from the application. It has as well a powerfull print to PDF feature that leverages the browser's PDF generation capabilities for better compatibility and quality. Co-authored-by: AntoLC <anthony.le-courric@mail.numerique.gouv.fr> Co-authored-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -6,10 +6,15 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
✨(frontend) Can print a doc #1832
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Focus main container after navigation #1854
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
🐛(frontend) fix broadcast store sync #1846
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Download, Page, expect, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
@@ -33,7 +33,9 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
|
||||
page.getByText(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
||||
await expect(
|
||||
@@ -306,6 +308,50 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfString).toContain('/Lang (fr)');
|
||||
});
|
||||
|
||||
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await overrideDocContent({ page, browserName });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'Print' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Print' }).click();
|
||||
|
||||
await expect(page.locator('#print-only-content-styles')).toBeAttached();
|
||||
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
format: 'A4',
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// If we need to update the PDF regression fixture, uncomment the line below
|
||||
// await savePDFToAssetFolder(
|
||||
// pdfBuffer,
|
||||
// 'doc-export-PDF-browser-regressions.pdf',
|
||||
// );
|
||||
|
||||
// Assert the generated PDF matches the initial PDF regression fixture
|
||||
await comparePDFWithAssetFolder(
|
||||
pdfBuffer,
|
||||
'doc-export-PDF-browser-regressions.pdf',
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('it exports the doc to PDF and checks regressions', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -325,10 +371,6 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
@@ -339,28 +381,29 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
// If we need to update the PDF regression fixture, uncomment the line below
|
||||
//await savePDFToAssetFolder(download);
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
|
||||
|
||||
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
|
||||
await comparePDFWithAssetFolder(download);
|
||||
await comparePDFWithAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
export const savePDFToAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
|
||||
export const savePDFToAssetFolder = async (
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
) => {
|
||||
const pdfPath = path.join(__dirname, 'assets', filename);
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
};
|
||||
|
||||
export const comparePDFWithAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
|
||||
export const comparePDFWithAssetFolder = async (
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
compareTextContent = true,
|
||||
) => {
|
||||
// Load reference PDF for comparison
|
||||
const referencePdfPath = path.join(
|
||||
__dirname,
|
||||
'assets',
|
||||
'doc-export-regressions.pdf',
|
||||
);
|
||||
const referencePdfPath = path.join(__dirname, 'assets', filename);
|
||||
|
||||
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
||||
|
||||
@@ -387,8 +430,16 @@ export const comparePDFWithAssetFolder = async (download: Download) => {
|
||||
// Compare page count
|
||||
expect(generatedInfo.total).toBe(referenceInfo.total);
|
||||
|
||||
// Compare text content
|
||||
/*
|
||||
Compare text content
|
||||
We make this optional because text extraction from PDFs can vary
|
||||
slightly between environments and PDF versions, leading to false negatives.
|
||||
Particularly with emojis which can be represented differently when
|
||||
exporting or parsing the PDF.
|
||||
*/
|
||||
if (compareTextContent) {
|
||||
expect(generatedText.text).toBe(referenceText.text);
|
||||
}
|
||||
|
||||
// Compare screenshots page by page
|
||||
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
||||
|
||||
@@ -34,12 +34,14 @@ import {
|
||||
generateHtmlDocument,
|
||||
improveHtmlAccessibility,
|
||||
} from '../utils_html';
|
||||
import { printDocumentWithStyles } from '../utils_print';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
HTML = 'html',
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
ODT = 'odt',
|
||||
PRINT = 'print',
|
||||
}
|
||||
|
||||
interface ModalExportProps {
|
||||
@@ -66,6 +68,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
// Handle print separately as it doesn't download a file
|
||||
if (format === DocDownloadFormat.PRINT) {
|
||||
printDocumentWithStyles();
|
||||
setIsExporting(false);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = (doc.title || untitledDocument)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
@@ -199,13 +209,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="doc-export-download-button"
|
||||
aria-label={t('Download')}
|
||||
aria-label={
|
||||
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
|
||||
}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t('Download')}
|
||||
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -225,7 +237,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
$align="flex-start"
|
||||
data-testid="modal-export-title"
|
||||
>
|
||||
{t('Download')}
|
||||
{t('Export')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the download modal')}
|
||||
@@ -243,7 +255,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
>
|
||||
<Text $variation="secondary" $size="sm" as="p">
|
||||
{t(
|
||||
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
@@ -251,10 +263,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
fullWidth
|
||||
label={t('Format')}
|
||||
options={[
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||
{ label: t('Print'), value: DocDownloadFormat.PRINT },
|
||||
]}
|
||||
value={format}
|
||||
onChange={(options) =>
|
||||
|
||||
@@ -11,6 +11,12 @@ export const escapeHtml = (value: string): string =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const moveChildNodes = (from: Element, to: Element) => {
|
||||
while (from.firstChild) {
|
||||
to.appendChild(from.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a stable, readable filename for media exported in the HTML ZIP.
|
||||
*
|
||||
@@ -138,7 +144,7 @@ export const improveHtmlAccessibility = (
|
||||
const rawLevel = Number(block.getAttribute('data-level')) || 1;
|
||||
const level = Math.min(Math.max(rawLevel, 1), 6);
|
||||
const heading = parsedDocument.createElement(`h${level}`);
|
||||
heading.innerHTML = block.innerHTML;
|
||||
moveChildNodes(block, heading);
|
||||
block.replaceWith(heading);
|
||||
});
|
||||
|
||||
@@ -252,7 +258,7 @@ export const improveHtmlAccessibility = (
|
||||
|
||||
// Create list item and add content
|
||||
const li = parsedDocument.createElement('li');
|
||||
li.innerHTML = listItem.innerHTML;
|
||||
moveChildNodes(listItem, li);
|
||||
targetList.appendChild(li);
|
||||
|
||||
// Remove original block-outer
|
||||
@@ -265,7 +271,7 @@ export const improveHtmlAccessibility = (
|
||||
);
|
||||
quoteBlocks.forEach((block) => {
|
||||
const quote = parsedDocument.createElement('blockquote');
|
||||
quote.innerHTML = block.innerHTML;
|
||||
moveChildNodes(block, quote);
|
||||
block.replaceWith(quote);
|
||||
});
|
||||
|
||||
@@ -276,7 +282,7 @@ export const improveHtmlAccessibility = (
|
||||
calloutBlocks.forEach((block) => {
|
||||
const aside = parsedDocument.createElement('aside');
|
||||
aside.setAttribute('role', 'note');
|
||||
aside.innerHTML = block.innerHTML;
|
||||
moveChildNodes(block, aside);
|
||||
block.replaceWith(aside);
|
||||
});
|
||||
|
||||
@@ -303,7 +309,7 @@ export const improveHtmlAccessibility = (
|
||||
}
|
||||
|
||||
const li = parsedDocument.createElement('li');
|
||||
li.innerHTML = item.innerHTML;
|
||||
moveChildNodes(item, li);
|
||||
|
||||
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
|
||||
const checkbox = li.querySelector<HTMLInputElement>(
|
||||
@@ -340,7 +346,7 @@ export const improveHtmlAccessibility = (
|
||||
});
|
||||
|
||||
// Move content inside <code>.
|
||||
code.innerHTML = block.innerHTML;
|
||||
moveChildNodes(block, code);
|
||||
pre.appendChild(code);
|
||||
block.replaceWith(pre);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
const PRINT_ONLY_CONTENT_STYLES_ID = 'print-only-content-styles';
|
||||
const PRINT_APPLY_DELAY_MS = 200;
|
||||
const PRINT_CLEANUP_DELAY_MS = 1000;
|
||||
const PRINT_ONLY_CONTENT_CSS = `
|
||||
@media print {
|
||||
/* Reset body and html for proper pagination */
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: var(--c--theme--colors--greyscale-000) !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements for printing */
|
||||
.--docs--header,
|
||||
.--docs--resizable-left-panel,
|
||||
.--docs--doc-editor-header,
|
||||
.--docs--doc-header,
|
||||
.--docs--doc-toolbox,
|
||||
.--docs--table-content,
|
||||
.--docs--doc-footer,
|
||||
.--docs--footer,
|
||||
footer,
|
||||
[role="contentinfo"],
|
||||
div[data-is-empty-and-focused="true"],
|
||||
div[data-floating-ui-focusable],
|
||||
.collaboration-cursor-custom__base
|
||||
{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide selection highlights */
|
||||
.ProseMirror-yjs-selection {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Reset all layout containers for print flow */
|
||||
.--docs--main-layout,
|
||||
.--docs--main-layout > *,
|
||||
main[role="main"],
|
||||
#mainContent {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
background: var(--c--theme--colors--greyscale-000) !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Prevent any ancestor from clipping the end of the document */
|
||||
.--docs--main-layout,
|
||||
.--docs--main-layout * {
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Allow editor containers to flow across pages */
|
||||
.--docs--editor-container,
|
||||
.--docs--doc-editor,
|
||||
.--docs--doc-editor-content {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Reset all Box components that might have height constraints */
|
||||
.--docs--doc-editor > div,
|
||||
.--docs--doc-editor-content > div {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure BlockNote content flows properly */
|
||||
.bn-editor,
|
||||
.bn-container,
|
||||
.--docs--main-editor,
|
||||
.bn-block-outer {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide media/embed placeholders and render their URLs */
|
||||
[data-content-type="file"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="pdf"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="audio"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="video"] .bn-file-block-content-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
div[data-page-break] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Allow large blocks/media to split across pages */
|
||||
.bn-block-content {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.--docs--main-editor {
|
||||
width: 100% !important;
|
||||
padding: 0.5cm !important;
|
||||
}
|
||||
|
||||
/* Force print all colors and backgrounds */
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Add minimal print margins */
|
||||
@page {
|
||||
margin: 0cm;
|
||||
margin-bottom: 0.7cm;
|
||||
margin-top: 0.7cm;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.print-url-label {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Removes the print-only styles from the document head if they exist.
|
||||
*/
|
||||
function removePrintOnlyStyles() {
|
||||
const stylesElement = document.getElementById(PRINT_ONLY_CONTENT_STYLES_ID);
|
||||
if (stylesElement) {
|
||||
stylesElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a style element containing CSS rules that are applied only during printing.
|
||||
*/
|
||||
function createPrintOnlyStyleElement() {
|
||||
const printStyles = document.createElement('style');
|
||||
printStyles.id = PRINT_ONLY_CONTENT_STYLES_ID;
|
||||
printStyles.textContent = PRINT_ONLY_CONTENT_CSS;
|
||||
return printStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any existing print-only styles and appends new ones to the document head.
|
||||
*/
|
||||
function appendPrintOnlyStyles() {
|
||||
removePrintOnlyStyles();
|
||||
document.head.appendChild(createPrintOnlyStyleElement());
|
||||
return removePrintOnlyStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps media elements with links to their source URLs for printing.
|
||||
*/
|
||||
function wrapMediaWithLink() {
|
||||
const createdShadowWrapper: HTMLElement[] = [];
|
||||
|
||||
const prependLink = (
|
||||
el: Element,
|
||||
url: string | null,
|
||||
name: string | null,
|
||||
type: 'file' | 'audio' | 'video' | 'pdf',
|
||||
) => {
|
||||
if (!url || !isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
const block = document.createElement('div');
|
||||
block.className = 'print-url-block-media';
|
||||
const link = document.createElement('a');
|
||||
link.className = 'print-url-link';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'print-url-label';
|
||||
|
||||
if (type === 'audio') {
|
||||
label.textContent = '🔊: ';
|
||||
} else if (type === 'video') {
|
||||
label.textContent = '📹: ';
|
||||
} else if (type === 'pdf') {
|
||||
label.textContent = '📑: ';
|
||||
} else {
|
||||
label.textContent = '🔗: ';
|
||||
}
|
||||
|
||||
link.href = url;
|
||||
link.textContent = name || url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.setAttribute('data-print-link', 'true');
|
||||
block.appendChild(label);
|
||||
block.appendChild(link);
|
||||
|
||||
const shadowWrapper = document.createElement('div');
|
||||
el.prepend(shadowWrapper);
|
||||
|
||||
// Use a shadow root to avoid propagatic the changes to the collaboration provider
|
||||
const shadowRoot = shadowWrapper.attachShadow({ mode: 'open' });
|
||||
shadowRoot.appendChild(block);
|
||||
createdShadowWrapper.push(shadowWrapper);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
|
||||
)
|
||||
.forEach((el) => {
|
||||
const url = el?.getAttribute('data-url');
|
||||
const name = el?.getAttribute('data-name');
|
||||
const type = el?.getAttribute('data-content-type') as
|
||||
| 'file'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'pdf';
|
||||
if (type) {
|
||||
prependLink(el, url, name, type);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
// remove the shadow roots that were created
|
||||
createdShadowWrapper.forEach((link) => {
|
||||
link.remove();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function printDocumentWithStyles() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupPrintStyles = appendPrintOnlyStyles();
|
||||
|
||||
// Small delay to ensure styles are applied
|
||||
setTimeout(() => {
|
||||
const cleanupLinks = wrapMediaWithLink();
|
||||
const cleanup = () => {
|
||||
cleanupLinks();
|
||||
cleanupPrintStyles();
|
||||
};
|
||||
|
||||
window.addEventListener('afterprint', cleanup, { once: true });
|
||||
requestAnimationFrame(() => window.print());
|
||||
|
||||
// Also clean up after a delay as fallback
|
||||
setTimeout(cleanup, PRINT_CLEANUP_DELAY_MS);
|
||||
}, PRINT_APPLY_DELAY_MS);
|
||||
}
|
||||
@@ -46,7 +46,9 @@ describe('DocToolBox - Licence', () => {
|
||||
// Wait for the export modal to be visible, then assert on its content text.
|
||||
await screen.findByTestId('modal-export-title');
|
||||
expect(
|
||||
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
|
||||
screen.getByText(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
}, 10000);
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ResizableLeftPanel = ({
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
ref={ref}
|
||||
className="--docs--resizable-left-panel"
|
||||
order={0}
|
||||
defaultSize={
|
||||
isDesktop
|
||||
|
||||
Reference in New Issue
Block a user