Skip to content

Commit a1e1a38

Browse files
authored
[lexical][lexical-rich-text][lexical-code-core] Bug Fix: Cursor stuck before leading inline DecoratorNode (#8558)
1 parent 168f803 commit a1e1a38

7 files changed

Lines changed: 537 additions & 2 deletions

File tree

packages/lexical-code-core/src/CodeIndentation.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,14 @@ function $handleMoveTo(
446446
const direction = $getCodeLineDirection(focusLineNode);
447447
const moveToStart = direction === 'rtl' ? !isMoveToStart : isMoveToStart;
448448

449+
// Shift variant: let the non-shift branches resolve the target via
450+
// framework helpers (`selectNext` / `selectStart` / `setTextNodeRange` /
451+
// `node.select`), then restore the original anchor so we end up with an
452+
// extended selection rather than a collapsed caret. This keeps point
453+
// shapes (text vs. element) consistent between shift and non-shift.
454+
const originalAnchorKey = anchor.key;
455+
const originalAnchorOffset = anchor.offset;
456+
const originalAnchorType = anchor.type;
449457
if (moveToStart) {
450458
const start = $getStartOfCodeInLine(focusLineNode, focus.offset);
451459
if (start !== null) {
@@ -462,6 +470,13 @@ function $handleMoveTo(
462470
const node = $getEndOfCodeInLine(focusLineNode);
463471
node.select();
464472
}
473+
if (event.shiftKey) {
474+
selection.anchor.set(
475+
originalAnchorKey,
476+
originalAnchorOffset,
477+
originalAnchorType,
478+
);
479+
}
465480

466481
event.preventDefault();
467482
event.stopPropagation();

packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,52 @@ describe('LexicalCodeNode tests', () => {
579579
);
580580
});
581581
});
582+
583+
test('Shift+MOVE_TO_END preserves anchor and extends focus', async () => {
584+
const {editor} = testEnv;
585+
await setupRTLCode(editor);
586+
const event = new KeyboardEventMock('keydown');
587+
event.shiftKey = true;
588+
589+
const before = editor.read(() => {
590+
const s = $getSelection();
591+
invariant($isRangeSelection(s));
592+
return {key: s.anchor.key, offset: s.anchor.offset};
593+
});
594+
595+
editor.dispatchCommand(MOVE_TO_END, event);
596+
597+
editor.read(() => {
598+
const selection = $getSelection();
599+
invariant($isRangeSelection(selection));
600+
expect(selection.isCollapsed()).toBe(false);
601+
expect(selection.anchor.key).toBe(before.key);
602+
expect(selection.anchor.offset).toBe(before.offset);
603+
});
604+
});
605+
606+
test('Shift+MOVE_TO_START preserves anchor and extends focus', async () => {
607+
const {editor} = testEnv;
608+
await setupRTLCode(editor);
609+
const event = new KeyboardEventMock('keydown');
610+
event.shiftKey = true;
611+
612+
const before = editor.read(() => {
613+
const s = $getSelection();
614+
invariant($isRangeSelection(s));
615+
return {key: s.anchor.key, offset: s.anchor.offset};
616+
});
617+
618+
editor.dispatchCommand(MOVE_TO_START, event);
619+
620+
editor.read(() => {
621+
const selection = $getSelection();
622+
invariant($isRangeSelection(selection));
623+
expect(selection.isCollapsed()).toBe(false);
624+
expect(selection.anchor.key).toBe(before.key);
625+
expect(selection.anchor.offset).toBe(before.offset);
626+
});
627+
});
582628
});
583629

