✨(frontend) added accessible html export and moved download option
replaced “copy as html” with export modal option and full media zip export Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -13,13 +13,16 @@ import {
|
|||||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
|
import JSZip from 'jszip';
|
||||||
import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||||
|
import { useMediaUrl } from '@/core';
|
||||||
import { useEditorStore } from '@/docs/doc-editor';
|
import { useEditorStore } from '@/docs/doc-editor';
|
||||||
import { Doc, useTrans } from '@/docs/doc-management';
|
import { Doc, useTrans } from '@/docs/doc-management';
|
||||||
|
import { fallbackLng } from '@/i18n/config';
|
||||||
|
|
||||||
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
|
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
|
||||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||||
@@ -27,7 +30,7 @@ import { docxDocsSchemaMappings } from '../mappingDocx';
|
|||||||
import { odtDocsSchemaMappings } from '../mappingODT';
|
import { odtDocsSchemaMappings } from '../mappingODT';
|
||||||
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
||||||
import {
|
import {
|
||||||
deriveMediaFilename,
|
addMediaFilesToZip,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
generateHtmlDocument,
|
generateHtmlDocument,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
@@ -57,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
DocDownloadFormat.PDF,
|
DocDownloadFormat.PDF,
|
||||||
);
|
);
|
||||||
const { untitledDocument } = useTrans();
|
const { untitledDocument } = useTrans();
|
||||||
|
const mediaUrl = useMediaUrl();
|
||||||
|
|
||||||
const templateOptions = useMemo(() => {
|
const templateOptions = useMemo(() => {
|
||||||
const templateOptions = (templates?.pages || [])
|
const templateOptions = (templates?.pages || [])
|
||||||
@@ -155,41 +159,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
|
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
|
||||||
|
|
||||||
const mediaFiles: { filename: string; blob: Blob }[] = [];
|
const zip = new JSZip();
|
||||||
const mediaElements = Array.from(
|
|
||||||
parsedDocument.querySelectorAll<
|
|
||||||
| HTMLImageElement
|
|
||||||
| HTMLVideoElement
|
|
||||||
| HTMLAudioElement
|
|
||||||
| HTMLSourceElement
|
|
||||||
>('img, video, audio, source'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
|
||||||
mediaElements.map(async (element, index) => {
|
|
||||||
const src = element.getAttribute('src');
|
|
||||||
|
|
||||||
if (!src) {
|
const lang = i18next.language || fallbackLng;
|
||||||
return;
|
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
|
||||||
}
|
|
||||||
|
|
||||||
const fetched = await exportCorsResolveFileUrl(doc.id, src);
|
|
||||||
|
|
||||||
if (!(fetched instanceof Blob)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = deriveMediaFilename({
|
|
||||||
src,
|
|
||||||
index,
|
|
||||||
blob: fetched,
|
|
||||||
});
|
|
||||||
element.setAttribute('src', filename);
|
|
||||||
mediaFiles.push({ filename, blob: fetched });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const lang = i18next.language || 'fr';
|
|
||||||
|
|
||||||
const htmlContent = generateHtmlDocument(
|
const htmlContent = generateHtmlDocument(
|
||||||
documentTitle,
|
documentTitle,
|
||||||
@@ -197,16 +172,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
|
|
||||||
blobExport = new Blob([htmlContent], {
|
zip.file('index.html', htmlContent);
|
||||||
type: 'text/html;charset=utf-8',
|
|
||||||
});
|
blobExport = await zip.generateAsync({ type: 'blob' });
|
||||||
} else {
|
} else {
|
||||||
toast(t('The export failed'), VariantType.ERROR);
|
toast(t('The export failed'), VariantType.ERROR);
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(blobExport, `${filename}.${format}`);
|
const downloadExtension =
|
||||||
|
format === DocDownloadFormat.HTML ? 'zip' : format;
|
||||||
|
|
||||||
|
downloadFile(blobExport, `${filename}.${downloadExtension}`);
|
||||||
|
|
||||||
toast(
|
toast(
|
||||||
t('Your {{format}} was downloaded succesfully', {
|
t('Your {{format}} was downloaded succesfully', {
|
||||||
@@ -283,7 +261,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
className="--docs--modal-export-content"
|
className="--docs--modal-export-content"
|
||||||
>
|
>
|
||||||
<Text $variation="secondary" $size="sm" as="p">
|
<Text $variation="secondary" $size="sm" as="p">
|
||||||
{t('Download your document in a .docx, .odt or .pdf format.')}
|
{t(
|
||||||
|
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
clearable={false}
|
clearable={false}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
} from '@blocknote/core';
|
} from '@blocknote/core';
|
||||||
import { Canvg } from 'canvg';
|
import { Canvg } from 'canvg';
|
||||||
import { IParagraphOptions, ShadingType } from 'docx';
|
import { IParagraphOptions, ShadingType } from 'docx';
|
||||||
|
import JSZip from 'jszip';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { exportResolveFileUrl } from './api';
|
||||||
|
|
||||||
export function downloadFile(blob: Blob, filename: string) {
|
export function downloadFile(blob: Blob, filename: string) {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -288,3 +291,62 @@ ${editorHtmlWithLocalMedia}
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addMediaFilesToZip = async (
|
||||||
|
parsedDocument: Document,
|
||||||
|
zip: JSZip,
|
||||||
|
mediaUrl: string,
|
||||||
|
) => {
|
||||||
|
const mediaFiles: { filename: string; blob: Blob }[] = [];
|
||||||
|
const mediaElements = Array.from(
|
||||||
|
parsedDocument.querySelectorAll<
|
||||||
|
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
|
||||||
|
>('img, video, audio, source'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
mediaElements.map(async (element, index) => {
|
||||||
|
const src = element.getAttribute('src');
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data: URLs are already embedded and work offline; no need to create separate files.
|
||||||
|
if (src.startsWith('data:')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only download same-origin resources (internal media like /media/...).
|
||||||
|
// External URLs keep their original src and are not included in the ZIP
|
||||||
|
let url: URL | null = null;
|
||||||
|
try {
|
||||||
|
url = new URL(src, mediaUrl);
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url || url.origin !== mediaUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetched = await exportResolveFileUrl(url.href);
|
||||||
|
|
||||||
|
if (!(fetched instanceof Blob)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = deriveMediaFilename({
|
||||||
|
src: url.href,
|
||||||
|
index,
|
||||||
|
blob: fetched,
|
||||||
|
});
|
||||||
|
element.setAttribute('src', filename);
|
||||||
|
mediaFiles.push({ filename, blob: fetched });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mediaFiles.forEach(({ filename, blob }) => {
|
||||||
|
zip.file(filename, blob);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user