Repository navigation
[lexical-list] Bug Fix: Renumber a list when an item starts or stops wrapping a nested list #9662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
|
@@ -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`. */ | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: this also keeps values that an earlier |
||
| } else if (current !== value) { | ||
| child.setValue(value); | ||
| } | ||
| } | ||
| if (!$isListNode(child.getFirstChild())) { | ||
| value++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function $isListItemNode( | ||
| node: LexicalNode | null | undefined, | ||
| ): node is ListItemNode { | ||
|
|
||
There was a problem hiding this comment.
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.tsloses its doc comment and$wrapChangedgets two doc blocks. Moving both helpers above the/** Checks to see if the node is a ListItemNode.block keeps the docs where they were.