New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@haprompt/list

Package Overview
Dependencies
Maintainers
2
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@haprompt/list - npm Package Compare versions

Comparing version
0.12.0
to
0.12.6
+68
formatList.d.ts
/**
* 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 { HapromptEditor, HapromptNode } from 'haprompt';
import { ListItemNode, ListNode } from './';
import { ListType } from './HapromptListNode';
/**
* 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 haprompt editor.
* @param listType - The type of list, "number" | "bullet" | "check".
*/
export declare function insertList(editor: HapromptEditor, listType: ListType): void;
/**
* 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 declare function mergeLists(list1: ListNode, list2: ListNode): void;
/**
* 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 haprompt editor.
*/
export declare function removeList(editor: HapromptEditor): void;
/**
* Takes the value of a child ListItemNode and makes it the value the ListItemNode
* should be if it isn't already. If only certain children should be updated, they
* can be passed optionally in an array.
* @param list - The list whose children are updated.
* @param children - An array of the children to be updated.
*/
export declare function updateChildrenListItemValue(list: ListNode, children?: Array<HapromptNode>): void;
/**
* 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 declare function $handleIndent(listItemNode: ListItemNode): void;
/**
* 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 declare function $handleOutdent(listItemNode: ListItemNode): void;
/**
* 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 declare function $handleListInsertParagraph(): boolean;
/**
* 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.
*/
'use strict';
var haprompt = require('haprompt');
var utils = require('@haprompt/utils');
/**
* 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.
*
*/
/**
* 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;
}
{
throw Error(`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)) {
{
throw Error(`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(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.
*/
function wrapInListItem(node) {
const listItemWrapper = $createListItemNode();
return listItemWrapper.append(node);
}
/**
* 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 $isSelectingEmptyListItem(anchorNode, nodes) {
return $isListItemNode(anchorNode) && (nodes.length === 0 || nodes.length === 1 && anchorNode.is(nodes[0]) && anchorNode.getChildrenSize() === 0);
}
function $getListItemValue(listItem) {
const list = listItem.getParent();
let value = 1;
if (list != null) {
if (!$isListNode(list)) {
{
throw Error(`$getListItemValue: list node is not parent of list item node`);
}
} else {
value = list.getStart();
}
}
const siblings = listItem.getPreviousSiblings();
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if ($isListItemNode(sibling) && !$isListNode(sibling.getFirstChild())) {
value++;
}
}
return value;
}
/**
* 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 haprompt editor.
* @param listType - The type of list, "number" | "bullet" | "check".
*/
function insertList(editor, listType) {
editor.update(() => {
const selection = haprompt.$getSelection();
if (haprompt.$isRangeSelection(selection) || haprompt.DEPRECATED_$isGridSelection(selection)) {
const nodes = selection.getNodes();
const anchor = selection.anchor;
const anchorNode = anchor.getNode();
const anchorNodeParent = anchorNode.getParent();
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
const list = $createListNode(listType);
if (haprompt.$isRootOrShadowRoot(anchorNodeParent)) {
anchorNode.replace(list);
const listItem = $createListItemNode();
if (haprompt.$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;
} else {
const handled = new Set();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (haprompt.$isElementNode(node) && node.isEmpty() && !handled.has(node.getKey())) {
createListOrMerge(node, listType);
continue;
}
if (haprompt.$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);
updateChildrenListItemValue(newListNode);
handled.add(parentKey);
}
break;
} else {
const nextParent = parent.getParent();
if (haprompt.$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();
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);
updateChildrenListItemValue(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.
*/
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);
updateChildrenListItemValue(list1);
}
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 haprompt editor.
*/
function removeList(editor) {
editor.update(() => {
const selection = haprompt.$getSelection();
if (haprompt.$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 (haprompt.$isLeafNode(node)) {
const listItemNode = utils.$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 = haprompt.$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. If only certain children should be updated, they
* can be passed optionally in an array.
* @param list - The list whose children are updated.
* @param children - An array of the children to be updated.
*/
function updateChildrenListItemValue(list, children) {
const childrenOrExisting = children || list.getChildren();
if (childrenOrExisting !== undefined) {
for (let i = 0; i < childrenOrExisting.length; i++) {
const child = childrenOrExisting[i];
if ($isListItemNode(child)) {
const prevValue = child.getValue();
const nextValue = $getListItemValue(child);
if (prevValue !== nextValue) {
child.setValue(nextValue);
}
}
}
}
}
/**
* 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());
}
updateChildrenListItemValue(innerList);
}
} 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);
}
updateChildrenListItemValue(innerList);
}
} else if (isNestedListNode(previousSibling)) {
const innerList = previousSibling.getFirstChild();
if ($isListNode(innerList)) {
innerList.append(listItemNode);
updateChildrenListItemValue(innerList);
}
} 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);
}
updateChildrenListItemValue(newList);
}
}
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
}
}
/**
* 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);
}
updateChildrenListItemValue(parentList);
updateChildrenListItemValue(greatGrandparentList);
}
}
/**
* 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.
*/
function $handleListInsertParagraph() {
const selection = haprompt.$getSelection();
if (!haprompt.$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)) {
throw Error(`A ListItemNode must have a ListNode for a parent.`);
}
const grandparent = parent.getParent();
let replacementNode;
if (haprompt.$isRootOrShadowRoot(grandparent)) {
replacementNode = haprompt.$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 (haprompt.$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;
}
/**
* 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 ListItemNode extends haprompt.ElementNode {
/** @internal */
/** @internal */
static getType() {
return 'listitem';
}
static clone(node) {
return new ListItemNode(node.__value, node.__checked, node.__key);
}
constructor(value, checked, key) {
super(key);
this.__value = value === undefined ? 1 : value;
this.__checked = checked;
}
createDOM(config) {
const element = document.createElement('li');
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(element, this, null);
}
element.value = this.__value;
$setListItemThemeClassNames(element, config.theme, this);
return element;
}
updateDOM(prevNode, dom, config) {
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(dom, this, prevNode);
} // @ts-expect-error - this is always HTMLListItemElement
dom.value = this.__value;
$setListItemThemeClassNames(dom, config.theme, this);
return false;
}
static transform() {
return node => {
const parent = node.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
if (parent.getListType() !== 'check' && node.getChecked() != null) {
node.setChecked(undefined);
}
}
};
}
static importDOM() {
return {
li: node => ({
conversion: convertListItemElement,
priority: 0
})
};
}
static importJSON(serializedNode) {
const node = $createListItemNode();
node.setChecked(serializedNode.checked);
node.setValue(serializedNode.value);
node.setFormat(serializedNode.format);
node.setDirection(serializedNode.direction);
return node;
}
exportJSON() {
return { ...super.exportJSON(),
checked: this.getChecked(),
type: 'listitem',
value: this.getValue(),
version: 1
};
}
append(...nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (haprompt.$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) {
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)) {
{
throw Error(`insertAfter: list node is not parent of list item node`);
}
}
const siblings = this.getNextSiblings();
if ($isListItemNode(node)) {
const after = super.insertAfter(node, restoreSelection);
const afterListNode = node.getParentOrThrow();
if ($isListNode(afterListNode)) {
updateChildrenListItemValue(afterListNode);
}
return after;
} // Attempt to merge if the list is of the same type.
if ($isListNode(node)) {
let child = node;
const children = node.getChildren();
for (let i = children.length - 1; i >= 0; i--) {
child = children[i];
this.insertAfter(child, restoreSelection);
}
return child;
} // Otherwise, split the list
// 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();
} else if (nextSibling) {
const parent = nextSibling.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
}
}
}
insertNewAfter(_, restoreSelection = true) {
const newElement = $createListItemNode(this.__checked == null ? undefined : false);
this.insertAfter(newElement, restoreSelection);
return newElement;
}
collapseAtStart(selection) {
const paragraph = haprompt.$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;
}
getChecked() {
const self = this.getLatest();
return self.__checked;
}
setChecked(checked) {
const self = this.getWritable();
self.__checked = checked;
}
toggleChecked() {
this.setChecked(!this.__checked);
}
getIndent() {
// 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) {
if (!(typeof indent === 'number' && indent > -1)) {
throw Error(`Invalid indent value.`);
}
let currentIndent = this.getIndent();
while (currentIndent !== indent) {
if (currentIndent < indent) {
$handleIndent(this);
currentIndent++;
} else {
$handleOutdent(this);
currentIndent--;
}
}
return this;
}
insertBefore(nodeToInsert) {
if ($isListItemNode(nodeToInsert)) {
const parent = this.getParentOrThrow();
if ($isListNode(parent)) {
const siblings = this.getNextSiblings();
updateChildrenListItemValue(parent, siblings);
}
}
return super.insertBefore(nodeToInsert);
}
canInsertAfter(node) {
return $isListItemNode(node);
}
canReplaceWith(replacement) {
return $isListItemNode(replacement);
}
canMergeWith(node) {
return haprompt.$isParagraphNode(node) || $isListItemNode(node);
}
extractWithChild(child, selection) {
if (!haprompt.$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');
}
}
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) {
const listItemClasses = listItemClassName.split(' ');
classesToAdd.push(...listItemClasses);
}
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 = nestedListItemClassName.split(' ');
if (node.getChildren().some(child => $isListNode(child))) {
classesToAdd.push(...nestedListItemClasses);
} else {
classesToRemove.push(...nestedListItemClasses);
}
}
if (classesToRemove.length > 0) {
utils.removeClassNamesFromElement(dom, ...classesToRemove);
}
if (classesToAdd.length > 0) {
utils.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 checked = utils.isHTMLElement(domNode) && domNode.getAttribute('aria-checked') === 'true';
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 haprompt.$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 haprompt.ElementNode {
/** @internal */
/** @internal */
/** @internal */
static getType() {
return 'list';
}
static clone(node) {
const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
return new ListNode(listType, node.__start, node.__key);
}
constructor(listType, start, key) {
super(key);
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
this.__listType = _listType;
this.__tag = _listType === 'number' ? 'ol' : 'ul';
this.__start = start;
}
getTag() {
return this.__tag;
}
setListType(type) {
const writable = this.getWritable();
writable.__listType = type;
writable.__tag = type === 'number' ? 'ol' : 'ul';
}
getListType() {
return this.__listType;
}
getStart() {
return this.__start;
} // 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.__hapromptListType = 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;
}
static importDOM() {
return {
ol: node => ({
conversion: convertListNode,
priority: 0
}),
ul: node => ({
conversion: convertListNode,
priority: 0
})
};
}
static importJSON(serializedNode) {
const node = $createListNode(serializedNode.listType, serializedNode.start);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
exportDOM(editor) {
const {
element
} = super.exportDOM(editor);
if (element && utils.isHTMLElement(element)) {
if (this.__start !== 1) {
element.setAttribute('start', String(this.__start));
}
if (this.__listType === 'check') {
element.setAttribute('__hapromptListType', 'check');
}
}
return {
element
};
}
exportJSON() {
return { ...super.exportJSON(),
listType: this.getListType(),
start: this.getStart(),
tag: this.getTag(),
type: 'list',
version: 1
};
}
canBeEmpty() {
return false;
}
canIndent() {
return false;
}
append(...nodesToAppend) {
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 (haprompt.$isElementNode(currentNode)) {
const textNode = haprompt.$createTextNode(currentNode.getTextContent());
listItemNode.append(textNode);
} else {
listItemNode.append(currentNode);
}
super.append(listItemNode);
}
}
updateChildrenListItemValue(this);
return this;
}
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;
if (nestedListTheme !== undefined && nestedListTheme.list) {
nestedListClassName = nestedListTheme.list;
}
if (listClassName !== undefined) {
classesToAdd.push(listClassName);
}
if (listLevelClassName !== undefined) {
const listItemClasses = listLevelClassName.split(' ');
classesToAdd.push(...listItemClasses);
for (let i = 0; i < listLevelsClassNames.length; i++) {
if (i !== normalizedListDepth) {
classesToRemove.push(node.__tag + i);
}
}
}
if (nestedListClassName !== undefined) {
const nestedListItemClasses = nestedListClassName.split(' ');
if (listDepth > 1) {
classesToAdd.push(...nestedListItemClasses);
} else {
classesToRemove.push(...nestedListItemClasses);
}
}
}
if (classesToRemove.length > 0) {
utils.removeClassNamesFromElement(dom, ...classesToRemove);
}
if (classesToAdd.length > 0) {
utils.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 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 (utils.isHTMLElement(domNode) && domNode.getAttribute('__hapromptlisttype') === 'check') {
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, start = 1) {
return haprompt.$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;
}
/** @module @haprompt/list */
const INSERT_UNORDERED_LIST_COMMAND = haprompt.createCommand('INSERT_UNORDERED_LIST_COMMAND');
const INSERT_ORDERED_LIST_COMMAND = haprompt.createCommand('INSERT_ORDERED_LIST_COMMAND');
const INSERT_CHECK_LIST_COMMAND = haprompt.createCommand('INSERT_CHECK_LIST_COMMAND');
const REMOVE_LIST_COMMAND = haprompt.createCommand('REMOVE_LIST_COMMAND');
exports.$createListItemNode = $createListItemNode;
exports.$createListNode = $createListNode;
exports.$getListDepth = $getListDepth;
exports.$handleListInsertParagraph = $handleListInsertParagraph;
exports.$isListItemNode = $isListItemNode;
exports.$isListNode = $isListNode;
exports.INSERT_CHECK_LIST_COMMAND = INSERT_CHECK_LIST_COMMAND;
exports.INSERT_ORDERED_LIST_COMMAND = INSERT_ORDERED_LIST_COMMAND;
exports.INSERT_UNORDERED_LIST_COMMAND = INSERT_UNORDERED_LIST_COMMAND;
exports.ListItemNode = ListItemNode;
exports.ListNode = ListNode;
exports.REMOVE_LIST_COMMAND = REMOVE_LIST_COMMAND;
exports.insertList = insertList;
exports.removeList = removeList;

Sorry, the diff of this file is not supported yet

/**
* 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.
*/
'use strict';var h=require("haprompt"),k=require("@haprompt/utils");function m(a){let b=new URLSearchParams;b.append("code",a);for(let c=1;c<arguments.length;c++)b.append("v",arguments[c]);throw Error(`Minified Haprompt error #${a}; visit https://haprompt.dev/docs/error?${b} for the full message or `+"use the non-minified dev environment for full errors and additional helpful warnings.");}
function n(a){let b=1;for(a=a.getParent();null!=a;){if(p(a)){a=a.getParent();if(q(a)){b++;a=a.getParent();continue}m(40)}break}return b}function r(a){a=a.getParent();q(a)||m(40);let b=a;for(;null!==b;)b=b.getParent(),q(b)&&(a=b);return a}function t(a){let b=[];a=a.getChildren().filter(p);for(let c=0;c<a.length;c++){let d=a[c],e=d.getFirstChild();q(e)?b=b.concat(t(e)):b.push(d)}return b}function u(a){return p(a)&&q(a.getFirstChild())}
function v(a){for(;null==a.getNextSibling()&&null==a.getPreviousSibling();){let b=a.getParent();if(null==b||!p(a)&&!q(a))break;a=b}a.remove()}function w(a){return y().append(a)}function z(a,b){return p(a)&&(0===b.length||1===b.length&&a.is(b[0])&&0===a.getChildrenSize())}function B(a,b){a.splice(a.getChildrenSize(),0,b)}
function C(a,b){if(q(a))return a;let c=a.getPreviousSibling(),d=a.getNextSibling(),e=y();e.setFormat(a.getFormatType());e.setIndent(a.getIndent());B(e,a.getChildren());if(q(c)&&b===c.getListType())return c.append(e),a.remove(),q(d)&&b===d.getListType()&&(B(c,d.getChildren()),d.remove()),c;if(q(d)&&b===d.getListType())return d.getFirstChildOrThrow().insertBefore(e),a.remove(),d;b=D(b);b.append(e);a.replace(b);E(b);return b}
function F(a,b){var c=a.getLastChild();let d=b.getFirstChild();c&&d&&u(c)&&u(d)&&(F(c.getFirstChild(),d.getFirstChild()),d.remove());c=b.getChildren();0<c.length&&(a.append(...c),E(a));b.remove()}function E(a,b){a=b||a.getChildren();if(void 0!==a)for(b=0;b<a.length;b++){let f=a[b];if(p(f)){let g=f.getValue();var c=f,d=c.getParent(),e=1;null!=d&&(q(d)?e=d.getStart():m(44));c=c.getPreviousSiblings();for(d=0;d<c.length;d++){let l=c[d];p(l)&&!q(l.getFirstChild())&&e++}g!==e&&f.setValue(e)}}}
function G(a){if(!u(a)){var b=a.getParent(),c=b?b.getParent():void 0,d=c?c.getParent():void 0;if(q(d)&&p(c)&&q(b)){var e=b?b.getFirstChild():void 0,f=b?b.getLastChild():void 0;if(a.is(e))c.insertBefore(a),b.isEmpty()&&c.remove();else if(a.is(f))c.insertAfter(a),b.isEmpty()&&c.remove();else{var g=b.getListType();e=y();let l=D(g);e.append(l);a.getPreviousSiblings().forEach(x=>l.append(x));f=y();g=D(g);f.append(g);B(g,a.getNextSiblings());c.insertBefore(e);c.insertAfter(f);c.replace(a)}E(b);E(d)}}}
class H extends h.ElementNode{static getType(){return"listitem"}static clone(a){return new H(a.__value,a.__checked,a.__key)}constructor(a,b,c){super(c);this.__value=void 0===a?1:a;this.__checked=b}createDOM(a){let b=document.createElement("li"),c=this.getParent();q(c)&&"check"===c.getListType()&&I(b,this,null);b.value=this.__value;J(b,a.theme,this);return b}updateDOM(a,b,c){let d=this.getParent();q(d)&&"check"===d.getListType()&&I(b,this,a);b.value=this.__value;J(b,c.theme,this);return!1}static transform(){return a=>
{let b=a.getParent();q(b)&&(E(b),"check"!==b.getListType()&&null!=a.getChecked()&&a.setChecked(void 0))}}static importDOM(){return{li:()=>({conversion:K,priority:0})}}static importJSON(a){let b=y();b.setChecked(a.checked);b.setValue(a.value);b.setFormat(a.format);b.setDirection(a.direction);return b}exportJSON(){return{...super.exportJSON(),checked:this.getChecked(),type:"listitem",value:this.getValue(),version:1}}append(...a){for(let b=0;b<a.length;b++){let c=a[b];if(h.$isElementNode(c)&&this.canMergeWith(c)){let d=
c.getChildren();this.append(...d);c.remove()}else super.append(c)}return this}replace(a,b){if(p(a))return super.replace(a);this.setIndent(0);let c=this.getParentOrThrow();if(!q(c))return a;if(c.__first===this.getKey())c.insertBefore(a);else if(c.__last===this.getKey())c.insertAfter(a);else{let d=D(c.getListType()),e=this.getNextSibling();for(;e;){let f=e;e=e.getNextSibling();d.append(f)}c.insertAfter(a);a.insertAfter(d)}b&&this.getChildren().forEach(d=>{a.append(d)});this.remove();0===c.getChildrenSize()&&
c.remove();return a}insertAfter(a,b=!0){var c=this.getParentOrThrow();q(c)||m(39);var d=this.getNextSiblings();if(p(a))return b=super.insertAfter(a,b),a=a.getParentOrThrow(),q(a)&&E(a),b;if(q(a)){c=a;a=a.getChildren();for(d=a.length-1;0<=d;d--)c=a[d],this.insertAfter(c,b);return c}c.insertAfter(a,b);if(0!==d.length){let e=D(c.getListType());d.forEach(f=>e.append(f));a.insertAfter(e,b)}return a}remove(a){let b=this.getPreviousSibling(),c=this.getNextSibling();super.remove(a);b&&c&&u(b)&&u(c)?(F(b.getFirstChild(),
c.getFirstChild()),c.remove()):c&&(a=c.getParent(),q(a)&&E(a))}insertNewAfter(a,b=!0){a=y(null==this.__checked?void 0:!1);this.insertAfter(a,b);return a}collapseAtStart(a){let b=h.$createParagraphNode();this.getChildren().forEach(f=>b.append(f));var c=this.getParentOrThrow(),d=c.getParentOrThrow();let e=p(d);1===c.getChildrenSize()?e?(c.remove(),d.select()):(c.insertBefore(b),c.remove(),c=a.anchor,a=a.focus,d=b.getKey(),"element"===c.type&&c.getNode().is(this)&&c.set(d,c.offset,"element"),"element"===
a.type&&a.getNode().is(this)&&a.set(d,a.offset,"element")):(c.insertBefore(b),this.remove());return!0}getValue(){return this.getLatest().__value}setValue(a){this.getWritable().__value=a}getChecked(){return this.getLatest().__checked}setChecked(a){this.getWritable().__checked=a}toggleChecked(){this.setChecked(!this.__checked)}getIndent(){var a=this.getParent();if(null===a)return this.getLatest().__indent;a=a.getParentOrThrow();let b=0;for(;p(a);)a=a.getParentOrThrow().getParentOrThrow(),b++;return b}setIndent(a){"number"===
typeof a&&-1<a||m(117);let b=this.getIndent();for(;b!==a;)if(b<a){a:{var c=new Set;if(u(this)||c.has(this.getKey()))break a;let g=this.getParent();var d=this.getNextSibling(),e=this.getPreviousSibling();if(u(d)&&u(e)){if(e=e.getFirstChild(),q(e)){e.append(this);var f=d.getFirstChild();q(f)&&(f=f.getChildren(),B(e,f),d.remove(),c.add(d.getKey()));E(e)}}else u(d)?(d=d.getFirstChild(),q(d)&&(c=d.getFirstChild(),null!==c&&c.insertBefore(this),E(d))):u(e)?(d=e.getFirstChild(),q(d)&&(d.append(this),E(d))):
q(g)&&(c=y(),f=D(g.getListType()),c.append(f),f.append(this),e?e.insertAfter(c):d?d.insertBefore(c):g.append(c),E(f));q(g)&&E(g)}b++}else G(this),b--;return this}insertBefore(a){if(p(a)){let b=this.getParentOrThrow();if(q(b)){let c=this.getNextSiblings();E(b,c)}}return super.insertBefore(a)}canInsertAfter(a){return p(a)}canReplaceWith(a){return p(a)}canMergeWith(a){return h.$isParagraphNode(a)||p(a)}extractWithChild(a,b){if(!h.$isRangeSelection(b))return!1;a=b.anchor.getNode();let c=b.focus.getNode();
return this.isParentOf(a)&&this.isParentOf(c)&&this.getTextContent().length===b.getTextContent().length}isParentRequired(){return!0}createParentElementNode(){return D("bullet")}}
function J(a,b,c){let d=[],e=[];var f=(b=b.list)?b.listitem:void 0;if(b&&b.nested)var g=b.nested.listitem;void 0!==f&&(f=f.split(" "),d.push(...f));if(b){f=c.getParent();f=q(f)&&"check"===f.getListType();let l=c.getChecked();f&&!l||e.push(b.listitemUnchecked);f&&l||e.push(b.listitemChecked);f&&d.push(l?b.listitemChecked:b.listitemUnchecked)}void 0!==g&&(g=g.split(" "),c.getChildren().some(l=>q(l))?d.push(...g):e.push(...g));0<e.length&&k.removeClassNamesFromElement(a,...e);0<d.length&&k.addClassNamesToElement(a,
...d)}function I(a,b,c){q(b.getFirstChild())?(a.removeAttribute("role"),a.removeAttribute("tabIndex"),a.removeAttribute("aria-checked")):(a.setAttribute("role","checkbox"),a.setAttribute("tabIndex","-1"),c&&b.__checked===c.__checked||a.setAttribute("aria-checked",b.getChecked()?"true":"false"))}function K(a){a=k.isHTMLElement(a)&&"true"===a.getAttribute("aria-checked");return{node:y(a)}}function y(a){return h.$applyNodeReplacement(new H(void 0,a))}function p(a){return a instanceof H}
class L extends h.ElementNode{static getType(){return"list"}static clone(a){return new L(a.__listType||N[a.__tag],a.__start,a.__key)}constructor(a,b,c){super(c);this.__listType=a=N[a]||a;this.__tag="number"===a?"ol":"ul";this.__start=b}getTag(){return this.__tag}setListType(a){let b=this.getWritable();b.__listType=a;b.__tag="number"===a?"ol":"ul"}getListType(){return this.__listType}getStart(){return this.__start}createDOM(a){let b=document.createElement(this.__tag);1!==this.__start&&b.setAttribute("start",
String(this.__start));b.__hapromptListType=this.__listType;O(b,a.theme,this);return b}updateDOM(a,b,c){if(a.__tag!==this.__tag)return!0;O(b,c.theme,this);return!1}static importDOM(){return{ol:()=>({conversion:P,priority:0}),ul:()=>({conversion:P,priority:0})}}static importJSON(a){let b=D(a.listType,a.start);b.setFormat(a.format);b.setIndent(a.indent);b.setDirection(a.direction);return b}exportDOM(a){({element:a}=super.exportDOM(a));a&&k.isHTMLElement(a)&&(1!==this.__start&&a.setAttribute("start",
String(this.__start)),"check"===this.__listType&&a.setAttribute("__hapromptListType","check"));return{element:a}}exportJSON(){return{...super.exportJSON(),listType:this.getListType(),start:this.getStart(),tag:this.getTag(),type:"list",version:1}}canBeEmpty(){return!1}canIndent(){return!1}append(...a){for(let c=0;c<a.length;c++){var b=a[c];if(p(b))super.append(b);else{let d=y();q(b)?d.append(b):h.$isElementNode(b)?(b=h.$createTextNode(b.getTextContent()),d.append(b)):d.append(b);super.append(d)}}E(this);
return this}extractWithChild(a){return p(a)}}function O(a,b,c){let d=[],e=[];var f=b.list;if(void 0!==f){let l=f[`${c.__tag}Depth`]||[];b=n(c)-1;let x=b%l.length;var g=l[x];let M=f[c.__tag],A;f=f.nested;void 0!==f&&f.list&&(A=f.list);void 0!==M&&d.push(M);if(void 0!==g)for(g=g.split(" "),d.push(...g),g=0;g<l.length;g++)g!==x&&e.push(c.__tag+g);void 0!==A&&(c=A.split(" "),1<b?d.push(...c):e.push(...c))}0<e.length&&k.removeClassNamesFromElement(a,...e);0<d.length&&k.addClassNamesToElement(a,...d)}
function Q(a){let b=[];for(let d=0;d<a.length;d++){var c=a[d];p(c)?(b.push(c),c=c.getChildren(),1<c.length&&c.forEach(e=>{q(e)&&b.push(w(e))})):b.push(w(c))}return b}function P(a){let b=a.nodeName.toLowerCase(),c=null;"ol"===b?c=D("number",a.start):"ul"===b&&(c=k.isHTMLElement(a)&&"check"===a.getAttribute("__hapromptlisttype")?D("check"):D("bullet"));return{after:Q,node:c}}let N={ol:"number",ul:"bullet"};function D(a,b=1){return h.$applyNodeReplacement(new L(a,b))}
function q(a){return a instanceof L}let R=h.createCommand("INSERT_UNORDERED_LIST_COMMAND"),S=h.createCommand("INSERT_ORDERED_LIST_COMMAND"),T=h.createCommand("INSERT_CHECK_LIST_COMMAND"),U=h.createCommand("REMOVE_LIST_COMMAND");exports.$createListItemNode=y;exports.$createListNode=D;exports.$getListDepth=n;
exports.$handleListInsertParagraph=function(){var a=h.$getSelection();if(!h.$isRangeSelection(a)||!a.isCollapsed())return!1;a=a.anchor.getNode();if(!p(a)||0!==a.getChildrenSize())return!1;var b=r(a),c=a.getParent();q(c)||m(40);let d=c.getParent(),e;if(h.$isRootOrShadowRoot(d))e=h.$createParagraphNode(),b.insertAfter(e);else if(p(d))e=y(),d.insertAfter(e);else return!1;e.select();b=a.getNextSiblings();if(0<b.length){let f=D(c.getListType());h.$isParagraphNode(e)?e.insertAfter(f):(c=y(),c.append(f),
e.insertAfter(c));b.forEach(g=>{g.remove();f.append(g)})}v(a);return!0};exports.$isListItemNode=p;exports.$isListNode=q;exports.INSERT_CHECK_LIST_COMMAND=T;exports.INSERT_ORDERED_LIST_COMMAND=S;exports.INSERT_UNORDERED_LIST_COMMAND=R;exports.ListItemNode=H;exports.ListNode=L;exports.REMOVE_LIST_COMMAND=U;
exports.insertList=function(a,b){a.update(()=>{var c=h.$getSelection();if(h.$isRangeSelection(c)||h.DEPRECATED_$isGridSelection(c)){var d=c.getNodes();c=c.anchor.getNode();var e=c.getParent();if(z(c,d))d=D(b),h.$isRootOrShadowRoot(e)?(c.replace(d),e=y(),h.$isElementNode(c)&&(e.setFormat(c.getFormatType()),e.setIndent(c.getIndent())),d.append(e)):p(c)&&(c=c.getParentOrThrow(),B(d,c.getChildren()),c.replace(d));else for(c=new Set,e=0;e<d.length;e++){var f=d[e];if(h.$isElementNode(f)&&f.isEmpty()&&!c.has(f.getKey()))C(f,
b);else if(h.$isLeafNode(f))for(f=f.getParent();null!=f;){let l=f.getKey();if(q(f)){if(!c.has(l)){var g=D(b);B(g,f.getChildren());f.replace(g);E(g);c.add(l)}break}else{g=f.getParent();if(h.$isRootOrShadowRoot(g)&&!c.has(l)){c.add(l);C(f,b);break}f=g}}}}})};
exports.removeList=function(a){a.update(()=>{let b=h.$getSelection();if(h.$isRangeSelection(b)){var c=new Set,d=b.getNodes(),e=b.anchor.getNode();if(z(e,d))c.add(r(e));else for(e=0;e<d.length;e++){var f=d[e];h.$isLeafNode(f)&&(f=k.$getNearestNodeOfType(f,H),null!=f&&c.add(r(f)))}for(let g of c){c=g;d=t(g);for(let l of d)d=h.$createParagraphNode(),B(d,l.getChildren()),c.insertAfter(d),c=d,l.__key===b.anchor.key&&b.anchor.set(d.getKey(),0,"element"),l.__key===b.focus.key&&b.focus.set(d.getKey(),0,"element"),
l.remove();g.remove()}}})}
/**
* 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 { DOMConversionMap, EditorConfig, GridSelection, HapromptNode, NodeKey, NodeSelection, ParagraphNode, RangeSelection, SerializedElementNode, Spread } from 'haprompt';
import { ElementNode } from 'haprompt';
export type SerializedListItemNode = Spread<{
checked: boolean | undefined;
value: number;
}, SerializedElementNode>;
/** @noInheritDoc */
export declare class ListItemNode extends ElementNode {
/** @internal */
__value: number;
/** @internal */
__checked?: boolean;
static getType(): string;
static clone(node: ListItemNode): ListItemNode;
constructor(value?: number, checked?: boolean, key?: NodeKey);
createDOM(config: EditorConfig): HTMLElement;
updateDOM(prevNode: ListItemNode, dom: HTMLElement, config: EditorConfig): boolean;
static transform(): (node: HapromptNode) => void;
static importDOM(): DOMConversionMap | null;
static importJSON(serializedNode: SerializedListItemNode): ListItemNode;
exportJSON(): SerializedListItemNode;
append(...nodes: HapromptNode[]): this;
replace<N extends HapromptNode>(replaceWithNode: N, includeChildren?: boolean): N;
insertAfter(node: HapromptNode, restoreSelection?: boolean): HapromptNode;
remove(preserveEmptyParent?: boolean): void;
insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ListItemNode | ParagraphNode;
collapseAtStart(selection: RangeSelection): true;
getValue(): number;
setValue(value: number): void;
getChecked(): boolean | undefined;
setChecked(checked?: boolean): void;
toggleChecked(): void;
getIndent(): number;
setIndent(indent: number): this;
insertBefore(nodeToInsert: HapromptNode): HapromptNode;
canInsertAfter(node: HapromptNode): boolean;
canReplaceWith(replacement: HapromptNode): boolean;
canMergeWith(node: HapromptNode): boolean;
extractWithChild(child: HapromptNode, selection: RangeSelection | NodeSelection | GridSelection): boolean;
isParentRequired(): true;
createParentElementNode(): ElementNode;
}
/**
* 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 declare function $createListItemNode(checked?: boolean): ListItemNode;
/**
* 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 declare function $isListItemNode(node: HapromptNode | null | undefined): node is 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.
*
*/
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementNode, HapromptEditor, HapromptNode, NodeKey, SerializedElementNode, Spread } from 'haprompt';
export type SerializedListNode = Spread<{
listType: ListType;
start: number;
tag: ListNodeTagType;
}, SerializedElementNode>;
export type ListType = 'number' | 'bullet' | 'check';
export type ListNodeTagType = 'ul' | 'ol';
/** @noInheritDoc */
export declare class ListNode extends ElementNode {
/** @internal */
__tag: ListNodeTagType;
/** @internal */
__start: number;
/** @internal */
__listType: ListType;
static getType(): string;
static clone(node: ListNode): ListNode;
constructor(listType: ListType, start: number, key?: NodeKey);
getTag(): ListNodeTagType;
setListType(type: ListType): void;
getListType(): ListType;
getStart(): number;
createDOM(config: EditorConfig, _editor?: HapromptEditor): HTMLElement;
updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean;
static importDOM(): DOMConversionMap | null;
static importJSON(serializedNode: SerializedListNode): ListNode;
exportDOM(editor: HapromptEditor): DOMExportOutput;
exportJSON(): SerializedListNode;
canBeEmpty(): false;
canIndent(): false;
append(...nodesToAppend: HapromptNode[]): this;
extractWithChild(child: HapromptNode): boolean;
}
/**
* 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 declare function $createListNode(listType: ListType, start?: number): ListNode;
/**
* 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 declare function $isListNode(node: HapromptNode | null | undefined): node is ListNode;
/** @module @haprompt/list */
/**
* 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 './HapromptListItemNode';
import type { ListType, SerializedListNode } from './HapromptListNode';
import type { HapromptCommand } from 'haprompt';
import { $handleListInsertParagraph, insertList, removeList } from './formatList';
import { $createListItemNode, $isListItemNode, ListItemNode } from './HapromptListItemNode';
import { $createListNode, $isListNode, ListNode } from './HapromptListNode';
import { $getListDepth } from './utils';
export { $createListItemNode, $createListNode, $getListDepth, $handleListInsertParagraph, $isListItemNode, $isListNode, insertList, ListItemNode, ListNode, ListType, removeList, SerializedListItemNode, SerializedListNode, };
export declare const INSERT_UNORDERED_LIST_COMMAND: HapromptCommand<void>;
export declare const INSERT_ORDERED_LIST_COMMAND: HapromptCommand<void>;
export declare const INSERT_CHECK_LIST_COMMAND: HapromptCommand<void>;
export declare const REMOVE_LIST_COMMAND: HapromptCommand<void>;
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
/**
* 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 { HapromptNode } from 'haprompt';
import { 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 declare function $getListDepth(listNode: ListNode): number;
/**
* 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 declare function $getTopListNode(listItem: HapromptNode): ListNode;
/**
* 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 declare function $isLastItemInList(listItem: ListItemNode): boolean;
/**
* 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.
*/
export declare function $getAllListItems(node: ListNode): Array<ListItemNode>;
/**
* 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 declare function isNestedListNode(node: HapromptNode | null | undefined): boolean;
/**
* 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 declare function findNearestListItemNode(node: HapromptNode): 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 declare function $removeHighestEmptyListParent(sublist: ListItemNode | ListNode): void;
/**
* 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 declare function wrapInListItem(node: HapromptNode): ListItemNode;
+3
-5

@@ -6,7 +6,5 @@ /**

* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
module.exports = require('./dist/HapromptList.js');
'use strict'
const HapromptList = process.env.NODE_ENV === 'development' ? require('./HapromptList.dev.js') : require('./HapromptList.prod.js')
module.exports = HapromptList;

@@ -11,9 +11,9 @@ {

"license": "MIT",
"version": "0.12.0",
"version": "0.12.6",
"main": "HapromptList.js",
"peerDependencies": {
"haprompt": "0.12.0"
"haprompt": "0.12.6"
},
"dependencies": {
"@haprompt/utils": "0.12.0"
"@haprompt/utils": "0.12.6"
},

@@ -20,0 +20,0 @@ "repository": {

Sorry, the diff of this file is not supported yet

/**
* 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, TextNode} from 'haprompt';
import {initializeUnitTest} from 'haprompt/src/__tests__/utils';
import {
$createListItemNode,
$isListItemNode,
ListItemNode,
ListNode,
} from '../..';
import {expectHtmlToBeEqual, html} from '../utils';
const editorConfig = Object.freeze({
namespace: '',
theme: {
list: {
listitem: 'my-listItem-item-class',
nested: {
listitem: 'my-nested-list-listItem-class',
},
},
},
});
describe('HapromptListItemNode tests', () => {
initializeUnitTest((testEnv) => {
test('ListItemNode.constructor', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listItemNode = new ListItemNode();
expect(listItemNode.getType()).toBe('listitem');
expect(listItemNode.getTextContent()).toBe('');
});
expect(() => new ListItemNode()).toThrow();
});
test('ListItemNode.createDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listItemNode = new ListItemNode();
expectHtmlToBeEqual(
listItemNode.createDOM(editorConfig).outerHTML,
html`
<li value="1" class="my-listItem-item-class"></li>
`,
);
expectHtmlToBeEqual(
listItemNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
html`
<li value="1"></li>
`,
);
});
});
describe('ListItemNode.updateDOM()', () => {
test('base', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listItemNode = new ListItemNode();
const domElement = listItemNode.createDOM(editorConfig);
expectHtmlToBeEqual(
domElement.outerHTML,
html`
<li value="1" class="my-listItem-item-class"></li>
`,
);
const newListItemNode = new ListItemNode();
const result = newListItemNode.updateDOM(
listItemNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expectHtmlToBeEqual(
domElement.outerHTML,
html`
<li value="1" class="my-listItem-item-class"></li>
`,
);
});
});
test('nested list', async () => {
const {editor} = testEnv;
await editor.update(() => {
const parentListNode = new ListNode('bullet', 1);
const parentlistItemNode = new ListItemNode();
parentListNode.append(parentlistItemNode);
const domElement = parentlistItemNode.createDOM(editorConfig);
expectHtmlToBeEqual(
domElement.outerHTML,
html`
<li value="1" class="my-listItem-item-class"></li>
`,
);
const nestedListNode = new ListNode('bullet', 1);
nestedListNode.append(new ListItemNode());
parentlistItemNode.append(nestedListNode);
const result = parentlistItemNode.updateDOM(
parentlistItemNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expectHtmlToBeEqual(
domElement.outerHTML,
html`
<li
value="1"
class="my-listItem-item-class my-nested-list-listItem-class"></li>
`,
);
});
});
});
describe('ListItemNode.replace()', () => {
let listNode;
let listItemNode1;
let listItemNode2;
let listItemNode3;
beforeEach(async () => {
const {editor} = testEnv;
await editor.update(() => {
const root = $getRoot();
listNode = new ListNode('bullet', 1);
listItemNode1 = new ListItemNode();
listItemNode1.append(new TextNode('one'));
listItemNode2 = new ListItemNode();
listItemNode2.append(new TextNode('two'));
listItemNode3 = new ListItemNode();
listItemNode3.append(new TextNode('three'));
root.append(listNode);
listNode.append(listItemNode1, listItemNode2, listItemNode3);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('another list item node', async () => {
const {editor} = testEnv;
await editor.update(() => {
const newListItemNode = new ListItemNode();
newListItemNode.append(new TextNode('bar'));
listItemNode1.replace(newListItemNode);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">bar</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('first list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => {
return;
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
await editor.update(() => {
const paragraphNode = $createParagraphNode();
listItemNode1.replace(paragraphNode);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<p><br /></p>
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('last list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => {
const paragraphNode = $createParagraphNode();
listItemNode3.replace(paragraphNode);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
</ul>
<p><br /></p>
</div>
`,
);
});
test('middle list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => {
const paragraphNode = $createParagraphNode();
listItemNode2.replace(paragraphNode);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
</ul>
<p><br /></p>
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('the only list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode2.remove();
listItemNode3.remove();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
</ul>
</div>
`,
);
await editor.update(() => {
const paragraphNode = $createParagraphNode();
listItemNode1.replace(paragraphNode);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<p><br /></p>
</div>
`,
);
});
});
describe('ListItemNode.remove()', () => {
// - A
// - x
// - B
test('siblings are not nested', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
A_listItem.append(new TextNode('A'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
B_listItem.append(new TextNode('B'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</div>
`,
);
});
// - A
// - x
// - B
test('the previous sibling is nested', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
const A_nestedList = new ListNode('bullet', 1);
const A_nestedListItem = new ListItemNode();
A_listItem.append(A_nestedList);
A_nestedList.append(A_nestedListItem);
A_nestedListItem.append(new TextNode('A'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
B_listItem.append(new TextNode('B'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</div>
`,
);
});
// - A
// - x
// - B
test('the next sibling is nested', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
A_listItem.append(new TextNode('A'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
const B_nestedList = new ListNode('bullet', 1);
const B_nestedListItem = new ListItemNode();
B_listItem.append(B_nestedList);
B_nestedList.append(B_nestedListItem);
B_nestedListItem.append(new TextNode('B'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="3">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
});
// - A
// - x
// - B
test('both siblings are nested', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
const A_nestedList = new ListNode('bullet', 1);
const A_nestedListItem = new ListItemNode();
A_listItem.append(A_nestedList);
A_nestedList.append(A_nestedListItem);
A_nestedListItem.append(new TextNode('A'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
const B_nestedList = new ListNode('bullet', 1);
const B_nestedListItem = new ListItemNode();
B_listItem.append(B_nestedList);
B_nestedList.append(B_nestedListItem);
B_nestedListItem.append(new TextNode('B'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
});
// - A1
// - A2
// - x
// - B
test('the previous sibling is nested deeper than the next sibling', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
const A_nestedList = new ListNode('bullet', 1);
const A_nestedListItem1 = new ListItemNode();
const A_nestedListItem2 = new ListItemNode();
const A_deeplyNestedList = new ListNode('bullet', 1);
const A_deeplyNestedListItem = new ListItemNode();
A_listItem.append(A_nestedList);
A_nestedList.append(A_nestedListItem1);
A_nestedList.append(A_nestedListItem2);
A_nestedListItem1.append(new TextNode('A1'));
A_nestedListItem2.append(A_deeplyNestedList);
A_deeplyNestedList.append(A_deeplyNestedListItem);
A_deeplyNestedListItem.append(new TextNode('A2'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
const B_nestedList = new ListNode('bullet', 1);
const B_nestedlistItem = new ListItemNode();
B_listItem.append(B_nestedList);
B_nestedList.append(B_nestedlistItem);
B_nestedlistItem.append(new TextNode('B'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A1</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A2</span>
</li>
</ul>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A1</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A2</span>
</li>
</ul>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
});
// - A
// - x
// - B1
// - B2
test('the next sibling is nested deeper than the previous sibling', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
const A_nestedList = new ListNode('bullet', 1);
const A_nestedListItem = new ListItemNode();
A_listItem.append(A_nestedList);
A_nestedList.append(A_nestedListItem);
A_nestedListItem.append(new TextNode('A'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
const B_nestedList = new ListNode('bullet', 1);
const B_nestedListItem1 = new ListItemNode();
const B_nestedListItem2 = new ListItemNode();
const B_deeplyNestedList = new ListNode('bullet', 1);
const B_deeplyNestedListItem = new ListItemNode();
B_listItem.append(B_nestedList);
B_nestedList.append(B_nestedListItem1);
B_nestedList.append(B_nestedListItem2);
B_nestedListItem1.append(B_deeplyNestedList);
B_nestedListItem2.append(new TextNode('B2'));
B_deeplyNestedList.append(B_deeplyNestedListItem);
B_deeplyNestedListItem.append(new TextNode('B1'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="2">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B1</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B2</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B1</span>
</li>
</ul>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B2</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
});
// - A1
// - A2
// - x
// - B1
// - B2
test('both siblings are deeply nested', async () => {
const {editor} = testEnv;
let x;
await editor.update(() => {
const root = $getRoot();
const parent = new ListNode('bullet', 1);
const A_listItem = new ListItemNode();
const A_nestedList = new ListNode('bullet', 1);
const A_nestedListItem1 = new ListItemNode();
const A_nestedListItem2 = new ListItemNode();
const A_deeplyNestedList = new ListNode('bullet', 1);
const A_deeplyNestedListItem = new ListItemNode();
A_listItem.append(A_nestedList);
A_nestedList.append(A_nestedListItem1);
A_nestedList.append(A_nestedListItem2);
A_nestedListItem1.append(new TextNode('A1'));
A_nestedListItem2.append(A_deeplyNestedList);
A_deeplyNestedList.append(A_deeplyNestedListItem);
A_deeplyNestedListItem.append(new TextNode('A2'));
x = new ListItemNode();
x.append(new TextNode('x'));
const B_listItem = new ListItemNode();
const B_nestedList = new ListNode('bullet', 1);
const B_nestedListItem1 = new ListItemNode();
const B_nestedListItem2 = new ListItemNode();
const B_deeplyNestedList = new ListNode('bullet', 1);
const B_deeplyNestedListItem = new ListItemNode();
B_listItem.append(B_nestedList);
B_nestedList.append(B_nestedListItem1);
B_nestedList.append(B_nestedListItem2);
B_nestedListItem1.append(B_deeplyNestedList);
B_nestedListItem2.append(new TextNode('B2'));
B_deeplyNestedList.append(B_deeplyNestedListItem);
B_deeplyNestedListItem.append(new TextNode('B1'));
parent.append(A_listItem, x, B_listItem);
root.append(parent);
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A1</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A2</span>
</li>
</ul>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">x</span>
</li>
<li value="2">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B1</span>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">B2</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
await editor.update(() => x.remove());
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A1</span>
</li>
<li value="2">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">A2</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B1</span>
</li>
</ul>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">B2</span>
</li>
</ul>
</li>
</ul>
</div>
`,
);
});
});
describe('ListItemNode.insertNewAfter(): non-empty list items', () => {
let listNode;
let listItemNode1;
let listItemNode2;
let listItemNode3;
beforeEach(async () => {
const {editor} = testEnv;
await editor.update(() => {
const root = $getRoot();
listNode = new ListNode('bullet', 1);
listItemNode1 = new ListItemNode();
listItemNode2 = new ListItemNode();
listItemNode3 = new ListItemNode();
root.append(listNode);
listNode.append(listItemNode1, listItemNode2, listItemNode3);
listItemNode1.append(new TextNode('one'));
listItemNode2.append(new TextNode('two'));
listItemNode3.append(new TextNode('three'));
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('first list item', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode1.insertNewAfter();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2"><br /></li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="4" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
</ul>
</div>
`,
);
});
test('last list item', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode3.insertNewAfter();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
<li value="4"><br /></li>
</ul>
</div>
`,
);
});
test('middle list item', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode3.insertNewAfter();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
<li value="3" dir="ltr">
<span data-haprompt-text="true">three</span>
</li>
<li value="4"><br /></li>
</ul>
</div>
`,
);
});
test('the only list item', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode2.remove();
listItemNode3.remove();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
</ul>
</div>
`,
);
await editor.update(() => {
listItemNode1.insertNewAfter();
});
expectHtmlToBeEqual(
testEnv.outerHTML,
html`
<div
contenteditable="true"
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
data-haprompt-editor="true">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2"><br /></li>
</ul>
</div>
`,
);
});
});
test('$createListItemNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listItemNode = new ListItemNode();
const createdListItemNode = $createListItemNode();
expect(listItemNode.__type).toEqual(createdListItemNode.__type);
expect(listItemNode.__parent).toEqual(createdListItemNode.__parent);
expect(listItemNode.__key).not.toEqual(createdListItemNode.__key);
});
});
test('$isListItemNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const listItemNode = new ListItemNode();
expect($isListItemNode(listItemNode)).toBe(true);
});
});
describe('ListItemNode.setIndent()', () => {
let listNode;
let listItemNode1;
let listItemNode2;
beforeEach(async () => {
const {editor} = testEnv;
await editor.update(() => {
const root = $getRoot();
listNode = new ListNode('bullet', 1);
listItemNode1 = new ListItemNode();
listItemNode2 = new ListItemNode();
root.append(listNode);
listNode.append(listItemNode1, listItemNode2);
listItemNode1.append(new TextNode('one'));
listItemNode2.append(new TextNode('two'));
});
});
it('indents and outdents list item', async () => {
const {editor} = testEnv;
await editor.update(() => {
listItemNode1.setIndent(3);
});
await editor.update(() => {
expect(listItemNode1.getIndent()).toBe(3);
});
expectHtmlToBeEqual(
editor.getRootElement().innerHTML,
html`
<ul>
<li value="1">
<ul>
<li value="1">
<ul>
<li value="1">
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li value="1" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
</ul>
`,
);
await editor.update(() => {
listItemNode1.setIndent(0);
});
await editor.update(() => {
expect(listItemNode1.getIndent()).toBe(0);
});
expectHtmlToBeEqual(
editor.getRootElement().innerHTML,
html`
<ul>
<li value="1" dir="ltr">
<span data-haprompt-text="true">one</span>
</li>
<li value="2" dir="ltr">
<span data-haprompt-text="true">two</span>
</li>
</ul>
`,
);
});
});
});
});
/**
* 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 'haprompt';
import {initializeUnitTest} from 'haprompt/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('HapromptListNode 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');
});
});
});
});
/**
* 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 'haprompt';
import {initializeUnitTest} from 'haprompt/src/__tests__/utils';
import {$createListItemNode, $createListNode} from '../..';
import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils';
describe('Haprompt 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);
});
});
});
});
/**
* 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));
}
function prettifyHtml(s: string): string {
return prettier.format(s.replace(/\n/g, ''), {parser: 'html'});
}
/**
* 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 '@haprompt/utils';
import {
$createParagraphNode,
$getSelection,
$isElementNode,
$isLeafNode,
$isParagraphNode,
$isRangeSelection,
$isRootOrShadowRoot,
DEPRECATED_$isGridSelection,
ElementNode,
HapromptEditor,
HapromptNode,
NodeKey,
ParagraphNode,
} from 'haprompt';
import invariant from 'shared/invariant';
import {
$createListItemNode,
$createListNode,
$isListItemNode,
$isListNode,
ListItemNode,
ListNode,
} from './';
import {ListType} from './HapromptListNode';
import {
$getAllListItems,
$getTopListNode,
$removeHighestEmptyListParent,
isNestedListNode,
} from './utils';
function $isSelectingEmptyListItem(
anchorNode: ListItemNode | HapromptNode,
nodes: Array<HapromptNode>,
): boolean {
return (
$isListItemNode(anchorNode) &&
(nodes.length === 0 ||
(nodes.length === 1 &&
anchorNode.is(nodes[0]) &&
anchorNode.getChildrenSize() === 0))
);
}
function $getListItemValue(listItem: ListItemNode): number {
const list = listItem.getParent();
let value = 1;
if (list != null) {
if (!$isListNode(list)) {
invariant(
false,
'$getListItemValue: list node is not parent of list item node',
);
} else {
value = list.getStart();
}
}
const siblings = listItem.getPreviousSiblings();
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if ($isListItemNode(sibling) && !$isListNode(sibling.getFirstChild())) {
value++;
}
}
return value;
}
/**
* 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 haprompt editor.
* @param listType - The type of list, "number" | "bullet" | "check".
*/
export function insertList(editor: HapromptEditor, listType: ListType): void {
editor.update(() => {
const selection = $getSelection();
if (
$isRangeSelection(selection) ||
DEPRECATED_$isGridSelection(selection)
) {
const nodes = selection.getNodes();
const anchor = selection.anchor;
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;
} else {
const handled = new Set();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (
$isElementNode(node) &&
node.isEmpty() &&
!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);
updateChildrenListItemValue(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<HapromptNode>) {
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);
updateChildrenListItemValue(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);
updateChildrenListItemValue(list1);
}
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 haprompt editor.
*/
export function removeList(editor: HapromptEditor): 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. If only certain children should be updated, they
* can be passed optionally in an array.
* @param list - The list whose children are updated.
* @param children - An array of the children to be updated.
*/
export function updateChildrenListItemValue(
list: ListNode,
children?: Array<HapromptNode>,
): void {
const childrenOrExisting = children || list.getChildren();
if (childrenOrExisting !== undefined) {
for (let i = 0; i < childrenOrExisting.length; i++) {
const child = childrenOrExisting[i];
if ($isListItemNode(child)) {
const prevValue = child.getValue();
const nextValue = $getListItemValue(child);
if (prevValue !== nextValue) {
child.setValue(nextValue);
}
}
}
}
}
/**
* 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());
}
updateChildrenListItemValue(innerList);
}
} 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);
}
updateChildrenListItemValue(innerList);
}
} else if (isNestedListNode(previousSibling)) {
const innerList = previousSibling.getFirstChild();
if ($isListNode(innerList)) {
innerList.append(listItemNode);
updateChildrenListItemValue(innerList);
}
} 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);
}
updateChildrenListItemValue(newList);
}
}
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
}
}
/**
* 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);
}
updateChildrenListItemValue(parentList);
updateChildrenListItemValue(greatGrandparentList);
}
}
/**
* 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;
}
/**
* 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} from './';
import type {
DOMConversionMap,
DOMConversionOutput,
EditorConfig,
EditorThemeClasses,
GridSelection,
HapromptNode,
NodeKey,
NodeSelection,
ParagraphNode,
RangeSelection,
SerializedElementNode,
Spread,
} from 'haprompt';
import {
addClassNamesToElement,
isHTMLElement,
removeClassNamesFromElement,
} from '@haprompt/utils';
import {
$applyNodeReplacement,
$createParagraphNode,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
ElementNode,
} from 'haprompt';
import invariant from 'shared/invariant';
import {$createListNode, $isListNode} from './';
import {
$handleIndent,
$handleOutdent,
mergeLists,
updateChildrenListItemValue,
} 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: HapromptNode) => void {
return (node: HapromptNode) => {
const parent = node.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
if (parent.getListType() !== 'check' && node.getChecked() != null) {
node.setChecked(undefined);
}
}
};
}
static importDOM(): DOMConversionMap | null {
return {
li: (node: Node) => ({
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;
}
exportJSON(): SerializedListItemNode {
return {
...super.exportJSON(),
checked: this.getChecked(),
type: 'listitem',
value: this.getValue(),
version: 1,
};
}
append(...nodes: HapromptNode[]): 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 HapromptNode>(
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) {
this.getChildren().forEach((child: HapromptNode) => {
replaceWithNode.append(child);
});
}
this.remove();
if (list.getChildrenSize() === 0) {
list.remove();
}
return replaceWithNode;
}
insertAfter(node: HapromptNode, restoreSelection = true): HapromptNode {
const listNode = this.getParentOrThrow();
if (!$isListNode(listNode)) {
invariant(
false,
'insertAfter: list node is not parent of list item node',
);
}
const siblings = this.getNextSiblings();
if ($isListItemNode(node)) {
const after = super.insertAfter(node, restoreSelection);
const afterListNode = node.getParentOrThrow();
if ($isListNode(afterListNode)) {
updateChildrenListItemValue(afterListNode);
}
return after;
}
// Attempt to merge if the list is of the same type.
if ($isListNode(node)) {
let child = node;
const children = node.getChildren<ListNode>();
for (let i = children.length - 1; i >= 0; i--) {
child = children[i];
this.insertAfter(child, restoreSelection);
}
return child;
}
// Otherwise, split the list
// 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();
} else if (nextSibling) {
const parent = nextSibling.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
}
}
}
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();
return self.__checked;
}
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' && indent > -1,
'Invalid indent value.',
);
let currentIndent = this.getIndent();
while (currentIndent !== indent) {
if (currentIndent < indent) {
$handleIndent(this);
currentIndent++;
} else {
$handleOutdent(this);
currentIndent--;
}
}
return this;
}
insertBefore(nodeToInsert: HapromptNode): HapromptNode {
if ($isListItemNode(nodeToInsert)) {
const parent = this.getParentOrThrow();
if ($isListNode(parent)) {
const siblings = this.getNextSiblings();
updateChildrenListItemValue(parent, siblings);
}
}
return super.insertBefore(nodeToInsert);
}
canInsertAfter(node: HapromptNode): boolean {
return $isListItemNode(node);
}
canReplaceWith(replacement: HapromptNode): boolean {
return $isListItemNode(replacement);
}
canMergeWith(node: HapromptNode): boolean {
return $isParagraphNode(node) || $isListItemNode(node);
}
extractWithChild(
child: HapromptNode,
selection: RangeSelection | NodeSelection | GridSelection,
): 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');
}
}
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) {
const listItemClasses = listItemClassName.split(' ');
classesToAdd.push(...listItemClasses);
}
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 = nestedListItemClassName.split(' ');
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: Node): DOMConversionOutput {
const checked =
isHTMLElement(domNode) && domNode.getAttribute('aria-checked') === 'true';
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: HapromptNode | null | undefined,
): node is ListItemNode {
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.
*
*/
import {
addClassNamesToElement,
isHTMLElement,
removeClassNamesFromElement,
} from '@haprompt/utils';
import {
$applyNodeReplacement,
$createTextNode,
$isElementNode,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
EditorThemeClasses,
ElementNode,
HapromptEditor,
HapromptNode,
NodeKey,
SerializedElementNode,
Spread,
} from 'haprompt';
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
import {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?: HapromptEditor): 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.__hapromptListType = 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 importDOM(): DOMConversionMap | null {
return {
ol: (node: Node) => ({
conversion: convertListNode,
priority: 0,
}),
ul: (node: Node) => ({
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: HapromptEditor): 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('__hapromptListType', '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: HapromptNode[]): 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);
}
}
updateChildrenListItemValue(this);
return this;
}
extractWithChild(child: HapromptNode): 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;
if (nestedListTheme !== undefined && nestedListTheme.list) {
nestedListClassName = nestedListTheme.list;
}
if (listClassName !== undefined) {
classesToAdd.push(listClassName);
}
if (listLevelClassName !== undefined) {
const listItemClasses = listLevelClassName.split(' ');
classesToAdd.push(...listItemClasses);
for (let i = 0; i < listLevelsClassNames.length; i++) {
if (i !== normalizedListDepth) {
classesToRemove.push(node.__tag + i);
}
}
}
if (nestedListClassName !== undefined) {
const nestedListItemClasses = nestedListClassName.split(' ');
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<HapromptNode>): 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 convertListNode(domNode: Node): 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 (
isHTMLElement(domNode) &&
domNode.getAttribute('__hapromptlisttype') === 'check'
) {
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: HapromptNode | null | undefined,
): node is ListNode {
return node instanceof ListNode;
}
/** @module @haprompt/list */
/**
* 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 './HapromptListItemNode';
import type {ListType, SerializedListNode} from './HapromptListNode';
import type {HapromptCommand} from 'haprompt';
import {createCommand} from 'haprompt';
import {$handleListInsertParagraph, insertList, removeList} from './formatList';
import {
$createListItemNode,
$isListItemNode,
ListItemNode,
} from './HapromptListItemNode';
import {$createListNode, $isListNode, ListNode} from './HapromptListNode';
import {$getListDepth} from './utils';
export {
$createListItemNode,
$createListNode,
$getListDepth,
$handleListInsertParagraph,
$isListItemNode,
$isListNode,
insertList,
ListItemNode,
ListNode,
ListType,
removeList,
SerializedListItemNode,
SerializedListNode,
};
export const INSERT_UNORDERED_LIST_COMMAND: HapromptCommand<void> =
createCommand('INSERT_UNORDERED_LIST_COMMAND');
export const INSERT_ORDERED_LIST_COMMAND: HapromptCommand<void> = createCommand(
'INSERT_ORDERED_LIST_COMMAND',
);
export const INSERT_CHECK_LIST_COMMAND: HapromptCommand<void> = createCommand(
'INSERT_CHECK_LIST_COMMAND',
);
export const REMOVE_LIST_COMMAND: HapromptCommand<void> = createCommand(
'REMOVE_LIST_COMMAND',
);
/**
* 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 {HapromptNode} from 'haprompt';
import invariant from '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: HapromptNode): 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;
}
/**
* 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: HapromptNode | null | undefined,
): boolean {
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.
*/
// TODO: rewrite with $findMatchingParent or *nodeOfType
export function findNearestListItemNode(
node: HapromptNode,
): ListItemNode | null {
let currentNode: HapromptNode | null = node;
while (currentNode !== null) {
if ($isListItemNode(currentNode)) {
return currentNode;
}
currentNode = currentNode.getParent();
}
return 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: HapromptNode): ListItemNode {
const listItemWrapper = $createListItemNode();
return listItemWrapper.append(node);
}