@@ -13,6 +13,7 @@ import {
1313import { DocumentProps , pdf } from '@react-pdf/renderer' ;
1414import jsonemoji from 'emoji-datasource-apple' assert { type : 'json' } ;
1515import i18next from 'i18next' ;
16+ import JSZip from 'jszip' ;
1617import { cloneElement , isValidElement , useMemo , useState } from 'react' ;
1718import { useTranslation } from 'react-i18next' ;
1819import { css } from 'styled-components' ;
@@ -145,7 +146,88 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
145146 blobExport = await exporter . toODTDocument ( exportDocument ) ;
146147 } else if ( format === DocDownloadFormat . HTML ) {
147148 const editorHtml = await editor . blocksToHTMLLossy ( ) ;
149+
150+ // Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
151+ const domParser = new DOMParser ( ) ;
152+ const parsedDocument = domParser . parseFromString ( editorHtml , 'text/html' ) ;
153+
154+ const mediaFiles : { filename : string ; blob : Blob } [ ] = [ ] ;
155+ const mediaElements = Array . from (
156+ parsedDocument . querySelectorAll <
157+ | HTMLImageElement
158+ | HTMLVideoElement
159+ | HTMLAudioElement
160+ | HTMLSourceElement
161+ > ( 'img, video, audio, source' ) ,
162+ ) ;
163+
164+ await Promise . all (
165+ mediaElements . map ( async ( element , index ) => {
166+ const src = element . getAttribute ( 'src' ) ;
167+
168+ if ( ! src ) {
169+ return ;
170+ }
171+
172+ const fetched = await exportCorsResolveFileUrl ( doc . id , src ) ;
173+
174+ if ( ! ( fetched instanceof Blob ) ) {
175+ return ;
176+ }
177+
178+ // Derive a readable filename:
179+ // - data: URLs → use a generic "media-N.ext"
180+ // - normal URLs → keep the last path segment as base name.
181+ let baseName = `media-${ index + 1 } ` ;
182+
183+ if ( ! src . startsWith ( 'data:' ) ) {
184+ try {
185+ const url = new URL ( src , window . location . origin ) ;
186+ const lastSegment = url . pathname . split ( '/' ) . pop ( ) ;
187+ if ( lastSegment ) {
188+ baseName = `${ index + 1 } -${ lastSegment } ` ;
189+ }
190+ } catch {
191+ // Ignore invalid URLs, keep default baseName.
192+ }
193+ }
194+
195+ let filename = baseName ;
196+
197+ // Ensure the filename has an extension consistent with the blob MIME type.
198+ const mimeType = fetched . type ;
199+ if ( mimeType && ! baseName . includes ( '.' ) ) {
200+ const subtype = mimeType . split ( '/' ) [ 1 ] || '' ;
201+ let extension = '' ;
202+
203+ if ( subtype . includes ( 'svg' ) ) {
204+ extension = 'svg' ;
205+ } else if ( subtype . includes ( 'jpeg' ) || subtype . includes ( 'pjpeg' ) ) {
206+ extension = 'jpg' ;
207+ } else if ( subtype . includes ( 'png' ) ) {
208+ extension = 'png' ;
209+ } else if ( subtype . includes ( 'gif' ) ) {
210+ extension = 'gif' ;
211+ } else if ( subtype . includes ( 'webp' ) ) {
212+ extension = 'webp' ;
213+ } else if ( subtype . includes ( 'pdf' ) ) {
214+ extension = 'pdf' ;
215+ } else if ( subtype ) {
216+ extension = subtype . split ( '+' ) [ 0 ] ;
217+ }
218+
219+ if ( extension ) {
220+ filename = `${ baseName } .${ extension } ` ;
221+ }
222+ }
223+
224+ element . setAttribute ( 'src' , filename ) ;
225+ mediaFiles . push ( { filename, blob : fetched } ) ;
226+ } ) ,
227+ ) ;
228+
148229 const lang = i18next . language || 'fr' ;
230+ const editorHtmlWithLocalMedia = parsedDocument . body . innerHTML ;
149231
150232 const htmlContent = `<!DOCTYPE html>
151233<html lang="${ lang } ">
@@ -155,21 +237,29 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
155237 </head>
156238 <body>
157239 <main role="main">
158- ${ editorHtml }
240+ ${ editorHtmlWithLocalMedia }
159241 </main>
160242 </body>
161243</html>` ;
162244
163- blobExport = new Blob ( [ htmlContent ] , {
164- type : 'text/html;charset=utf-8' ,
245+ const zip = new JSZip ( ) ;
246+ zip . file ( 'index.html' , htmlContent ) ;
247+
248+ mediaFiles . forEach ( ( { filename, blob } ) => {
249+ zip . file ( filename , blob ) ;
165250 } ) ;
251+
252+ blobExport = await zip . generateAsync ( { type : 'blob' } ) ;
166253 } else {
167254 toast ( t ( 'The export failed' ) , VariantType . ERROR ) ;
168255 setIsExporting ( false ) ;
169256 return ;
170257 }
171258
172- downloadFile ( blobExport , `${ filename } .${ format } ` ) ;
259+ const downloadExtension =
260+ format === DocDownloadFormat . HTML ? 'zip' : format ;
261+
262+ downloadFile ( blobExport , `${ filename } .${ downloadExtension } ` ) ;
173263
174264 toast (
175265 t ( 'Your {{format}} was downloaded succesfully' , {
@@ -246,7 +336,9 @@ ${editorHtml}
246336 className = "--docs--modal-export-content"
247337 >
248338 < Text $variation = "secondary" $size = "sm" as = "p" >
249- { t ( 'Download your document in a .docx, .odt or .pdf format.' ) }
339+ { t (
340+ 'Download your document in a .docx, .odt, .pdf or .html(zip) format.' ,
341+ ) }
250342 </ Text >
251343 < Select
252344 clearable = { false }
0 commit comments