✨(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:
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user