From 0b5635e440f93eeb91eb60788c355c67e2b307c4 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 6 Jan 2025 16:15:45 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8(frontend)=20export=20pdf=20docx?= =?UTF-8?q?=20front=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 | 150 ------ src/frontend/apps/e2e/package.json | 1 - src/frontend/apps/impress/package.json | 4 + .../docs/doc-header/components/DocToolBox.tsx | 10 +- .../doc-header/components/ModalExport.tsx | 116 ++--- src/frontend/yarn.lock | 469 ++++++++++++++++-- 7 files changed, 495 insertions(+), 256 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e07ec46..feefc5135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - 💄(frontend) add filtering to left panel #475 - ✨(frontend) new share modal ui #489 - ✨(frontend) add favorite feature #515 +- ✨(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..2141cc5a4 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,5 @@ 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'; @@ -110,153 +109,4 @@ test.describe('Doc Export', () => { const download = await downloadPromise; expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); - - test('it converts the blocknote json in correct html for the export', async ({ - page, - browserName, - }) => { - test.setTimeout(60000); - - 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(); - }); - - 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(); - - // 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.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - - // 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'); - - // 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(); - - // Download - await page - .getByRole('button', { - name: 'download', - }) - .click(); - - 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 { JSDOM } = jsdom; - const DOMParser = new JSDOM().window.DOMParser; - const parser = new DOMParser(); - const html = parser.parseFromString(body, 'text/html'); - - 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'); - }); }); diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index 290b8808e..715230c0d 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 1439798d0..8f06d7dc7 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,13 +18,17 @@ "@blocknote/core": "0.22.0", "@blocknote/mantine": "0.22.0", "@blocknote/react": "0.22.0", + "@blocknote/xl-docx-exporter": "0.22.0", + "@blocknote/xl-pdf-exporter": "0.22.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 adf3ce5c2..1fd228836 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 @@ -27,7 +27,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(); @@ -200,7 +200,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } onClick={() => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }} size={isSmallMobile ? 'small' : 'medium'} /> @@ -230,8 +230,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..2dace32f0 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,97 +1,108 @@ +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from '@blocknote/xl-docx-exporter'; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from '@blocknote/xl-pdf-exporter'; import { Button, - Loader, Modal, ModalSize, Select, 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 { 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 } from '../utils'; export 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 [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 (!format) { return; } - toast(error.message, VariantType.ERROR); - - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, t]); - - useEffect(() => { - if (!documentGenerated || !isSuccess) { + if (!editor) { + toast(t('No editor found'), VariantType.ERROR); 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; + if (format === 'pdf') { + const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); + const pdfDocument = await exporter.toReactPDFDocument(exportDocument); + blobExport = await pdf(pdfDocument).toBlob(); + } else { + const exporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + ); + + blobExport = await exporter.toBlob(exportDocument); + } + + downloadFile(blobExport, `${title}.${format}`); toast( t('Your {{format}} was downloaded succesfully', { @@ -101,28 +112,6 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { ); 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 ( @@ -146,7 +135,6 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="primary" fullWidth onClick={() => void onSubmit()} - disabled={isPending || !templateIdSelected} > {t('Download')} @@ -173,9 +161,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) } />