diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e075b1afd..e4e42d1b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to ### Fixed - 🐛(frontend) fix clipped formatting toolbar in new comment composer #2585 +- 📄(frontend) allowed partially export when MIT #2551 ## [v5.5.0] - 2026-08-24 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 dec8aa1b1b..c6dfbb6834 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 @@ -30,7 +30,7 @@ test.describe('Doc Export', () => { await expect(page.getByTestId('modal-export-title')).toBeVisible(); await expect( page.getByText( - 'Export your document to download in .docx, .odt, .pdf or .html(zip) format.', + 'Export your document to download in .pdf, .docx, .odt or .html(zip) format.', ), ).toBeVisible(); await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible(); 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 3e5f3c3c07..b614a74cd1 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 @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@/docs/doc-export/components/ModalExport', () => ({ - ModalExport: vi.fn(), +vi.mock('@/docs/doc-export/hooks/useExportAGPL', () => ({ + useExportAGPL: vi.fn(), })); const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; @@ -18,15 +18,15 @@ describe('useModuleExport', () => { it('should return undefined when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => { process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true'; - const Export = await import('@/features/docs/doc-export/'); + const Export = await import('@/docs/doc-export/hooks'); expect(Export.default).toBeUndefined(); }); 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/'); + const Export = await import('@/docs/doc-export/hooks'); - expect(Export.default).toHaveProperty('ModalExport'); + expect(Export.default).toHaveProperty('useExportAGPL'); }); }); 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 8333d191c4..70a5188981 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 @@ -1,6 +1,3 @@ -import { DOCXExporter } from '@blocknote/xl-docx-exporter'; -import { ODTExporter } from '@blocknote/xl-odt-exporter'; -import { PDFExporter } from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -10,30 +7,19 @@ import { VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; -import { DocumentProps, pdf } from '@react-pdf/renderer'; -import jsonemoji from 'emoji-datasource-apple' with { type: 'json' }; import i18next from 'i18next'; import JSZip from 'jszip'; -import { - cloneElement, - isValidElement, - useEffect, - useRef, - useState, -} from 'react'; +import { useEffect, useMemo, useRef, 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/stores/useEditorStore'; -import { Doc, useTrans } from '@/docs/doc-management'; +import { type Doc, useTrans } from '@/docs/doc-management'; import { fallbackLng } from '@/i18n/config'; -import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; -import { docxDocsSchemaMappings } from '../mappingDocx'; -import { odtDocsSchemaMappings } from '../mappingODT'; -import { pdfDocsSchemaMappings } from '../mappingPDF'; +import ModulesExport from '../hooks/'; import { downloadFile } from '../utils'; import { addMediaFilesToZip, @@ -41,12 +27,7 @@ import { improveHtmlAccessibility, } from '../utils_html'; -enum DocDownloadFormat { - HTML = 'html', - PDF = 'pdf', - DOCX = 'docx', - ODT = 'odt', -} +const useExportAGPL = ModulesExport?.useExportAGPL; interface ModalExportProps { onClose: () => void; @@ -58,12 +39,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { toast } = useToastProvider(); const { editor } = useEditorStore(); const [isExporting, setIsExporting] = useState(false); - const [format, setFormat] = useState( - DocDownloadFormat.PDF, - ); const { untitledDocument } = useTrans(); const mediaUrl = useMediaUrl(); const selectRef = useRef(null); + const exportAGPL = useExportAGPL?.(doc, editor); + const [format, setFormat] = useState( + exportAGPL?.formats.find((opt) => opt.value === 'pdf')?.value || 'html', + ); useEffect(() => { const frameId = requestAnimationFrame(() => { @@ -75,16 +57,31 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { return () => cancelAnimationFrame(frameId); }, []); - const formatOptions = [ - { label: t('PDF'), value: DocDownloadFormat.PDF }, - { label: t('Docx'), value: DocDownloadFormat.DOCX }, - { label: t('ODT'), value: DocDownloadFormat.ODT }, - { label: t('HTML'), value: DocDownloadFormat.HTML }, - ]; + const formatSelect = useMemo(() => { + const formatOptions = (exportAGPL?.formats || []).concat([ + { + label: t('HTML'), + value: 'html', + labelDescription: t('.html(zip)'), + }, + ]); + + const formatLabels = Object.fromEntries( + formatOptions.map((opt) => [opt.value, opt.label]), + ); - const formatLabels = Object.fromEntries( - formatOptions.map((opt) => [opt.value, opt.label]), - ); + const labels = formatOptions.map((opt) => opt.labelDescription); + const or = t('or', { + description: + 'Word joining the last two items of the list of available export formats', + }); + const allFormatsLabel = + labels.length > 1 + ? `${labels.slice(0, -1).join(', ')} ${or} ${labels[labels.length - 1]}` + : labels.join(''); + + return { formatOptions, formatLabels, allFormatsLabel }; + }, [t, exportAGPL?.formats]); async function onSubmit() { if (!editor) { @@ -102,62 +99,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const documentTitle = doc.title || untitledDocument; - const exportDocument = editor.document; - let blobExport: Blob; - if (format === DocDownloadFormat.PDF) { - const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - emojiSource: { - format: 'png', - builder(code) { - const emojisFound = jsonemoji.filter( - (e) => - e.unified.split('-')[0].toLowerCase() === - code.split('-')[0].toLowerCase(), - ); - - const emoji = emojisFound.find((e) => - e.unified.toLocaleLowerCase().includes(code.toLowerCase()), - ); - - if (emoji) { - return `/assets/fonts/emoji/${emoji.image}`; - } - - return '/assets/fonts/emoji/fallback.png'; - }, - }, - }); - const rawPdfDocument = (await exporter.toReactPDFDocument( - exportDocument, - )) as React.ReactElement; - - // Add language, title and outline properties to improve PDF accessibility and navigation - const pdfDocument = isValidElement(rawPdfDocument) - ? cloneElement(rawPdfDocument, { - language: i18next.language, - title: documentTitle, - pageMode: 'useOutlines', - }) - : rawPdfDocument; - - blobExport = await pdf(pdfDocument).toBlob(); - } else if (format === DocDownloadFormat.DOCX) { - const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - }); - - blobExport = await exporter.toBlob(exportDocument, { - documentOptions: { title: documentTitle }, - sectionOptions: {}, - }); - } else if (format === DocDownloadFormat.ODT) { - const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - }); - - blobExport = await exporter.toODTDocument(exportDocument); - } else if (format === DocDownloadFormat.HTML) { + let blobExport = await exportAGPL?.docToBlob(format, documentTitle); + + if (!blobExport && format === 'html') { // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. const fullHtml = await editor.blocksToFullHTML(); @@ -190,14 +134,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { zip.file('styles.css', cssContent); blobExport = await zip.generateAsync({ type: 'blob' }); - } else { + } + + if (!blobExport) { toast(t('The export failed'), VariantType.ERROR); setIsExporting(false); return; } - const downloadExtension = - format === DocDownloadFormat.HTML ? 'zip' : format; + const downloadExtension = format === 'html' ? 'zip' : format; downloadFile(blobExport, `${filename}.${downloadExtension}`); @@ -235,7 +180,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {