✨(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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
✨ Add a custom callout block to the editor #892
|
||||||
|
|
||||||
## [3.2.1] - 2025-05-06
|
## [3.2.1] - 2025-05-06
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|||||||
@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
|
|||||||
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
||||||
expect(svgBuffer.toString()).toContain('Hello svg');
|
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 { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||||
import { DividerBlock } from './custom-blocks';
|
import { CalloutBlock, DividerBlock } from './custom-blocks';
|
||||||
|
|
||||||
export const blockNoteSchema = withPageBreak(
|
export const blockNoteSchema = withPageBreak(
|
||||||
BlockNoteSchema.create({
|
BlockNoteSchema.create({
|
||||||
blockSpecs: {
|
blockSpecs: {
|
||||||
...defaultBlockSpecs,
|
...defaultBlockSpecs,
|
||||||
|
callout: CalloutBlock,
|
||||||
divider: DividerBlock,
|
divider: DividerBlock,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { DocsBlockSchema } from '../types';
|
import { DocsBlockSchema } from '../types';
|
||||||
|
|
||||||
import { getDividerReactSlashMenuItems } from './custom-blocks';
|
import {
|
||||||
|
getCalloutReactSlashMenuItems,
|
||||||
|
getDividerReactSlashMenuItems,
|
||||||
|
} from './custom-blocks';
|
||||||
|
|
||||||
export const BlockNoteSuggestionMenu = () => {
|
export const BlockNoteSuggestionMenu = () => {
|
||||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||||
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
|
|||||||
combineByGroup(
|
combineByGroup(
|
||||||
getDefaultReactSlashMenuItems(editor),
|
getDefaultReactSlashMenuItems(editor),
|
||||||
getPageBreakReactSlashMenuItems(editor),
|
getPageBreakReactSlashMenuItems(editor),
|
||||||
|
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||||
),
|
),
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
useDictionary,
|
useDictionary,
|
||||||
} from '@blocknote/react';
|
} from '@blocknote/react';
|
||||||
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useConfig } from '@/core/config/api';
|
import { useConfig } from '@/core/config/api';
|
||||||
|
|
||||||
|
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
|
||||||
|
|
||||||
import { AIGroupButton } from './AIButton';
|
import { AIGroupButton } from './AIButton';
|
||||||
import { FileDownloadButton } from './FileDownloadButton';
|
import { FileDownloadButton } from './FileDownloadButton';
|
||||||
import { MarkdownButton } from './MarkdownButton';
|
import { MarkdownButton } from './MarkdownButton';
|
||||||
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
|
|||||||
const dict = useDictionary();
|
const dict = useDictionary();
|
||||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: conf } = useConfig();
|
const { data: conf } = useConfig();
|
||||||
|
|
||||||
const toolbarItems = useMemo(() => {
|
const toolbarItems = useMemo(() => {
|
||||||
const toolbarItems = getFormattingToolbarItems([
|
const toolbarItems = getFormattingToolbarItems([
|
||||||
...blockTypeSelectItems(dict),
|
...blockTypeSelectItems(dict),
|
||||||
|
getCalloutFormattingToolbarItems(t),
|
||||||
]);
|
]);
|
||||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return toolbarItems as JSX.Element[];
|
return toolbarItems as JSX.Element[];
|
||||||
}, [dict]);
|
}, [dict, t]);
|
||||||
|
|
||||||
const formattingToolbar = useCallback(() => {
|
const formattingToolbar = useCallback(() => {
|
||||||
return (
|
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';
|
export * from './DividerBlock';
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
|
|||||||
height: 38px;
|
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 {
|
h1 {
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"Accessible to anyone": "Für alle zugänglich",
|
"Accessible to anyone": "Für alle zugänglich",
|
||||||
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
|
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
|
||||||
"Add": "Hinzufügen",
|
"Add": "Hinzufügen",
|
||||||
|
"Add a callout block": "Hebt schrift hervor",
|
||||||
"Add a horizontal line": "Waagerechte Linie einfügen",
|
"Add a horizontal line": "Waagerechte Linie einfügen",
|
||||||
"Address:": "Anschrift:",
|
"Address:": "Anschrift:",
|
||||||
"Administrator": "Administrator",
|
"Administrator": "Administrator",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"Available soon": "Bald verfügbar",
|
"Available soon": "Bald verfügbar",
|
||||||
"Banner image": "Bannerbild",
|
"Banner image": "Bannerbild",
|
||||||
"Beautify": "Verschönern",
|
"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.",
|
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||||
"Cancel": "Abbrechen",
|
"Cancel": "Abbrechen",
|
||||||
"Close the modal": "Pop up schliessen",
|
"Close the modal": "Pop up schliessen",
|
||||||
@@ -284,6 +286,7 @@
|
|||||||
"Accessible to anyone": "Accesible para todos",
|
"Accessible to anyone": "Accesible para todos",
|
||||||
"Accessible to authenticated users": "Accesible a usuarios autenticados",
|
"Accessible to authenticated users": "Accesible a usuarios autenticados",
|
||||||
"Add": "Añadir",
|
"Add": "Añadir",
|
||||||
|
"Add a callout block": "Resaltar el texto para que destaque",
|
||||||
"Add a horizontal line": "Añadir una línea horizontal",
|
"Add a horizontal line": "Añadir una línea horizontal",
|
||||||
"Address:": "Dirección:",
|
"Address:": "Dirección:",
|
||||||
"Administrator": "Administrador",
|
"Administrator": "Administrador",
|
||||||
@@ -298,6 +301,7 @@
|
|||||||
"Available soon": "Próximamente disponible",
|
"Available soon": "Próximamente disponible",
|
||||||
"Banner image": "Imagen de portada",
|
"Banner image": "Imagen de portada",
|
||||||
"Beautify": "Embellecer",
|
"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.",
|
"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",
|
"Cancel": "Cancelar",
|
||||||
"Close the modal": "Cerrar modal",
|
"Close the modal": "Cerrar modal",
|
||||||
@@ -508,6 +512,7 @@
|
|||||||
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
|
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
|
||||||
"Add": "Ajouter",
|
"Add": "Ajouter",
|
||||||
"Add a horizontal line": "Ajouter une ligne horizontale",
|
"Add a horizontal line": "Ajouter une ligne horizontale",
|
||||||
|
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
|
||||||
"Address:": "Adresse :",
|
"Address:": "Adresse :",
|
||||||
"Administrator": "Administrateur",
|
"Administrator": "Administrateur",
|
||||||
"All docs": "Tous les documents",
|
"All docs": "Tous les documents",
|
||||||
@@ -521,6 +526,7 @@
|
|||||||
"Available soon": "Disponible prochainement",
|
"Available soon": "Disponible prochainement",
|
||||||
"Banner image": "Image de la bannière",
|
"Banner image": "Image de la bannière",
|
||||||
"Beautify": "Embellir",
|
"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.",
|
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
|
||||||
"Cancel": "Annuler",
|
"Cancel": "Annuler",
|
||||||
"Close the modal": "Fermer la modale",
|
"Close the modal": "Fermer la modale",
|
||||||
@@ -914,6 +920,7 @@
|
|||||||
"Accessible to anyone": "Toegankelijk voor iedereen",
|
"Accessible to anyone": "Toegankelijk voor iedereen",
|
||||||
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
|
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
|
||||||
"Add": "Voeg toe",
|
"Add": "Voeg toe",
|
||||||
|
"Add a callout block": "Laat je tekst opvallen",
|
||||||
"Add a horizontal line": "Voeg horizontale lijn toe",
|
"Add a horizontal line": "Voeg horizontale lijn toe",
|
||||||
"Address:": "Adres:",
|
"Address:": "Adres:",
|
||||||
"Administrator": "Administrator",
|
"Administrator": "Administrator",
|
||||||
@@ -928,6 +935,7 @@
|
|||||||
"Available soon": "Binnenkort beschikbaar",
|
"Available soon": "Binnenkort beschikbaar",
|
||||||
"Banner image": "Banner afbeelding",
|
"Banner image": "Banner afbeelding",
|
||||||
"Beautify": "Maak mooier",
|
"Beautify": "Maak mooier",
|
||||||
|
"Callout": "Benadrukken",
|
||||||
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
|
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
|
||||||
"Cancel": "Breek af",
|
"Cancel": "Breek af",
|
||||||
"Close the modal": "Sluit het venster",
|
"Close the modal": "Sluit het venster",
|
||||||
|
|||||||
Reference in New Issue
Block a user