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 {EditorApi} from "./api/api";
import {registerMentions} from "./services/mentions"; import {registerMentions} from "./services/mentions";
import {MentionDecorator} from "./ui/decorators/MentionDecorator"; import {MentionDecorator} from "./ui/decorators/MentionDecorator";
import {registerLists} from "@lexical/list";
const theme = { const theme = {
text: { text: {
@@ -58,6 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
mergeRegister( mergeRegister(
registerRichText(editor), registerRichText(editor),
registerLists(editor),
registerHistory(editor, createEmptyHistoryState(), 300), registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context, true), registerShortcuts(context, true),
registerKeyboardHandling(context), registerKeyboardHandling(context),
@@ -122,6 +124,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
const editorTeardown = mergeRegister( const editorTeardown = mergeRegister(
registerRichText(editor), registerRichText(editor),
registerLists(editor),
registerHistory(editor, createEmptyHistoryState(), 300), registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context, false), registerShortcuts(context, false),
registerAutoLinks(editor), registerAutoLinks(editor),

View File

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

View File

@@ -63,7 +63,6 @@ import {
toggleTextFormatType, toggleTextFormatType,
} from './LexicalUtils'; } from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
import {$selectSingleNode} from "../../utils/selection";
export type TextPointType = { export type TextPointType = {
_selection: BaseSelection; _selection: BaseSelection;
@@ -2224,6 +2223,12 @@ export function $createNodeSelection(): NodeSelection {
return new NodeSelection(new Set()); return new NodeSelection(new Set());
} }
function $selectSingleNode(node: LexicalNode): void {
const nodeSelection = $createNodeSelection();
nodeSelection.add(node.getKey());
$setSelection(nodeSelection);
}
export function $internalCreateSelection( export function $internalCreateSelection(
editor: LexicalEditor, editor: LexicalEditor,
): null | BaseSelection { ): null | BaseSelection {

View File

@@ -16,11 +16,9 @@ import type {KlassConstructor, Spread} from 'lexical';
import invariant from 'lexical/shared/invariant'; import invariant from 'lexical/shared/invariant';
import {$isTextNode, TextNode} from '../index'; import {$isTextNode, TextNode} from './LexicalTextNode';
import { import {
DOUBLE_LINE_BREAK, DOUBLE_LINE_BREAK,
} from '../LexicalConstants'; } from '../LexicalConstants';
import {LexicalNode} from '../LexicalNode'; import {LexicalNode} from '../LexicalNode';
import { import {

View File

@@ -6,7 +6,7 @@
* *
*/ */
import type {ListNode, ListType} from './'; import type {ListNode, ListType} from './LexicalListNode';
import type { import type {
BaseSelection, BaseSelection,
DOMConversionMap, DOMConversionMap,
@@ -32,7 +32,7 @@ import {
} from 'lexical'; } from 'lexical';
import invariant from 'lexical/shared/invariant'; import invariant from 'lexical/shared/invariant';
import {$createListNode, $isListNode} from './'; import {$createListNode, $isListNode} from './LexicalListNode';
import {mergeLists} from './formatList'; import {mergeLists} from './formatList';
import {isNestedListNode} from './utils'; import {isNestedListNode} from './utils';
import {el} from "../../utils/dom"; import {el} from "../../utils/dom";

View File

@@ -30,7 +30,7 @@ import {
import invariant from 'lexical/shared/invariant'; import invariant from 'lexical/shared/invariant';
import normalizeClassNames from 'lexical/shared/normalizeClassNames'; import normalizeClassNames from 'lexical/shared/normalizeClassNames';
import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; import {$createListItemNode, $isListItemNode, ListItemNode} from './LexicalListItemNode';
import { import {
mergeNextSiblingListIfSameType, mergeNextSiblingListIfSameType,
updateChildrenListItemValue, updateChildrenListItemValue,

View File

@@ -25,12 +25,14 @@ import invariant from 'lexical/shared/invariant';
import { import {
$createListItemNode, $createListItemNode,
$createListNode,
$isListItemNode, $isListItemNode,
$isListNode,
ListItemNode, ListItemNode,
} from './LexicalListItemNode';
import {
$createListNode,
$isListNode,
ListNode, ListNode,
} from './'; } from './LexicalListNode';
import {ListType} from './LexicalListNode'; import {ListType} from './LexicalListNode';
import { import {
$getAllListItems, $getAllListItems,

View File

@@ -8,7 +8,10 @@
import type {SerializedListItemNode} from './LexicalListItemNode'; import type {SerializedListItemNode} from './LexicalListItemNode';
import type {ListType, SerializedListNode} from './LexicalListNode'; 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'; import {createCommand} from 'lexical';
@@ -20,6 +23,8 @@ import {
} from './LexicalListItemNode'; } from './LexicalListItemNode';
import {$createListNode, $isListNode, ListNode} from './LexicalListNode'; import {$createListNode, $isListNode, ListNode} from './LexicalListNode';
import {$getListDepth} from './utils'; import {$getListDepth} from './utils';
import {mergeRegister} from "@lexical/utils";
import {$getAncestor, INTERNAL_$isBlock} from "lexical/LexicalUtils";
export { export {
$createListItemNode, $createListItemNode,
@@ -48,3 +53,34 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand( export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
'REMOVE_LIST_COMMAND', '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 { import {
$createListItemNode, $createListItemNode,
$isListItemNode, $isListItemNode,
$isListNode,
ListItemNode, ListItemNode,
} from './LexicalListItemNode';
import {
$isListNode,
ListNode, ListNode,
} from './'; } from './LexicalListNode';
/** /**
* Checks the depth of listNode from the root node. * Checks the depth of listNode from the root node.

View File

@@ -3,7 +3,7 @@ import {
$createTextNode, $createTextNode,
$getSelection, $getSelection,
$insertNodes, $insertNodes,
$isParagraphNode, $isParagraphNode, $isRangeSelection,
LexicalEditor, LexicalEditor,
LexicalNode LexicalNode
} from "lexical"; } from "lexical";
@@ -16,10 +16,12 @@ import {
} from "./selection"; } from "./selection";
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode";
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; 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 {$createLinkNode, $isLinkNode} from "@lexical/link";
import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {$setBlocksType} from "@lexical/selection";
const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
return $isHeadingNode(node) && node.getTag() === tag; return $isHeadingNode(node) && node.getTag() === tag;
@@ -62,28 +64,35 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) {
} }
export function formatCodeBlock(editor: LexicalEditor) { export function formatCodeBlock(editor: LexicalEditor) {
editor.getEditorState().read(() => { editor.update(() => {
const selection = $getSelection(); const selection = $getSelection();
const lastSelection = getLastSelection(editor); const lastSelection = getLastSelection(editor);
const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null);
if (codeBlock === null) { if (codeBlock === null) {
editor.update(() => { const codeBlock = $createCodeBlockNode();
const codeBlock = $createCodeBlockNode();
codeBlock.setCode(selection?.getTextContent() || '');
const selectionNodes = $getBlockElementNodesInSelection(selection); const codeLines = [];
const firstSelectionNode = selectionNodes[0]; const selectionNodes = $getBlockElementNodesInSelection(selection);
const extraNodes = selectionNodes.slice(1); for (const node of selectionNodes) {
if (firstSelectionNode) { codeLines.push(node.getTextContent());
firstSelectionNode.replace(codeBlock); }
extraNodes.forEach(n => n.remove()); codeBlock.setCode(codeLines.join('\n'));
} else {
$insertNewBlockNodeAtSelection(codeBlock, true);
}
$openCodeEditorForNode(editor, codeBlock); const firstSelectionNode = selectionNodes[0];
$selectSingleNode(codeBlock); 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 { } else {
$openCodeEditorForNode(editor, codeBlock); $openCodeEditorForNode(editor, codeBlock);
} }

View File

@@ -17,6 +17,7 @@ import {$setBlocksType} from "@lexical/selection";
import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes";
import {CommonBlockAlignment} from "lexical/nodes/common"; import {CommonBlockAlignment} from "lexical/nodes/common";
import {$isListItemNode} from "@lexical/list";
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>; const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
@@ -76,9 +77,33 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo
return false; 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) { export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) {
const selection = $getSelection(); const selection = $getSelection();
const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null;
const inListItem = $isListItemNode(blockElement);
if (inListItem && selection) {
createNewBlockIfSelectionIsSingleListItemText(selection);
}
if (selection && matcher(blockElement)) { if (selection && matcher(blockElement)) {
$setBlocksType(selection, $createParagraphNode); $setBlocksType(selection, $createParagraphNode);
} else { } else {