Skip to content

Commit f4005a1

Browse files
committed
Lexical: Adjusted handling of child/sibling list items on nesting
Sibling/child items will now remain at the same visual level during nesting/un-nested, so only the selected item level is visually altered. Also added new model-based editor content matching system for tests.
1 parent fca8f92 commit f4005a1

File tree

3 files changed

+183
-6
lines changed

3 files changed

+183
-6
lines changed

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

+37-6
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,14 @@ import {
3030
TextNode,
3131
} from 'lexical';
3232

33-
import {
34-
CreateEditorArgs,
35-
HTMLConfig,
36-
LexicalNodeReplacement,
37-
} from '../../LexicalEditor';
33+
import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
3834
import {resetRandomKey} from '../../LexicalUtils';
3935
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
4036
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
4137
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
4238
import {EditorUiContext} from "../../../../ui/framework/core";
4339
import {EditorUIManager} from "../../../../ui/framework/manager";
44-
import {registerRichText} from "@lexical/rich-text";
40+
import {turtle} from "@codemirror/legacy-modes/mode/turtle";
4541

4642

4743
type TestEnv = {
@@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
764760
expect(formatHtml(expected)).toBe(formatHtml(actual));
765761
}
766762

763+
type nodeTextShape = {
764+
text: string;
765+
};
766+
767+
type nodeShape = {
768+
type: string;
769+
children?: (nodeShape|nodeTextShape)[];
770+
}
771+
772+
export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
773+
// @ts-ignore
774+
const children: SerializedLexicalNode[] = (node.children || []);
775+
776+
const shape: nodeShape = {
777+
type: node.type,
778+
};
779+
780+
if (shape.type === 'text') {
781+
// @ts-ignore
782+
return {text: node.text}
783+
}
784+
785+
if (children.length > 0) {
786+
shape.children = children.map(c => getNodeShape(c));
787+
}
788+
789+
return shape;
790+
}
791+
792+
export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
793+
const json = editor.getEditorState().toJSON();
794+
const shape = getNodeShape(json.root) as nodeShape;
795+
expect(shape.children).toMatchObject(expected);
796+
}
797+
767798
function formatHtml(s: string): string {
768799
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
769800
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
createTestContext, destroyFromContext,
3+
dispatchKeydownEventForNode, expectNodeShapeToMatch,
4+
} from "lexical/__tests__/utils";
5+
import {
6+
$createParagraphNode, $getRoot, LexicalEditor, LexicalNode,
7+
ParagraphNode,
8+
} from "lexical";
9+
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
10+
import {EditorUiContext} from "../../ui/framework/core";
11+
import {$htmlToBlockNodes} from "../nodes";
12+
import {ListItemNode, ListNode} from "@lexical/list";
13+
import {$nestListItem, $unnestListItem} from "../lists";
14+
15+
describe('List Utils', () => {
16+
17+
let context!: EditorUiContext;
18+
let editor!: LexicalEditor;
19+
20+
beforeEach(() => {
21+
context = createTestContext();
22+
editor = context.editor;
23+
});
24+
25+
afterEach(() => {
26+
destroyFromContext(context);
27+
});
28+
29+
describe('$nestListItem', () => {
30+
test('nesting handles child items to leave at the same level', () => {
31+
const input = `<ul>
32+
<li>Inner A</li>
33+
<li>Inner B <ul>
34+
<li>Inner C</li>
35+
</ul></li>
36+
</ul>`;
37+
let list!: ListNode;
38+
39+
editor.updateAndCommit(() => {
40+
$getRoot().append(...$htmlToBlockNodes(editor, input));
41+
list = $getRoot().getFirstChild() as ListNode;
42+
});
43+
44+
editor.updateAndCommit(() => {
45+
$nestListItem(list.getChildren()[1] as ListItemNode);
46+
});
47+
48+
expectNodeShapeToMatch(editor, [
49+
{
50+
type: 'list',
51+
children: [
52+
{
53+
type: 'listitem',
54+
children: [
55+
{text: 'Inner A'},
56+
{
57+
type: 'list',
58+
children: [
59+
{type: 'listitem', children: [{text: 'Inner B'}]},
60+
{type: 'listitem', children: [{text: 'Inner C'}]},
61+
]
62+
}
63+
]
64+
},
65+
]
66+
}
67+
]);
68+
});
69+
});
70+
71+
describe('$unnestListItem', () => {
72+
test('middle in nested list converts to new parent item at same place', () => {
73+
const input = `<ul>
74+
<li>Nested list:<ul>
75+
<li>Inner A</li>
76+
<li>Inner B</li>
77+
<li>Inner C</li>
78+
</ul></li>
79+
</ul>`;
80+
let innerList!: ListNode;
81+
82+
editor.updateAndCommit(() => {
83+
$getRoot().append(...$htmlToBlockNodes(editor, input));
84+
innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode;
85+
});
86+
87+
editor.updateAndCommit(() => {
88+
$unnestListItem(innerList.getChildren()[1] as ListItemNode);
89+
});
90+
91+
expectNodeShapeToMatch(editor, [
92+
{
93+
type: 'list',
94+
children: [
95+
{
96+
type: 'listitem',
97+
children: [
98+
{text: 'Nested list:'},
99+
{
100+
type: 'list',
101+
children: [
102+
{type: 'listitem', children: [{text: 'Inner A'}]},
103+
],
104+
}
105+
],
106+
},
107+
{
108+
type: 'listitem',
109+
children: [
110+
{text: 'Inner B'},
111+
{
112+
type: 'list',
113+
children: [
114+
{type: 'listitem', children: [{text: 'Inner C'}]},
115+
],
116+
}
117+
],
118+
}
119+
]
120+
}
121+
]);
122+
});
123+
});
124+
});

resources/js/wysiwyg/utils/lists.ts

+22
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
1010
return node;
1111
}
1212

13+
const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
14+
const nodeChildItems = nodeChildList?.getChildren() || [];
15+
1316
const listItems = list.getChildren() as ListItemNode[];
1417
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
1518
const isFirst = nodeIndex === 0;
@@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
2730
node.remove();
2831
}
2932

33+
if (nodeChildList) {
34+
for (const child of nodeChildItems) {
35+
newListItem.insertAfter(child);
36+
}
37+
nodeChildList.remove();
38+
}
39+
3040
return newListItem;
3141
}
3242

@@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
3848
return node;
3949
}
4050

51+
const laterSiblings = node.getNextSiblings();
52+
4153
parentListItem.insertAfter(node);
4254
if (list.getChildren().length === 0) {
4355
list.remove();
@@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
4759
parentListItem.remove();
4860
}
4961

62+
if (laterSiblings.length > 0) {
63+
const childList = $createListNode(list.getListType());
64+
childList.append(...laterSiblings);
65+
node.append(childList);
66+
}
67+
68+
if (list.getChildrenSize() === 0) {
69+
list.remove();
70+
}
71+
5072
return node;
5173
}
5274

0 commit comments

Comments
 (0)