Skip to content

Commit a070f56

Browse files
ZouicheOmarAntoLC
authored andcommitted
✨(frontend) add custom callout block to editor
Add a custom block to the editor, the callout block.
1 parent 02478ac commit a070f56

File tree

9 files changed

+246
-3
lines changed

9 files changed

+246
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
## Added
12+
13+
✨ Add a custom callout block to the editor #892
14+
1115
## [3.2.1] - 2025-05-06
1216

1317
## Fixed

src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
452452
const svgBuffer = await cs.toBuffer(await download.createReadStream());
453453
expect(svgBuffer.toString()).toContain('Hello svg');
454454
});
455+
456+
test('it checks if callout custom block', async ({ page, browserName }) => {
457+
await createDoc(page, 'doc-toolbar', browserName, 1);
458+
459+
const editor = page.locator('.ProseMirror');
460+
await editor.click();
461+
await page.locator('.bn-block-outer').last().fill('/');
462+
await page.getByText('Add a callout block').click();
463+
464+
const calloutBlock = page
465+
.locator('div[data-content-type="callout"]')
466+
.first();
467+
468+
await expect(calloutBlock).toBeVisible();
469+
470+
await calloutBlock.locator('.inline-content').fill('example text');
471+
472+
await expect(page.locator('.bn-block').first()).toHaveAttribute(
473+
'data-background-color',
474+
'yellow',
475+
);
476+
477+
const emojiButton = calloutBlock.getByRole('button');
478+
await expect(emojiButton).toHaveText('💡');
479+
await emojiButton.click();
480+
await page.locator('button[aria-label="⚠️"]').click();
481+
await expect(emojiButton).toHaveText('⚠️');
482+
483+
await page.locator('.bn-side-menu > button').last().click();
484+
await page.locator('.mantine-Menu-dropdown > button').last().click();
485+
await page.locator('.bn-color-picker-dropdown > button').last().click();
486+
487+
await expect(page.locator('.bn-block').first()).toHaveAttribute(
488+
'data-background-color',
489+
'pink',
490+
);
491+
});
455492
});

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
2727

2828
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
2929
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
30-
import { DividerBlock } from './custom-blocks';
30+
import { CalloutBlock, DividerBlock } from './custom-blocks';
3131

3232
export const blockNoteSchema = withPageBreak(
3333
BlockNoteSchema.create({
3434
blockSpecs: {
3535
...defaultBlockSpecs,
36+
callout: CalloutBlock,
3637
divider: DividerBlock,
3738
},
3839
}),

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
1111

1212
import { DocsBlockSchema } from '../types';
1313

14-
import { getDividerReactSlashMenuItems } from './custom-blocks';
14+
import {
15+
getCalloutReactSlashMenuItems,
16+
getDividerReactSlashMenuItems,
17+
} from './custom-blocks';
1518

1619
export const BlockNoteSuggestionMenu = () => {
1720
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
2528
combineByGroup(
2629
getDefaultReactSlashMenuItems(editor),
2730
getPageBreakReactSlashMenuItems(editor),
31+
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
2832
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
2933
),
3034
query,

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import {
66
useDictionary,
77
} from '@blocknote/react';
88
import React, { JSX, useCallback, useMemo, useState } from 'react';
9+
import { useTranslation } from 'react-i18next';
910

1011
import { useConfig } from '@/core/config/api';
1112

13+
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
14+
1215
import { AIGroupButton } from './AIButton';
1316
import { FileDownloadButton } from './FileDownloadButton';
1417
import { MarkdownButton } from './MarkdownButton';
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
1821
const dict = useDictionary();
1922
const [confirmOpen, setIsConfirmOpen] = useState(false);
2023
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
24+
const { t } = useTranslation();
2125
const { data: conf } = useConfig();
2226

2327
const toolbarItems = useMemo(() => {
2428
const toolbarItems = getFormattingToolbarItems([
2529
...blockTypeSelectItems(dict),
30+
getCalloutFormattingToolbarItems(t),
2631
]);
2732
const fileDownloadButtonIndex = toolbarItems.findIndex(
2833
(item) =>
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
4651
}
4752

4853
return toolbarItems as JSX.Element[];
49-
}, [dict]);
54+
}, [dict, t]);
5055

