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 ( + + + + + + {alertType.icon} + + + + + {t('Alert Type')} + + {alertTypes.map((type) => ( + + {type.icon} + + } + onClick={() => + props.editor.updateBlock(props.block, { + type: 'alert', + props: { type: type.value }, + }) + } + > + {t(type.title)} + + ))} + + + + + ); + }, + }, +); + +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;