diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts index 7c5112009ee..da48aec79bb 100644 --- a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts @@ -14,9 +14,12 @@ import { $createTextNode, $getRoot, $getSelection, + $isNodeSelection, $isRangeSelection, $setSelection, KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, type LexicalEditor, } from 'lexical'; @@ -119,3 +122,60 @@ describe('block cursor root boundary navigation (#8886)', () => { expectBlockCursorAt(editor, 3); }); }); + +describe('block cursor root boundary navigation (#7999)', () => { + test('ArrowRight at the block cursor after the last block stays put', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(3, 3), {discrete: true}); + expectBlockCursorAt(editor, 3); + + const event = makeArrowEvent('ArrowRight'); + const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expectBlockCursorAt(editor, 3); + }); + + test('ArrowLeft at the block cursor before the first block stays put', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(0, 0), {discrete: true}); + expectBlockCursorAt(editor, 0); + + const event = makeArrowEvent('ArrowLeft'); + const handled = editor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event); + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expectBlockCursorAt(editor, 0); + }); + + test('ArrowRight at the block cursor before the last block is not consumed', () => { + using editor = createBoundaryEditor(); + + // root:2 sits between the paragraph and the trailing decorator, so there + // is still a block to move into. + editor.update(() => $getRoot().select(2, 2), {discrete: true}); + + const event = makeArrowEvent('ArrowRight'); + editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + // The pre-existing decorator navigation still runs and selects the + // trailing decorator rather than leaving the caret at the root. + editor.read(() => { + const s = $getSelection(); + assert($isNodeSelection(s)); + expect(s.getNodes()).toEqual([$getRoot().getChildAtIndex(2)]); + }); + }); + + test('ArrowRight with a non-collapsed selection at the root edge is not consumed', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(0, 3), {discrete: true}); + + const event = makeArrowEvent('ArrowRight'); + const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 2f18b7cfb12..f1b62652b6a 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -584,6 +584,35 @@ function $isSelectionAtStartOfRoot(selection: RangeSelection) { return focus.key === 'root' && focus.offset === 0; } +/** + * True when the selection is a collapsed element point at the very start or + * end of the root, beside a child that renders a block cursor (a decorator, a + * table or another shadow root). There is nothing past the block cursor to + * move to, so horizontal arrow navigation in that direction has to be a no-op. + * Left to the browser, the native caret escapes to the other side of the block + * instead and the caret appears to cycle around it. See #7999. + */ +function $isBlockCursorAtRootEdge( + selection: RangeSelection, + direction: CaretDirection, +): boolean { + if (!selection.isCollapsed()) { + return false; + } + const isNext = direction === 'next'; + if ( + !(isNext + ? $isSelectionAtEndOfRoot(selection) + : $isSelectionAtStartOfRoot(selection)) + ) { + return false; + } + const offset = selection.focus.offset; + return $needsBlockCursorBeside( + $getRoot().getChildAtIndex(isNext ? offset - 1 : offset), + ); +} + function $isSelectionCollapsedAtFrontOfIndentedBlock( selection: RangeSelection, ): boolean { @@ -1491,12 +1520,16 @@ export function registerRichText( if (!$isRangeSelection(selection)) { return false; } + const leftDirection = $isParentRTL(selection.anchor.getNode()) + ? 'next' + : 'previous'; + if ($isBlockCursorAtRootEdge(selection, leftDirection)) { + event.preventDefault(); + return true; + } if ( !event.shiftKey && - $tryBlockCursorShadowRootNavigation( - selection, - $isParentRTL(selection.anchor.getNode()) ? 'next' : 'previous', - ) + $tryBlockCursorShadowRootNavigation(selection, leftDirection) ) { event.preventDefault(); return true; @@ -1539,12 +1572,16 @@ export function registerRichText( if (!$isRangeSelection(selection)) { return false; } + const rightDirection = $isParentRTL(selection.anchor.getNode()) + ? 'previous' + : 'next'; + if ($isBlockCursorAtRootEdge(selection, rightDirection)) { + event.preventDefault(); + return true; + } if ( !event.shiftKey && - $tryBlockCursorShadowRootNavigation( - selection, - $isParentRTL(selection.anchor.getNode()) ? 'previous' : 'next', - ) + $tryBlockCursorShadowRootNavigation(selection, rightDirection) ) { event.preventDefault(); return true; diff --git a/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts new file mode 100644 index 00000000000..34eb0c93b67 --- /dev/null +++ b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import {$createTableNodeWithDimensions, TableExtension} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + type LexicalEditor, +} from 'lexical'; +import {assert, expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +// Regression tests for #7999. +// +// With a table as the last node of the document, the caret used to cycle +// around it: right arrow out of the last cell reached the block cursor +// beneath the table, the next right arrow jumped back to the root offset +// *before* the table (where Enter inserts a paragraph above it), and the one +// after that dropped back into the last cell. +// +// Nothing in the Lexical model moves on that second key press - the native +// caret walks around the block cursor element and the selectionchange +// listener imports the result - so this only reproduces in a real browser. +// See the `browser` project in vitest.config.mts. + +function mount($initialEditorState: () => void): { + editor: LexicalEditor; + contentEditable: HTMLElement; +} { + const container = document.createElement('div'); + document.body.appendChild(container); + const contentEditable = document.createElement('div'); + contentEditable.contentEditable = 'true'; + container.appendChild(contentEditable); + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState, + dependencies: [RichTextExtension, TableExtension], + name: 'issue-7999', + onError: (error: Error) => { + throw error; + }, + }), + ); + editor.setRootElement(contentEditable); + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + contentEditable.focus(); + return {contentEditable, editor}; +} + +function expectCaretAfterTable(editor: LexicalEditor): void { + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected RangeSelection'); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.type).toBe('element'); + expect(selection.anchor.key).toBe($getRoot().getKey()); + expect(selection.anchor.offset).toBe($getRoot().getChildrenSize()); + }); +} + +test('the caret stops beneath a trailing table instead of cycling around it', async () => { + const {editor} = mount(() => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(2, 2, false)); + }); + editor.update(() => $getRoot().getLastChildOrThrow().selectEnd(), { + discrete: true, + }); + + // Leaves the last cell and lands on the block cursor beneath the table. + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + + // Further presses have nowhere to go and must leave the caret alone. + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + + // Enter at that caret adds the paragraph after the table, not before it. + await userEvent.keyboard('{Enter}'); + expect( + editor.read(() => + $getRoot() + .getChildren() + .map(node => node.getType()), + ), + ).toEqual(['table', 'paragraph']); +}); + +test('the caret still moves past a table that is followed by a paragraph', async () => { + const {editor} = mount(() => { + $getRoot() + .clear() + .append( + $createTableNodeWithDimensions(2, 2, false), + $createParagraphNode().append($createTextNode('after')), + ); + }); + editor.update(() => $getRoot().getFirstChildOrThrow().selectEnd(), { + discrete: true, + }); + + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected RangeSelection'); + expect(selection.anchor.getNode().getTextContent()).toBe('after'); + }); +});