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.
This commit is contained in:
		
							parent
							
								
									fca8f928a3
								
							
						
					
					
						commit
						f4005a139b
					
				| 
						 | 
				
			
			@ -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();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'}]},
 | 
			
		||||
                                    ],
 | 
			
		||||
                                }
 | 
			
		||||
                            ],
 | 
			
		||||
                        }
 | 
			
		||||
                    ]
 | 
			
		||||
                }
 | 
			
		||||
            ]);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue