DOMConversionOutput,
DOMExportOutput,
EditorConfig,
- EditorThemeClasses,
LexicalNode,
NodeKey,
ParagraphNode,
Spread,
} from 'lexical';
-import {
- addClassNamesToElement,
- removeClassNamesFromElement,
-} from '@lexical/utils';
import {
$applyNodeReplacement,
$createParagraphNode,
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<
{
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;
}
): 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;
}
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,
};
}
}
-function $setListItemThemeClassNames(
- dom: HTMLElement,
- editorThemeClasses: EditorThemeClasses,
- node: ListItemNode,
-): void {
- const classesToAdd = [];
- const classesToRemove = [];
- const listTheme = editorThemeClasses.list;
- const listItemClassName = listTheme ? listTheme.listitem : undefined;
- let nestedListItemClassName;
-
- if (listTheme && listTheme.nested) {
- nestedListItemClassName = listTheme.nested.listitem;
- }
-
- if (listItemClassName !== undefined) {
- classesToAdd.push(...normalizeClassNames(listItemClassName));
- }
-
- if (listTheme) {
- const parentNode = node.getParent();
- const isCheckList =
- $isListNode(parentNode) && parentNode.getListType() === 'check';
- const checked = node.getChecked();
-
- if (!isCheckList || checked) {
- classesToRemove.push(listTheme.listitemUnchecked);
- }
-
- if (!isCheckList || !checked) {
- classesToRemove.push(listTheme.listitemChecked);
- }
+function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
+ const children = node.getChildren();
+ let hasLabel = false;
+ let hasNestedList = false;
- 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');
}
}
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;
__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) {
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;
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;
}
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;
}
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;
}
tag: this.getTag(),
type: 'list',
version: 1,
+ id: this.__id,
};
}
}
/*
- * 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;
}
}
}
+ if (domNode.id && node) {
+ node.setId(domNode.id);
+ }
+
+ if (domNode.dir && node) {
+ node.setDirection(extractDirectionFromElement(domNode));
+ }
+
return {
after: $normalizeChildren,
node,
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);
- });
- });
- });
});
});
+++ /dev/null
-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();
-}
\ No newline at end of file
+++ /dev/null
-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;
-}
\ No newline at end of file
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";
CalloutNode,
HeadingNode,
QuoteNode,
- CustomListNode,
- CustomListItemNode, // TODO - Alignment?
+ ListNode,
+ ListItemNode,
CustomTableNode,
CustomTableRowNode,
CustomTableCellNode,
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) {
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) {
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);
});
import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
-import {$isCustomListItemNode} from "../../../nodes/custom-list-item";
+import {$isListItemNode} from "@lexical/list";
class TaskListHandler {
protected editorContainer: HTMLElement;
this.editor.update(() => {
const node = $getNearestNodeFromDOMNode(listItem);
- if ($isCustomListItemNode(node)) {
+ if ($isListItemNode(node)) {
node.setChecked(!node.getChecked());
}
});
} 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";
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) {
-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());
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;
}
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;
}
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) {