0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-04-05 21:35:24 +00:00

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:
Dan Brown 2024-12-17 14:44:10 +00:00
parent e50cd33277
commit ace8af077d
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
4 changed files with 154 additions and 70 deletions
resources/js/wysiwyg
lexical/core/__tests__/utils
services
utils

View file

@ -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 = { const context = {
containerDOM: document.createElement('div'), containerDOM: container,
editor: env.editor, editor: editor,
editorDOM: document.createElement('div'), editorDOM: editorDOM,
error(text: string | Error): void { error(text: string | Error): void {
}, },
manager: new EditorUIManager(), manager: new EditorUIManager(),
options: {}, options: {},
scrollDOM: document.createElement('div'), scrollDOM: scrollWrap,
translate(text: string): string { translate(text: string): string {
return ""; return "";
} }
@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext {
return context; return context;
} }
export function destroyFromContext(context: EditorUiContext) {
context.containerDOM.remove();
}
export function $assertRangeSelection(selection: unknown): RangeSelection { export function $assertRangeSelection(selection: unknown): RangeSelection {
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
throw new Error(`Expected RangeSelection, got ${selection}`); throw new Error(`Expected RangeSelection, got ${selection}`);

View file

@ -1,95 +1,135 @@
import { import {
createTestContext, createTestContext, destroyFromContext,
dispatchKeydownEventForNode, dispatchKeydownEventForNode,
dispatchKeydownEventForSelectedNode, dispatchKeydownEventForSelectedNode,
initializeUnitTest
} from "lexical/__tests__/utils"; } from "lexical/__tests__/utils";
import { import {
$createParagraphNode, $createTextNode, $createParagraphNode, $createTextNode,
$getRoot, LexicalNode, $getRoot, $getSelection, LexicalEditor, LexicalNode,
ParagraphNode, ParagraphNode, TextNode,
} from "lexical"; } from "lexical";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {registerKeyboardHandling} from "../keyboard-handling"; import {registerKeyboardHandling} from "../keyboard-handling";
import {registerRichText} from "@lexical/rich-text"; 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', () => { describe('Keyboard-handling service tests', () => {
initializeUnitTest((testEnv) => {
test('Details: down key on last lines creates new sibling node', () => { let context!: EditorUiContext;
const {editor} = testEnv; let editor!: LexicalEditor;
registerRichText(editor); beforeEach(() => {
registerKeyboardHandling(createTestContext(testEnv)); context = createTestContext();
editor = context.editor;
registerRichText(editor);
registerKeyboardHandling(context);
});
let lastRootChild!: LexicalNode|null; afterEach(() => {
let detailsPara!: ParagraphNode; destroyFromContext(context);
});
editor.updateAndCommit(() => { test('Details: down key on last lines creates new sibling node', () => {
const root = $getRoot() let lastRootChild!: LexicalNode|null;
const details = $createDetailsNode(); let detailsPara!: ParagraphNode;
detailsPara = $createParagraphNode();
details.append(detailsPara);
$getRoot().append(details);
detailsPara.select();
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); lastRootChild = root.getLastChild();
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
editor.commitUpdates();
editor.getEditorState().read(() => {
lastRootChild = $getRoot().getLastChild();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
}); });
test('Details: enter on last empy block creates new sibling node', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode);
const {editor} = testEnv;
registerRichText(editor); dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
registerKeyboardHandling(createTestContext(testEnv)); editor.commitUpdates();
let lastRootChild!: LexicalNode|null; editor.getEditorState().read(() => {
let detailsPara!: ParagraphNode; lastRootChild = $getRoot().getLastChild();
});
editor.updateAndCommit(() => { expect(lastRootChild).toBeInstanceOf(ParagraphNode);
const root = $getRoot() });
const details = $createDetailsNode();
const text = $createTextNode('Hello!');
detailsPara = $createParagraphNode();
detailsPara.append(text);
details.append(detailsPara);
$getRoot().append(details);
text.selectEnd();
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.updateAndCommit(() => {
editor.commitUpdates(); 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'); lastRootChild = root.getLastChild();
editor.commitUpdates(); });
let detailsChildren!: LexicalNode[]; expect(lastRootChild).toBeInstanceOf(DetailsNode);
let lastDetailsText!: string;
editor.getEditorState().read(() => { dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
detailsChildren = (lastRootChild as DetailsNode).getChildren(); editor.commitUpdates();
lastRootChild = $getRoot().getLastChild();
lastDetailsText = detailsChildren[0].getTextContent();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode); dispatchKeydownEventForSelectedNode(editor, 'Enter');
expect(detailsChildren).toHaveLength(1); editor.commitUpdates();
expect(lastDetailsText).toBe('Hello!');
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());
}); });
}); });
}); });

View file

@ -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 * Inset the nodes within selection when a range of nodes is selected
* or if a list node 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 change = event?.shiftKey ? -40 : 40;
const selection = $getSelection(); const selection = $getSelection();
const nodes = selection?.getNodes() || []; const nodes = selection?.getNodes() || [];
if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { if (nodes.length > 1 || $isSingleListItem(nodes)) {
editor.update(() => { editor.update(() => {
$setInsetForSelection(editor, change); $setInsetForSelection(editor, change);
}); });

View file

@ -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 {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
import {nodeHasInset} from "./nodes"; import {nodeHasInset} from "./nodes";
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; 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 { export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
const selection = $getSelection(); const selection = $getSelection();
const selectionBounds = selection?.getStartEndPoints();
const listItemsInSelection = getListItemsForSelection(selection); const listItemsInSelection = getListItemsForSelection(selection);
const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
alteredListItems.reverse(); 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; return;
} }