From ace8af077dfa5173c24cdf8b50eb82ccbd1dbf7e Mon Sep 17 00:00:00 2001 From: Dan Brown 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; }