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..f6d575088 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts @@ -0,0 +1,102 @@ +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('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: '>' }); + + 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('Removes nested blockquote after deleting its text with backspace', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: `> foo\n>> bar` }); + + const editor = screen.getByRole('textbox'); + const quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(2); + + await user.click(quotes[1]); + selectContent(quotes[1], 'bar'); + + // First remove the text + await user.keyboard('{Backspace}'); + expect(within(editor).queryByText('bar')).not.toBeInTheDocument(); + + // Second press remove the blockquote + await user.keyboard('{Backspace}'); + expect(within(editor).getAllByRole('blockquote')).toHaveLength(1); + + selectContent(quotes[0], 'foo'); + + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + + expect(within(editor).queryByText('foo')).not.toBeInTheDocument(); + expect(within(editor).queryByRole('blockquote')).not.toBeInTheDocument(); +}); 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], );