✨(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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>`;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user