Skip to content

Commit d7d4e1a

Browse files
committed
[lexical-markdown] Fix: Prevent Link Transformer from consuming preceding text
1 parent 4b70f58 commit d7d4e1a

File tree

4 files changed

+167
-4
lines changed

4 files changed

+167
-4
lines changed

packages/lexical-list/src/LexicalListItemNode.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
*
77
*/
88

9-
import type {ListType} from './';
109
import type {
1110
BaseSelection,
1211
DOMConversionOutput,
@@ -40,8 +39,8 @@ import {
4039
} from 'lexical';
4140
import invariant from 'shared/invariant';
4241

43-
import {$createListNode, $isListNode} from './';
4442
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
43+
import {$createListNode, $isListNode, type ListType} from './LexicalListNode';
4544
import {isNestedListNode} from './utils';
4645

4746
export type SerializedListItemNode = Spread<
@@ -326,8 +325,14 @@ export class ListItemNode extends ElementNode {
326325
// if the list node is nested, we just want to remove it,
327326
// effectively unindenting it.
328327
listNode.remove();
329-
listNodeParent.select();
328+
329+
const paragraphChildren = paragraph.getChildren();
330+
listNodeParent.append(...paragraphChildren);
331+
332+
// Ensure cursor is placed correctly at the end of the merged content
333+
listNodeParent.selectEnd();
330334
} else {
335+
// top level list with 1 item, we want to convert it to a paragraph
331336
listNode.insertBefore(paragraph);
332337
listNode.remove();
333338
// If we have selection on the list item, we'll need to move it
@@ -343,8 +348,10 @@ export class ListItemNode extends ElementNode {
343348
if (focus.type === 'element' && focus.getNode().is(this)) {
344349
focus.set(key, focus.offset, 'element');
345350
}
351+
paragraph.select();
346352
}
347353
} else {
354+
// list here has multiple items, we just un-indent this specific item
348355
listNode.insertBefore(paragraph);
349356
this.remove();
350357
}

packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@
66
*
77
*/
88
import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link';
9-
import {$createTextNode, $getRoot, ParagraphNode, TextNode} from 'lexical';
9+
import {
10+
$createRangeSelection,
11+
$createTextNode,
12+
$getRoot,
13+
$isTextNode,
14+
$setSelection,
15+
DELETE_CHARACTER_COMMAND,
16+
ParagraphNode,
17+
TextNode,
18+
} from 'lexical';
1019
import {
1120
expectHtmlToBeEqual,
1221
html,
1322
initializeUnitTest,
23+
invariant,
1424
} from 'lexical/src/__tests__/utils';
1525
import {waitForReact} from 'packages/lexical-react/src/__tests__/unit/utils';
1626
import {describe, expect, test} from 'vitest';
@@ -424,6 +434,95 @@ describe('LexicalListNode tests', () => {
424434
expect(bulletList.__listType).toBe('bullet');
425435
});
426436
});
437+
438+
test('Deleting selection across parent and nested list item should remove nested list', async () => {
439+
const {editor} = testEnv;
440+
await editor.update(() => {
441+
const root = $getRoot();
442+
const mainList = $createListNode('number');
443+
const parentItem = $createListItemNode();
444+
parentItem.append($createTextNode('Parent Item'));
445+
446+
const nestedList = $createListNode('number');
447+
const nestedItem = $createListItemNode();
448+
nestedItem.append($createTextNode('Child Item'));
449+
450+
//build the tree
451+
nestedList.append(nestedItem);
452+
parentItem.append(nestedList);
453+
mainList.append(parentItem);
454+
root.append(mainList);
455+
});
456+
457+
await editor.update(() => {
458+
const root = $getRoot();
459+
const mainList = root.getFirstChildOrThrow();
460+
invariant($isListNode(mainList), 'Expected mainList to be a ListNode');
461+
462+
const parentItem = mainList.getFirstChildOrThrow();
463+
invariant(
464+
$isListItemNode(parentItem),
465+
'Expected parentItem to be a ListItemNode',
466+
);
467+
468+
// The text node inside Parent ("Parent")
469+
const parentText = parentItem.getFirstChildOrThrow();
470+
471+
// The nested list is the second child of parentItem
472+
const nestedList = parentItem.getLastChild();
473+
invariant($isListNode(nestedList), 'Expected nested list');
474+
475+
const nestedItem = nestedList.getFirstChildOrThrow();
476+
invariant(
477+
$isListItemNode(nestedItem),
478+
'Expected nestedItem to be a ListItemNode',
479+
);
480+
481+
const childText = nestedItem.getFirstChildOrThrow();
482+
invariant(
483+
$isTextNode(childText),
484+
'Expected childText to be a TextNode',
485+
);
486+
487+
const selection = $createRangeSelection();
488+
selection.anchor.set(parentText.getKey(), 0, 'text'); // Start of "Parent"
489+
selection.focus.set(
490+
childText.getKey(),
491+
childText.getTextContentSize(),
492+
'text',
493+
); // End of "Child"
494+
495+
$setSelection(selection);
496+
497+
editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
498+
});
499+
500+
await editor.update(() => {
501+
const root = $getRoot();
502+
const mainList = root.getFirstChildOrThrow();
503+
invariant($isListNode(mainList), 'Expected mainList to be a ListNode');
504+
505+
// We expect the Parent Item to still exist (maybe empty),
506+
// but the Nested List inside it should be destroyed.
507+
const parentItem = mainList.getFirstChildOrThrow();
508+
invariant(
509+
$isListItemNode(parentItem),
510+
'Expected parentItem to be a ListItemNode',
511+
);
512+
// Debugging helper: Print what remains
513+
// console.log(JSON.stringify(parentItem.exportJSON(), null, 2));
514+
515+
const children = parentItem.getChildren();
516+
517+
// If the bug exists, this will likely fail because the nested list is still there
518+
// or the structure is corrupted.
519+
const hasNestedList = children.some((node) => $isListNode(node));
520+
expect(hasNestedList).toBe(false);
521+
522+
// Optionally, check if the text is gone too
523+
expect(parentItem.getTextContent()).toBe('');
524+
});
525+
});
427526
});
428527
});
429528

