From d47277a1ea311d21f08fd2ba0c19f8e36b1b87ce Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 7 Jul 2026 20:37:38 +0200 Subject: [PATCH 1/4] test: add test for blockquote --- packages/app/scripts/vitest.setup.ts | 5 ++ .../__tests__/spec/blockquote.dom.test.ts | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts diff --git a/packages/app/scripts/vitest.setup.ts b/packages/app/scripts/vitest.setup.ts index d41503db4..8d23f8e82 100644 --- a/packages/app/scripts/vitest.setup.ts +++ b/packages/app/scripts/vitest.setup.ts @@ -26,4 +26,9 @@ if (isDOMLikeEnv) { y: 0, toJSON: () => {}, }); + + Object.defineProperty(Selection.prototype, 'modify', { + value: vi.fn(), + configurable: true, + }); } diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts new file mode 100644 index 000000000..ca57169d1 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts @@ -0,0 +1,90 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { selectContent } from '../utils/utils'; + +test('Blockquote is removed when backspace is pressed', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '>' }); + + const editor = screen.getByRole('textbox'); + const quote = within(editor).getByRole('blockquote'); + + await user.click(quote); + await user.keyboard('{Backspace}'); + + expect(within(editor).queryByRole('blockquote')).not.toBeInTheDocument(); + expect(within(editor).queryByRole('paragraph')).toHaveTextContent(''); +}); + +test('Blockquote with text is removed when backspace is pressed', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '> text' }); + + const editor = screen.getByRole('textbox'); + const quote = within(editor).getByRole('blockquote'); + + await user.click(quote); + selectContent(quote, 'text'); + + // First delete text, then blockquote + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + + expect(within(editor).queryByRole('blockquote')).not.toBeInTheDocument(); + expect(within(editor).queryByText('text')).not.toBeInTheDocument(); +}); + +test('Removed nested blockquote', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '>>' }); + + const editor = screen.getByRole('textbox'); + const quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); + + // Delete nested quote + await user.click(quotes[1]); + await user.keyboard('{Backspace}'); + + expect(quotes[1]).not.toBeInTheDocument(); + expect(within(editor).getAllByRole('blockquote')).toHaveLength(1); + + // Delete external quote + await user.keyboard('{Backspace}'); + expect(within(editor).queryAllByRole('blockquote')).toHaveLength(0); +}); + +test.fails( + 'deletes nested empty blockquote on backspace instead of jumping cursor to previous line', + async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: `> foo\n>> bar` }); + + const editor = screen.getByRole('textbox'); + let quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); + + await user.click(quotes[1]); + selectContent(quotes[1], 'bar'); + + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + + quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); + expect(quotes[1]).toHaveTextContent(''); + + await user.keyboard('{Backspace}'); + + quotes = within(editor).getAllByRole('blockquote'); + + expect(quotes).toHaveLength(1); + expect(editor).toHaveTextContent('foo'); + + const selection = window.getSelection(); + expect(selection?.anchorNode?.textContent).toBe('foo'); + expect(selection?.anchorOffset).toBe(3); + }, +); From 4107aca355ee782553149a6e78a4609e4293a00f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 7 Jul 2026 23:17:54 +0200 Subject: [PATCH 2/4] test: update --- .../__tests__/spec/blockquote.dom.test.ts | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts index ca57169d1..37a7614eb 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts @@ -56,35 +56,30 @@ test('Removed nested blockquote', async () => { expect(within(editor).queryAllByRole('blockquote')).toHaveLength(0); }); -test.fails( - 'deletes nested empty blockquote on backspace instead of jumping cursor to previous line', - async () => { - const user = userEvent.setup(); - await renderRichEditor({ value: `> foo\n>> bar` }); - - const editor = screen.getByRole('textbox'); - let quotes = within(editor).getAllByRole('blockquote'); - expect(quotes).toHaveLength(2); +test('Removes nested blockquote after deleting its text with backspace', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: `> foo\n>> bar` }); - await user.click(quotes[1]); - selectContent(quotes[1], 'bar'); + const editor = screen.getByRole('textbox'); + const quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); - await user.keyboard('{Backspace}'); - await user.keyboard('{Backspace}'); + await user.click(quotes[1]); + selectContent(quotes[1], 'bar'); - quotes = within(editor).getAllByRole('blockquote'); - expect(quotes).toHaveLength(2); - expect(quotes[1]).toHaveTextContent(''); + // First remove the text + await user.keyboard('{Backspace}'); + expect(within(editor).queryByText('bar')).not.toBeInTheDocument(); - await user.keyboard('{Backspace}'); + // Second press remove the blockquote + await user.keyboard('{Backspace}'); + expect(within(editor).getAllByRole('blockquote')).toHaveLength(1); - quotes = within(editor).getAllByRole('blockquote'); + selectContent(quotes[0], 'foo'); - expect(quotes).toHaveLength(1); - expect(editor).toHaveTextContent('foo'); + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); - const selection = window.getSelection(); - expect(selection?.anchorNode?.textContent).toBe('foo'); - expect(selection?.anchorOffset).toBe(3); - }, -); + expect(within(editor).queryByText('foo')).not.toBeInTheDocument(); + expect(within(editor).queryByRole('blockquote')).not.toBeInTheDocument(); +}); From efc692827ca11ecbe31a527b47d95d7298967fb8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 7 Jul 2026 23:20:27 +0200 Subject: [PATCH 3/4] feat: handle backspace --- .../KeyboardControlsPlugin.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts index b01406f95..6acb29b2b 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts @@ -3,11 +3,13 @@ import { $createParagraphNode, $getSelection, $isParagraphNode, + $isRangeSelection, $isTextNode, BaseSelection, COMMAND_PRIORITY_LOW, createCommand, ElementNode, + KEY_BACKSPACE_COMMAND, KEY_ENTER_COMMAND, } from 'lexical'; import { $isCodeNode } from '@lexical/code'; @@ -94,6 +96,39 @@ export const KeyboardControlsPlugin = () => { }, COMMAND_PRIORITY_LOW, ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + (event) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) + return false; + + // cursor must be start of string + if (selection.anchor.offset !== 0) return false; + + const blockElement = $getParentOfTextOnEnd(selection); + + if ($isQuoteNode(blockElement)) { + if (blockElement.getTextContentSize() !== 0) return false; + + const sibling = blockElement.getPreviousSibling(); + if (sibling) { + blockElement.remove(); + sibling.selectEnd(); + } else { + const paragraph = $createParagraphNode(); + blockElement.replace(paragraph); + paragraph.select(); + } + + event.preventDefault(); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW, + ), ), [editor], ); From 7703bcb0f596adf9eefcdc8f046efa7d155ae082 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 9 Jul 2026 17:39:13 +0200 Subject: [PATCH 4/4] test: add --- .../__tests__/spec/blockquote.dom.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts index 37a7614eb..f6d575088 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts @@ -4,7 +4,24 @@ import userEvent from '@testing-library/user-event'; import { renderRichEditor } from '../utils/renderRichEditor'; import { selectContent } from '../utils/utils'; -test('Blockquote is removed when backspace is pressed', async () => { +test('Insert blockquote', async () => { + const richEditor = await renderRichEditor({ value: 'cat' }); + + const editor = screen.getByRole('textbox'); + selectContent(editor, 'cat'); + await richEditor.insert({ type: 'quote' }); + + expect(within(editor).getByRole('blockquote')).toHaveTextContent('cat'); + + selectContent(editor, 'cat'); + await richEditor.insert({ type: 'quote' }); + + const quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); + expect(quotes[1]).toHaveTextContent('cat'); +}); + +test('Empty blockquote is removed when backspace is pressed', async () => { const user = userEvent.setup(); await renderRichEditor({ value: '>' });