From 5d429ea9bb5d48b4a4b3bab4b09fe21dea00b752 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 May 2026 17:07:50 +0100 Subject: [PATCH] Lexical: Added better support for block content in list items Improved the ability to set/use block formats in lists. Setting a format, will now attempt to create that, if just plain text to start with, when in a list. Added handling so that blocks split into new list elements instead of new blocks within the list element. Also fixed some issues with cyclic import references, and updated editor event types to always include their type for easier debug. --- resources/js/wysiwyg/index.ts | 3 ++ .../wysiwyg/lexical/core/LexicalCommands.ts | 2 +- .../wysiwyg/lexical/core/LexicalSelection.ts | 7 ++- .../lexical/core/nodes/LexicalElementNode.ts | 4 +- .../lexical/list/LexicalListItemNode.ts | 4 +- .../wysiwyg/lexical/list/LexicalListNode.ts | 2 +- .../js/wysiwyg/lexical/list/formatList.ts | 8 ++-- resources/js/wysiwyg/lexical/list/index.ts | 38 +++++++++++++++- resources/js/wysiwyg/lexical/list/utils.ts | 6 ++- resources/js/wysiwyg/utils/formats.ts | 45 +++++++++++-------- resources/js/wysiwyg/utils/selection.ts | 25 +++++++++++ 11 files changed, 112 insertions(+), 32 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index dc0ea211f..59fb2acde 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -29,6 +29,7 @@ import {registerSelectionHandling} from "./services/selection-handling"; import {EditorApi} from "./api/api"; import {registerMentions} from "./services/mentions"; import {MentionDecorator} from "./ui/decorators/MentionDecorator"; +import {registerLists} from "@lexical/list"; const theme = { text: { @@ -58,6 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), + registerLists(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context, true), registerKeyboardHandling(context), @@ -122,6 +124,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s const editorTeardown = mergeRegister( registerRichText(editor), + registerLists(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context, false), registerAutoLinks(editor), diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index 1b378b4a0..36badba97 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -16,7 +16,7 @@ import type { export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent; export function createCommand(type?: string): LexicalCommand { - return __DEV__ ? {type} : {}; + return {type}; } export const SELECTION_CHANGE_COMMAND: LexicalCommand = createCommand( diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index 36e2db547..2904867f0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -63,7 +63,6 @@ import { toggleTextFormatType, } from './LexicalUtils'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; -import {$selectSingleNode} from "../../utils/selection"; export type TextPointType = { _selection: BaseSelection; @@ -2224,6 +2223,12 @@ export function $createNodeSelection(): NodeSelection { return new NodeSelection(new Set()); } +function $selectSingleNode(node: LexicalNode): void { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} + export function $internalCreateSelection( editor: LexicalEditor, ): null | BaseSelection { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 0329aed6f..0304fe4ad 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -16,11 +16,9 @@ import type {KlassConstructor, Spread} from 'lexical'; import invariant from 'lexical/shared/invariant'; -import {$isTextNode, TextNode} from '../index'; +import {$isTextNode, TextNode} from './LexicalTextNode'; import { DOUBLE_LINE_BREAK, - - } from '../LexicalConstants'; import {LexicalNode} from '../LexicalNode'; import { diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 239c49a8c..a7b04d476 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -6,7 +6,7 @@ * */ -import type {ListNode, ListType} from './'; +import type {ListNode, ListType} from './LexicalListNode'; import type { BaseSelection, DOMConversionMap, @@ -32,7 +32,7 @@ import { } from 'lexical'; import invariant from 'lexical/shared/invariant'; -import {$createListNode, $isListNode} from './'; +import {$createListNode, $isListNode} from './LexicalListNode'; import {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; import {el} from "../../utils/dom"; diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index b5c83addd..0311e8b31 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -30,7 +30,7 @@ import { import invariant from 'lexical/shared/invariant'; import normalizeClassNames from 'lexical/shared/normalizeClassNames'; -import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; +import {$createListItemNode, $isListItemNode, ListItemNode} from './LexicalListItemNode'; import { mergeNextSiblingListIfSameType, updateChildrenListItemValue, diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts index aa0d5d611..5c7b81b7a 100644 --- a/resources/js/wysiwyg/lexical/list/formatList.ts +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -25,12 +25,14 @@ import invariant from 'lexical/shared/invariant'; import { $createListItemNode, - $createListNode, $isListItemNode, - $isListNode, ListItemNode, +} from './LexicalListItemNode'; +import { + $createListNode, + $isListNode, ListNode, -} from './'; +} from './LexicalListNode'; import {ListType} from './LexicalListNode'; import { $getAllListItems, diff --git a/resources/js/wysiwyg/lexical/list/index.ts b/resources/js/wysiwyg/lexical/list/index.ts index 157fe79de..00e33c115 100644 --- a/resources/js/wysiwyg/lexical/list/index.ts +++ b/resources/js/wysiwyg/lexical/list/index.ts @@ -8,7 +8,10 @@ import type {SerializedListItemNode} from './LexicalListItemNode'; import type {ListType, SerializedListNode} from './LexicalListNode'; -import type {LexicalCommand} from 'lexical'; +import { + $getSelection, + $isRangeSelection, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, LexicalCommand, LexicalEditor +} from 'lexical'; import {createCommand} from 'lexical'; @@ -20,6 +23,8 @@ import { } from './LexicalListItemNode'; import {$createListNode, $isListNode, ListNode} from './LexicalListNode'; import {$getListDepth} from './utils'; +import {mergeRegister} from "@lexical/utils"; +import {$getAncestor, INTERNAL_$isBlock} from "lexical/LexicalUtils"; export { $createListItemNode, @@ -48,3 +53,34 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand = createCommand( export const REMOVE_LIST_COMMAND: LexicalCommand = createCommand( 'REMOVE_LIST_COMMAND', ); + +export function registerLists(editor: LexicalEditor): () => void { + return mergeRegister( + + // Override the default insert paragraph command when within a list item + // so that new blocks are inserted as their own list items. + editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const block = $getAncestor(anchorNode, INTERNAL_$isBlock)!; + const blockParent = block.getParent(); + if ($isListItemNode(blockParent)) { + const newBlock = selection.insertParagraph(); + if (newBlock) { + const newListItem = $createListItemNode(); + const newBlockSiblings = newBlock.getNextSiblings(); + newListItem.append(newBlock, ...newBlockSiblings); + blockParent.insertAfter(newListItem, false); + newListItem.selectStart(); + } + return true; + } + + return false; + }, COMMAND_PRIORITY_NORMAL), + ); +} diff --git a/resources/js/wysiwyg/lexical/list/utils.ts b/resources/js/wysiwyg/lexical/list/utils.ts index c451a4508..984e77016 100644 --- a/resources/js/wysiwyg/lexical/list/utils.ts +++ b/resources/js/wysiwyg/lexical/list/utils.ts @@ -14,10 +14,12 @@ import invariant from 'lexical/shared/invariant'; import { $createListItemNode, $isListItemNode, - $isListNode, ListItemNode, +} from './LexicalListItemNode'; +import { + $isListNode, ListNode, -} from './'; +} from './LexicalListNode'; /** * Checks the depth of listNode from the root node. diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index a5f06f147..fbc11af49 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -3,7 +3,7 @@ import { $createTextNode, $getSelection, $insertNodes, - $isParagraphNode, + $isParagraphNode, $isRangeSelection, LexicalEditor, LexicalNode } from "lexical"; @@ -16,10 +16,12 @@ import { } from "./selection"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; -import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isListItemNode, $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"; +import {$setBlocksType} from "@lexical/selection"; + const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { return $isHeadingNode(node) && node.getTag() === tag; @@ -62,28 +64,35 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { } export function formatCodeBlock(editor: LexicalEditor) { - editor.getEditorState().read(() => { + editor.update(() => { const selection = $getSelection(); const lastSelection = getLastSelection(editor); const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); if (codeBlock === null) { - editor.update(() => { - const codeBlock = $createCodeBlockNode(); - codeBlock.setCode(selection?.getTextContent() || ''); + const codeBlock = $createCodeBlockNode(); - const selectionNodes = $getBlockElementNodesInSelection(selection); - const firstSelectionNode = selectionNodes[0]; - const extraNodes = selectionNodes.slice(1); - if (firstSelectionNode) { - firstSelectionNode.replace(codeBlock); - extraNodes.forEach(n => n.remove()); - } else { - $insertNewBlockNodeAtSelection(codeBlock, true); - } + const codeLines = []; + const selectionNodes = $getBlockElementNodesInSelection(selection); + for (const node of selectionNodes) { + codeLines.push(node.getTextContent()); + } + codeBlock.setCode(codeLines.join('\n')); - $openCodeEditorForNode(editor, codeBlock); - $selectSingleNode(codeBlock); - }); + const firstSelectionNode = selectionNodes[0]; + const extraNodes = selectionNodes.slice(1); + if ($isListItemNode(firstSelectionNode)) { + firstSelectionNode.getChildren().forEach(c => c.remove()); + firstSelectionNode.append(codeBlock); + extraNodes.forEach(n => n.remove()); + } else if (firstSelectionNode) { + firstSelectionNode.replace(codeBlock); + extraNodes.forEach(n => n.remove()); + } else { + $insertNewBlockNodeAtSelection(codeBlock, true); + } + + $openCodeEditorForNode(editor, codeBlock); + $selectSingleNode(codeBlock); } else { $openCodeEditorForNode(editor, codeBlock); } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28050571e..e6815e250 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -17,6 +17,7 @@ import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {$isListItemNode} from "@lexical/list"; const lastSelectionByEditor = new WeakMap; @@ -76,9 +77,33 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo return false; } +function createNewBlockIfSelectionIsSingleListItemText(selection: BaseSelection): void { + const startEnd = selection.getStartEndPoints(); + if (!startEnd) { + return; + } + + const startBlock = $getNearestNodeBlockParent(startEnd[0].getNode()); + const endBlock = $getNearestNodeBlockParent(startEnd[1].getNode()); + const isSingleListItemTextSelection = $isListItemNode(startBlock) && startBlock.getKey() === endBlock?.getKey(); + + if (isSingleListItemTextSelection) { + const wrapper = $createParagraphNode(); + const startNode = startEnd[0].getNode(); + startNode.insertBefore(wrapper); + wrapper.append(...selection.getNodes()); + } +} + export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + + const inListItem = $isListItemNode(blockElement); + if (inListItem && selection) { + createNewBlockIfSelectionIsSingleListItemText(selection); + } + if (selection && matcher(blockElement)) { $setBlocksType(selection, $createParagraphNode); } else {