diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36af2298a4..cf30b5bfa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
+- ⚡️(frontend) Enhance/html copy to download #1669
### Fixed
@@ -36,6 +37,9 @@ and this project adheres to
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2 #1605
- ⚡️(frontend) improve perf on upload and table of contents #1662
+
+### Fixed
+
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
index 52af85ad4f..23e9913e19 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
@@ -2,6 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
+import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
- page.getByText('Download your document in a .docx, .odt or .pdf format.'),
+ page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
+ test('it exports the doc to html zip', async ({ page, browserName }) => {
+ const [randomDoc] = await createDoc(
+ page,
+ 'doc-editor-html-zip',
+ browserName,
+ 1,
+ );
+
+ await verifyDocName(page, randomDoc);
+
+ // Add some content and at least one image so that the ZIP contains media files.
+ await page.locator('.ProseMirror.bn-editor').click();
+ await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');
+
+ await page.keyboard.press('Enter');
+ await page.locator('.bn-block-outer').last().fill('/');
+ await page.getByText('Resizable image with caption').click();
+
+ const fileChooserPromise = page.waitForEvent('filechooser');
+ await page.getByText('Upload image').click();
+
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
+
+ const image = page
+ .locator('.--docs--editor-container img.bn-visual-media')
+ .first();
+
+ // Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
+ await expect(image).toBeAttached({ timeout: 10000 });
+ await expect(image).toHaveAttribute('src', /.*\.svg/);
+
+ // Give some time for the image to be fully processed
+ await page.waitForTimeout(1000);
+
+ await page
+ .getByRole('button', {
+ name: 'Export the document',
+ })
+ .click();
+
+ await page.getByRole('combobox', { name: 'Format' }).click();
+ await page.getByRole('option', { name: 'HTML' }).click();
+
+ await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
+
+ const downloadPromise = page.waitForEvent('download', (download) => {
+ return download.suggestedFilename().includes(`${randomDoc}.zip`);
+ });
+
+ void page.getByTestId('doc-export-download-button').click();
+
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
+
+ const zipBuffer = await cs.toBuffer(await download.createReadStream());
+ // Unzip and inspect contents
+ const zip = await JSZip.loadAsync(zipBuffer);
+
+ // Check that index.html exists
+ const indexHtml = zip.file('index.html');
+ expect(indexHtml).not.toBeNull();
+
+ // Read and verify HTML content
+ const htmlContent = await indexHtml!.async('string');
+ console.log('HTML content preview:', htmlContent.substring(0, 1000));
+ expect(htmlContent).toContain('Hello HTML ZIP');
+
+ // Check for media files (they are at the root of the ZIP, not in a media/ folder)
+ // Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
+ const allFiles = Object.keys(zip.files);
+ console.log('All files in ZIP:', allFiles);
+ const mediaFiles = allFiles.filter(
+ (name) => name !== 'index.html' && !name.endsWith('/'),
+ );
+ console.log('Media files found:', mediaFiles);
+ expect(mediaFiles.length).toBeGreaterThan(0);
+
+ // Verify the SVG image is included
+ const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
+ expect(svgFile).toBeDefined();
+ });
+
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
index 6a4e01b192..b479a48f49 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
@@ -408,7 +408,10 @@ test.describe('Doc Header', () => {
expect(clipboardContent.trim()).toBe('# Hello World');
});
- test('It checks the copy as HTML button', async ({ page, browserName }) => {
+ test('It no longer shows the copy as HTML button', async ({
+ page,
+ browserName,
+ }) => {
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
@@ -429,17 +432,16 @@ test.describe('Doc Header', () => {
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
- // Copy content to clipboard
+ // Open document options menu
await page.getByLabel('Open the document options').click();
- await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
- await expect(page.getByText('Copied to clipboard')).toBeVisible();
- // Test that clipboard is in HTML format
- const handle = await page.evaluateHandle(() =>
- navigator.clipboard.readText(),
+ // We should no longer see "Copy as HTML" in the menu
+ await expect(
+ page.getByRole('menuitem', { name: 'Copy as Markdown' }),
+ ).toBeVisible();
+ await expect(page.getByRole('menu').getByText('Copy as HTML')).toHaveCount(
+ 0,
);
- const clipboardContent = await handle.jsonValue();
- expect(clipboardContent.trim()).toBe(`
Hello World
`);
});
test('it checks the copy link button', async ({ page, browserName }) => {
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
index 8c3efaa027..67058e326e 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
@@ -16,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
- }, 10000);
+ }, 60000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
- });
+ }, 60000);
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts
new file mode 100644
index 0000000000..1ac5949262
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts
@@ -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');
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
index 4d0338a201..8cb8a537b6 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
@@ -13,6 +13,7 @@ import {
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
+import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -26,9 +27,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,13 +148,75 @@ 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 editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
+
+ const htmlContent = generateHtmlDocument(
+ documentTitle,
+ editorHtmlWithLocalMedia,
+ lang,
+ );
+
+ const zip = new JSZip();
+ zip.file('index.html', htmlContent);
+
+ mediaFiles.forEach(({ filename, blob }) => {
+ zip.file(filename, blob);
+ });
+
+ blobExport = await zip.generateAsync({ type: 'blob' });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
- downloadFile(blobExport, `${filename}.${format}`);
+ const downloadExtension =
+ format === DocDownloadFormat.HTML ? 'zip' : format;
+
+ downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -225,18 +293,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
- {t('Download your document in a .docx, .odt or .pdf format.')}
+ {t(
+ 'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
+ )}
-