From 662110c269218807379546cc19c2292f5e3765de Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Sep 2024 15:50:42 +0100 Subject: [PATCH] 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. --- .../js/wysiwyg/nodes/custom-list-item.ts | 25 ++++ resources/js/wysiwyg/nodes/custom-list.ts | 38 +++++- .../js/wysiwyg/services/keyboard-handling.ts | 21 ++- resources/js/wysiwyg/todo.md | 5 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 24 +--- resources/js/wysiwyg/utils/lists.ts | 123 ++++++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 53 +++++++- 7 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 resources/js/wysiwyg/utils/lists.ts diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts index 2b4d74146..659a55a15 100644 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -3,6 +3,7 @@ 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, @@ -38,6 +39,10 @@ export class CustomListItemNode extends ListItemNode { element.value = this.__value; + if ($hasNestedListWithoutLabel(this)) { + element.style.listStyle = 'none'; + } + 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( node: LexicalNode | null | undefined, ): node is CustomListItemNode { return node instanceof CustomListItemNode; +} + +export function $createCustomListItemNode(): CustomListItemNode { + return new CustomListItemNode(); } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts index 953bcb8cd..a6c473999 100644 --- a/resources/js/wysiwyg/nodes/custom-list.ts +++ b/resources/js/wysiwyg/nodes/custom-list.ts @@ -5,7 +5,8 @@ import { 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<{ @@ -30,7 +31,7 @@ export class CustomListNode extends ListNode { } 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; } @@ -67,6 +68,11 @@ export class CustomListNode extends ListNode { if (element.id && baseResult?.node) { (baseResult.node as CustomListNode).setId(element.id); } + + if (baseResult) { + baseResult.after = $normalizeChildren; + } + 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): Array { + const normalizedListItems: Array = []; + + 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 { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 65a8e4254..791fb0bed 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -1,10 +1,11 @@ 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"; @@ -13,6 +14,8 @@ import {$isMediaNode} from "../nodes/media"; 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) { @@ -55,6 +58,17 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve 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); @@ -70,9 +84,14 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { 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 diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 34367a36b..2662350af 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,8 +6,8 @@ ## Main Todo -- Align list nesting with old editor - Mac: Shortcut support via command. +- RTL/LTR support ## Secondary Todo @@ -18,4 +18,5 @@ ## 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 diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index 0857fb70a..87630eb27 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -13,12 +13,14 @@ import indentIncreaseIcon from "@icons/editor/indent-increase.svg"; 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 { @@ -40,28 +42,12 @@ export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 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() { @@ -74,7 +60,7 @@ export const indentDecrease: EditorButtonDefinition = { icon: indentDecreaseIcon, action(context: EditorUiContext) { context.editor.update(() => { - setInsetForSelection(context.editor, -40); + $setInsetForSelection(context.editor, -40); }); }, isActive() { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts new file mode 100644 index 000000000..edde994e5 --- /dev/null +++ b/resources/js/wysiwyg/utils/lists.ts @@ -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 = {}; + + 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 diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 4aa21045f..2110ea4be 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -10,7 +10,7 @@ import { ElementFormatType, ElementNode, LexicalEditor, LexicalNode, - TextFormatType + TextFormatType, TextNode } from "lexical"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; @@ -106,6 +106,57 @@ export function $selectSingleNode(node: LexicalNode) { $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);