From 8036f16cc3171f0361cf8a90fe0383617d981a1e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:49:27 +0000 Subject: [PATCH 01/75] =?UTF-8?q?=E2=AC=86=EF=B8=8F(dependencies)=20update?= =?UTF-8?q?=20next=20to=20v15.5.7=20[SECURITY]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/apps/impress/package.json | 2 +- src/frontend/yarn.lock | 98 +++++++++++++------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 9a5692d94..415c30792 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -57,7 +57,7 @@ "idb": "8.0.3", "lodash": "4.17.21", "luxon": "3.7.2", - "next": "15.5.4", + "next": "15.5.7", "posthog-js": "1.298.0", "react": "*", "react-aria-components": "1.13.0", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index a97b47691..94c6a137f 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2530,10 +2530,10 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@next/env@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.4.tgz#1d4aa6b238662d9cd95aea356b149b6f73061f95" - integrity sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A== +"@next/env@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.7.tgz#4168db34ae3bc9fd9ad3b951d327f4cfc38d4362" + integrity sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg== "@next/eslint-plugin-next@16.0.3": version "16.0.3" @@ -2542,45 +2542,45 @@ dependencies: fast-glob "3.3.1" -"@next/swc-darwin-arm64@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz#80cba1bec831d4b01fd03cbc48dfb7050775e5ee" - integrity sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA== +"@next/swc-darwin-arm64@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz#f0c9ccfec2cd87cbd4b241ce4c779a7017aed958" + integrity sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw== -"@next/swc-darwin-x64@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz#d5408b19298f40da2b3dc9c2f9d1063ad98bd626" - integrity sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA== +"@next/swc-darwin-x64@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz#18009e9fcffc5c0687cc9db24182ddeac56280d9" + integrity sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg== -"@next/swc-linux-arm64-gnu@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz#3b6b389bb4a1c9728a14afbbd59d2366ccd80b55" - integrity sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA== +"@next/swc-linux-arm64-gnu@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz#fe7c7e08264cf522d4e524299f6d3e63d68d579a" + integrity sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA== -"@next/swc-linux-arm64-musl@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz#956127ecdfd56cda535af4651eed72a3b7270971" - integrity sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A== +"@next/swc-linux-arm64-musl@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz#94228fe293475ec34a5a54284e1056876f43a3cf" + integrity sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw== -"@next/swc-linux-x64-gnu@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz#9386de65e86c0b34ef19e14f0ffbd4328a08d5e6" - integrity sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA== +"@next/swc-linux-x64-gnu@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz#078c71201dfe7fcfb8fa6dc92aae6c94bc011cdc" + integrity sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw== -"@next/swc-linux-x64-musl@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz#c9094e5479b58c89d35b465f165b69be68de5a75" - integrity sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw== +"@next/swc-linux-x64-musl@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz#72947f5357f9226292353e0bb775643da3c7a182" + integrity sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA== -"@next/swc-win32-arm64-msvc@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz#e83ca6b5ce9499bde5a4f3351cf74dc9e92cc83e" - integrity sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA== +"@next/swc-win32-arm64-msvc@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz#397b912cd51c6a80e32b9c0507ecd82514353941" + integrity sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ== -"@next/swc-win32-x64-msvc@15.5.4": - version "15.5.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz#5b5baf1bcb0ecba70d1768a0c8be59dfdcb2f111" - integrity sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg== +"@next/swc-win32-x64-msvc@15.5.7": + version "15.5.7" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz#e02b543d9dc6c1631d4ac239cb1177245dfedfe4" + integrity sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw== "@noble/hashes@^2.0.1": version "2.0.1" @@ -12080,25 +12080,25 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -next@15.5.4: - version "15.5.4" - resolved "https://registry.yarnpkg.com/next/-/next-15.5.4.tgz#e7412c805c0b686ceaf294de703b7c9be59a4081" - integrity sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA== +next@15.5.7: + version "15.5.7" + resolved "https://registry.yarnpkg.com/next/-/next-15.5.7.tgz#4507700b2bbcaf2c9fb7a9ad25c0dac2ba4a9a75" + integrity sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ== dependencies: - "@next/env" "15.5.4" + "@next/env" "15.5.7" "@swc/helpers" "0.5.15" caniuse-lite "^1.0.30001579" postcss "8.4.31" styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "15.5.4" - "@next/swc-darwin-x64" "15.5.4" - "@next/swc-linux-arm64-gnu" "15.5.4" - "@next/swc-linux-arm64-musl" "15.5.4" - "@next/swc-linux-x64-gnu" "15.5.4" - "@next/swc-linux-x64-musl" "15.5.4" - "@next/swc-win32-arm64-msvc" "15.5.4" - "@next/swc-win32-x64-msvc" "15.5.4" + "@next/swc-darwin-arm64" "15.5.7" + "@next/swc-darwin-x64" "15.5.7" + "@next/swc-linux-arm64-gnu" "15.5.7" + "@next/swc-linux-arm64-musl" "15.5.7" + "@next/swc-linux-x64-gnu" "15.5.7" + "@next/swc-linux-x64-musl" "15.5.7" + "@next/swc-win32-arm64-msvc" "15.5.7" + "@next/swc-win32-x64-msvc" "15.5.7" sharp "^0.34.3" no-case@^3.0.4: From 00ae7fdd6067666c74298e2363649c9e0b5cf8ae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:36:21 +0000 Subject: [PATCH 02/75] =?UTF-8?q?=E2=AC=86=EF=B8=8F(dependencies)=20update?= =?UTF-8?q?=20django=20to=20v5.2.9=20[SECURITY]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upgrade to django 5.2.9 --- src/backend/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index be7c4817b..595b6034c 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "django-redis==6.0.0", "django-storages[s3]==1.14.6", "django-timezone-field>=5.1", - "django==5.2.8", + "django==5.2.9", "django-treebeard==4.7.1", "djangorestframework==3.16.1", "drf_spectacular==0.29.0", From 5e398e8e79bd3f73513cdc7e422a01676eac4811 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 26 Nov 2025 11:26:40 +0100 Subject: [PATCH 03/75] =?UTF-8?q?=E2=9C=A8(frontend)=20move=20html=20optio?= =?UTF-8?q?n=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 000000000..1ac594926 --- /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 4d0338a20..51d95a4fe 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 05/75] =?UTF-8?q?=E2=9C=85(e2e)=20add=20test=20for=20acces?= =?UTF-8?q?sible=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 36af2298a..395eb6d75 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 52af85ad4..a54d9e2a2 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 6a4e01b19..34f0b4c45 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 8c3efaa02..9ae3e7312 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 f6245ab5c..000000000 --- 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 9b1b29967..54029d7fa 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); From acdde81a3d056e2c5834df80d5b4dfe360335f80 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 19 Nov 2025 11:29:34 +0100 Subject: [PATCH 06/75] =?UTF-8?q?=E2=9C=A8(frontend)=20fix=20toggle=20pane?= =?UTF-8?q?l=20button=20a11y=20with=20dynamic=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improves screen sr by updating label and state indication dynamically Signed-off-by: Cyril --- CHANGELOG.md | 3 +- .../app-impress/doc-grid-dnd.spec.ts | 7 ++-- .../e2e/__tests__/app-impress/utils-common.ts | 33 ++++++++++++++++--- .../__tests__/app-impress/utils-sub-pages.ts | 18 +++------- .../header/components/ButtonTogglePanel.tsx | 6 +++- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395eb6d75..9e005de82 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) fix toggle panel button a11y labels #1634 - ⚡️(frontend) Enhance/html copy to download #1669 ### Fixed @@ -23,8 +24,6 @@ and this project adheres to - ✨ Add comments feature to the editor #1330 - ✨(backend) Comments on text editor #1330 - ✨(frontend) link to create new doc #1574 -- ♿(frontend) improve accessibility: - - ♿(frontend) add skip to content button for keyboard accessibility #1624 ### Fixed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts index 4deda6456..17cfa8156 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createDoc, mockedListDocs } from './utils-common'; +import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Doc grid dnd', () => { @@ -185,10 +185,7 @@ test.describe('Doc grid dnd mobile', () => { true, ); - await page - .getByRole('button', { name: 'Open the header menu' }) - .getByText('menu') - .click(); + await toggleHeaderMenu(page); await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute( 'draggable', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 49038642d..b4fda8168 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -83,6 +83,34 @@ export const randomName = (name: string, browserName: string, length: number) => return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`; }); +export const openHeaderMenu = async (page: Page) => { + const toggleButton = page.getByTestId('header-menu-toggle'); + await expect(toggleButton).toBeVisible(); + + const isExpanded = + (await toggleButton.getAttribute('aria-expanded')) === 'true'; + if (!isExpanded) { + await toggleButton.click(); + } +}; + +export const closeHeaderMenu = async (page: Page) => { + const toggleButton = page.getByTestId('header-menu-toggle'); + await expect(toggleButton).toBeVisible(); + + const isExpanded = + (await toggleButton.getAttribute('aria-expanded')) === 'true'; + if (isExpanded) { + await toggleButton.click(); + } +}; + +export const toggleHeaderMenu = async (page: Page) => { + const toggleButton = page.getByTestId('header-menu-toggle'); + await expect(toggleButton).toBeVisible(); + await toggleButton.click(); +}; + export const createDoc = async ( page: Page, docName: string, @@ -94,10 +122,7 @@ export const createDoc = async ( for (let i = 0; i < randomDocs.length; i++) { if (isMobile) { - await page - .getByRole('button', { name: 'Open the header menu' }) - .getByText('menu') - .click(); + await openHeaderMenu(page); } await page diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts index 9ba382985..a946d4c9f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts @@ -2,6 +2,8 @@ import { Page, expect } from '@playwright/test'; import { BrowserName, + closeHeaderMenu, + openHeaderMenu, randomName, updateDocTitle, verifyDocName, @@ -15,10 +17,7 @@ export const createRootSubPage = async ( isMobile = false, ) => { if (isMobile) { - await page - .getByRole('button', { name: 'Open the header menu' }) - .getByText('menu') - .click(); + await openHeaderMenu(page); } // Get response @@ -29,10 +28,7 @@ export const createRootSubPage = async ( const subPageJson = (await response.json()) as { id: string }; if (isMobile) { - await page - .getByRole('button', { name: 'Open the header menu' }) - .getByText('menu') - .click(); + await openHeaderMenu(page); } // Get doc tree @@ -44,13 +40,9 @@ export const createRootSubPage = async ( .getByTestId(`doc-sub-page-item-${subPageJson.id}`) .first(); await expect(subPageItem).toBeVisible(); - await subPageItem.click(); if (isMobile) { - await page - .getByRole('button', { name: 'Open the header menu' }) - .getByText('close') - .click(); + await closeHeaderMenu(page); } // Update sub page name diff --git a/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx b/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx index f53c0425b..e1a85117d 100644 --- a/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx +++ b/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx @@ -12,12 +12,16 @@ export const ButtonTogglePanel = () => { '; + header.appendChild(customMenu); + + console.log('Custom menu added successfully'); + return true; + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCustomMenu); + } else { + initCustomMenu(); + } +})(); +``` + +Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header. + +---- + # **Your Docs icon** 📝 You can add your own Docs icon in the header from the theme customization file. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index eccbf4952..6f959009f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2197,6 +2197,7 @@ class ConfigView(drf.views.APIView): "ENVIRONMENT", "FRONTEND_CSS_URL", "FRONTEND_HOMEPAGE_FEATURE_ENABLED", + "FRONTEND_JS_URL", "FRONTEND_THEME", "MEDIA_BASE_URL", "POSTHOG_KEY", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 0261125e8..e29187b3e 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -24,6 +24,7 @@ pytestmark = pytest.mark.django_db COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True, CRISP_WEBSITE_ID="123", FRONTEND_CSS_URL="http://testcss/", + FRONTEND_JS_URL="http://testjs/", FRONTEND_THEME="test-theme", MEDIA_BASE_URL="http://testserver/", POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, @@ -49,6 +50,7 @@ def test_api_config(is_authenticated): "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", "FRONTEND_HOMEPAGE_FEATURE_ENABLED": True, + "FRONTEND_JS_URL": "http://testjs/", "FRONTEND_THEME": "test-theme", "LANGUAGES": [ ["en-us", "English"], diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 4b456ebbb..5f74d7d57 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -509,6 +509,9 @@ class Base(Configuration): FRONTEND_CSS_URL = values.Value( None, environ_name="FRONTEND_CSS_URL", environ_prefix=None ) + FRONTEND_JS_URL = values.Value( + None, environ_name="FRONTEND_JS_URL", environ_prefix=None + ) THEME_CUSTOMIZATION_FILE_PATH = values.Value( os.path.join(BASE_DIR, "impress/configuration/theme/default.json"), diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index c5410c7cf..44470fbb2 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -126,6 +126,20 @@ test.describe('Config', () => { ).toBeAttached(); }); + test('it checks FRONTEND_JS_URL config', async ({ page }) => { + await overrideConfig(page, { + FRONTEND_JS_URL: 'http://localhost:123465/js/script.js', + }); + + await page.goto('/'); + + await expect( + page + .locator('script[src="http://localhost:123465/js/script.js"]') + .first(), + ).toBeAttached(); + }); + test('it checks theme_customization.translations config', async ({ page, }) => { @@ -145,10 +159,6 @@ test.describe('Config', () => { await expect(page.getByText('MyCustomDocs')).toBeAttached(); }); -}); - -test.describe('Config: Not logged', () => { - test.use({ storageState: { cookies: [], origins: [] } }); test('it checks the config api is called', async ({ page }) => { const responsePromise = page.waitForResponse( @@ -168,6 +178,10 @@ test.describe('Config: Not logged', () => { expect(configApi).toStrictEqual(CONFIG_LEFT); }); +}); + +test.describe('Config: Not logged', () => { + test.use({ storageState: { cookies: [], origins: [] } }); test('it checks that theme is configured from config endpoint', async ({ page, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index b4fda8168..67adf44c7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -10,6 +10,7 @@ export const CONFIG = { COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, + FRONTEND_JS_URL: null, FRONTEND_HOMEPAGE_FEATURE_ENABLED: true, FRONTEND_THEME: null, MEDIA_BASE_URL: 'http://localhost:8083', diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 6694f6b94..f6e399938 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -1,5 +1,6 @@ import { Loader } from '@openfun/cunningham-react'; import Head from 'next/head'; +import Script from 'next/script'; import { PropsWithChildren, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -87,6 +88,9 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { )} + {conf?.FRONTEND_JS_URL && ( +