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.
This commit is contained in:
Dan Brown
2026-05-11 17:07:50 +01:00
parent 2aba39b176
commit 5d429ea9bb
11 changed files with 112 additions and 32 deletions

View File

@@ -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),

View File

@@ -16,7 +16,7 @@ import type {
export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent;
export function createCommand<T>(type?: string): LexicalCommand<T> {
return __DEV__ ? {type} : {};
return {type};
}
export const SELECTION_CHANGE_COMMAND: LexicalCommand<void> = createCommand(

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<void> = createCommand(
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = 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),
);
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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<LexicalEditor, BaseSelection|null>;
@@ -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 {