5156
const formattingToolbar = useCallback(() => {
5257
return (
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/* eslint-disable react-hooks/rules-of-hooks */
2+
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
3+
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
4+
import { TFunction } from 'i18next';
5+
import React, { useEffect, useState } from 'react';
6+
import { css } from 'styled-components';
7+
8+
import { Box, BoxButton, Icon } from '@/components';
9+
10+
import { DocsBlockNoteEditor } from '../../types';
11+
import { EmojiPicker } from '../EmojiPicker';
12+
13+
const calloutCustom = [
14+
{
15+
name: 'Callout',
16+
id: 'callout',
17+
emojis: [
18+
'bulb',
19+
'point_right',
20+
'point_up',
21+
'ok_hand',
22+
'key',
23+
'construction',
24+
'warning',
25+
'fire',
26+
'pushpin',
27+
'scissors',
28+
'question',
29+
'no_entry',
30+
'no_entry_sign',
31+
'alarm_clock',
32+
'phone',
33+
'rotating_light',
34+
'recycle',
35+
'white_check_mark',
36+
'lock',
37+
'paperclip',
38+
'book',
39+
'speaking_head_in_silhouette',
40+
'arrow_right',
41+
'loudspeaker',
42+
'hammer_and_wrench',
43+
'gear',
44+
],
45+
},
46+
];
47+
48+
const calloutCategories = [
49+
'callout',
50+
'people',
51+
'nature',
52+
'foods',
53+
'activity',
54+
'places',
55+
'flags',
56+
'objects',
57+
'symbols',
58+
];
59+
60+
export const CalloutBlock = createReactBlockSpec(
61+
{
62+
type: 'callout',
63+
propSchema: {
64+
textAlignment: defaultProps.textAlignment,
65+
backgroundColor: defaultProps.backgroundColor,
66+
emoji: { default: '💡' },
67+
},
68+
content: 'inline',
69+
},
70+
{
71+
render: ({ block, editor, contentRef }) => {
72+
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
73+
74+
const toggleEmojiPicker = (e: React.MouseEvent) => {
75+
e.preventDefault();
76+
e.stopPropagation();
77+
setOpenEmojiPicker(!openEmojiPicker);
78+
};
79+
80+
const onClickOutside = () => setOpenEmojiPicker(false);
81+
82+
const onEmojiSelect = ({ native }: { native: string }) => {
83+
editor.updateBlock(block, { props: { emoji: native } });
84+
setOpenEmojiPicker(false);
85+
};
86+
87+
// Temporary: sets a yellow background color to a callout block when added by
88+
// the user, while keeping the colors menu on the drag handler usable for
89+
// this custom block.
90+
useEffect(() => {
91+
if (
92+
!block.content.length &&
93+
block.props.backgroundColor === 'default'
94+
) {
95+
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
96+
}
97+
}, [block, editor]);
98+
99+
return (
100+
<Box
101+
$padding="1rem"
102+
$gap="0.625rem"
103+
style={{
104+
flexGrow: 1,
105+
flexDirection: 'row',
106+
}}
107+
>
108+
<BoxButton
109+
contentEditable={false}
110+
onClick={toggleEmojiPicker}
111+
$css={css`
112+
font-size: 1.125rem;
113+
&:hover {
114+
background-color: rgba(0, 0, 0, 0.1);
115+
}
116+
`}
117+
$align="center"
118+
$height="28px"
119+
$width="28px"
120+
$radius="4px"
121+
>
122+
{block.props.emoji}
123+
</BoxButton>
124+
125+
{openEmojiPicker && (
126+
<EmojiPicker
127+
categories={calloutCategories}
128+
custom={calloutCustom}
129+
onClickOutside={onClickOutside}
130+
onEmojiSelect={onEmojiSelect}
131+
/>
132+
)}
133+
<Box as="p" className="inline-content" ref={contentRef} />
134+
</Box>
135+
);
136+
},
137+
},
138+
);
139+
140+
export const getCalloutReactSlashMenuItems = (
141+
editor: DocsBlockNoteEditor,
142+
t: TFunction<'translation', undefined>,
143+
group: string,
144+
) => [
145+
{
146+
title: t('Callout'),
147+
onItemClick: () => {
148+
insertOrUpdateBlock(editor, {
149+
type: 'callout',
150+
});
151+
},
152+
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
153+
group,
154+
icon: <Icon iconName="lightbulb" $size="18px" />,
155+
subtext: t('Add a callout block'),
156+
},
157+
];
158+
159+
export const getCalloutFormattingToolbarItems = (
160+
t: TFunction<'translation', undefined>,
161+
): BlockTypeSelectItem => ({
162+
name: t('Callout'),
163+
type: 'callout',
164+
icon: () => <Icon iconName="lightbulb" $size="16px" />,
165+
isSelected: (block) => block.type === 'callout',
166+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './CalloutBlock';
12
export * from './DividerBlock';

src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
6161
height: 38px;
6262
}
6363
64+
/**
65+
* Callout, Paragraph and Heading blocks
66+
*/
67+
.bn-block {
68+
border-radius: var(--c--theme--spacings--3xs);
69+
}
70+
71+
.bn-block-outer {
72+
border-radius: var(--c--theme--spacings--3xs);
73+
}
74+
75+
.bn-block-content[data-content-type='paragraph'],
76+
.bn-block-content[data-content-type='heading'] {
77+
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
78+
border-radius: var(--c--theme--spacings--3xs);
79+
}
80+
6481
h1 {
6582
font-size: 1.875rem;
6683
}

src/frontend/apps/impress/src/i18n/translations.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"Accessible to anyone": "Für alle zugänglich",
5454
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
5555
"Add": "Hinzufügen",
56+
"Add a callout block": "Hebt schrift hervor",
5657
"Add a horizontal line": "Waagerechte Linie einfügen",
5758
"Address:": "Anschrift:",
5859
"Administrator": "Administrator",
@@ -67,6 +68,7 @@
6768
"Available soon": "Bald verfügbar",
6869
"Banner image": "Bannerbild",
6970
"Beautify": "Verschönern",
71+
"Callout": "Hervorhebung",
7072
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
7173
"Cancel": "Abbrechen",
7274
"Close the modal": "Pop up schliessen",
@@ -284,6 +286,7 @@
284286
"Accessible to anyone": "Accesible para todos",
285287
"Accessible to authenticated users": "Accesible a usuarios autenticados",
286288
"Add": "Añadir",
289+
"Add a callout block": "Resaltar el texto para que destaque",
287290
"Add a horizontal line": "Añadir una línea horizontal",
288291
"Address:": "Dirección:",
289292
"Administrator": "Administrador",
@@ -298,6 +301,7 @@
298301
"Available soon": "Próximamente disponible",
299302
"Banner image": "Imagen de portada",
300303
"Beautify": "Embellecer",
304+
"Callout": "Destacado",
301305
"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.",
302306
"Cancel": "Cancelar",
303307
"Close the modal": "Cerrar modal",
@@ -508,6 +512,7 @@
508512
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
509513
"Add": "Ajouter",
510514
"Add a horizontal line": "Ajouter une ligne horizontale",
515+
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
511516
"Address:": "Adresse :",
512517
"Administrator": "Administrateur",
513518
"All docs": "Tous les documents",
@@ -521,6 +526,7 @@
521526
"Available soon": "Disponible prochainement",
522527
"Banner image": "Image de la bannière",
523528
"Beautify": "Embellir",
529+
"Callout": "Encadré",
524530
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
525531
"Cancel": "Annuler",
526532
"Close the modal": "Fermer la modale",
@@ -914,6 +920,7 @@
914920
"Accessible to anyone": "Toegankelijk voor iedereen",
915921
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
916922
"Add": "Voeg toe",
923+
"Add a callout block": "Laat je tekst opvallen",
917924
"Add a horizontal line": "Voeg horizontale lijn toe",
918925
"Address:": "Adres:",
919926
"Administrator": "Administrator",
@@ -928,6 +935,7 @@
928935
"Available soon": "Binnenkort beschikbaar",
929936
"Banner image": "Banner afbeelding",
930937
"Beautify": "Maak mooier",
938+
"Callout": "Benadrukken",
931939
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
932940
"Cancel": "Breek af",
933941
"Close the modal": "Sluit het venster",

0 commit comments

Comments
 (0)