(frontend) add custom callout block to editor

Add a custom block to the editor, the callout block.
This commit is contained in:
ZouicheOmar
2025-04-18 17:09:05 +02:00
committed by Anthony LC
parent 02478acb3f
commit a070f56339
9 changed files with 246 additions and 3 deletions

View File

@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
## Added
✨ Add a custom callout block to the editor #892
## [3.2.1] - 2025-05-06
## Fixed

View File

@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
const svgBuffer = await cs.toBuffer(await download.createReadStream());
expect(svgBuffer.toString()).toContain('Hello svg');
});
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')
.first();
await expect(calloutBlock).toBeVisible();
await calloutBlock.locator('.inline-content').fill('example text');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'yellow',
);
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
await emojiButton.click();
await page.locator('button[aria-label="⚠️"]').click();
await expect(emojiButton).toHaveText('⚠️');
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'pink',
);
});
});

View File

@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { DividerBlock } from './custom-blocks';
import { CalloutBlock, DividerBlock } from './custom-blocks';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
},
}),

View File

@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
import { DocsBlockSchema } from '../types';
import { getDividerReactSlashMenuItems } from './custom-blocks';
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
),
query,

View File

@@ -6,9 +6,12 @@ import {
useDictionary,
} from '@blocknote/react';
import React, { JSX, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
import { FileDownloadButton } from './FileDownloadButton';
import { MarkdownButton } from './MarkdownButton';
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
const dict = useDictionary();
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
]);
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) =>
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
}
return toolbarItems as JSX.Element[];
}, [dict]);
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (

View File

@@ -0,0 +1,166 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const toggleEmojiPicker = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
},
);
export const getCalloutReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'callout',
});
},
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
group,
icon: <Icon iconName="lightbulb" $size="18px" />,
subtext: t('Add a callout block'),
},
];
export const getCalloutFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});

View File

@@ -1 +1,2 @@
export * from './CalloutBlock';
export * from './DividerBlock';

View File

@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
height: 38px;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
h1 {
font-size: 1.875rem;
}

View File

@@ -53,6 +53,7 @@
"Accessible to anyone": "Für alle zugänglich",
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
"Add": "Hinzufügen",
"Add a callout block": "Hebt schrift hervor",
"Add a horizontal line": "Waagerechte Linie einfügen",
"Address:": "Anschrift:",
"Administrator": "Administrator",
@@ -67,6 +68,7 @@
"Available soon": "Bald verfügbar",
"Banner image": "Bannerbild",
"Beautify": "Verschönern",
"Callout": "Hervorhebung",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Close the modal": "Pop up schliessen",
@@ -284,6 +286,7 @@
"Accessible to anyone": "Accesible para todos",
"Accessible to authenticated users": "Accesible a usuarios autenticados",
"Add": "Añadir",
"Add a callout block": "Resaltar el texto para que destaque",
"Add a horizontal line": "Añadir una línea horizontal",
"Address:": "Dirección:",
"Administrator": "Administrador",
@@ -298,6 +301,7 @@
"Available soon": "Próximamente disponible",
"Banner image": "Imagen de portada",
"Beautify": "Embellecer",
"Callout": "Destacado",
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
"Cancel": "Cancelar",
"Close the modal": "Cerrar modal",
@@ -508,6 +512,7 @@
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
"Add": "Ajouter",
"Add a horizontal line": "Ajouter une ligne horizontale",
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
@@ -521,6 +526,7 @@
"Available soon": "Disponible prochainement",
"Banner image": "Image de la bannière",
"Beautify": "Embellir",
"Callout": "Encadré",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Close the modal": "Fermer la modale",
@@ -914,6 +920,7 @@
"Accessible to anyone": "Toegankelijk voor iedereen",
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
"Add": "Voeg toe",
"Add a callout block": "Laat je tekst opvallen",
"Add a horizontal line": "Voeg horizontale lijn toe",
"Address:": "Adres:",
"Administrator": "Administrator",
@@ -928,6 +935,7 @@
"Available soon": "Binnenkort beschikbaar",
"Banner image": "Banner afbeelding",
"Beautify": "Maak mooier",
"Callout": "Benadrukken",
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
"Cancel": "Breek af",
"Close the modal": "Sluit het venster",