diff --git a/CHANGELOG.md b/CHANGELOG.md
index a29f4cf81..f8f569ee0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index 476510e12..722bcc298 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -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();
+ });
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
index 326163ddb..3e88178fc 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
@@ -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';
-
-const cssEditor = (readonly: boolean) => css`
- &,
- & > .bn-container,
- & .ProseMirror {
- height: 100%;
-
- .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;
- }
- }
-`;
+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;
@@ -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"
>
+
@@ -225,6 +165,7 @@ export const BlockNoteEditorVersion = ({
},
provider: undefined,
},
+ schema,
},
[initialContent],
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx
new file mode 100644
index 000000000..c84c5a19f
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx
@@ -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 (
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AlertBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AlertBlock.tsx
new file mode 100644
index 000000000..d6ec30ea5
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AlertBlock.tsx
@@ -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 (
+
+
+
+
+ );
+ },
+ },
+);
+
+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: (
+
+ warning
+
+ ),
+ subtext: t('Add a colored alert box'),
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx
new file mode 100644
index 000000000..c92350d63
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DividerBlock.tsx
@@ -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 (
+
+ );
+ },
+ },
+);
+
+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: (
+
+ remove
+
+ ),
+ subtext: t('Add a horizontal line'),
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/QuoteBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/QuoteBlock.tsx
new file mode 100644
index 000000000..5ee5ea47e
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/QuoteBlock.tsx
@@ -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 (
+
+ );
+ },
+ 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: (
+
+ format_quote
+
+ ),
+ subtext: t('Add a quote block'),
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts
new file mode 100644
index 000000000..3deff5c36
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts
@@ -0,0 +1,3 @@
+export * from './AlertBlock';
+export * from './DividerBlock';
+export * from './QuoteBlock';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx
index 9468a7963..1212e15c3 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx
@@ -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(() => {
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx
index 025f2ad8e..1b02d6fd5 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useEditorStore.tsx
@@ -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((set) => ({
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx
index ac9b8a4b3..ff423a940 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useHeadingStore.tsx
@@ -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;
}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.ts
new file mode 100644
index 000000000..7a57bc711
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.ts
@@ -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;
+ }
+ }
+`;
diff --git a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
index 032a44d53..850126031 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
@@ -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;