Compare commits

..

2 Commits

Author SHA1 Message Date
Anthony LC 0e7edb9239 🚚(frontend) move Blocknote styles
Move Blocknote styles to separate file.
2025-02-05 15:51:04 +01:00
Anthony LC 48cb2587be 💄(frontend) improve styles export pdf
When exporting a document to PDF, the headings
spacings were too small, the break lines were
not displayed. This commit fixes these issues
by extending PDFExporter.
2025-02-05 15:51:04 +01:00
16 changed files with 126 additions and 484 deletions
+5 -1
View File
@@ -10,8 +10,12 @@ and this project adheres to
## [Unreleased]
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add Alert, Quote, and Divider blocks to the editor #566
## Fixed
- 💄improve export spacings PDF #613
## [2.1.0] - 2025-01-29
@@ -368,84 +368,4 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
test('it checks the divider block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Divider', { exact: true }).click();
await expect(
editor.locator('.bn-block-content[data-content-type="divider"]'),
).toBeVisible();
});
test('it checks the quote block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Quote', { exact: true }).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',
);
});
test('it checks the alert block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Alert', { exact: true }).click();
const alertBlock = editor.locator(
'.bn-block-content[data-content-type="alert"]',
);
await expect(
alertBlock.locator('div[data-alert-type="warning"]'),
).toBeVisible();
await editor.fill('My alert');
await expect(alertBlock.getByText('My alert')).toBeVisible();
await alertBlock.getByText('warning').click();
await expect(
alertBlock.getByRole('menuitem', { name: 'warning Warning' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'error Error' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'info Info' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'check_circle Success' }),
).toBeVisible();
await alertBlock
.getByRole('menuitem', { name: 'check_circle Success' })
.click();
await expect(
alertBlock.locator('div[data-alert-type="success"]'),
).toBeVisible();
});
});
@@ -1,10 +1,4 @@
import {
BlockNoteEditor as BlockNoteEditorCore,
BlockNoteSchema,
Dictionary,
defaultBlockSpecs,
locales,
} from '@blocknote/core';
import { Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -25,24 +19,7 @@ import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { AlertBlock, DividerBlock, QuoteBlock } from './custom-blocks';
export const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
alert: AlertBlock,
quote: QuoteBlock,
divider: DividerBlock,
},
});
export type DocsBlockNoteEditor = BlockNoteEditorCore<
typeof schema.blockSchema,
typeof schema.inlineContentSchema,
typeof schema.styleSchema
>;
interface BlockNoteEditorProps {
doc: Doc;
@@ -101,7 +78,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
schema,
uploadFile,
},
[collabName, lang, provider, uploadFile],
@@ -136,10 +112,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
editable={!readOnly}
slashMenu={false}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
@@ -165,7 +139,6 @@ export const BlockNoteEditorVersion = ({
},
provider: undefined,
},
schema,
},
[initialContent],
);
@@ -1,39 +0,0 @@
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
import '@blocknote/mantine/style.css';
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
useBlockNoteEditor,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockNoteEditor } from './BlockNoteEditor';
import { insertAlert, insertDivider, insertQuote } from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const { t } = useTranslation();
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
Promise.resolve(
filterSuggestionItems(
combineByGroup(
getDefaultReactSlashMenuItems(editor),
[insertAlert(editor, t)],
[insertQuote(editor, t)],
[insertDivider(editor, t)],
),
query,
),
);
}, [editor, t]);
return (
<SuggestionMenuController
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
);
};
@@ -1,179 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { Menu } from '@mantine/core';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
// The types of alerts that users can choose from.
export const alertTypes = [
{
title: 'Warning',
value: 'warning',
icon: 'warning',
color: 'warning-500',
backgroundColor: 'warning-300',
},
{
title: 'Error',
value: 'danger',
icon: 'error',
color: 'danger-500',
backgroundColor: 'danger-300',
},
{
title: 'Info',
value: 'info',
icon: 'info',
color: 'info-500',
backgroundColor: 'info-300',
},
{
title: 'Success',
value: 'success',
icon: 'check_circle',
color: 'success-500',
backgroundColor: 'success-100',
},
] as const;
// The Alert block.
export const AlertBlock = createReactBlockSpec(
{
type: 'alert',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
type: {
default: 'warning',
values: ['warning', 'danger', 'info', 'success'],
},
},
content: 'inline',
},
{
render: (props) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
let alertType = alertTypes.find(
(a) => a.value === props.block.props.type,
);
if (!alertType) {
alertType = alertTypes[0];
}
return (
<Box
className="alert"
data-alert-type={props.block.props.type}
$direction="row"
$justify="center"
$align="center"
$radius="4px"
$padding="4px"
$background={colorsTokens()[alertType.backgroundColor]}
$minHeight="48px"
$css={css`
flex-grow: 1;
`}
>
<Menu withinPortal={false}>
<Menu.Target>
<Box
className="alert-icon-wrapper"
$margin={{ horizontal: '12px' }}
$radius="16px"
$justify="center"
$align="center"
$height="24px"
$width="24px"
contentEditable={false}
$css="user-select: none; cursor: pointer;"
>
<Text
$isMaterialIcon
$theme={alertType.value}
$variation="500"
$size="20px"
>
{alertType.icon}
</Text>
</Box>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: 9999 }}>
<Menu.Label>{t('Alert Type')}</Menu.Label>
<Menu.Divider />
{alertTypes.map((type) => (
<Menu.Item
key={type.value}
leftSection={
<Text
$isMaterialIcon
$color={colorsTokens()[type.color]}
$size="16px"
>
{type.icon}
</Text>
}
onClick={() =>
props.editor.updateBlock(props.block, {
type: 'alert',
props: { type: type.value },
})
}
>
{t(type.title)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<Box
className="inline-content"
$css={css`
flex-grow: 1;
& * {
color: ${colorsTokens()[alertType.color]};
}
`}
ref={props.contentRef}
/>
</Box>
);
},
},
);
export const insertAlert = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Alert'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'alert',
});
},
aliases: [
'alert',
'notification',
'emphasize',
'warning',
'error',
'info',
'success',
],
group: t('Others'),
icon: (
<Text $isMaterialIcon $size="18px">
warning
</Text>
),
subtext: t('Add a colored alert box'),
});
@@ -1,55 +0,0 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
export const DividerBlock = createReactBlockSpec(
{
type: 'divider',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
},
content: 'none',
},
{
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<div
style={{
width: '100%',
height: '2px',
backgroundColor: colorsTokens()['greyscale-300'],
margin: '1rem 0',
}}
/>
);
},
},
);
export const insertDivider = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Divider'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'divider',
});
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group: t('Others'),
icon: (
<span className="material-icons" style={{ fontSize: '18px' }}>
remove
</span>
),
subtext: t('Add a horizontal line'),
});
@@ -1,63 +0,0 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
export const QuoteBlock = createReactBlockSpec(
{
type: 'quote',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
},
content: 'inline',
},
{
render: (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<div
className="inline-content"
style={{
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
margin: '0 0 1rem 0',
padding: '0.5rem 1rem',
color: colorsTokens()['greyscale-600'],
fontStyle: 'italic',
flexGrow: 1,
}}
ref={props.contentRef}
/>
);
},
parse: () => {
return undefined;
},
},
);
export const insertQuote = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Quote'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'quote',
});
},
aliases: ['quote', 'blockquote', 'citation'],
group: t('Others'),
icon: (
<span className="material-icons" style={{ fontSize: '18px' }}>
format_quote
</span>
),
subtext: t('Add a quote block'),
});
@@ -1,3 +0,0 @@
export * from './AlertBlock';
export * from './DividerBlock';
export * from './QuoteBlock';
@@ -1,9 +1,9 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useEffect } from 'react';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
import { useHeadingStore } from '../stores';
export const useHeadings = (editor: DocsBlockNoteEditor) => {
export const useHeadings = (editor: BlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
@@ -1,10 +1,9 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
export interface UseEditorstore {
editor?: DocsBlockNoteEditor;
setEditor: (editor: DocsBlockNoteEditor | undefined) => void;
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
@@ -1,6 +1,6 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
import { HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
@@ -21,7 +21,7 @@ const recursiveTextContent = (content: HeadingBlock['content']): string => {
export interface UseHeadingStore {
headings: HeadingBlock[];
setHeadings: (editor: DocsBlockNoteEditor) => void;
setHeadings: (editor: BlockNoteEditor) => void;
resetHeadings: () => void;
}
@@ -6,15 +6,6 @@ export const cssEditor = (readonly: boolean) => css`
& .ProseMirror {
height: 100%;
.bn-side-menu[data-block-type='alert'] {
height: 55px;
}
.bn-side-menu[data-block-type='divider'] {
height: 40px;
}
.bn-side-menu[data-block-type='quote'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
@@ -1,3 +1,5 @@
import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
export interface DocAttachment {
file: string;
}
@@ -12,3 +14,19 @@ export type HeadingBlock = {
level: number;
};
};
export const blockNoteInstance = BlockNoteSchema.create();
export type DocsBlockSchema = typeof blockNoteInstance.blockSchema;
export type DocsInlineContentSchema =
typeof blockNoteInstance.inlineContentSchema;
export type DocsStyleSchema = typeof blockNoteInstance.styleSchema;
export type DocsBlockNoteEditor = BlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>;
export type DocsBlockNoteSchema = BlockNoteSchema<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>;
@@ -2,10 +2,6 @@ import {
DOCXExporter,
docxDefaultSchemaMappings,
} from '@blocknote/xl-docx-exporter';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -25,6 +21,7 @@ import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { DocsPDFExporter } from '../libs/DocsPDFExporter';
import { downloadFile, exportResolveFileUrl } from '../utils';
enum DocDownloadFormat {
@@ -91,19 +88,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
let blobExport: Blob;
if (format === DocDownloadFormat.PDF) {
const defaultExporter = new PDFExporter(
editor.schema,
pdfDefaultSchemaMappings,
);
const defaultExporter = new DocsPDFExporter(editor.schema);
const exporter = new DocsPDFExporter(editor.schema, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
const exporter = new PDFExporter(
editor.schema,
pdfDefaultSchemaMappings,
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
},
);
const pdfDocument = await exporter.toReactPDFDocument(exportDocument);
blobExport = await pdf(pdfDocument).toBlob();
} else {
@@ -0,0 +1,87 @@
import { Block, DefaultProps, ExporterOptions } from '@blocknote/core';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import { Font } from '@react-pdf/renderer';
import {
DocsBlockNoteSchema,
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '@/features/docs/doc-editor';
type Options = ExporterOptions & {
emojiSource: false | ReturnType<typeof Font.getEmojiSource>;
};
type DocsDefaultProps = DefaultProps & {
level?: number;
};
export class DocsPDFExporter extends PDFExporter<
DocsBlockSchema,
DocsStyleSchema,
DocsInlineContentSchema
> {
constructor(
protected readonly schemaMappings: DocsBlockNoteSchema,
options?: Partial<Options>,
) {
super(schemaMappings, pdfDefaultSchemaMappings, options);
}
/**
* Breaklines are not displayed in PDFs, by adding a space we ensure that the line is not ignored
* @param blocks
* @param nestingLevel
* @returns
*/
public transformBlocks(
blocks: Block<DocsBlockSchema, DocsInlineContentSchema, DocsStyleSchema>[], // Or BlockFromConfig<B[keyof B], I, S>?
nestingLevel?: number,
) {
blocks.forEach((block) => {
if (Array.isArray(block.content)) {
block.content.forEach((content) => {
if (content.type === 'text' && !content.text) {
content.text = ' ';
}
});
if (!block.content.length) {
block.content.push({
styles: {},
text: ' ',
type: 'text',
});
}
}
});
return super.transformBlocks(blocks, nestingLevel);
}
/**
* Override the method to add our custom styles
* @param props
* @returns
*/
public blocknoteDefaultPropsToReactPDFStyle(
props: Partial<DocsDefaultProps>,
) {
let styles = super.blocknoteDefaultPropsToReactPDFStyle(props);
// Add margin to headings
if (props.level) {
styles = {
marginTop: 15,
marginBottom: 15,
...styles,
};
}
return styles;
}
}
@@ -1,11 +1,10 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useState } from 'react';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { DocsBlockNoteEditor } from '../../doc-editor/components/BlockNoteEditor';
const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem',
2: '0.9rem',
@@ -18,7 +17,7 @@ export type HeadingsHighlight = {
}[];
interface HeadingProps {
editor: DocsBlockNoteEditor;
editor: BlockNoteEditor;
level: number;
text: string;
headingId: string;