(frontend) export pdf docx front side

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.
This commit is contained in:
Anthony LC
2025-01-06 16:15:45 +01:00
committed by Anthony LC
parent 40c1107959
commit 81837aff2b
8 changed files with 611 additions and 364 deletions

View File

@@ -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",

View File

@@ -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) => {
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalPDFOpen(true);
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
@@ -228,8 +228,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalPDFOpen && (
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
{isModalExportOpen && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />

View File

@@ -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<string>();
const [templateSelected, setTemplateSelected] = useState<string>('');
const [isExporting, setIsExporting] = useState(false);
const [format, setFormat] = useState<DocDownloadFormat>(
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')}
</Button>
@@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
color="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isPending || !templateIdSelected}
disabled={isExporting}
>
{t('Download')}
</Button>
@@ -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)
}
/>
<Select
@@ -192,8 +206,17 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
}
/>
{isPending && (
<Box $align="center" $margin={{ top: 'big' }}>
{isExporting && (
<Box
$align="center"
$margin={{ top: 'big' }}
$css={css`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -100%);
`}
>
<Loader />
</Box>
)}

View File

@@ -10,137 +10,23 @@ export function downloadFile(blob: Blob, filename: string) {
window.URL.revokeObjectURL(url);
}
const convertToLi = (html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const divs = doc.querySelectorAll(
'div[data-content-type="bulletListItem"] , div[data-content-type="numberedListItem"]',
);
export const exportResolveFileUrl = async (
url: string,
resolveFileUrl: ((url: string) => Promise<string | Blob>) | undefined,
) => {
if (!url.includes(window.location.hostname) && resolveFileUrl) {
return resolveFileUrl(url);
}
// Loop through each div and replace it with a li
divs.forEach((div) => {
// Create a new li element
const li = document.createElement('li');
// Copy the attributes from the div to the li
for (let i = 0; i < div.attributes.length; i++) {
li.setAttribute(div.attributes[i].name, div.attributes[i].value);
}
// Move all child elements of the div to the li
while (div.firstChild) {
li.appendChild(div.firstChild);
}
// Replace the div with the li in the DOM
if (div.parentNode) {
div.parentNode.replaceChild(li, div);
}
});
/**
* Convert the blocknote content to a simplified version to be
* correctly parsed by our pdf and docx parser
*/
const newContent: string[] = [];
let currentList: HTMLUListElement | HTMLOListElement | null = null;
// Iterate over all the children of the bn-block-group
doc.body
.querySelectorAll('.bn-block-group .bn-block-outer')
.forEach((outerDiv) => {
const blockContent = outerDiv.querySelector('.bn-block-content');
if (blockContent) {
const contentType = blockContent.getAttribute('data-content-type');
if (contentType === 'bulletListItem') {
// If a list is not started, start a new one
if (!currentList) {
currentList = document.createElement('ul');
}
currentList.appendChild(blockContent);
} else if (contentType === 'numberedListItem') {
// If a numbered list is not started, start a new one
if (!currentList) {
currentList = document.createElement('ol');
}
currentList.appendChild(blockContent);
} else {
/***
* If there is a current list, add it to the new content
* It means that the current list has ended
*/
if (currentList) {
newContent.push(currentList.outerHTML);
}
currentList = null;
newContent.push(outerDiv.outerHTML);
}
} else {
// In case there is no content-type, add the outerDiv as is
newContent.push(outerDiv.outerHTML);
}
try {
const response = await fetch(url, {
credentials: 'include',
});
return newContent.join('');
};
const convertToImg = (html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const divs = doc.querySelectorAll('div[data-content-type="image"]');
// Loop through each div and replace it with a img
divs.forEach((div) => {
const img = document.createElement('img');
// Copy the attributes from the div to the img
for (let i = 0; i < div.attributes.length; i++) {
img.setAttribute(div.attributes[i].name, div.attributes[i].value);
if (div.attributes[i].name === 'data-url') {
img.setAttribute('src', div.attributes[i].value);
}
if (div.attributes[i].name === 'data-preview-width') {
img.setAttribute('width', div.attributes[i].value);
}
}
// Move all child elements of the div to the img
while (div.firstChild) {
img.appendChild(div.firstChild);
}
// Replace the div with the img in the DOM
if (div.parentNode) {
div.parentNode.replaceChild(img, div);
}
});
return doc.body.innerHTML;
};
export const adaptBlockNoteHTML = (html: string) => {
html = html.replaceAll('<p class="bn-inline-content"></p>', '<br/>');
// custom-style is used by pandoc to convert the style
html = html.replaceAll(
/data-text-alignment=\"([a-z]+)\"/g,
'custom-style="$1"',
);
html = html.replaceAll(/data-text-color=\"([a-z]+)\"/g, 'style="color: $1;"');
html = html.replaceAll(
/data-background-color=\"([a-z]+)\"/g,
'style="background-color: $1;"',
);
html = convertToLi(html);
html = convertToImg(html);
return html;
return response.blob();
} catch {
console.error(`Failed to fetch image: ${url}`);
}
return url;
};