packages/lexical-markdown/src/MarkdownTransformers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,18 @@ export const LINK: TextMatchTransformer = {
681681
/(?:\[(.+?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
682682
replace: (textNode, match) => {
683683
const [, linkText, linkUrl, linkTitle] = match;
684+
const matchText = match[0];
685+
686+
const textContent = textNode.getTextContent();
687+
const localMatchIndex = textContent.indexOf(matchText);
688+
if (localMatchIndex === -1) {
689+
return;
690+
}
691+
692+
//split only if necessary
693+
if (localMatchIndex > 0) {
694+
textNode = textNode.splitText(localMatchIndex)[1];
695+
}
684696
const linkNode = $createLinkNode(linkUrl, {title: linkTitle});
685697
const openBracketAmount = linkText.split('[').length - 1;
686698
const closeBracketAmount = linkText.split(']').length - 1;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
import {createHeadlessEditor} from '@lexical/headless';
9+
import {LinkNode} from '@lexical/link';
10+
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
11+
import {describe, expect, test} from 'vitest';
12+
13+
import {LINK} from '../../MarkdownTransformers';
14+
15+
describe('MarkdownLinkBug', () => {
16+
test('Expect text BEFORE a markdown link to be preserved', () => {
17+
const editor = createHeadlessEditor({
18+
nodes: [LinkNode],
19+
onError(error) {},
20+
});
21+
22+
editor.update(() => {
23+
// creating a scenario: "Start [test](url)"
24+
const root = $getRoot();
25+
const paragraph = $createParagraphNode();
26+
const textNode = $createTextNode('Start [test](url)');
27+
paragraph.append(textNode);
28+
root.append(paragraph);
29+
30+
const match = LINK.regExp.exec(textNode.getTextContent());
31+
32+
expect(match).not.toBeNull();
33+
if (match) {
34+
LINK.replace!(textNode, match);
35+
}
36+
37+
// paragraph should now contain a text node with "Start " and a link node with "test"
38+
const children = paragraph.getChildren();
39+
expect(children.length).toBe(2);
40+
expect(children[0].getTextContent()).toBe('Start ');
41+
expect(children[1].getTextContent()).toBe('test');
42+
expect(children[1]).toBeInstanceOf(LinkNode);
43+
});
44+
});
45+
});

0 commit comments

Comments
 (0)