Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
1593 lines
55 KiB
Plaintext
1593 lines
55 KiB
Plaintext
/**
|
|
* 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, removeClassNamesFromElement, addClassNamesToElement, isHTMLElement, mergeRegister, $findMatchingParent, calculateZoomLevel } from '@lexical/utils';
|
|
import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, $createParagraphNode, $isElementNode, $isLeafNode, $setPointFromCaret, $normalizeCaret, $getChildCaret, ElementNode, buildImportMap, $isParagraphNode, $applyNodeReplacement, $createTextNode, createCommand, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, $getNearestNodeFromDOMNode, KEY_ARROW_LEFT_COMMAND, getNearestEditorFromDOMNode, $getNodeByKey, INSERT_PARAGRAPH_COMMAND, $isTextNode, TextNode } from 'lexical';
|
|
import { getStyleObjectFromCSS } from '@lexical/selection';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
// Do not require this module directly! Use normal `invariant` calls.
|
|
|
|
function formatDevErrorMessage(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
/**
|
|
* Checks the depth of listNode from the root node.
|
|
* @param listNode - The ListNode to be checked.
|
|
* @returns The depth of the ListNode.
|
|
*/
|
|
function $getListDepth(listNode) {
|
|
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;
|
|
}
|
|
{
|
|
formatDevErrorMessage(`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.
|
|
*/
|
|
function $getTopListNode(listItem) {
|
|
let list = listItem.getParent();
|
|
if (!$isListNode(list)) {
|
|
{
|
|
formatDevErrorMessage(`A ListItemNode must have a ListNode for a parent.`);
|
|
}
|
|
}
|
|
let parent = list;
|
|
while (parent !== null) {
|
|
parent = parent.getParent();
|
|
if ($isListNode(parent)) {
|
|
list = parent;
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
function $getAllListItems(node) {
|
|
let listItemNodes = [];
|
|
const listChildren = 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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function isNestedListNode(node) {
|
|
return $isListItemNode(node) && $isListNode(node.getFirstChild());
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function $removeHighestEmptyListParent(sublist) {
|
|
// 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();
|
|
if (parent == null || !($isListItemNode(parent) || $isListNode(parent))) {
|
|
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.
|
|
*/
|
|
function $wrapInListItem(node) {
|
|
const listItemWrapper = $createListItemNode();
|
|
return listItemWrapper.append(node);
|
|
}
|
|
|
|
function $isSelectingEmptyListItem(anchorNode, nodes) {
|
|
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 listType - The type of list, "number" | "bullet" | "check".
|
|
*/
|
|
function $insertList(listType) {
|
|
const selection = $getSelection();
|
|
if (selection !== null) {
|
|
let nodes = selection.getNodes();
|
|
if ($isRangeSelection(selection)) {
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
if (!(anchorAndFocus !== null)) {
|
|
formatDevErrorMessage(`insertList: anchor should be defined`);
|
|
}
|
|
const [anchor] = anchorAndFocus;
|
|
const anchorNode = anchor.getNode();
|
|
const anchorNodeParent = anchorNode.getParent();
|
|
if ($isRootOrShadowRoot(anchorNode)) {
|
|
const firstChild = anchorNode.getFirstChild();
|
|
if (firstChild) {
|
|
nodes = firstChild.selectStart().getNodes();
|
|
} else {
|
|
const paragraph = $createParagraphNode();
|
|
anchorNode.append(paragraph);
|
|
nodes = paragraph.select().getNodes();
|
|
}
|
|
} else 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;
|
|
}
|
|
let parent = $isLeafNode(node) ? node.getParent() : $isListItemNode(node) && node.isEmpty() ? node : null;
|
|
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, nodesToAppend) {
|
|
node.splice(node.getChildrenSize(), 0, nodesToAppend);
|
|
}
|
|
function $createListOrMerge(node, listType) {
|
|
if ($isListNode(node)) {
|
|
return node;
|
|
}
|
|
const previousSibling = node.getPreviousSibling();
|
|
const nextSibling = node.getNextSibling();
|
|
const listItem = $createListItemNode();
|
|
append(listItem, node.getChildren());
|
|
let targetList;
|
|
if ($isListNode(previousSibling) && listType === previousSibling.getListType()) {
|
|
previousSibling.append(listItem);
|
|
// if the same type of list is on both sides, merge them.
|
|
if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
|
|
append(previousSibling, nextSibling.getChildren());
|
|
nextSibling.remove();
|
|
}
|
|
targetList = previousSibling;
|
|
} else if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
|
|
nextSibling.getFirstChildOrThrow().insertBefore(listItem);
|
|
targetList = nextSibling;
|
|
} else {
|
|
const list = $createListNode(listType);
|
|
list.append(listItem);
|
|
node.replace(list);
|
|
targetList = list;
|
|
}
|
|
// listItem needs to be attached to root prior to setting indent
|
|
listItem.setFormat(node.getFormatType());
|
|
listItem.setIndent(node.getIndent());
|
|
node.remove();
|
|
return targetList;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function mergeLists(list1, list2) {
|
|
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.
|
|
*/
|
|
function $removeList() {
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection)) {
|
|
const listNodes = new Set();
|
|
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;
|
|
const listItems = $getAllListItems(listNode);
|
|
for (const listItemNode of listItems) {
|
|
const paragraph = $createParagraphNode().setTextStyle(selection.style).setTextFormat(selection.format);
|
|
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) {
|
|
$setPointFromCaret(selection.anchor, $normalizeCaret($getChildCaret(paragraph, 'next')));
|
|
}
|
|
if (listItemNode.__key === selection.focus.key) {
|
|
$setPointFromCaret(selection.focus, $normalizeCaret($getChildCaret(paragraph, 'next')));
|
|
}
|
|
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.
|
|
*/
|
|
function updateChildrenListItemValue(list) {
|
|
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
|
|
*/
|
|
function mergeNextSiblingListIfSameType(list) {
|
|
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.
|
|
*/
|
|
function $handleIndent(listItemNode) {
|
|
// go through each node and decide where to move it.
|
|
const removed = new Set();
|
|
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();
|
|
const previousSibling = listItemNode.getPreviousSibling();
|
|
// 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().setTextFormat(listItemNode.getTextFormat()).setTextStyle(listItemNode.getTextStyle());
|
|
const newList = $createListNode(parent.getListType()).setTextFormat(parent.getTextFormat()).setTextStyle(parent.getTextStyle());
|
|
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).
|
|
*/
|
|
function $handleOutdent(listItemNode) {
|
|
// 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 successfully, false if there is no selection
|
|
* or the selection does not contain a ListItemNode or the node already holds text.
|
|
*/
|
|
function $handleListInsertParagraph() {
|
|
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();
|
|
if (!$isListNode(parent)) {
|
|
formatDevErrorMessage(`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.setTextStyle(selection.style).setTextFormat(selection.format).select();
|
|
const nextSiblings = anchor.getNextSiblings();
|
|
if (nextSiblings.length > 0) {
|
|
const newList = $createListNode(parent.getListType());
|
|
if ($isListItemNode(replacementNode)) {
|
|
const newListItem = $createListItemNode();
|
|
newListItem.append(newList);
|
|
replacementNode.insertAfter(newListItem);
|
|
} else {
|
|
replacementNode.insertAfter(newList);
|
|
}
|
|
newList.append(...nextSiblings);
|
|
}
|
|
|
|
// Don't leave hanging nested empty lists
|
|
$removeHighestEmptyListParent(anchor);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
function normalizeClassNames(...classNames) {
|
|
const rval = [];
|
|
for (const className of classNames) {
|
|
if (className && typeof className === 'string') {
|
|
for (const [s] of className.matchAll(/\S+/g)) {
|
|
rval.push(s);
|
|
}
|
|
}
|
|
}
|
|
return rval;
|
|
}
|
|
|
|
function applyMarkerStyles(dom, node, prevNode) {
|
|
const styles = getStyleObjectFromCSS(node.__textStyle);
|
|
for (const k in styles) {
|
|
dom.style.setProperty(`--listitem-marker-${k}`, styles[k]);
|
|
}
|
|
if (prevNode) {
|
|
for (const k in getStyleObjectFromCSS(prevNode.__textStyle)) {
|
|
if (!(k in styles)) {
|
|
dom.style.removeProperty(`--listitem-marker-${k}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @noInheritDoc */
|
|
class ListItemNode extends ElementNode {
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
$config() {
|
|
return this.config('listitem', {
|
|
$transform: node => {
|
|
if (node.__checked == null) {
|
|
return;
|
|
}
|
|
const parent = node.getParent();
|
|
if ($isListNode(parent)) {
|
|
if (parent.getListType() !== 'check' && node.getChecked() != null) {
|
|
node.setChecked(undefined);
|
|
}
|
|
}
|
|
},
|
|
extends: ElementNode,
|
|
importDOM: buildImportMap({
|
|
li: () => ({
|
|
conversion: $convertListItemElement,
|
|
priority: 0
|
|
})
|
|
})
|
|
});
|
|
}
|
|
constructor(value = 1, checked = undefined, key) {
|
|
super(key);
|
|
this.__value = value === undefined ? 1 : value;
|
|
this.__checked = checked;
|
|
}
|
|
afterCloneFrom(prevNode) {
|
|
super.afterCloneFrom(prevNode);
|
|
this.__value = prevNode.__value;
|
|
this.__checked = prevNode.__checked;
|
|
}
|
|
createDOM(config) {
|
|
const element = document.createElement('li');
|
|
this.updateListItemDOM(null, element, config);
|
|
return element;
|
|
}
|
|
updateListItemDOM(prevNode, dom, config) {
|
|
const parent = this.getParent();
|
|
if ($isListNode(parent) && parent.getListType() === 'check') {
|
|
updateListItemChecked(dom, this, prevNode);
|
|
}
|
|
dom.value = this.__value;
|
|
$setListItemThemeClassNames(dom, config.theme, this);
|
|
const prevStyle = prevNode ? prevNode.__style : '';
|
|
const nextStyle = this.__style;
|
|
if (prevStyle !== nextStyle) {
|
|
if (nextStyle === '') {
|
|
dom.removeAttribute('style');
|
|
} else {
|
|
dom.style.cssText = nextStyle;
|
|
}
|
|
}
|
|
applyMarkerStyles(dom, this, prevNode);
|
|
}
|
|
updateDOM(prevNode, dom, config) {
|
|
// @ts-expect-error - this is always HTMLListItemElement
|
|
const element = dom;
|
|
this.updateListItemDOM(prevNode, element, config);
|
|
return false;
|
|
}
|
|
updateFromJSON(serializedNode) {
|
|
return super.updateFromJSON(serializedNode).setValue(serializedNode.value).setChecked(serializedNode.checked);
|
|
}
|
|
exportDOM(editor) {
|
|
const element = this.createDOM(editor._config);
|
|
const formatType = this.getFormatType();
|
|
if (formatType) {
|
|
element.style.textAlign = formatType;
|
|
}
|
|
const direction = this.getDirection();
|
|
if (direction) {
|
|
element.dir = direction;
|
|
}
|
|
return {
|
|
element
|
|
};
|
|
}
|
|
exportJSON() {
|
|
return {
|
|
...super.exportJSON(),
|
|
checked: this.getChecked(),
|
|
value: this.getValue()
|
|
};
|
|
}
|
|
append(...nodes) {
|
|
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(replaceWithNode, includeChildren) {
|
|
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) {
|
|
if (!$isElementNode(replaceWithNode)) {
|
|
formatDevErrorMessage(`includeChildren should only be true for ElementNodes`);
|
|
}
|
|
this.getChildren().forEach(child => {
|
|
replaceWithNode.append(child);
|
|
});
|
|
}
|
|
this.remove();
|
|
if (list.getChildrenSize() === 0) {
|
|
list.remove();
|
|
}
|
|
return replaceWithNode;
|
|
}
|
|
insertAfter(node, restoreSelection = true) {
|
|
const listNode = this.getParentOrThrow();
|
|
if (!$isListNode(listNode)) {
|
|
{
|
|
formatDevErrorMessage(`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) {
|
|
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(_, restoreSelection = true) {
|
|
const newElement = $createListItemNode().updateFromJSON(this.exportJSON()).setChecked(this.getChecked() ? false : undefined);
|
|
this.insertAfter(newElement, restoreSelection);
|
|
return newElement;
|
|
}
|
|
collapseAtStart(selection) {
|
|
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() {
|
|
const self = this.getLatest();
|
|
return self.__value;
|
|
}
|
|
setValue(value) {
|
|
const self = this.getWritable();
|
|
self.__value = value;
|
|
return self;
|
|
}
|
|
getChecked() {
|
|
const self = this.getLatest();
|
|
let listType;
|
|
const parent = this.getParent();
|
|
if ($isListNode(parent)) {
|
|
listType = parent.getListType();
|
|
}
|
|
return listType === 'check' ? Boolean(self.__checked) : undefined;
|
|
}
|
|
setChecked(checked) {
|
|
const self = this.getWritable();
|
|
self.__checked = checked;
|
|
return self;
|
|
}
|
|
toggleChecked() {
|
|
const self = this.getWritable();
|
|
return self.setChecked(!self.__checked);
|
|
}
|
|
getIndent() {
|
|
// If we don't have a parent, we are likely serializing
|
|
const parent = this.getParent();
|
|
if (parent === null || !this.isAttached()) {
|
|
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) {
|
|
if (!(typeof indent === 'number')) {
|
|
formatDevErrorMessage(`Invalid indent value.`);
|
|
}
|
|
indent = Math.floor(indent);
|
|
if (!(indent >= 0)) {
|
|
formatDevErrorMessage(`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) {
|
|
return $isListItemNode(node);
|
|
}
|
|
|
|
/** @deprecated @internal */
|
|
canReplaceWith(replacement) {
|
|
return $isListItemNode(replacement);
|
|
}
|
|
canMergeWith(node) {
|
|
return $isListItemNode(node) || $isParagraphNode(node);
|
|
}
|
|
extractWithChild(child, selection) {
|
|
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() {
|
|
return true;
|
|
}
|
|
createParentElementNode() {
|
|
return $createListNode('bullet');
|
|
}
|
|
canMergeWhenEmpty() {
|
|
return true;
|
|
}
|
|
}
|
|
function $setListItemThemeClassNames(dom, editorThemeClasses, node) {
|
|
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, listItemNode, prevListItemNode, listNode) {
|
|
// 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) {
|
|
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) {
|
|
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.
|
|
*/
|
|
function $createListItemNode(checked) {
|
|
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.
|
|
*/
|
|
function $isListItemNode(node) {
|
|
return node instanceof ListItemNode;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
/** @noInheritDoc */
|
|
class ListNode extends ElementNode {
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
$config() {
|
|
return this.config('list', {
|
|
$transform: node => {
|
|
mergeNextSiblingListIfSameType(node);
|
|
updateChildrenListItemValue(node);
|
|
},
|
|
extends: ElementNode,
|
|
importDOM: buildImportMap({
|
|
ol: () => ({
|
|
conversion: $convertListNode,
|
|
priority: 0
|
|
}),
|
|
ul: () => ({
|
|
conversion: $convertListNode,
|
|
priority: 0
|
|
})
|
|
})
|
|
});
|
|
}
|
|
constructor(listType = 'number', start = 1, key) {
|
|
super(key);
|
|
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
|
|
this.__listType = _listType;
|
|
this.__tag = _listType === 'number' ? 'ol' : 'ul';
|
|
this.__start = start;
|
|
}
|
|
afterCloneFrom(prevNode) {
|
|
super.afterCloneFrom(prevNode);
|
|
this.__listType = prevNode.__listType;
|
|
this.__tag = prevNode.__tag;
|
|
this.__start = prevNode.__start;
|
|
}
|
|
getTag() {
|
|
return this.getLatest().__tag;
|
|
}
|
|
setListType(type) {
|
|
const writable = this.getWritable();
|
|
writable.__listType = type;
|
|
writable.__tag = type === 'number' ? 'ol' : 'ul';
|
|
return writable;
|
|
}
|
|
getListType() {
|
|
return this.getLatest().__listType;
|
|
}
|
|
getStart() {
|
|
return this.getLatest().__start;
|
|
}
|
|
setStart(start) {
|
|
const self = this.getWritable();
|
|
self.__start = start;
|
|
return self;
|
|
}
|
|
|
|
// View
|
|
|
|
createDOM(config, _editor) {
|
|
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, dom, config) {
|
|
if (prevNode.__tag !== this.__tag) {
|
|
return true;
|
|
}
|
|
$setListThemeClassNames(dom, config.theme, this);
|
|
return false;
|
|
}
|
|
updateFromJSON(serializedNode) {
|
|
return super.updateFromJSON(serializedNode).setListType(serializedNode.listType).setStart(serializedNode.start);
|
|
}
|
|
exportDOM(editor) {
|
|
const element = this.createDOM(editor._config, editor);
|
|
if (isHTMLElement(element)) {
|
|
if (this.__start !== 1) {
|
|
element.setAttribute('start', String(this.__start));
|
|
}
|
|
if (this.__listType === 'check') {
|
|
element.setAttribute('__lexicalListType', 'check');
|
|
}
|
|
}
|
|
return {
|
|
element
|
|
};
|
|
}
|
|
exportJSON() {
|
|
return {
|
|
...super.exportJSON(),
|
|
listType: this.getListType(),
|
|
start: this.getStart(),
|
|
tag: this.getTag()
|
|
};
|
|
}
|
|
canBeEmpty() {
|
|
return false;
|
|
}
|
|
canIndent() {
|
|
return false;
|
|
}
|
|
splice(start, deleteCount, nodesToInsert) {
|
|
let listItemNodesToInsert = nodesToInsert;
|
|
for (let i = 0; i < nodesToInsert.length; i++) {
|
|
const node = nodesToInsert[i];
|
|
if (!$isListItemNode(node)) {
|
|
if (listItemNodesToInsert === nodesToInsert) {
|
|
listItemNodesToInsert = [...nodesToInsert];
|
|
}
|
|
listItemNodesToInsert[i] = $createListItemNode().append($isElementNode(node) && !($isListNode(node) || node.isInline()) ? $createTextNode(node.getTextContent()) : node);
|
|
}
|
|
}
|
|
return super.splice(start, deleteCount, listItemNodesToInsert);
|
|
}
|
|
extractWithChild(child) {
|
|
return $isListItemNode(child);
|
|
}
|
|
}
|
|
function $setListThemeClassNames(dom, editorThemeClasses, node) {
|
|
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) {
|
|
const normalizedListItems = [];
|
|
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) {
|
|
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) {
|
|
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 = {
|
|
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
|
|
*/
|
|
function $createListNode(listType = 'number', start = 1) {
|
|
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.
|
|
*/
|
|
function $isListNode(node) {
|
|
return node instanceof ListNode;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
const INSERT_CHECK_LIST_COMMAND = createCommand('INSERT_CHECK_LIST_COMMAND');
|
|
function registerCheckList(editor) {
|
|
return mergeRegister(editor.registerCommand(INSERT_CHECK_LIST_COMMAND, () => {
|
|
$insertList('check');
|
|
return true;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, event => {
|
|
return handleArrowUpOrDown(event, editor, false);
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_UP_COMMAND, event => {
|
|
return handleArrowUpOrDown(event, editor, true);
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ESCAPE_COMMAND, () => {
|
|
const activeItem = getActiveCheckListItem();
|
|
if (activeItem != null) {
|
|
const rootElement = editor.getRootElement();
|
|
if (rootElement != null) {
|
|
rootElement.focus();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_SPACE_COMMAND, event => {
|
|
const activeItem = getActiveCheckListItem();
|
|
if (activeItem != null && editor.isEditable()) {
|
|
editor.update(() => {
|
|
const listItemNode = $getNearestNodeFromDOMNode(activeItem);
|
|
if ($isListItemNode(listItemNode)) {
|
|
event.preventDefault();
|
|
listItemNode.toggleChecked();
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_LEFT_COMMAND, event => {
|
|
return editor.getEditorState().read(() => {
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
|
const {
|
|
anchor
|
|
} = selection;
|
|
const isElement = anchor.type === 'element';
|
|
if (isElement || anchor.offset === 0) {
|
|
const anchorNode = anchor.getNode();
|
|
const elementNode = $findMatchingParent(anchorNode, node => $isElementNode(node) && !node.isInline());
|
|
if ($isListItemNode(elementNode)) {
|
|
const parent = elementNode.getParent();
|
|
if ($isListNode(parent) && parent.getListType() === 'check' && (isElement || elementNode.getFirstDescendant() === anchorNode)) {
|
|
const domNode = editor.getElementByKey(elementNode.__key);
|
|
if (domNode != null && document.activeElement !== domNode) {
|
|
domNode.focus();
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}, COMMAND_PRIORITY_LOW), editor.registerRootListener((rootElement, prevElement) => {
|
|
if (rootElement !== null) {
|
|
rootElement.addEventListener('click', handleClick);
|
|
rootElement.addEventListener('pointerdown', handlePointerDown);
|
|
}
|
|
if (prevElement !== null) {
|
|
prevElement.removeEventListener('click', handleClick);
|
|
prevElement.removeEventListener('pointerdown', handlePointerDown);
|
|
}
|
|
}));
|
|
}
|
|
function handleCheckItemEvent(event, callback) {
|
|
const target = event.target;
|
|
if (!isHTMLElement(target)) {
|
|
return;
|
|
}
|
|
|
|
// Ignore clicks on LI that have nested lists
|
|
const firstChild = target.firstChild;
|
|
if (isHTMLElement(firstChild) && (firstChild.tagName === 'UL' || firstChild.tagName === 'OL')) {
|
|
return;
|
|
}
|
|
const parentNode = target.parentNode;
|
|
|
|
// @ts-ignore internal field
|
|
if (!parentNode || parentNode.__lexicalListType !== 'check') {
|
|
return;
|
|
}
|
|
const rect = target.getBoundingClientRect();
|
|
const zoom = calculateZoomLevel(target);
|
|
const clientX = event.clientX / zoom;
|
|
|
|
// Use getComputedStyle if available, otherwise fallback to 0px width
|
|
const beforeStyles = window.getComputedStyle ? window.getComputedStyle(target, '::before') : {
|
|
width: '0px'
|
|
};
|
|
const beforeWidthInPixels = parseFloat(beforeStyles.width);
|
|
|
|
// Make click area slightly larger for touch devices to improve accessibility
|
|
const isTouchEvent = event.pointerType === 'touch';
|
|
const clickAreaPadding = isTouchEvent ? 32 : 0; // Add 32px padding for touch events
|
|
|
|
if (target.dir === 'rtl' ? clientX < rect.right + clickAreaPadding && clientX > rect.right - beforeWidthInPixels - clickAreaPadding : clientX > rect.left - clickAreaPadding && clientX < rect.left + beforeWidthInPixels + clickAreaPadding) {
|
|
callback();
|
|
}
|
|
}
|
|
function handleClick(event) {
|
|
handleCheckItemEvent(event, () => {
|
|
if (isHTMLElement(event.target)) {
|
|
const domNode = event.target;
|
|
const editor = getNearestEditorFromDOMNode(domNode);
|
|
if (editor != null && editor.isEditable()) {
|
|
editor.update(() => {
|
|
const node = $getNearestNodeFromDOMNode(domNode);
|
|
if ($isListItemNode(node)) {
|
|
domNode.focus();
|
|
node.toggleChecked();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
function handlePointerDown(event) {
|
|
handleCheckItemEvent(event, () => {
|
|
// Prevents caret moving when clicking on check mark
|
|
event.preventDefault();
|
|
});
|
|
}
|
|
function getActiveCheckListItem() {
|
|
const activeElement = document.activeElement;
|
|
return isHTMLElement(activeElement) && activeElement.tagName === 'LI' && activeElement.parentNode != null &&
|
|
// @ts-ignore internal field
|
|
activeElement.parentNode.__lexicalListType === 'check' ? activeElement : null;
|
|
}
|
|
function findCheckListItemSibling(node, backward) {
|
|
let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
|
|
let parent = node;
|
|
|
|
// Going up in a tree to get non-null sibling
|
|
while (sibling == null && $isListItemNode(parent)) {
|
|
// Get li -> parent ul/ol -> parent li
|
|
parent = parent.getParentOrThrow().getParent();
|
|
if (parent != null) {
|
|
sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling();
|
|
}
|
|
}
|
|
|
|
// Going down in a tree to get first non-nested list item
|
|
while ($isListItemNode(sibling)) {
|
|
const firstChild = backward ? sibling.getLastChild() : sibling.getFirstChild();
|
|
if (!$isListNode(firstChild)) {
|
|
return sibling;
|
|
}
|
|
sibling = backward ? firstChild.getLastChild() : firstChild.getFirstChild();
|
|
}
|
|
return null;
|
|
}
|
|
function handleArrowUpOrDown(event, editor, backward) {
|
|
const activeItem = getActiveCheckListItem();
|
|
if (activeItem != null) {
|
|
editor.update(() => {
|
|
const listItem = $getNearestNodeFromDOMNode(activeItem);
|
|
if (!$isListItemNode(listItem)) {
|
|
return;
|
|
}
|
|
const nextListItem = findCheckListItemSibling(listItem, backward);
|
|
if (nextListItem != null) {
|
|
nextListItem.selectStart();
|
|
const dom = editor.getElementByKey(nextListItem.__key);
|
|
if (dom != null) {
|
|
event.preventDefault();
|
|
setTimeout(() => {
|
|
dom.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
const UPDATE_LIST_START_COMMAND = createCommand('UPDATE_LIST_START_COMMAND');
|
|
const INSERT_UNORDERED_LIST_COMMAND = createCommand('INSERT_UNORDERED_LIST_COMMAND');
|
|
const INSERT_ORDERED_LIST_COMMAND = createCommand('INSERT_ORDERED_LIST_COMMAND');
|
|
const REMOVE_LIST_COMMAND = createCommand('REMOVE_LIST_COMMAND');
|
|
function registerList(editor) {
|
|
const removeListener = mergeRegister(editor.registerCommand(INSERT_ORDERED_LIST_COMMAND, () => {
|
|
$insertList('number');
|
|
return true;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(UPDATE_LIST_START_COMMAND, payload => {
|
|
const {
|
|
listNodeKey,
|
|
newStart
|
|
} = payload;
|
|
const listNode = $getNodeByKey(listNodeKey);
|
|
if (!$isListNode(listNode)) {
|
|
return false;
|
|
}
|
|
if (listNode.getListType() === 'number') {
|
|
listNode.setStart(newStart);
|
|
updateChildrenListItemValue(listNode);
|
|
}
|
|
return true;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(INSERT_UNORDERED_LIST_COMMAND, () => {
|
|
$insertList('bullet');
|
|
return true;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(REMOVE_LIST_COMMAND, () => {
|
|
$removeList();
|
|
return true;
|
|
}, COMMAND_PRIORITY_LOW), editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => $handleListInsertParagraph(), COMMAND_PRIORITY_LOW), editor.registerNodeTransform(ListItemNode, node => {
|
|
const firstChild = node.getFirstChild();
|
|
if (firstChild) {
|
|
if ($isTextNode(firstChild)) {
|
|
const style = firstChild.getStyle();
|
|
const format = firstChild.getFormat();
|
|
if (node.getTextStyle() !== style) {
|
|
node.setTextStyle(style);
|
|
}
|
|
if (node.getTextFormat() !== format) {
|
|
node.setTextFormat(format);
|
|
}
|
|
}
|
|
} else {
|
|
// If it's empty, check the selection
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection) && (selection.style !== node.getTextStyle() || selection.format !== node.getTextFormat()) && selection.isCollapsed() && node.is(selection.anchor.getNode())) {
|
|
node.setTextStyle(selection.style).setTextFormat(selection.format);
|
|
}
|
|
}
|
|
}), editor.registerNodeTransform(TextNode, node => {
|
|
const listItemParentNode = node.getParent();
|
|
if ($isListItemNode(listItemParentNode) && node.is(listItemParentNode.getFirstChild())) {
|
|
const style = node.getStyle();
|
|
const format = node.getFormat();
|
|
if (style !== listItemParentNode.getTextStyle() || format !== listItemParentNode.getTextFormat()) {
|
|
listItemParentNode.setTextStyle(style).setTextFormat(format);
|
|
}
|
|
}
|
|
}));
|
|
return removeListener;
|
|
}
|
|
function registerListStrictIndentTransform(editor) {
|
|
const $formatListIndentStrict = listItemNode => {
|
|
const listNode = listItemNode.getParent();
|
|
if ($isListNode(listItemNode.getFirstChild()) || !$isListNode(listNode)) {
|
|
return;
|
|
}
|
|
const startingListItemNode = $findMatchingParent(listItemNode, node => $isListItemNode(node) && $isListNode(node.getParent()) && $isListItemNode(node.getPreviousSibling()));
|
|
if (startingListItemNode === null && listItemNode.getIndent() > 0) {
|
|
listItemNode.setIndent(0);
|
|
} else if ($isListItemNode(startingListItemNode)) {
|
|
const prevListItemNode = startingListItemNode.getPreviousSibling();
|
|
if ($isListItemNode(prevListItemNode)) {
|
|
const endListItemNode = $findChildrenEndListItemNode(prevListItemNode);
|
|
const endListNode = endListItemNode.getParent();
|
|
if ($isListNode(endListNode)) {
|
|
const prevDepth = $getListDepth(endListNode);
|
|
const depth = $getListDepth(listNode);
|
|
if (prevDepth + 1 < depth) {
|
|
listItemNode.setIndent(prevDepth);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const $processListWithStrictIndent = listNode => {
|
|
const queue = [listNode];
|
|
while (queue.length > 0) {
|
|
const node = queue.shift();
|
|
if (!$isListNode(node)) {
|
|
continue;
|
|
}
|
|
for (const child of node.getChildren()) {
|
|
if ($isListItemNode(child)) {
|
|
$formatListIndentStrict(child);
|
|
const firstChild = child.getFirstChild();
|
|
if ($isListNode(firstChild)) {
|
|
queue.push(firstChild);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return editor.registerNodeTransform(ListNode, $processListWithStrictIndent);
|
|
}
|
|
function $findChildrenEndListItemNode(listItemNode) {
|
|
let current = listItemNode;
|
|
let firstChild = current.getFirstChild();
|
|
while ($isListNode(firstChild)) {
|
|
const lastChild = firstChild.getLastChild();
|
|
if ($isListItemNode(lastChild)) {
|
|
current = lastChild;
|
|
firstChild = current.getFirstChild();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* @deprecated use {@link $insertList} from an update or command listener.
|
|
*
|
|
* 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".
|
|
*/
|
|
function insertList(editor, listType) {
|
|
editor.update(() => $insertList(listType));
|
|
}
|
|
|
|
/**
|
|
* @deprecated use {@link $removeList} from an update or command listener.
|
|
*
|
|
* 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.
|
|
*/
|
|
function removeList(editor) {
|
|
editor.update(() => $removeList());
|
|
}
|
|
|
|
export { $createListItemNode, $createListNode, $getListDepth, $handleListInsertParagraph, $insertList, $isListItemNode, $isListNode, $removeList, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode, REMOVE_LIST_COMMAND, UPDATE_LIST_START_COMMAND, insertList, registerCheckList, registerList, registerListStrictIndentTransform, removeList };
|