584630
for (const moveTo of ['start', 'end']) {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import {buildEditorFromExtensions} from '@lexical/extension';
10+
import {$createHeadingNode, RichTextExtension} from '@lexical/rich-text';
11+
import {
12+
$createParagraphNode,
13+
$createTextNode,
14+
$getRoot,
15+
$getSelection,
16+
$isRangeSelection,
17+
LexicalEditor,
18+
MOVE_TO_END,
19+
} from 'lexical';
20+
import {
21+
$createTestDecoratorNode,
22+
TestDecoratorNode,
23+
} from 'lexical/src/__tests__/utils';
24+
import {assert, describe, expect, test} from 'vitest';
25+
26+
function dispatchMoveToEnd(editor: LexicalEditor, shiftKey: boolean) {
27+
editor.dispatchCommand(
28+
MOVE_TO_END,
29+
new KeyboardEvent('keydown', {ctrlKey: true, key: 'ArrowRight', shiftKey}),
30+
);
31+
}
32+
33+
function snapshotSelection(editor: LexicalEditor) {
34+
return editor.read(() => {
35+
const s = $getSelection();
36+
if (!$isRangeSelection(s)) {
37+
return null;
38+
}
39+
return {
40+
anchor: [s.anchor.type, s.anchor.key, s.anchor.offset],
41+
focus: [s.focus.type, s.focus.key, s.focus.offset],
42+
};
43+
});
44+
}
45+
46+
describe('MOVE_TO_END on a leading inline DecoratorNode (Issue #8555)', () => {
47+
test('Cmd+ArrowRight at offset 0 moves caret past the inline decorator', () => {
48+
using editor = buildEditorFromExtensions({
49+
$initialEditorState: () => {
50+
const decorator = $createTestDecoratorNode().setIsInline(true);
51+
const text = $createTextNode('hello');
52+
const paragraph = $createParagraphNode().append(decorator, text);
53+
$getRoot().clear().append(paragraph);
54+
paragraph.select(0, 0);
55+
},
56+
dependencies: [RichTextExtension],
57+
name: 'test',
58+
nodes: [TestDecoratorNode],
59+
});
60+
61+
dispatchMoveToEnd(editor, false);
62+
63+
editor.read(() => {
64+
const selection = $getSelection();
65+
assert($isRangeSelection(selection));
66+
expect(selection.isCollapsed()).toBe(true);
67+
expect(selection.focus.type).toBe('text');
68+
expect(selection.focus.offset).toBe('hello'.length);
69+
});
70+
});
71+
72+
test('Shift+Cmd+ArrowRight at offset 0 selects to end of element', () => {
73+
using editor = buildEditorFromExtensions({
74+
$initialEditorState: () => {
75+
const decorator = $createTestDecoratorNode().setIsInline(true);
76+
const text = $createTextNode('hello');
77+
const paragraph = $createParagraphNode().append(decorator, text);
78+
$getRoot().clear().append(paragraph);
79+
paragraph.select(0, 0);
80+
},
81+
dependencies: [RichTextExtension],
82+
name: 'test',
83+
nodes: [TestDecoratorNode],
84+
});
85+
86+
dispatchMoveToEnd(editor, true);
87+
88+
editor.read(() => {
89+
const selection = $getSelection();
90+
assert($isRangeSelection(selection));
91+
expect(selection.isCollapsed()).toBe(false);
92+
expect(selection.anchor.type).toBe('element');
93+
expect(selection.anchor.offset).toBe(0);
94+
expect(selection.focus.offset).toBe('hello'.length);
95+
});
96+
});
97+
98+
test('Same fix applies inside HeadingNode', () => {
99+
using editor = buildEditorFromExtensions({
100+
$initialEditorState: () => {
101+
const decorator = $createTestDecoratorNode().setIsInline(true);
102+
const text = $createTextNode('world');
103+
const heading = $createHeadingNode('h1').append(decorator, text);
104+
$getRoot().clear().append(heading);
105+
heading.select(0, 0);
106+
},
107+
dependencies: [RichTextExtension],
108+
name: 'test',
109+
nodes: [TestDecoratorNode],
110+
});
111+
112+
dispatchMoveToEnd(editor, false);
113+
114+
editor.read(() => {
115+
const selection = $getSelection();
116+
assert($isRangeSelection(selection));
117+
expect(selection.isCollapsed()).toBe(true);
118+
expect(selection.focus.offset).toBe('world'.length);
119+
});
120+
});
121+
});
122+
123+
describe('MOVE_TO_END no-op cases (Issue #8555)', () => {
124+
test.for([
125+
{
126+
label: 'first child is text, not a decorator',
127+
setup: () => {
128+
const text = $createTextNode('plain');
129+
const paragraph = $createParagraphNode().append(text);
130+
$getRoot().clear().append(paragraph);
131+
paragraph.select(0, 0);
132+
},
133+
},
134+
{
135+
label: 'first child is a block (non-inline) decorator',
136+
setup: () => {
137+
const decorator = $createTestDecoratorNode().setIsInline(false);
138+
const text = $createTextNode('after');
139+
const paragraph = $createParagraphNode().append(decorator, text);
140+
$getRoot().clear().append(paragraph);
141+
paragraph.select(0, 0);
142+
},
143+
},
144+
{
145+
label: 'caret is past offset 0',
146+
setup: () => {
147+
const decorator = $createTestDecoratorNode().setIsInline(true);
148+
const text = $createTextNode('hello');
149+
const paragraph = $createParagraphNode().append(decorator, text);
150+
$getRoot().clear().append(paragraph);
151+
paragraph.select(1, 1);
152+
},
153+
},
154+
])('no-op: $label', ({setup}) => {
155+
using editor = buildEditorFromExtensions({
156+
$initialEditorState: setup,
157+
dependencies: [RichTextExtension],
158+
name: 'test',
159+
nodes: [TestDecoratorNode],
160+
});
161+
162+
const before = snapshotSelection(editor);
163+
164+
dispatchMoveToEnd(editor, false);
165+
166+
expect(snapshotSelection(editor)).toEqual(before);
167+
});
168+
});

0 commit comments

Comments
 (0)