Skip to content

Commit 5e398e8

Browse files
committed
✨(frontend) move html option to downloads section
makes the option less visible as it's not useful to most users Signed-off-by: Cyril <[email protected]>
1 parent 00ae7fd commit 5e398e8

File tree

4 files changed

+247
-21
lines changed

4 files changed

+247
-21
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { deriveMediaFilename } from '../utils';
2+
3+
describe('deriveMediaFilename', () => {
4+
test('uses last URL segment when src is a valid URL', () => {
5+
const result = deriveMediaFilename({
6+
src: 'https://example.com/path/video.mp4',
7+
index: 0,
8+
blob: new Blob([], { type: 'video/mp4' }),
9+
});
10+
expect(result).toBe('1-video.mp4');
11+
});
12+
13+
test('handles URLs with query/hash and keeps the last segment', () => {
14+
const result = deriveMediaFilename({
15+
src: 'https://site.com/assets/file.name.svg?x=1#test',
16+
index: 0,
17+
blob: new Blob([], { type: 'image/svg+xml' }),
18+
});
19+
expect(result).toBe('1-file.name.svg');
20+
});
21+
22+
test('handles relative URLs using last segment', () => {
23+
const result = deriveMediaFilename({
24+
src: 'not a valid url',
25+
index: 0,
26+
blob: new Blob([], { type: 'image/png' }),
27+
});
28+
// "not a valid url" becomes a relative URL, so we get the last segment
29+
expect(result).toBe('1-not%20a%20valid%20url.png');
30+
});
31+
32+
test('data URLs always use media-{index+1}', () => {
33+
const result = deriveMediaFilename({
34+
src: 'data:image/png;base64,xxx',
35+
index: 0,
36+
blob: new Blob([], { type: 'image/png' }),
37+
});
38+
expect(result).toBe('media-1.png');
39+
});
40+
41+
test('adds extension from MIME when baseName has no extension', () => {
42+
const result = deriveMediaFilename({
43+
src: 'https://a.com/abc',
44+
index: 0,
45+
blob: new Blob([], { type: 'image/webp' }),
46+
});
47+
expect(result).toBe('1-abc.webp');
48+
});
49+
50+
test('does not override extension if baseName already contains one', () => {
51+
const result = deriveMediaFilename({
52+
src: 'https://a.com/image.png',
53+
index: 0,
54+
blob: new Blob([], { type: 'image/jpeg' }),
55+
});
56+
expect(result).toBe('1-image.png');
57+
});
58+
59+
test('handles complex MIME types (e.g., audio/mpeg)', () => {
60+
const result = deriveMediaFilename({
61+
src: 'https://a.com/song',
62+
index: 1,
63+
blob: new Blob([], { type: 'audio/mpeg' }),
64+
});
65+
expect(result).toBe('2-song.mpeg');
66+
});
67+
});

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

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
2626
import { docxDocsSchemaMappings } from '../mappingDocx';
2727
import { odtDocsSchemaMappings } from '../mappingODT';
2828
import { pdfDocsSchemaMappings } from '../mappingPDF';
29-
import { downloadFile } from '../utils';
29+
import {
30+
deriveMediaFilename,
31+
downloadFile,
32+
generateHtmlDocument,
33+
} from '../utils';
3034

3135
enum DocDownloadFormat {
36+
HTML = 'html',
3237
PDF = 'pdf',
3338
DOCX = 'docx',
3439
ODT = 'odt',
@@ -142,6 +147,59 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
142147
});
143148

144149
blobExport = await exporter.toODTDocument(exportDocument);
150+
} else if (format === DocDownloadFormat.HTML) {
151+
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
152+
const fullHtml = await editor.blocksToFullHTML();
153+
154+
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
155+
const domParser = new DOMParser();
156+
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
157+
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+
}
175+
176+
const fetched = await exportCorsResolveFileUrl(doc.id, src);
177+
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';
193+
194+
const htmlContent = generateHtmlDocument(
195+
documentTitle,
196+
editorHtmlWithLocalMedia,
197+
lang,
198+
);
199+
200+
blobExport = new Blob([htmlContent], {
201+
type: 'text/html;charset=utf-8',
202+
});
145203
} else {
146204
toast(t('The export failed'), VariantType.ERROR);
147205
setIsExporting(false);
@@ -227,16 +285,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
227285
<Text $variation="secondary" $size="sm" as="p">
228286
{t('Download your document in a .docx, .odt or .pdf format.')}
229287
</Text>
230-
<Select
231-
clearable={false}
232-
fullWidth
233-
label={t('Template')}
234-
options={templateOptions}
235-
value={templateSelected}
236-
onChange={(options) =>
237-
setTemplateSelected(options.target.value as string)
238-
}
239-
/>
240288
<Select
241289
clearable={false}
242290
fullWidth
@@ -245,12 +293,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
245293
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
246294
{ label: t('ODT'), value: DocDownloadFormat.ODT },
247295
{ label: t('PDF'), value: DocDownloadFormat.PDF },
296+
{ label: t('HTML'), value: DocDownloadFormat.HTML },
248297
]}
249298
value={format}
250299
onChange={(options) =>
251300
setFormat(options.target.value as DocDownloadFormat)
252301
}
253302
/>
303+
<Select
304+
clearable={false}
305+
fullWidth
306+
label={t('Template')}
307+
options={templateOptions}
308+
value={templateSelected}
309+
disabled={format === DocDownloadFormat.HTML}
310+
onChange={(options) =>
311+
setTemplateSelected(options.target.value as string)
312+
}
313+
/>
254314

