Repository: suitenumerique/docs
Pull request #892: ✨ Add a custom callout block to the editor
Author: login: ZouicheOmar
Labels: name: frontend
name: ✅ Design approved
description: The PO has approved the design: developers can start implementation
Created: 2025-04-18T16:35:43Z | Merged: 2025-05-12T08:19:41Z

## Purpose
This PR addresses issue #305. Add a new custom block to the editor, the callout block.

## Proposal
- Introduced a custom emoji picker component, using the `emoji-mart` library, which is already used by blocknote. That's to have custom triggers and use of the component.
- Introduced a callout component in the editor that uses the custom emoji picker.
- Updated BlockNoteEditor to include this new block.
- Updated BlockNoteSuggestionMenu and  BlockNoteToolbar to include easy insertion of the callout block.
- Adapted ModalExport to include both PDF and Docx exports for the document containing the new block. 
- Updated translations to include the name and the sentences displayed in the slash menus for the callout.

## Demo
[callout.webm](https://github.com/user-attachments/assets/b92f4294-f17c-43ab-b002-506aeea025cc)
 
## PDF and Docx Exports
![callout_pdf_docx](https://github.com/user-attachments/assets/e623814f-b284-4f3b-bf24-ea2a597d0139)

Would love any feedback if this needs improvements, corrections or any changes.



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0fb0b0dd9..92de297c5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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 09f0cde2e8..d1a8d85679 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
@@ -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',
+    );
+  });
 });
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index d567f5af1e..d9ecfdece4 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -22,6 +22,8 @@
     "@blocknote/react": "0.29.1",
     "@blocknote/xl-docx-exporter": "0.29.1",
     "@blocknote/xl-pdf-exporter": "0.29.1",
+    "@emoji-mart/data": "1.2.1",
+    "@emoji-mart/react": "1.1.1",
     "@fontsource/material-icons": "5.2.5",
     "@gouvfr-lasuite/integration": "1.0.3",
     "@gouvfr-lasuite/ui-kit": "0.4.1",
@@ -35,6 +37,7 @@
     "cmdk": "1.1.1",
     "crisp-sdk-web": "1.0.25",
     "docx": "9.4.1",
+    "emoji-mart": "5.6.0",
     "i18next": "25.1.1",
     "i18next-browser-languagedetector": "8.1.0",
     "idb": "8.0.2",
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 1f4018190a..a927578cf3 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
@@ -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,
     },
   }),
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
index 4c90ba5fd1..3122b1c175 100644
--- 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
@@ -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,
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx
index a250741f80..d59a09ab2e 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx
@@ -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 (
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx
new file mode 100644
index 0000000000..f2ff859baf
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx
@@ -0,0 +1,43 @@
+import data from '@emoji-mart/data';
+import Picker from '@emoji-mart/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box } from '@/components';
+
+interface EmojiPickerProps {
+  categories: string[];
+  custom: {
+    name: string;
+    id: string;
+    emojis: string[];
+  }[];
+  onClickOutside: () => void;
+  onEmojiSelect: ({ native }: { native: string }) => void;
+}
+
+export const EmojiPicker = ({
+  categories,
+  custom,
+  onClickOutside,
+  onEmojiSelect,
+}: EmojiPickerProps) => {
+  const { i18n } = useTranslation();
+
+  return (
+    <Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
+      <Picker
+        categories={categories}
+        custom={custom}
+        data={data}
+        locale={i18n.resolvedLanguage}
+        navPosition="none"
+        onClickOutside={onClickOutside}
+        onEmojiSelect={onEmojiSelect}
+        previewPosition="none"
+        skinTonePosition="none"
+        theme="light"
+      />
+    </Box>
+  );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
new file mode 100644
index 0000000000..bbb0850c1d
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
@@ -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',
+});
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
index 70bc3faef6..34a8c459cb 100644
--- 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
@@ -1 +1,2 @@
+export * from './CalloutBlock';
 export * from './DividerBlock';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx
