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
This commit is contained in:
		
							parent
							
								
									e50cd33277
								
							
						
					
					
						commit
						ace8af077d
					
				| 
						 | 
				
			
			@ -472,16 +472,34 @@ export function createTestHeadlessEditor(
 | 
			
		|||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createTestContext(env: TestEnv): EditorUiContext {
 | 
			
		||||
export function createTestContext(): EditorUiContext {
 | 
			
		||||
 | 
			
		||||
  const container = document.createElement('div');
 | 
			
		||||
  document.body.appendChild(container);
 | 
			
		||||
 | 
			
		||||
  const scrollWrap = document.createElement('div');
 | 
			
		||||
  const editorDOM = document.createElement('div');
 | 
			
		||||
  editorDOM.setAttribute('contenteditable', 'true');
 | 
			
		||||
 | 
			
		||||
  scrollWrap.append(editorDOM);
 | 
			
		||||
  container.append(scrollWrap);
 | 
			
		||||
 | 
			
		||||
  const editor = createTestEditor({
 | 
			
		||||
    namespace: 'testing',
 | 
			
		||||
    theme: {},
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  editor.setRootElement(editorDOM);
 | 
			
		||||
 | 
			
		||||
  const context = {
 | 
			
		||||
    containerDOM: document.createElement('div'),
 | 
			
		||||
    editor: env.editor,
 | 
			
		||||
    editorDOM: document.createElement('div'),
 | 
			
		||||
    containerDOM: container,
 | 
			
		||||
    editor: editor,
 | 
			
		||||
    editorDOM: editorDOM,
 | 
			
		||||
    error(text: string | Error): void {
 | 
			
		||||
    },
 | 
			
		||||
    manager: new EditorUIManager(),
 | 
			
		||||
    options: {},
 | 
			
		||||
    scrollDOM: document.createElement('div'),
 | 
			
		||||
    scrollDOM: scrollWrap,
 | 
			
		||||
    translate(text: string): string {
 | 
			
		||||
      return "";
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext {
 | 
			
		|||
  return context;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function destroyFromContext(context: EditorUiContext) {
 | 
			
		||||
  context.containerDOM.remove();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $assertRangeSelection(selection: unknown): RangeSelection {
 | 
			
		||||
  if (!$isRangeSelection(selection)) {
 | 
			
		||||
    throw new Error(`Expected RangeSelection, got ${selection}`);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,95 +1,135 @@
 | 
			
		|||
import {
 | 
			
		||||
    createTestContext,
 | 
			
		||||
    createTestContext, destroyFromContext,
 | 
			
		||||
    dispatchKeydownEventForNode,
 | 
			
		||||
    dispatchKeydownEventForSelectedNode,
 | 
			
		||||
    initializeUnitTest
 | 
			
		||||
} from "lexical/__tests__/utils";
 | 
			
		||||
import {
 | 
			
		||||
    $createParagraphNode, $createTextNode,
 | 
			
		||||
    $getRoot, LexicalNode,
 | 
			
		||||
    ParagraphNode,
 | 
			
		||||
    $getRoot, $getSelection, LexicalEditor, LexicalNode,
 | 
			
		||||
    ParagraphNode, TextNode,
 | 
			
		||||
} from "lexical";
 | 
			
		||||
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
import {registerKeyboardHandling} from "../keyboard-handling";
 | 
			
		||||
import {registerRichText} from "@lexical/rich-text";
 | 
			
		||||
import {EditorUiContext} from "../../ui/framework/core";
 | 
			
		||||
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
 | 
			
		||||
 | 
			
		||||
describe('Keyboard-handling service tests', () => {
 | 
			
		||||
    initializeUnitTest((testEnv) => {
 | 
			
		||||
 | 
			
		||||
        test('Details: down key on last lines creates new sibling node', () => {
 | 
			
		||||
            const {editor} = testEnv;
 | 
			
		||||
    let context!: EditorUiContext;
 | 
			
		||||
    let editor!: LexicalEditor;
 | 
			
		||||
 | 
			
		||||
            registerRichText(editor);
 | 
			
		||||
            registerKeyboardHandling(createTestContext(testEnv));
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
        context = createTestContext();
 | 
			
		||||
        editor = context.editor;
 | 
			
		||||
        registerRichText(editor);
 | 
			
		||||
        registerKeyboardHandling(context);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
            let lastRootChild!: LexicalNode|null;
 | 
			
		||||
            let detailsPara!: ParagraphNode;
 | 
			
		||||
    afterEach(() => {
 | 
			
		||||
        destroyFromContext(context);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
            editor.updateAndCommit(() => {
 | 
			
		||||
                const root = $getRoot()
 | 
			
		||||
                const details = $createDetailsNode();
 | 
			
		||||
                detailsPara = $createParagraphNode();
 | 
			
		||||
                details.append(detailsPara);
 | 
			
		||||
                $getRoot().append(details);
 | 
			
		||||
                detailsPara.select();
 | 
			
		||||
    test('Details: down key on last lines creates new sibling node', () => {
 | 
			
		||||
        let lastRootChild!: LexicalNode|null;
 | 
			
		||||
        let detailsPara!: ParagraphNode;
 | 
			
		||||
 | 
			
		||||
                lastRootChild = root.getLastChild();
 | 
			
		||||
            });
 | 
			
		||||
        editor.updateAndCommit(() => {
 | 
			
		||||
            const root = $getRoot()
 | 
			
		||||
            const details = $createDetailsNode();
 | 
			
		||||
            detailsPara = $createParagraphNode();
 | 
			
		||||
            details.append(detailsPara);
 | 
			
		||||
            $getRoot().append(details);
 | 
			
		||||
            detailsPara.select();
 | 
			
		||||
 | 
			
		||||
            expect(lastRootChild).toBeInstanceOf(DetailsNode);
 | 
			
		||||
 | 
			
		||||
            dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
 | 
			
		||||
            editor.commitUpdates();
 | 
			
		||||
 | 
			
		||||
            editor.getEditorState().read(() => {
 | 
			
		||||
                lastRootChild = $getRoot().getLastChild();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            expect(lastRootChild).toBeInstanceOf(ParagraphNode);
 | 
			
		||||
            lastRootChild = root.getLastChild();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        test('Details: enter on last empy block creates new sibling node', () => {
 | 
			
		||||
            const {editor} = testEnv;
 | 
			
		||||
        expect(lastRootChild).toBeInstanceOf(DetailsNode);
 | 
			
		||||
 | 
			
		||||
            registerRichText(editor);
 | 
			
		||||
            registerKeyboardHandling(createTestContext(testEnv));
 | 
			
		||||
        dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
 | 
			
		||||
        editor.commitUpdates();
 | 
			
		||||
 | 
			
		||||
            let lastRootChild!: LexicalNode|null;
 | 
			
		||||
            let detailsPara!: ParagraphNode;
 | 
			
		||||
        editor.getEditorState().read(() => {
 | 
			
		||||
            lastRootChild = $getRoot().getLastChild();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
            editor.updateAndCommit(() => {
 | 
			
		||||
                const root = $getRoot()
 | 
			
		||||
                const details = $createDetailsNode();
 | 
			
		||||
                const text = $createTextNode('Hello!');
 | 
			
		||||
                detailsPara = $createParagraphNode();
 | 
			
		||||
                detailsPara.append(text);
 | 
			
		||||
                details.append(detailsPara);
 | 
			
		||||
                $getRoot().append(details);
 | 
			
		||||
                text.selectEnd();
 | 
			
		||||
        expect(lastRootChild).toBeInstanceOf(ParagraphNode);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
                lastRootChild = root.getLastChild();
 | 
			
		||||
            });
 | 
			
		||||
    test('Details: enter on last empty block creates new sibling node', () => {
 | 
			
		||||
        registerRichText(editor);
 | 
			
		||||
 | 
			
		||||
            expect(lastRootChild).toBeInstanceOf(DetailsNode);
 | 
			
		||||
        let lastRootChild!: LexicalNode|null;
 | 
			
		||||
        let detailsPara!: ParagraphNode;
 | 
			
		||||
 | 
			
		||||
            dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
 | 
			
		||||
            editor.commitUpdates();
 | 
			
		||||
        editor.updateAndCommit(() => {
 | 
			
		||||
            const root = $getRoot()
 | 
			
		||||
            const details = $createDetailsNode();
 | 
			
		||||
            const text = $createTextNode('Hello!');
 | 
			
		||||
            detailsPara = $createParagraphNode();
 | 
			
		||||
            detailsPara.append(text);
 | 
			
		||||
            details.append(detailsPara);
 | 
			
		||||
            $getRoot().append(details);
 | 
			
		||||
            text.selectEnd();
 | 
			
		||||
 | 
			
		||||
            dispatchKeydownEventForSelectedNode(editor, 'Enter');
 | 
			
		||||
            editor.commitUpdates();
 | 
			
		||||
            lastRootChild = root.getLastChild();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
            let detailsChildren!: LexicalNode[];
 | 
			
		||||
            let lastDetailsText!: string;
 | 
			
		||||
        expect(lastRootChild).toBeInstanceOf(DetailsNode);
 | 
			
		||||
 | 
			
		||||
            editor.getEditorState().read(() => {
 | 
			
		||||
                detailsChildren = (lastRootChild as DetailsNode).getChildren();
 | 
			
		||||
                lastRootChild = $getRoot().getLastChild();
 | 
			
		||||
                lastDetailsText = detailsChildren[0].getTextContent();
 | 
			
		||||
            });
 | 
			
		||||
        dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
 | 
			
		||||
        editor.commitUpdates();
 | 
			
		||||
 | 
			
		||||
            expect(lastRootChild).toBeInstanceOf(ParagraphNode);
 | 
			
		||||
            expect(detailsChildren).toHaveLength(1);
 | 
			
		||||
            expect(lastDetailsText).toBe('Hello!');
 | 
			
		||||
        dispatchKeydownEventForSelectedNode(editor, 'Enter');
 | 
			
		||||
        editor.commitUpdates();
 | 
			
		||||
 | 
			
		||||
        let detailsChildren!: LexicalNode[];
 | 
			
		||||
        let lastDetailsText!: string;
 | 
			
		||||
 | 
			
		||||
        editor.getEditorState().read(() => {
 | 
			
		||||
            detailsChildren = (lastRootChild as DetailsNode).getChildren();
 | 
			
		||||
            lastRootChild = $getRoot().getLastChild();
 | 
			
		||||
            lastDetailsText = detailsChildren[0].getTextContent();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        expect(lastRootChild).toBeInstanceOf(ParagraphNode);
 | 
			
		||||
        expect(detailsChildren).toHaveLength(1);
 | 
			
		||||
        expect(lastDetailsText).toBe('Hello!');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Lists: tab on empty list item insets item', () => {
 | 
			
		||||
 | 
			
		||||
        let list!: ListNode;
 | 
			
		||||
        let listItemB!: ListItemNode;
 | 
			
		||||
 | 
			
		||||
        editor.updateAndCommit(() => {
 | 
			
		||||
            const root = $getRoot();
 | 
			
		||||
            list = $createListNode('bullet');
 | 
			
		||||
            const listItemA = $createListItemNode();
 | 
			
		||||
            listItemA.append($createTextNode('Hello!'));
 | 
			
		||||
            listItemB = $createListItemNode();
 | 
			
		||||
            list.append(listItemA, listItemB);
 | 
			
		||||
            root.append(list);
 | 
			
		||||
            listItemB.selectStart();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        dispatchKeydownEventForNode(listItemB, editor, 'Tab');
 | 
			
		||||
        editor.commitUpdates();
 | 
			
		||||
 | 
			
		||||
        editor.getEditorState().read(() => {
 | 
			
		||||
            const list = $getRoot().getChildren()[0] as ListNode;
 | 
			
		||||
            const listChild = list.getChildren()[0] as ListItemNode;
 | 
			
		||||
            const children = listChild.getChildren();
 | 
			
		||||
            expect(children).toHaveLength(2);
 | 
			
		||||
            expect(children[0]).toBeInstanceOf(TextNode);
 | 
			
		||||
            expect(children[0].getTextContent()).toBe('Hello!');
 | 
			
		||||
            expect(children[1]).toBeInstanceOf(ListNode);
 | 
			
		||||
 | 
			
		||||
            const innerList = children[1] as ListNode;
 | 
			
		||||
            const selectedNode = $getSelection()?.getNodes()[0];
 | 
			
		||||
            expect(selectedNode).toBeInstanceOf(ListItemNode);
 | 
			
		||||
            expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -151,6 +151,15 @@ function getDetailsScenario(editor: LexicalEditor): {
 | 
			
		|||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function $isSingleListItem(nodes: LexicalNode[]): boolean {
 | 
			
		||||
    if (nodes.length !== 1) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const node = nodes[0];
 | 
			
		||||
    return $isListItemNode(node) || $isListItemNode(node.getParent());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Inset the nodes within selection when a range of nodes is selected
 | 
			
		||||
 * or if a list node is selected.
 | 
			
		||||
| 
						 | 
				
			
			@ -159,7 +168,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
 | 
			
		|||
    const change = event?.shiftKey ? -40 : 40;
 | 
			
		||||
    const selection = $getSelection();
 | 
			
		||||
    const nodes = selection?.getNodes() || [];
 | 
			
		||||
    if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
 | 
			
		||||
    if (nodes.length > 1 || $isSingleListItem(nodes)) {
 | 
			
		||||
        editor.update(() => {
 | 
			
		||||
            $setInsetForSelection(editor, change);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
 | 
			
		||||
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
 | 
			
		||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
 | 
			
		||||
import {nodeHasInset} from "./nodes";
 | 
			
		||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
 | 
			
		||||
| 
						 | 
				
			
			@ -93,6 +93,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
 | 
			
		|||
 | 
			
		||||
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
 | 
			
		||||
    const selection = $getSelection();
 | 
			
		||||
    const selectionBounds = selection?.getStartEndPoints();
 | 
			
		||||
    const listItemsInSelection = getListItemsForSelection(selection);
 | 
			
		||||
    const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
 | 
			
		|||
            alteredListItems.reverse();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $selectNodes(alteredListItems);
 | 
			
		||||
        if (alteredListItems.length === 1 && selectionBounds) {
 | 
			
		||||
            // Retain selection range if moving just one item
 | 
			
		||||
            const listItem = alteredListItems[0] as ListItemNode;
 | 
			
		||||
            let child = listItem.getChildren()[0] as TextNode;
 | 
			
		||||
            if (!child) {
 | 
			
		||||
                child = $createTextNode('');
 | 
			
		||||
                listItem.append(child);
 | 
			
		||||
            }
 | 
			
		||||
            child.select(selectionBounds[0].offset, selectionBounds[1].offset);
 | 
			
		||||
        } else {
 | 
			
		||||
            $selectNodes(alteredListItems);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue