1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-30 04:23:11 +03:00

Lexical: Imported core lexical libs

Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
This commit is contained in:
Dan Brown
2024-09-18 13:43:39 +01:00
parent 03490d6597
commit 22d078b47f
128 changed files with 54875 additions and 208 deletions

View File

@ -0,0 +1,552 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {ListNode, ListType} from './';
import type {
BaseSelection,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
EditorThemeClasses,
LexicalNode,
NodeKey,
ParagraphNode,
RangeSelection,
SerializedElementNode,
Spread,
} from 'lexical';
import {
addClassNamesToElement,
removeClassNamesFromElement,
} from '@lexical/utils';
import {
$applyNodeReplacement,
$createParagraphNode,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
ElementNode,
LexicalEditor,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
import {$createListNode, $isListNode} from './';
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
import {isNestedListNode} from './utils';
export type SerializedListItemNode = Spread<
{
checked: boolean | undefined;
value: number;
},
SerializedElementNode
>;
/** @noInheritDoc */
export class ListItemNode extends ElementNode {
/** @internal */
__value: number;
/** @internal */
__checked?: boolean;
static getType(): string {
return 'listitem';
}
static clone(node: ListItemNode): ListItemNode {
return new ListItemNode(node.__value, node.__checked, node.__key);
}
constructor(value?: number, checked?: boolean, key?: NodeKey) {
super(key);
this.__value = value === undefined ? 1 : value;
this.__checked = checked;
}
createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('li');
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(element, this, null, parent);
}
element.value = this.__value;
$setListItemThemeClassNames(element, config.theme, this);
return element;
}
updateDOM(
prevNode: ListItemNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(dom, this, prevNode, parent);
}
// @ts-expect-error - this is always HTMLListItemElement
dom.value = this.__value;
$setListItemThemeClassNames(dom, config.theme, this);
return false;
}
static transform(): (node: LexicalNode) => void {
return (node: LexicalNode) => {
invariant($isListItemNode(node), 'node is not a ListItemNode');
if (node.__checked == null) {
return;
}
const parent = node.getParent();
if ($isListNode(parent)) {
if (parent.getListType() !== 'check' && node.getChecked() != null) {
node.setChecked(undefined);
}
}
};
}
static importDOM(): DOMConversionMap | null {
return {
li: () => ({
conversion: $convertListItemElement,
priority: 0,
}),
};
}
static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
const node = $createListItemNode();
node.setChecked(serializedNode.checked);
node.setValue(serializedNode.value);
node.setFormat(serializedNode.format);
node.setDirection(serializedNode.direction);
return node;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = this.createDOM(editor._config);
element.style.textAlign = this.getFormatType();
return {
element,
};
}
exportJSON(): SerializedListItemNode {
return {
...super.exportJSON(),
checked: this.getChecked(),
type: 'listitem',
value: this.getValue(),
version: 1,
};
}
append(...nodes: LexicalNode[]): this {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isElementNode(node) && this.canMergeWith(node)) {
const children = node.getChildren();
this.append(...children);
node.remove();
} else {
super.append(node);
}
}
return this;
}
replace<N extends LexicalNode>(
replaceWithNode: N,
includeChildren?: boolean,
): N {
if ($isListItemNode(replaceWithNode)) {
return super.replace(replaceWithNode);
}
this.setIndent(0);
const list = this.getParentOrThrow();
if (!$isListNode(list)) {
return replaceWithNode;
}
if (list.__first === this.getKey()) {
list.insertBefore(replaceWithNode);
} else if (list.__last === this.getKey()) {
list.insertAfter(replaceWithNode);
} else {
// Split the list
const newList = $createListNode(list.getListType());
let nextSibling = this.getNextSibling();
while (nextSibling) {
const nodeToAppend = nextSibling;
nextSibling = nextSibling.getNextSibling();
newList.append(nodeToAppend);
}
list.insertAfter(replaceWithNode);
replaceWithNode.insertAfter(newList);
}
if (includeChildren) {
invariant(
$isElementNode(replaceWithNode),
'includeChildren should only be true for ElementNodes',
);
this.getChildren().forEach((child: LexicalNode) => {
replaceWithNode.append(child);
});
}
this.remove();
if (list.getChildrenSize() === 0) {
list.remove();
}
return replaceWithNode;
}
insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
const listNode = this.getParentOrThrow();
if (!$isListNode(listNode)) {
invariant(
false,
'insertAfter: list node is not parent of list item node',
);
}
if ($isListItemNode(node)) {
return super.insertAfter(node, restoreSelection);
}
const siblings = this.getNextSiblings();
// Split the lists and insert the node in between them
listNode.insertAfter(node, restoreSelection);
if (siblings.length !== 0) {
const newListNode = $createListNode(listNode.getListType());
siblings.forEach((sibling) => newListNode.append(sibling));
node.insertAfter(newListNode, restoreSelection);
}
return node;
}
remove(preserveEmptyParent?: boolean): void {
const prevSibling = this.getPreviousSibling();
const nextSibling = this.getNextSibling();
super.remove(preserveEmptyParent);
if (
prevSibling &&
nextSibling &&
isNestedListNode(prevSibling) &&
isNestedListNode(nextSibling)
) {
mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
nextSibling.remove();
}
}
insertNewAfter(
_: RangeSelection,
restoreSelection = true,
): ListItemNode | ParagraphNode {
const newElement = $createListItemNode(
this.__checked == null ? undefined : false,
);
this.insertAfter(newElement, restoreSelection);
return newElement;
}
collapseAtStart(selection: RangeSelection): true {
const paragraph = $createParagraphNode();
const children = this.getChildren();
children.forEach((child) => paragraph.append(child));
const listNode = this.getParentOrThrow();
const listNodeParent = listNode.getParentOrThrow();
const isIndented = $isListItemNode(listNodeParent);
if (listNode.getChildrenSize() === 1) {
if (isIndented) {
// if the list node is nested, we just want to remove it,
// effectively unindenting it.
listNode.remove();
listNodeParent.select();
} else {
listNode.insertBefore(paragraph);
listNode.remove();
// If we have selection on the list item, we'll need to move it
// to the paragraph
const anchor = selection.anchor;
const focus = selection.focus;
const key = paragraph.getKey();
if (anchor.type === 'element' && anchor.getNode().is(this)) {
anchor.set(key, anchor.offset, 'element');
}
if (focus.type === 'element' && focus.getNode().is(this)) {
focus.set(key, focus.offset, 'element');
}
}
} else {
listNode.insertBefore(paragraph);
this.remove();
}
return true;
}
getValue(): number {
const self = this.getLatest();
return self.__value;
}
setValue(value: number): void {
const self = this.getWritable();
self.__value = value;
}
getChecked(): boolean | undefined {
const self = this.getLatest();
let listType: ListType | undefined;
const parent = this.getParent();
if ($isListNode(parent)) {
listType = parent.getListType();
}
return listType === 'check' ? Boolean(self.__checked) : undefined;
}
setChecked(checked?: boolean): void {
const self = this.getWritable();
self.__checked = checked;
}
toggleChecked(): void {
this.setChecked(!this.__checked);
}
getIndent(): number {
// If we don't have a parent, we are likely serializing
const parent = this.getParent();
if (parent === null) {
return this.getLatest().__indent;
}
// ListItemNode should always have a ListNode for a parent.
let listNodeParent = parent.getParentOrThrow();
let indentLevel = 0;
while ($isListItemNode(listNodeParent)) {
listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
indentLevel++;
}
return indentLevel;
}
setIndent(indent: number): this {
invariant(typeof indent === 'number', 'Invalid indent value.');
indent = Math.floor(indent);
invariant(indent >= 0, 'Indent value must be non-negative.');
let currentIndent = this.getIndent();
while (currentIndent !== indent) {
if (currentIndent < indent) {
$handleIndent(this);
currentIndent++;
} else {
$handleOutdent(this);
currentIndent--;
}
}
return this;
}
/** @deprecated @internal */
canInsertAfter(node: LexicalNode): boolean {
return $isListItemNode(node);
}
/** @deprecated @internal */
canReplaceWith(replacement: LexicalNode): boolean {
return $isListItemNode(replacement);
}
canMergeWith(node: LexicalNode): boolean {
return $isParagraphNode(node) || $isListItemNode(node);
}
extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
if (!$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
return (
this.isParentOf(anchorNode) &&
this.isParentOf(focusNode) &&
this.getTextContent().length === selection.getTextContent().length
);
}
isParentRequired(): true {
return true;
}
createParentElementNode(): ElementNode {
return $createListNode('bullet');
}
canMergeWhenEmpty(): true {
return true;
}
}
function $setListItemThemeClassNames(
dom: HTMLElement,
editorThemeClasses: EditorThemeClasses,
node: ListItemNode,
): void {
const classesToAdd = [];
const classesToRemove = [];
const listTheme = editorThemeClasses.list;
const listItemClassName = listTheme ? listTheme.listitem : undefined;
let nestedListItemClassName;
if (listTheme && listTheme.nested) {
nestedListItemClassName = listTheme.nested.listitem;
}
if (listItemClassName !== undefined) {
classesToAdd.push(...normalizeClassNames(listItemClassName));
}
if (listTheme) {
const parentNode = node.getParent();
const isCheckList =
$isListNode(parentNode) && parentNode.getListType() === 'check';
const checked = node.getChecked();
if (!isCheckList || checked) {
classesToRemove.push(listTheme.listitemUnchecked);
}
if (!isCheckList || !checked) {
classesToRemove.push(listTheme.listitemChecked);
}
if (isCheckList) {
classesToAdd.push(
checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
);
}
}
if (nestedListItemClassName !== undefined) {
const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
if (node.getChildren().some((child) => $isListNode(child))) {
classesToAdd.push(...nestedListItemClasses);
} else {
classesToRemove.push(...nestedListItemClasses);
}
}
if (classesToRemove.length > 0) {
removeClassNamesFromElement(dom, ...classesToRemove);
}
if (classesToAdd.length > 0) {
addClassNamesToElement(dom, ...classesToAdd);
}
}
function updateListItemChecked(
dom: HTMLElement,
listItemNode: ListItemNode,
prevListItemNode: ListItemNode | null,
listNode: ListNode,
): void {
// Only add attributes for leaf list items
if ($isListNode(listItemNode.getFirstChild())) {
dom.removeAttribute('role');
dom.removeAttribute('tabIndex');
dom.removeAttribute('aria-checked');
} else {
dom.setAttribute('role', 'checkbox');
dom.setAttribute('tabIndex', '-1');
if (
!prevListItemNode ||
listItemNode.__checked !== prevListItemNode.__checked
) {
dom.setAttribute(
'aria-checked',
listItemNode.getChecked() ? 'true' : 'false',
);
}
}
}
function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
const isGitHubCheckList = domNode.classList.contains('task-list-item');
if (isGitHubCheckList) {
for (const child of domNode.children) {
if (child.tagName === 'INPUT') {
return $convertCheckboxInput(child);
}
}
}
const ariaCheckedAttr = domNode.getAttribute('aria-checked');
const checked =
ariaCheckedAttr === 'true'
? true
: ariaCheckedAttr === 'false'
? false
: undefined;
return {node: $createListItemNode(checked)};
}
function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
if (!isCheckboxInput) {
return {node: null};
}
const checked = domNode.hasAttribute('checked');
return {node: $createListItemNode(checked)};
}
/**
* Creates a new List Item node, passing true/false will convert it to a checkbox input.
* @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
* @returns The new List Item.
*/
export function $createListItemNode(checked?: boolean): ListItemNode {
return $applyNodeReplacement(new ListItemNode(undefined, checked));
}
/**
* Checks to see if the node is a ListItemNode.
* @param node - The node to be checked.
* @returns true if the node is a ListItemNode, false otherwise.
*/
export function $isListItemNode(
node: LexicalNode | null | undefined,
): node is ListItemNode {
return node instanceof ListItemNode;
}