index 8a96c86bc8..da02458e07 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx
@@ -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;
     }
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutDocx.tsx
new file mode 100644
index 0000000000..779b7c48f1
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutDocx.tsx
@@ -0,0 +1,24 @@
+import { Paragraph, TextRun } from 'docx';
+
+import { DocsExporterDocx } from '../types';
+import { docxBlockPropsToStyles } from '../utils';
+
+export const blockMappingCalloutDocx: DocsExporterDocx['mappings']['blockMapping']['callout'] =
+  (block, exporter) => {
+    return new Paragraph({
+      ...docxBlockPropsToStyles(block.props, exporter.options.colors),
+      spacing: { before: 10, after: 10 },
+      children: [
+        new TextRun({
+          text: ' ',
+          break: 1,
+        }),
+        new TextRun('   ' + block.props.emoji + ' '),
+        ...exporter.transformInlineContent(block.content),
+        new TextRun({
+          text: ' ',
+          break: 1,
+        }),
+      ],
+    });
+  };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutPDF.tsx
new file mode 100644
index 0000000000..e4deb23592
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutPDF.tsx
@@ -0,0 +1,35 @@
+import { StyleSheet, Text, View } from '@react-pdf/renderer';
+
+import { DocsExporterPDF } from '../types';
+
+const styles = StyleSheet.create({
+  wrapper: {
+    width: '100%',
+    display: 'flex',
+    flexDirection: 'row',
+    padding: 8,
+    gap: 4,
+  },
+  emoji: {
+    fontSize: 16,
+  },
+  text: {
+    maxWidth: '94%',
+    paddingTop: 2,
+  },
+});
+
+export const blockMappingCalloutPDF: DocsExporterPDF['mappings']['blockMapping']['callout'] =
+  (block, exporter) => {
+    return (
+      <View wrap={false} style={styles.wrapper}>
+        <Text debug={false} style={styles.emoji}>
+          {block.props.emoji}
+        </Text>
+        <Text debug={false} style={styles.text}>
+          {' '}
+          {exporter.transformInlineContent(block.content)}{' '}
+        </Text>
+      </View>
+    );
+  };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
index 5cfbb48fd2..e3e766dbcb 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
@@ -1,3 +1,5 @@
+export * from './calloutDocx';
+export * from './calloutPDF';
 export * from './dividerDocx';
 export * from './dividerPDF';
 export * from './headingPDF';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
