]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Adjusted handling of child/sibling list items on nesting
authorDan Brown <redacted>
Tue, 17 Dec 2024 18:07:46 +0000 (18:07 +0000)
committerDan Brown <redacted>
Tue, 17 Dec 2024 18:07:46 +0000 (18:07 +0000)
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.

resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
resources/js/wysiwyg/utils/__tests__/lists.test.ts [new file with mode: 0644]
resources/js/wysiwyg/utils/lists.ts

index d90853b7c34330609ff2346ffbd4b089be105211..b13bba6977e882a2fc7970687e9715779c027809 100644 (file)
@@ -30,18 +30,14 @@ import {
   TextNode,
 } from 'lexical';
 
-import {
-  CreateEditorArgs,
-  HTMLConfig,
-  LexicalNodeReplacement,
-} from '../../LexicalEditor';
+import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
 import {resetRandomKey} from '../../LexicalUtils';
 import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 import {EditorUiContext} from "../../../../ui/framework/core";
 import {EditorUIManager} from "../../../../ui/framework/manager";
-import {registerRichText} from "@lexical/rich-text";
+import {turtle} from "@codemirror/legacy-modes/mode/turtle";
 
 
 type TestEnv = {
@@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
   expect(formatHtml(expected)).toBe(formatHtml(actual));
 }
 
+type nodeTextShape = {
+  text: string;
+};
+
+type nodeShape = {
+  type: string;
+  children?: (nodeShape|nodeTextShape)[];
+}
+
+export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
+  // @ts-ignore
+  const children: SerializedLexicalNode[] = (node.children || []);
+
+  const shape: nodeShape = {
+    type: node.type,
+  };
+
+  if (shape.type === 'text') {
+    // @ts-ignore
+    return  {text: node.text}
+  }
+
+  if (children.length > 0) {
+    shape.children = children.map(c => getNodeShape(c));
+  }
+
+  return shape;
+}
+
+export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
+  const json = editor.getEditorState().toJSON();
+  const shape = getNodeShape(json.root) as nodeShape;
+  expect(shape.children).toMatchObject(expected);
+}
+
 function formatHtml(s: string): string {
   return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
 }
diff --git a/resources/js/wysiwyg/utils/__tests__/lists.test.ts b/resources/js/wysiwyg/utils/__tests__/lists.test.ts
new file mode 100644 (file)
index 0000000..20dcad2
--- /dev/null
@@ -0,0 +1,124 @@
+import {
+    createTestContext, destroyFromContext,
+    dispatchKeydownEventForNode, expectNodeShapeToMatch,
+} from "lexical/__tests__/utils";
+import {
+    $createParagraphNode, $getRoot, LexicalEditor, LexicalNode,
+    ParagraphNode,
+} from "lexical";
+import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {EditorUiContext} from "../../ui/framework/core";
+import {$htmlToBlockNodes} from "../nodes";
+import {ListItemNode, ListNode} from "@lexical/list";
+import {$nestListItem, $unnestListItem} from "../lists";
+
+describe('List Utils', () => {
+
+    let context!: EditorUiContext;
+    let editor!: LexicalEditor;
+
+    beforeEach(() => {
+        context = createTestContext();
+        editor = context.editor;
+    });
+
+    afterEach(() => {
+        destroyFromContext(context);
+    });
+
+    describe('$nestListItem', () => {
+        test('nesting handles child items to leave at the same level', () => {
+            const input = `<ul>
+    <li>Inner A</li>
+    <li>Inner B <ul>
+            <li>Inner C</li>
+    </ul></li>
+</ul>`;
+            let list!: ListNode;
+
+            editor.updateAndCommit(() => {
+                $getRoot().append(...$htmlToBlockNodes(editor, input));
+                list = $getRoot().getFirstChild() as ListNode;
+            });
+
+            editor.updateAndCommit(() => {
+                $nestListItem(list.getChildren()[1] as ListItemNode);
+            });
+
+            expectNodeShapeToMatch(editor, [
+                {
+                    type: 'list',
+                    children: [
+                        {
+                            type: 'listitem',
+                            children: [
+                                {text: 'Inner A'},
+                                {
+                                    type: 'list',
+                                    children: [
+                                        {type: 'listitem', children: [{text: 'Inner B'}]},
+                                        {type: 'listitem', children: [{text: 'Inner C'}]},
+                                    ]
+                                }
+                            ]
+                        },
+                    ]
+                }
+            ]);
+        });
+    });
+
+    describe('$unnestListItem', () => {
+        test('middle in nested list converts to new parent item at same place', () => {
+            const input = `<ul>
+<li>Nested list:<ul>
+    <li>Inner A</li>
+    <li>Inner B</li>
+    <li>Inner C</li>
+</ul></li>
+</ul>`;
+            let innerList!: ListNode;
+
+            editor.updateAndCommit(() => {
+                $getRoot().append(...$htmlToBlockNodes(editor, input));
+                innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode;
+            });
+
+            editor.updateAndCommit(() => {
+                $unnestListItem(innerList.getChildren()[1] as ListItemNode);
+            });
+
+            expectNodeShapeToMatch(editor, [
+                {
+                    type: 'list',
+                    children: [
+                        {
+                            type: 'listitem',
+                            children: [
+                                {text: 'Nested list:'},
+                                {
+                                    type: 'list',
+                                    children: [
+                                        {type: 'listitem', children: [{text: 'Inner A'}]},
+                                    ],
+                                }
+                            ],
+                        },
+                        {
+                            type: 'listitem',
+                            children: [
+                                {text: 'Inner B'},
+                                {
+                                    type: 'list',
+                                    children: [
+                                        {type: 'listitem', children: [{text: 'Inner C'}]},
+                                    ],
+                                }
+                            ],
+                        }
+                    ]
+                }
+            ]);
+        });
+    });
+});
\ No newline at end of file
index 2fc1c5f6b4ed3d2a3ff14368017381e7513061d6..005b05f98167aa97b5998e47263ace9ec14a6382 100644 (file)
@@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
         return node;
     }
 
+    const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
+    const nodeChildItems = nodeChildList?.getChildren() || [];
+
     const listItems = list.getChildren() as ListItemNode[];
     const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
     const isFirst = nodeIndex === 0;
@@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
         node.remove();
     }
 
+    if (nodeChildList) {
+        for (const child of nodeChildItems) {
+            newListItem.insertAfter(child);
+        }
+        nodeChildList.remove();
+    }
+
     return newListItem;
 }
 
@@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
         return node;
     }
 
+    const laterSiblings = node.getNextSiblings();
+
     parentListItem.insertAfter(node);
     if (list.getChildren().length === 0) {
         list.remove();
@@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
         parentListItem.remove();
     }
 
+    if (laterSiblings.length > 0) {
+        const childList = $createListNode(list.getListType());
+        childList.append(...laterSiblings);
+        node.append(childList);
+    }
+
+    if (list.getChildrenSize() === 0) {
+        list.remove();
+    }
+
     return node;
 }
 
Morty Proxy This is a proxified and sanitized view of the page, visit original site.