Skip to content

Commit ace8af0

Browse files
committed
Lexical: Improved list tab handling, Improved test utils
- Made tab work on empty list items - Improved select preservation on single list item tab - Altered test context creation for more standard testing
1 parent e50cd33 commit ace8af0

File tree

4 files changed

+151
-67
lines changed

4 files changed

+151
-67
lines changed

resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts

+27-5
Original file line numberDiff line numberDiff line change
@@ -472,16 +472,34 @@ export function createTestHeadlessEditor(
472472
});
473473
}
474474

475-
export function createTestContext(env: TestEnv): EditorUiContext {
475+
export function createTestContext(): EditorUiContext {
476+
477+
const container = document.createElement('div');
478+
document.body.appendChild(container);
479+
480+
const scrollWrap = document.createElement('div');
481+
const editorDOM = document.createElement('div');
482+
editorDOM.setAttribute('contenteditable', 'true');
483+
484+
scrollWrap.append(editorDOM);
485+
container.append(scrollWrap);
486+
487+
const editor = createTestEditor({
488+
namespace: 'testing',
489+
theme: {},
490+
});
491+
492+
editor.setRootElement(editorDOM);
493+
476494
const context = {
477-
containerDOM: document.createElement('div'),
478-
editor: env.editor,
479-
editorDOM: document.createElement('div'),
495+
containerDOM: container,
496+
editor: editor,
497+
editorDOM: editorDOM,
480498
error(text: string | Error): void {
481499
},
482500
manager: new EditorUIManager(),
483501
options: {},
484-
scrollDOM: document.createElement('div'),
502+
scrollDOM: scrollWrap,
485503
translate(text: string): string {
486504
return "";
487505
}
@@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext {
492510
return context;
493511
}
494512

513+
export function destroyFromContext(context: EditorUiContext) {
514+
context.containerDOM.remove();
515+
}
516+
495517
export function $assertRangeSelection(selection: unknown): RangeSelection {
496518
if (!$isRangeSelection(selection)) {
497519
throw new Error(`Expected RangeSelection, got ${selection}`);
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,135 @@
11
import {
2-
createTestContext,
2+
createTestContext, destroyFromContext,
33
dispatchKeydownEventForNode,
44
dispatchKeydownEventForSelectedNode,
5-
initializeUnitTest
65
} from "lexical/__tests__/utils";
76
import {
87
$createParagraphNode, $createTextNode,
9-
$getRoot, LexicalNode,
10-
ParagraphNode,
8+
$getRoot, $getSelection, LexicalEditor, LexicalNode,
9+
ParagraphNode, TextNode,
1110
} from "lexical";
1211
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
1312
import {registerKeyboardHandling} from "../keyboard-handling";
1413
import {registerRichText} from "@lexical/rich-text";
14+
import {EditorUiContext} from "../../ui/framework/core";
15+
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
1516

1617
describe('Keyboard-handling service tests', () => {
17-
initializeUnitTest((testEnv) => {
1818

19-
test('Details: down key on last lines creates new sibling node', () => {
20-
const {editor} = testEnv;
19+
let context!: EditorUiContext;
20+
let editor!: LexicalEditor;
2121

22-
registerRichText(editor);
23-
registerKeyboardHandling(createTestContext(testEnv));
22+
beforeEach(() => {
23+
context = createTestContext();
24+
editor = context.editor;
25+
registerRichText(editor);
26+
registerKeyboardHandling(context);
27+
});
2428

25-
let lastRootChild!: LexicalNode|null;
26-
let detailsPara!: ParagraphNode;
29+
afterEach(() => {
30+
destroyFromContext(context);
31+
});
2732

28-
editor.updateAndCommit(() => {
29-
const root = $getRoot()
30-
const details = $createDetailsNode();
31-
detailsPara = $createParagraphNode();
32-
details.append(detailsPara);
33-
$getRoot().append(details);
34-
detailsPara.select();
33+
test('Details: down key on last lines creates new sibling node', () => {
34+
let lastRootChild!: LexicalNode|null;
35+
let detailsPara!: ParagraphNode;
3536

36-
lastRootChild = root.getLastChild();
37-
});
37+
editor.updateAndCommit(() => {
38+
const root = $getRoot()
39+
const details = $createDetailsNode();
40+
detailsPara = $createParagraphNode();
41+
details.append(detailsPara);
42+
$getRoot().append(details);
43+
detailsPara.select();
3844

39-
expect(lastRootChild).toBeInstanceOf(DetailsNode);
45+
lastRootChild = root.getLastChild();
46+
});
4047

41-
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
42-
editor.commitUpdates();
48+
expect(lastRootChild).toBeInstanceOf(DetailsNode);
4349

44-
editor.getEditorState().read(() => {
45-
lastRootChild = $getRoot().getLastChild();
46-
});
50+
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
51+
editor.commitUpdates();
4752

48-
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
53+
editor.getEditorState().read(() => {
54+
lastRootChild = $getRoot().getLastChild();
4955
});
5056

51-
test('Details: enter on last empy block creates new sibling node', () => {
52-
const {editor} = testEnv;
57+
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
58+
});
59+
60+
test('Details: enter on last empty block creates new sibling node', () => {
61+
registerRichText(editor);
62+
63+
let lastRootChild!: LexicalNode|null;
64+
let detailsPara!: ParagraphNode;
5365

54-
registerRichText(editor);
55-
registerKeyboardHandling(createTestContext(testEnv));
66+
editor.updateAndCommit(() => {
67+
const root = $getRoot()
68+
const details = $createDetailsNode();
69+
const text = $createTextNode('Hello!');
70+
detailsPara = $createParagraphNode();
71+
detailsPara.append(text);
72+
details.append(detailsPara);
73+
$getRoot().append(details);
74+
text.selectEnd();
5675

57-
let lastRootChild!: LexicalNode|null;
58-
let detailsPara!: ParagraphNode;
76+
lastRootChild = root.getLastChild();
77+
});
78+
79+
expect(lastRootChild).toBeInstanceOf(DetailsNode);
80+
81+
dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
82+
editor.commitUpdates();
5983

60-
editor.updateAndCommit(() => {
61-
const root = $getRoot()
62-
const details = $createDetailsNode();
63-
const text = $createTextNode('Hello!');
64-
detailsPara = $createParagraphNode();
65-
detailsPara.append(text);
66-
details.append(detailsPara);
67-
$getRoot().append(details);
68-
text.selectEnd();
84+
dispatchKeydownEventForSelectedNode(editor, 'Enter');
85+
editor.commitUpdates();
6986

70-
lastRootChild = root.getLastChild();
71-
});
87+
let detailsChildren!: LexicalNode[];
88+
let lastDetailsText!: string;
7289

73-
expect(lastRootChild).toBeInstanceOf(DetailsNode);
90+
editor.getEditorState().read(() => {
91+
detailsChildren = (lastRootChild as DetailsNode).getChildren();
92+
lastRootChild = $getRoot().getLastChild();
93+
lastDetailsText = detailsChildren[0].getTextContent();
94+
});
7495

75-
dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
76-
editor.commitUpdates();
96+
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
97+
expect(detailsChildren).toHaveLength(1);
98+
expect(lastDetailsText).toBe('Hello!');
99+
});
77100

78-
dispatchKeydownEventForSelectedNode(editor, 'Enter');
79-
editor.commitUpdates();
101+
test('Lists: tab on empty list item insets item', () => {
80102

81-
let detailsChildren!: LexicalNode[];
82-
let lastDetailsText!: string;
103+
let list!: ListNode;
104+
let listItemB!: ListItemNode;
83105

84-
editor.getEditorState().read(() => {
85-
detailsChildren = (lastRootChild as DetailsNode).getChildren();
86-
lastRootChild = $getRoot().getLastChild();
87-
lastDetailsText = detailsChildren[0].getTextContent();
88-
});
106+
editor.updateAndCommit(() => {
107+
const root = $getRoot();
108+
list = $createListNode('bullet');
109+
const listItemA = $createListItemNode();
110+
listItemA.append($createTextNode('Hello!'));
111+
listItemB = $createListItemNode();
112+
list.append(listItemA, listItemB);
113+
root.append(list);
114+
listItemB.selectStart();
115+
});
89116

90-
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
91-
expect(detailsChildren).toHaveLength(1);
92-
expect(lastDetailsText).toBe('Hello!');
117+
dispatchKeydownEventForNode(listItemB, editor, 'Tab');
118+
editor.commitUpdates();
119+
120+
editor.getEditorState().read(() => {
121+
const list = $getRoot().getChildren()[0] as ListNode;
122+
const listChild = list.getChildren()[0] as ListItemNode;
123+
const children = listChild.getChildren();
124+
expect(children).toHaveLength(2);
125+
expect(children[0]).toBeInstanceOf(TextNode);
126+
expect(children[0].getTextContent()).toBe('Hello!');
127+
expect(children[1]).toBeInstanceOf(ListNode);
128+
129+
const innerList = children[1] as ListNode;
130+
const selectedNode = $getSelection()?.getNodes()[0];
131+
expect(selectedNode).toBeInstanceOf(ListItemNode);
132+
expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
93133
});
94134
});
95135
});

resources/js/wysiwyg/services/keyboard-handling.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ function getDetailsScenario(editor: LexicalEditor): {
151151
}
152152
}
153153

154+
function $isSingleListItem(nodes: LexicalNode[]): boolean {
155+
if (nodes.length !== 1) {
156+
return false;
157+
}
158+
159+
const node = nodes[0];
160+
return $isListItemNode(node) || $isListItemNode(node.getParent());
161+
}
162+
154163
/**
155164
* Inset the nodes within selection when a range of nodes is selected
156165
* or if a list node is selected.
@@ -159,7 +168,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
159168
const change = event?.shiftKey ? -40 : 40;
160169
const selection = $getSelection();
161170
const nodes = selection?.getNodes() || [];
162-
if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
171+
if (nodes.length > 1 || $isSingleListItem(nodes)) {
163172
editor.update(() => {
164173
$setInsetForSelection(editor, change);
165174
});

resources/js/wysiwyg/utils/lists.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
1+
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
22
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
33
import {nodeHasInset} from "./nodes";
44
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
@@ -93,6 +93,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
9393

9494
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
9595
const selection = $getSelection();
96+
const selectionBounds = selection?.getStartEndPoints();
9697
const listItemsInSelection = getListItemsForSelection(selection);
9798
const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
9899

@@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
110111
alteredListItems.reverse();
111112
}
112113

113-
$selectNodes(alteredListItems);
114+
if (alteredListItems.length === 1 && selectionBounds) {
115+
// Retain selection range if moving just one item
116+
const listItem = alteredListItems[0] as ListItemNode;
117+
let child = listItem.getChildren()[0] as TextNode;
118+
if (!child) {
119+
child = $createTextNode('');
120+
listItem.append(child);
121+
}
122+
child.select(selectionBounds[0].offset, selectionBounds[1].offset);
123+
} else {
124+
$selectNodes(alteredListItems);
125+
}
126+
114127
return;
115128
}
116129

0 commit comments

Comments
 (0)