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:
552
resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Normal file
552
resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Normal 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;
|
||||
}
|
367
resources/js/wysiwyg/lexical/list/LexicalListNode.ts
Normal file
367
resources/js/wysiwyg/lexical/list/LexicalListNode.ts
Normal 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
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
335
resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts
Normal file
335
resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
33
resources/js/wysiwyg/lexical/list/__tests__/utils.ts
Normal file
33
resources/js/wysiwyg/lexical/list/__tests__/utils.ts
Normal 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'});
|
||||
}
|
530
resources/js/wysiwyg/lexical/list/formatList.ts
Normal file
530
resources/js/wysiwyg/lexical/list/formatList.ts
Normal 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;
|
||||
}
|
50
resources/js/wysiwyg/lexical/list/index.ts
Normal file
50
resources/js/wysiwyg/lexical/list/index.ts
Normal 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',
|
||||
);
|
205
resources/js/wysiwyg/lexical/list/utils.ts
Normal file
205
resources/js/wysiwyg/lexical/list/utils.ts
Normal 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);
|
||||
}
|
Reference in New Issue
Block a user