✨(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 { docxDocsSchemaMappings } from '../mappingDocx';
|
||||||
import { odtDocsSchemaMappings } from '../mappingODT';
|
import { odtDocsSchemaMappings } from '../mappingODT';
|
||||||
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
||||||
import { downloadFile } from '../utils';
|
import {
|
||||||
|
deriveMediaFilename,
|
||||||
|
downloadFile,
|
||||||
|
generateHtmlDocument,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
enum DocDownloadFormat {
|
enum DocDownloadFormat {
|
||||||
|
HTML = 'html',
|
||||||
PDF = 'pdf',
|
PDF = 'pdf',
|
||||||
DOCX = 'docx',
|
DOCX = 'docx',
|
||||||
ODT = 'odt',
|
ODT = 'odt',
|
||||||
@@ -142,6 +147,59 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
blobExport = await exporter.toODTDocument(exportDocument);
|
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 {
|
} else {
|
||||||
toast(t('The export failed'), VariantType.ERROR);
|
toast(t('The export failed'), VariantType.ERROR);
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
@@ -227,16 +285,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
<Text $variation="secondary" $size="sm" as="p">
|
<Text $variation="secondary" $size="sm" as="p">
|
||||||
{t('Download your document in a .docx, .odt or .pdf format.')}
|
{t('Download your document in a .docx, .odt or .pdf format.')}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
|
||||||
clearable={false}
|
|
||||||
fullWidth
|
|
||||||
label={t('Template')}
|
|
||||||
options={templateOptions}
|
|
||||||
value={templateSelected}
|
|
||||||
onChange={(options) =>
|
|
||||||
setTemplateSelected(options.target.value as string)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
clearable={false}
|
clearable={false}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -245,12 +293,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||||
|
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||||
]}
|
]}
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(options) =>
|
onChange={(options) =>
|
||||||
setFormat(options.target.value as DocDownloadFormat)
|
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 && (
|
{isExporting && (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -179,3 +179,112 @@ export function odtRegisterParagraphStyleForBlock(
|
|||||||
|
|
||||||
return styleName;
|
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,
|
KEY_LIST_DOC_VERSIONS,
|
||||||
ModalSelectVersion,
|
ModalSelectVersion,
|
||||||
} from '@/docs/doc-versioning';
|
} from '@/docs/doc-versioning';
|
||||||
import { useAnalytics } from '@/libs';
|
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||||
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
void router.push(`/docs/${data.id}`);
|
void router.push(`/docs/${data.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { isFeatureFlagActivated } = useAnalytics();
|
|
||||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||||
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
|
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||||
});
|
});
|
||||||
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
callback: () => {
|
callback: () => {
|
||||||
void copyCurrentEditorToClipboard('markdown');
|
void copyCurrentEditorToClipboard('markdown');
|
||||||
},
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('Copy as {{format}}', { format: 'HTML' }),
|
|
||||||
icon: 'content_copy',
|
|
||||||
callback: () => {
|
|
||||||
void copyCurrentEditorToClipboard('html');
|
|
||||||
},
|
|
||||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
|
||||||
showSeparator: true,
|
showSeparator: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user