[Breaking Changes][lexical][lexical-list][lexical-playground] Bug Fix: deleteCharacter through ListNode->ListItemNode (#7248)

This commit is contained in:
Bob Ippolito
2025-02-25 13:08:55 -08:00
committed by GitHub
parent 8e8e45a051
commit a79ed52fd0
4 changed files with 131 additions and 27 deletions

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {
deleteBackward,
moveToLineBeginning,
} from '../keyboardShortcuts/index.mjs';
import {
assertHTML,
focusEditor,
html,
initialize,
test,
} from '../utils/index.mjs';
test.describe('Regression tests for #7246', () => {
test.beforeEach(({isPlainText, isCollab, page}) =>
initialize({isCollab, isPlainText, page}),
);
test(`deleteCharacter merges children from block adjacent to ListNode`, async ({
page,
isCollab,
isPlainText,
}) => {
test.skip(isCollab || isPlainText);
await focusEditor(page);
await page.keyboard.type('* list');
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.keyboard.type('paragraph');
const beforeHtml = html`
<ul>
<li dir="ltr" value="1"><span data-lexical-text="true">list</span></li>
</ul>
<p dir="ltr"><span data-lexical-text="true">paragraph</span></p>
`;
await assertHTML(page, beforeHtml, beforeHtml, {
ignoreClasses: true,
ignoreInlineStyles: true,
});
await moveToLineBeginning(page);
await deleteBackward(page);
const afterHtml = html`
<ul>
<li dir="ltr" value="1">
<span data-lexical-text="true">listparagraph</span>
</li>
</ul>
`;
await assertHTML(page, afterHtml, afterHtml, {
ignoreClasses: true,
ignoreInlineStyles: true,
});
});
});

View File

@ -7,6 +7,9 @@
*/
import {
$getSiblingCaret,
$isElementNode,
$rewindSiblingCaret,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
@ -16,6 +19,7 @@ import {
LexicalEditor,
LexicalNode,
NodeKey,
RangeSelection,
SerializedElementNode,
Spread,
} from 'lexical';
@ -57,6 +61,30 @@ export class CollapsibleContainerNode extends ElementNode {
return new CollapsibleContainerNode(node.__open, node.__key);
}
isShadowRoot(): boolean {
return true;
}
collapseAtStart(selection: RangeSelection): boolean {
// Unwrap the CollapsibleContainerNode by replacing it with the children
// of its children (CollapsibleTitleNode, CollapsibleContentNode)
const nodesToInsert: LexicalNode[] = [];
for (const child of this.getChildren()) {
if ($isElementNode(child)) {
nodesToInsert.push(...child.getChildren());
}
}
const caret = $rewindSiblingCaret($getSiblingCaret(this, 'previous'));
caret.splice(1, nodesToInsert);
// Merge the first child of the CollapsibleTitleNode with the
// previous sibling of the CollapsibleContainerNode
const [firstChild] = nodesToInsert;
if (firstChild) {
firstChild.selectStart().deleteCharacter(true);
}
return true;
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
// details is not well supported in Chrome #5582
let dom: HTMLElement;

View File

@ -83,11 +83,6 @@ export class CollapsibleTitleNode extends ElementNode {
return $createCollapsibleTitleNode().updateFromJSON(serializedNode);
}
collapseAtStart(_selection: RangeSelection): boolean {
this.getParentOrThrow().insertBefore(this);
return true;
}
static transform(): (node: LexicalNode) => void {
return (node: LexicalNode) => {
invariant(

View File

@ -41,6 +41,7 @@ import {
$setSelection,
$updateRangeSelectionFromCaretRange,
CaretRange,
ChildCaret,
NodeCaret,
PointCaret,
TextNode,
@ -67,6 +68,7 @@ import {
$getNodeFromDOM,
$getRoot,
$hasAncestor,
$isRootOrShadowRoot,
$isTokenOrSegmented,
$setCompositionKey,
doesContainSurrogatePair,
@ -1721,18 +1723,27 @@ export class RangeSelection implements BaseSelection {
.getTextSlices()
.every((slice) => slice === null || slice.distance === 0)
) {
// debugger;
// There's no text in the direction of the deletion so we can explore our options
let state:
| {type: 'initial'}
| {
type: 'merge-next-block';
block: ElementNode;
}
| {
type: 'merge-block';
caret: ChildCaret<ElementNode, typeof direction>;
block: ElementNode;
} = {type: 'initial'};
for (const caret of initialRange.iterNodeCarets('shadowRoot')) {
if ($isChildCaret(caret)) {
if (caret.origin.isInline()) {
// fall through when descending an inline
} else if (caret.origin.isShadowRoot()) {
if (state.type === 'merge-block') {
break;
}
// Don't merge with a shadow root block
if (
$isElementNode(initialRange.anchor.origin) &&
@ -1747,20 +1758,15 @@ export class RangeSelection implements BaseSelection {
initialRange.anchor.origin.remove();
}
return;
} else if (state.type === 'merge-next-block') {
$updateRangeSelectionFromCaretRange(
this,
$getCaretRange(
!caret.origin.isEmpty() && state.block.isEmpty()
? $rewindSiblingCaret(
$getSiblingCaret(state.block, caret.direction),
)
: initialRange.anchor,
caret,
),
);
return this.removeText();
} else if (
state.type === 'merge-next-block' ||
state.type === 'merge-block'
) {
// Keep descending ChildCaret to find which block to merge with
state = {block: state.block, caret, type: 'merge-block'};
}
} else if (state.type === 'merge-block') {
break;
} else if ($isSiblingCaret(caret)) {
if ($isElementNode(caret.origin)) {
if (!caret.origin.isInline()) {
@ -1797,6 +1803,19 @@ export class RangeSelection implements BaseSelection {
break;
}
}
if (state.type === 'merge-block') {
const {caret, block} = state;
$updateRangeSelectionFromCaretRange(
this,
$getCaretRange(
!caret.origin.isEmpty() && block.isEmpty()
? $rewindSiblingCaret($getSiblingCaret(block, caret.direction))
: initialRange.anchor,
caret,
),
);
return this.removeText();
}
}
// Handle the deletion around decorators.
@ -1875,19 +1894,20 @@ export class RangeSelection implements BaseSelection {
this.modify('extend', isBackward, 'lineboundary');
// If the selection starts at the beginning of a text node (offset 0),
// extend the selection by one character in the specified direction.
// This ensures that the parent element is deleted along with its content.
// Otherwise, only the text content will be deleted, leaving an empty parent node.
if (this.isCollapsed() && this.anchor.offset === 0) {
this.modify('extend', isBackward, 'character');
}
const useDeleteCharacter = this.isCollapsed() && this.anchor.offset === 0;
// Adjusts selection to include an extra character added for element anchors to remove it
if (anchorIsElement) {
const startPoint = isBackward ? this.anchor : this.focus;
startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
}
// If the selection starts at the beginning of a text node (offset 0),
// use the deleteCharacter operation to handle all of the logic associated
// with navigating through the parent element
if (useDeleteCharacter) {
// Remove the inserted space, if added above
this.removeText();
return this.deleteCharacter(isBackward);
}
}
this.removeText();
}
@ -1972,7 +1992,7 @@ function $collapseAtStart(
if (node.collapseAtStart(selection)) {
return true;
}
if (!node.isInline()) {
if ($isRootOrShadowRoot(node)) {
break;
}
}