diff --git a/CHANGELOG.md b/CHANGELOG.md index da6a6826..4721091c 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 6e62d3ad..eff9592a 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 b46958ca..b9dcad2d 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 7ae5efa6..a4735a57 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 cdfdacaf..30786e3e 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 e08ac21d..4e72a6eb 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) } />