From 5e398e8e79bd3f73513cdc7e422a01676eac4811 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 26 Nov 2025 11:26:40 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(frontend)=20move=20html=20option?= =?UTF-8?q?=20to=20downloads=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makes the option less visible as it's not useful to most users Signed-off-by: Cyril --- .../__tests__/utilsMediaFilename.test.ts | 67 +++++++++++ .../doc-export/components/ModalExport.tsx | 82 +++++++++++-- .../src/features/docs/doc-export/utils.ts | 109 ++++++++++++++++++ .../docs/doc-header/components/DocToolBox.tsx | 10 -- 4 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts 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..51d95a4fe4 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 @@ -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) => { {t('Download your document in a .docx, .odt or .pdf 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) } /> + `; }; + +export const addMediaFilesToZip = async ( + parsedDocument: Document, + zip: JSZip, + mediaUrl: string, +) => { + 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; + } + + // data: URLs are already embedded and work offline; no need to create separate files. + if (src.startsWith('data:')) { + return; + } + + // Only download same-origin resources (internal media like /media/...). + // External URLs keep their original src and are not included in the ZIP + let url: URL | null = null; + try { + url = new URL(src, mediaUrl); + } catch { + url = null; + } + + if (!url || url.origin !== mediaUrl) { + return; + } + + const fetched = await exportResolveFileUrl(url.href); + + if (!(fetched instanceof Blob)) { + return; + } + + const filename = deriveMediaFilename({ + src: url.href, + index, + blob: fetched, + }); + element.setAttribute('src', filename); + mediaFiles.push({ filename, blob: fetched }); + }), + ); + + mediaFiles.forEach(({ filename, blob }) => { + zip.file(filename, blob); + }); +}; From 9b03754f8809b5904fb36eb5a848dca086302eb9 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 1 Dec 2025 15:23:50 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85(e2e)=20add=20test=20for=20accessi?= =?UTF-8?q?ble=20html=20export=20from=20export=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checks generated zip contains html and embedded media files Signed-off-by: Cyril --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-export.spec.ts | 83 +++++++++++++++++- .../__tests__/app-impress/doc-header.spec.ts | 34 -------- .../doc-export/__tests__/ExportMIT.test.tsx | 4 +- .../doc-header/__tests__/DocToolBox.spec.tsx | 86 ------------------- .../__tests__/DocToolBoxLicence.spec.tsx | 7 +- 6 files changed, 89 insertions(+), 126 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36af2298a4..395eb6d75f 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 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..a54d9e2a2d 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,86 @@ 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'); + 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); + const mediaFiles = allFiles.filter( + (name) => name !== 'index.html' && !name.endsWith('/'), + ); + 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..34f0b4c451 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,40 +408,6 @@ test.describe('Doc Header', () => { expect(clipboardContent.trim()).toBe('# Hello World'); }); - test('It checks the copy as HTML button', async ({ page, browserName }) => { - test.skip( - browserName === 'webkit', - 'navigator.clipboard is not working with webkit and playwright', - ); - - // create page and navigate to it - await page - .getByRole('button', { - name: 'New doc', - }) - .click(); - - // Add dummy content to the doc - const editor = page.locator('.ProseMirror'); - const docFirstBlock = editor.locator('.bn-block-content').first(); - await docFirstBlock.click(); - await page.keyboard.type('# Hello World', { delay: 100 }); - const docFirstBlockContent = docFirstBlock.locator('h1'); - await expect(docFirstBlockContent).toHaveText('Hello World'); - - // Copy content to clipboard - 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(), - ); - const clipboardContent = await handle.jsonValue(); - expect(clipboardContent.trim()).toBe(`

Hello World

`); - }); - test('it checks the copy link button', async ({ page, browserName }) => { test.skip( browserName === 'webkit', 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..9ae3e7312d 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); + }, 15000); 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'); - }); + }, 15000); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx deleted file mode 100644 index f6245ab5c5..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React, { Fragment } from 'react'; -import { beforeEach, describe, expect, vi } from 'vitest'; - -import { AbstractAnalytic, Analytics } from '@/libs'; -import { AppWrapper } from '@/tests/utils'; - -import { DocToolBox } from '../components/DocToolBox'; - -let flag = true; -class TestAnalytic extends AbstractAnalytic { - public constructor() { - super(); - } - - public Provider() { - return ; - } - - public trackEvent() {} - - public isFeatureFlagActivated(flagName: string): boolean { - if (flagName === 'CopyAsHTML') { - return flag; - } - - return true; - } -} - -vi.mock('next/router', async () => ({ - ...(await vi.importActual('next/router')), - useRouter: () => ({ - push: vi.fn(), - }), -})); - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -beforeEach(() => { - Analytics.clearAnalytics(); - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; -}); - -describe('DocToolBox "Copy as HTML" option', () => { - test('renders "Copy as HTML" option when feature flag is enabled', async () => { - new TestAnalytic(); - - render(, { - wrapper: AppWrapper, - }); - const optionsButton = await screen.findByLabelText( - 'Open the document options', - ); - await userEvent.click(optionsButton); - expect(await screen.findByText('Copy as HTML')).toBeInTheDocument(); - }); - - test('does not render "Copy as HTML" option when feature flag is disabled', async () => { - flag = false; - new TestAnalytic(); - - render(, { - wrapper: AppWrapper, - }); - const optionsButton = screen.getByLabelText('Open the document options'); - await userEvent.click(optionsButton); - expect(screen.queryByText('Copy as HTML')).not.toBeInTheDocument(); - }); - - test('render "Copy as HTML" option when we did not add analytics', async () => { - render(, { - wrapper: AppWrapper, - }); - const optionsButton = screen.getByLabelText('Open the document options'); - await userEvent.click(optionsButton); - expect(screen.getByText('Copy as HTML')).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx index 9b1b299672..54029d7fa5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx @@ -42,10 +42,11 @@ describe('DocToolBox - Licence', () => { }); const optionsButton = await screen.findByLabelText('Export the document'); await userEvent.click(optionsButton); + + // Wait for the export modal to be visible, then assert on its content text. + await screen.findByTestId('modal-export-title'); expect( - await screen.findByText( - 'Download your document in a .docx, .odt or .pdf format.', - ), + screen.getByText(/Download your document in a .docx, .odt.*format\./i), ).toBeInTheDocument(); }, 10000);