Lexical: Merged list nodes
This commit is contained in:
		
							parent
							
								
									36a4d79120
								
							
						
					
					
						commit
						ebd4604f21
					
				| 
						 | 
				
			
			@ -13,7 +13,6 @@ import type {
 | 
			
		|||
  DOMConversionOutput,
 | 
			
		||||
  DOMExportOutput,
 | 
			
		||||
  EditorConfig,
 | 
			
		||||
  EditorThemeClasses,
 | 
			
		||||
  LexicalNode,
 | 
			
		||||
  NodeKey,
 | 
			
		||||
  ParagraphNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -22,10 +21,6 @@ import type {
 | 
			
		|||
  Spread,
 | 
			
		||||
} from 'lexical';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  addClassNamesToElement,
 | 
			
		||||
  removeClassNamesFromElement,
 | 
			
		||||
} from '@lexical/utils';
 | 
			
		||||
import {
 | 
			
		||||
  $applyNodeReplacement,
 | 
			
		||||
  $createParagraphNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -36,11 +31,11 @@ import {
 | 
			
		|||
  LexicalEditor,
 | 
			
		||||
} from 'lexical';
 | 
			
		||||
import invariant from 'lexical/shared/invariant';
 | 
			
		||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
 | 
			
		||||
 | 
			
		||||
import {$createListNode, $isListNode} from './';
 | 
			
		||||
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
 | 
			
		||||
import {mergeLists} from './formatList';
 | 
			
		||||
import {isNestedListNode} from './utils';
 | 
			
		||||
import {el} from "../../utils/dom";
 | 
			
		||||
 | 
			
		||||
export type SerializedListItemNode = Spread<
 | 
			
		||||
  {
 | 
			
		||||
| 
						 | 
				
			
			@ -74,11 +69,17 @@ export class ListItemNode extends ElementNode {
 | 
			
		|||
  createDOM(config: EditorConfig): HTMLElement {
 | 
			
		||||
    const element = document.createElement('li');
 | 
			
		||||
    const parent = this.getParent();
 | 
			
		||||
 | 
			
		||||
    if ($isListNode(parent) && parent.getListType() === 'check') {
 | 
			
		||||
      updateListItemChecked(element, this, null, parent);
 | 
			
		||||
      updateListItemChecked(element, this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    element.value = this.__value;
 | 
			
		||||
    $setListItemThemeClassNames(element, config.theme, this);
 | 
			
		||||
 | 
			
		||||
    if ($hasNestedListWithoutLabel(this)) {
 | 
			
		||||
      element.style.listStyle = 'none';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return element;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -89,11 +90,12 @@ export class ListItemNode extends ElementNode {
 | 
			
		|||
  ): boolean {
 | 
			
		||||
    const parent = this.getParent();
 | 
			
		||||
    if ($isListNode(parent) && parent.getListType() === 'check') {
 | 
			
		||||
      updateListItemChecked(dom, this, prevNode, parent);
 | 
			
		||||
      updateListItemChecked(dom, this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
 | 
			
		||||
    // @ts-expect-error - this is always HTMLListItemElement
 | 
			
		||||
    dom.value = this.__value;
 | 
			
		||||
    $setListItemThemeClassNames(dom, config.theme, this);
 | 
			
		||||
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -132,6 +134,20 @@ export class ListItemNode extends ElementNode {
 | 
			
		|||
 | 
			
		||||
  exportDOM(editor: LexicalEditor): DOMExportOutput {
 | 
			
		||||
    const element = this.createDOM(editor._config);
 | 
			
		||||
 | 
			
		||||
    if (element.classList.contains('task-list-item')) {
 | 
			
		||||
      const input = el('input', {
 | 
			
		||||
        type: 'checkbox',
 | 
			
		||||
        disabled: 'disabled',
 | 
			
		||||
      });
 | 
			
		||||
      if (element.hasAttribute('checked')) {
 | 
			
		||||
        input.setAttribute('checked', 'checked');
 | 
			
		||||
        element.removeAttribute('checked');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      element.prepend(input);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      element,
 | 
			
		||||
    };
 | 
			
		||||
| 
						 | 
				
			
			@ -390,89 +406,33 @@ export class ListItemNode extends ElementNode {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
 | 
			
		||||
  const children = node.getChildren();
 | 
			
		||||
  let hasLabel = false;
 | 
			
		||||
  let hasNestedList = false;
 | 
			
		||||
 | 
			
		||||
  if (listTheme && listTheme.nested) {
 | 
			
		||||
    nestedListItemClassName = listTheme.nested.listitem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (listItemClassName !== undefined) {
 | 
			
		||||
    classesToAdd.push(...normalizeClassNames(listItemClassName));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (listTheme) {
 | 
			
		||||
    const parentNode = node.getParent();
 | 
			
		||||
    const isCheckList =
 | 
			
		||||
      $isListNode(parentNode) && parentNode.getListType() === 'check';
 | 
			
		||||
    const checked = node.getChecked();
 | 
			
		||||
 | 
			
		||||
    if (!isCheckList || checked) {
 | 
			
		||||
      classesToRemove.push(listTheme.listitemUnchecked);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!isCheckList || !checked) {
 | 
			
		||||
      classesToRemove.push(listTheme.listitemChecked);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (isCheckList) {
 | 
			
		||||
      classesToAdd.push(
 | 
			
		||||
        checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
 | 
			
		||||
      );
 | 
			
		||||
  for (const child of children) {
 | 
			
		||||
    if ($isListNode(child)) {
 | 
			
		||||
      hasNestedList = true;
 | 
			
		||||
    } else if (child.getTextContent().trim().length > 0) {
 | 
			
		||||
      hasLabel = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (nestedListItemClassName !== undefined) {
 | 
			
		||||
    const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
 | 
			
		||||
 | 
			
		||||
    if (node.getChildren().some((child) => $isListNode(child))) {
 | 
			
		||||
      classesToAdd.push(...nestedListItemClasses);
 | 
			
		||||
    } else {
 | 
			
		||||
      classesToRemove.push(...nestedListItemClasses);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (classesToRemove.length > 0) {
 | 
			
		||||
    removeClassNamesFromElement(dom, ...classesToRemove);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (classesToAdd.length > 0) {
 | 
			
		||||
    addClassNamesToElement(dom, ...classesToAdd);
 | 
			
		||||
  }
 | 
			
		||||
  return hasNestedList && !hasLabel;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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');
 | 
			
		||||
  // Only set task list attrs for leaf list items
 | 
			
		||||
  const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
 | 
			
		||||
  dom.classList.toggle('task-list-item', shouldBeTaskItem);
 | 
			
		||||
  if (listItemNode.__checked) {
 | 
			
		||||
    dom.setAttribute('checked', 'checked');
 | 
			
		||||
  } else {
 | 
			
		||||
    dom.setAttribute('role', 'checkbox');
 | 
			
		||||
    dom.setAttribute('tabIndex', '-1');
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      !prevListItemNode ||
 | 
			
		||||
      listItemNode.__checked !== prevListItemNode.__checked
 | 
			
		||||
    ) {
 | 
			
		||||
      dom.setAttribute(
 | 
			
		||||
        'aria-checked',
 | 
			
		||||
        listItemNode.getChecked() ? 'true' : 'false',
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    dom.removeAttribute('checked');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,9 +36,11 @@ import {
 | 
			
		|||
  updateChildrenListItemValue,
 | 
			
		||||
} from './formatList';
 | 
			
		||||
import {$getListDepth, $wrapInListItem} from './utils';
 | 
			
		||||
import {extractDirectionFromElement} from "../../nodes/_common";
 | 
			
		||||
 | 
			
		||||
export type SerializedListNode = Spread<
 | 
			
		||||
  {
 | 
			
		||||
    id: string;
 | 
			
		||||
    listType: ListType;
 | 
			
		||||
    start: number;
 | 
			
		||||
    tag: ListNodeTagType;
 | 
			
		||||
| 
						 | 
				
			
			@ -58,15 +60,18 @@ export class ListNode extends ElementNode {
 | 
			
		|||
  __start: number;
 | 
			
		||||
  /** @internal */
 | 
			
		||||
  __listType: ListType;
 | 
			
		||||
  /** @internal */
 | 
			
		||||
  __id: string = '';
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
    const newNode = new ListNode(node.__listType, node.__start, node.__key);
 | 
			
		||||
    newNode.__id = node.__id;
 | 
			
		||||
    newNode.__dir = node.__dir;
 | 
			
		||||
    return newNode;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  constructor(listType: ListType, start: number, key?: NodeKey) {
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +86,16 @@ export class ListNode extends ElementNode {
 | 
			
		|||
    return this.__tag;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setId(id: string) {
 | 
			
		||||
    const self = this.getWritable();
 | 
			
		||||
    self.__id = id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getId(): string {
 | 
			
		||||
    const self = this.getLatest();
 | 
			
		||||
    return self.__id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setListType(type: ListType): void {
 | 
			
		||||
    const writable = this.getWritable();
 | 
			
		||||
    writable.__listType = type;
 | 
			
		||||
| 
						 | 
				
			
			@ -108,6 +123,14 @@ export class ListNode extends ElementNode {
 | 
			
		|||
    dom.__lexicalListType = this.__listType;
 | 
			
		||||
    $setListThemeClassNames(dom, config.theme, this);
 | 
			
		||||
 | 
			
		||||
    if (this.__id) {
 | 
			
		||||
      dom.setAttribute('id', this.__id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.__dir) {
 | 
			
		||||
      dom.setAttribute('dir', this.__dir);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return dom;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -116,7 +139,11 @@ export class ListNode extends ElementNode {
 | 
			
		|||
    dom: HTMLElement,
 | 
			
		||||
    config: EditorConfig,
 | 
			
		||||
  ): boolean {
 | 
			
		||||
    if (prevNode.__tag !== this.__tag) {
 | 
			
		||||
    if (
 | 
			
		||||
        prevNode.__tag !== this.__tag
 | 
			
		||||
        || prevNode.__dir !== this.__dir
 | 
			
		||||
        || prevNode.__id !== this.__id
 | 
			
		||||
    ) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -148,8 +175,7 @@ export class ListNode extends ElementNode {
 | 
			
		|||
 | 
			
		||||
  static importJSON(serializedNode: SerializedListNode): ListNode {
 | 
			
		||||
    const node = $createListNode(serializedNode.listType, serializedNode.start);
 | 
			
		||||
    node.setFormat(serializedNode.format);
 | 
			
		||||
    node.setIndent(serializedNode.indent);
 | 
			
		||||
    node.setId(serializedNode.id);
 | 
			
		||||
    node.setDirection(serializedNode.direction);
 | 
			
		||||
    return node;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -177,6 +203,7 @@ export class ListNode extends ElementNode {
 | 
			
		|||
      tag: this.getTag(),
 | 
			
		||||
      type: 'list',
 | 
			
		||||
      version: 1,
 | 
			
		||||
      id: this.__id,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -277,28 +304,21 @@ function $setListThemeClassNames(
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * 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.
 | 
			
		||||
 * This function is a custom normalization function to allow nested lists within list item elements.
 | 
			
		||||
 * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
 | 
			
		||||
 * With modifications made.
 | 
			
		||||
 */
 | 
			
		||||
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
 | 
			
		||||
  const normalizedListItems: Array<ListItemNode> = [];
 | 
			
		||||
  for (let i = 0; i < nodes.length; i++) {
 | 
			
		||||
    const node = nodes[i];
 | 
			
		||||
 | 
			
		||||
  for (const node of nodes) {
 | 
			
		||||
    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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -334,6 +354,14 @@ function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (domNode.id && node) {
 | 
			
		||||
    node.setId(domNode.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (domNode.dir && node) {
 | 
			
		||||
    node.setDirection(extractDirectionFromElement(domNode));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    after: $normalizeChildren,
 | 
			
		||||
    node,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1265,99 +1265,5 @@ describe('LexicalListItemNode tests', () => {
 | 
			
		|||
        expect($isListItemNode(listItemNode)).toBe(true);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('ListItemNode.setIndent()', () => {
 | 
			
		||||
      let listNode: ListNode;
 | 
			
		||||
      let listItemNode1: ListItemNode;
 | 
			
		||||
      let listItemNode2: ListItemNode;
 | 
			
		||||
 | 
			
		||||
      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">
 | 
			
		||||
                            <span data-lexical-text="true">one</span>
 | 
			
		||||
                          </li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                      </li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
                  </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li value="1">
 | 
			
		||||
                <span data-lexical-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">
 | 
			
		||||
                <span data-lexical-text="true">one</span>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li value="2">
 | 
			
		||||
                <span data-lexical-text="true">two</span>
 | 
			
		||||
              </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
          `,
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('handles fractional indent values', async () => {
 | 
			
		||||
        const {editor} = testEnv;
 | 
			
		||||
 | 
			
		||||
        await editor.update(() => {
 | 
			
		||||
          listItemNode1.setIndent(0.5);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await editor.update(() => {
 | 
			
		||||
          expect(listItemNode1.getIndent()).toBe(0);
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,120 +0,0 @@
 | 
			
		|||
import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list";
 | 
			
		||||
import {EditorConfig} from "lexical/LexicalEditor";
 | 
			
		||||
import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
 | 
			
		||||
 | 
			
		||||
import {el} from "../utils/dom";
 | 
			
		||||
import {$isCustomListNode} from "./custom-list";
 | 
			
		||||
 | 
			
		||||
function updateListItemChecked(
 | 
			
		||||
    dom: HTMLElement,
 | 
			
		||||
    listItemNode: ListItemNode,
 | 
			
		||||
): void {
 | 
			
		||||
    // Only set task list attrs for leaf list items
 | 
			
		||||
    const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
 | 
			
		||||
    dom.classList.toggle('task-list-item', shouldBeTaskItem);
 | 
			
		||||
    if (listItemNode.__checked) {
 | 
			
		||||
        dom.setAttribute('checked', 'checked');
 | 
			
		||||
    } else {
 | 
			
		||||
        dom.removeAttribute('checked');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export class CustomListItemNode extends ListItemNode {
 | 
			
		||||
    static getType(): string {
 | 
			
		||||
        return 'custom-list-item';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static clone(node: CustomListItemNode): CustomListItemNode {
 | 
			
		||||
        return new CustomListItemNode(node.__value, node.__checked, node.__key);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createDOM(config: EditorConfig): HTMLElement {
 | 
			
		||||
        const element = document.createElement('li');
 | 
			
		||||
        const parent = this.getParent();
 | 
			
		||||
 | 
			
		||||
        if ($isListNode(parent) && parent.getListType() === 'check') {
 | 
			
		||||
            updateListItemChecked(element, this);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        element.value = this.__value;
 | 
			
		||||
 | 
			
		||||
        if ($hasNestedListWithoutLabel(this)) {
 | 
			
		||||
            element.style.listStyle = 'none';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return element;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateDOM(
 | 
			
		||||
        prevNode: ListItemNode,
 | 
			
		||||
        dom: HTMLElement,
 | 
			
		||||
        config: EditorConfig,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        const parent = this.getParent();
 | 
			
		||||
        if ($isListNode(parent) && parent.getListType() === 'check') {
 | 
			
		||||
            updateListItemChecked(dom, this);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
 | 
			
		||||
        // @ts-expect-error - this is always HTMLListItemElement
 | 
			
		||||
        dom.value = this.__value;
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportDOM(editor: LexicalEditor): DOMExportOutput {
 | 
			
		||||
        const element = this.createDOM(editor._config);
 | 
			
		||||
        element.style.textAlign = this.getFormatType();
 | 
			
		||||
 | 
			
		||||
        if (element.classList.contains('task-list-item')) {
 | 
			
		||||
            const input = el('input', {
 | 
			
		||||
                type: 'checkbox',
 | 
			
		||||
                disabled: 'disabled',
 | 
			
		||||
            });
 | 
			
		||||
            if (element.hasAttribute('checked')) {
 | 
			
		||||
                input.setAttribute('checked', 'checked');
 | 
			
		||||
                element.removeAttribute('checked');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            element.prepend(input);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            element,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportJSON(): SerializedListItemNode {
 | 
			
		||||
        return {
 | 
			
		||||
            ...super.exportJSON(),
 | 
			
		||||
            type: 'custom-list-item',
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean {
 | 
			
		||||
    const children = node.getChildren();
 | 
			
		||||
    let hasLabel = false;
 | 
			
		||||
    let hasNestedList = false;
 | 
			
		||||
 | 
			
		||||
    for (const child of children) {
 | 
			
		||||
        if ($isCustomListNode(child)) {
 | 
			
		||||
            hasNestedList = true;
 | 
			
		||||
        } else if (child.getTextContent().trim().length > 0) {
 | 
			
		||||
            hasLabel = true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return hasNestedList && !hasLabel;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $isCustomListItemNode(
 | 
			
		||||
    node: LexicalNode | null | undefined,
 | 
			
		||||
): node is CustomListItemNode {
 | 
			
		||||
    return node instanceof CustomListItemNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $createCustomListItemNode(): CustomListItemNode {
 | 
			
		||||
    return new CustomListItemNode();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,139 +0,0 @@
 | 
			
		|||
import {
 | 
			
		||||
    DOMConversionFn,
 | 
			
		||||
    DOMConversionMap, EditorConfig,
 | 
			
		||||
    LexicalNode,
 | 
			
		||||
    Spread
 | 
			
		||||
} from "lexical";
 | 
			
		||||
import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list";
 | 
			
		||||
import {$createCustomListItemNode} from "./custom-list-item";
 | 
			
		||||
import {extractDirectionFromElement} from "./_common";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export type SerializedCustomListNode = Spread<{
 | 
			
		||||
    id: string;
 | 
			
		||||
}, SerializedListNode>
 | 
			
		||||
 | 
			
		||||
export class CustomListNode extends ListNode {
 | 
			
		||||
    __id: string = '';
 | 
			
		||||
 | 
			
		||||
    static getType() {
 | 
			
		||||
        return 'custom-list';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setId(id: string) {
 | 
			
		||||
        const self = this.getWritable();
 | 
			
		||||
        self.__id = id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getId(): string {
 | 
			
		||||
        const self = this.getLatest();
 | 
			
		||||
        return self.__id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static clone(node: CustomListNode) {
 | 
			
		||||
        const newNode = new CustomListNode(node.__listType, node.__start, node.__key);
 | 
			
		||||
        newNode.__id = node.__id;
 | 
			
		||||
        newNode.__dir = node.__dir;
 | 
			
		||||
        return newNode;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createDOM(config: EditorConfig): HTMLElement {
 | 
			
		||||
        const dom = super.createDOM(config);
 | 
			
		||||
        if (this.__id) {
 | 
			
		||||
            dom.setAttribute('id', this.__id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.__dir) {
 | 
			
		||||
            dom.setAttribute('dir', this.__dir);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return dom;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean {
 | 
			
		||||
        return super.updateDOM(prevNode, dom, config) ||
 | 
			
		||||
            prevNode.__dir !== this.__dir;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportJSON(): SerializedCustomListNode {
 | 
			
		||||
        return {
 | 
			
		||||
            ...super.exportJSON(),
 | 
			
		||||
            type: 'custom-list',
 | 
			
		||||
            version: 1,
 | 
			
		||||
            id: this.__id,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static importJSON(serializedNode: SerializedCustomListNode): CustomListNode {
 | 
			
		||||
        const node = $createCustomListNode(serializedNode.listType);
 | 
			
		||||
        node.setId(serializedNode.id);
 | 
			
		||||
        node.setDirection(serializedNode.direction);
 | 
			
		||||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static importDOM(): DOMConversionMap | null {
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        const converter = super.importDOM().ol().conversion as DOMConversionFn<HTMLElement>;
 | 
			
		||||
        const customConvertFunction = (element: HTMLElement) => {
 | 
			
		||||
            const baseResult = converter(element);
 | 
			
		||||
            if (element.id && baseResult?.node) {
 | 
			
		||||
                (baseResult.node as CustomListNode).setId(element.id);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (element.dir && baseResult?.node) {
 | 
			
		||||
                (baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (baseResult) {
 | 
			
		||||
                baseResult.after = $normalizeChildren;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return baseResult;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            ol: () => ({
 | 
			
		||||
                conversion: customConvertFunction,
 | 
			
		||||
                priority: 0,
 | 
			
		||||
            }),
 | 
			
		||||
            ul: () => ({
 | 
			
		||||
                conversion: customConvertFunction,
 | 
			
		||||
                priority: 0,
 | 
			
		||||
            }),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * This function is a custom normalization function to allow nested lists within list item elements.
 | 
			
		||||
 * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
 | 
			
		||||
 * With modifications made.
 | 
			
		||||
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 | 
			
		||||
 * MIT license
 | 
			
		||||
 */
 | 
			
		||||
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
 | 
			
		||||
    const normalizedListItems: Array<ListItemNode> = [];
 | 
			
		||||
 | 
			
		||||
    for (const node of nodes) {
 | 
			
		||||
        if ($isListItemNode(node)) {
 | 
			
		||||
            normalizedListItems.push(node);
 | 
			
		||||
        } else {
 | 
			
		||||
            normalizedListItems.push($wrapInListItem(node));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return normalizedListItems;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function $wrapInListItem(node: LexicalNode): ListItemNode {
 | 
			
		||||
    const listItemWrapper = $createCustomListItemNode();
 | 
			
		||||
    return listItemWrapper.append(node);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $createCustomListNode(type: ListType): CustomListNode {
 | 
			
		||||
    return new CustomListNode(type, 1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
 | 
			
		||||
    return node instanceof CustomListNode;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -17,10 +17,8 @@ import {CodeBlockNode} from "./code-block";
 | 
			
		|||
import {DiagramNode} from "./diagram";
 | 
			
		||||
import {EditorUiContext} from "../ui/framework/core";
 | 
			
		||||
import {MediaNode} from "./media";
 | 
			
		||||
import {CustomListItemNode} from "./custom-list-item";
 | 
			
		||||
import {CustomTableCellNode} from "./custom-table-cell";
 | 
			
		||||
import {CustomTableRowNode} from "./custom-table-row";
 | 
			
		||||
import {CustomListNode} from "./custom-list";
 | 
			
		||||
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 | 
			
		||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -32,8 +30,8 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
 | 
			
		|||
        CalloutNode,
 | 
			
		||||
        HeadingNode,
 | 
			
		||||
        QuoteNode,
 | 
			
		||||
        CustomListNode,
 | 
			
		||||
        CustomListItemNode, // TODO - Alignment?
 | 
			
		||||
        ListNode,
 | 
			
		||||
        ListItemNode,
 | 
			
		||||
        CustomTableNode,
 | 
			
		||||
        CustomTableRowNode,
 | 
			
		||||
        CustomTableCellNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -45,18 +43,6 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
 | 
			
		|||
        MediaNode, // TODO - Alignment
 | 
			
		||||
        ParagraphNode,
 | 
			
		||||
        LinkNode,
 | 
			
		||||
        {
 | 
			
		||||
            replace: ListNode,
 | 
			
		||||
            with: (node: ListNode) => {
 | 
			
		||||
                return new CustomListNode(node.getListType(), node.getStart());
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            replace: ListItemNode,
 | 
			
		||||
            with: (node: ListItemNode) => {
 | 
			
		||||
                return new CustomListItemNode(node.__value, node.__checked);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            replace: TableNode,
 | 
			
		||||
            with(node: TableNode) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,8 +14,8 @@ import {$isImageNode} from "../nodes/image";
 | 
			
		|||
import {$isMediaNode} from "../nodes/media";
 | 
			
		||||
import {getLastSelection} from "../utils/selection";
 | 
			
		||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
 | 
			
		||||
import {$isCustomListItemNode} from "../nodes/custom-list-item";
 | 
			
		||||
import {$setInsetForSelection} from "../utils/lists";
 | 
			
		||||
import {$isListItemNode} from "@lexical/list";
 | 
			
		||||
 | 
			
		||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
			
		||||
    if (nodes.length === 1) {
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +62,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
 | 
			
		|||
    const change = event?.shiftKey ? -40 : 40;
 | 
			
		||||
    const selection = $getSelection();
 | 
			
		||||
    const nodes = selection?.getNodes() || [];
 | 
			
		||||
    if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
 | 
			
		||||
    if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
 | 
			
		||||
        editor.update(() => {
 | 
			
		||||
            $setInsetForSelection(editor, change);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
 | 
			
		||||
import {$isCustomListItemNode} from "../../../nodes/custom-list-item";
 | 
			
		||||
import {$isListItemNode} from "@lexical/list";
 | 
			
		||||
 | 
			
		||||
class TaskListHandler {
 | 
			
		||||
    protected editorContainer: HTMLElement;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +38,7 @@ class TaskListHandler {
 | 
			
		|||
 | 
			
		||||
        this.editor.update(() => {
 | 
			
		||||
            const node = $getNearestNodeFromDOMNode(listItem);
 | 
			
		||||
            if ($isCustomListItemNode(node)) {
 | 
			
		||||
            if ($isListItemNode(node)) {
 | 
			
		||||
                node.setChecked(!node.getChecked());
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,8 +16,7 @@ import {
 | 
			
		|||
} from "./selection";
 | 
			
		||||
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block";
 | 
			
		||||
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout";
 | 
			
		||||
import {insertList, ListNode, ListType, removeList} from "@lexical/list";
 | 
			
		||||
import {$isCustomListNode} from "../nodes/custom-list";
 | 
			
		||||
import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list";
 | 
			
		||||
import {$createLinkNode, $isLinkNode} from "@lexical/link";
 | 
			
		||||
import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
 | 
			
		||||
import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 | 
			
		||||
| 
						 | 
				
			
			@ -51,7 +50,7 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) {
 | 
			
		|||
    editor.getEditorState().read(() => {
 | 
			
		||||
        const selection = $getSelection();
 | 
			
		||||
        const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
 | 
			
		||||
            return $isCustomListNode(node) && (node as ListNode).getListType() === type;
 | 
			
		||||
            return $isListNode(node) && (node as ListNode).getListType() === type;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (listSelected) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,22 +1,21 @@
 | 
			
		|||
import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item";
 | 
			
		||||
import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list";
 | 
			
		||||
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
 | 
			
		||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
 | 
			
		||||
import {nodeHasInset} from "./nodes";
 | 
			
		||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export function $nestListItem(node: CustomListItemNode): CustomListItemNode {
 | 
			
		||||
export function $nestListItem(node: ListItemNode): ListItemNode {
 | 
			
		||||
    const list = node.getParent();
 | 
			
		||||
    if (!$isCustomListNode(list)) {
 | 
			
		||||
    if (!$isListNode(list)) {
 | 
			
		||||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const listItems = list.getChildren() as CustomListItemNode[];
 | 
			
		||||
    const listItems = list.getChildren() as ListItemNode[];
 | 
			
		||||
    const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
 | 
			
		||||
    const isFirst = nodeIndex === 0;
 | 
			
		||||
 | 
			
		||||
    const newListItem = $createCustomListItemNode();
 | 
			
		||||
    const newList = $createCustomListNode(list.getListType());
 | 
			
		||||
    const newListItem = $createListItemNode();
 | 
			
		||||
    const newList = $createListNode(list.getListType());
 | 
			
		||||
    newList.append(newListItem);
 | 
			
		||||
    newListItem.append(...node.getChildren());
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -31,11 +30,11 @@ export function $nestListItem(node: CustomListItemNode): CustomListItemNode {
 | 
			
		|||
    return newListItem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $unnestListItem(node: CustomListItemNode): CustomListItemNode {
 | 
			
		||||
export function $unnestListItem(node: ListItemNode): ListItemNode {
 | 
			
		||||
    const list = node.getParent();
 | 
			
		||||
    const parentListItem = list?.getParent();
 | 
			
		||||
    const outerList = parentListItem?.getParent();
 | 
			
		||||
    if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) {
 | 
			
		||||
    if (!$isListNode(list) || !$isListNode(outerList) || !$isListItemNode(parentListItem)) {
 | 
			
		||||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -51,19 +50,19 @@ export function $unnestListItem(node: CustomListItemNode): CustomListItemNode {
 | 
			
		|||
    return node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] {
 | 
			
		||||
function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
 | 
			
		||||
    const nodes = selection?.getNodes() || [];
 | 
			
		||||
    const listItemNodes = [];
 | 
			
		||||
 | 
			
		||||
    outer: for (const node of nodes) {
 | 
			
		||||
        if ($isCustomListItemNode(node)) {
 | 
			
		||||
        if ($isListItemNode(node)) {
 | 
			
		||||
            listItemNodes.push(node);
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const parents = node.getParents();
 | 
			
		||||
        for (const parent of parents) {
 | 
			
		||||
            if ($isCustomListItemNode(parent)) {
 | 
			
		||||
            if ($isListItemNode(parent)) {
 | 
			
		||||
                listItemNodes.push(parent);
 | 
			
		||||
                continue outer;
 | 
			
		||||
            }
 | 
			
		||||
| 
						 | 
				
			
			@ -75,8 +74,8 @@ function getListItemsForSelection(selection: BaseSelection|null): (CustomListIte
 | 
			
		|||
    return listItemNodes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] {
 | 
			
		||||
    const listItemMap: Record<string, CustomListItemNode> = {};
 | 
			
		||||
function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[] {
 | 
			
		||||
    const listItemMap: Record<string, ListItemNode> = {};
 | 
			
		||||
 | 
			
		||||
    for (const item of listItems) {
 | 
			
		||||
        if (item === null) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue