Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/list-renumber-nested-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lexical/list': patch
---

[fix] Removing the last item of a nested list renumbers the items after its wrapper item, so a split there continues the numbering

Verify: in a numbered list "ff", "gg hh", indent "ff", then remove it: "gg hh" reads 2. (It read 1, and inserting paragraphs at the empty item started the list after them at 1.)
74 changes: 74 additions & 0 deletions packages/lexical-list/src/LexicalListItemNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ export class ListItemNode extends ElementNode {
if (parent.getListType() !== 'check' && node.getChecked() != null) {
node.setChecked(undefined);
}
// An item that only wraps a nested list does not count in the
// numbering (see updateChildrenListItemValue). It can start or stop
// wrapping one without its list being written (its nested list's
// last item removed, a list appended to it while empty), and the
// list's own transform, which renumbers, does not run then. Where
// the item has started or stopped wrapping a nested list since the
// last update and the item after it has another value than this
// item gives it, the list's items are renumbered once (see
// $renumberAfterWrapChange). Why: the items after it kept stale
// values, and a split there started the next list from them
// (#9661).
const prevMap = $getEditor().getEditorState()._nodeMap;
const wraps = $isListNode(node.getFirstChild());
if ($wrapChanged(node, prevMap)) {
const next = node.getNextSibling();
if (
$isListItemNode(next) &&
next.getValue() !== node.getValue() + (wraps ? 0 : 1)
) {
$renumberAfterWrapChange(parent, prevMap);
}
}
} else if (parent) {
const newParent = node.createParentElementNode();
invariant(
Expand Down Expand Up @@ -859,6 +881,58 @@ export function $createListItemNode(checked?: boolean): ListItemNode {
* @param node - The node to be checked.
* @returns true if the node is a ListItemNode, false otherwise.
*/
/** Whether `item` started or stopped wrapping a nested list since `prevMap`. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: these helpers sit between $isListItemNode's JSDoc (lines 879-883) and its declaration, so the exported guard's emitted .d.ts loses its doc comment and $wrapChanged gets two doc blocks. Moving both helpers above the /** Checks to see if the node is a ListItemNode. block keeps the docs where they were.

function $wrapChanged(
item: ListItemNode,
prevMap: Map<NodeKey, LexicalNode>,
): boolean {
const prev = prevMap.get(item.getKey());
return (
$isListItemNode(prev) &&
$isListNode(prev.__first === null ? null : prevMap.get(prev.__first)) !==
$isListNode(item.getFirstChild())
);
}

/**
* Renumbers the items of `list` after the first of them that started or
* stopped wrapping a nested list (see ListItemNode's transform), counting on
* from that item's value as updateChildrenListItemValue counts. The items
* before it and that item keep their values, and so does an item whose value
* changed in this update (an app set it), the items after it counting on from
* it. Why: an app that keeps a value (a transform that sets it) never
* conflicts with this. One pass leaves the list consistent, so the other
* changed items of the update find nothing to do: the cost is linear in the
* list, however many items changed.
*/
function $renumberAfterWrapChange(
list: ListNode,
prevMap: Map<NodeKey, LexicalNode>,
): void {
let started = false;
let value = list.getStart();
for (const child of list.getChildren()) {
if (!$isListItemNode(child)) {
continue;
}
const current = child.getValue();
if (!started) {
started = $wrapChanged(child, prevMap);
value = current;
} else {
const prev = prevMap.get(child.getKey());
if ($isListItemNode(prev) && prev.__value !== current) {
value = current;
Comment on lines +923 to +925

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this also keeps values that an earlier $renumberAfterWrapChange pass wrote in the same update, so a wrap change that a transform makes in a later round stops being corrected after the changed item. Repro: [w0⟨x⟩, a, w1⟨y⟩, b] with a ListItemNode transform that removes empty items inside nested lists; one update removes x and empties y. The result is [1, 2, 3, 3] instead of [1, 2, 3, 4]. Tracking the keys this function writes during the pending update, and not treating those as app-set, would close it.

} else if (current !== value) {
child.setValue(value);
}
}
if (!$isListNode(child.getFirstChild())) {
value++;
}
}
}

export function $isListItemNode(
node: LexicalNode | null | undefined,
): node is ListItemNode {
Expand Down
Loading
Loading