Lexical: Custom list nesting support

Added list nesting support to allow li > ul style nesting which lexical
didn't do by default.
Adds tab handling for inset/outset controls.
Will be a range of edge-case bugs to squash during testing.
This commit is contained in:
Dan Brown 2024-09-13 15:50:42 +01:00
parent 5083188ed8
commit 662110c269
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 263 additions and 26 deletions

View File

@ -3,6 +3,7 @@ import {EditorConfig} from "lexical/LexicalEditor";
import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
import {el} from "../utils/dom"; import {el} from "../utils/dom";
import {$isCustomListNode} from "./custom-list";
function updateListItemChecked( function updateListItemChecked(
dom: HTMLElement, dom: HTMLElement,
@ -38,6 +39,10 @@ export class CustomListItemNode extends ListItemNode {
element.value = this.__value; element.value = this.__value;
if ($hasNestedListWithoutLabel(this)) {
element.style.listStyle = 'none';
}
return element; return element;
} }
@ -86,8 +91,28 @@ export class CustomListItemNode extends ListItemNode {
} }
} }
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( export function $isCustomListItemNode(
node: LexicalNode | null | undefined, node: LexicalNode | null | undefined,
): node is CustomListItemNode { ): node is CustomListItemNode {
return node instanceof CustomListItemNode; return node instanceof CustomListItemNode;
}
export function $createCustomListItemNode(): CustomListItemNode {
return new CustomListItemNode();
} }

View File

@ -5,7 +5,8 @@ import {
Spread Spread
} from "lexical"; } from "lexical";
import {EditorConfig} from "lexical/LexicalEditor"; 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<{ export type SerializedCustomListNode = Spread<{
@ -30,7 +31,7 @@ export class CustomListNode extends ListNode {
} }
static clone(node: CustomListNode) { 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; newNode.__id = node.__id;
return newNode; return newNode;
} }
@ -67,6 +68,11 @@ export class CustomListNode extends ListNode {
if (element.id && baseResult?.node) { if (element.id && baseResult?.node) {
(baseResult.node as CustomListNode).setId(element.id); (baseResult.node as CustomListNode).setId(element.id);
} }
if (baseResult) {
baseResult.after = $normalizeChildren;
}
return baseResult; return baseResult;
}; };
@ -83,8 +89,34 @@ export class CustomListNode extends ListNode {
} }
} }
/*
* 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 { 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 { export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {

View File

@ -1,10 +1,11 @@
import {EditorUiContext} from "../ui/framework/core"; import {EditorUiContext} from "../ui/framework/core";
import { import {
$getSelection,
$isDecoratorNode, $isDecoratorNode,
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND, KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND, KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
LexicalEditor, LexicalEditor,
LexicalNode LexicalNode
} from "lexical"; } from "lexical";
@ -13,6 +14,8 @@ import {$isMediaNode} from "../nodes/media";
import {getLastSelection} from "../utils/selection"; import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent} from "../utils/nodes"; import {$getNearestNodeBlockParent} from "../utils/nodes";
import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
import {$isCustomListItemNode} from "../nodes/custom-list-item";
import {$setInsetForSelection} from "../utils/lists";
function isSingleSelectedNode(nodes: LexicalNode[]): boolean { function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
if (nodes.length === 1) { if (nodes.length === 1) {
@ -55,6 +58,17 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
return false; 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 { export function registerKeyboardHandling(context: EditorUiContext): () => void {
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => { const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
deleteSingleSelectedNode(context.editor); deleteSingleSelectedNode(context.editor);
@ -70,9 +84,14 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
return insertAfterSingleSelectedNode(context.editor, event); return insertAfterSingleSelectedNode(context.editor, event);
}, COMMAND_PRIORITY_LOW); }, COMMAND_PRIORITY_LOW);
const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
return handleInsetOnTab(context.editor, event);
}, COMMAND_PRIORITY_LOW);
return () => { return () => {
unregisterBackspace(); unregisterBackspace();
unregisterDelete(); unregisterDelete();
unregisterEnter(); unregisterEnter();
unregisterTab();
}; };
} }

View File

@ -6,8 +6,8 @@
## Main Todo ## Main Todo
- Align list nesting with old editor
- Mac: Shortcut support via command. - Mac: Shortcut support via command.
- RTL/LTR support
## Secondary Todo ## Secondary Todo
@ -18,4 +18,5 @@
## Bugs ## Bugs
// - Focus/click area reduced to content area, single line on initial access
- List selection can get lost on nesting/unnesting

View File

@ -13,12 +13,14 @@ import indentIncreaseIcon from "@icons/editor/indent-increase.svg";
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg"; import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
import { import {
$getBlockElementNodesInSelection, $getBlockElementNodesInSelection,
$selectionContainsNodeType, $selectionContainsNodeType, $selectNodes, $selectSingleNode,
$toggleSelection, $toggleSelection,
getLastSelection getLastSelection
} from "../../../utils/selection"; } from "../../../utils/selection";
import {toggleSelectionAsList} from "../../../utils/formats"; import {toggleSelectionAsList} from "../../../utils/formats";
import {nodeHasInset} from "../../../utils/nodes"; 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 { function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
@ -40,28 +42,12 @@ export const bulletList: EditorButtonDefinition = buildListButton('Bullet list',
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); 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 = { export const indentIncrease: EditorButtonDefinition = {
label: 'Increase indent', label: 'Increase indent',
icon: indentIncreaseIcon, icon: indentIncreaseIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => { context.editor.update(() => {
setInsetForSelection(context.editor, 40); $setInsetForSelection(context.editor, 40);
}); });
}, },
isActive() { isActive() {
@ -74,7 +60,7 @@ export const indentDecrease: EditorButtonDefinition = {
icon: indentDecreaseIcon, icon: indentDecreaseIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => { context.editor.update(() => {
setInsetForSelection(context.editor, -40); $setInsetForSelection(context.editor, -40);
}); });
}, },
isActive() { isActive() {

View File

@ -0,0 +1,123 @@
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);
}

View File

@ -10,7 +10,7 @@ import {
ElementFormatType, ElementFormatType,
ElementNode, LexicalEditor, ElementNode, LexicalEditor,
LexicalNode, LexicalNode,
TextFormatType TextFormatType, TextNode
} from "lexical"; } from "lexical";
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
@ -106,6 +106,57 @@ export function $selectSingleNode(node: LexicalNode) {
$setSelection(nodeSelection); $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) { export function $toggleSelection(editor: LexicalEditor) {
const lastSelection = getLastSelection(editor); const lastSelection = getLastSelection(editor);