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-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..df994c5420 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,22 +13,30 @@ 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'; import { Box, ButtonCloseModal, Text } from '@/components'; +import { useMediaUrl } from '@/core'; import { useEditorStore } from '@/docs/doc-editor'; import { Doc, useTrans } from '@/docs/doc-management'; +import { fallbackLng } from '@/i18n/config'; import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; import { docxDocsSchemaMappings } from '../mappingDocx'; import { odtDocsSchemaMappings } from '../mappingODT'; import { pdfDocsSchemaMappings } from '../mappingPDF'; -import { downloadFile } from '../utils'; +import { + addMediaFilesToZip, + downloadFile, + generateHtmlDocument, +} from '../utils'; enum DocDownloadFormat { + HTML = 'html', PDF = 'pdf', DOCX = 'docx', ODT = 'odt', @@ -52,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { DocDownloadFormat.PDF, ); const { untitledDocument } = useTrans(); + const mediaUrl = useMediaUrl(); const templateOptions = useMemo(() => { const templateOptions = (templates?.pages || []) @@ -142,13 +151,40 @@ 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 zip = new JSZip(); + + await addMediaFilesToZip(parsedDocument, zip, mediaUrl); + + const lang = i18next.language || fallbackLng; + const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML; + + const htmlContent = generateHtmlDocument( + documentTitle, + editorHtmlWithLocalMedia, + lang, + ); + + zip.file('index.html', htmlContent); + + 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 +261,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) } /> +