index 4494469248..434daa995b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx
@@ -1,6 +1,7 @@
 import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
 
 import {
+  blockMappingCalloutDocx,
   blockMappingDividerDocx,
   blockMappingImageDocx,
   blockMappingQuoteDocx,
@@ -11,6 +12,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
   ...docxDefaultSchemaMappings,
   blockMapping: {
     ...docxDefaultSchemaMappings.blockMapping,
+    callout: blockMappingCalloutDocx,
     divider: blockMappingDividerDocx,
     quote: blockMappingQuoteDocx,
     image: blockMappingImageDocx,
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
index 380e2f1aff..4224045d15 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx
@@ -1,6 +1,7 @@
 import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
 
 import {
+  blockMappingCalloutPDF,
   blockMappingDividerPDF,
   blockMappingHeadingPDF,
   blockMappingImagePDF,
@@ -14,6 +15,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
   ...pdfDefaultSchemaMappings,
   blockMapping: {
     ...pdfDefaultSchemaMappings.blockMapping,
+    callout: blockMappingCalloutPDF,
     heading: blockMappingHeadingPDF,
     image: blockMappingImagePDF,
     paragraph: blockMappingParagraphPDF,
diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json
index 72b981bd53..fb906dea0d 100644
--- a/src/frontend/apps/impress/src/i18n/translations.json
+++ b/src/frontend/apps/impress/src/i18n/translations.json
@@ -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",
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index fef745e6b7..2cb36297ba 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -1238,11 +1238,16 @@
   dependencies:
     tslib "^2.4.0"
 
-"@emoji-mart/data@^1.2.1":
+"@emoji-mart/data@1.2.1", "@emoji-mart/data@^1.2.1":
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
   integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
 
+"@emoji-mart/react@1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
+  integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
+
 "@emotion/babel-plugin@^11.13.5":
   version "11.13.5"
   resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0"
@@ -7462,7 +7467,7 @@ emittery@^0.13.1:
   resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
   integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
 
-emoji-mart@^5.6.0:
+emoji-mart@5.6.0, emoji-mart@^5.6.0:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
   integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==


Reviews:
user: login: virgile-dev
body: 

Hey @ZouicheOmar this looks awesome ! Thanks a lot for your contribution.
Just one feedback, one thing I noticed in your screen capture is that by default the call out block has a white background.
How about we give it yellow background by default that people can change if they want to.
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-05-02T14:42:36Z
user: login: AntoLC
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T11:57:54Z
user: login: AntoLC
body: Thank you a lot for the contribution.

I can see you still have some linting issues. 
I use the extension https://marketplace.visualstudio.com/items/?itemName=dbaeumer.vscode-eslint to get informations about them.

Your last 3 commits seems more fixing your previous commits, consider using the `fixup` commits instead of a new ones.

We are adding tests using Playwright, do you know it ? 
If yes maybe you can add test here: https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts 

Otherwise, no problem, I will add tests after your changes.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T13:08:08Z
user: login: AntoLC
body: Thank you a lot for your contribution ! 🎉 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T08:17:29Z

Review comments:
diff_hunk: @@ -38,5 +38,6 @@
     "react-dom": "19.1.0",
     "typescript": "5.8.3",
     "yjs": "13.6.26"
-  }
+  },
+  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
path: src/frontend/package.json
user: login: AntoLC
body: We will not use this part for the moment.
created_at: 2025-05-05T11:57:53Z
author_association: COLLABORATOR
diff_hunk: @@ -22,6 +22,7 @@
     "@blocknote/react": "0.29.1",
     "@blocknote/xl-docx-exporter": "0.29.1",
     "@blocknote/xl-pdf-exporter": "0.29.1",
+    "@emoji-mart/data": "^1.2.1",
path: src/frontend/apps/impress/package.json
user: login: AntoLC
body: Better to pin the dependency, we are using `renovate` to bump them.

```suggestion
    "@emoji-mart/data": "1.2.1",
```
created_at: 2025-05-05T11:58:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import {
+  defaultProps,
+  insertOrUpdateBlock,
+  CustomBlockConfig,
+  DefaultInlineContentSchema,
+  DefaultStyleSchema,
+  PartialBlock,
+} from '@blocknote/core';
+import {
+  BlockTypeSelectItem,
+  ReactCustomBlockImplementation,
+  ReactCustomBlockRenderProps,
+  createReactBlockSpec,
+  useBlockNoteEditor,
+  useEditorSelectionChange,
path: src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
user: login: AntoLC
body: You don't seem to use it.
created_at: 2025-05-05T12:16:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import {
+  defaultProps,
+  insertOrUpdateBlock,
+  CustomBlockConfig,
+  DefaultInlineContentSchema,
+  DefaultStyleSchema,
+  PartialBlock,
path: src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
user: login: AntoLC
body: You don't seem to use it.
created_at: 2025-05-05T12:16:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import {
+  defaultProps,
+  insertOrUpdateBlock,
+  CustomBlockConfig,
+  DefaultInlineContentSchema,
+  DefaultStyleSchema,
+  PartialBlock,
+} from '@blocknote/core';
+import {
+  BlockTypeSelectItem,
+  ReactCustomBlockImplementation,
+  ReactCustomBlockRenderProps,
+  createReactBlockSpec,
+  useBlockNoteEditor,
+  useEditorSelectionChange,
+} from '@blocknote/react';
+import { TFunction } from 'i18next';
+import React, { useEffect, useState } from 'react';
+
+import {
+  Box,
+  Emoji,
+  EmojiDataCategory,
+  EmojiPicker,
+  Icon,
+  Text,
+} from '@/components';
+
+import { DocsBlockNoteEditor } from '../../types';
+
+const calloutEmojisCategory: EmojiDataCategory = {
+  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',
+  ],
+} as const;
+
+const calloutBlockConfig = {
+  type: 'callout',
+  propSchema: {
+    textAlignment: defaultProps.textAlignment,
+    backgroundColor: defaultProps.backgroundColor,
+    emoji: { default: '💡' },
+  },
+  content: 'inline',
+} satisfies CustomBlockConfig;
+
+const useCalloutBlock = () => {
+  const editor = useBlockNoteEditor();
+  const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
+
+  const toggleEmojiPicker = (e: React.MouseEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    setOpenEmojiPicker(!openEmojiPicker);
+  };
+
+  const handleClickOutside = () => setOpenEmojiPicker(false);
+
+  const handleEmojiSelect = (emoji: Emoji) => {
+    const { block } = editor.getTextCursorPosition();
+    editor.updateBlock(block, { props: { emoji: emoji.native } } as any);
+    setOpenEmojiPicker(false);
+  };
+
+  return {
+    openEmojiPicker,
+    toggleEmojiPicker,
+    handleClickOutside,
+    handleEmojiSelect,
+  };
+};
+
+const CalloutBlockElement = ({
+  block,
+  editor,
+  contentRef,
+}: ReactCustomBlockRenderProps<
+  typeof calloutBlockConfig,
+  DefaultInlineContentSchema,
+  DefaultStyleSchema
+>) => {
+  const {
+    openEmojiPicker,
+    toggleEmojiPicker,
+    handleClickOutside,
+    handleEmojiSelect,
+  } = useCalloutBlock();
+
+  // 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') {
+      console.log('in if statement');
path: src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
user: login: AntoLC
body: 
```suggestion
```
created_at: 2025-05-05T12:17:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import {
+  defaultProps,
+  insertOrUpdateBlock,
+  CustomBlockConfig,
+  DefaultInlineContentSchema,
+  DefaultStyleSchema,
+  PartialBlock,
+} from '@blocknote/core';
+import {
+  BlockTypeSelectItem,
+  ReactCustomBlockImplementation,
+  ReactCustomBlockRenderProps,
+  createReactBlockSpec,
+  useBlockNoteEditor,
+  useEditorSelectionChange,
+} from '@blocknote/react';
+import { TFunction } from 'i18next';
+import React, { useEffect, useState } from 'react';
+
+import {
+  Box,
+  Emoji,
+  EmojiDataCategory,
+  EmojiPicker,
+  Icon,
+  Text,
+} from '@/components';
+
+import { DocsBlockNoteEditor } from '../../types';
+
+const calloutEmojisCategory: EmojiDataCategory = {
+  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',
+  ],
+} as const;
+
+const calloutBlockConfig = {
+  type: 'callout',
+  propSchema: {
+    textAlignment: defaultProps.textAlignment,
+    backgroundColor: defaultProps.backgroundColor,
+    emoji: { default: '💡' },
+  },
+  content: 'inline',
+} satisfies CustomBlockConfig;
+
+const useCalloutBlock = () => {
+  const editor = useBlockNoteEditor();
path: src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx
user: login: AntoLC
body: ```suggestion
    const editor = useBlockNoteEditor<
      DocsBlockSchema,
      DocsInlineContentSchema,
      DocsStyleSchema
    >();
```

By typing the editor, you will not have to cast to "any" inside `updateBlock`.


created_at: 2025-05-05T12:37:15Z
author_association: COLLABORATOR

PR comments:
user: login: robin-lecomte
created_at: 2025-04-29T13:15:30Z
body: Great job!

Just small UI considerations here.
I had suggested these specific spacings and a 4px rounding (xxxs) on the callout ([see it on Figma here](https://www.figma.com/design/qdCWR4tTUr7vQSecEjCyqO/DOCS?node-id=7053-15181&t=PSKs1xpjuFTclrvX-1) + [Issue 305](https://github.com/suitenumerique/docs/issues/305)).

![CleanShot 2025-04-29 at 15 12 19@2x](https://github.com/user-attachments/assets/def7aac5-af43-4213-841f-204e7d53d831)
author_association: COLLABORATOR
user: login: ZouicheOmar
created_at: 2025-04-30T13:58:40Z
body: Hey thanks for your feedback

Made some changes on the callout.
![docs_screenshot_003](https://github.com/user-attachments/assets/3b3a6fb6-5603-4a6b-850a-47a14e0fafb1)
To keep block highlighting consistent, I added paddings and margins on the heading and paragraph blocks, also following the figma file. Can revert this one back if needed. 

And the emoji's background color transition on hover
[docs_emoji_hover.webm](https://github.com/user-attachments/assets/64178f48-6288-4676-addd-2a4c43d62718)
author_association: CONTRIBUTOR
user: login: virgile-dev
created_at: 2025-05-02T14:47:11Z
body: @ZouicheOmar 
I hope you're planning to come to the Hack Days, so we can build more stuff on Docs ! 
https://hackdays.numerique.gouv.fr/
author_association: COLLABORATOR
user: login: ZouicheOmar
created_at: 2025-05-03T12:04:03Z
body: Now the default bg for a callout is yellow.
#### Demo
[callout_default_bg.webm](https://github.com/user-attachments/assets/3bd518ac-e7a2-41fd-b64f-a61c3fb83ef5)

Solution is not quite elegant, would be cleaner with a custom side menu, or a change on blocknotejs. Will keep an eye on it.

@virgile-dev I am definitely coming to the hack days ! Just waiting for my invite to be checked and I'll be all set.
author_association: CONTRIBUTOR
user: login: ZouicheOmar
created_at: 2025-05-06T16:29:31Z
body: @AntoLC Thank you very much for your suggestions !

I applied fixes according to your comments, plus other minor ones. 
On [doc-editor.spec.ts](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts), I added tests covering rendering with defaults (yellow background and "💡') their updates according to user changes, along with the emoji picker. 
author_association: CONTRIBUTOR
user: login: virgile-dev
created_at: 2025-06-11T16:34:55Z
body:  @ZouicheOmar we noticed with @rl-83 3 little things that need fixing 
- the emoji is not centered with the back ground 
![image](https://github.com/user-attachments/assets/fde8c95c-1489-4515-b6d9-3a1ec7de513a)
- the padding is not the same up and down 
![image](https://github.com/user-attachments/assets/c244f610-db01-4b11-84ed-f405b12b66f3)
- when make :arrow_backward: your cursor gets above the callout box haha
[Capture vidéo du 11-06-2025 19:02:56.webm](https://github.com/user-attachments/assets/6a23657c-2d9b-47c1-a85e-e475874dcf0f)

Do you think you could make a PR to fix it?
Thanks a ton !
author_association: COLLABORATOR
user: login: ZouicheOmar
created_at: 2025-06-17T15:51:37Z
body: Hey @virgile-dev, sorry for such a late response
Okey I can defo work on this !
What would be the desired behavior on pressing the left arrow key ?
author_association: CONTRIBUTOR
user: login: virgile-dev
created_at: 2025-06-20T09:01:44Z
body: @ZouicheOmar 
It should go straight to the block that's above.
Is that clear ?
Thanks !
author_association: COLLABORATOR
user: login: ZouicheOmar
created_at: 2025-07-10T14:53:27Z
body: Alright should be okey now
#### current
https://github.com/user-attachments/assets/1dd12041-3518-4274-ab6c-22e0e098f325


When looking for the left arrow behavior, found that the cursor not only goes above the callout box,
but also between callout boxes (if contiguous), and even under ahaha. 
It can get triggered by all the arrow keys.

I don't know if you found the same @virgile-dev, or even other unexpected behavior ?

btw happens on the example blocknote custom block too.
#### from the blocknote docs
https://github.com/user-attachments/assets/b97ecdff-89ae-4da2-82aa-42a7c3e027f3
author_association: CONTRIBUTOR
user: login: virgile-dev
created_at: 2025-07-16T07:07:52Z
body: @ZouicheOmar no didn't notice! Good catch!
If it's happening on BlockNote as well we should send them a fix so everyone benefit from it.
Do you feel up to it?
Hey @YousefED are you aware of this weird arrow behaviour? 
author_association: COLLABORATOR
user: login: YousefED
created_at: 2025-07-21T14:32:30Z
body: This is what's called the "gapcursor" in prosemirror: https://github.com/ProseMirror/prosemirror-gapcursor

it allows you to insert content between two blocks. I think it's quite useful, of course we can consider disabling it or making it toggleable
author_association: COLLABORATOR
user: login: ZouicheOmar
created_at: 2025-07-23T11:21:33Z
body: Right, by just disabling the "gapCursor" extension on the editor, cursor positions works fine. Which is 1 LOC instead of my PR's ~370 :sob: ...
Which is a valid solution as there are no blocks that use the GapCursor extension.
@YousefED by toggleable do you mean on a single block type or on the editor instance ?
author_association: CONTRIBUTOR
user: login: virgile-dev
created_at: 2025-07-25T10:18:19Z
body: Thanks @ZouicheOmar 
Really nice you could do the fix ! 
Sorry about these 370 LOC :cry:  
author_association: COLLABORATOR