View File

@ -0,0 +1,367 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {
addClassNamesToElement,
isHTMLElement,
removeClassNamesFromElement,
} from '@lexical/utils';
import {
$applyNodeReplacement,
$createTextNode,
$isElementNode,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
EditorThemeClasses,
ElementNode,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedElementNode,
Spread,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
import {
mergeNextSiblingListIfSameType,
updateChildrenListItemValue,
} from './formatList';
import {$getListDepth, $wrapInListItem} from './utils';
export type SerializedListNode = Spread<
{
listType: ListType;
start: number;
tag: ListNodeTagType;
},
SerializedElementNode
>;
export type ListType = 'number' | 'bullet' | 'check';
export type ListNodeTagType = 'ul' | 'ol';
/** @noInheritDoc */
export class ListNode extends ElementNode {
/** @internal */
__tag: ListNodeTagType;
/** @internal */
__start: number;
/** @internal */
__listType: ListType;
static getType(): string {
return 'list';
}
static clone(node: ListNode): ListNode {
const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
return new ListNode(listType, node.__start, node.__key);
}
constructor(listType: ListType, start: number, key?: NodeKey) {
super(key);
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
this.__listType = _listType;
this.__tag = _listType === 'number' ? 'ol' : 'ul';
this.__start = start;
}
getTag(): ListNodeTagType {
return this.__tag;
}
setListType(type: ListType): void {
const writable = this.getWritable();
writable.__listType = type;
writable.__tag = type === 'number' ? 'ol' : 'ul';
}
getListType(): ListType {
return this.__listType;
}
getStart(): number {
return this.__start;
}
// View
createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
const tag = this.__tag;
const dom = document.createElement(tag);
if (this.__start !== 1) {
dom.setAttribute('start', String(this.__start));
}
// @ts-expect-error Internal field.
dom.__lexicalListType = this.__listType;
$setListThemeClassNames(dom, config.theme, this);
return dom;
}
updateDOM(
prevNode: ListNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
if (prevNode.__tag !== this.__tag) {
return true;
}
$setListThemeClassNames(dom, config.theme, this);
return false;
}
static transform(): (node: LexicalNode) => void {
return (node: LexicalNode) => {
invariant($isListNode(node), 'node is not a ListNode');
mergeNextSiblingListIfSameType(node);
updateChildrenListItemValue(node);
};
}
static importDOM(): DOMConversionMap | null {
return {
ol: () => ({
conversion: $convertListNode,
priority: 0,
}),
ul: () => ({
conversion: $convertListNode,
priority: 0,
}),
};
}
static importJSON(serializedNode: SerializedListNode): ListNode {
const node = $createListNode(serializedNode.listType, serializedNode.start);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const {element} = super.exportDOM(editor);
if (element && isHTMLElement(element)) {
if (this.__start !== 1) {
element.setAttribute('start', String(this.__start));
}
if (this.__listType === 'check') {
element.setAttribute('__lexicalListType', 'check');
}
}
return {
element,
};
}
exportJSON(): SerializedListNode {
return {
...super.exportJSON(),
listType: this.getListType(),
start: this.getStart(),
tag: this.getTag(),
type: 'list',
version: 1,
};
}
canBeEmpty(): false {
return false;
}
canIndent(): false {
return false;
}
append(...nodesToAppend: LexicalNode[]): this {
for (let i = 0; i < nodesToAppend.length; i++) {
const currentNode = nodesToAppend[i];
if ($isListItemNode(currentNode)) {
super.append(currentNode);
} else {
const listItemNode = $createListItemNode();
if ($isListNode(currentNode)) {
listItemNode.append(currentNode);
} else if ($isElementNode(currentNode)) {
const textNode = $createTextNode(currentNode.getTextContent());
listItemNode.append(textNode);
} else {
listItemNode.append(currentNode);
}
super.append(listItemNode);
}
}
return this;
}
extractWithChild(child: LexicalNode): boolean {
return $isListItemNode(child);
}
}
function $setListThemeClassNames(
dom: HTMLElement,
editorThemeClasses: EditorThemeClasses,
node: ListNode,
): void {
const classesToAdd = [];
const classesToRemove = [];
const listTheme = editorThemeClasses.list;
if (listTheme !== undefined) {
const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
const listDepth = $getListDepth(node) - 1;
const normalizedListDepth = listDepth % listLevelsClassNames.length;
const listLevelClassName = listLevelsClassNames[normalizedListDepth];
const listClassName = listTheme[node.__tag];
let nestedListClassName;
const nestedListTheme = listTheme.nested;
const checklistClassName = listTheme.checklist;
if (nestedListTheme !== undefined && nestedListTheme.list) {
nestedListClassName = nestedListTheme.list;
}
if (listClassName !== undefined) {
classesToAdd.push(listClassName);
}
if (checklistClassName !== undefined && node.__listType === 'check') {
classesToAdd.push(checklistClassName);
}
if (listLevelClassName !== undefined) {
classesToAdd.push(...normalizeClassNames(listLevelClassName));
for (let i = 0; i < listLevelsClassNames.length; i++) {
if (i !== normalizedListDepth) {
classesToRemove.push(node.__tag + i);
}
}
}
if (nestedListClassName !== undefined) {
const nestedListItemClasses = normalizeClassNames(nestedListClassName);
if (listDepth > 1) {
classesToAdd.push(...nestedListItemClasses);
} else {
classesToRemove.push(...nestedListItemClasses);
}
}
}
if (classesToRemove.length > 0) {
removeClassNamesFromElement(dom, ...classesToRemove);
}
if (classesToAdd.length > 0) {
addClassNamesToElement(dom, ...classesToAdd);
}
}
/*
* This function normalizes the children of a ListNode after the conversion from HTML,
* ensuring that they are all ListItemNodes and contain either a single nested ListNode
* or some other inline content.
*/
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
const normalizedListItems: Array<ListItemNode> = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isListItemNode(node)) {
normalizedListItems.push(node);
const children = node.getChildren();
if (children.length > 1) {
children.forEach((child) => {
if ($isListNode(child)) {
normalizedListItems.push($wrapInListItem(child));
}
});
}
} else {
normalizedListItems.push($wrapInListItem(node));
}
}
return normalizedListItems;
}
function isDomChecklist(domNode: HTMLElement) {
if (
domNode.getAttribute('__lexicallisttype') === 'check' ||
// is github checklist
domNode.classList.contains('contains-task-list')
) {
return true;
}
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
for (const child of domNode.childNodes) {
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
return true;
}
}
return false;
}
function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
const nodeName = domNode.nodeName.toLowerCase();
let node = null;
if (nodeName === 'ol') {
// @ts-ignore
const start = domNode.start;
node = $createListNode('number', start);
} else if (nodeName === 'ul') {
if (isDomChecklist(domNode)) {
node = $createListNode('check');
} else {
node = $createListNode('bullet');
}
}
return {
after: $normalizeChildren,
node,
};
}
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
ol: 'number',
ul: 'bullet',
};
/**
* Creates a ListNode of listType.
* @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
* @param start - Where an ordered list starts its count, start = 1 if left undefined.
* @returns The new ListNode
*/
export function $createListNode(listType: ListType, start = 1): ListNode {
return $applyNodeReplacement(new ListNode(listType, start));
}
/**
* Checks to see if the node is a ListNode.
* @param node - The node to be checked.
* @returns true if the node is a ListNode, false otherwise.
*/
export function $isListNode(
node: LexicalNode | null | undefined,
): node is ListNode {
return node instanceof ListNode;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,317 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {ParagraphNode, TextNode} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
import {
$createListItemNode,
$createListNode,
$isListItemNode,
$isListNode,
ListItemNode,
ListNode,
} from '../..';
const editorConfig = Object.freeze({
namespace: '',
theme: {
list: {
ol: 'my-ol-list-class',
olDepth: [
'my-ol-list-class-1',
'my-ol-list-class-2',
'my-ol-list-class-3',
'my-ol-list-class-4',
'my-ol-list-class-5',
'my-ol-list-class-6',
'my-ol-list-class-7',
],
ul: 'my-ul-list-class',
ulDepth: [
'my-ul-list-class-1',
'my-ul-list-class-2',
'my-ul-list-class-3',
'my-ul-list-class-4',
'my-ul-list-class-5',
'my-ul-list-class-6',
'my-ul-list-class-7',
],
},
},
});
describe('LexicalListNode tests', () => {
initializeUnitTest((testEnv) => {
test('ListNode.constructor', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
expect(listNode.getType()).toBe('list');
expect(listNode.getTag()).toBe('ul');
expect(listNode.getTextContent()).toBe('');
});
// @ts-expect-error
expect(() => $createListNode()).toThrow();
});
test('ListNode.getTag()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const ulListNode = $createListNode('bullet', 1);
expect(ulListNode.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 () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
expect(listNode.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
);
expect(
listNode.createDOM({
namespace: '',
theme: {
list: {},
},
}).outerHTML,
).toBe('<ul></ul>');
expect(
listNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe('<ul></ul>');
});
});
test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode1 = $createListNode('bullet');
const listNode2 = $createListNode('bullet');
const listNode3 = $createListNode('bullet');
const listNode4 = $createListNode('bullet');
const listNode5 = $createListNode('bullet');
const listNode6 = $createListNode('bullet');
const listNode7 = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
const listItem4 = $createListItemNode();
listNode1.append(listItem1);
listItem1.append(listNode2);
listNode2.append(listItem2);
listItem2.append(listNode3);
listNode3.append(listItem3);
listItem3.append(listNode4);
listNode4.append(listItem4);
listNode4.append(listNode5);
listNode5.append(listNode6);
listNode6.append(listNode7);
expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
);
expect(
listNode1.createDOM({
namespace: '',
theme: {
list: {},
},
}).outerHTML,
).toBe('<ul></ul>');
expect(
listNode1.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe('<ul></ul>');
expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
);
expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
);
expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
);
expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
);
expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
);
expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
);
expect(
listNode5.createDOM({
namespace: '',
theme: {
list: {
...editorConfig.theme.list,
ulDepth: [
'my-ul-list-class-1',
'my-ul-list-class-2',
'my-ul-list-class-3',
],
},
},
}).outerHTML,
).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
});
});
test('ListNode.updateDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listNode = $createListNode('bullet', 1);
const domElement = listNode.createDOM(editorConfig);
expect(domElement.outerHTML).toBe(
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
);
const newListNode = $createListNode('number', 1);
const result = newListNode.updateDOM(
listNode,
domElement,
editorConfig,
);
expect(result).toBe(true);
expect(domElement.outerHTML).toBe(
'<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.clone() without list type (backward compatibility)', async () => {
const {editor} = testEnv;
await editor.update(() => {
const olNode = ListNode.clone({
__key: '1',
__start: 1,
__tag: 'ol',
} as unknown as ListNode);
const ulNode = ListNode.clone({
__key: '1',
__start: 1,
__tag: 'ul',
} as unknown as ListNode);
expect(olNode.__listType).toBe('number');
expect(ulNode.__listType).toBe('bullet');
});
});
});
});

