diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b8833461..b716e2ff3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to
### Fixed
+- 🐛(frontend) clear callout background on Backspace #2052
+
- 🐛(export) keep image aspect ratio in PDF columns #2670
- 🐛(frontend) fix redirect after deleting a document #2706
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
index 845d014fd..74c00457e 100644
--- 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
@@ -4,6 +4,7 @@ import {
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
+ createExtension,
defaultProps,
} from '@blocknote/core';
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
@@ -157,6 +158,30 @@ const CalloutComponent = ({
);
};
+export const CalloutBlockShortcuts = createExtension({
+ key: 'callout-shortcuts',
+ keyboardShortcuts: {
+ Backspace: ({ editor }) => {
+ const { selection } = editor.prosemirrorState;
+ if (
+ !selection.empty ||
+ selection.$from.parentOffset !== 0 ||
+ selection.$from.parent.type.name !== 'callout'
+ ) {
+ return false;
+ }
+
+ // BlockNote's default conversion retains shared props, including the
+ // callout background. Reset it when removing the callout formatting.
+ editor.updateBlock(editor.getTextCursorPosition().block, {
+ type: 'paragraph',
+ props: { backgroundColor: defaultProps.backgroundColor.default },
+ });
+ return true;
+ },
+ },
+});
+
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
@@ -172,6 +197,7 @@ export const CalloutBlock = createReactBlockSpec(
),
},
+ [CalloutBlockShortcuts],
);
export const getCalloutReactSlashMenuItems = (
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/__tests__/CalloutBlockShortcuts.test.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/__tests__/CalloutBlockShortcuts.test.ts
new file mode 100644
index 000000000..d322a0143
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/__tests__/CalloutBlockShortcuts.test.ts
@@ -0,0 +1,149 @@
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ createBlockSpec,
+ defaultBlockSpecs,
+ defaultProps,
+} from '@blocknote/core';
+import { TextSelection } from '@tiptap/pm/state';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { CalloutBlockShortcuts } from '../CalloutBlock';
+
+// Use a DOM renderer so the real editor keymaps can run without React UI.
+const schema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: createBlockSpec(
+ {
+ type: 'callout',
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ backgroundColor: defaultProps.backgroundColor,
+ emoji: { default: '💡' },
+ },
+ content: 'inline',
+ },
+ {
+ render: () => {
+ const dom = document.createElement('div');
+ return { dom, contentDOM: dom };
+ },
+ },
+ [CalloutBlockShortcuts],
+ )(),
+ },
+});
+
+function createEditor(content = '', backgroundColor = 'yellow') {
+ const editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [
+ {
+ id: 'callout',
+ type: 'callout',
+ props: { backgroundColor, textAlignment: 'center' },
+ content,
+ },
+ ],
+ });
+ editor.mount(document.createElement('div'));
+ editor.setTextCursorPosition('callout', 'start');
+ return editor;
+}
+
+type Editor = ReturnType;
+let editor: Editor | undefined;
+
+function backspace(editor: Editor) {
+ const view = editor.prosemirrorView;
+ return view.someProp('handleKeyDown', (handler) =>
+ handler(view, new KeyboardEvent('keydown', { key: 'Backspace' })),
+ );
+}
+
+afterEach(() => {
+ editor?._tiptapEditor.destroy();
+ editor = undefined;
+});
+
+describe('callout Backspace', () => {
+ it.each(['yellow', 'blue', 'default'])(
+ 'clears the %s background when an empty callout becomes a paragraph',
+ (color) => {
+ editor = createEditor('', color);
+ backspace(editor);
+
+ expect(editor.document[0]).toMatchObject({
+ id: 'callout',
+ type: 'paragraph',
+ props: { backgroundColor: 'default', textAlignment: 'center' },
+ content: [],
+ });
+ },
+ );
+
+ it('preserves text when converting at the start of a nonempty callout', () => {
+ editor = createEditor('Keep this text');
+ backspace(editor);
+
+ expect(editor.document[0]).toMatchObject({
+ type: 'paragraph',
+ props: { backgroundColor: 'default' },
+ content: [{ type: 'text', text: 'Keep this text' }],
+ });
+ });
+
+ it('leaves character deletion inside a callout to the editor', () => {
+ editor = createEditor('Text');
+ editor.setTextCursorPosition('callout', 'end');
+ expect(backspace(editor)).toBeFalsy();
+
+ expect(editor.document[0]).toMatchObject({
+ type: 'callout',
+ props: { backgroundColor: 'yellow' },
+ });
+ });
+
+ it('deletes selected text without converting the callout', () => {
+ editor = createEditor('Text');
+ const view = editor.prosemirrorView;
+ const start = view.state.selection.from;
+ view.dispatch(
+ view.state.tr.setSelection(
+ TextSelection.create(view.state.doc, start, start + 4),
+ ),
+ );
+ backspace(editor);
+
+ expect(editor.document[0]).toMatchObject({
+ type: 'callout',
+ props: { backgroundColor: 'yellow' },
+ content: [],
+ });
+ });
+
+ it('does not clear the background of an ordinary paragraph', () => {
+ editor = createEditor();
+ editor.updateBlock('callout', { type: 'paragraph' });
+ editor.setTextCursorPosition('callout', 'start');
+ backspace(editor);
+
+ expect(editor.document[0]).toMatchObject({
+ type: 'paragraph',
+ props: { backgroundColor: 'yellow' },
+ });
+ });
+
+ it('restores the callout and its background in one undo', () => {
+ editor = createEditor('Text', 'blue');
+ backspace(editor);
+ editor.undo();
+
+ expect(editor.document[0]).toMatchObject({
+ type: 'callout',
+ props: { backgroundColor: 'blue' },
+ content: [{ type: 'text', text: 'Text' }],
+ });
+ });
+});