(frontend) move html option to downloads section

makes the option less visible as it's not useful to most users

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-11-26 11:26:40 +01:00
parent 00ae7fdd60
commit 5e398e8e79
4 changed files with 247 additions and 21 deletions

View File

@@ -0,0 +1,67 @@
import { deriveMediaFilename } from '../utils';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {
const result = deriveMediaFilename({
src: 'https://example.com/path/video.mp4',
index: 0,
blob: new Blob([], { type: 'video/mp4' }),
});
expect(result).toBe('1-video.mp4');
});
test('handles URLs with query/hash and keeps the last segment', () => {
const result = deriveMediaFilename({
src: 'https://site.com/assets/file.name.svg?x=1#test',
index: 0,
blob: new Blob([], { type: 'image/svg+xml' }),
});
expect(result).toBe('1-file.name.svg');
});
test('handles relative URLs using last segment', () => {
const result = deriveMediaFilename({
src: 'not a valid url',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
// "not a valid url" becomes a relative URL, so we get the last segment
expect(result).toBe('1-not%20a%20valid%20url.png');
});
test('data URLs always use media-{index+1}', () => {
const result = deriveMediaFilename({
src: 'data:image/png;base64,xxx',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
expect(result).toBe('media-1.png');
});
test('adds extension from MIME when baseName has no extension', () => {
const result = deriveMediaFilename({
src: 'https://a.com/abc',
index: 0,
blob: new Blob([], { type: 'image/webp' }),
});
expect(result).toBe('1-abc.webp');
});
test('does not override extension if baseName already contains one', () => {
const result = deriveMediaFilename({
src: 'https://a.com/image.png',
index: 0,
blob: new Blob([], { type: 'image/jpeg' }),
});
expect(result).toBe('1-image.png');
});
test('handles complex MIME types (e.g., audio/mpeg)', () => {
const result = deriveMediaFilename({
src: 'https://a.com/song',
index: 1,
blob: new Blob([], { type: 'audio/mpeg' }),
});
expect(result).toBe('2-song.mpeg');
});
});

View File

@@ -26,9 +26,14 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
import {
deriveMediaFilename,
downloadFile,
generateHtmlDocument,
} from '../utils';
enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
@@ -142,6 +147,59 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
});
blobExport = await exporter.toODTDocument(exportDocument);
} else if (format === DocDownloadFormat.HTML) {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
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'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
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 htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
);
blobExport = new Blob([htmlContent], {
type: 'text/html;charset=utf-8',
});
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
@@ -227,16 +285,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
<Text $variation="secondary" $size="sm" as="p">
{t('Download your document in a .docx, .odt or .pdf format.')}
</Text>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
<Select
clearable={false}
fullWidth
@@ -245,12 +293,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
]}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
}
/>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
disabled={format === DocDownloadFormat.HTML}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
{isExporting && (
<Box

View File

@@ -179,3 +179,112 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};

View File

@@ -33,7 +33,6 @@ import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
void router.push(`/docs/${data.id}`);
},
});
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
showSeparator: true,
},
{