Skip to content

Commit bd874a3

Browse files
authored
[Breaking Change][lexical][lexical-selection][lexical-list] Bug Fix: Fix infinite loop when splitting invalid ListItemNode (#7037)
1 parent 541fa43 commit bd874a3

File tree

12 files changed

+320
-190
lines changed

12 files changed

+320
-190
lines changed

packages/lexical-list/README.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ The API of @lexical/list primarily consists of Lexical Nodes that encapsulate li
99

1010
## Functions
1111

12-
### insertList
12+
### $insertList
1313

1414
As the name suggests, this inserts a list of the provided type according to an algorithm that tries to determine the best way to do that based on
15-
the current Selection. For instance, if some text is selected, insertList may try to move it into the first item in the list. See the API documentation for more detail.
15+
the current Selection. For instance, if some text is selected, $insertList may try to move it into the first item in the list. See the API documentation for more detail.
1616

17-
### removeList
17+
### $removeList
1818

1919
Attempts to remove lists inside the current selection based on a set of opinionated heuristics that implement conventional editor behaviors. For instance, it converts empty ListItemNodes into empty ParagraphNodes.
2020

@@ -43,7 +43,7 @@ It's important to note that these commands don't have any functionality on their
4343
// MyListPlugin.ts
4444

4545
editor.registerCommand(INSERT_UNORDERED_LIST_COMMAND, () => {
46-
insertList(editor, 'bullet');
46+
$insertList(editor, 'bullet');
4747
return true;
4848
}, COMMAND_PRIORITY_LOW);
4949

packages/lexical-list/flow/LexicalList.js.flow

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ declare export function $isListNode(
3535
node: ?LexicalNode,
3636
): node is ListNode;
3737
declare export function indentList(): void;
38+
declare export function $insertList(
39+
listType: ListType,
40+
): void;
41+
/** @deprecated use {@link $insertList} from an update or command listener */
3842
declare export function insertList(
3943
editor: LexicalEditor,
4044
listType: ListType,
@@ -72,7 +76,9 @@ declare export class ListNode extends ElementNode {
7276
static importJSON(serializedNode: SerializedListNode): ListNode;
7377
}
7478
declare export function outdentList(): void;
75-
declare export function removeList(editor: LexicalEditor): boolean;
79+
/** @deprecated use {@link $removeList} from an update or command listener */
80+
declare export function removeList(editor: LexicalEditor): void;
81+
declare export function $removeList(): void;
7682

7783
declare export var INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void>;
7884
declare export var INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void>;

packages/lexical-list/src/formatList.ts

+105-112
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
$isRangeSelection,
1616
$isRootOrShadowRoot,
1717
ElementNode,
18-
LexicalEditor,
1918
LexicalNode,
2019
NodeKey,
2120
ParagraphNode,
@@ -58,90 +57,87 @@ function $isSelectingEmptyListItem(
5857
* If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
5958
* unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with
6059
* a new ListNode, or create a new ListNode at the nearest root/shadow root.
61-
* @param editor - The lexical editor.
6260
* @param listType - The type of list, "number" | "bullet" | "check".
6361
*/
64-
export function insertList(editor: LexicalEditor, listType: ListType): void {
65-
editor.update(() => {
66-
const selection = $getSelection();
67-
68-
if (selection !== null) {
69-
const nodes = selection.getNodes();
70-
if ($isRangeSelection(selection)) {
71-
const anchorAndFocus = selection.getStartEndPoints();
72-
invariant(
73-
anchorAndFocus !== null,
74-
'insertList: anchor should be defined',
75-
);
76-
const [anchor] = anchorAndFocus;
77-
const anchorNode = anchor.getNode();
78-
const anchorNodeParent = anchorNode.getParent();
79-
80-
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
81-
const list = $createListNode(listType);
82-
83-
if ($isRootOrShadowRoot(anchorNodeParent)) {
84-
anchorNode.replace(list);
85-
const listItem = $createListItemNode();
86-
if ($isElementNode(anchorNode)) {
87-
listItem.setFormat(anchorNode.getFormatType());
88-
listItem.setIndent(anchorNode.getIndent());
89-
}
90-
list.append(listItem);
91-
} else if ($isListItemNode(anchorNode)) {
92-
const parent = anchorNode.getParentOrThrow();
93-
append(list, parent.getChildren());
94-
parent.replace(list);
95-
}
62+
export function $insertList(listType: ListType): void {
63+
const selection = $getSelection();
64+
65+
if (selection !== null) {
66+
const nodes = selection.getNodes();
67+
if ($isRangeSelection(selection)) {
68+
const anchorAndFocus = selection.getStartEndPoints();
69+
invariant(
70+
anchorAndFocus !== null,
71+
'insertList: anchor should be defined',
72+
);
73+
const [anchor] = anchorAndFocus;
74+
const anchorNode = anchor.getNode();
75+
const anchorNodeParent = anchorNode.getParent();
9676

97-
return;
77+
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
78+
const list = $createListNode(listType);
79+
80+
if ($isRootOrShadowRoot(anchorNodeParent)) {
81+
anchorNode.replace(list);
82+
const listItem = $createListItemNode();
83+
if ($isElementNode(anchorNode)) {
84+
listItem.setFormat(anchorNode.getFormatType());
85+
listItem.setIndent(anchorNode.getIndent());
86+
}
87+
list.append(listItem);
88+
} else if ($isListItemNode(anchorNode)) {
89+
const parent = anchorNode.getParentOrThrow();
90+
append(list, parent.getChildren());
91+
parent.replace(list);
9892
}
93+
94+
return;
9995
}
96+
}
10097

101-
const handled = new Set();
102-
for (let i = 0; i < nodes.length; i++) {
103-
const node = nodes[i];
98+
const handled = new Set();
99+
for (let i = 0; i < nodes.length; i++) {
100+
const node = nodes[i];
101+
102+
if (
103+
$isElementNode(node) &&
104+
node.isEmpty() &&
105+
!$isListItemNode(node) &&
106+
!handled.has(node.getKey())
107+
) {
108+
$createListOrMerge(node, listType);
109+
continue;
110+
}
104111

105-
if (
106-
$isElementNode(node) &&
107-
node.isEmpty() &&
108-
!$isListItemNode(node) &&
109-
!handled.has(node.getKey())
110-
) {
111-
$createListOrMerge(node, listType);
112-
continue;
113-
}
112+
if ($isLeafNode(node)) {
113+
let parent = node.getParent();
114+
while (parent != null) {
115+
const parentKey = parent.getKey();
116+
117+
if ($isListNode(parent)) {
118+
if (!handled.has(parentKey)) {
119+
const newListNode = $createListNode(listType);
120+
append(newListNode, parent.getChildren());
121+
parent.replace(newListNode);
122+
handled.add(parentKey);
123+
}
114124

115-
if ($isLeafNode(node)) {
116-
let parent = node.getParent();
117-
while (parent != null) {
118-
const parentKey = parent.getKey();
119-
120-
if ($isListNode(parent)) {
121-
if (!handled.has(parentKey)) {
122-
const newListNode = $createListNode(listType);
123-
append(newListNode, parent.getChildren());
124-
parent.replace(newListNode);
125-
handled.add(parentKey);
126-
}
125+
break;
126+
} else {
127+
const nextParent = parent.getParent();
127128

129+
if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
130+
handled.add(parentKey);
131+
$createListOrMerge(parent, listType);
128132
break;
129-
} else {
130-
const nextParent = parent.getParent();
131-
132-
if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
133-
handled.add(parentKey);
134-
$createListOrMerge(parent, listType);
135-
break;
136-
}
137-
138-
parent = nextParent;
139133
}
134+
135+
parent = nextParent;
140136
}
141137
}
142138
}
143139
}
144-
});
140+
}
145141
}
146142

147143
function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
@@ -223,65 +219,62 @@ export function mergeLists(list1: ListNode, list2: ListNode): void {
223219
* it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
224220
* removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
225221
* inside a ListItemNode will be appended to the new ParagraphNodes.
226-
* @param editor - The lexical editor.
227222
*/
228-
export function removeList(editor: LexicalEditor): void {
229-
editor.update(() => {
230-
const selection = $getSelection();
223+
export function $removeList(): void {
224+
const selection = $getSelection();
231225

232-
if ($isRangeSelection(selection)) {
233-
const listNodes = new Set<ListNode>();
234-
const nodes = selection.getNodes();
235-
const anchorNode = selection.anchor.getNode();
226+
if ($isRangeSelection(selection)) {
227+
const listNodes = new Set<ListNode>();
228+
const nodes = selection.getNodes();
229+
const anchorNode = selection.anchor.getNode();
236230

237-
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
238-
listNodes.add($getTopListNode(anchorNode));
239-
} else {
240-
for (let i = 0; i < nodes.length; i++) {
241-
const node = nodes[i];
231+
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
232+
listNodes.add($getTopListNode(anchorNode));
233+
} else {
234+
for (let i = 0; i < nodes.length; i++) {
235+
const node = nodes[i];
242236

243-
if ($isLeafNode(node)) {
244-
const listItemNode = $getNearestNodeOfType(node, ListItemNode);
237+
if ($isLeafNode(node)) {
238+
const listItemNode = $getNearestNodeOfType(node, ListItemNode);
245239

246-
if (listItemNode != null) {
247-
listNodes.add($getTopListNode(listItemNode));
248-
}
240+
if (listItemNode != null) {
241+
listNodes.add($getTopListNode(listItemNode));
249242
}
250243
}
251244
}
245+
}
252246

253-
for (const listNode of listNodes) {
254-
let insertionPoint: ListNode | ParagraphNode = listNode;
255-
256-
const listItems = $getAllListItems(listNode);
247+
for (const listNode of listNodes) {
248+
let insertionPoint: ListNode | ParagraphNode = listNode;
257249

258-
for (const listItemNode of listItems) {
259-
const paragraph = $createParagraphNode();
250+
const listItems = $getAllListItems(listNode);
260251

261-
append(paragraph, listItemNode.getChildren());
252+
for (const listItemNode of listItems) {
253+
const paragraph = $createParagraphNode();
262254

263-
insertionPoint.insertAfter(paragraph);
264-
insertionPoint = paragraph;
255+
append(paragraph, listItemNode.getChildren());
265256

266-
// When the anchor and focus fall on the textNode
267-
// we don't have to change the selection because the textNode will be appended to
268-
// the newly generated paragraph.
269-
// When selection is in empty nested list item, selection is actually on the listItemNode.
270-
// When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
271-
// we should manually set the selection's focus and anchor to the newly generated paragraph.
272-
if (listItemNode.__key === selection.anchor.key) {
273-
selection.anchor.set(paragraph.getKey(), 0, 'element');
274-
}
275-
if (listItemNode.__key === selection.focus.key) {
276-
selection.focus.set(paragraph.getKey(), 0, 'element');
277-
}
257+
insertionPoint.insertAfter(paragraph);
258+
insertionPoint = paragraph;
278259

279-
listItemNode.remove();
260+
// When the anchor and focus fall on the textNode
261+
// we don't have to change the selection because the textNode will be appended to
262+
// the newly generated paragraph.
263+
// When selection is in empty nested list item, selection is actually on the listItemNode.
264+
// When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
265+
// we should manually set the selection's focus and anchor to the newly generated paragraph.
266+
if (listItemNode.__key === selection.anchor.key) {
267+
selection.anchor.set(paragraph.getKey(), 0, 'element');
268+
}
269+
if (listItemNode.__key === selection.focus.key) {
270+
selection.focus.set(paragraph.getKey(), 0, 'element');
280271
}
281-
listNode.remove();
272+
273+
listItemNode.remove();
282274
}
275+
listNode.remove();
283276
}
284-
});
277+
}
285278
}
286279

287280
/**

0 commit comments

Comments
 (0)