0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-05-04 08:10:26 +00:00

Merge pull request from BookStackApp/lexical_round3

Lexical Fixes: Round 3
This commit is contained in:
Dan Brown 2025-04-02 17:23:38 +01:00 committed by GitHub
commit 78a0a2f519
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 350 additions and 201 deletions

View file

@ -112,7 +112,7 @@ export class PageEditor extends Component {
} }
savePage() { savePage() {
this.container.closest('form').submit(); this.container.closest('form').requestSubmit();
} }
async saveDraft() { async saveDraft() {

View file

@ -25,6 +25,7 @@ export class WysiwygEditor extends Component {
textDirection: this.$opts.textDirection, textDirection: this.$opts.textDirection,
translations, translations,
}); });
window.wysiwyg = this.editor;
}); });
let handlingFormSubmit = false; let handlingFormSubmit = false;
@ -38,7 +39,9 @@ export class WysiwygEditor extends Component {
handlingFormSubmit = true; handlingFormSubmit = true;
this.editor.getContentAsHtml().then(html => { this.editor.getContentAsHtml().then(html => {
this.input.value = html; this.input.value = html;
this.input.form.submit(); setTimeout(() => {
this.input.form.requestSubmit();
}, 5);
}); });
} else { } else {
handlingFormSubmit = false; handlingFormSubmit = false;

View file

@ -37,6 +37,7 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUiContext} from "../../../../ui/framework/core";
import {EditorUIManager} from "../../../../ui/framework/manager"; import {EditorUIManager} from "../../../../ui/framework/manager";
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
type TestEnv = { type TestEnv = {
readonly container: HTMLDivElement; readonly container: HTMLDivElement;
@ -484,6 +485,9 @@ export function createTestContext(): EditorUiContext {
const editor = createTestEditor({ const editor = createTestEditor({
namespace: 'testing', namespace: 'testing',
theme: {}, theme: {},
nodes: [
ImageNode,
]
}); });
editor.setRootElement(editorDOM); editor.setRootElement(editorDOM);

View file

@ -332,7 +332,19 @@ function isDomChecklist(domNode: HTMLElement) {
} }
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting. // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
for (const child of domNode.childNodes) { for (const child of domNode.childNodes) {
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) { if (!isHTMLElement(child)) {
continue;
}
if (child.hasAttribute('aria-checked')) {
return true;
}
if (child.classList.contains('task-list-item')) {
return true;
}
if (child.firstElementChild && child.firstElementChild.matches('input[type="checkbox"]')) {
return true; return true;
} }
} }

View file

@ -6,7 +6,7 @@
* *
*/ */
import {ParagraphNode, TextNode} from 'lexical'; import {ParagraphNode, TextNode} from 'lexical';
import {initializeUnitTest} from 'lexical/__tests__/utils'; import {createTestContext} from 'lexical/__tests__/utils';
import { import {
$createListItemNode, $createListItemNode,
@ -16,6 +16,7 @@ import {
ListItemNode, ListItemNode,
ListNode, ListNode,
} from '../..'; } from '../..';
import {$htmlToBlockNodes} from "../../../../utils/nodes";
const editorConfig = Object.freeze({ const editorConfig = Object.freeze({
namespace: '', namespace: '',
@ -46,123 +47,122 @@ const editorConfig = Object.freeze({
}); });
describe('LexicalListNode tests', () => { describe('LexicalListNode tests', () => {
initializeUnitTest((testEnv) => { test('ListNode.constructor', async () => {
test('ListNode.constructor', async () => { const {editor} = createTestContext();
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const listNode = $createListNode('bullet', 1); const listNode = $createListNode('bullet', 1);
expect(listNode.getType()).toBe('list'); expect(listNode.getType()).toBe('list');
expect(listNode.getTag()).toBe('ul'); expect(listNode.getTag()).toBe('ul');
expect(listNode.getTextContent()).toBe(''); expect(listNode.getTextContent()).toBe('');
});
// @ts-expect-error
expect(() => $createListNode()).toThrow();
}); });
test('ListNode.getTag()', async () => { // @ts-expect-error
const {editor} = testEnv; expect(() => $createListNode()).toThrow();
});
await editor.update(() => { test('ListNode.getTag()', async () => {
const ulListNode = $createListNode('bullet', 1); const {editor} = createTestContext();
expect(ulListNode.getTag()).toBe('ul');
const olListNode = $createListNode('number', 1); await editor.update(() => {
expect(olListNode.getTag()).toBe('ol'); const ulListNode = $createListNode('bullet', 1);
const checkListNode = $createListNode('check', 1); expect(ulListNode.getTag()).toBe('ul');
expect(checkListNode.getTag()).toBe('ul'); const olListNode = $createListNode('number', 1);
}); expect(olListNode.getTag()).toBe('ol');
const checkListNode = $createListNode('check', 1);
expect(checkListNode.getTag()).toBe('ul');
}); });
});
test('ListNode.createDOM()', async () => { test('ListNode.createDOM()', async () => {
const {editor} = testEnv; const {editor} = createTestContext();
await editor.update(() => { await editor.update(() => {
const listNode = $createListNode('bullet', 1); const listNode = $createListNode('bullet', 1);
expect(listNode.createDOM(editorConfig).outerHTML).toBe( expect(listNode.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>', '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
); );
expect( expect(
listNode.createDOM({ listNode.createDOM({
namespace: '', namespace: '',
theme: { theme: {
list: {}, list: {},
}, },
}).outerHTML, }).outerHTML,
).toBe('<ul></ul>'); ).toBe('<ul></ul>');
expect( expect(
listNode.createDOM({ listNode.createDOM({
namespace: '', namespace: '',
theme: {}, theme: {},
}).outerHTML, }).outerHTML,
).toBe('<ul></ul>'); ).toBe('<ul></ul>');
});
}); });
});
test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => { test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
const {editor} = testEnv; const {editor} = createTestContext();
await editor.update(() => { await editor.update(() => {
const listNode1 = $createListNode('bullet'); const listNode1 = $createListNode('bullet');
const listNode2 = $createListNode('bullet'); const listNode2 = $createListNode('bullet');
const listNode3 = $createListNode('bullet'); const listNode3 = $createListNode('bullet');
const listNode4 = $createListNode('bullet'); const listNode4 = $createListNode('bullet');
const listNode5 = $createListNode('bullet'); const listNode5 = $createListNode('bullet');
const listNode6 = $createListNode('bullet'); const listNode6 = $createListNode('bullet');
const listNode7 = $createListNode('bullet'); const listNode7 = $createListNode('bullet');
const listItem1 = $createListItemNode(); const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode(); const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode(); const listItem3 = $createListItemNode();
const listItem4 = $createListItemNode(); const listItem4 = $createListItemNode();
listNode1.append(listItem1); listNode1.append(listItem1);
listItem1.append(listNode2); listItem1.append(listNode2);
listNode2.append(listItem2); listNode2.append(listItem2);
listItem2.append(listNode3); listItem2.append(listNode3);
listNode3.append(listItem3); listNode3.append(listItem3);
listItem3.append(listNode4); listItem3.append(listNode4);
listNode4.append(listItem4); listNode4.append(listItem4);
listNode4.append(listNode5); listNode4.append(listNode5);
listNode5.append(listNode6); listNode5.append(listNode6);
listNode6.append(listNode7); listNode6.append(listNode7);
expect(listNode1.createDOM(editorConfig).outerHTML).toBe( expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>', '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
); );
expect( expect(
listNode1.createDOM({ listNode1.createDOM({
namespace: '', namespace: '',
theme: { theme: {
list: {}, list: {},
}, },
}).outerHTML, }).outerHTML,
).toBe('<ul></ul>'); ).toBe('<ul></ul>');
expect( expect(
listNode1.createDOM({ listNode1.createDOM({
namespace: '', namespace: '',
theme: {}, theme: {},
}).outerHTML, }).outerHTML,
).toBe('<ul></ul>'); ).toBe('<ul></ul>');
expect(listNode2.createDOM(editorConfig).outerHTML).toBe( expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-2"></ul>', '<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
); );
expect(listNode3.createDOM(editorConfig).outerHTML).toBe( expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-3"></ul>', '<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
); );
expect(listNode4.createDOM(editorConfig).outerHTML).toBe( expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-4"></ul>', '<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
); );
expect(listNode5.createDOM(editorConfig).outerHTML).toBe( expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-5"></ul>', '<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
); );
expect(listNode6.createDOM(editorConfig).outerHTML).toBe( expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-6"></ul>', '<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
); );
expect(listNode7.createDOM(editorConfig).outerHTML).toBe( expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-7"></ul>', '<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
); );
expect( expect(
listNode5.createDOM({ listNode5.createDOM({
namespace: '', namespace: '',
theme: { theme: {
@ -176,123 +176,135 @@ describe('LexicalListNode tests', () => {
}, },
}, },
}).outerHTML, }).outerHTML,
).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>'); ).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
});
}); });
});
test('ListNode.updateDOM()', async () => { test('ListNode.updateDOM()', async () => {
const {editor} = testEnv; const {editor} = createTestContext();
await editor.update(() => { await editor.update(() => {
const listNode = $createListNode('bullet', 1); const listNode = $createListNode('bullet', 1);
const domElement = listNode.createDOM(editorConfig); const domElement = listNode.createDOM(editorConfig);
expect(domElement.outerHTML).toBe( expect(domElement.outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>', '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
); );
const newListNode = $createListNode('number', 1); const newListNode = $createListNode('number', 1);
const result = newListNode.updateDOM( const result = newListNode.updateDOM(
listNode, listNode,
domElement, domElement,
editorConfig, editorConfig,
); );
expect(result).toBe(true); expect(result).toBe(true);
expect(domElement.outerHTML).toBe( expect(domElement.outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>', '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
); );
});
});
test('ListNode.append() should properly transform a ListItemNode', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const listItemNode = new ListItemNode();
const textNode = new TextNode('Hello');
listItemNode.append(textNode);
const nodesToAppend = [listItemNode];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect(listNode.getFirstChild()).toBe(listItemNode);
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
});
});
test('ListNode.append() should properly transform a ListNode', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const nestedListNode = new ListNode('bullet', 1);
const listItemNode = new ListItemNode();
const textNode = new TextNode('Hello');
listItemNode.append(textNode);
nestedListNode.append(listItemNode);
const nodesToAppend = [nestedListNode];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
nestedListNode,
);
});
});
test('ListNode.append() should properly transform a ParagraphNode', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const paragraph = new ParagraphNode();
const textNode = new TextNode('Hello');
paragraph.append(textNode);
const nodesToAppend = [paragraph];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
});
});
test('$createListNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
const createdListNode = $createListNode('bullet');
expect(listNode.__type).toEqual(createdListNode.__type);
expect(listNode.__parent).toEqual(createdListNode.__parent);
expect(listNode.__tag).toEqual(createdListNode.__tag);
expect(listNode.__key).not.toEqual(createdListNode.__key);
});
});
test('$isListNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
expect($isListNode(listNode)).toBe(true);
});
});
test('$createListNode() with tag name (backward compatibility)', async () => {
const {editor} = testEnv;
await editor.update(() => {
const numberList = $createListNode('number', 1);
const bulletList = $createListNode('bullet', 1);
expect(numberList.__listType).toBe('number');
expect(bulletList.__listType).toBe('bullet');
});
}); });
}); });
test('ListNode.append() should properly transform a ListItemNode', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const listItemNode = new ListItemNode();
const textNode = new TextNode('Hello');
listItemNode.append(textNode);
const nodesToAppend = [listItemNode];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect(listNode.getFirstChild()).toBe(listItemNode);
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
});
});
test('ListNode.append() should properly transform a ListNode', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const nestedListNode = new ListNode('bullet', 1);
const listItemNode = new ListItemNode();
const textNode = new TextNode('Hello');
listItemNode.append(textNode);
nestedListNode.append(listItemNode);
const nodesToAppend = [nestedListNode];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
nestedListNode,
);
});
});
test('ListNode.append() should properly transform a ParagraphNode', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const listNode = new ListNode('bullet', 1);
const paragraph = new ParagraphNode();
const textNode = new TextNode('Hello');
paragraph.append(textNode);
const nodesToAppend = [paragraph];
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
});
});
test('$createListNode()', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
const createdListNode = $createListNode('bullet');
expect(listNode.__type).toEqual(createdListNode.__type);
expect(listNode.__parent).toEqual(createdListNode.__parent);
expect(listNode.__tag).toEqual(createdListNode.__tag);
expect(listNode.__key).not.toEqual(createdListNode.__key);
});
});
test('$isListNode()', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
expect($isListNode(listNode)).toBe(true);
});
});
test('$createListNode() with tag name (backward compatibility)', async () => {
const {editor} = createTestContext();
await editor.update(() => {
const numberList = $createListNode('number', 1);
const bulletList = $createListNode('bullet', 1);
expect(numberList.__listType).toBe('number');
expect(bulletList.__listType).toBe('bullet');
});
});
test('importDOM handles old editor expected task list format', async () => {
const {editor} = createTestContext();
let list!: ListNode;
editor.update(() => {
const nodes = $htmlToBlockNodes(editor, `<ul><li class="task-list-item"><input checked="" disabled="" type="checkbox"> A</li></ul>`);
list = nodes[0] as ListNode;
});
expect(list).toBeInstanceOf(ListNode);
expect(list.getListType()).toBe('check');
});
}); });

