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.', + )} - { { 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) } /> +