255315
{isExporting && (
256316
<Box

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,112 @@ export function odtRegisterParagraphStyleForBlock(
179179

180180
return styleName;
181181
}
182+
183+
// Escape user-provided text before injecting it into the exported HTML document.
184+
export const escapeHtml = (value: string): string =>
185+
value
186+
.replace(/&/g, '&amp;')
187+
.replace(/</g, '&lt;')
188+
.replace(/>/g, '&gt;')
189+
.replace(/"/g, '&quot;')
190+
.replace(/'/g, '&#39;');
191+
192+
interface MediaFilenameParams {
193+
src: string;
194+
index: number;
195+
blob: Blob;
196+
}
197+
198+
/**
199+
* Derives a stable, readable filename for media exported in the HTML ZIP.
200+
*
201+
* Rules:
202+
* - Default base name is "media-{index+1}".
203+
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
204+
* - If the base name has no extension, we try to infer one from the blob MIME type.
205+
*/
206+
export const deriveMediaFilename = ({
207+
src,
208+
index,
209+
blob,
210+
}: MediaFilenameParams): string => {
211+
// Default base name
212+
let baseName = `media-${index + 1}`;
213+
214+
// Try to reuse the last path segment for non data URLs.
215+
if (!src.startsWith('data:')) {
216+
try {
217+
const url = new URL(src, window.location.origin);
218+
const lastSegment = url.pathname.split('/').pop();
219+
if (lastSegment) {
220+
baseName = `${index + 1}-${lastSegment}`;
221+
}
222+
} catch {
223+
// Ignore invalid URLs, keep default baseName.
224+
}
225+
}
226+
227+
let filename = baseName;
228+
229+
// Ensure the filename has an extension consistent with the blob MIME type.
230+
const mimeType = blob.type;
231+
if (mimeType && !baseName.includes('.')) {
232+
const slashIndex = mimeType.indexOf('/');
233+
const rawSubtype =
234+
slashIndex !== -1 && slashIndex < mimeType.length - 1
235+
? mimeType.slice(slashIndex + 1)
236+
: '';
237+
238+
let extension = '';
239+
const subtype = rawSubtype.toLowerCase();
240+
241+
if (subtype.includes('svg')) {
242+
extension = 'svg';
243+
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
244+
extension = 'jpg';
245+
} else if (subtype.includes('png')) {
246+
extension = 'png';
247+
} else if (subtype.includes('gif')) {
248+
extension = 'gif';
249+
} else if (subtype.includes('webp')) {
250+
extension = 'webp';
251+
} else if (subtype.includes('pdf')) {
252+
extension = 'pdf';
253+
} else if (subtype) {
254+
extension = subtype.split('+')[0];
255+
}
256+
257+
if (extension) {
258+
filename = `${baseName}.${extension}`;
259+
}
260+
}
261+
262+
return filename;
263+
};
264+
265+
/**
266+
* Generates a complete HTML document structure for export.
267+
*
268+
* @param documentTitle - The title of the document (will be escaped)
269+
* @param editorHtmlWithLocalMedia - The HTML content from the editor
270+
* @param lang - The language code for the document (e.g., 'fr', 'en')
271+
* @returns A complete HTML5 document string
272+
*/
273+
export const generateHtmlDocument = (
274+
documentTitle: string,
275+
editorHtmlWithLocalMedia: string,
276+
lang: string,
277+
): string => {
278+
return `<!DOCTYPE html>
279+
<html lang="${lang}">
280+
<head>
281+
<meta charset="utf-8" />
282+
<title>${escapeHtml(documentTitle)}</title>
283+
</head>
284+
<body>
285+
<main role="main">
286+
${editorHtmlWithLocalMedia}
287+
</main>
288+
</body>
289+
</html>`;
290+
};

src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {
3333
KEY_LIST_DOC_VERSIONS,
3434
ModalSelectVersion,
3535
} from '@/docs/doc-versioning';
36-
import { useAnalytics } from '@/libs';
3736
import { useResponsiveStore } from '@/stores';
3837

3938
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
6766
void router.push(`/docs/${data.id}`);
6867
},
6968
});
70-
const { isFeatureFlagActivated } = useAnalytics();
7169
const removeFavoriteDoc = useDeleteFavoriteDoc({
7270
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
7371
});
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
155153
callback: () => {
156154
void copyCurrentEditorToClipboard('markdown');
157155
},
158-
},
159-
{
160-
label: t('Copy as {{format}}', { format: 'HTML' }),
161-
icon: 'content_copy',
162-
callback: () => {
163-
void copyCurrentEditorToClipboard('html');
164-
},
165-
show: isFeatureFlagActivated('CopyAsHTML'),
166156
showSeparator: true,
167157
},
168158
{

0 commit comments

Comments
 (0)