Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
53 changes: 45 additions & 8 deletions packages/lexical-rich-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading