Skip to content

Commit 0805216

Browse files
committed
✨(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 <[email protected]>
1 parent 5e398e8 commit 0805216

File tree

2 files changed

+81
-39
lines changed

2 files changed

+81
-39
lines changed

src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,24 @@ import {
1313
import { DocumentProps, pdf } from '@react-pdf/renderer';
1414
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
1515
import i18next from 'i18next';
16+
import JSZip from 'jszip';
1617
import { cloneElement, isValidElement, useMemo, useState } from 'react';
1718
import { useTranslation } from 'react-i18next';
1819
import { css } from 'styled-components';
1920

2021
import { Box, ButtonCloseModal, Text } from '@/components';
22+
import { useMediaUrl } from '@/core';
2123
import { useEditorStore } from '@/docs/doc-editor';
2224
import { Doc, useTrans } from '@/docs/doc-management';
25+
import { fallbackLng } from '@/i18n/config';
2326

2427
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
2528
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
2629
import { docxDocsSchemaMappings } from '../mappingDocx';
2730
import { odtDocsSchemaMappings } from '../mappingODT';
2831
import { pdfDocsSchemaMappings } from '../mappingPDF';
2932
import {
30-
deriveMediaFilename,
33+
addMediaFilesToZip,
3134
downloadFile,
3235
generateHtmlDocument,
3336
} from '../utils';
@@ -57,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
5760
DocDownloadFormat.PDF,
5861
);
5962
const { untitledDocument } = useTrans();
63+
const mediaUrl = useMediaUrl();
6064

6165
const templateOptions = useMemo(() => {
6266
const templateOptions = (templates?.pages || [])
@@ -155,58 +159,32 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
155159
const domParser = new DOMParser();
156160
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
157161

158-
const mediaFiles: { filename: string; blob: Blob }[] = [];
159-
const mediaElements = Array.from(
160-
parsedDocument.querySelectorAll<
161-
| HTMLImageElement
162-
| HTMLVideoElement
163-
| HTMLAudioElement
164-
| HTMLSourceElement
165-
>('img, video, audio, source'),
166-
);
167-
168-
await Promise.all(
169-
mediaElements.map(async (element, index) => {
170-
const src = element.getAttribute('src');
171-
172-
if (!src) {
173-
return;
174-
}
162+
const zip = new JSZip();
175163

176-
const fetched = await exportCorsResolveFileUrl(doc.id, src);
164+
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
177165

178-
if (!(fetched instanceof Blob)) {
179-
return;
180-
}
181-
182-
const filename = deriveMediaFilename({
183-
src,
184-
index,
185-
blob: fetched,
186-
});
187-
element.setAttribute('src', filename);
188-
mediaFiles.push({ filename, blob: fetched });
189-
}),
190-
);
191-
192-
const lang = i18next.language || 'fr';
166+
const lang = i18next.language || fallbackLng;
167+
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
193168

194169
const htmlContent = generateHtmlDocument(
195170
documentTitle,
196171
editorHtmlWithLocalMedia,
197172
lang,
198173
);
199174

200-
blobExport = new Blob([htmlContent], {
201-
type: 'text/html;charset=utf-8',
202-
});
175+
zip.file('index.html', htmlContent);
176+
177+
blobExport = await zip.generateAsync({ type: 'blob' });
203178
} else {
204179
toast(t('The export failed'), VariantType.ERROR);
205180
setIsExporting(false);
206181
return;
207182
}
208183

209-
downloadFile(blobExport, `${filename}.${format}`);
184+
const downloadExtension =
185+
format === DocDownloadFormat.HTML ? 'zip' : format;
186+
187+
downloadFile(blobExport, `${filename}.${downloadExtension}`);
210188

211189
toast(
212190
t('Your {{format}} was downloaded succesfully', {
@@ -283,7 +261,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
283261
className="--docs--modal-export-content"
284262
>
285263
<Text $variation="secondary" $size="sm" as="p">
286-
{t('Download your document in a .docx, .odt or .pdf format.')}
264+
{t(
265+
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
266+
)}
287267
</Text>
288268
<Select
289269
clearable={false}

src/frontend/apps/impress/src/features/docs/doc-export/utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import {
55
} from '@blocknote/core';
66
import { Canvg } from 'canvg';
77
import { IParagraphOptions, ShadingType } from 'docx';
8+
import JSZip from 'jszip';
89
import React from 'react';
910

11+
import { exportResolveFileUrl } from './api';
12+
1013
export function downloadFile(blob: Blob, filename: string) {
1114
const url = window.URL.createObjectURL(blob);
1215
const a = document.createElement('a');
@@ -288,3 +291,62 @@ ${editorHtmlWithLocalMedia}
288291
</body>
289292
</html>`;
290293
};
294+
295+
export const addMediaFilesToZip = async (
296+
parsedDocument: Document,
297+
zip: JSZip,
298+
mediaUrl: string,
299+
) => {
300+
const mediaFiles: { filename: string; blob: Blob }[] = [];
301+
const mediaElements = Array.from(
302+
parsedDocument.querySelectorAll<
303+
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
304+
>('img, video, audio, source'),
305+
);
306+
307+
await Promise.all(
308+
mediaElements.map(async (element, index) => {
309+
const src = element.getAttribute('src');
310+
311+
if (!src) {
312+
return;
313+
}
314+
315+
// data: URLs are already embedded and work offline; no need to create separate files.
316+
if (src.startsWith('data:')) {
317+
return;
318+
}
319+
320+
// Only download same-origin resources (internal media like /media/...).
321+
// External URLs keep their original src and are not included in the ZIP
322+
let url: URL | null = null;
323+
try {
324+
url = new URL(src, mediaUrl);
325+
} catch {
326+
url = null;
327+
}
328+
329+
if (!url || url.origin !== mediaUrl) {
330+
return;
331+
}
332+
333+
const fetched = await exportResolveFileUrl(url.href);
334+
335+
if (!(fetched instanceof Blob)) {
336+
return;
337+
}
338+
339+
const filename = deriveMediaFilename({
340+
src: url.href,
341+
index,
342+
blob: fetched,
343+
});
344+
element.setAttribute('src', filename);
345+
mediaFiles.push({ filename, blob: fetched });
346+
}),
347+
);
348+
349+
mediaFiles.forEach(({ filename, blob }) => {
350+
zip.file(filename, blob);
351+
});
352+
};

0 commit comments

Comments
 (0)