View File

@ -0,0 +1,335 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {$createParagraphNode, $getRoot} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
import {$createListItemNode, $createListNode} from '../..';
import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils';
describe('Lexical List Utils tests', () => {
initializeUnitTest((testEnv) => {
test('getListDepth should return the 1-based depth of a list with one levels', async () => {
const editor = testEnv.editor;
editor.update(() => {
// Root
// |- ListNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
root.append(topListNode);
const result = $getListDepth(topListNode);
expect(result).toEqual(1);
});
});
test('getListDepth should return the 1-based depth of a list with two levels', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
topListNode.append(listItem2);
topListNode.append(secondLevelListNode);
secondLevelListNode.append(listItem3);
const result = $getListDepth(secondLevelListNode);
expect(result).toEqual(2);
});
});
test('getListDepth should return the 1-based depth of a list with five levels', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const listNode2 = $createListNode('bullet');
const listNode3 = $createListNode('bullet');
const listNode4 = $createListNode('bullet');
const listNode5 = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
const listItem4 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
listItem1.append(listNode2);
listNode2.append(listItem2);
listItem2.append(listNode3);
listNode3.append(listItem3);
listItem3.append(listNode4);
listNode4.append(listItem4);
listItem4.append(listNode5);
const result = $getListDepth(listNode5);
expect(result).toEqual(5);
});
});
test('getTopListNode should return the top list node when the list is a direct child of the RootNode', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
topListNode.append(listItem2);
topListNode.append(secondLevelListNode);
secondLevelListNode.append(listItem3);
const result = $getTopListNode(listItem3);
expect(result.getKey()).toEqual(topListNode.getKey());
});
});
test('getTopListNode should return the top list node when the list is not a direct child of the RootNode', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ParagraphNode
// |- ListNode
// |- ListItemNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
const root = $getRoot();
const paragraphNode = $createParagraphNode();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
root.append(paragraphNode);
paragraphNode.append(topListNode);
topListNode.append(listItem1);
topListNode.append(listItem2);
topListNode.append(secondLevelListNode);
secondLevelListNode.append(listItem3);
const result = $getTopListNode(listItem3);
expect(result.getKey()).toEqual(topListNode.getKey());
});
});
test('getTopListNode should return the top list node when the list item is deeply nested.', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ParagraphNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListItemNode
const root = $getRoot();
const paragraphNode = $createParagraphNode();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const thirdLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
const listItem4 = $createListItemNode();
root.append(paragraphNode);
paragraphNode.append(topListNode);
topListNode.append(listItem1);
listItem1.append(secondLevelListNode);
secondLevelListNode.append(listItem2);
listItem2.append(thirdLevelListNode);
thirdLevelListNode.append(listItem3);
topListNode.append(listItem4);
const result = $getTopListNode(listItem4);
expect(result.getKey()).toEqual(topListNode.getKey());
});
});
test('isLastItemInList should return true if the listItem is the last in a nested list.', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const thirdLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
listItem1.append(secondLevelListNode);
secondLevelListNode.append(listItem2);
listItem2.append(thirdLevelListNode);
thirdLevelListNode.append(listItem3);
const result = $isLastItemInList(listItem3);
expect(result).toEqual(true);
});
});
test('isLastItemInList should return true if the listItem is the last in a non-nested list.', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
topListNode.append(listItem2);
const result = $isLastItemInList(listItem2);
expect(result).toEqual(true);
});
});
test('isLastItemInList should return false if the listItem is not the last in a nested list.', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
// |- ListNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const secondLevelListNode = $createListNode('bullet');
const thirdLevelListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
const listItem3 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
listItem1.append(secondLevelListNode);
secondLevelListNode.append(listItem2);
listItem2.append(thirdLevelListNode);
thirdLevelListNode.append(listItem3);
const result = $isLastItemInList(listItem2);
expect(result).toEqual(false);
});
});
test('isLastItemInList should return true if the listItem is not the last in a non-nested list.', async () => {
const editor = testEnv.editor;
await editor.update(() => {
// Root
// |- ListNode
// |- ListItemNode
// |- ListItemNode
const root = $getRoot();
const topListNode = $createListNode('bullet');
const listItem1 = $createListItemNode();
const listItem2 = $createListItemNode();
root.append(topListNode);
topListNode.append(listItem1);
topListNode.append(listItem2);
const result = $isLastItemInList(listItem1);
expect(result).toEqual(false);
});
});
});
});

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {expect} from '@playwright/test';
import prettier from 'prettier';
// This tag function is just used to trigger prettier auto-formatting.
// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
export function html(
partials: TemplateStringsArray,
...params: string[]
): string {
let output = '';
for (let i = 0; i < partials.length; i++) {
output += partials[i];
if (i < partials.length - 1) {
output += params[i];
}
}
return output;
}
export function expectHtmlToBeEqual(expected: string, actual: string): void {
expect(prettifyHtml(expected)).toBe(prettifyHtml(actual));
}
export function prettifyHtml(s: string): string {
return prettier.format(s.replace(/\n/g, ''), {parser: 'html'});
}

