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