mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-22 16:23:06 +03:00
Start of work to merge custom nodes into lexical, removing old unused format/indent core logic while extending common block elements where possible.
525 lines
17 KiB
TypeScript
525 lines
17 KiB
TypeScript
/**
|
|
* 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();
|
|
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();
|
|
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;
|
|
}
|