(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:
Cyril
2025-12-01 15:16:41 +01:00
parent 5e398e8e79
commit 0805216cc6
2 changed files with 81 additions and 39 deletions

View File

@@ -13,13 +13,16 @@ import {
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
@@ -27,7 +30,7 @@ import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import {
deriveMediaFilename,
addMediaFilesToZip,
downloadFile,
generateHtmlDocument,
} from '../utils';
@@ -57,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -155,41 +159,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const domParser = new DOMParser();
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
| HTMLImageElement
| HTMLVideoElement
| HTMLAudioElement
| HTMLSourceElement
>('img, video, audio, source'),
);
const zip = new JSZip();
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
if (!src) {
return;
}
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 lang = i18next.language || fallbackLng;
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
const htmlContent = generateHtmlDocument(
documentTitle,
@@ -197,16 +172,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
lang,
);
blobExport = new Blob([htmlContent], {
type: 'text/html;charset=utf-8',
});
zip.file('index.html', htmlContent);
blobExport = await zip.generateAsync({ type: 'blob' });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
const downloadExtension =
format === DocDownloadFormat.HTML ? 'zip' : format;
downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -283,7 +261,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<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>
<Select
clearable={false}

View File

@@ -5,8 +5,11 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import JSZip from 'jszip';
import React from 'react';
import { exportResolveFileUrl } from './api';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -288,3 +291,62 @@ ${editorHtmlWithLocalMedia}
</body>
</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);
});
};