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..d3cd50e3a --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/blockquote.dom.test.ts @@ -0,0 +1,101 @@ +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('Wrap text in a 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('Removes an empty blockquote 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('Removes a blockquote after deleting its text with Backspace', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '> text' }); + + const editor = screen.getByRole('textbox'); + const quote = within(editor).getByRole('blockquote'); + expect(quote).toHaveTextContent('text'); + + // Select the text for deletion + await user.click(quote); + selectContent(quote, 'text'); + + // First press deletes the text, second press removes the blockquote + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + + expect(within(editor).queryByRole('blockquote')).not.toBeInTheDocument(); + expect(within(editor).queryByText('text')).not.toBeInTheDocument(); +}); + +test('Removes nested blockquote one level at a time on Backspace', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '>>>' }); + + const editor = screen.getByRole('textbox'); + const quotes = within(editor).getAllByRole('blockquote'); + expect(quotes).toHaveLength(3); + + // Delete the nested blockquote + await user.click(quotes[2]); + await user.keyboard('{Backspace}'); + + expect(quotes[2]).not.toBeInTheDocument(); + expect(within(editor).getAllByRole('blockquote')).toHaveLength(2); + + await user.keyboard('{Backspace}'); + expect(within(editor).queryAllByRole('blockquote')).toHaveLength(1); + + await user.keyboard('{Backspace}'); + expect(within(editor).queryAllByRole('blockquote')).toHaveLength(0); +}); + +test('Removes an empty nested blockquote on backspace after deleting its text', 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'); + + // Delete the text + await user.keyboard('{Backspace}'); + expect(quotes[1]).toHaveTextContent(''); + expect(within(editor).getAllByRole('blockquote')).toHaveLength(2); + + // The second press should remove the blockquote + await user.keyboard('{Backspace}'); + + expect(within(editor).getAllByRole('blockquote')).toHaveLength(1); + expect(within(editor).getByRole('blockquote')).toHaveTextContent('foo'); +}); 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], );