diff --git a/CHANGELOG.md b/CHANGELOG.md
index f81c5a00..81286ad6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to
## Added
- 💄(frontend) add error pages #643
+- ✨(frontend) Custom block quote with export #646
## Changed
@@ -24,6 +25,7 @@ and this project adheres to
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
+
## [2.2.0] - 2025-02-10
## Added
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 2bce4061..42f3ed90 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
@@ -197,4 +197,49 @@ test.describe('Doc Export', () => {
expect(pdfText).toContain('Hello World');
});
+
+ test('it exports the doc with quotes', async ({ page, browserName }) => {
+ const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
+
+ const downloadPromise = page.waitForEvent('download', (download) => {
+ return download.suggestedFilename().includes(`${randomDoc}.pdf`);
+ });
+
+ const editor = page.locator('.ProseMirror');
+ // Trigger slash menu to show menu
+ await editor.click();
+ await editor.fill('/');
+ await page.getByText('Add a quote block').click();
+
+ await expect(
+ editor.locator('.bn-block-content[data-content-type="quote"]'),
+ ).toBeVisible();
+
+ await editor.fill('Hello World');
+
+ await expect(editor.getByText('Hello World')).toHaveCSS(
+ 'font-style',
+ 'italic',
+ );
+
+ await page
+ .getByRole('button', {
+ name: 'download',
+ })
+ .click();
+
+ await page
+ .getByRole('button', {
+ name: 'Download',
+ })
+ .click();
+
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
+
+ const pdfBuffer = await cs.toBuffer(await download.createReadStream());
+ const pdfData = await pdf(pdfBuffer);
+
+ expect(pdfData.text).toContain('Hello World'); // This is the pdf text
+ });
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts
index 6d59303e..643b57fa 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/index.ts
@@ -1 +1,2 @@
export * from './DocEditor';
+export * from './custom-blocks/';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
index 36f9c79f..a4fd91a2 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
@@ -1,3 +1,5 @@
export * from './headingPDF';
export * from './paragraphPDF';
+export * from './quoteDocx';
+export * from './quotePDF';
export * from './tablePDF';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx
new file mode 100644
index 00000000..bcdaa68c
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx
@@ -0,0 +1,33 @@
+import { Paragraph } from 'docx';
+
+import { DocsExporterDocx } from '../types';
+import { docxBlockPropsToStyles } from '../utils';
+
+export const blockMappingQuoteDocx: DocsExporterDocx['mappings']['blockMapping']['quote'] =
+ (block, exporter) => {
+ if (Array.isArray(block.content)) {
+ block.content.forEach((content) => {
+ if (content.type === 'text') {
+ content.styles = {
+ ...content.styles,
+ italic: true,
+ textColor: 'gray',
+ };
+ }
+ });
+ }
+
+ return new Paragraph({
+ ...docxBlockPropsToStyles(block.props, exporter.options.colors),
+ spacing: { before: 10, after: 10 },
+ border: {
+ left: {
+ color: '#cecece',
+ space: 4,
+ style: 'thick',
+ },
+ },
+ style: 'Normal',
+ children: exporter.transformInlineContent(block.content),
+ });
+ };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quotePDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quotePDF.tsx
new file mode 100644
index 00000000..0fd88d75
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quotePDF.tsx
@@ -0,0 +1,21 @@
+import { Text } from '@react-pdf/renderer';
+
+import { DocsExporterPDF } from '../types';
+
+export const blockMappingQuotePDF: DocsExporterPDF['mappings']['blockMapping']['quote'] =
+ (block, exporter) => {
+ return (
+
+ {exporter.transformInlineContent(block.content)}
+
+ );
+ };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
index 68b90803..c34f28fb 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
@@ -1,10 +1,12 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
+import { blockMappingQuoteDocx } from './blocks-mapping/';
import { DocsExporterDocx } from './types';
export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings,
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
+ quote: blockMappingQuoteDocx,
},
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
index 19c9f204..d4ca9364 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
@@ -3,6 +3,7 @@ import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
blockMappingHeadingPDF,
blockMappingParagraphPDF,
+ blockMappingQuotePDF,
blockMappingTablePDF,
} from './blocks-mapping';
import { DocsExporterPDF } from './types';
@@ -13,6 +14,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
...pdfDefaultSchemaMappings.blockMapping,
heading: blockMappingHeadingPDF,
paragraph: blockMappingParagraphPDF,
+ quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
},
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
index d4a6165d..54a15a25 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
@@ -1,3 +1,10 @@
+import {
+ COLORS_DEFAULT,
+ DefaultProps,
+ UnreachableCaseError,
+} from '@blocknote/core';
+import { IParagraphOptions, ShadingType } from 'docx';
+
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -30,3 +37,39 @@ export const exportResolveFileUrl = async (
return url;
};
+
+export function docxBlockPropsToStyles(
+ props: Partial,
+ colors: typeof COLORS_DEFAULT,
+): IParagraphOptions {
+ return {
+ shading:
+ props.backgroundColor === 'default' || !props.backgroundColor
+ ? undefined
+ : {
+ type: ShadingType.SOLID,
+ color:
+ colors[
+ props.backgroundColor as keyof typeof colors
+ ].background.slice(1),
+ },
+ run:
+ props.textColor === 'default' || !props.textColor
+ ? undefined
+ : {
+ color: colors[props.textColor as keyof typeof colors].text.slice(1),
+ },
+ alignment:
+ !props.textAlignment || props.textAlignment === 'left'
+ ? undefined
+ : props.textAlignment === 'center'
+ ? 'center'
+ : props.textAlignment === 'right'
+ ? 'right'
+ : props.textAlignment === 'justify'
+ ? 'distribute'
+ : (() => {
+ throw new UnreachableCaseError(props.textAlignment);
+ })(),
+ };
+}