View file

@ -133,7 +133,7 @@ export class ImageNode extends ElementNode {
element.addEventListener('click', e => { element.addEventListener('click', e => {
_editor.update(() => { _editor.update(() => {
$selectSingleNode(this); this.select();
}); });
}); });

View file

@ -1,7 +1,7 @@
import { import {
createTestContext, destroyFromContext, createTestContext, destroyFromContext,
dispatchKeydownEventForNode, dispatchKeydownEventForNode,
dispatchKeydownEventForSelectedNode, dispatchKeydownEventForSelectedNode, expectNodeShapeToMatch,
} from "lexical/__tests__/utils"; } from "lexical/__tests__/utils";
import { import {
$createParagraphNode, $createTextNode, $createParagraphNode, $createTextNode,
@ -13,6 +13,7 @@ import {registerKeyboardHandling} from "../keyboard-handling";
import {registerRichText} from "@lexical/rich-text"; import {registerRichText} from "@lexical/rich-text";
import {EditorUiContext} from "../../ui/framework/core"; import {EditorUiContext} from "../../ui/framework/core";
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list"; import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
import {$createImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode";
describe('Keyboard-handling service tests', () => { describe('Keyboard-handling service tests', () => {
@ -127,4 +128,34 @@ describe('Keyboard-handling service tests', () => {
expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
}); });
}); });
test('Images: up on selected image creates new paragraph if none above', () => {
let image!: ImageNode;
editor.updateAndCommit(() => {
const root = $getRoot();
const imageWrap = $createParagraphNode();
image = $createImageNode('https://example.com/cat.png');
imageWrap.append(image);
root.append(imageWrap);
image.select();
});
expectNodeShapeToMatch(editor, [{
type: 'paragraph',
children: [
{type: 'image'}
],
}]);
dispatchKeydownEventForNode(image, editor, 'ArrowUp');
expectNodeShapeToMatch(editor, [{
type: 'paragraph',
}, {
type: 'paragraph',
children: [
{type: 'image'}
],
}]);
});
}); });

