♻️(frontend) export to docx

We can now export the document to docx format.
We adapted the frontend to be able to choose
between pdf or docx export.
This commit is contained in:
Anthony LC
2024-08-07 14:45:02 +02:00
committed by Anthony LC
parent 4280f0779e
commit c1566d98fe
10 changed files with 748 additions and 340 deletions

View File

@@ -2,17 +2,19 @@ import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
interface CreatePdfParams {
interface CreateExportParams {
templateId: string;
body: string;
body_type: 'html' | 'markdown';
format: 'pdf' | 'docx';
}
export const createPdf = async ({
export const createExport = async ({
templateId,
body,
body_type,
}: CreatePdfParams): Promise<Blob> => {
format,
}: CreateExportParams): Promise<Blob> => {
const response = await fetchAPI(
`templates/${templateId}/generate-document/`,
{
@@ -20,19 +22,23 @@ export const createPdf = async ({
body: JSON.stringify({
body,
body_type,
format,
}),
},
);
if (!response.ok) {
throw new APIError('Failed to create the pdf', await errorCauses(response));
throw new APIError(
'Failed to export the document',
await errorCauses(response),
);
}
return await response.blob();
};
export function useCreatePdf() {
return useMutation<Blob, APIError, CreatePdfParams>({
mutationFn: createPdf,
export function useExport() {
return useMutation<Blob, APIError, CreateExportParams>({
mutationFn: createExport,
});
}

View File

@@ -10,7 +10,7 @@ import {
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { ModalPDF } from './ModalPDF';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
@@ -83,10 +83,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">picture_as_pdf</span>}
icon={<span className="material-icons">file_download</span>}
size="small"
>
<Text $theme="primary">{t('Generate PDF')}</Text>
<Text $theme="primary">{t('Export')}</Text>
</Button>
</Box>
</DropButton>

View File

@@ -4,6 +4,8 @@ import {
Loader,
Modal,
ModalSize,
Radio,
RadioGroup,
Select,
VariantType,
useToastProvider,
@@ -15,7 +17,7 @@ import { Box, Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-editor/';
import { Doc } from '@/features/docs/doc-management';
import { useCreatePdf } from '../api/useCreatePdf';
import { useExport } from '../api/useExport';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { adaptBlockNoteHTML, downloadFile } from '../utils';
@@ -31,14 +33,14 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
const { toast } = useToastProvider();
const { docsStore } = useDocStore();
const {
mutate: createPdf,
data: pdf,
mutate: createExport,
data: documentGenerated,
isSuccess,
isPending,
error,
} = useCreatePdf();
} = useExport();
const [templateIdSelected, setTemplateIdSelected] = useState<string>();
const [format, setFormat] = useState<'pdf' | 'docx'>('pdf');
const templateOptions = useMemo(() => {
if (!templates?.pages) {
@@ -73,7 +75,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
}, [error, t]);
useEffect(() => {
if (!pdf || !isSuccess) {
if (!documentGenerated || !isSuccess) {
return;
}
@@ -84,16 +86,21 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '-');
downloadFile(pdf, `${title}.pdf`);
downloadFile(documentGenerated, `${title}.${format}`);
toast(t('Your pdf was downloaded succesfully'), VariantType.SUCCESS);
toast(
t('Your {{format}} was downloaded succesfully', {
format,
}),
VariantType.SUCCESS,
);
onClose();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pdf, isSuccess, t]);
}, [documentGenerated, isSuccess, t]);
async function onSubmit() {
if (!templateIdSelected) {
if (!templateIdSelected || !format) {
return;
}
@@ -107,10 +114,11 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
let body = await editor.blocksToFullHTML(editor.document);
body = adaptBlockNoteHTML(body);
createPdf({
createExport({
templateId: templateIdSelected,
body,
body_type: 'html',
format,
});
}
@@ -148,20 +156,20 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
picture_as_pdf
</Text>
<Text as="h2" $size="h3" $margin="none" $theme="primary">
{t('Generate PDF')}
{t('Export')}
</Text>
</Box>
}
>
<Box
$margin={{ bottom: 'xl' }}
aria-label={t('Content modal to generate a PDF')}
aria-label={t('Content modal to export the document')}
$gap="1.5rem"
>
<Alert canClose={false} type={VariantType.INFO}>
<Text>
{t(
'Generate a PDF from your document, it will be inserted in the selected template.',
'Export your document, it will be inserted in the selected template.',
)}
</Text>
</Alert>
@@ -176,6 +184,22 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
}
/>
<RadioGroup>
<Radio
label={t('PDF')}
value="pdf"
name="format"
onChange={(evt) => setFormat(evt.target.value as 'pdf')}
defaultChecked={true}
/>
<Radio
label={t('Docx')}
value="docx"
name="format"
onChange={(evt) => setFormat(evt.target.value as 'docx')}
/>
</RadioGroup>
{isPending && (
<Box $align="center" $margin={{ top: 'big' }}>
<Loader />

View File

@@ -10,16 +10,137 @@ 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"]',
);
// 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);
}
});
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,
'style="text-align: $1;"',
'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;
};