From ace8af077dfa5173c24cdf8b50eb82ccbd1dbf7e Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Tue, 17 Dec 2024 14:44:10 +0000
Subject: [PATCH] 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
---
 .../lexical/core/__tests__/utils/index.ts     |  32 +++-
 .../__tests__/keyboard-handling.test.ts       | 164 +++++++++++-------
 .../js/wysiwyg/services/keyboard-handling.ts  |  11 +-
 resources/js/wysiwyg/utils/lists.ts           |  17 +-
 4 files changed, 154 insertions(+), 70 deletions(-)

diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
index 2fc57315b..7815d4f0d 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
@@ -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}`);
diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts
index 14a1ea973..0ab6935fb 100644
--- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts
+++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts
@@ -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());
         });
     });
 });
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts
index 08eed7645..ff6117b2b 100644
--- a/resources/js/wysiwyg/services/keyboard-handling.ts
+++ b/resources/js/wysiwyg/services/keyboard-handling.ts
@@ -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);
         });
diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts
index 646f341c2..2fc1c5f6b 100644
--- a/resources/js/wysiwyg/utils/lists.ts
+++ b/resources/js/wysiwyg/utils/lists.ts
@@ -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;
     }