✨(frontend) enable ODT export for documents
provides ODT export with support for callout, upload, interlinking and tests Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) add image and interlinking support for odt export Added image mapping with SVG conversion and clickable document links. Signed-off-by: Cyril <c.gromoff@gmail.com> ✅(e2e) add e2e tests for odt export and interlinking features covers odt document export and cross-section interlinking use cases Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(odt) add generic helper and style callout block for odt export create odtRegisterParagraphStyleForBlock and apply background/padding styles Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -6,6 +6,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) enable ODT export for documents #1524
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- ♿(frontend) improve accessibility:
|
- ♿(frontend) improve accessibility:
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test.describe('Doc Export', () => {
|
|||||||
|
|
||||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Download your document in a .docx or .pdf format.'),
|
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('combobox', { name: 'Template' }),
|
page.getByRole('combobox', { name: 'Template' }),
|
||||||
@@ -142,6 +142,51 @@ test.describe('Doc Export', () => {
|
|||||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
|
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it exports the doc to odt', async ({ page, browserName }) => {
|
||||||
|
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
|
||||||
|
|
||||||
|
await verifyDocName(page, randomDoc);
|
||||||
|
|
||||||
|
await page.locator('.ProseMirror.bn-editor').click();
|
||||||
|
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.locator('.bn-block-outer').last().fill('/');
|
||||||
|
await page.getByText('Resizable image with caption').click();
|
||||||
|
|
||||||
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||||
|
await page.getByText('Upload image').click();
|
||||||
|
|
||||||
|
const fileChooser = await fileChooserPromise;
|
||||||
|
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||||
|
|
||||||
|
const image = page
|
||||||
|
.locator('.--docs--editor-container img.bn-visual-media')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Export the document',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Odt' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||||
|
return download.suggestedFilename().includes(`${randomDoc}.odt`);
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.getByTestId('doc-export-download-button').click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This test tell us that the export to pdf is working with images
|
* This test tell us that the export to pdf is working with images
|
||||||
* but it does not tell us if the images are being displayed correctly
|
* but it does not tell us if the images are being displayed correctly
|
||||||
@@ -442,4 +487,68 @@ test.describe('Doc Export', () => {
|
|||||||
const pdfText = await pdfParse.getText();
|
const pdfText = await pdfParse.getText();
|
||||||
expect(pdfText.text).toContain(randomDoc);
|
expect(pdfText.text).toContain(randomDoc);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it exports the doc with interlinking to odt', async ({
|
||||||
|
page,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
const [randomDoc] = await createDoc(
|
||||||
|
page,
|
||||||
|
'export-interlinking-odt',
|
||||||
|
browserName,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifyDocName(page, randomDoc);
|
||||||
|
|
||||||
|
const { name: docChild } = await createRootSubPage(
|
||||||
|
page,
|
||||||
|
browserName,
|
||||||
|
'export-interlink-child-odt',
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifyDocName(page, docChild);
|
||||||
|
|
||||||
|
await page.locator('.bn-block-outer').last().fill('/');
|
||||||
|
await page.getByText('Link a doc').first().click();
|
||||||
|
|
||||||
|
const input = page.locator(
|
||||||
|
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||||
|
);
|
||||||
|
const searchContainer = page.locator('.quick-search-container');
|
||||||
|
|
||||||
|
await input.fill('export-interlink');
|
||||||
|
|
||||||
|
await expect(searchContainer).toBeVisible();
|
||||||
|
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
|
||||||
|
|
||||||
|
// We are in docChild, we want to create a link to randomDoc (parent)
|
||||||
|
await searchContainer.getByText(randomDoc).click();
|
||||||
|
|
||||||
|
// Search the interlinking link in the editor (not in the document tree)
|
||||||
|
const editor = page.locator('.ProseMirror.bn-editor');
|
||||||
|
const interlink = editor.getByRole('button', {
|
||||||
|
name: randomDoc,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(interlink).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Export the document',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Odt' }).click();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||||
|
return download.suggestedFilename().includes(`${docChild}.odt`);
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.getByTestId('doc-export-download-button').click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@blocknote/react": "0.41.1",
|
"@blocknote/react": "0.41.1",
|
||||||
"@blocknote/xl-docx-exporter": "0.41.1",
|
"@blocknote/xl-docx-exporter": "0.41.1",
|
||||||
"@blocknote/xl-multi-column": "0.41.1",
|
"@blocknote/xl-multi-column": "0.41.1",
|
||||||
|
"@blocknote/xl-odt-exporter": "0.41.1",
|
||||||
"@blocknote/xl-pdf-exporter": "0.41.1",
|
"@blocknote/xl-pdf-exporter": "0.41.1",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/modifiers": "9.0.0",
|
"@dnd-kit/modifiers": "9.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DocsExporterODT } from '../types';
|
||||||
|
import { odtRegisterParagraphStyleForBlock } from '../utils';
|
||||||
|
|
||||||
|
export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
|
||||||
|
(block, exporter) => {
|
||||||
|
// Map callout to paragraph with emoji prefix
|
||||||
|
const emoji = block.props.emoji || '💡';
|
||||||
|
|
||||||
|
// Transform inline content (text, bold, links, etc.)
|
||||||
|
const inlineContent = exporter.transformInlineContent(block.content);
|
||||||
|
|
||||||
|
// Resolve background and alignment → create a dedicated paragraph style
|
||||||
|
const styleName = odtRegisterParagraphStyleForBlock(
|
||||||
|
exporter,
|
||||||
|
{
|
||||||
|
backgroundColor: block.props.backgroundColor,
|
||||||
|
textAlignment: block.props.textAlignment,
|
||||||
|
},
|
||||||
|
{ paddingCm: 0.42 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
'text:p',
|
||||||
|
{
|
||||||
|
'text:style-name': styleName,
|
||||||
|
},
|
||||||
|
`${emoji} `,
|
||||||
|
...inlineContent,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DocsExporterODT } from '../types';
|
||||||
|
import { convertSvgToPng, odtRegisterParagraphStyleForBlock } from '../utils';
|
||||||
|
|
||||||
|
const MAX_WIDTH = 600;
|
||||||
|
|
||||||
|
export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
|
||||||
|
async (block, exporter) => {
|
||||||
|
try {
|
||||||
|
const blob = await exporter.resolveFile(block.props.url);
|
||||||
|
|
||||||
|
if (!blob || !blob.type) {
|
||||||
|
console.warn(`Failed to resolve image: ${block.props.url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pngConverted: string | undefined;
|
||||||
|
let dimensions: { width: number; height: number } | undefined;
|
||||||
|
let previewWidth = block.props.previewWidth || undefined;
|
||||||
|
|
||||||
|
if (!blob.type.includes('image')) {
|
||||||
|
console.warn(`Not an image type: ${blob.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blob.type.includes('svg')) {
|
||||||
|
const svgText = await blob.text();
|
||||||
|
const FALLBACK_SIZE = 536;
|
||||||
|
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
|
||||||
|
pngConverted = await convertSvgToPng(svgText, previewWidth);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = pngConverted;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
img.onload = () => {
|
||||||
|
dimensions = { width: img.width, height: img.height };
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dimensions = await getImageDimensions(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dimensions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = dimensions;
|
||||||
|
|
||||||
|
if (previewWidth && previewWidth > MAX_WIDTH) {
|
||||||
|
previewWidth = MAX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert image to base64 for ODT embedding
|
||||||
|
const arrayBuffer = pngConverted
|
||||||
|
? await (await fetch(pngConverted)).arrayBuffer()
|
||||||
|
: await blob.arrayBuffer();
|
||||||
|
const base64 = btoa(
|
||||||
|
Array.from(new Uint8Array(arrayBuffer))
|
||||||
|
.map((byte) => String.fromCharCode(byte))
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalWidth = previewWidth || width;
|
||||||
|
const finalHeight = ((previewWidth || width) / width) * height;
|
||||||
|
|
||||||
|
const baseParagraphProps = {
|
||||||
|
backgroundColor: block.props.backgroundColor,
|
||||||
|
textAlignment: block.props.textAlignment,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paragraphStyleName = odtRegisterParagraphStyleForBlock(
|
||||||
|
exporter,
|
||||||
|
baseParagraphProps,
|
||||||
|
{ paddingCm: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert pixels to cm (ODT uses cm for dimensions)
|
||||||
|
const widthCm = finalWidth / 37.795275591;
|
||||||
|
const heightCm = finalHeight / 37.795275591;
|
||||||
|
|
||||||
|
// Create ODT image structure using React.createElement
|
||||||
|
const frame = React.createElement(
|
||||||
|
'text:p',
|
||||||
|
{
|
||||||
|
'text:style-name': paragraphStyleName,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
'draw:frame',
|
||||||
|
{
|
||||||
|
'draw:name': `Image${Date.now()}`,
|
||||||
|
'text:anchor-type': 'as-char',
|
||||||
|
'svg:width': `${widthCm}cm`,
|
||||||
|
'svg:height': `${heightCm}cm`,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
'draw:image',
|
||||||
|
{
|
||||||
|
xlinkType: 'simple',
|
||||||
|
xlinkShow: 'embed',
|
||||||
|
xlinkActuate: 'onLoad',
|
||||||
|
},
|
||||||
|
React.createElement('office:binary-data', {}, base64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add caption if present
|
||||||
|
if (block.props.caption) {
|
||||||
|
const captionStyleName = odtRegisterParagraphStyleForBlock(
|
||||||
|
exporter,
|
||||||
|
baseParagraphProps,
|
||||||
|
{ paddingCm: 0, parentStyleName: 'Caption' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
frame,
|
||||||
|
React.createElement(
|
||||||
|
'text:p',
|
||||||
|
{ 'text:style-name': captionStyleName },
|
||||||
|
block.props.caption,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing image for ODT export:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getImageDimensions(blob: Blob) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const bmp = await createImageBitmap(blob);
|
||||||
|
const { width, height } = bmp;
|
||||||
|
bmp.close();
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
export * from './calloutDocx';
|
export * from './calloutDocx';
|
||||||
|
export * from './calloutODT';
|
||||||
export * from './calloutPDF';
|
export * from './calloutPDF';
|
||||||
export * from './headingPDF';
|
export * from './headingPDF';
|
||||||
export * from './imageDocx';
|
export * from './imageDocx';
|
||||||
|
export * from './imageODT';
|
||||||
export * from './imagePDF';
|
export * from './imagePDF';
|
||||||
export * from './paragraphPDF';
|
export * from './paragraphPDF';
|
||||||
export * from './quoteDocx';
|
export * from './quoteDocx';
|
||||||
export * from './quotePDF';
|
export * from './quotePDF';
|
||||||
export * from './tablePDF';
|
export * from './tablePDF';
|
||||||
export * from './uploadLoaderPDF';
|
|
||||||
export * from './uploadLoaderDocx';
|
export * from './uploadLoaderDocx';
|
||||||
|
export * from './uploadLoaderODT';
|
||||||
|
export * from './uploadLoaderPDF';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DocsExporterODT } from '../types';
|
||||||
|
|
||||||
|
export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
|
||||||
|
(block) => {
|
||||||
|
// Map uploadLoader to paragraph with information text
|
||||||
|
const information = block.props.information || '';
|
||||||
|
const type = block.props.type || 'loading';
|
||||||
|
const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
'text:p',
|
||||||
|
{ 'text:style-name': 'Text_20_body' },
|
||||||
|
`${prefix}${information}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
|
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
|
||||||
|
import { ODTExporter } from '@blocknote/xl-odt-exporter';
|
||||||
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
|
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -23,12 +24,14 @@ import { Doc, useTrans } from '@/docs/doc-management';
|
|||||||
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
|
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
|
||||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||||
import { docxDocsSchemaMappings } from '../mappingDocx';
|
import { docxDocsSchemaMappings } from '../mappingDocx';
|
||||||
|
import { odtDocsSchemaMappings } from '../mappingODT';
|
||||||
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
||||||
import { downloadFile } from '../utils';
|
import { downloadFile } from '../utils';
|
||||||
|
|
||||||
enum DocDownloadFormat {
|
enum DocDownloadFormat {
|
||||||
PDF = 'pdf',
|
PDF = 'pdf',
|
||||||
DOCX = 'docx',
|
DOCX = 'docx',
|
||||||
|
ODT = 'odt',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalExportProps {
|
interface ModalExportProps {
|
||||||
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
: rawPdfDocument;
|
: rawPdfDocument;
|
||||||
|
|
||||||
blobExport = await pdf(pdfDocument).toBlob();
|
blobExport = await pdf(pdfDocument).toBlob();
|
||||||
} else {
|
} else if (format === DocDownloadFormat.DOCX) {
|
||||||
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
|
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
|
||||||
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
|
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
|
||||||
});
|
});
|
||||||
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
documentOptions: { title: documentTitle },
|
documentOptions: { title: documentTitle },
|
||||||
sectionOptions: {},
|
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 {
|
||||||
|
toast(t('The export failed'), VariantType.ERROR);
|
||||||
|
setIsExporting(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(blobExport, `${filename}.${format}`);
|
downloadFile(blobExport, `${filename}.${format}`);
|
||||||
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
className="--docs--modal-export-content"
|
className="--docs--modal-export-content"
|
||||||
>
|
>
|
||||||
<Text $variation="600" $size="sm" as="p">
|
<Text $variation="600" $size="sm" as="p">
|
||||||
{t('Download your document in a .docx or .pdf format.')}
|
{t('Download your document in a .docx, .odt or .pdf format.')}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
clearable={false}
|
clearable={false}
|
||||||
@@ -231,6 +244,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
label={t('Format')}
|
label={t('Format')}
|
||||||
options={[
|
options={[
|
||||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||||
|
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||||
]}
|
]}
|
||||||
value={format}
|
value={format}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './interlinkingLinkPDF';
|
export * from './interlinkingLinkPDF';
|
||||||
export * from './interlinkingLinkDocx';
|
export * from './interlinkingLinkDocx';
|
||||||
|
export * from './interlinkingLinkODT';
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DocsExporterODT } from '../types';
|
||||||
|
|
||||||
|
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||||
|
(inline) => {
|
||||||
|
const url = window.location.origin + inline.props.url;
|
||||||
|
const title = inline.props.title;
|
||||||
|
|
||||||
|
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
|
||||||
|
// Uses the same structure as BlockNote's default link mapping
|
||||||
|
return React.createElement(
|
||||||
|
'text:a',
|
||||||
|
{
|
||||||
|
xlinkType: 'simple',
|
||||||
|
'text:style-name': 'Internet_20_link',
|
||||||
|
'office:target-frame-name': '_top',
|
||||||
|
xlinkShow: 'replace',
|
||||||
|
xlinkHref: url,
|
||||||
|
},
|
||||||
|
`📄${title}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { odtDefaultSchemaMappings } from '@blocknote/xl-odt-exporter';
|
||||||
|
|
||||||
|
import {
|
||||||
|
blockMappingCalloutODT,
|
||||||
|
blockMappingImageODT,
|
||||||
|
blockMappingUploadLoaderODT,
|
||||||
|
} from './blocks-mapping';
|
||||||
|
import { inlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
|
||||||
|
import { DocsExporterODT } from './types';
|
||||||
|
|
||||||
|
// Align default inline mappings to our editor inline schema without using `any`
|
||||||
|
const baseInlineMappings =
|
||||||
|
odtDefaultSchemaMappings.inlineContentMapping as unknown as DocsExporterODT['mappings']['inlineContentMapping'];
|
||||||
|
|
||||||
|
export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
|
||||||
|
...odtDefaultSchemaMappings,
|
||||||
|
blockMapping: {
|
||||||
|
...odtDefaultSchemaMappings.blockMapping,
|
||||||
|
callout: blockMappingCalloutODT,
|
||||||
|
image: blockMappingImageODT,
|
||||||
|
// We're reusing the file block mapping for PDF blocks
|
||||||
|
// The types don't match exactly but the implementation is compatible
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
|
||||||
|
uploadLoader: blockMappingUploadLoaderODT,
|
||||||
|
},
|
||||||
|
|
||||||
|
inlineContentMapping: {
|
||||||
|
...baseInlineMappings,
|
||||||
|
interlinkingSearchInline: () => null,
|
||||||
|
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
|
||||||
|
},
|
||||||
|
styleMapping: {
|
||||||
|
...odtDefaultSchemaMappings.styleMapping,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -51,3 +51,13 @@ export type DocsExporterDocx = Exporter<
|
|||||||
IRunPropertiesOptions,
|
IRunPropertiesOptions,
|
||||||
TextRun
|
TextRun
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type DocsExporterODT = Exporter<
|
||||||
|
DocsBlockSchema,
|
||||||
|
DocsInlineContentSchema,
|
||||||
|
DocsStyleSchema,
|
||||||
|
React.ReactNode,
|
||||||
|
React.ReactNode,
|
||||||
|
Record<string, string>,
|
||||||
|
React.ReactNode
|
||||||
|
>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@blocknote/core';
|
} from '@blocknote/core';
|
||||||
import { Canvg } from 'canvg';
|
import { Canvg } from 'canvg';
|
||||||
import { IParagraphOptions, ShadingType } from 'docx';
|
import { IParagraphOptions, ShadingType } from 'docx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
export function downloadFile(blob: Blob, filename: string) {
|
export function downloadFile(blob: Blob, filename: string) {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
@@ -98,3 +99,76 @@ export function docxBlockPropsToStyles(
|
|||||||
})(),
|
})(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ODT helpers
|
||||||
|
type OdtExporterLike = {
|
||||||
|
options?: { colors?: typeof COLORS_DEFAULT };
|
||||||
|
registerStyle: (fn: (name: string) => React.ReactNode) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOdtExporterLike(value: unknown): value is OdtExporterLike {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof (value as { registerStyle?: unknown }).registerStyle === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function odtRegisterParagraphStyleForBlock(
|
||||||
|
exporter: unknown,
|
||||||
|
props: Partial<DefaultProps>,
|
||||||
|
options?: { paddingCm?: number; parentStyleName?: string },
|
||||||
|
) {
|
||||||
|
if (!isOdtExporterLike(exporter)) {
|
||||||
|
throw new Error('Invalid ODT exporter: missing registerStyle');
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = exporter.options?.colors;
|
||||||
|
|
||||||
|
const bgColorHex =
|
||||||
|
props.backgroundColor && props.backgroundColor !== 'default' && colors
|
||||||
|
? colors[props.backgroundColor].background
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const textColorHex =
|
||||||
|
props.textColor && props.textColor !== 'default' && colors
|
||||||
|
? colors[props.textColor].text
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const foTextAlign =
|
||||||
|
!props.textAlignment || props.textAlignment === 'left'
|
||||||
|
? 'start'
|
||||||
|
: props.textAlignment === 'center'
|
||||||
|
? 'center'
|
||||||
|
: props.textAlignment === 'right'
|
||||||
|
? 'end'
|
||||||
|
: 'justify';
|
||||||
|
|
||||||
|
const paddingCm = options?.paddingCm ?? 0.42; // ~1rem (16px)
|
||||||
|
const parentStyleName = options?.parentStyleName;
|
||||||
|
|
||||||
|
// registerStyle is available on ODT exporter; call through with React elements
|
||||||
|
const styleName = exporter.registerStyle((name: string) =>
|
||||||
|
React.createElement(
|
||||||
|
'style:style',
|
||||||
|
{
|
||||||
|
'style:name': name,
|
||||||
|
'style:family': 'paragraph',
|
||||||
|
...(parentStyleName
|
||||||
|
? { 'style:parent-style-name': parentStyleName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
React.createElement('style:paragraph-properties', {
|
||||||
|
'fo:text-align': foTextAlign,
|
||||||
|
'fo:padding': `${paddingCm}cm`,
|
||||||
|
...(bgColorHex ? { 'fo:background-color': bgColorHex } : {}),
|
||||||
|
}),
|
||||||
|
textColorHex
|
||||||
|
? React.createElement('style:text-properties', {
|
||||||
|
'fo:color': textColorHex,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return styleName;
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe('DocToolBox - Licence', () => {
|
|||||||
await userEvent.click(optionsButton);
|
await userEvent.click(optionsButton);
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
'Download your document in a .docx or .pdf format.',
|
'Download your document in a .docx, .odt or .pdf format.',
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Pellgargañ",
|
"Download": "Pellgargañ",
|
||||||
"Download anyway": "Pellgargañ memestra",
|
"Download anyway": "Pellgargañ memestra",
|
||||||
"Download your document in a .docx or .pdf format.": "Pellgargañ ho restr dindan ur stumm .docx pe .pdf.",
|
"Download your document in a .docx, .odt or .pdf format.": "Pellgargañ ho restr dindan ur stumm .docx, .odt pe .pdf.",
|
||||||
"Duplicate": "Eilañ",
|
"Duplicate": "Eilañ",
|
||||||
"Editing": "Oc'h aozañ",
|
"Editing": "Oc'h aozañ",
|
||||||
"Editor": "Embanner",
|
"Editor": "Embanner",
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Herunterladen",
|
"Download": "Herunterladen",
|
||||||
"Download anyway": "Trotzdem herunterladen",
|
"Download anyway": "Trotzdem herunterladen",
|
||||||
"Download your document in a .docx or .pdf format.": "Ihr Dokument als .docx- oder .pdf-Datei herunterladen.",
|
"Download your document in a .docx, .odt or .pdf format.": "Ihr Dokument als .docx-, .odt- oder .pdf-Datei herunterladen.",
|
||||||
"Duplicate": "Duplizieren",
|
"Duplicate": "Duplizieren",
|
||||||
"Editing": "Bearbeiten",
|
"Editing": "Bearbeiten",
|
||||||
"Editor": "Mitbearbeiter",
|
"Editor": "Mitbearbeiter",
|
||||||
@@ -494,7 +494,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Descargar",
|
"Download": "Descargar",
|
||||||
"Download anyway": "Descargar de todos modos",
|
"Download anyway": "Descargar de todos modos",
|
||||||
"Download your document in a .docx or .pdf format.": "Descargue su documento en formato .docx o .pdf.",
|
"Download your document in a .docx, .odt or .pdf format.": "Descargue su documento en formato .docx, .odt o .pdf.",
|
||||||
"Editor": "Editor",
|
"Editor": "Editor",
|
||||||
"Editor unavailable": "Editor no disponible",
|
"Editor unavailable": "Editor no disponible",
|
||||||
"Emojify": "Emojizar",
|
"Emojify": "Emojizar",
|
||||||
@@ -698,7 +698,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Télécharger",
|
"Download": "Télécharger",
|
||||||
"Download anyway": "Télécharger malgré tout",
|
"Download anyway": "Télécharger malgré tout",
|
||||||
"Download your document in a .docx or .pdf format.": "Téléchargez votre document au format .docx ou .pdf.",
|
"Download your document in a .docx, .odt or .pdf format.": "Téléchargez votre document au format .docx, .odt ou .pdf.",
|
||||||
"Drag and drop status": "État du glisser-déposer",
|
"Drag and drop status": "État du glisser-déposer",
|
||||||
"Duplicate": "Dupliquer",
|
"Duplicate": "Dupliquer",
|
||||||
"Edit document emoji": "Modifier l'émoticône du document",
|
"Edit document emoji": "Modifier l'émoticône du document",
|
||||||
@@ -930,7 +930,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Scarica",
|
"Download": "Scarica",
|
||||||
"Download anyway": "Scarica comunque",
|
"Download anyway": "Scarica comunque",
|
||||||
"Download your document in a .docx or .pdf format.": "Scarica il tuo documento in formato .docx o .pdf",
|
"Download your document in a .docx, .odt or .pdf format.": "Scarica il tuo documento in formato .docx, .odt o .pdf",
|
||||||
"Editor": "Editor",
|
"Editor": "Editor",
|
||||||
"Editor unavailable": "Editor non disponibile",
|
"Editor unavailable": "Editor non disponibile",
|
||||||
"Emojify": "Emojify",
|
"Emojify": "Emojify",
|
||||||
@@ -1117,7 +1117,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Download",
|
"Download": "Download",
|
||||||
"Download anyway": "Download alsnog",
|
"Download anyway": "Download alsnog",
|
||||||
"Download your document in a .docx or .pdf format.": "Download jouw document in .docx of .pdf formaat.",
|
"Download your document in a .docx, .odt or .pdf format.": "Download jouw document in .docx, .odt of .pdf formaat.",
|
||||||
"Drag and drop status": "Drag & drop status",
|
"Drag and drop status": "Drag & drop status",
|
||||||
"Duplicate": "Dupliceer",
|
"Duplicate": "Dupliceer",
|
||||||
"Edit document emoji": "Bewerk document emoji",
|
"Edit document emoji": "Bewerk document emoji",
|
||||||
@@ -1394,7 +1394,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Загрузить",
|
"Download": "Загрузить",
|
||||||
"Download anyway": "Всё равно загрузить",
|
"Download anyway": "Всё равно загрузить",
|
||||||
"Download your document in a .docx or .pdf format.": "Загрузите свой документ в формате .docx или .pdf.",
|
"Download your document in a .docx, .odt or .pdf format.": "Загрузите свой документ в формате .docx, .odt или .pdf.",
|
||||||
"Drag and drop status": "Состояние перетаскивания",
|
"Drag and drop status": "Состояние перетаскивания",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
"Editing": "Редактирование",
|
"Editing": "Редактирование",
|
||||||
@@ -1642,7 +1642,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "İndir",
|
"Download": "İndir",
|
||||||
"Download anyway": "Yine de indir",
|
"Download anyway": "Yine de indir",
|
||||||
"Download your document in a .docx or .pdf format.": "Belgenizi .docx veya .pdf formatında indirin.",
|
"Download your document in a .docx, .odt or .pdf format.": "Belgenizi .docx, .odt veya .pdf formatında indirin.",
|
||||||
"Editor": "Editör",
|
"Editor": "Editör",
|
||||||
"Editor unavailable": "Editör mevcut değil",
|
"Editor unavailable": "Editör mevcut değil",
|
||||||
"Emojify": "Emojileştir",
|
"Emojify": "Emojileştir",
|
||||||
@@ -1774,7 +1774,7 @@
|
|||||||
"Docx": "Docx",
|
"Docx": "Docx",
|
||||||
"Download": "Завантажити",
|
"Download": "Завантажити",
|
||||||
"Download anyway": "Все одно завантажити",
|
"Download anyway": "Все одно завантажити",
|
||||||
"Download your document in a .docx or .pdf format.": "Завантажте ваш документ у форматі .docx або .pdf.",
|
"Download your document in a .docx, .odt or .pdf format.": "Завантажте ваш документ у форматі .docx, .odt або .pdf.",
|
||||||
"Drag and drop status": "Стан перетягування",
|
"Drag and drop status": "Стан перетягування",
|
||||||
"Duplicate": "Дублювати",
|
"Duplicate": "Дублювати",
|
||||||
"Editing": "Редагування",
|
"Editing": "Редагування",
|
||||||
@@ -2018,7 +2018,7 @@
|
|||||||
"Docx": "Doc",
|
"Docx": "Doc",
|
||||||
"Download": "下载",
|
"Download": "下载",
|
||||||
"Download anyway": "仍要下载",
|
"Download anyway": "仍要下载",
|
||||||
"Download your document in a .docx or .pdf format.": "以doc或者pdf格式下载。",
|
"Download your document in a .docx, .odt or .pdf format.": "以doc, odt或者pdf格式下载。",
|
||||||
"Editing": "正在编辑",
|
"Editing": "正在编辑",
|
||||||
"Editor": "编辑者",
|
"Editor": "编辑者",
|
||||||
"Editor unavailable": "编辑功能不可用",
|
"Editor unavailable": "编辑功能不可用",
|
||||||
|
|||||||
@@ -1192,6 +1192,17 @@
|
|||||||
prosemirror-view "^1.41.2"
|
prosemirror-view "^1.41.2"
|
||||||
react-icons "^5.2.1"
|
react-icons "^5.2.1"
|
||||||
|
|
||||||
|
"@blocknote/xl-odt-exporter@0.41.1":
|
||||||
|
version "0.41.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@blocknote/xl-odt-exporter/-/xl-odt-exporter-0.41.1.tgz#55be888d7b6158a6e352aab414cee12e1dcf4326"
|
||||||
|
integrity sha512-VAQC8isRoioK097yuFX0p6dIrwp/GyWInd4hDkux3gsGTMqdXRiLV42symC6+qEseukz+IbGqWvWGsgAplwkZQ==
|
||||||
|
dependencies:
|
||||||
|
"@blocknote/core" "0.41.1"
|
||||||
|
"@blocknote/xl-multi-column" "0.41.1"
|
||||||
|
"@zip.js/zip.js" "^2.7.57"
|
||||||
|
buffer "^6.0.3"
|
||||||
|
image-meta "^0.2.1"
|
||||||
|
|
||||||
"@blocknote/xl-pdf-exporter@0.41.1":
|
"@blocknote/xl-pdf-exporter@0.41.1":
|
||||||
version "0.41.1"
|
version "0.41.1"
|
||||||
resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.41.1.tgz#b55c7e8c6a21ae069a42671b1391eab9d4119195"
|
resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.41.1.tgz#b55c7e8c6a21ae069a42671b1391eab9d4119195"
|
||||||
@@ -6253,6 +6264,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
||||||
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
|
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
|
||||||
|
|
||||||
|
"@zip.js/zip.js@^2.7.57":
|
||||||
|
version "2.8.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.8.10.tgz#98a0cc7fdef9d6e227236271af412db02b18a5b2"
|
||||||
|
integrity sha512-WVywWK8HSttmFFYSih7lUjjaV4zGzMxy992y0tHrZY4Wf9x/uNBA/XJ50RvfGjuuJKti4yueEHA2ol2pOq6VDg==
|
||||||
|
|
||||||
abs-svg-path@^0.1.1:
|
abs-svg-path@^0.1.1:
|
||||||
version "0.1.1"
|
version "0.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf"
|
resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf"
|
||||||
|
|||||||
Reference in New Issue
Block a user