✨(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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
✨(frontend) Can print a doc #1832
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿️(frontend) Focus main container after navigation #1854
|
- ♿️(frontend) Focus main container after navigation #1854
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
🐛(frontend) fix broadcast store sync #1846
|
🐛(frontend) fix broadcast store sync #1846
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 cs from 'convert-stream';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { PDFParse } from 'pdf-parse';
|
import { PDFParse } from 'pdf-parse';
|
||||||
@@ -33,7 +33,9 @@ test.describe('Doc Export', () => {
|
|||||||
|
|
||||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -306,6 +308,50 @@ test.describe('Doc Export', () => {
|
|||||||
expect(pdfString).toContain('/Lang (fr)');
|
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 ({
|
test('it exports the doc to PDF and checks regressions', async ({
|
||||||
page,
|
page,
|
||||||
browserName,
|
browserName,
|
||||||
@@ -325,10 +371,6 @@ test.describe('Doc Export', () => {
|
|||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByTestId('doc-open-modal-download-button'),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||||
});
|
});
|
||||||
@@ -339,28 +381,29 @@ test.describe('Doc Export', () => {
|
|||||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||||
|
|
||||||
// If we need to update the PDF regression fixture, uncomment the line below
|
// 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"
|
// 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) => {
|
export const savePDFToAssetFolder = async (
|
||||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
pdfBuffer: Buffer,
|
||||||
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
|
filename: string,
|
||||||
|
) => {
|
||||||
|
const pdfPath = path.join(__dirname, 'assets', filename);
|
||||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const comparePDFWithAssetFolder = async (download: Download) => {
|
export const comparePDFWithAssetFolder = async (
|
||||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
pdfBuffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
compareTextContent = true,
|
||||||
|
) => {
|
||||||
// Load reference PDF for comparison
|
// Load reference PDF for comparison
|
||||||
const referencePdfPath = path.join(
|
const referencePdfPath = path.join(__dirname, 'assets', filename);
|
||||||
__dirname,
|
|
||||||
'assets',
|
|
||||||
'doc-export-regressions.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
||||||
|
|
||||||
@@ -387,8 +430,16 @@ export const comparePDFWithAssetFolder = async (download: Download) => {
|
|||||||
// Compare page count
|
// Compare page count
|
||||||
expect(generatedInfo.total).toBe(referenceInfo.total);
|
expect(generatedInfo.total).toBe(referenceInfo.total);
|
||||||
|
|
||||||
// Compare text content
|
/*
|
||||||
expect(generatedText.text).toBe(referenceText.text);
|
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
|
// Compare screenshots page by page
|
||||||
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ import {
|
|||||||
generateHtmlDocument,
|
generateHtmlDocument,
|
||||||
improveHtmlAccessibility,
|
improveHtmlAccessibility,
|
||||||
} from '../utils_html';
|
} from '../utils_html';
|
||||||
|
import { printDocumentWithStyles } from '../utils_print';
|
||||||
|
|
||||||
enum DocDownloadFormat {
|
enum DocDownloadFormat {
|
||||||
HTML = 'html',
|
HTML = 'html',
|
||||||
PDF = 'pdf',
|
PDF = 'pdf',
|
||||||
DOCX = 'docx',
|
DOCX = 'docx',
|
||||||
ODT = 'odt',
|
ODT = 'odt',
|
||||||
|
PRINT = 'print',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalExportProps {
|
interface ModalExportProps {
|
||||||
@@ -66,6 +68,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
|
|
||||||
setIsExporting(true);
|
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)
|
const filename = (doc.title || untitledDocument)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@@ -199,13 +209,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="doc-export-download-button"
|
data-testid="doc-export-download-button"
|
||||||
aria-label={t('Download')}
|
aria-label={
|
||||||
|
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
|
||||||
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => void onSubmit()}
|
onClick={() => void onSubmit()}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
{t('Download')}
|
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -225,7 +237,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
$align="flex-start"
|
$align="flex-start"
|
||||||
data-testid="modal-export-title"
|
data-testid="modal-export-title"
|
||||||
>
|
>
|
||||||
{t('Download')}
|
{t('Export')}
|
||||||
</Text>
|
</Text>
|
||||||
<ButtonCloseModal
|
<ButtonCloseModal
|
||||||
aria-label={t('Close the download modal')}
|
aria-label={t('Close the download modal')}
|
||||||
@@ -243,7 +255,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
>
|
>
|
||||||
<Text $variation="secondary" $size="sm" as="p">
|
<Text $variation="secondary" $size="sm" as="p">
|
||||||
{t(
|
{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>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
@@ -251,10 +263,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
label={t('Format')}
|
label={t('Format')}
|
||||||
options={[
|
options={[
|
||||||
|
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
|
||||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||||
|
{ label: t('Print'), value: DocDownloadFormat.PRINT },
|
||||||
]}
|
]}
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(options) =>
|
onChange={(options) =>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export const escapeHtml = (value: string): string =>
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.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.
|
* 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 rawLevel = Number(block.getAttribute('data-level')) || 1;
|
||||||
const level = Math.min(Math.max(rawLevel, 1), 6);
|
const level = Math.min(Math.max(rawLevel, 1), 6);
|
||||||
const heading = parsedDocument.createElement(`h${level}`);
|
const heading = parsedDocument.createElement(`h${level}`);
|
||||||
heading.innerHTML = block.innerHTML;
|
moveChildNodes(block, heading);
|
||||||
block.replaceWith(heading);
|
block.replaceWith(heading);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,7 +258,7 @@ export const improveHtmlAccessibility = (
|
|||||||
|
|
||||||
// Create list item and add content
|
// Create list item and add content
|
||||||
const li = parsedDocument.createElement('li');
|
const li = parsedDocument.createElement('li');
|
||||||
li.innerHTML = listItem.innerHTML;
|
moveChildNodes(listItem, li);
|
||||||
targetList.appendChild(li);
|
targetList.appendChild(li);
|
||||||
|
|
||||||
// Remove original block-outer
|
// Remove original block-outer
|
||||||
@@ -265,7 +271,7 @@ export const improveHtmlAccessibility = (
|
|||||||
);
|
);
|
||||||
quoteBlocks.forEach((block) => {
|
quoteBlocks.forEach((block) => {
|
||||||
const quote = parsedDocument.createElement('blockquote');
|
const quote = parsedDocument.createElement('blockquote');
|
||||||
quote.innerHTML = block.innerHTML;
|
moveChildNodes(block, quote);
|
||||||
block.replaceWith(quote);
|
block.replaceWith(quote);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,7 +282,7 @@ export const improveHtmlAccessibility = (
|
|||||||
calloutBlocks.forEach((block) => {
|
calloutBlocks.forEach((block) => {
|
||||||
const aside = parsedDocument.createElement('aside');
|
const aside = parsedDocument.createElement('aside');
|
||||||
aside.setAttribute('role', 'note');
|
aside.setAttribute('role', 'note');
|
||||||
aside.innerHTML = block.innerHTML;
|
moveChildNodes(block, aside);
|
||||||
block.replaceWith(aside);
|
block.replaceWith(aside);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,7 +309,7 @@ export const improveHtmlAccessibility = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const li = parsedDocument.createElement('li');
|
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.
|
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
|
||||||
const checkbox = li.querySelector<HTMLInputElement>(
|
const checkbox = li.querySelector<HTMLInputElement>(
|
||||||
@@ -340,7 +346,7 @@ export const improveHtmlAccessibility = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Move content inside <code>.
|
// Move content inside <code>.
|
||||||
code.innerHTML = block.innerHTML;
|
moveChildNodes(block, code);
|
||||||
pre.appendChild(code);
|
pre.appendChild(code);
|
||||||
block.replaceWith(pre);
|
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.
|
// Wait for the export modal to be visible, then assert on its content text.
|
||||||
await screen.findByTestId('modal-export-title');
|
await screen.findByTestId('modal-export-title');
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const ResizableLeftPanel = ({
|
|||||||
<PanelGroup direction="horizontal">
|
<PanelGroup direction="horizontal">
|
||||||
<Panel
|
<Panel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
className="--docs--resizable-left-panel"
|
||||||
order={0}
|
order={0}
|
||||||
defaultSize={
|
defaultSize={
|
||||||
isDesktop
|
isDesktop
|
||||||
|
|||||||
Reference in New Issue
Block a user