Skip to content

Commit 3713f00

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 1d858eb commit 3713f00

File tree

1 file changed

+97
-5
lines changed
  • src/frontend/apps/impress/src/features/docs/doc-export/components

1 file changed

+97
-5
lines changed

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

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ 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';
@@ -145,7 +146,88 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
145146
blobExport = await exporter.toODTDocument(exportDocument);
146147
} else if (format === DocDownloadFormat.HTML) {
147148
const editorHtml = await editor.blocksToHTMLLossy();
149+
150+
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
151+
const domParser = new DOMParser();
152+
const parsedDocument = domParser.parseFromString(editorHtml, 'text/html');
153+
154+
const mediaFiles: { filename: string; blob: Blob }[] = [];
155+
const mediaElements = Array.from(
156+
parsedDocument.querySelectorAll<
157+
| HTMLImageElement
158+
| HTMLVideoElement
159+
| HTMLAudioElement
160+
| HTMLSourceElement
161+
>('img, video, audio, source'),
162+
);
163+
164+
await Promise.all(
165+
mediaElements.map(async (element, index) => {
166+
const src = element.getAttribute('src');
167+
168+
if (!src) {
169+
return;
170+
}
171+
172+
const fetched = await exportCorsResolveFileUrl(doc.id, src);
173+
174+
if (!(fetched instanceof Blob)) {
175+
return;
176+
}
177+
178+
// Derive a readable filename:
179+
// - data: URLs → use a generic "media-N.ext"
180+
// - normal URLs → keep the last path segment as base name.
181+
let baseName = `media-${index + 1}`;
182+
183+
if (!src.startsWith('data:')) {
184+
try {
185+
const url = new URL(src, window.location.origin);
186+
const lastSegment = url.pathname.split('/').pop();
187+
if (lastSegment) {
188+
baseName = `${index + 1}-${lastSegment}`;
189+
}
190+
} catch {
191+
// Ignore invalid URLs, keep default baseName.
192+
}
193+
}
194+
195+
let filename = baseName;
196+
197+
// Ensure the filename has an extension consistent with the blob MIME type.
198+
const mimeType = fetched.type;
199+
if (mimeType && !baseName.includes('.')) {
200+
const subtype = mimeType.split('/')[1] || '';
201+
let extension = '';
202+
203+
if (subtype.includes('svg')) {
204+
extension = 'svg';
205+
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
206+
extension = 'jpg';
207+
} else if (subtype.includes('png')) {
208+
extension = 'png';
209+
} else if (subtype.includes('gif')) {
210+
extension = 'gif';
211+
} else if (subtype.includes('webp')) {
212+
extension = 'webp';
213+
} else if (subtype.includes('pdf')) {
214+
extension = 'pdf';
215+
} else if (subtype) {
216+
extension = subtype.split('+')[0];
217+
}
218+
219+
if (extension) {
220+
filename = `${baseName}.${extension}`;
221+
}
222+
}
223+
224+
element.setAttribute('src', filename);
225+
mediaFiles.push({ filename, blob: fetched });
226+
}),
227+
);
228+
148229
const lang = i18next.language || 'fr';
230+
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
149231

150232
const htmlContent = `<!DOCTYPE html>
151233
<html lang="${lang}">
@@ -155,21 +237,29 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
155237
</head>
156238
<body>
157239
<main role="main">
158-
${editorHtml}
240+
${editorHtmlWithLocalMedia}
159241
</main>
160242
</body>
161243
</html>`;
162244

163-
blobExport = new Blob([htmlContent], {
164-
type: 'text/html;charset=utf-8',
245+
const zip = new JSZip();
246+
zip.file('index.html', htmlContent);
247+
248+
mediaFiles.forEach(({ filename, blob }) => {
249+
zip.file(filename, blob);
165250
});
251+
252+
blobExport = await zip.generateAsync({ type: 'blob' });
166253
} else {
167254
toast(t('The export failed'), VariantType.ERROR);
168255
setIsExporting(false);
169256
return;
170257
}
171258

172-
downloadFile(blobExport, `${filename}.${format}`);
259+
const downloadExtension =
260+
format === DocDownloadFormat.HTML ? 'zip' : format;
261+
262+
downloadFile(blobExport, `${filename}.${downloadExtension}`);
173263

174264
toast(
175265
t('Your {{format}} was downloaded succesfully', {
@@ -246,7 +336,9 @@ ${editorHtml}
246336
className="--docs--modal-export-content"
247337
>
248338
<Text $variation="secondary" $size="sm" as="p">
249-
{t('Download your document in a .docx, .odt or .pdf format.')}
339+
{t(
340+
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
341+
)}
250342
</Text>
251343
<Select
252344
clearable={false}

0 commit comments

Comments
 (0)