import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
import {el} from "../utils/dom";
+import {$isCustomListNode} from "./custom-list";
function updateListItemChecked(
dom: HTMLElement,
element.value = this.__value;
+ if ($hasNestedListWithoutLabel(this)) {
+ element.style.listStyle = 'none';
+ }
+
return element;
}
}
}
+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
Spread
} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor";
-import {ListNode, ListType, SerializedListNode} from "@lexical/list";
+import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list";
+import {$createCustomListItemNode} from "./custom-list-item";
export type SerializedCustomListNode = Spread<{
}
static clone(node: CustomListNode) {
- const newNode = new CustomListNode(node.__listType, 0, node.__key);
+ const newNode = new CustomListNode(node.__listType, node.__start, node.__key);
newNode.__id = node.__id;
return newNode;
}
if (element.id && baseResult?.node) {
(baseResult.node as CustomListNode).setId(element.id);
}
+
+ if (baseResult) {
+ baseResult.after = $normalizeChildren;
+ }
+
return baseResult;
};
}
}
+/*
+ * 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, 0);
+ return new CustomListNode(type, 1);
}
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
import {EditorUiContext} from "../ui/framework/core";
import {
+ $getSelection,
$isDecoratorNode,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
- KEY_ENTER_COMMAND,
+ KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
LexicalEditor,
LexicalNode
} from "lexical";
import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent} from "../utils/nodes";
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
+import {$isCustomListItemNode} from "../nodes/custom-list-item";
+import {$setInsetForSelection} from "../utils/lists";
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
if (nodes.length === 1) {
return false;
}
+function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null) {
+ const change = event?.shiftKey ? -40 : 40;
+ editor.update(() => {
+ const selection = $getSelection();
+ const nodes = selection?.getNodes() || [];
+ if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
+ $setInsetForSelection(editor, change);
+ }
+ });
+}
+
export function registerKeyboardHandling(context: EditorUiContext): () => void {
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
deleteSingleSelectedNode(context.editor);
return insertAfterSingleSelectedNode(context.editor, event);
}, COMMAND_PRIORITY_LOW);
+ const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
+ return handleInsetOnTab(context.editor, event);
+ }, COMMAND_PRIORITY_LOW);
+
return () => {
unregisterBackspace();
unregisterDelete();
unregisterEnter();
+ unregisterTab();
};
}
\ No newline at end of file
## Main Todo
-- Align list nesting with old editor
- Mac: Shortcut support via command.
+- RTL/LTR support
## Secondary Todo
## Bugs
-//
\ No newline at end of file
+- Focus/click area reduced to content area, single line on initial access
+- List selection can get lost on nesting/unnesting
\ No newline at end of file
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
import {
$getBlockElementNodesInSelection,
- $selectionContainsNodeType,
+ $selectionContainsNodeType, $selectNodes, $selectSingleNode,
$toggleSelection,
getLastSelection
} from "../../../utils/selection";
import {toggleSelectionAsList} from "../../../utils/formats";
import {nodeHasInset} from "../../../utils/nodes";
+import {$isCustomListItemNode, CustomListItemNode} from "../../../nodes/custom-list-item";
+import {$nestListItem, $setInsetForSelection, $unnestListItem} from "../../../utils/lists";
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
-
-function setInsetForSelection(editor: LexicalEditor, change: number): void {
- const selection = getLastSelection(editor);
-
- const elements = $getBlockElementNodesInSelection(selection);
- for (const node of elements) {
- if (nodeHasInset(node)) {
- const currentInset = node.getInset();
- const newInset = Math.min(Math.max(currentInset + change, 0), 500);
- node.setInset(newInset)
- }
- }
-
- $toggleSelection(editor);
-}
-
export const indentIncrease: EditorButtonDefinition = {
label: 'Increase indent',
icon: indentIncreaseIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
- setInsetForSelection(context.editor, 40);
+ $setInsetForSelection(context.editor, 40);
});
},
isActive() {
icon: indentDecreaseIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
- setInsetForSelection(context.editor, -40);
+ $setInsetForSelection(context.editor, -40);
});
},
isActive() {
--- /dev/null
+import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item";
+import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list";
+import {BaseSelection, LexicalEditor} from "lexical";
+import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection, getLastSelection} from "./selection";
+import {nodeHasInset} from "./nodes";
+
+
+export function $nestListItem(node: CustomListItemNode) {
+ const list = node.getParent();
+ if (!$isCustomListNode(list)) {
+ return;
+ }
+
+ const listItems = list.getChildren() as CustomListItemNode[];
+ const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
+ const isFirst = nodeIndex === 0;
+
+ const newListItem = $createCustomListItemNode();
+ const newList = $createCustomListNode(list.getListType());
+ newList.append(newListItem);
+ newListItem.append(...node.getChildren());
+
+ if (isFirst) {
+ node.append(newList);
+ } else {
+ const prevListItem = listItems[nodeIndex - 1];
+ prevListItem.append(newList);
+ node.remove();
+ }
+}
+
+export function $unnestListItem(node: CustomListItemNode) {
+ const list = node.getParent();
+ const parentListItem = list?.getParent();
+ const outerList = parentListItem?.getParent();
+ if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) {
+ return;
+ }
+
+ parentListItem.insertAfter(node);
+ if (list.getChildren().length === 0) {
+ list.remove();
+ }
+
+ if (parentListItem.getChildren().length === 0) {
+ parentListItem.remove();
+ }
+}
+
+function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] {
+ const nodes = selection?.getNodes() || [];
+ const listItemNodes = [];
+
+ outer: for (const node of nodes) {
+ if ($isCustomListItemNode(node)) {
+ listItemNodes.push(node);
+ continue;
+ }
+
+ const parents = node.getParents();
+ for (const parent of parents) {
+ if ($isCustomListItemNode(parent)) {
+ listItemNodes.push(parent);
+ continue outer;
+ }
+ }
+
+ listItemNodes.push(null);
+ }
+
+ return listItemNodes;
+}
+
+function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] {
+ const listItemMap: Record<string, CustomListItemNode> = {};
+
+ for (const item of listItems) {
+ if (item === null) {
+ continue;
+ }
+
+ const key = item.getKey();
+ if (typeof listItemMap[key] === 'undefined') {
+ listItemMap[key] = item;
+ }
+ }
+
+ return Object.values(listItemMap);
+}
+
+export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
+ const selection = getLastSelection(editor);
+
+ const listItemsInSelection = getListItemsForSelection(selection);
+ const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
+
+ if (isListSelection) {
+ const listItems = $reduceDedupeListItems(listItemsInSelection);
+ if (change > 0) {
+ for (const listItem of listItems) {
+ $nestListItem(listItem);
+ }
+ } else if (change < 0) {
+ for (const listItem of [...listItems].reverse()) {
+ $unnestListItem(listItem);
+ }
+ }
+
+ $selectNodes(listItems);
+ return;
+ }
+
+ const elements = $getBlockElementNodesInSelection(selection);
+ for (const node of elements) {
+ if (nodeHasInset(node)) {
+ const currentInset = node.getInset();
+ const newInset = Math.min(Math.max(currentInset + change, 0), 500);
+ node.setInset(newInset)
+ }
+ }
+
+ $toggleSelection(editor);
+}
\ No newline at end of file
ElementFormatType,
ElementNode, LexicalEditor,
LexicalNode,
- TextFormatType
+ TextFormatType, TextNode
} from "lexical";
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
$setSelection(nodeSelection);
}
+function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
+ for (const node of nodes) {
+ if ($isTextNode(node)) {
+ return node;
+ }
+
+ if ($isElementNode(node)) {
+ const children = node.getChildren();
+ const textNode = getFirstTextNodeInNodes(children);
+ if (textNode !== null) {
+ return textNode;
+ }
+ }
+ }
+
+ return null;
+}
+
+function getLastTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
+ const revNodes = [...nodes].reverse();
+ for (const node of revNodes) {
+ if ($isTextNode(node)) {
+ return node;
+ }
+
+ if ($isElementNode(node)) {
+ const children = [...node.getChildren()].reverse();
+ const textNode = getLastTextNodeInNodes(children);
+ if (textNode !== null) {
+ return textNode;
+ }
+ }
+ }
+
+ return null;
+}
+
+export function $selectNodes(nodes: LexicalNode[]) {
+ if (nodes.length === 0) {
+ return;
+ }
+
+ const selection = $createRangeSelection();
+ const firstText = getFirstTextNodeInNodes(nodes);
+ const lastText = getLastTextNodeInNodes(nodes);
+ if (firstText && lastText) {
+ selection.setTextNodeRange(firstText, 0, lastText, lastText.getTextContentSize() || 0)
+ $setSelection(selection);
+ }
+}
+
export function $toggleSelection(editor: LexicalEditor) {
const lastSelection = getLastSelection(editor);