View file

@ -3,7 +3,7 @@ import {
$createParagraphNode, $createParagraphNode,
$getSelection, $getSelection,
$isDecoratorNode, $isDecoratorNode,
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND, KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND, KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND, KEY_TAB_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
@ -43,7 +43,7 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
} }
/** /**
* Insert a new empty node after the selection if the selection contains a single * Insert a new empty node before/after the selection if the selection contains a single
* selected node (like image, media etc...). * selected node (like image, media etc...).
*/ */
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
@ -67,6 +67,34 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
return false; return false;
} }
function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
const selectionNodes = getLastSelection(editor)?.getNodes() || [];
if (!isSingleSelectedNode(selectionNodes)) {
return false;
}
event?.preventDefault();
const node = selectionNodes[0];
const nearestBlock = $getNearestNodeBlockParent(node) || node;
let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
editor.update(() => {
if (!target) {
target = $createParagraphNode();
if (after) {
nearestBlock.insertAfter(target)
} else {
nearestBlock.insertBefore(target);
}
}
target.selectStart();
});
return true;
}
/** /**
* Insert a new node after a details node, if inside a details node that's * Insert a new node after a details node, if inside a details node that's
* the last element, and if the cursor is at the last block within the details node. * the last element, and if the cursor is at the last block within the details node.
@ -199,8 +227,13 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
return handleInsetOnTab(context.editor, event); return handleInsetOnTab(context.editor, event);
}, COMMAND_PRIORITY_LOW); }, COMMAND_PRIORITY_LOW);
const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
}, COMMAND_PRIORITY_LOW);
const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
return insertAfterDetails(context.editor, event); return insertAfterDetails(context.editor, event)
|| focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true)
}, COMMAND_PRIORITY_LOW); }, COMMAND_PRIORITY_LOW);
return () => { return () => {
@ -208,6 +241,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
unregisterDelete(); unregisterDelete();
unregisterEnter(); unregisterEnter();
unregisterTab(); unregisterTab();
unregisterUp();
unregisterDown(); unregisterDown();
}; };
} }

View file

@ -1,6 +1,6 @@
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} 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 {$sortNodes, nodeHasInset} from "./nodes";
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
@ -49,16 +49,11 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
} }
const laterSiblings = node.getNextSiblings(); const laterSiblings = node.getNextSiblings();
parentListItem.insertAfter(node); parentListItem.insertAfter(node);
if (list.getChildren().length === 0) { if (list.getChildren().length === 0) {
list.remove(); list.remove();
} }
if (parentListItem.getChildren().length === 0) {
parentListItem.remove();
}
if (laterSiblings.length > 0) { if (laterSiblings.length > 0) {
const childList = $createListNode(list.getListType()); const childList = $createListNode(list.getListType());
childList.append(...laterSiblings); childList.append(...laterSiblings);
@ -69,23 +64,54 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
list.remove(); list.remove();
} }
if (parentListItem.getChildren().length === 0) {
parentListItem.remove();
}
return node; return node;
} }
function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] { function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
const nodes = selection?.getNodes() || []; const nodes = selection?.getNodes() || [];
const listItemNodes = []; let [start, end] = selection?.getStartEndPoints() || [null, null];
// Ensure we ignore parent list items of the top-most list item since,
// although technically part of the selection, from a user point of
// view the selection does not spread to encompass this outer element.
const itemsToIgnore: Set<string> = new Set();
if (selection && start) {
if (selection.isBackward() && end) {
[end, start] = [start, end];
}
const startParents = start.getNode().getParents();
let foundList = false;
for (const parent of startParents) {
if ($isListItemNode(parent)) {
if (foundList) {
itemsToIgnore.add(parent.getKey());
} else {
foundList = true;
}
}
}
}
const listItemNodes = [];
outer: for (const node of nodes) { outer: for (const node of nodes) {
if ($isListItemNode(node)) { if ($isListItemNode(node)) {
listItemNodes.push(node); if (!itemsToIgnore.has(node.getKey())) {
listItemNodes.push(node);
}
continue; continue;
} }
const parents = node.getParents(); const parents = node.getParents();
for (const parent of parents) { for (const parent of parents) {
if ($isListItemNode(parent)) { if ($isListItemNode(parent)) {
listItemNodes.push(parent); if (!itemsToIgnore.has(parent.getKey())) {
listItemNodes.push(parent);
}
continue outer; continue outer;
} }
} }
@ -110,7 +136,8 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
} }
} }
return Object.values(listItemMap); const items = Object.values(listItemMap);
return $sortNodes(items) as ListItemNode[];
} }
export function $setInsetForSelection(editor: LexicalEditor, change: number): void { export function $setInsetForSelection(editor: LexicalEditor, change: number): void {

View file

@ -94,6 +94,30 @@ export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null
return $findMatchingParent(node, isBlockNode); return $findMatchingParent(node, isBlockNode);
} }
export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
const idChain: string[] = [];
const addIds = (n: ElementNode) => {
for (const child of n.getChildren()) {
idChain.push(child.getKey())
if ($isElementNode(child)) {
addIds(child)
}
}
};
const root = $getRoot();
addIds(root);
const sorted = Array.from(nodes);
sorted.sort((a, b) => {
const aIndex = idChain.indexOf(a.getKey());
const bIndex = idChain.indexOf(b.getKey());
return aIndex - bIndex;
});
return sorted;
}
export function nodeHasAlignment(node: object): node is NodeHasAlignment { export function nodeHasAlignment(node: object): node is NodeHasAlignment {
return '__alignment' in node; return '__alignment' in node;
} }

View file

@ -370,8 +370,10 @@ body.editor-is-fullscreen {
display: inline-block; display: inline-block;
outline: 2px dashed var(--editor-color-primary); outline: 2px dashed var(--editor-color-primary);
direction: ltr; direction: ltr;
pointer-events: none;
} }
.editor-node-resizer-handle { .editor-node-resizer-handle {
pointer-events: auto;
position: absolute; position: absolute;
display: block; display: block;
width: 10px; width: 10px;