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)
}
/>