View File

@ -0,0 +1,530 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {$getNearestNodeOfType} from '@lexical/utils';
import {
$createParagraphNode,
$getSelection,
$isElementNode,
$isLeafNode,
$isParagraphNode,
$isRangeSelection,
$isRootOrShadowRoot,
ElementNode,
LexicalEditor,
LexicalNode,
NodeKey,
ParagraphNode,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {
$createListItemNode,
$createListNode,
$isListItemNode,
$isListNode,
ListItemNode,
ListNode,
} from './';
import {ListType} from './LexicalListNode';
import {
$getAllListItems,
$getTopListNode,
$removeHighestEmptyListParent,
isNestedListNode,
} from './utils';
function $isSelectingEmptyListItem(
anchorNode: ListItemNode | LexicalNode,
nodes: Array<LexicalNode>,
): boolean {
return (
$isListItemNode(anchorNode) &&
(nodes.length === 0 ||
(nodes.length === 1 &&
anchorNode.is(nodes[0]) &&
anchorNode.getChildrenSize() === 0))
);
}
/**
* Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of
* the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode.
* Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children.
* If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
* unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with
* a new ListNode, or create a new ListNode at the nearest root/shadow root.
* @param editor - The lexical editor.
* @param listType - The type of list, "number" | "bullet" | "check".
*/
export function insertList(editor: LexicalEditor, listType: ListType): void {
editor.update(() => {
const selection = $getSelection();
if (selection !== null) {
const nodes = selection.getNodes();
if ($isRangeSelection(selection)) {
const anchorAndFocus = selection.getStartEndPoints();
invariant(
anchorAndFocus !== null,
'insertList: anchor should be defined',
);
const [anchor] = anchorAndFocus;
const anchorNode = anchor.getNode();
const anchorNodeParent = anchorNode.getParent();
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
const list = $createListNode(listType);
if ($isRootOrShadowRoot(anchorNodeParent)) {
anchorNode.replace(list);
const listItem = $createListItemNode();
if ($isElementNode(anchorNode)) {
listItem.setFormat(anchorNode.getFormatType());
listItem.setIndent(anchorNode.getIndent());
}
list.append(listItem);
} else if ($isListItemNode(anchorNode)) {
const parent = anchorNode.getParentOrThrow();
append(list, parent.getChildren());
parent.replace(list);
}
return;
}
}
const handled = new Set();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (
$isElementNode(node) &&
node.isEmpty() &&
!$isListItemNode(node) &&
!handled.has(node.getKey())
) {
$createListOrMerge(node, listType);
continue;
}
if ($isLeafNode(node)) {
let parent = node.getParent();
while (parent != null) {
const parentKey = parent.getKey();
if ($isListNode(parent)) {
if (!handled.has(parentKey)) {
const newListNode = $createListNode(listType);
append(newListNode, parent.getChildren());
parent.replace(newListNode);
handled.add(parentKey);
}
break;
} else {
const nextParent = parent.getParent();
if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
handled.add(parentKey);
$createListOrMerge(parent, listType);
break;
}
parent = nextParent;
}
}
}
}
}
});
}
function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
node.splice(node.getChildrenSize(), 0, nodesToAppend);
}
function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
if ($isListNode(node)) {
return node;
}
const previousSibling = node.getPreviousSibling();
const nextSibling = node.getNextSibling();
const listItem = $createListItemNode();
listItem.setFormat(node.getFormatType());
listItem.setIndent(node.getIndent());
append(listItem, node.getChildren());
if (
$isListNode(previousSibling) &&
listType === previousSibling.getListType()
) {
previousSibling.append(listItem);
node.remove();
// if the same type of list is on both sides, merge them.
if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
append(previousSibling, nextSibling.getChildren());
nextSibling.remove();
}
return previousSibling;
} else if (
$isListNode(nextSibling) &&
listType === nextSibling.getListType()
) {
nextSibling.getFirstChildOrThrow().insertBefore(listItem);
node.remove();
return nextSibling;
} else {
const list = $createListNode(listType);
list.append(listItem);
node.replace(list);
return list;
}
}
/**
* A recursive function that goes through each list and their children, including nested lists,
* appending list2 children after list1 children and updating ListItemNode values.
* @param list1 - The first list to be merged.
* @param list2 - The second list to be merged.
*/
export function mergeLists(list1: ListNode, list2: ListNode): void {
const listItem1 = list1.getLastChild();
const listItem2 = list2.getFirstChild();
if (
listItem1 &&
listItem2 &&
isNestedListNode(listItem1) &&
isNestedListNode(listItem2)
) {
mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
listItem2.remove();
}
const toMerge = list2.getChildren();
if (toMerge.length > 0) {
list1.append(...toMerge);
}
list2.remove();
}
/**
* Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
* it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
* removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
* inside a ListItemNode will be appended to the new ParagraphNodes.
* @param editor - The lexical editor.
*/
export function removeList(editor: LexicalEditor): void {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const listNodes = new Set<ListNode>();
const nodes = selection.getNodes();
const anchorNode = selection.anchor.getNode();
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
listNodes.add($getTopListNode(anchorNode));
} else {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isLeafNode(node)) {
const listItemNode = $getNearestNodeOfType(node, ListItemNode);
if (listItemNode != null) {
listNodes.add($getTopListNode(listItemNode));
}
}
}
}
for (const listNode of listNodes) {
let insertionPoint: ListNode | ParagraphNode = listNode;
const listItems = $getAllListItems(listNode);
for (const listItemNode of listItems) {
const paragraph = $createParagraphNode();
append(paragraph, listItemNode.getChildren());
insertionPoint.insertAfter(paragraph);
insertionPoint = paragraph;
// When the anchor and focus fall on the textNode
// we don't have to change the selection because the textNode will be appended to
// the newly generated paragraph.
// When selection is in empty nested list item, selection is actually on the listItemNode.
// When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
// we should manually set the selection's focus and anchor to the newly generated paragraph.
if (listItemNode.__key === selection.anchor.key) {
selection.anchor.set(paragraph.getKey(), 0, 'element');
}
if (listItemNode.__key === selection.focus.key) {
selection.focus.set(paragraph.getKey(), 0, 'element');
}
listItemNode.remove();
}
listNode.remove();
}
}
});
}
/**
* Takes the value of a child ListItemNode and makes it the value the ListItemNode
* should be if it isn't already. Also ensures that checked is undefined if the
* parent does not have a list type of 'check'.
* @param list - The list whose children are updated.
*/
export function updateChildrenListItemValue(list: ListNode): void {
const isNotChecklist = list.getListType() !== 'check';
let value = list.getStart();
for (const child of list.getChildren()) {
if ($isListItemNode(child)) {
if (child.getValue() !== value) {
child.setValue(value);
}
if (isNotChecklist && child.getLatest().__checked != null) {
child.setChecked(undefined);
}
if (!$isListNode(child.getFirstChild())) {
value++;
}
}
}
}
/**
* Merge the next sibling list if same type.
* <ul> will merge with <ul>, but NOT <ul> with <ol>.
* @param list - The list whose next sibling should be potentially merged
*/
export function mergeNextSiblingListIfSameType(list: ListNode): void {
const nextSibling = list.getNextSibling();
if (
$isListNode(nextSibling) &&
list.getListType() === nextSibling.getListType()
) {
mergeLists(list, nextSibling);
}
}
/**
* Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
* create an indent effect. Won't indent ListItemNodes that have a ListNode as
* a child, but does merge sibling ListItemNodes if one has a nested ListNode.
* @param listItemNode - The ListItemNode to be indented.
*/
export function $handleIndent(listItemNode: ListItemNode): void {
// go through each node and decide where to move it.
const removed = new Set<NodeKey>();
if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
return;
}
const parent = listItemNode.getParent();
// We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
const nextSibling =
listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
const previousSibling =
listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
// if there are nested lists on either side, merge them all together.
if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
const innerList = previousSibling.getFirstChild();
if ($isListNode(innerList)) {
innerList.append(listItemNode);
const nextInnerList = nextSibling.getFirstChild();
if ($isListNode(nextInnerList)) {
const children = nextInnerList.getChildren();
append(innerList, children);
nextSibling.remove();
removed.add(nextSibling.getKey());
}
}
} else if (isNestedListNode(nextSibling)) {
// if the ListItemNode is next to a nested ListNode, merge them
const innerList = nextSibling.getFirstChild();
if ($isListNode(innerList)) {
const firstChild = innerList.getFirstChild();
if (firstChild !== null) {
firstChild.insertBefore(listItemNode);
}
}
} else if (isNestedListNode(previousSibling)) {
const innerList = previousSibling.getFirstChild();
if ($isListNode(innerList)) {
innerList.append(listItemNode);
}
} else {
// otherwise, we need to create a new nested ListNode
if ($isListNode(parent)) {
const newListItem = $createListItemNode();
const newList = $createListNode(parent.getListType());
newListItem.append(newList);
newList.append(listItemNode);
if (previousSibling) {
previousSibling.insertAfter(newListItem);
} else if (nextSibling) {
nextSibling.insertBefore(newListItem);
} else {
parent.append(newListItem);
}
}
}
}
/**
* Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
* has a great grandparent node of type ListNode, which is where the ListItemNode will reside
* within as a child.
* @param listItemNode - The ListItemNode to remove the indent (outdent).
*/
export function $handleOutdent(listItemNode: ListItemNode): void {
// go through each node and decide where to move it.
if (isNestedListNode(listItemNode)) {
return;
}
const parentList = listItemNode.getParent();
const grandparentListItem = parentList ? parentList.getParent() : undefined;
const greatGrandparentList = grandparentListItem
? grandparentListItem.getParent()
: undefined;
// If it doesn't have these ancestors, it's not indented.
if (
$isListNode(greatGrandparentList) &&
$isListItemNode(grandparentListItem) &&
$isListNode(parentList)
) {
// if it's the first child in it's parent list, insert it into the
// great grandparent list before the grandparent
const firstChild = parentList ? parentList.getFirstChild() : undefined;
const lastChild = parentList ? parentList.getLastChild() : undefined;
if (listItemNode.is(firstChild)) {
grandparentListItem.insertBefore(listItemNode);
if (parentList.isEmpty()) {
grandparentListItem.remove();
}
// if it's the last child in it's parent list, insert it into the
// great grandparent list after the grandparent.
} else if (listItemNode.is(lastChild)) {
grandparentListItem.insertAfter(listItemNode);
if (parentList.isEmpty()) {
grandparentListItem.remove();
}
} else {
// otherwise, we need to split the siblings into two new nested lists
const listType = parentList.getListType();
const previousSiblingsListItem = $createListItemNode();
const previousSiblingsList = $createListNode(listType);
previousSiblingsListItem.append(previousSiblingsList);
listItemNode
.getPreviousSiblings()
.forEach((sibling) => previousSiblingsList.append(sibling));
const nextSiblingsListItem = $createListItemNode();
const nextSiblingsList = $createListNode(listType);
nextSiblingsListItem.append(nextSiblingsList);
append(nextSiblingsList, listItemNode.getNextSiblings());
// put the sibling nested lists on either side of the grandparent list item in the great grandparent.
grandparentListItem.insertBefore(previousSiblingsListItem);
grandparentListItem.insertAfter(nextSiblingsListItem);
// replace the grandparent list item (now between the siblings) with the outdented list item.
grandparentListItem.replace(listItemNode);
}
}
}
/**
* Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
* or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
* (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
* nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
* Throws an invariant if the selection is not a child of a ListNode.
* @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
* or the selection does not contain a ListItemNode or the node already holds text.
*/
export function $handleListInsertParagraph(): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}
// Only run this code on empty list items
const anchor = selection.anchor.getNode();
if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
return false;
}
const topListNode = $getTopListNode(anchor);
const parent = anchor.getParent();
invariant(
$isListNode(parent),
'A ListItemNode must have a ListNode for a parent.',
);
const grandparent = parent.getParent();
let replacementNode;
if ($isRootOrShadowRoot(grandparent)) {
replacementNode = $createParagraphNode();
topListNode.insertAfter(replacementNode);
} else if ($isListItemNode(grandparent)) {
replacementNode = $createListItemNode();
grandparent.insertAfter(replacementNode);
} else {
return false;
}
replacementNode.select();
const nextSiblings = anchor.getNextSiblings();
if (nextSiblings.length > 0) {
const newList = $createListNode(parent.getListType());
if ($isParagraphNode(replacementNode)) {
replacementNode.insertAfter(newList);
} else {
const newListItem = $createListItemNode();
newListItem.append(newList);
replacementNode.insertAfter(newListItem);
}
nextSiblings.forEach((sibling) => {
sibling.remove();
newList.append(sibling);
});
}
// Don't leave hanging nested empty lists
$removeHighestEmptyListParent(anchor);
return true;
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {SerializedListItemNode} from './LexicalListItemNode';
import type {ListType, SerializedListNode} from './LexicalListNode';
import type {LexicalCommand} from 'lexical';
import {createCommand} from 'lexical';
import {$handleListInsertParagraph, insertList, removeList} from './formatList';
import {
$createListItemNode,
$isListItemNode,
ListItemNode,
} from './LexicalListItemNode';
import {$createListNode, $isListNode, ListNode} from './LexicalListNode';
import {$getListDepth} from './utils';
export {
$createListItemNode,
$createListNode,
$getListDepth,
$handleListInsertParagraph,
$isListItemNode,
$isListNode,
insertList,
ListItemNode,
ListNode,
ListType,
removeList,
SerializedListItemNode,
SerializedListNode,
};
export const INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void> =
createCommand('INSERT_UNORDERED_LIST_COMMAND');
export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void> = createCommand(
'INSERT_ORDERED_LIST_COMMAND',
);
export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
'INSERT_CHECK_LIST_COMMAND',
);
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
'REMOVE_LIST_COMMAND',
);

