-
Notifications
You must be signed in to change notification settings - Fork 472
⚡️Enhance/html copy to download #1669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63f2ac8
12ffc57
bceafc6
960b2eb
a962780
5633d65
33616c0
516f446
24f8da0
22ff23f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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)); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| 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); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| const mediaFiles = allFiles.filter( | ||||
| (name) => name !== 'index.html' && !name.endsWith('/'), | ||||
| ); | ||||
| console.log('Media files found:', mediaFiles); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| 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 | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can remove totally this test, we don't assert things that does not exist anymore. |
||
| 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(`<h1>Hello World</h1><p></p>`); | ||
| }); | ||
|
|
||
| test('it checks the copy link button', async ({ page, browserName }) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,12 +16,12 @@ describe('useModuleExport', () => { | |
| const Export = await import('@/features/docs/doc-export/'); | ||
|
|
||
| expect(Export.default).toBeUndefined(); | ||
| }, 10000); | ||
| }, 60000); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same, is 1mn really necessary, seems big ?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test was a bit flaky, I saw it fail occasionally and one run even took ~9s, so I put timeout to 1min a bit aggressively 😅
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes 15 or 20 max, if it takes more than that it means there is a problem somewhere. |
||
|
|
||
| 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); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+177
to
+178
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can see you use the CORS proxy, but I think it works only with images: docs/src/backend/core/api/viewsets.py Lines 1551 to 1555 in 999c814
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh yes, I see, the current CORS proxy only supports images.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are 2 types of "link" (internal / external), if the resources are internal like |
||||||||||||
| 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'; | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| 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" | ||||||||||||
| > | ||||||||||||
| <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, .pdf or .html(zip) format.', | ||||||||||||
| )} | ||||||||||||
| </Text> | ||||||||||||
| <Select | ||||||||||||
| clearable={false} | ||||||||||||
| fullWidth | ||||||||||||
| label={t('Template')} | ||||||||||||
| options={templateOptions} | ||||||||||||
| value={templateSelected} | ||||||||||||
| onChange={(options) => | ||||||||||||
| setTemplateSelected(options.target.value as string) | ||||||||||||
| } | ||||||||||||
| /> | ||||||||||||
| <Select | ||||||||||||
| clearable={false} | ||||||||||||
| fullWidth | ||||||||||||
|
|
@@ -245,12 +305,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { | |||||||||||
| { 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) | ||||||||||||
| } | ||||||||||||
| /> | ||||||||||||
| <Select | ||||||||||||
| clearable={false} | ||||||||||||
| fullWidth | ||||||||||||
| label={t('Template')} | ||||||||||||
| options={templateOptions} | ||||||||||||
| value={templateSelected} | ||||||||||||
| disabled={format === DocDownloadFormat.HTML} | ||||||||||||
| onChange={(options) => | ||||||||||||
| setTemplateSelected(options.target.value as string) | ||||||||||||
| } | ||||||||||||
| /> | ||||||||||||
|
|
||||||||||||
| {isExporting && ( | ||||||||||||
| <Box | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.