Skip to content
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

[Breaking Change][lexical][lexical-utils]: Bug Fix: Handle canBeEmpty in $splitNodes #7342

Merged
merged 10 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
144 changes: 143 additions & 1 deletion packages/lexical-playground/__tests__/e2e/HorizontalRule.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {
assertHTML,
assertSelection,
click,
copyToClipboard,
focusEditor,
html,
Expand All @@ -26,6 +27,11 @@ import {
withExclusiveClipboardAccess,
} from '../utils/index.mjs';

async function toggleBulletList(page) {
await click(page, '.block-controls');
await click(page, '.dropdown .icon.bullet-list');
}

test.describe('HorizontalRule', () => {
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
test(
Expand Down Expand Up @@ -209,7 +215,7 @@ test.describe('HorizontalRule', () => {
});
});

test('Will add a horizontal rule and split a TextNode across 2 paragraphs if the carat is in the middle of the TextNode, moving selection to the start of the new ParagraphNode.', async ({
test('Will add a horizontal rule and split a TextNode across 2 paragraphs if the caret is in the middle of the TextNode, moving selection to the start of the new ParagraphNode.', async ({
page,
isPlainText,
}) => {
Expand Down Expand Up @@ -277,6 +283,142 @@ test.describe('HorizontalRule', () => {
});
});

test('Will add a horizontal rule and split a TextNode across 2 ListItemNode if the caret is in the middle of the TextNode, moving selection to the start of the new ParagraphNode', async ({
page,
isPlainText,
}) => {
test.skip(isPlainText);
await focusEditor(page);
await toggleBulletList(page);

await page.keyboard.type('Test');

await assertSelection(page, {
anchorOffset: 4,
anchorPath: [0, 0, 0, 0],
focusOffset: 4,
focusPath: [0, 0, 0, 0],
});

await assertHTML(
page,
html`
<ul class="PlaygroundEditorTheme__ul">
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<span data-lexical-text="true">Test</span>
</li>
</ul>
`,
);

await moveLeft(page, 2);

await assertSelection(page, {
anchorOffset: 2,
anchorPath: [0, 0, 0, 0],
focusOffset: 2,
focusPath: [0, 0, 0, 0],
});

await selectFromInsertDropdown(page, '.horizontal-rule');

await waitForSelector(page, 'hr');

await assertHTML(
page,
html`
<ul class="PlaygroundEditorTheme__ul">
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<span data-lexical-text="true">Te</span>
</li>
</ul>
<hr
class="PlaygroundEditorTheme__hr"
contenteditable="false"
data-lexical-decorator="true" />
<ul class="PlaygroundEditorTheme__ul">
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<span data-lexical-text="true">st</span>
</li>
</ul>
`,
);

await assertSelection(page, {
anchorOffset: 0,
anchorPath: [2, 0, 0, 0],
focusOffset: 0,
focusPath: [2, 0, 0, 0],
});
});

test('Will add a horizontal rule and split a TextNode across 2 ListItemNode if the caret is in an empty ListItemNode, moving selection to the start of the new ListItemNode (#6849)', async ({
page,
isPlainText,
}) => {
test.skip(isPlainText);
await focusEditor(page);
await toggleBulletList(page);

await assertSelection(page, {
anchorOffset: 0,
anchorPath: [0, 0],
focusOffset: 0,
focusPath: [0, 0],
});

await assertHTML(
page,
html`
<ul class="PlaygroundEditorTheme__ul">
<li class="PlaygroundEditorTheme__listItem" value="1">
<br />
</li>
</ul>
`,
);

await selectFromInsertDropdown(page, '.horizontal-rule');

await waitForSelector(page, 'hr');

await assertHTML(
page,
html`
<ul class="PlaygroundEditorTheme__ul">
<li class="PlaygroundEditorTheme__listItem" value="1">
<br />
</li>
</ul>
<hr
class="PlaygroundEditorTheme__hr"
contenteditable="false"
data-lexical-decorator="true" />
<ul class="PlaygroundEditorTheme__ul">
<li class="PlaygroundEditorTheme__listItem" value="1">
<br />
</li>
</ul>
`,
);

await assertSelection(page, {
anchorOffset: 0,
anchorPath: [2, 0],
focusOffset: 0,
focusPath: [2, 0],
});
});

test('Can copy and paste a horizontal rule', async ({page, isPlainText}) => {
test.skip(isPlainText);

Expand Down
4 changes: 4 additions & 0 deletions packages/lexical-utils/flow/LexicalUtils.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
CaretDirection,
SiblingCaret,
RootMode,
SplitAtPointCaretNextOptions,
PointCaret,
} from 'lexical';
declare export function addClassNamesToElement(
element: HTMLElement,
Expand Down Expand Up @@ -102,7 +104,9 @@ declare export function $restoreEditorState(
editorState: EditorState,
): void;


declare export function $insertNodeToNearestRoot<T: LexicalNode>(node: T): T;
declare export function $insertNodeToNearestRootAtCaret<T: LexicalNode>(node: T, caret: PointCaret<CaretDirection>, options?: SplitAtPointCaretNextOptions): T;

declare export function $wrapNodeInElement(
node: LexicalNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
'<test-decorator></test-decorator>' +
'<p><br></p>',
initialHtml: '<p>Hello world</p>',
selectionOffset: 12, // Selection on text node after "Hello" world
selectionOffset: 'Hello world'.length, // Selection on text node after "Hello" world
selectionPath: [0, 0],
},
{
Expand All @@ -96,7 +96,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
'<test-decorator></test-decorator>' +
'<p><span style="white-space: pre-wrap;">Hello world</span></p>',
initialHtml: '<p>Hello world</p>',
selectionOffset: 0, // Selection on text node after "Hello" world
selectionOffset: 0, // Selection on text node before "Hello" world
selectionPath: [0, 0],
},
{
Expand Down Expand Up @@ -175,7 +175,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
);
$setSelection(selection);

$insertNodeToNearestRoot($createTestDecoratorNode());
$insertNodeToNearestRoot($createTestDecoratorNode().setIsInline(false));

// Cleaning up list value attributes as it's not really needed in this test
// and it clutters expected output
Expand Down
101 changes: 64 additions & 37 deletions packages/lexical-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
*/

import {
$caretFromPoint,
$cloneWithProperties,
$createParagraphNode,
$getAdjacentChildCaret,
$getCaretInDirection,
$getChildCaret,
$getChildCaretAtIndex,
$getChildCaretOrSelf,
$getCollapsedCaretRange,
$getPreviousSelection,
$getRoot,
$getSelection,
Expand All @@ -21,13 +23,14 @@ import {
$isChildCaret,
$isElementNode,
$isRangeSelection,
$isRootOrShadowRoot,
$isSiblingCaret,
$isTextNode,
$isTextPointCaret,
$normalizeCaret,
$rewindSiblingCaret,
$setSelection,
$setSelectionFromCaretRange,
$setState,
$splitNode,
$splitAtPointCaretNext,
type CaretDirection,
type EditorState,
ElementNode,
Expand All @@ -37,8 +40,10 @@ import {
makeStepwiseIterator,
type NodeCaret,
type NodeKey,
PointCaret,
RootMode,
type SiblingCaret,
SplitAtPointCaretNextOptions,
StateConfig,
ValueOrUpdater,
} from 'lexical';
Expand Down Expand Up @@ -550,53 +555,75 @@ export function $restoreEditorState(
* If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}),
* the node will be appended there, otherwise, it will be inserted before the insertion area.
* If there is no selection where the node is to be inserted, it will be appended after any current nodes
* within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected.
* within the tree, as a child of the root node. A paragraph will then be added after the inserted node and selected.
* @param node - The node to be inserted
* @returns The node after its insertion
*/
export function $insertNodeToNearestRoot<T extends LexicalNode>(node: T): T {
const selection = $getSelection() || $getPreviousSelection();

let initialCaret: undefined | PointCaret<'next'>;
if ($isRangeSelection(selection)) {
const {focus} = selection;
const focusNode = focus.getNode();
const focusOffset = focus.offset;

if ($isRootOrShadowRoot(focusNode)) {
$getChildCaretAtIndex(focusNode, focusOffset, 'next').insert(node);
node.selectNext();
} else {
let splitNode: ElementNode;
let splitOffset: number;
if ($isTextNode(focusNode)) {
splitNode = focusNode.getParentOrThrow();
splitOffset = focusNode.getIndexWithinParent();
if (focusOffset > 0) {
splitOffset += 1;
focusNode.splitText(focusOffset);
}
} else {
splitNode = focusNode;
splitOffset = focusOffset;
}
const [, rightTree] = $splitNode(splitNode, splitOffset);
rightTree.insertBefore(node);
rightTree.selectStart();
}
initialCaret = $caretFromPoint(selection.focus, 'next');
} else {
if (selection != null) {
const nodes = selection.getNodes();
nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node);
} else {
$getRoot().append(node);
const lastNode = nodes[nodes.length - 1];
if (lastNode) {
initialCaret = $getSiblingCaret(lastNode, 'next');
}
}
const paragraphNode = $createParagraphNode();
node.insertAfter(paragraphNode);
paragraphNode.select();
initialCaret =
initialCaret ||
$getChildCaret($getRoot(), 'previous')
.getFlipped()
.insert($createParagraphNode());
}
const insertCaret = $insertNodeToNearestRootAtCaret(node, initialCaret);
const adjacent = $getAdjacentChildCaret(insertCaret);
const selectionCaret = $isChildCaret(adjacent)
? $normalizeCaret(adjacent)
: insertCaret;
$setSelectionFromCaretRange($getCollapsedCaretRange(selectionCaret));
return node.getLatest();
}

/**
* If the insertion caret is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}),
* the node will be inserted there, otherwise the parent nodes will be split according to the
* given options.
* @param node - The node to be inserted
* @param caret - The location to insert or split from
* @returns The node after its insertion
*/
export function $insertNodeToNearestRootAtCaret<
T extends LexicalNode,
D extends CaretDirection,
>(
node: T,
caret: PointCaret<D>,
options?: SplitAtPointCaretNextOptions,
): NodeCaret<D> {
let insertCaret: PointCaret<'next'> = $getCaretInDirection(caret, 'next');
for (
let nextCaret: null | PointCaret<'next'> = insertCaret;
nextCaret;
nextCaret = $splitAtPointCaretNext(nextCaret, options)
) {
insertCaret = nextCaret;
}
invariant(
!$isTextPointCaret(insertCaret),
'$insertNodeToNearestRootAtCaret: An unattached TextNode can not be split',
);
insertCaret.insert(
node.isInline() ? $createParagraphNode().append(node) : node,
);
return $getCaretInDirection(
$getSiblingCaret(node.getLatest(), 'next'),
caret.direction,
);
}

/**
* Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode
* @param node - Node to be wrapped.
Expand Down
17 changes: 16 additions & 1 deletion packages/lexical/flow/Lexical.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,22 @@ declare export function $getCommonAncestor<
declare export function $extendCaretToRange<D: CaretDirection>(
anchor: PointCaret<D>,
): CaretRange<D>;

declare export function $getCollapsedCaretRange<D: CaretDirection>(
anchor: PointCaret<D>,
): CaretRange<D>;
declare export function $isExtendableTextPointCaret<D: CaretDirection>(
caret: PointCaret<D>
): implies caret is TextPointCaret<TextNode, D>;

export interface SplitAtPointCaretNextOptions {
$copyElementNode?: (node: ElementNode) => ElementNode;
$splitTextPointCaretNext?: (
caret: TextPointCaret<TextNode, 'next'>,
) => NodeCaret<'next'>;
rootMode?: RootMode;
$shouldSplit?: (node: ElementNode, edge: 'first' | 'last') => boolean;
}
declare export function $splitAtPointCaretNext(
pointCaret: PointCaret<'next'>,
options?: SplitAtPointCaretNextOptions,
): null | NodeCaret<'next'>;
Loading
Loading