View File

@ -0,0 +1,205 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {LexicalNode, Spread} from 'lexical';
import {$findMatchingParent} from '@lexical/utils';
import invariant from 'lexical/shared/invariant';
import {
$createListItemNode,
$isListItemNode,
$isListNode,
ListItemNode,
ListNode,
} from './';
/**
* Checks the depth of listNode from the root node.
* @param listNode - The ListNode to be checked.
* @returns The depth of the ListNode.
*/
export function $getListDepth(listNode: ListNode): number {
let depth = 1;
let parent = listNode.getParent();
while (parent != null) {
if ($isListItemNode(parent)) {
const parentList = parent.getParent();
if ($isListNode(parentList)) {
depth++;
parent = parentList.getParent();
continue;
}
invariant(false, 'A ListItemNode must have a ListNode for a parent.');
}
return depth;
}
return depth;
}
/**
* Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode.
* @param listItem - The node to be checked.
* @returns The ListNode found.
*/
export function $getTopListNode(listItem: LexicalNode): ListNode {
let list = listItem.getParent<ListNode>();
if (!$isListNode(list)) {
invariant(false, 'A ListItemNode must have a ListNode for a parent.');
}
let parent: ListNode | null = list;
while (parent !== null) {
parent = parent.getParent();
if ($isListNode(parent)) {
list = parent;
}
}
return list;
}
/**
* Checks if listItem has no child ListNodes and has no ListItemNode ancestors with siblings.
* @param listItem - the ListItemNode to be checked.
* @returns true if listItem has no child ListNode and no ListItemNode ancestors with siblings, false otherwise.
*/
export function $isLastItemInList(listItem: ListItemNode): boolean {
let isLast = true;
const firstChild = listItem.getFirstChild();
if ($isListNode(firstChild)) {
return false;
}
let parent: ListItemNode | null = listItem;
while (parent !== null) {
if ($isListItemNode(parent)) {
if (parent.getNextSiblings().length > 0) {
isLast = false;
}
}
parent = parent.getParent();
}
return isLast;
}
/**
* A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children
* that are of type ListItemNode and returns them in an array.
* @param node - The ListNode to start the search.
* @returns An array containing all nodes of type ListItemNode found.
*/
// This should probably be $getAllChildrenOfType
export function $getAllListItems(node: ListNode): Array<ListItemNode> {
let listItemNodes: Array<ListItemNode> = [];
const listChildren: Array<ListItemNode> = node
.getChildren()
.filter($isListItemNode);
for (let i = 0; i < listChildren.length; i++) {
const listItemNode = listChildren[i];
const firstChild = listItemNode.getFirstChild();
if ($isListNode(firstChild)) {
listItemNodes = listItemNodes.concat($getAllListItems(firstChild));
} else {
listItemNodes.push(listItemNode);
}
}
return listItemNodes;
}
const NestedListNodeBrand: unique symbol = Symbol.for(
'@lexical/NestedListNodeBrand',
);
/**
* Checks to see if the passed node is a ListItemNode and has a ListNode as a child.
* @param node - The node to be checked.
* @returns true if the node is a ListItemNode and has a ListNode child, false otherwise.
*/
export function isNestedListNode(
node: LexicalNode | null | undefined,
): node is Spread<
{getFirstChild(): ListNode; [NestedListNodeBrand]: never},
ListItemNode
> {
return $isListItemNode(node) && $isListNode(node.getFirstChild());
}
/**
* Traverses up the tree and returns the first ListItemNode found.
* @param node - Node to start the search.
* @returns The first ListItemNode found, or null if none exist.
*/
export function $findNearestListItemNode(
node: LexicalNode,
): ListItemNode | null {
const matchingParent = $findMatchingParent(node, (parent) =>
$isListItemNode(parent),
);
return matchingParent as ListItemNode | null;
}
/**
* Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first
* ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially
* bringing the deeply nested node up the branch once. Would remove sublist if it has siblings.
* Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove().
* @param sublist - The nested ListNode or ListItemNode to be brought up the branch.
*/
export function $removeHighestEmptyListParent(
sublist: ListItemNode | ListNode,
) {
// Nodes may be repeatedly indented, to create deeply nested lists that each
// contain just one bullet.
// Our goal is to remove these (empty) deeply nested lists. The easiest
// way to do that is crawl back up the tree until we find a node that has siblings
// (e.g. is actually part of the list contents) and delete that, or delete
// the root of the list (if no list nodes have siblings.)
let emptyListPtr = sublist;
while (
emptyListPtr.getNextSibling() == null &&
emptyListPtr.getPreviousSibling() == null
) {
const parent = emptyListPtr.getParent<ListItemNode | ListNode>();
if (
parent == null ||
!($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr))
) {
break;
}
emptyListPtr = parent;
}
emptyListPtr.remove();
}
/**
* Wraps a node into a ListItemNode.
* @param node - The node to be wrapped into a ListItemNode
* @returns The ListItemNode which the passed node is wrapped in.
*/
export function $wrapInListItem(node: LexicalNode): ListItemNode {
const listItemWrapper = $createListItemNode();
return listItemWrapper.append(node);
}