Compare commits

...

2 Commits

Author SHA1 Message Date
Anthony LC
0815af4977 🚚(frontend) move Blocknote styles
Move Blocknote styles to separate file.
2025-02-04 15:51:28 +01:00
Arnaud Robin
bf0b1591b3 (frontend) add Alert, Quote, and Divider blocks to the editor
- Introduced new Alert, Quote, and Divider components for enhanced document editing.
- Updated BlockNoteEditor to include these new block types in the schema.
- Implemented corresponding slash menu items for easy insertion of these blocks.
- Added translations for new block types and their descriptions in the i18n files.
2025-02-04 15:49:49 +01:00
13 changed files with 555 additions and 95 deletions

View File

@@ -11,6 +11,8 @@ and this project adheres to
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add Alert, Quote, and Divider blocks to the editor #566
## [2.1.0] - 2025-01-29

View File

@@ -368,4 +368,84 @@ 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();
});
});

View File

@@ -1,4 +1,10 @@
import { Dictionary, locales } from '@blocknote/core';
import {
BlockNoteEditor as BlockNoteEditorCore,
BlockNoteSchema,
Dictionary,
defaultBlockSpecs,
locales,
} from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -6,7 +12,6 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -17,95 +22,27 @@ import { useUploadFile } from '../hook';
import { useHeadings } from '../hook/useHeadings';
import useSaveDoc from '../hook/useSaveDoc';
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';
const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
export const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
alert: AlertBlock,
quote: QuoteBlock,
divider: DividerBlock,
},
});
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;
export type DocsBlockNoteEditor = BlockNoteEditorCore<
typeof schema.blockSchema,
typeof schema.inlineContentSchema,
typeof schema.styleSchema
>;
interface BlockNoteEditorProps {
doc: Doc;
@@ -164,6 +101,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
schema,
uploadFile,
},
[collabName, lang, provider, uploadFile],
@@ -198,8 +136,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
editable={!readOnly}
slashMenu={false}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
@@ -225,6 +165,7 @@ export const BlockNoteEditorVersion = ({
},
provider: undefined,
},
schema,
},
[initialContent],
);

View File

@@ -0,0 +1,39 @@
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}
/>
);
};

View File

@@ -0,0 +1,179 @@
/* 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'),
});

View File

@@ -0,0 +1,55 @@
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'),
});

View File

@@ -0,0 +1,63 @@
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'),
});

View File

@@ -0,0 +1,3 @@
export * from './AlertBlock';
export * from './DividerBlock';
export * from './QuoteBlock';

View File

@@ -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: BlockNoteEditor) => {
export const useHeadings = (editor: DocsBlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {

View File

@@ -1,9 +1,10 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
editor?: DocsBlockNoteEditor;
setEditor: (editor: DocsBlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({

View File

@@ -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: BlockNoteEditor) => void;
setHeadings: (editor: DocsBlockNoteEditor) => void;
resetHeadings: () => void;
}

View File

@@ -0,0 +1,96 @@
import { css } from 'styled-components';
export const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .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;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;

View File

@@ -1,10 +1,11 @@
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',
@@ -17,7 +18,7 @@ export type HeadingsHighlight = {
}[];
interface HeadingProps {
editor: BlockNoteEditor;
editor: DocsBlockNoteEditor;
level: number;
text: string;
headingId: string;