Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,43 +7,27 @@ 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,
generateHtmlDocument,
improveHtmlAccessibility,
} from '../utils_html';

enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
}
const useExportAGPL = ModulesExport?.useExportAGPL;

interface ModalExportProps {
onClose: () => void;
Expand All @@ -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>(
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const selectRef = useRef<HTMLDivElement>(null);
const exportAGPL = useExportAGPL?.(doc, editor);
const [format, setFormat] = useState(
exportAGPL?.formats.find((opt) => opt.value === 'pdf')?.value || 'html',
);

useEffect(() => {
const frameId = requestAnimationFrame(() => {
Expand All @@ -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) {
Expand All @@ -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<DocumentProps>;

// 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();

Expand Down Expand Up @@ -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}`);

Expand Down Expand Up @@ -235,7 +180,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
<Button
data-testid="doc-export-download-button"
aria-label={t('Download {{format}}', {
format: formatLabels[format],
format: formatSelect.formatLabels[format],
})}
variant="primary"
fullWidth
Expand Down Expand Up @@ -280,20 +225,18 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
as="p"
id="modal-export-description"
>
{t(
'Export your document to download in .docx, .odt, .pdf or .html(zip) format.',
)}
{t('Export your document to download in {{format}} format.', {
format: formatSelect.allFormatsLabel,
})}
</Text>
<Box ref={selectRef}>
<Select
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)}
/>
</Box>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<DocumentProps>;

// 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', labelDescription: t('.pdf') },
{ label: t('Docx'), value: 'docx', labelDescription: t('.docx') },
{ label: t('ODT'), value: 'odt', labelDescription: t('.odt') },
],
docToBlob,
};
};
Loading
Loading