✨(frontend) add custom callout block to editor
Add a custom block to the editor, the callout block.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user