diff --git a/CHANGELOG.md b/CHANGELOG.md index ac490d64a..b4708a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,15 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 - 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486 -### Fixed - -- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 - ### Changed - ♿️(frontend) use semantic `
` structure in document info card #2379 +### Fixed + +- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 +- 📄(frontend) allowed partially export when MIT #2551 + ## [v5.4.1] - 2026-07-09 ### Changed 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 3e5f3c3c0..b614a74cd 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 b504c9fc9..f7a6cee02 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, @@ -42,13 +28,7 @@ import { } from '../utils_html'; import { printDocumentWithStyles } from '../utils_print'; -enum DocDownloadFormat { - HTML = 'html', - PDF = 'pdf', - DOCX = 'docx', - ODT = 'odt', - PRINT = 'print', -} +const useExportAGPL = ModulesExport?.useExportAGPL; interface ModalExportProps { onClose: () => void; @@ -60,12 +40,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(() => { @@ -77,22 +58,18 @@ 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 }, - { label: t('Print'), value: DocDownloadFormat.PRINT }, - ]; + const formatSelect = useMemo(() => { + const formatOptions = (exportAGPL?.formats || []).concat([ + { label: t('HTML'), value: 'html' }, + { label: t('Print'), value: 'print' }, + ]); - const formatLabels = Object.fromEntries( - formatOptions.map((opt) => [opt.value, opt.label]), - ); + const formatLabels = Object.fromEntries( + formatOptions.map((opt) => [opt.value, opt.label]), + ); - const downloadButtonAriaLabel = - format === DocDownloadFormat.PRINT - ? t('Print') - : t('Download {{format}}', { format: formatLabels[format] }); + return { formatOptions, formatLabels }; + }, [t, exportAGPL?.formats]); async function onSubmit() { if (!editor) { @@ -103,7 +80,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { setIsExporting(true); // Handle print separately as it doesn't download a file - if (format === DocDownloadFormat.PRINT) { + if (format === 'print') { printDocumentWithStyles(); setIsExporting(false); onClose(); @@ -118,62 +95,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(), - ); + let blobExport = await exportAGPL?.docToBlob(format, documentTitle); - 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) { + if (!blobExport && format === 'html') { // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. const fullHtml = await editor.blocksToFullHTML(); @@ -206,14 +130,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}`); @@ -250,13 +175,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { } @@ -303,11 +234,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { clearable={false} fullWidth label={t('Format')} - options={formatOptions} + options={formatSelect.formatOptions} value={format} - onChange={(options) => - setFormat(options.target.value as DocDownloadFormat) - } + onChange={(options) => setFormat(options.target.value as string)} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts new file mode 100644 index 000000000..4cbea5953 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts @@ -0,0 +1,18 @@ +/** + * To import export modules you must import from the index file. + * This is to ensure that the export modules are only loaded when + * the application is not published as MIT. + */ + +import * as useExportAGPL from './useExportAGPL'; + +let modulesExport = undefined; +if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { + modulesExport = { + ...useExportAGPL, + }; +} + +type ModulesExport = typeof useExportAGPL; + +export default modulesExport as ModulesExport; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx new file mode 100644 index 000000000..a803e3aeb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx @@ -0,0 +1,98 @@ +/** + * This exports modules are AGPL licensed and should only + * be used when the application is not published as MIT. + */ +import { DOCXExporter } from '@blocknote/xl-docx-exporter'; +import { ODTExporter } from '@blocknote/xl-odt-exporter'; +import { PDFExporter } from '@blocknote/xl-pdf-exporter'; +import { DocumentProps, pdf } from '@react-pdf/renderer'; +import jsonemoji from 'emoji-datasource-apple' with { type: 'json' }; +import i18next from 'i18next'; +import { cloneElement, isValidElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DocsBlockNoteEditor } from '@/docs/doc-editor/types'; +import { Doc } from '@/docs/doc-management/types'; + +import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; +import { docxDocsSchemaMappings } from '../mappingDocx'; +import { odtDocsSchemaMappings } from '../mappingODT'; +import { pdfDocsSchemaMappings } from '../mappingPDF'; + +export const useExportAGPL = (doc: Doc, editor?: DocsBlockNoteEditor) => { + const { t } = useTranslation(); + + const docToBlob = async (format: string, documentTitle: string) => { + if (!editor) { + return; + } + + const exportDocument = editor.document; + let blobExport: Blob | undefined = undefined; + if (format === '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 === '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 === 'odt') { + const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, { + resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), + }); + + blobExport = await exporter.toODTDocument(exportDocument); + } + + return blobExport; + }; + + return { + formats: [ + { label: t('PDF'), value: 'pdf' }, + { label: t('Docx'), value: 'docx' }, + { label: t('ODT'), value: 'odt' }, + ], + docToBlob, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts index 6306d9b08..2397d43bf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts @@ -6,14 +6,4 @@ export * from './api'; export * from './utils'; export * from './utils_html'; - -import * as ModalExport from './components/ModalExport'; - -let modulesExport = undefined; -if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { - modulesExport = { - ...ModalExport, - }; -} - -export default modulesExport; +export * from './components'; 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 deleted file mode 100644 index c6116ac5e..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { afterAll, beforeEach, describe, expect, vi } from 'vitest'; - -const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; - -vi.mock('next/router', async () => ({ - ...(await vi.importActual('next/router')), - useRouter: () => ({ - push: vi.fn(), - pathname: '/docs/doc-1', - }), -})); - -vi.mock('@gouvfr-lasuite/ui-kit', async () => { - const actual = await vi.importActual('@gouvfr-lasuite/ui-kit'); - return { - ...actual, - DropdownMenu: ({ options, children }: any) => ( - <> - {children} -
    - {options - .filter((o: any) => !o.isHidden) - .map((o: any) => ( -
  • {o.label}
  • - ))} -
- - ), - }; -}); - -vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({ - useCopyCurrentEditorToClipboard: () => vi.fn(), -})); - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -describe('DocToolBox - Licence', () => { - afterAll(() => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('The export button is rendered when MIT version is deactivated', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; - - const { AppWrapper } = await import('@/tests/utils'); - const { DocToolBox } = await import('../components/DocToolBox'); - - render(, { - wrapper: AppWrapper, - }); - - expect(await screen.findByText('Download')).toBeInTheDocument(); - }, 15000); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx deleted file mode 100644 index aab64de00..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { afterAll, beforeEach, describe, expect, vi } from 'vitest'; - -const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; - -vi.mock('next/router', async () => ({ - ...(await vi.importActual('next/router')), - useRouter: () => ({ - push: vi.fn(), - pathname: '/docs/doc-1', - }), -})); - -vi.mock('@gouvfr-lasuite/ui-kit', async () => { - const actual = await vi.importActual('@gouvfr-lasuite/ui-kit'); - return { - ...actual, - DropdownMenu: ({ options, children }: any) => ( - <> - {children} -
    - {options - .filter((o: any) => !o.isHidden) - .map((o: any) => ( -
  • {o.label}
  • - ))} -
- - ), - }; -}); - -vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({ - useCopyCurrentEditorToClipboard: () => vi.fn(), -})); - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -describe('DocToolBox - Licence MIT', () => { - afterAll(() => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('The export button is not rendered when MIT version is activated', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true'; - - const { AppWrapper } = await import('@/tests/utils'); - const { DocToolBox } = await import('../components/DocToolBox'); - - render(, { - wrapper: AppWrapper, - }); - - expect(screen.queryByText('Download')).not.toBeInTheDocument(); - }, 15000); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 8ad47dda4..7138fe9ed 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -72,16 +72,13 @@ const ConfirmationLeaveModal = dynamic( { ssr: false }, ); -const ModalExport = - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' - ? dynamic( - () => - import('@/docs/doc-export/components/ModalExport').then((mod) => ({ - default: mod.ModalExport, - })), - { ssr: false }, - ) - : null; +const ModalExport = dynamic( + () => + import('@/docs/doc-export/components/ModalExport').then((mod) => ({ + default: mod.ModalExport, + })), + { ssr: false }, +); interface DocToolBoxProps { doc: Doc; @@ -167,7 +164,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { callback: () => { setIsModalExportOpen(true); }, - isHidden: !ModalExport, }, { label: t('Copy as {{format}}', { format: 'Markdown' }), @@ -247,7 +243,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { /> - {isModalExportOpen && ModalExport && ( + {isModalExportOpen && ( { setIsModalExportOpen(false);