Skip to content

Commit 608eb13

Browse files
committed
📄(frontend) allowed partially export when MIT
When MIT, we were not able to export anything. But actually, the export HTML and Print are allowed for MIT, so we should allow them. We now allow partially export when MIT, but the AGPL export is still not allowed (pdf / odt / docx).
1 parent eed828d commit 608eb13

4 files changed

Lines changed: 159 additions & 118 deletions

File tree

src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx

Lines changed: 35 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
2-
import { ODTExporter } from '@blocknote/xl-odt-exporter';
3-
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
41
import {
52
Button,
63
Loader,
@@ -10,30 +7,19 @@ import {
107
VariantType,
118
useToastProvider,
129
} from '@gouvfr-lasuite/cunningham-react';
13-
import { DocumentProps, pdf } from '@react-pdf/renderer';
14-
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
1510
import i18next from 'i18next';
1611
import JSZip from 'jszip';
17-
import {
18-
cloneElement,
19-
isValidElement,
20-
useEffect,
21-
useRef,
22-
useState,
23-
} from 'react';
12+
import { useEffect, useMemo, useRef, useState } from 'react';
2413
import { useTranslation } from 'react-i18next';
2514
import { css } from 'styled-components';
2615

2716
import { Box, ButtonCloseModal, Text } from '@/components';
2817
import { useMediaUrl } from '@/core';
2918
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
30-
import { Doc, useTrans } from '@/docs/doc-management';
19+
import { type Doc, useTrans } from '@/docs/doc-management';
3120
import { fallbackLng } from '@/i18n/config';
3221

33-
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
34-
import { docxDocsSchemaMappings } from '../mappingDocx';
35-
import { odtDocsSchemaMappings } from '../mappingODT';
36-
import { pdfDocsSchemaMappings } from '../mappingPDF';
22+
import ModulesExport from '../hooks/';
3723
import { downloadFile } from '../utils';
3824
import {
3925
addMediaFilesToZip,
@@ -42,13 +28,7 @@ import {
4228
} from '../utils_html';
4329
import { printDocumentWithStyles } from '../utils_print';
4430

45-
enum DocDownloadFormat {
46-
HTML = 'html',
47-
PDF = 'pdf',
48-
DOCX = 'docx',
49-
ODT = 'odt',
50-
PRINT = 'print',
51-
}
31+
const useExportAGPL = ModulesExport?.useExportAGPL;
5232

5333
interface ModalExportProps {
5434
onClose: () => void;
@@ -60,12 +40,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
6040
const { toast } = useToastProvider();
6141
const { editor } = useEditorStore();
6242
const [isExporting, setIsExporting] = useState(false);
63-
const [format, setFormat] = useState<DocDownloadFormat>(
64-
DocDownloadFormat.PDF,
65-
);
6643
const { untitledDocument } = useTrans();
6744
const mediaUrl = useMediaUrl();
6845
const selectRef = useRef<HTMLDivElement>(null);
46+
const exportAGPL = useExportAGPL?.(doc, editor);
47+
const [format, setFormat] = useState(
48+
exportAGPL?.formats.find((opt) => opt.value === 'pdf')?.value || 'html',
49+
);
6950

7051
useEffect(() => {
7152
const frameId = requestAnimationFrame(() => {
@@ -77,22 +58,18 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
7758
return () => cancelAnimationFrame(frameId);
7859
}, []);
7960

80-
const formatOptions = [
81-
{ label: t('PDF'), value: DocDownloadFormat.PDF },
82-
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
83-
{ label: t('ODT'), value: DocDownloadFormat.ODT },
84-
{ label: t('HTML'), value: DocDownloadFormat.HTML },
85-
{ label: t('Print'), value: DocDownloadFormat.PRINT },
86-
];
61+
const formatSelect = useMemo(() => {
62+
const formatOptions = (exportAGPL?.formats || []).concat([
63+
{ label: t('HTML'), value: 'html' },
64+
{ label: t('Print'), value: 'print' },
65+
]);
8766

88-
const formatLabels = Object.fromEntries(
89-
formatOptions.map((opt) => [opt.value, opt.label]),
90-
);
67+
const formatLabels = Object.fromEntries(
68+
formatOptions.map((opt) => [opt.value, opt.label]),
69+
);
9170

92-
const downloadButtonAriaLabel =
93-
format === DocDownloadFormat.PRINT
94-
? t('Print')
95-
: t('Download {{format}}', { format: formatLabels[format] });
71+
return { formatOptions, formatLabels };
72+
}, [t, exportAGPL?.formats]);
9673

9774
async function onSubmit() {
9875
if (!editor) {
@@ -103,7 +80,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
10380
setIsExporting(true);
10481

10582
// Handle print separately as it doesn't download a file
106-
if (format === DocDownloadFormat.PRINT) {
83+
if (format === 'print') {
10784
printDocumentWithStyles();
10885
setIsExporting(false);
10986
onClose();
@@ -118,62 +95,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
11895

11996
const documentTitle = doc.title || untitledDocument;
12097

121-
const exportDocument = editor.document;
122-
let blobExport: Blob;
123-
if (format === DocDownloadFormat.PDF) {
124-
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
125-
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
126-
emojiSource: {
127-
format: 'png',
128-
builder(code) {
129-
const emojisFound = jsonemoji.filter(
130-
(e) =>
131-
e.unified.split('-')[0].toLowerCase() ===
132-
code.split('-')[0].toLowerCase(),
133-
);
134-
135-
const emoji = emojisFound.find((e) =>
136-
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
137-
);
138-
139-
if (emoji) {
140-
return `/assets/fonts/emoji/${emoji.image}`;
141-
}
98+
let blobExport = await exportAGPL?.docToBlob(format, documentTitle);
14299

143-
return '/assets/fonts/emoji/fallback.png';
144-
},
145-
},
146-
});
147-
const rawPdfDocument = (await exporter.toReactPDFDocument(
148-
exportDocument,
149-
)) as React.ReactElement<DocumentProps>;
150-
151-
// Add language, title and outline properties to improve PDF accessibility and navigation
152-
const pdfDocument = isValidElement(rawPdfDocument)
153-
? cloneElement(rawPdfDocument, {
154-
language: i18next.language,
155-
title: documentTitle,
156-
pageMode: 'useOutlines',
157-
})
158-
: rawPdfDocument;
159-
160-
blobExport = await pdf(pdfDocument).toBlob();
161-
} else if (format === DocDownloadFormat.DOCX) {
162-
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
163-
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
164-
});
165-
166-
blobExport = await exporter.toBlob(exportDocument, {
167-
documentOptions: { title: documentTitle },
168-
sectionOptions: {},
169-
});
170-
} else if (format === DocDownloadFormat.ODT) {
171-
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
172-
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
173-
});
174-
175-
blobExport = await exporter.toODTDocument(exportDocument);
176-
} else if (format === DocDownloadFormat.HTML) {
100+
if (!blobExport && format === 'html') {
177101
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
178102
const fullHtml = await editor.blocksToFullHTML();
179103

@@ -206,14 +130,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
206130
zip.file('styles.css', cssContent);
207131

208132
blobExport = await zip.generateAsync({ type: 'blob' });
209-
} else {
133+
}
134+
135+
if (!blobExport) {
210136
toast(t('The export failed'), VariantType.ERROR);
211137
setIsExporting(false);
212138
return;
213139
}
214140

215-
const downloadExtension =
216-
format === DocDownloadFormat.HTML ? 'zip' : format;
141+
const downloadExtension = format === 'html' ? 'zip' : format;
217142

218143
downloadFile(blobExport, `${filename}.${downloadExtension}`);
219144

@@ -250,13 +175,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
250175
</Button>
251176
<Button
252177
data-testid="doc-export-download-button"
253-
aria-label={downloadButtonAriaLabel}
178+
aria-label={
179+
format === 'print'
180+
? t('Print')
181+
: t('Download {{format}}', {
182+
format: formatSelect.formatLabels[format],
183+
})
184+
}
254185
variant="primary"
255186
fullWidth
256187
onClick={() => void onSubmit()}
257188
disabled={isExporting}
258189
>
259-
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
190+
{format === 'print' ? t('Print') : t('Download')}
260191
</Button>
261192
</>
262193
}
@@ -303,11 +234,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
303234
clearable={false}
304235
fullWidth
305236
label={t('Format')}
306-
options={formatOptions}
237+
options={formatSelect.formatOptions}
307238
value={format}
308-
onChange={(options) =>
309-
setFormat(options.target.value as DocDownloadFormat)
310-
}
239+
onChange={(options) => setFormat(options.target.value as string)}
311240
/>
312241
</Box>
313242

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* To import export modules you must import from the index file.
3+
* This is to ensure that the export modules are only loaded when
4+
* the application is not published as MIT.
5+
*/
6+
7+
import * as useExportAGPL from './useExportAGPL';
8+
9+
let modulesExport = undefined;
10+
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
11+
modulesExport = {
12+
...useExportAGPL,
13+
};
14+
}
15+
16+
type ModulesExport = typeof useExportAGPL;
17+
18+
export default modulesExport as ModulesExport;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* This exports modules are AGPL licensed and should only
3+
* be used when the application is not published as MIT.
4+
*/
5+
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
6+
import { ODTExporter } from '@blocknote/xl-odt-exporter';
7+
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
8+
import { DocumentProps, pdf } from '@react-pdf/renderer';
9+
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
10+
import i18next from 'i18next';
11+
import { cloneElement, isValidElement } from 'react';
12+
import { useTranslation } from 'react-i18next';
13+
14+
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
15+
import { Doc } from '@/docs/doc-management/types';
16+
17+
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
18+
import { docxDocsSchemaMappings } from '../mappingDocx';
19+
import { odtDocsSchemaMappings } from '../mappingODT';
20+
import { pdfDocsSchemaMappings } from '../mappingPDF';
21+
22+
export const useExportAGPL = (doc: Doc, editor?: DocsBlockNoteEditor) => {
23+
const { t } = useTranslation();
24+
25+
const docToBlob = async (format: string, documentTitle: string) => {
26+
if (!editor) {
27+
return;
28+
}
29+
30+
const exportDocument = editor.document;
31+
let blobExport: Blob | undefined = undefined;
32+
if (format === 'pdf') {
33+
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
34+
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
35+
emojiSource: {
36+
format: 'png',
37+
builder(code) {
38+
const emojisFound = jsonemoji.filter(
39+
(e) =>
40+
e.unified.split('-')[0].toLowerCase() ===
41+
code.split('-')[0].toLowerCase(),
42+
);
43+
44+
const emoji = emojisFound.find((e) =>
45+
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
46+
);
47+
48+
if (emoji) {
49+
return `/assets/fonts/emoji/${emoji.image}`;
50+
}
51+
52+
return '/assets/fonts/emoji/fallback.png';
53+
},
54+
},
55+
});
56+
const rawPdfDocument = (await exporter.toReactPDFDocument(
57+
exportDocument,
58+
)) as React.ReactElement<DocumentProps>;
59+
60+
// Add language, title and outline properties to improve PDF accessibility and navigation
61+
const pdfDocument = isValidElement(rawPdfDocument)
62+
? cloneElement(rawPdfDocument, {
63+
language: i18next.language,
64+
title: documentTitle,
65+
pageMode: 'useOutlines',
66+
})
67+
: rawPdfDocument;
68+
69+
blobExport = await pdf(pdfDocument).toBlob();
70+
} else if (format === 'docx') {
71+
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
72+
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
73+
});
74+
75+
blobExport = await exporter.toBlob(exportDocument, {
76+
documentOptions: { title: documentTitle },
77+
sectionOptions: {},
78+
});
79+
} else if (format === 'odt') {
80+
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
81+
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
82+
});
83+
84+
blobExport = await exporter.toODTDocument(exportDocument);
85+
}
86+
87+
return blobExport;
88+
};
89+
90+
return {
91+
formats: [
92+
{ label: t('PDF'), value: 'pdf' },
93+
{ label: t('Docx'), value: 'docx' },
94+
{ label: t('ODT'), value: 'odt' },
95+
],
96+
docToBlob,
97+
};
98+
};

src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,13 @@ const ConfirmationLeaveModal = dynamic(
7272
{ ssr: false },
7373
);
7474

75-
const ModalExport =
76-
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
77-
? dynamic(
78-
() =>
79-
import('@/docs/doc-export/components/ModalExport').then((mod) => ({
80-
default: mod.ModalExport,
81-
})),
82-
{ ssr: false },
83-
)
84-
: null;
75+
const ModalExport = dynamic(
76+
() =>
77+
import('@/docs/doc-export/components/ModalExport').then((mod) => ({
78+
default: mod.ModalExport,
79+
})),
80+
{ ssr: false },
81+
);
8582

8683
interface DocToolBoxProps {
8784
doc: Doc;
@@ -167,7 +164,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
167164
callback: () => {
168165
setIsModalExportOpen(true);
169166
},
170-
isHidden: !ModalExport,
171167
},
172168
{
173169
label: t('Copy as {{format}}', { format: 'Markdown' }),
@@ -247,7 +243,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
247243
/>
248244
</DropdownMenu>
249245

250-
{isModalExportOpen && ModalExport && (
246+
{isModalExportOpen && (
251247
<ModalExport
252248
onClose={() => {
253249
setIsModalExportOpen(false);

0 commit comments

Comments
 (0)