From a75dbaaeb9ed4190be4f8dbf2d39bb65a5858cb7 Mon Sep 17 00:00:00 2001 From: MANI K <151814075+Manixhor@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:49:27 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20clear=20callout=20back?= =?UTF-8?q?ground=20on=20Backspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset the background when Backspace converts a callout to a paragraph. Preserve content, alignment, regular text deletion, and undo behavior. Fixes #2052 Signed-off-by: Manixhor --- CHANGELOG.md | 2 + .../components/custom-blocks/CalloutBlock.tsx | 26 +++ .../__tests__/CalloutBlockShortcuts.test.ts | 149 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/__tests__/CalloutBlockShortcuts.test.ts 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' }], + }); + }); +});