From 81837aff2bcf873cb68eb42bbd318a24bbb09b1a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 6 Jan 2025 16:15:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20export=20pdf=20docx=20fro?= =?UTF-8?q?nt=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have added the export to pdf and docx feature to the front side. Thanks to that, the images are now correctly exported even when the doc is private. To be able to export the doc, the data must be in blocknote format, for legacy purpose, we have to convert the template to blocknote format before exporting it. --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-export.spec.ts | 177 ++----- src/frontend/apps/e2e/package.json | 1 - src/frontend/apps/impress/package.json | 4 + .../docs/doc-header/components/DocToolBox.tsx | 12 +- .../doc-header/components/ModalExport.tsx | 153 +++--- .../src/features/docs/doc-header/utils.ts | 146 +----- src/frontend/yarn.lock | 481 ++++++++++++++++-- 8 files changed, 611 insertions(+), 364 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6a6826f..4721091c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - github actions to managed Crowdin workflow - 📈Integrate Posthog #540 - 🏷️(backend) add content-type to uploaded files #552 +- ✨(frontend) export pdf docx front side #537 ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 6e62d3add..eff9592a6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -1,6 +1,7 @@ +import path from 'path'; + import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import jsdom from 'jsdom'; import pdf from 'pdf-parse'; import { createDoc, verifyDocName } from './common'; @@ -41,10 +42,8 @@ test.describe('Doc Export', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); }); - test('it converts the doc to pdf with a template integrated', async ({ - page, - browserName, - }) => { + + test('it exports the doc to pdf', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -77,10 +76,7 @@ test.describe('Doc Export', () => { expect(pdfText).toContain('Hello World'); // This is the doc text }); - test('it converts the doc to docx with a template integrated', async ({ - page, - browserName, - }) => { + test('it exports the doc to docx', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -111,152 +107,75 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); - test('it converts the blocknote json in correct html for the export', async ({ - page, - browserName, - }) => { - test.setTimeout(60000); - + /** + * This test tell us that the export to pdf is working with images + * but it does not tell us if the images are beeing displayed correctly + * in the pdf. + * + * TODO: Check if the images are displayed correctly in the pdf + */ + test('it exports the docs with images', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); - let body = ''; - await page.route('**/templates/*/generate-document/', async (route) => { - const request = route.request(); - body = request.postDataJSON().body; - - await route.continue(); + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); }); await verifyDocName(page, randomDoc); - await page.locator('.bn-block-outer').last().fill('Hello World'); - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('Break'); - await expect(page.getByText('Break')).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - // Center the text - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="alignTextCenter"]').click(); - - // Change the background color - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="background-color-brown"]').click(); - - // Change the text color - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="text-color-orange"]').click(); - - // Add a list - await page.locator('.bn-block-outer').last().click(); await page.keyboard.press('Enter'); await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Bullet List').click(); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 3'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); - // Add a number list - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Numbered List').click(); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 3'); + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); - // Add img - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page - .getByRole('option', { - name: 'Image', - }) - .click(); - await page - .getByRole('tab', { - name: 'Embed', - }) - .click(); - await page - .getByPlaceholder('Enter URL') - .fill('https://example.com/image.jpg'); - await page - .getByRole('button', { - name: 'Embed image', - }) - .click(); + await expect(image).toBeVisible(); - // Download await page .getByRole('button', { name: 'download', }) .click(); + await page + .getByRole('combobox', { + name: 'Template', + }) + .click(); + + await page + .getByRole('option', { + name: 'Demo Template', + }) + .click({ + delay: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page .getByRole('button', { name: 'Download', }) .click(); - // Empty paragraph should be replaced by a
- expect(body.match(/
/g)?.length).toBeGreaterThanOrEqual(2); - expect(body).toContain('style="color: orange;"'); - expect(body).toContain('custom-style="center"'); - expect(body).toContain('style="background-color: brown;"'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - const { JSDOM } = jsdom; - const DOMParser = new JSDOM().window.DOMParser; - const parser = new DOMParser(); - const html = parser.parseFromString(body, 'text/html'); + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfExport = await pdf(pdfBuffer); + const pdfText = pdfExport.text; - const ulLis = html.querySelectorAll('ul li'); - expect(ulLis.length).toBe(3); - expect(ulLis[0].textContent).toBe('Test List 1'); - expect(ulLis[1].textContent).toBe('Test List 2'); - expect(ulLis[2].textContent).toBe('Test List 3'); - - const olLis = html.querySelectorAll('ol li'); - expect(olLis.length).toBe(3); - expect(olLis[0].textContent).toBe('Test Number 1'); - expect(olLis[1].textContent).toBe('Test Number 2'); - expect(olLis[2].textContent).toBe('Test Number 3'); - - const img = html.querySelectorAll('img'); - expect(img.length).toBe(1); - expect(img[0].src).toBe('https://example.com/image.jpg'); + expect(pdfText).toContain('Hello World'); }); }); diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index b46958ca4..b9dcad2d6 100644 --- a/src/frontend/apps/e2e/package.json +++ b/src/frontend/apps/e2e/package.json @@ -22,7 +22,6 @@ }, "dependencies": { "convert-stream": "1.0.2", - "jsdom": "25.0.1", "pdf-parse": "1.1.1" } } diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 7ae5efa6e..a4735a579 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,13 +18,17 @@ "@blocknote/core": "0.21.0", "@blocknote/mantine": "0.21.0", "@blocknote/react": "0.21.0", + "@blocknote/xl-docx-exporter": "0.21.0", + "@blocknote/xl-pdf-exporter": "0.21.0", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.15.0", "@openfun/cunningham-react": "2.9.4", + "@react-pdf/renderer": "4.1.6", "@sentry/nextjs": "8.47.0", "@tanstack/react-query": "5.62.11", "cmdk": "1.0.4", "crisp-sdk-web": "1.0.25", + "docx": "9.1.0", "i18next": "24.2.0", "i18next-browser-languagedetector": "8.0.2", "idb": "8.0.1", diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index cdfdacaff..30786e3e5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -26,7 +26,7 @@ import { } from '@/features/docs/doc-versioning'; import { useResponsiveStore } from '@/stores'; -import { ModalPDF } from './ModalExport'; +import { ModalExport } from './ModalExport'; interface DocToolBoxProps { doc: Doc; @@ -43,7 +43,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const colors = colorsTokens(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); + const [isModalExportOpen, setIsModalExportOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); @@ -63,7 +63,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { label: t('Export'), icon: 'download', callback: () => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }, }, ] @@ -198,7 +198,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } onClick={() => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }} size={isSmallMobile ? 'small' : 'medium'} /> @@ -228,8 +228,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { {modalShare.isOpen && ( modalShare.close()} doc={doc} /> )} - {isModalPDFOpen && ( - setIsModalPDFOpen(false)} doc={doc} /> + {isModalExportOpen && ( + setIsModalExportOpen(false)} doc={doc} /> )} {isModalRemoveOpen && ( setIsModalRemoveOpen(false)} doc={doc} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx index e08ac21dc..4e72a6eb2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx @@ -1,3 +1,11 @@ +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from '@blocknote/xl-docx-exporter'; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -7,91 +15,116 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; -import { useEffect, useMemo, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useEditorStore } from '@/features/docs/doc-editor'; import { Doc } from '@/features/docs/doc-management'; -import { useExport } from '../api/useExport'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; -import { adaptBlockNoteHTML, downloadFile } from '../utils'; +import { downloadFile, exportResolveFileUrl } from '../utils'; -export enum DocDownloadFormat { +enum DocDownloadFormat { PDF = 'pdf', DOCX = 'docx', } -interface ModalPDFProps { +interface ModalExportProps { onClose: () => void; doc: Doc; } -export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { +export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { t } = useTranslation(); const { data: templates } = useTemplates({ ordering: TemplatesOrdering.BY_CREATED_ON_DESC, }); const { toast } = useToastProvider(); const { editor } = useEditorStore(); - const { - mutate: createExport, - data: documentGenerated, - isSuccess, - isPending, - error, - } = useExport(); - const [templateIdSelected, setTemplateIdSelected] = useState(); + const [templateSelected, setTemplateSelected] = useState(''); + const [isExporting, setIsExporting] = useState(false); const [format, setFormat] = useState( DocDownloadFormat.PDF, ); const templateOptions = useMemo(() => { - if (!templates?.pages) { - return []; - } - - const templateOptions = templates.pages + const templateOptions = (templates?.pages || []) .map((page) => page.results.map((template) => ({ label: template.title, - value: template.id, + value: template.code, })), ) .flat(); - if (templateOptions.length) { - setTemplateIdSelected(templateOptions[0].value); - } + templateOptions.unshift({ + label: t('Empty template'), + value: '', + }); return templateOptions; - }, [templates?.pages]); + }, [t, templates?.pages]); - useEffect(() => { - if (!error) { + async function onSubmit() { + if (!editor) { + toast(t('The export failed'), VariantType.ERROR); return; } - toast(error.message, VariantType.ERROR); + setIsExporting(true); - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, t]); - - useEffect(() => { - if (!documentGenerated || !isSuccess) { - return; - } - - // normalize title const title = doc.title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s/g, '-'); - downloadFile(documentGenerated, `${title}.${format}`); + const html = templateSelected; + let exportDocument = editor.document; + if (html) { + const blockTemplate = await editor.tryParseHTMLToBlocks(html); + exportDocument = [...blockTemplate, ...editor.document]; + } + + let blobExport: Blob; + if (format === DocDownloadFormat.PDF) { + const defaultExporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + ); + + const exporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + const pdfDocument = await exporter.toReactPDFDocument(exportDocument); + blobExport = await pdf(pdfDocument).toBlob(); + } else { + const defaultExporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + ); + + const exporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + + blobExport = await exporter.toBlob(exportDocument); + } + + downloadFile(blobExport, `${title}.${format}`); toast( t('Your {{format}} was downloaded succesfully', { @@ -100,29 +133,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { VariantType.SUCCESS, ); + setIsExporting(false); + onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [documentGenerated, isSuccess, t]); - - async function onSubmit() { - if (!templateIdSelected || !format) { - return; - } - - if (!editor) { - toast(t('No editor found'), VariantType.ERROR); - return; - } - - let body = await editor.blocksToFullHTML(editor.document); - body = adaptBlockNoteHTML(body); - - createExport({ - templateId: templateIdSelected, - body, - body_type: 'html', - format, - }); } return ( @@ -138,6 +151,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="secondary" fullWidth onClick={() => onClose()} + disabled={isExporting} > {t('Cancel')} @@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="primary" fullWidth onClick={() => void onSubmit()} - disabled={isPending || !templateIdSelected} + disabled={isExporting} > {t('Download')} @@ -173,9 +187,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { clearable={false} label={t('Template')} options={templateOptions} - value={templateIdSelected} + value={templateSelected} onChange={(options) => - setTemplateIdSelected(options.target.value as string) + setTemplateSelected(options.target.value as string) } />