diff --git a/.changeset/lexical-text-entity-node-state.md b/.changeset/lexical-text-entity-node-state.md
new file mode 100644
index 00000000000..aeba71d398e
--- /dev/null
+++ b/.changeset/lexical-text-entity-node-state.md
@@ -0,0 +1,12 @@
+---
+'lexical': patch
+'@lexical/text': patch
+---
+
+[fix] `registerLexicalTextEntity` keeps the NodeState of a text entity (a hashtag, a keyword) that stops matching and becomes text, such as comment ids stored as NodeState, instead of dropping it
+
+The NodeState also comes back when that text is made an entity again in the same update, and plain text keeps its NodeState when it becomes an entity (state that `createNode` gives the entity wins). A token or segmented entity that text follows keeps its NodeState as well: it is turned back into text with it, and after twice in one update (or in updates batched into one commit) it is kept (taking the text its match extends into only when that text would merge with it), instead of looping forever. Editing inside a segmented node (typing, or removing a range that starts or ends in it) keeps its NodeState and detail too.
+
+Verify: put a comment (or any NodeState) on a hashtag, type right before it or delete its `#`, and check that its characters keep the comment.
+
+Verify also: type inside a segmented mention (or delete a range that starts in it) and check that the text left keeps its NodeState and `unmergeable` detail; paste ` #tag` right after a comment and check that the hashtag is not added to the comment.
diff --git a/.changeset/lexical-text-entity-settle.md b/.changeset/lexical-text-entity-settle.md
new file mode 100644
index 00000000000..be70e6a197d
--- /dev/null
+++ b/.changeset/lexical-text-entity-settle.md
@@ -0,0 +1,11 @@
+---
+'@lexical/text': patch
+---
+
+[fix] `registerLexicalTextEntity` no longer loops forever ("One or more transforms are endlessly triggering additional transforms") when typing splits text that sits between two text nodes of other formats, such as a keyword in plain text between two bold words
+
+A match at the start of a text node is now left as text when the text node before it would turn it straight back into text.
+
+A hashtag or other entity right after text in another format is now turned back into text when typing makes it touch that text (`b#tag`), as a fresh parse would.
+
+Verify: text entities (hashtags, mentions, keywords) right after text in another format or style, before and after typing next to them.
diff --git a/packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts b/packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts
index b993ebd2640..731b533b92b 100644
--- a/packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts
+++ b/packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts
@@ -898,6 +898,71 @@ describe('MarkStateExtension', () => {
}
});
+ test('a hashtag inserted at the edge of a marked run is not marked, like MarkNode', () => {
+ // The inserted text lands in the marked node, and the hashtag
+ // transform splits the hashtag off with a copy of that node's ids
+ // (which the hashtag keeps): those are not the inserted text's own
+ const cases: [string, number, string, string, string][] = [
+ [
+ 'pasted after',
+ 3,
+ ' #tag',
+ 'paragraph("see"[c1] " " "#tag" " more")',
+ 'paragraph(mark[c1]("see") " " "#tag" " more")',
+ ],
+ [
+ 'code after',
+ 3,
+ ' #tag',
+ 'paragraph("see"[c1] " " "#tag" " more")',
+ 'paragraph(mark[c1]("see") " " "#tag" " more")',
+ ],
+ [
+ 'code before',
+ 0,
+ '#tag ',
+ // As on main: the space lands in the text with "see"
+ 'paragraph("#tag" " see"[c1] " more")',
+ 'paragraph("#tag" " " mark[c1]("see") " more")',
+ ],
+ ];
+ for (const [how, at, text, expected, expectedWithMarkNode] of cases) {
+ for (const withMarkNode of [false, true]) {
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [
+ RichTextExtension,
+ HashtagExtension,
+ MarkExtension,
+ ...(withMarkNode ? [] : [MarkStateExtension]),
+ ],
+ name: '[root]',
+ }),
+ );
+ update(editor, () => $init('see more'));
+ update(editor, () => {
+ const selection = $select(0, 3);
+ if (withMarkNode) {
+ $wrapSelectionInMarkNode(selection, false, 'c1');
+ } else {
+ $addMarkID('c1', selection);
+ }
+ });
+ update(editor, () => $select(at));
+ if (how.startsWith('pasted')) {
+ editor.dispatchCommand(PASTE_COMMAND, pasteEvent(text));
+ } else {
+ update(editor, () => $select(at).insertText(text));
+ }
+ expect([how, withMarkNode, tree(editor)]).toEqual([
+ how,
+ withMarkNode,
+ withMarkNode ? expectedWithMarkNode : expected,
+ ]);
+ }
+ }
+ });
+
test('pasted Lexical data keeps its ids after a marked hashtag', () => {
// Pasted or inserted content with the same ids as the hashtag is not
// taken for text split off it: its ids are a value of its own, not
diff --git a/packages/lexical-playground/__tests__/e2e/Hashtags.spec.mjs b/packages/lexical-playground/__tests__/e2e/Hashtags.spec.mjs
index 925e7ace7a7..306f78db025 100644
--- a/packages/lexical-playground/__tests__/e2e/Hashtags.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Hashtags.spec.mjs
@@ -6,12 +6,14 @@
*
*/
-import {moveLeft} from '../keyboardShortcuts/index.mjs';
+import {moveLeft, toggleBold} from '../keyboardShortcuts/index.mjs';
import {
assertHTML,
assertSelection,
click,
+ expect,
focusEditor,
+ getPageOrFrame,
html,
initialize,
pasteFromClipboard,
@@ -83,6 +85,128 @@ test.describe('Hashtags', () => {
test.describe(() => {
test.skip(({isPlainText}) => isPlainText);
+
+ test('Typing hashtags between bold text settles', async ({page}) => {
+ // Hashtags next to bold text: "#foo" touches the bold "a" before it, so
+ // it is no hashtag, and "#bar" after a space is one. Typing a space
+ // inside "#foobar", between bold "a" and a hashtag before bold "!",
+ // keeps it that way. This settled before #9637's fix too (text after a
+ // hashtag only extends it); the keyword test below reproduces #9637.
+ await focusEditor(page);
+ await toggleBold(page);
+ await page.keyboard.type('a');
+ await toggleBold(page);
+ await page.keyboard.type('#foo #bar');
+ await assertHTML(
+ page,
+ html`
+
+
+ a
+
+ #foo
+
+ #bar
+
+
+ `,
+ );
+
+ await expect(
+ getPageOrFrame(page).locator('div[contenteditable="true"] p').first(),
+ ).toHaveText('a#foo #bar');
+
+ await page.keyboard.press('Enter');
+ await toggleBold(page);
+ await page.keyboard.type('a');
+ await toggleBold(page);
+ await page.keyboard.type('#foobar #baz');
+ await toggleBold(page);
+ await page.keyboard.type('!');
+ await moveLeft(page, 9);
+ await page.keyboard.type(' ');
+ await assertHTML(
+ page,
+ html`
+
+
+ a
+
+ #foo
+
+ #bar
+
+
+
+
+ a
+
+ #foo bar
+
+ #baz
+
+
+ !
+
+
+ `,
+ );
+ await expect(
+ getPageOrFrame(page).locator('div[contenteditable="true"] p').nth(1),
+ ).toHaveText('a#foo bar #baz!');
+ });
+ test('Typing a space between two keywords between bold text settles', async ({
+ page,
+ }) => {
+ // #9637: neither "congrats" is a keyword, as each touches a bold
+ // letter. Before, the space was dropped with "One or more transforms
+ // are endlessly triggering additional transforms".
+ await focusEditor(page);
+ await toggleBold(page);
+ await page.keyboard.type('x');
+ await toggleBold(page);
+ await page.keyboard.type('congratscongrats');
+ await toggleBold(page);
+ await page.keyboard.type('y');
+ await moveLeft(page, 9);
+ await page.keyboard.type(' ');
+ await assertHTML(
+ page,
+ html`
+
+
+ x
+
+ congrats congrats
+
+ y
+
+
+ `,
+ );
+ await expect(
+ getPageOrFrame(page).locator('div[contenteditable="true"] p').first(),
+ ).toHaveText('xcongrats congratsy');
+ });
+
test('Should not break when pasting multiple matches', async ({page}) => {
await focusEditor(page);
diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts
new file mode 100644
index 00000000000..f4317522fb8
--- /dev/null
+++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts
@@ -0,0 +1,558 @@
+/**
+ * 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 {buildEditorFromExtensions, defineExtension} from '@lexical/extension';
+import {
+ $createHashtagNode,
+ HashtagExtension,
+ HashtagNode,
+} from '@lexical/hashtag';
+import {
+ $createListItemNode,
+ $createListNode,
+ ListExtension,
+ ListItemNode,
+ ListNode,
+} from '@lexical/list';
+import {
+ $addMarkID,
+ $getNodeMarkIDs,
+ MarkExtension,
+ MarkStateExtension,
+} from '@lexical/mark';
+import {RichTextExtension} from '@lexical/rich-text';
+import {
+ $create,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTabNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $getState,
+ $isRangeSelection,
+ $setSelection,
+ $setState,
+ createEditor,
+ createState,
+ type ElementNode,
+ type LexicalEditor,
+ type LexicalNode,
+ type RangeSelection,
+ TextNode,
+} from 'lexical';
+import {describe, expect, test} from 'vitest';
+
+import {type EntityMatch, registerLexicalTextEntity} from '../..';
+
+/**
+ * #9704: joining a block onto text that ends in another format (or a tab)
+ * turns a hashtag at the start of the joined block into text (`word#tag`
+ * holds no hashtag), and its characters keep their NodeState (comment ids
+ * stored as NodeState included).
+ */
+
+// A NodeState on the hashtag, as a comment mark stored as NodeState is.
+const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+});
+
+function createHashtagEditor(): LexicalEditor {
+ const editor = createEditor({
+ nodes: [HashtagNode, ListNode, ListItemNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(
+ editor,
+ HashtagExtension.config!.getHashtagMatch,
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ return editor;
+}
+
+// [type, text, format, state] for every text node.
+const read = (editor: LexicalEditor) =>
+ editor.read(() =>
+ $getRoot()
+ .getAllTextNodes()
+ .map(n => [
+ n.getType(),
+ n.getTextContent(),
+ n.getFormat(),
+ $getState(n, note),
+ ]),
+ );
+
+// Backspace at the start of the last block: select from the end of the first
+// block to the start of the last one and remove.
+function $joinLastBlock(): void {
+ const last = (
+ $getRoot().getFirstChildOrThrow() as ElementNode
+ ).getLastDescendant() as TextNode;
+ const first = (
+ $getRoot().getLastChildOrThrow() as ElementNode
+ ).getFirstDescendant() as TextNode;
+ const s = $createRangeSelection();
+ s.anchor.set(last.getKey(), last.getTextContentSize(), 'text');
+ s.focus.set(first.getKey(), 0, 'text');
+ $setSelection(s);
+ s.removeText();
+ $setSelection(null);
+}
+
+const $list = (...children: LexicalNode[]) =>
+ $createListNode('bullet').append($createListItemNode().append(...children));
+
+describe('joining blocks before a hashtag keeps its NodeState (#9704)', () => {
+ test('#9704: a list item that starts with a hashtag, joined onto italic text', () => {
+ const editor = createHashtagEditor();
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('one '),
+ $createTextNode('word').setFormat('italic'),
+ ),
+ $list(
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ $createTextNode(' more'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update($joinLastBlock, {discrete: true});
+ // "word#tag" is no hashtag (a fresh parse finds none), so the hashtag
+ // becomes text; its characters keep their NodeState.
+ expect(read(editor)).toEqual([
+ ['text', 'one ', 0, ''],
+ ['text', 'word', 2, ''],
+ ['text', '#tag', 0, 'c1'],
+ ['text', ' more', 0, ''],
+ ]);
+ });
+
+ test('the same with backspace from the start of the item (deleteCharacter)', () => {
+ const editor = createHashtagEditor();
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('one '),
+ $createTextNode('word').setFormat('italic'),
+ ),
+ $createParagraphNode().append(
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ $createTextNode(' more'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ const hashtag = (
+ $getRoot().getLastChildOrThrow() as ElementNode
+ ).getFirstDescendant() as TextNode;
+ hashtag.select(0, 0).deleteCharacter(true);
+ $setSelection(null);
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'one ', 0, ''],
+ ['text', 'word', 2, ''],
+ ['text', '#tag', 0, 'c1'],
+ ['text', ' more', 0, ''],
+ ]);
+ });
+
+ test('forward delete at the end of the text before it', () => {
+ const editor = createHashtagEditor();
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('one '),
+ $createTextNode('word').setFormat('bold'),
+ ),
+ $createParagraphNode().append(
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ const word = (
+ $getRoot().getFirstChildOrThrow() as ElementNode
+ ).getLastDescendant() as TextNode;
+ word.select(4, 4).deleteCharacter(false);
+ $setSelection(null);
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'one ', 0, ''],
+ ['text', 'word', 1, ''],
+ ['text', '#tag', 0, 'c1'],
+ ]);
+ });
+
+ test('the block before ends with a tab, the text and tab carry NodeState', () => {
+ // From a long-session test: a block ending with a tab (both with comment
+ // marks) joined with a list item starting with a commented hashtag.
+ const editor = createHashtagEditor();
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $setState($createTextNode('t5015z t5016z'), note, 'c1,c4'),
+ $setState($createTabNode(), note, 'c1,c4'),
+ ),
+ $list(
+ $setState($createHashtagNode('#t5017z'), note, 'c1,c4'),
+ $setState($createTextNode(' t5018z'), note, 'c4'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update($joinLastBlock, {discrete: true});
+ const rows = read(editor);
+ expect(editor.read(() => $getRoot().getTextContent())).toBe(
+ 't5015z t5016z\t#t5017z t5018z',
+ );
+ // Whatever the hashtag becomes, its characters keep their NodeState
+ expect(rows.find(r => String(r[1]).includes('#t5017z'))?.[3]).toBe('c1,c4');
+ });
+});
+
+describe('with MarkStateExtension comments (#9704)', () => {
+ test('a commented hashtag joined onto text in another format keeps its comment ids', () => {
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [
+ RichTextExtension,
+ ListExtension,
+ HashtagExtension,
+ MarkExtension,
+ MarkStateExtension,
+ ],
+ name: 'test',
+ onError: (e: Error) => {
+ throw e;
+ },
+ }),
+ );
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append(
+ $createTextNode('one '),
+ $createTextNode('word').setFormat('italic'),
+ ),
+ $list($createHashtagNode('#tag'), $createTextNode(' more')),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ const hashtag = (
+ $getRoot().getLastChildOrThrow() as ElementNode
+ ).getFirstDescendant() as TextNode;
+ $addMarkID('c1', hashtag.select(0, 4));
+ $addMarkID('c4', hashtag.select(0, 4));
+ $setSelection(null);
+ },
+ {discrete: true},
+ );
+ editor.update($joinLastBlock, {discrete: true});
+ const ids = editor.read(() =>
+ $getRoot()
+ .getAllTextNodes()
+ .map(n => [n.getTextContent(), $getNodeMarkIDs(n).join(',')]),
+ );
+ expect(ids.find(([text]) => text.includes('#tag'))).toEqual([
+ '#tag',
+ 'c1,c4',
+ ]);
+ editor.dispose();
+ });
+});
+
+describe('an entity turned back into text does not take the text after it first', () => {
+ test('two hashtags turned into text: the text after them keeps its format, NodeState and the selection', () => {
+ const editor = createHashtagEditor();
+ editor.update(
+ () => {
+ const suffix = $setState(
+ $createTextNode('z!').setFormat('italic'),
+ note,
+ 'suffix',
+ );
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('x'),
+ $createTextNode('a').setFormat('bold'),
+ $createHashtagNode('#b').setFormat('bold'),
+ $setState($createHashtagNode('#c'), note, 'entity'),
+ suffix,
+ ),
+ );
+ suffix.select(2, 2);
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'x', 0, ''],
+ ['text', 'a#b', 1, ''],
+ ['text', '#c', 0, 'entity'],
+ ['text', 'z!', 2, 'suffix'],
+ ]);
+ editor.read(() => {
+ const selection = $getSelection();
+ expect($isRangeSelection(selection)).toBe(true);
+ const {anchor} = selection as RangeSelection;
+ expect([anchor.getNode().getTextContent(), anchor.offset]).toEqual([
+ 'z!',
+ 2,
+ ]);
+ });
+ });
+});
+
+describe('entities of two registrations that touch', () => {
+ class MentionNode extends TextNode {
+ $config() {
+ return this.config('join-mention', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ }
+ class SegmentedNode extends TextNode {
+ $config() {
+ return this.config('join-segmented', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ }
+ const regexMatch =
+ (regex: RegExp) =>
+ (text: string): null | EntityMatch => {
+ const m = regex.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ test('typing before a hashtag that a token and a segmented entity follow settles', () => {
+ let calls = 0;
+ const counted =
+ (getMatch: (text: string) => null | EntityMatch) => (text: string) => {
+ calls++;
+ return getMatch(text);
+ };
+ const editor = createEditor({
+ nodes: [HashtagNode, MentionNode, SegmentedNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(
+ editor,
+ counted(HashtagExtension.config!.getHashtagMatch),
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ registerLexicalTextEntity(
+ editor,
+ counted(regexMatch(/@[a-z]+/)),
+ MentionNode,
+ node =>
+ $create(MentionNode)
+ .setTextContent(node.getTextContent())
+ .setMode('token'),
+ );
+ registerLexicalTextEntity(
+ editor,
+ counted(regexMatch(/\$[A-Z]+/)),
+ SegmentedNode,
+ node =>
+ $create(SegmentedNode)
+ .setTextContent(node.getTextContent())
+ .setMode('segmented'),
+ );
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('x'),
+ $createTextNode(' #b').setFormat('bold'),
+ $createTextNode('$B@x').setFormat('code'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ calls = 0;
+ editor.update(
+ () => {
+ const space = $getRoot().getAllTextNodes()[1];
+ const selection = space.select(1, 1);
+ selection.format = space.getFormat();
+ selection.insertText('a');
+ },
+ {discrete: true},
+ );
+ expect(editor.read(() => $getRoot().getTextContent())).toBe('x a#b$B@x');
+ // The transforms of the two that touch run a bounded number of times
+ expect(calls).toBeLessThan(200);
+ });
+});
+
+describe('an entity that may not be kept does not grow', () => {
+ class PhraseNode extends TextNode {
+ $config() {
+ return this.config('join-phrase', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ }
+ const phraseMatch = (text: string): null | EntityMatch =>
+ /^a .*cat$/.test(text) ? {end: text.length, start: 0} : null;
+ test.each([7, 8])(
+ 'after text with %i earlier matches, a later match in the text after it is still made',
+ repeat => {
+ const editor = createEditor({
+ nodes: [HashtagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(
+ editor,
+ HashtagExtension.config!.getHashtagMatch,
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('#').setFormat('bold'),
+ $createTextNode('a '),
+ $createTextNode('#b').setFormat('italic'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ $getRoot()
+ .getAllTextNodes()
+ .find(n => n.getTextContent() === 'a ')!
+ .spliceText(2, 0, '#x '.repeat(repeat));
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ $getRoot()
+ .getAllTextNodes()
+ .find(n => n.getTextContent() === '#b')!
+ .selectEnd()
+ .insertNodes([$createTextNode('c rest #d').setFormat('bold')]);
+ },
+ {discrete: true},
+ );
+ editor.read(() => {
+ expect($getRoot().getTextContent()).toBe(
+ '#a ' + '#x '.repeat(repeat) + '#bc rest #d',
+ );
+ const hashtags = $getRoot()
+ .getAllTextNodes()
+ .filter(n => n.getType() === 'hashtag')
+ .map(n => n.getTextContent());
+ expect(hashtags).toContain('#d');
+ });
+ },
+ );
+
+ test.each([7, 8])(
+ 'after text with %i earlier matches, the text after it keeps its format and NodeState',
+ repeat => {
+ const editor = createEditor({
+ nodes: [HashtagNode, PhraseNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(
+ editor,
+ HashtagExtension.config!.getHashtagMatch,
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ registerLexicalTextEntity(editor, phraseMatch, PhraseNode, node =>
+ $create(PhraseNode).setTextContent(node.getTextContent()),
+ );
+ const phraseText = 'a ' + '#x '.repeat(repeat) + 'cat';
+ editor.update(
+ () => {
+ // Made in document order: the transforms run in that order
+ const first = $createTextNode('y');
+ const bold = $createTextNode('x').setFormat('bold');
+ const phrase = $create(PhraseNode)
+ .setTextContent(phraseText)
+ .setFormat('bold');
+ const hashtag = $setState($createHashtagNode('#c'), note, 'entity');
+ const suffix = $setState(
+ $createTextNode('z!').setFormat('italic'),
+ note,
+ 'suffix',
+ );
+ $getRoot().append(
+ $createParagraphNode().append(first, bold, phrase, hashtag, suffix),
+ );
+ // Comment ids too, as MarkStateExtension keeps them
+ $addMarkID('entity', hashtag.select(0, 2));
+ $addMarkID('suffix', suffix.select(0, 2));
+ suffix.select(2, 2);
+ },
+ {discrete: true},
+ );
+ editor.read(() => {
+ expect($getRoot().getTextContent()).toBe('yx' + phraseText + '#cz!');
+ const suffix = $getRoot()
+ .getAllTextNodes()
+ .find(n => n.getTextContent().includes('z'))!;
+ expect([
+ suffix.getTextContent(),
+ suffix.getFormat(),
+ $getState(suffix, note),
+ $getNodeMarkIDs(suffix),
+ ]).toEqual(['z!', 2, 'suffix', ['suffix']]);
+ const selection = $getSelection() as RangeSelection;
+ expect([
+ selection.anchor.getNode().is(suffix),
+ selection.anchor.offset,
+ ]).toEqual([true, 2]);
+ });
+ },
+ );
+});
diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts
new file mode 100644
index 00000000000..b9181d65807
--- /dev/null
+++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts
@@ -0,0 +1,871 @@
+/**
+ * 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 {buildEditorFromExtensions, defineExtension} from '@lexical/extension';
+import {
+ $createHashtagNode,
+ HashtagExtension,
+ HashtagNode,
+} from '@lexical/hashtag';
+import {HistoryExtension} from '@lexical/history';
+import {$createMarkNode, $isMarkNode, MarkNode} from '@lexical/mark';
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $getState,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ $setState,
+ createEditor,
+ createState,
+ type ElementNode,
+ HISTORY_PUSH_TAG,
+ type LexicalEditor,
+ type LexicalNode,
+ type RangeSelection,
+ REDO_COMMAND,
+ type SerializedTextNode,
+ TextNode,
+ UNDO_COMMAND,
+} from 'lexical';
+import {describe, expect, test} from 'vitest';
+
+import {type EntityMatch, registerLexicalTextEntity} from '../..';
+
+// Ad-hoc NodeState, as an app keeps comment ids or block ids on text.
+const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+});
+// Reference-typed state: the carried value is the same object.
+const ids = createState('ids', {
+ isEqual: (a: readonly string[], b: readonly string[]) =>
+ a.length === b.length && a.every((v, i) => v === b[i]),
+ parse: (v): readonly string[] =>
+ Array.isArray(v) ? v.filter(x => typeof x === 'string') : [],
+});
+
+// A keyword-style entity (normal mode) and a token entity (mention-like).
+class KeywordNode extends TextNode {
+ $config() {
+ return this.config('test-keyword', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+}
+function $createKeywordNode(text: string): KeywordNode {
+ return $applyNodeReplacement(new KeywordNode(text));
+}
+const getKeywordMatch = (text: string): null | EntityMatch => {
+ const m = /(^|[^A-Za-z])(cat)(?![A-Za-z])/.exec(text);
+ return m === null
+ ? null
+ : {end: m.index + m[1].length + 3, start: m.index + m[1].length};
+};
+
+class MentionNode extends TextNode {
+ $config() {
+ return this.config('test-mention', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+}
+function $createMentionNode(text: string): MentionNode {
+ return $applyNodeReplacement(new MentionNode(text)).setMode('token');
+}
+const getMentionMatch = (text: string): null | EntityMatch => {
+ const m = /@[a-z]+/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+};
+
+// An entity whose own $config declares a state (nested and flat).
+const entityOwn = createState('entityOwn', {
+ parse: v => (typeof v === 'string' ? v : ''),
+});
+const entityFlat = createState('entityFlat', {
+ parse: v => (typeof v === 'string' ? v : ''),
+});
+class StatefulEntityNode extends TextNode {
+ $config() {
+ return this.config('test-stateful-entity', {
+ extends: TextNode,
+ stateConfigs: [{flat: true, stateConfig: entityFlat}, entityOwn],
+ });
+ }
+ isTextEntity(): true {
+ return true;
+ }
+}
+const getHashMatch = (text: string): null | EntityMatch => {
+ const m = /#\w+/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+};
+
+const getHashtagMatch = HashtagExtension.config!.getHashtagMatch;
+
+function createHashtagEditor(
+ nodes: readonly (typeof TextNode | typeof MarkNode)[] = [],
+): LexicalEditor {
+ const editor = createEditor({
+ nodes: [HashtagNode, ...nodes],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getHashtagMatch, HashtagNode, node =>
+ $createHashtagNode(node.getTextContent()),
+ );
+ return editor;
+}
+
+type Row = [string, string, string];
+// [type, text, note] of every text node
+const read = (editor: LexicalEditor): Row[] =>
+ editor.read(() =>
+ $getRoot()
+ .getAllTextNodes()
+ .map((n): Row => [n.getType(), n.getTextContent(), $getState(n, note)]),
+ );
+
+const update = (editor: LexicalEditor, fn: () => void) =>
+ editor.update(
+ () => {
+ fn();
+ $setSelection(null);
+ },
+ {discrete: true},
+ );
+
+const $paragraph = (...children: LexicalNode[]) =>
+ $createParagraphNode().append(...children);
+const $text = (i: number): TextNode => $getRoot().getAllTextNodes()[i];
+
+describe('registerLexicalTextEntity keeps NodeState', () => {
+ test('#9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ $createTextNode(' more'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 0).insertText('x');
+ });
+ expect(read(editor)).toEqual([
+ ['text', 'one x', ''],
+ ['text', '#tag', 'c1'],
+ ['text', ' more', ''],
+ ]);
+ });
+
+ test('deleting the # of a hashtag keeps its NodeState (reverse transform, no match)', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 1).removeText();
+ });
+ expect(read(editor)).toEqual([
+ ['text', 'one ', ''],
+ ['text', 'tag', 'c1'],
+ ]);
+ });
+
+ test('a hashtag that becomes text merges into neighbors with the same NodeState', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $setState($createTextNode('one '), note, 'c1'),
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ $setState($createTextNode(' more'), note, 'c1'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 0).insertText('x');
+ });
+ expect(read(editor)).toEqual([['text', 'one x#tag more', 'c1']]);
+ });
+
+ test('a hashtag with no NodeState becomes text and merges as before', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $createHashtagNode('#tag'),
+ $createTextNode(' more'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 0).insertText('x');
+ });
+ expect(read(editor)).toEqual([['text', 'one x#tag more', '']]);
+ // No NodeState was created on the merged text
+ editor.getEditorState().read(() => {
+ expect($text(0).exportJSON().$).toBe(undefined);
+ });
+ });
+
+ test('plain text with NodeState that becomes a hashtag keeps it', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph($setState($createTextNode('a #'), note, 'c1')),
+ );
+ });
+ update(editor, () => {
+ // The "#" was there: the hashtag is not only inserted text
+ $text(0).select(3, 3).insertText('tag');
+ });
+ expect(read(editor)).toEqual([
+ ['text', 'a ', 'c1'],
+ ['hashtag', '#tag', 'c1'],
+ ]);
+ });
+
+ test('a hashtag made only of text inserted into a node does not take its NodeState', () => {
+ // The inserted characters land in the node and get a copy of its
+ // NodeState, which is not theirs (as on main; MarkStateExtension does not
+ // add text inserted at a comment's edge to the comment either)
+ for (const [at, inserted, expected] of [
+ [
+ 1,
+ ' #tag',
+ [
+ ['text', 'a ', 'c1'],
+ ['hashtag', '#tag', ''],
+ ],
+ ],
+ [
+ 0,
+ '#tag ',
+ [
+ ['hashtag', '#tag', ''],
+ ['text', ' a', 'c1'],
+ ],
+ ],
+ ] as const) {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph($setState($createTextNode('a'), note, 'c1')),
+ );
+ });
+ update(editor, () => {
+ $text(0).select(at, at).insertText(inserted);
+ });
+ expect(read(editor)).toEqual(expected);
+ }
+ });
+
+ test('state that createNode gives the entity wins over the text it replaces', () => {
+ const editor = createEditor({
+ nodes: [HashtagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getHashtagMatch, HashtagNode, node =>
+ $setState($createHashtagNode(node.getTextContent()), note, 'entity'),
+ );
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $setState($setState($createTextNode('a #tag'), note, 'text'), ids, [
+ 'c1',
+ ]),
+ ),
+ );
+ });
+ editor.getEditorState().read(() => {
+ const [text, hashtag] = $getRoot().getAllTextNodes();
+ expect($getState(text, note)).toBe('text');
+ expect(hashtag.getType()).toBe('hashtag');
+ expect($getState(hashtag, note)).toBe('entity');
+ expect($getState(hashtag, ids)).toEqual(['c1']);
+ });
+ });
+
+ test('text inserted after a hashtag that becomes a hashtag does not take the NodeState copied from it', () => {
+ // The hashtag's transform splits the inserted text off with a copy of
+ // its NodeState (as before); a hashtag made from that text does not
+ // take that copy, which is not the inserted text's own.
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('see '),
+ $setState($createHashtagNode('#foo'), note, 'c1'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(4, 4).insertText(', x #bar y');
+ });
+ const rows = read(editor);
+ expect(rows[1]).toEqual(['hashtag', '#foo', 'c1']);
+ expect(rows.find(r => r[1] === '#bar')).toEqual(['hashtag', '#bar', '']);
+ });
+
+ test('a reference-typed state value is carried as the same object', () => {
+ const editor = createHashtagEditor();
+ const value = Object.freeze(['c1', 'c2']);
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createHashtagNode('#tag'), ids, value),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 1).removeText();
+ });
+ editor.getEditorState().read(() => {
+ const node = $text(1);
+ expect(node.getType()).toBe('text');
+ expect($getState(node, ids)).toBe(value);
+ });
+ });
+
+ test('NodeState read from JSON (not parsed yet) is carried and exported', () => {
+ const editor = createHashtagEditor();
+ editor.setEditorState(
+ editor.parseEditorState({
+ root: {
+ children: [
+ {
+ children: [
+ {
+ detail: 0,
+ format: 0,
+ mode: 'normal',
+ style: '',
+ text: 'one ',
+ type: 'text',
+ version: 1,
+ },
+ {
+ $: {comment: ['c1'], note: 'n1'},
+ detail: 0,
+ format: 0,
+ mode: 'normal',
+ style: '',
+ text: '#tag',
+ type: 'hashtag',
+ version: 1,
+ },
+ ],
+ direction: null,
+ format: '',
+ indent: 0,
+ textFormat: 0,
+ textStyle: '',
+ type: 'paragraph',
+ version: 1,
+ },
+ ],
+ direction: null,
+ format: '',
+ indent: 0,
+ type: 'root',
+ version: 1,
+ },
+ } as never),
+ );
+ update(editor, () => {
+ $text(1).select(0, 0).insertText('x');
+ });
+ editor.getEditorState().read(() => {
+ const json = $text(1).exportJSON() as SerializedTextNode & {
+ $?: Record;
+ };
+ expect(json.type).toBe('text');
+ expect(json.text).toBe('#tag');
+ expect(json.$).toEqual({comment: ['c1'], note: 'n1'});
+ expect($getState($text(1), note)).toBe('n1');
+ });
+ });
+
+ test('NodeState declared in the entity $config (nested and flat) is carried to the text', () => {
+ const editor = createEditor({
+ nodes: [StatefulEntityNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getHashMatch, StatefulEntityNode, node =>
+ $applyNodeReplacement(new StatefulEntityNode(node.getTextContent())),
+ );
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState(
+ $setState(
+ $setState(new StatefulEntityNode('#tag'), entityOwn, 'own'),
+ entityFlat,
+ 'flat',
+ ),
+ note,
+ 'c1',
+ ),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).select(0, 1).removeText();
+ });
+ editor.getEditorState().read(() => {
+ const node = $text(1);
+ expect(node.getType()).toBe('text');
+ expect($getState(node, note)).toBe('c1');
+ expect($getState(node, entityOwn)).toBe('own');
+ expect($getState(node, entityFlat)).toBe('flat');
+ // TextNode does not declare `entityFlat` flat: it serializes nested
+ expect(node.exportJSON()).toMatchObject({
+ $: {entityFlat: 'flat', entityOwn: 'own', note: 'c1'},
+ });
+ });
+ // Exported and imported again, the text keeps it
+ const json = editor.getEditorState().toJSON();
+ editor.setEditorState(editor.parseEditorState(json));
+ editor.getEditorState().read(() => {
+ const node = $text(1);
+ expect($getState(node, entityOwn)).toBe('own');
+ expect($getState(node, entityFlat)).toBe('flat');
+ });
+ });
+
+ test('a key the plain text type declares with another config is parsed with that config', () => {
+ // The entity and the text use different StateConfigs for the key "tag"
+ const entityTag = createState('tag', {
+ parse: v => (typeof v === 'string' ? v : ''),
+ });
+ const textTag = createState('tag', {
+ parse: v => (typeof v === 'string' ? `text:${v}` : ''),
+ unparse: (v: string) => v.replace(/^text:/, ''),
+ });
+ class TagEntityNode extends TextNode {
+ $config() {
+ return this.config('test-tag-entity', {
+ extends: TextNode,
+ stateConfigs: [entityTag],
+ });
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ }
+ class TagTextNode extends TextNode {
+ $config() {
+ return this.config('test-tag-text', {
+ $transform: undefined,
+ extends: TextNode,
+ stateConfigs: [textTag],
+ });
+ }
+ }
+ const editor = createEditor({
+ nodes: [
+ TagEntityNode,
+ TagTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TagTextNode(node.__text),
+ withKlass: TagTextNode,
+ },
+ ],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getHashMatch, TagEntityNode, node =>
+ $applyNodeReplacement(new TagEntityNode(node.getTextContent())),
+ );
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState(new TagEntityNode('#tag'), entityTag, 'v'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $getRoot().getAllTextNodes()[1].select(0, 1).removeText();
+ });
+ editor.getEditorState().read(() => {
+ const node = $getRoot().getAllTextNodes()[1];
+ expect(node.getType()).toBe('test-tag-text');
+ expect($getState(node, textTag)).toBe('text:v');
+ });
+ });
+
+ test('a hashtag inside a MarkNode stays inside it when it becomes text', () => {
+ const editor = createHashtagEditor([MarkNode]);
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $createMarkNode(['c1']).append(
+ $setState($createHashtagNode('#tag'), note, 'n1'),
+ ),
+ $createTextNode(' more'),
+ ),
+ );
+ });
+ update(editor, () => {
+ const hashtag = $getRoot().getAllTextNodes()[1];
+ hashtag.select(0, 1).removeText();
+ });
+ editor.getEditorState().read(() => {
+ const node = $getRoot().getAllTextNodes()[1];
+ expect(node.getType()).toBe('text');
+ expect(node.getTextContent()).toBe('tag');
+ expect($getState(node, note)).toBe('n1');
+ const mark = node.getParent();
+ expect($isMarkNode(mark) && mark.getIDs()).toEqual(['c1']);
+ });
+ });
+
+ test('undo and redo of a demotion restore the entity and the text with their NodeState', () => {
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [HashtagExtension, HistoryExtension],
+ name: 'test',
+ onError: (e: Error) => {
+ throw e;
+ },
+ }),
+ );
+ editor.setRootElement(document.createElement('div'));
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ ),
+ );
+ $text(0).select(0, 0);
+ },
+ {discrete: true, tag: HISTORY_PUSH_TAG},
+ );
+ const before = read(editor);
+ editor.update(
+ () => {
+ $text(1).select(0, 0).insertText('x');
+ },
+ {discrete: true, tag: HISTORY_PUSH_TAG},
+ );
+ const after = read(editor);
+ expect(after).toEqual([
+ ['text', 'one x', ''],
+ ['text', '#tag', 'c1'],
+ ]);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(read(editor)).toEqual(before);
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ expect(read(editor)).toEqual(after);
+ editor.dispose();
+ });
+});
+
+describe('every path that turns an entity into text keeps NodeState', () => {
+ test('the text transform: the entity before it stops matching with its text (prevMatch null)', () => {
+ // A keyword followed by a letter in another node: "cat" + "s" is no
+ // keyword, so the text transform of "s" turns the keyword into text.
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getKeywordMatch, KeywordNode, node =>
+ $createKeywordNode(node.getTextContent()),
+ );
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('a '),
+ $setState($createKeywordNode('cat'), note, 'k1'),
+ $createTextNode(' b'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(2).select(0, 0).insertText('s');
+ });
+ expect(read(editor)).toEqual([
+ ['text', 'a ', ''],
+ ['text', 'cat', 'k1'],
+ ['text', 's b', ''],
+ ]);
+ });
+
+ describe('the text transform: a token entity (mention-like) that text follows', () => {
+ const createMentionEditor = () => {
+ const editor = createEditor({
+ nodes: [MentionNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ registerLexicalTextEntity(editor, getMentionMatch, MentionNode, node =>
+ $createMentionNode(node.getTextContent()),
+ );
+ return editor;
+ };
+ test('with the same NodeState as the text: it keeps it and takes the text', () => {
+ const editor = createMentionEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('hi '),
+ $setState($createMentionNode('@bob'), note, 'm1'),
+ $setState($createTextNode('x'), note, 'm1'),
+ ),
+ );
+ });
+ // Before this change, this looped forever: the entity became text
+ // without its NodeState, which did not merge with "x".
+ expect(read(editor)).toEqual([
+ ['text', 'hi ', ''],
+ ['test-mention', '@bobx', 'm1'],
+ ]);
+ });
+ test('with other NodeState than the text: kept with its NodeState, the text stays apart', () => {
+ const editor = createMentionEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('hi '),
+ $setState($createMentionNode('@bob'), note, 'm1'),
+ $createTextNode('x'),
+ ),
+ );
+ });
+ // It becomes text with its NodeState, which does not merge with "x",
+ // and is made again; the third time it is kept, and "x" (which has
+ // none) stays text rather than take "m1". Before, it became text
+ // without its NodeState, merged and was made again without it.
+ expect(read(editor)).toEqual([
+ ['text', 'hi ', ''],
+ ['test-mention', '@bob', 'm1'],
+ ['text', 'x', ''],
+ ]);
+ });
+ test('typed text after it: the selection stays after the typed text', () => {
+ const editor = createMentionEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('hi '),
+ $setState($createMentionNode('@bob'), note, 'm1'),
+ $createTextNode('!'),
+ ),
+ );
+ });
+ editor.update(
+ () => {
+ // Paste "x yz" right after the mention
+ $text(1).select(4, 4).insertText('x yz');
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'hi ', ''],
+ ['test-mention', '@bob', 'm1'],
+ ['text', 'x yz!', ''],
+ ]);
+ editor.read(() => {
+ const selection = $getSelection();
+ expect($isRangeSelection(selection)).toBe(true);
+ const {anchor, focus} = selection as RangeSelection;
+ expect([anchor.getNode().getTextContent(), anchor.offset]).toEqual([
+ 'x yz!',
+ 4,
+ ]);
+ expect(focus.is(anchor)).toBe(true);
+ });
+ });
+ test('with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own', () => {
+ // Text with NodeState of its own never merges with it (before this
+ // change, this looped forever). The third time it is kept, and the
+ // text stays apart with its own NodeState.
+ const editor = createMentionEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('hi '),
+ $setState($createMentionNode('@bob'), note, 'm1'),
+ $setState($createTextNode('x'), note, 'x1'),
+ ),
+ );
+ });
+ expect(read(editor)).toEqual([
+ ['text', 'hi ', ''],
+ ['test-mention', '@bob', 'm1'],
+ ['text', 'x', 'x1'],
+ ]);
+ });
+
+ test('with a value object distinct from the text after it, equal as JSON: each keeps its own', () => {
+ // Two value objects are two values unless their `isEqual` says
+ // otherwise, as when text nodes merge: the token does not take "x".
+ const tag = createState('tag', {
+ parse: v =>
+ typeof v === 'object' && v !== null ? (v as {id: string}) : {id: ''},
+ });
+ const a = {id: 'c1'};
+ const b = {id: 'c1'};
+ const editor = createMentionEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('hi '),
+ $setState($createMentionNode('@bob'), tag, a),
+ $setState($createTextNode('x'), tag, b),
+ ),
+ );
+ });
+ editor.read(() => {
+ const nodes = $getRoot().getAllTextNodes();
+ expect(nodes.map(n => [n.getType(), n.getTextContent()])).toEqual([
+ ['text', 'hi '],
+ ['test-mention', '@bob'],
+ ['text', 'x'],
+ ]);
+ expect($getState(nodes[1], tag)).toBe(a);
+ expect($getState(nodes[2], tag)).toBe(b);
+ });
+ });
+ });
+
+ test('the text transform: the entity after it stops matching (nextMatch null)', () => {
+ // "x" + "#tag": "x#tag" holds no hashtag, so the text transform of "x"
+ // turns the hashtag after it into text.
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('x').setFormat('bold'),
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ ),
+ );
+ });
+ update(editor, () => {
+ // Mark both dirty
+ $text(0).markDirty();
+ $text(1).markDirty();
+ });
+ const rows = read(editor);
+ // On main's rules the hashtag either stays or becomes text; either way
+ // its characters keep their NodeState.
+ expect(rows.find(r => r[1] === '#tag')?.[2]).toBe('c1');
+ });
+
+ test('the reverse transform: adjacent entities both become text', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $setState($createHashtagNode('#a'), note, 'c1'),
+ $setState($createHashtagNode('#b'), note, 'c2'),
+ ),
+ );
+ });
+ // Both become text, then the first is a hashtag again: each keeps its
+ // NodeState (before, both lost it)
+ expect(read(editor)).toEqual([
+ ['hashtag', '#a', 'c1'],
+ ['text', '#b', 'c2'],
+ ]);
+ });
+
+ test('the reverse transform: an entity followed by an entity', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createHashtagNode('#a'), note, 'c1'),
+ ),
+ );
+ });
+ update(editor, () => {
+ $text(1).insertAfter($setState($createHashtagNode('#b'), note, 'c2'));
+ });
+ const rows = read(editor);
+ expect(rows.find(r => r[1] === '#a')?.[2]).toBe('c1');
+ expect(rows.find(r => r[1] === '#b')?.[2]).toBe('c2');
+ });
+
+ test('joining two blocks across text of another format keeps the NodeState of every node', () => {
+ const editor = createHashtagEditor();
+ update(editor, () => {
+ $getRoot().append(
+ $paragraph(
+ $createTextNode('one '),
+ $setState($createTextNode('word').setFormat('italic'), note, 'w'),
+ ),
+ $paragraph(
+ $setState($createHashtagNode('#tag'), note, 'c1'),
+ $createTextNode(' more'),
+ ),
+ );
+ });
+ update(editor, () => {
+ const last = (
+ $getRoot().getFirstChildOrThrow() as ElementNode
+ ).getLastDescendant() as TextNode;
+ const first = (
+ $getRoot().getLastChildOrThrow() as ElementNode
+ ).getFirstDescendant() as TextNode;
+ const s = $createRangeSelection();
+ s.anchor.set(last.getKey(), last.getTextContentSize(), 'text');
+ s.focus.set(first.getKey(), 0, 'text');
+ $setSelection(s);
+ s.removeText();
+ });
+ const rows = read(editor);
+ expect(rows.find(r => r[1] === 'word')?.[2]).toBe('w');
+ expect(rows.find(r => r[1] === '#tag')?.[2]).toBe('c1');
+ editor.getEditorState().read(() => {
+ expect($getRoot().getTextContent()).toBe('one word#tag more');
+ expect($getRoot().getAllTextNodes().every($isTextNode)).toBe(true);
+ });
+ });
+});
diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
new file mode 100644
index 00000000000..a01a69f7726
--- /dev/null
+++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
@@ -0,0 +1,307 @@
+/**
+ * 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 {
+ $createHashtagNode,
+ HashtagExtension,
+ HashtagNode,
+} from '@lexical/hashtag';
+import {$createMarkNode, $isMarkNode, MarkNode} from '@lexical/mark';
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $getState,
+ $isElementNode,
+ $isTextNode,
+ $setSelection,
+ $setState,
+ createEditor,
+ createState,
+ type ElementNode,
+ type LexicalEditor,
+ TextNode,
+} from 'lexical';
+import {describe, expect, test} from 'vitest';
+
+import {type EntityMatch, registerLexicalTextEntity} from '../..';
+
+/**
+ * Property: in random layouts of entities and text in several formats, with
+ * NodeState on entities and text and some text in MarkNodes, an edit that
+ * turns an entity back into text leaves every character of that entity that
+ * is still there with the entity's NodeState (or inside another entity that
+ * grew over it, which takes characters with its own NodeState), and inside
+ * the same MarkNode. Each entity is written with a letter no other text has,
+ * which tells its characters apart after the edit.
+ */
+
+const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+});
+
+class MentionNode extends TextNode {
+ $config() {
+ return this.config('property-mention', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+}
+
+const getMentionMatch = (text: string): null | EntityMatch => {
+ const m = /@[a-z0-9]+/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+};
+
+type Kind = 'hashtag' | 'mention';
+const SEEDS = Number(process.env.TEXT_ENTITY_NODE_STATE_SEEDS ?? 200);
+const ENTITY_LETTERS = 'abcdefghijklmnop';
+const PLAIN = [' ', ' ', 'x', 'y', '.', '#', '@', 'xy', ' x '];
+const TYPED = [' ', 'x', '#', '.', '@', 'z'];
+const FORMATS = [0, 0, 1, 2];
+
+/** Mulberry32, so a failure names a seed that reproduces it. */
+function rng(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function createEntityEditor(kind: Kind): LexicalEditor {
+ const editor = createEditor({
+ nodes: [HashtagNode, MentionNode, MarkNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ editor.setRootElement(document.createElement('div'));
+ if (kind === 'hashtag') {
+ registerLexicalTextEntity(
+ editor,
+ HashtagExtension.config!.getHashtagMatch,
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ } else {
+ registerLexicalTextEntity(editor, getMentionMatch, MentionNode, node =>
+ $applyNodeReplacement(new MentionNode(node.getTextContent())).setMode(
+ 'token',
+ ),
+ );
+ }
+ return editor;
+}
+
+const $createEntity = (kind: Kind, text: string): TextNode =>
+ kind === 'hashtag'
+ ? $createHashtagNode(text)
+ : $applyNodeReplacement(new MentionNode(text)).setMode('token');
+
+// The text nodes of the editor with their block, in document order
+function $textNodes(): TextNode[] {
+ return $getRoot().getAllTextNodes();
+}
+
+function $blocks(): ElementNode[] {
+ return $getRoot()
+ .getChildren()
+ .filter((n): n is ElementNode => $isElementNode(n));
+}
+
+type Info = {entity: boolean; mark: string; note: string; text: string};
+const read = (editor: LexicalEditor): Info[] =>
+ editor.read(() =>
+ $textNodes().map(n => {
+ const parent = n.getParent();
+ return {
+ entity: n.isTextEntity(),
+ mark: $isMarkNode(parent) ? parent.getIDs().join(',') : '',
+ note: $getState(n, note),
+ text: n.getTextContent(),
+ };
+ }),
+ );
+
+function check(
+ label: string,
+ before: Info[],
+ after: Info[],
+ failures: string[],
+): void {
+ // The entities before the edit, by their own letter
+ for (const node of before) {
+ if (!node.entity || node.note === '') {
+ continue;
+ }
+ const letter = [...node.text].find(c => ENTITY_LETTERS.includes(c));
+ if (letter === undefined) {
+ continue;
+ }
+ for (const now of after) {
+ if (!now.text.includes(letter)) {
+ continue;
+ }
+ if (now.note === node.note && now.mark === node.mark) {
+ continue;
+ }
+ // Characters that another entity grew over (an entity takes the text
+ // its match extends into with its own NodeState, as before): not at
+ // the start of an entity, right after its sigil
+ const grownOver = now.entity && now.text.indexOf(letter) > 1;
+ if (grownOver) {
+ continue;
+ }
+ failures.push(
+ `${label}: the characters of ${JSON.stringify(node)} are in ${JSON.stringify(now)}; before ${JSON.stringify(before)} after ${JSON.stringify(after)}`,
+ );
+ return;
+ }
+ }
+}
+
+describe('registerLexicalTextEntity keeps NodeState (property)', () => {
+ for (const kind of ['hashtag', 'mention'] as Kind[]) {
+ test(`${kind}: an edit that turns an entity into text keeps its NodeState and marks on its characters`, () => {
+ const failures: string[] = [];
+ let demoted = 0;
+ for (let seed = 0; seed < SEEDS && failures.length < 3; seed++) {
+ const random = rng(seed * 11 + (kind === 'hashtag' ? 1 : 2));
+ const pick = (values: readonly V[]): V =>
+ values[Math.floor(random() * values.length)];
+ const editor = createEntityEditor(kind);
+ let letter = 0;
+ try {
+ editor.update(
+ () => {
+ const blocks = 1 + Math.floor(random() * 3);
+ for (let b = 0; b < blocks; b++) {
+ const paragraph = $createParagraphNode();
+ const runs = 1 + Math.floor(random() * 5);
+ for (let r = 0; r < runs; r++) {
+ let node: TextNode;
+ if (random() < 0.45 && letter < ENTITY_LETTERS.length) {
+ const own = ENTITY_LETTERS[letter++];
+ node = $setState(
+ $createEntity(
+ kind,
+ (kind === 'hashtag' ? '#' : '@') +
+ own +
+ (random() < 0.5 ? '1' : ''),
+ ),
+ note,
+ `e${letter}`,
+ );
+ } else {
+ node = $createTextNode(pick(PLAIN) + pick(PLAIN));
+ if (random() < 0.5) {
+ $setState(node, note, `t${Math.floor(random() * 3)}`);
+ }
+ }
+ node.setFormat(pick(FORMATS));
+ paragraph.append(
+ random() < 0.2
+ ? $createMarkNode([
+ `m${Math.floor(random() * 2)}`,
+ ]).append(node)
+ : node,
+ );
+ }
+ $getRoot().append(paragraph);
+ }
+ },
+ {discrete: true},
+ );
+ for (let edit = 0; edit < 4; edit++) {
+ const before = read(editor);
+ const op = random();
+ let description = '';
+ editor.update(
+ () => {
+ const nodes = $textNodes();
+ if (nodes.length === 0) {
+ return;
+ }
+ const node = pick(nodes);
+ const offset = Math.floor(
+ random() * (node.getTextContentSize() + 1),
+ );
+ if (op < 0.35) {
+ const typed = pick(TYPED);
+ description = `type ${JSON.stringify(typed)} at ${nodes.indexOf(node)}:${offset}`;
+ node.select(offset, offset).insertText(typed);
+ } else if (op < 0.6) {
+ const backward = random() < 0.5;
+ description = `${backward ? 'backspace' : 'delete'} at ${nodes.indexOf(node)}:${offset}`;
+ node.select(offset, offset).deleteCharacter(backward);
+ } else if (op < 0.8) {
+ // Join a block onto the one before it (backspace at its
+ // start)
+ const blocks = $blocks();
+ const block = pick(blocks);
+ description = `join block ${blocks.indexOf(block)}`;
+ const first = block.getFirstDescendant();
+ if ($isTextNode(first)) {
+ first.select(0, 0).deleteCharacter(true);
+ }
+ } else {
+ // Delete a range across nodes
+ const other = pick(nodes);
+ const [a, b] =
+ nodes.indexOf(node) <= nodes.indexOf(other)
+ ? [node, other]
+ : [other, node];
+ const selection = $createRangeSelection();
+ const from = Math.min(offset, a.getTextContentSize());
+ const to = Math.floor(
+ random() * (b.getTextContentSize() + 1),
+ );
+ selection.anchor.set(a.getKey(), from, 'text');
+ selection.focus.set(
+ b.getKey(),
+ a === b ? Math.max(from, to) : to,
+ 'text',
+ );
+ description = `remove ${nodes.indexOf(a)}:${from}-${nodes.indexOf(b)}:${to}`;
+ $setSelection(selection);
+ selection.removeText();
+ }
+ $setSelection(null);
+ },
+ {discrete: true},
+ );
+ const after = read(editor);
+ const entitiesBefore = before.filter(n => n.entity).length;
+ const entitiesAfter = after.filter(n => n.entity).length;
+ if (entitiesAfter < entitiesBefore) {
+ demoted++;
+ }
+ check(
+ `seed ${seed} edit ${edit} ${description}`,
+ before,
+ after,
+ failures,
+ );
+ }
+ } catch (e) {
+ failures.push(`seed ${seed}: ${String(e)}`);
+ }
+ }
+ expect(failures).toEqual([]);
+ // The property was exercised
+ expect(demoted).toBeGreaterThan(SEEDS / 10);
+ }, 3_600_000);
+ }
+});
diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts
new file mode 100644
index 00000000000..5db7ec67ccc
--- /dev/null
+++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts
@@ -0,0 +1,1424 @@
+/**
+ * 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.
+ *
+ */
+// @vitest-environment node
+
+// registerLexicalTextEntity's transforms must settle wherever an edit leaves
+// text between text nodes of other formats (#9637), and settle on the same
+// entities as a fresh parse of the text they leave.
+
+import {
+ $createHashtagNode,
+ HashtagExtension,
+ HashtagNode,
+} from '@lexical/hashtag';
+import {
+ $create,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $isTextNode,
+ $setCompositionKey,
+ $setSelection,
+ createEditor,
+ type ElementNode,
+ type LexicalEditor,
+ type RangeSelection,
+ type TextFormatType,
+ TextNode,
+} from 'lexical';
+import {describe, expect, test} from 'vitest';
+
+import {type EntityMatch, registerLexicalTextEntity} from '../..';
+
+class KeywordNode extends TextNode {
+ $config() {
+ return this.config('test-keyword', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ canInsertTextBefore(): boolean {
+ return false;
+ }
+ canInsertTextAfter(): boolean {
+ return false;
+ }
+}
+
+class TagNode extends TextNode {
+ $config() {
+ return this.config('test-tag', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ canInsertTextBefore(): boolean {
+ return false;
+ }
+}
+
+/** The issue's matcher: `cat` or `dog` with no letter next to them. */
+function keywordMatch(text: string): null | EntityMatch {
+ const m = /(^|[^a-z#])(cat|dog)(?=[^a-z]|$)/.exec(text);
+ return m === null
+ ? null
+ : {end: m.index + m[1].length + m[2].length, start: m.index + m[1].length};
+}
+
+/** A hashtag-like matcher: `#` and letters, after the start or a space. */
+function tagMatch(text: string): null | EntityMatch {
+ const m = /(^|\s)(#[a-z]+)/.exec(text);
+ return m === null
+ ? null
+ : {end: m.index + m[1].length + m[2].length, start: m.index + m[1].length};
+}
+
+/**
+ * `dog` with no letter before or after it, where the letter before is looked
+ * at but not taken into the match, like a regex lookbehind
+ * (`/(?
+ c !== undefined && /[a-z]/i.test(c);
+ for (let i = text.indexOf(word); i !== -1; i = text.indexOf(word, i + 1)) {
+ if (!isLetter(text[i - 1]) && !isLetter(text[i + word.length])) {
+ return {end: i + word.length, start: i};
+ }
+ }
+ return null;
+}
+
+type Matcher = 'keyword' | 'tag' | 'lookbehind';
+
+const MATCHERS: Record null | EntityMatch> = {
+ keyword: keywordMatch,
+ lookbehind: lookbehindMatch,
+ tag: tagMatch,
+};
+
+function makeEditor(
+ matcher: Matcher = 'keyword',
+ getMatch?: (text: string) => null | EntityMatch,
+): LexicalEditor {
+ const editor = createEditor({
+ nodes: [KeywordNode, TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ if (matcher === 'tag') {
+ registerLexicalTextEntity(
+ editor,
+ getMatch ?? MATCHERS[matcher],
+ TagNode,
+ node => $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ } else {
+ registerLexicalTextEntity(editor, MATCHERS[matcher], KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ }
+ return editor;
+}
+
+/** A run of text: `*` bold, `_` italic, `` ` `` code, then the text. */
+type Run = string;
+
+function parseRun(run: Run): {formats: TextFormatType[]; text: string} {
+ const formats: TextFormatType[] = [];
+ let i = 0;
+ for (; i < run.length; i++) {
+ const c = run[i];
+ if (c === '*') {
+ formats.push('bold');
+ } else if (c === '_') {
+ formats.push('italic');
+ } else if (c === '`') {
+ formats.push('code');
+ } else {
+ break;
+ }
+ }
+ return {formats, text: run.slice(i)};
+}
+
+function $createRun(run: Run): TextNode {
+ const {formats, text} = parseRun(run);
+ const node = $createTextNode(text);
+ for (const format of formats) {
+ node.toggleFormat(format);
+ }
+ return node;
+}
+
+function $paragraph(): ElementNode {
+ return $getRoot().getFirstChildOrThrow() as ElementNode;
+}
+
+type Out = [type: string, text: string, format: number][];
+
+function $read(): Out {
+ return $paragraph()
+ .getChildren()
+ .filter($isTextNode)
+ .map(node => [node.getType(), node.getTextContent(), node.getFormat()]);
+}
+
+function read(editor: LexicalEditor): Out {
+ return editor.getEditorState().read($read);
+}
+
+function setUp(editor: LexicalEditor, runs: Run[]): void {
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append($createParagraphNode().append(...runs.map($createRun)));
+ },
+ {discrete: true},
+ );
+}
+
+function $select(node: TextNode, offset: number): RangeSelection {
+ const sel = $createRangeSelection();
+ sel.anchor.set(node.getKey(), offset, 'text');
+ sel.focus.set(node.getKey(), offset, 'text');
+ $setSelection(sel);
+ return sel;
+}
+
+/** Type into run `index` at `offset` of a paragraph made of `runs`. */
+function typeInto(
+ runs: Run[],
+ index: number,
+ offset: number,
+ typed: string,
+ matcher: Matcher = 'keyword',
+): LexicalEditor {
+ const editor = makeEditor(matcher);
+ setUp(editor, runs);
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(index) as TextNode;
+ $select(node, offset).insertText(typed);
+ },
+ {discrete: true},
+ );
+ return editor;
+}
+
+/**
+ * The text of a paragraph as runs of one format each, adjacent runs of the
+ * same format joined: what a fresh parse starts from.
+ */
+function toRuns(out: Out): [text: string, format: number][] {
+ const runs: [string, number][] = [];
+ for (const [, text, format] of out) {
+ const last = runs[runs.length - 1];
+ if (last !== undefined && last[1] === format) {
+ last[0] += text;
+ } else {
+ runs.push([text, format]);
+ }
+ }
+ return runs;
+}
+
+/** The nodes the transforms settle on when they parse `runs` from scratch. */
+function freshParse(
+ runs: [text: string, format: number][],
+ matcher: Matcher,
+ getMatch?: (text: string) => null | EntityMatch,
+): Out {
+ const editor = makeEditor(matcher, getMatch);
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append(
+ ...runs.map(([text, format]) =>
+ $createTextNode(text).setFormat(format),
+ ),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ return read(editor);
+}
+
+function expectFreshParse(editor: LexicalEditor, matcher: Matcher): void {
+ const out = read(editor);
+ expect(out).toEqual(freshParse(toRuns(out), matcher));
+}
+
+const B = 1; // IS_BOLD
+
+describe('registerLexicalTextEntity between text nodes of other formats (#9637)', () => {
+ test('bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity', () => {
+ const editor = typeInto(['*x', 'dogcat', '*y'], 1, 3, ' ');
+ expect(read(editor)).toEqual([
+ ['text', 'x', B],
+ ['text', 'dog cat', 0],
+ ['text', 'y', B],
+ ]);
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity', () => {
+ const editor = typeInto(['*x', 'dogcat', '*dog'], 1, 3, '😀');
+ expect(read(editor)).toEqual([
+ ['text', 'x', B],
+ ['text', 'dog😀cat', 0],
+ ['text', 'dog', B],
+ ]);
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('a later match in the same node is still an entity, and one touching the next node is not', () => {
+ const editor = typeInto(['*x', 'dogcat dog', '*y'], 1, 3, ' ');
+ expect(read(editor)).toEqual([
+ ['text', 'x', B],
+ ['text', 'dog ', 0],
+ ['test-keyword', 'cat', 0],
+ ['text', ' dog', 0],
+ ['text', 'y', B],
+ ]);
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('control: "dogcat" | bold "y", nothing before', () => {
+ const editor = typeInto(['dogcat', '*y'], 0, 3, ' ');
+ expect(read(editor)).toEqual([
+ ['test-keyword', 'dog', 0],
+ ['text', ' cat', 0],
+ ['text', 'y', B],
+ ]);
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('control: bold " " | "dogcat" | bold "y", a space before', () => {
+ const editor = typeInto(['* ', 'dogcat', '*y'], 1, 3, ' ');
+ expect(read(editor)).toEqual([
+ ['text', ' ', B],
+ ['test-keyword', 'dog', 0],
+ ['text', ' cat', 0],
+ ['text', 'y', B],
+ ]);
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('a match that needs a lookbehind is checked against the previous node', () => {
+ const touching = typeInto(['*x', 'dogcat', '*y'], 1, 3, ' ', 'lookbehind');
+ expect(read(touching)).toEqual([
+ ['text', 'x', B],
+ ['text', 'dog cat', 0],
+ ['text', 'y', B],
+ ]);
+ const spaced = typeInto(['*x ', 'dogcat', '*y'], 1, 3, ' ', 'lookbehind');
+ expect(read(spaced)).toEqual([
+ ['text', 'x ', B],
+ ['test-keyword', 'dog', 0],
+ ['text', ' cat', 0],
+ ['text', 'y', B],
+ ]);
+ });
+
+ test('a hashtag after a formatted letter is not an entity, after a formatted space it is', () => {
+ const letter = typeInto(['*a', '#tagx', '*.'], 1, 4, ' ', 'tag');
+ expect(read(letter)).toEqual([
+ ['text', 'a', B],
+ ['text', '#tag x', 0],
+ ['text', '.', B],
+ ]);
+ expectFreshParse(letter, 'tag');
+ const space = typeInto(['*a ', '#tagx', '*.'], 1, 4, ' ', 'tag');
+ expect(read(space)).toEqual([
+ ['text', 'a ', B],
+ ['test-tag', '#tag', 0],
+ ['text', ' x', 0],
+ ['text', '.', B],
+ ]);
+ expectFreshParse(space, 'tag');
+ });
+
+ test('of two matches next to each other, the second stays text, as before', () => {
+ const editor = typeInto(['*x ', 'a#b', '*.'], 1, 0, '#', 'tag');
+ expect(read(editor)).toEqual([
+ ['text', 'x ', B],
+ ['test-tag', '#a', 0],
+ ['text', '#b', 0],
+ ['text', '.', B],
+ ]);
+ expectFreshParse(editor, 'tag');
+ });
+
+ test('entity at the edges of a node, every format combination around it', () => {
+ const formats = ['*', '_', '`', '*_'];
+ for (const before of formats) {
+ for (const after of formats) {
+ for (const [left, right] of [
+ ['x', 'y'],
+ [' ', ' '],
+ ['x', ' '],
+ [' ', 'y'],
+ ]) {
+ for (const typed of [' ', '😀', '!', 'z']) {
+ const runs = [before + left, 'dogcat', after + right];
+ let editor: LexicalEditor | undefined;
+ expect(() => {
+ editor = typeInto(runs, 1, 3, typed);
+ }, JSON.stringify({runs, typed})).not.toThrow();
+ const out = read(editor!);
+ expect(out.map(([, text]) => text).join('')).toBe(
+ left + 'dog' + typed + 'cat' + right,
+ );
+ expectFreshParse(editor!, 'keyword');
+ }
+ }
+ }
+ }
+ });
+
+ test('IME composition between two formatted nodes settles, and so does its end', () => {
+ const editor = makeEditor();
+ setUp(editor, ['*x', 'dogcat', '*y']);
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, 3);
+ $setCompositionKey(node.getKey());
+ },
+ {discrete: true},
+ );
+ for (const typed of ['、', ' ']) {
+ expect(() =>
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, 3).insertText(typed);
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+ }
+ expect(() =>
+ editor.update(
+ () => {
+ $setCompositionKey(null);
+ for (const node of $paragraph().getChildren()) {
+ node.markDirty();
+ }
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+ expect(
+ read(editor)
+ .map(([, text]) => text)
+ .join(''),
+ ).toBe('xdog 、caty');
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('pasting formatted runs around a keyword settles', () => {
+ const editor = makeEditor();
+ setUp(editor, ['dogcat']);
+ expect(() =>
+ editor.update(
+ () => {
+ const node = $paragraph().getFirstChildOrThrow() as TextNode;
+ $select(node, 3).insertNodes([
+ $createRun('*x'),
+ $createRun(' dog '),
+ $createRun('*y'),
+ ]);
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+ expect(
+ read(editor)
+ .map(([, text]) => text)
+ .join(''),
+ ).toBe('dogx dog ycat');
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('bolding the text before an entity turns it back into text', () => {
+ const editor = makeEditor();
+ setUp(editor, ['xdog cat']);
+ editor.update(
+ () => {
+ const node = $paragraph().getFirstChildOrThrow() as TextNode;
+ const [, rest] = node.splitText(1);
+ ($paragraph().getFirstChildOrThrow() as TextNode).toggleFormat('bold');
+ rest.markDirty();
+ },
+ {discrete: true},
+ );
+ expectFreshParse(editor, 'keyword');
+ });
+
+ test('typing a letter before an entity after text in another format turns it back into text', () => {
+ const editor = makeEditor();
+ setUp(editor, ['_😀', 'dog', '*!']);
+ expect(read(editor)).toEqual([
+ ['text', '😀', 2],
+ ['test-keyword', 'dog', 0],
+ ['text', '!', B],
+ ]);
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, 0).insertText('a');
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', '😀', 2],
+ ['text', 'adog', 0],
+ ['text', '!', B],
+ ]);
+ });
+
+ test('an earlier match that ends where the text ends is handled as before', () => {
+ // ` dog` at the end of the text, or else `dog` anywhere.
+ const phrase = (text: string): null | EntityMatch => {
+ const whole = /[a-z]+ dog$/.exec(text);
+ const word = keywordMatch(text);
+ if (whole !== null && (word === null || whole.index <= word.start)) {
+ return {end: whole.index + whole[0].length, start: whole.index};
+ }
+ return word;
+ };
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, phrase, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['*z', 'ab dog!']);
+ expect(read(editor)).toEqual([
+ ['text', 'z', B],
+ ['text', 'ab ', 0],
+ ['test-keyword', 'dog', 0],
+ ['text', '!', 0],
+ ]);
+ });
+
+ test('a matcher that needs the text after a match settles as before', () => {
+ // `dog` when " cat" follows it, or when it is all of the text.
+ const followed = (text: string): null | EntityMatch => {
+ const m = /^dog$|dog(?= cat)/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, followed, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['*x', '_a dog cat']);
+ const I = 2; // IS_ITALIC
+ expect(read(editor)).toEqual([
+ ['text', 'x', B],
+ ['text', 'a ', I],
+ ['test-keyword', 'dog', I],
+ ['text', ' cat', I],
+ ]);
+ });
+
+ test('a match at the start of a node that needs the text after it settles after a formatted node', () => {
+ // A hashtag when a space follows it, or when it is all of the text.
+ const spaced = (text: string): null | EntityMatch => {
+ const m = /^#[a-z]+$|#[a-z]+(?= )/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const make = () => {
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, spaced, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ return editor;
+ };
+ const typed = make();
+ setUp(typed, ['*x', '#tagx']);
+ typed.update(
+ () => {
+ $select($paragraph().getChildAtIndex(1) as TextNode, 4).insertText(' ');
+ },
+ {discrete: true},
+ );
+ expect(read(typed)).toEqual([
+ ['text', 'x', B],
+ ['test-tag', '#tag', 0],
+ ['text', ' x', 0],
+ ]);
+
+ // Removing the space after the entity turns it back into text at once,
+ // as a fresh parse of the text does.
+ const removed = make();
+ setUp(removed, ['*g', 'g#x ']);
+ expect(read(removed)).toEqual([
+ ['text', 'g', B],
+ ['text', 'g', 0],
+ ['test-tag', '#x', 0],
+ ['text', ' ', 0],
+ ]);
+ removed.update(
+ () => {
+ $paragraph().getLastChildOrThrow().remove();
+ },
+ {discrete: true},
+ );
+ expect(read(removed)).toEqual([
+ ['text', 'g', B],
+ ['text', 'g#x', 0],
+ ]);
+ });
+
+ test('typing before a formatted node with a token or segmented match leaves it as it was', () => {
+ for (const mode of ['token', 'segmented'] as const) {
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, tagMatch, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()).setMode(mode),
+ );
+ setUp(editor, ['#', '*#b ']);
+ expect(() =>
+ editor.update(
+ () => {
+ $select($paragraph().getChildAtIndex(0) as TextNode, 1).insertText(
+ ' ',
+ );
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+ expect(read(editor)).toEqual([
+ ['text', '# ', 0],
+ ['text', '#b ', B],
+ ]);
+ }
+ });
+
+ test('a node after an entity in the same format and style but another detail is not its source', () => {
+ const spaced = (text: string): null | EntityMatch => {
+ const m = /^#[a-z]+$|#[a-z]+(?= )/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, spaced, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('#x'),
+ $createTextNode(' ').setDetail(2), // IS_UNMERGEABLE
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ expect(read(editor)[0]).toEqual(['test-tag', '#x', 0]);
+ editor.update(
+ () => {
+ $select($paragraph().getChildAtIndex(0) as TextNode, 0).insertText('g');
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'g#x', 0],
+ ['text', ' ', 0],
+ ]);
+ });
+
+ test('an entity after a node with an earlier match is checked too', () => {
+ const spaced = (text: string): null | EntityMatch => {
+ const m = /^#[a-z]+$|#[a-z]+(?= )/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, spaced, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['*x', '_z ', '#b ']);
+ expect(read(editor)[2]).toEqual(['test-tag', '#b', 0]);
+ editor.update(
+ () => {
+ ($paragraph().getChildAtIndex(1) as TextNode).setTextContent('#a ');
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ $paragraph().getLastChildOrThrow().remove();
+ },
+ {discrete: true},
+ );
+ const out = read(editor);
+ expect(out).toEqual(freshParse(toRuns(out), 'tag', spaced));
+ });
+
+ test('removing the text between an entity and a token after it turns the entity back into text', () => {
+ const congrats = (text: string): null | EntityMatch => {
+ const m = /(^|[^a-z])(congrats)(?=[^a-z]|$)/i.exec(text);
+ return m === null
+ ? null
+ : {end: m.index + m[1].length + 8, start: m.index + m[1].length};
+ };
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, congrats, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['_!', '*!congrats ']);
+ editor.update(
+ () => {
+ $paragraph().append($createRun('*Alice').setMode('token'));
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', '!', 2],
+ ['text', '!', B],
+ ['test-keyword', 'congrats', B],
+ ['text', ' ', B],
+ ['text', 'Alice', B],
+ ]);
+ editor.update(
+ () => {
+ $paragraph().getChildAtIndex(3)!.remove();
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', '!', 2],
+ ['text', '!congrats', B],
+ ['text', 'Alice', B],
+ ]);
+ });
+
+ test('an entity is not kept by text after it in another format', () => {
+ const spaced = (text: string): null | EntityMatch => {
+ const m = /^#[a-z]+$|#[a-z]+(?= )/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, spaced, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['#x', '* ']);
+ expect(read(editor)).toEqual([
+ ['test-tag', '#x', 0],
+ ['text', ' ', B],
+ ]);
+ editor.update(
+ () => {
+ $select($paragraph().getChildAtIndex(0) as TextNode, 0).insertText('g');
+ },
+ {discrete: true},
+ );
+ // As a fresh parse of "g#x" and a bold " " makes it.
+ expect(read(editor)).toEqual([
+ ['text', 'g#x', 0],
+ ['text', ' ', B],
+ ]);
+ });
+
+ test('an entity is checked again in each update', () => {
+ const spaced = (text: string): null | EntityMatch => {
+ const m = /^#[a-z]+$|#[a-z]+(?= )/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, spaced, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()),
+ );
+ setUp(editor, ['*#ab#ab ', '_a']);
+ expect(read(editor)).toEqual([
+ ['text', '#ab', B],
+ ['test-tag', '#ab', B],
+ ['text', ' ', B],
+ ['text', 'a', 2],
+ ]);
+ editor.update(
+ () => {
+ $select($paragraph().getChildAtIndex(1) as TextNode, 2).insertText('x');
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ ($paragraph().getChildAtIndex(1) as TextNode).toggleFormat('bold');
+ },
+ {discrete: true},
+ );
+ const out = read(editor);
+ expect(out).toEqual(freshParse(toRuns(out), 'tag', spaced));
+ });
+
+ test('typing in text before a formatted hashtag marks only that text dirty, so history merges the keystrokes', () => {
+ const editor = makeEditor('tag');
+ setUp(editor, ['*a', '#foo ', '_#bar']);
+ const dirty: number[] = [];
+ editor.registerUpdateListener(({dirtyLeaves}) => {
+ dirty.push(dirtyLeaves.size);
+ });
+ for (const c of 'xyz') {
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, node.getTextContentSize() - 2).insertText(c);
+ },
+ {discrete: true},
+ );
+ }
+ expect(dirty).toEqual([1, 1, 1]);
+ });
+
+ test('typing in text with a match before a formatted hashtag marks only that text dirty', () => {
+ // With @lexical/hashtag's own matcher.
+ const editor = createEditor({
+ nodes: [HashtagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ const config = HashtagExtension.config;
+ if (config === undefined) {
+ throw new Error('HashtagExtension has a config');
+ }
+ registerLexicalTextEntity(
+ editor,
+ config.getHashtagMatch,
+ HashtagNode,
+ node => $createHashtagNode(node.getTextContent()),
+ );
+ setUp(editor, ['*#x', '#y!ab#', '_#z']);
+ expect(read(editor)[0]).toEqual(['hashtag', '#x', B]);
+ const dirty: number[] = [];
+ editor.registerUpdateListener(({dirtyLeaves}) => {
+ dirty.push(dirtyLeaves.size);
+ });
+ for (const c of 'cde') {
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, node.getTextContentSize() - 2).insertText(c);
+ },
+ {discrete: true},
+ );
+ }
+ expect(dirty).toEqual([1, 1, 1]);
+ });
+
+ test('typing that makes a match at the start of the next node one leaves it text until that node changes', () => {
+ const editor = makeEditor('tag');
+ setUp(editor, ['a', '*b', '#foo #bar']);
+ expect(read(editor)).toEqual([
+ ['text', 'a', 0],
+ ['text', 'b', B],
+ ['text', '#foo ', 0],
+ ['test-tag', '#bar', 0],
+ ]);
+ editor.update(
+ () => {
+ ($paragraph().getChildAtIndex(1) as TextNode).spliceText(
+ 1,
+ 0,
+ ' ',
+ false,
+ );
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'a', 0],
+ ['text', 'b ', B],
+ ['text', '#foo ', 0],
+ ['test-tag', '#bar', 0],
+ ]);
+ editor.update(
+ () => {
+ $paragraph().getChildAtIndex(2)!.markDirty();
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', 'a', 0],
+ ['text', 'b ', B],
+ ['test-tag', '#foo', 0],
+ ['text', ' ', 0],
+ ['test-tag', '#bar', 0],
+ ]);
+ });
+
+ test('a token or segmented entity with text after it in its node settles', () => {
+ for (const mode of ['token', 'segmented'] as const) {
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, tagMatch, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()).setMode(mode),
+ );
+ setUp(editor, ['#a b']);
+ expect(read(editor)).toEqual([
+ ['test-tag', '#a', 0],
+ ['text', ' b', 0],
+ ]);
+ editor.update(
+ () => {
+ $select($paragraph().getLastChild() as TextNode, 2).insertText('c');
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['test-tag', '#a', 0],
+ ['text', ' bc', 0],
+ ]);
+ }
+ });
+
+ test('a token or segmented entity after a match left as text settles', () => {
+ for (const mode of ['token', 'segmented'] as const) {
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, tagMatch, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()).setMode(mode),
+ );
+ setUp(editor, [' DOG x DOG', '*_#ab ##ab']);
+ expect(() =>
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ const selection = $createRangeSelection();
+ selection.anchor.set(node.getKey(), 5, 'text');
+ selection.focus.set(node.getKey(), 7, 'text');
+ $setSelection(selection);
+ selection.insertText('x');
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+ // `#x` is turned back into text twice, then kept, and takes the `b` its
+ // match extends into (3: bold and italic).
+ expect(read(editor)).toEqual([
+ ['text', ' DOG x DOG', 0],
+ ['text', '#ab ', 3],
+ ['test-tag', '#xb', 3],
+ ]);
+ }
+ });
+
+ test('an update that fails does not count toward the next one', () => {
+ const editor = createEditor({
+ nodes: [TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, tagMatch, TagNode, node =>
+ $create(TagNode).setTextContent(node.getTextContent()).setMode('token'),
+ );
+ let failures = 2;
+ editor.registerNodeTransform(TextNode, node => {
+ if (failures > 0 && node.getTextContent() === '#foo x') {
+ failures--;
+ throw new Error('fail this update');
+ }
+ });
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append(
+ $createTextNode('a').setFormat('bold'),
+ $create(TagNode).setTextContent('#foo').setMode('token'),
+ $createTextNode(' x'),
+ ),
+ );
+ },
+ {discrete: true, skipTransforms: true},
+ );
+ for (let i = 0; i < 3; i++) {
+ try {
+ editor.update(
+ () => {
+ $paragraph().getLastChildOrThrow().markDirty();
+ },
+ {discrete: true},
+ );
+ } catch {
+ // The first two updates fail and are discarded.
+ }
+ }
+ expect(failures).toBe(0);
+ expect(read(editor)).toEqual([
+ ['text', 'a', B],
+ ['text', '#foo x', 0],
+ ]);
+ });
+
+ test('two registrations, one making token or segmented entities, settle', () => {
+ const upperMatch = (text: string): null | EntityMatch => {
+ const m = /[A-Z]+/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const caseKeywordMatch = (text: string) => keywordMatch(text.toLowerCase());
+ for (const mode of ['token', 'segmented'] as const) {
+ for (const tokenFirst of [true, false]) {
+ const editor = createEditor({
+ nodes: [KeywordNode, TagNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ const registerToken = () =>
+ registerLexicalTextEntity(editor, upperMatch, TagNode, node =>
+ $create(TagNode)
+ .setTextContent(node.getTextContent())
+ .setMode(mode),
+ );
+ if (tokenFirst) {
+ registerToken();
+ }
+ registerLexicalTextEntity(editor, caseKeywordMatch, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ if (!tokenFirst) {
+ registerToken();
+ }
+ setUp(editor, ['catDOGg']);
+ expect(read(editor)).toEqual([
+ ['text', 'cat', 0],
+ ['test-tag', 'DOG', 0],
+ ['text', 'g', 0],
+ ]);
+ setUp(editor, ['xa', '*d', 'DOG!dog']);
+ const settled: [string, string, number][] = [
+ ['text', 'd', B],
+ ['test-tag', 'DOG', 0],
+ ...((tokenFirst
+ ? [['text', '!dog', 0]]
+ : [
+ ['text', '!', 0],
+ ['test-keyword', 'dog', 0],
+ ]) as [string, string, number][]),
+ ];
+ expect(read(editor)).toEqual([['text', 'xa', 0], ...settled]);
+ editor.update(
+ () => {
+ $select($paragraph().getChildAtIndex(0) as TextNode, 2).insertText(
+ 'x',
+ );
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([['text', 'xax', 0], ...settled]);
+ }
+ }
+ });
+
+ test('a createNode that adds text or changes its case settles', () => {
+ const anywhere = (text: string): null | EntityMatch => {
+ const m = /dog|cat/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const settle = (runs: Run[], createText: (text: string) => string): Out => {
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(editor, anywhere, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(createText(node.getTextContent())),
+ );
+ setUp(editor, runs);
+ return read(editor);
+ };
+ const I = 2; // IS_ITALIC
+ expect(settle(['*z', 'x', '_dog', '*!'], text => text + ' ')).toEqual([
+ ['text', 'z', B],
+ ['text', 'x', 0],
+ ['test-keyword', 'dog', I],
+ ['text', ' ', I],
+ ['text', '!', B],
+ ]);
+ // `dog` before "cat", or `DOG`: the entity `createNode` makes is a
+ // match after the bold "x" even though the text it replaces is one only
+ // with the "cat" after it.
+ const before = (text: string): null | EntityMatch => {
+ const m = /dog(?=cat)|DOG/.exec(text);
+ return m === null ? null : {end: m.index + m[0].length, start: m.index};
+ };
+ const upper = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(upper, before, KeywordNode, node =>
+ $create(KeywordNode).setTextContent(node.getTextContent().toUpperCase()),
+ );
+ setUp(upper, ['*x', 'dogcat', '*y']);
+ expect(read(upper)).toEqual([
+ ['text', 'x', B],
+ ['test-keyword', 'DOG', 0],
+ ['text', 'cat', 0],
+ ['text', 'y', B],
+ ]);
+ // The `DOG` made from "xdog" is no entity after the bold "z" either, so
+ // it is turned back into text before it grows over the italic `DOG`
+ // (before, it grew over it first, and that text lost its italic).
+ expect(
+ settle(['*z', 'xdog', '_dog cat', '*!'], text => text.toUpperCase()),
+ ).toEqual([
+ ['text', 'z', B],
+ ['text', 'xDOG', 0],
+ ['text', 'DOG CAT', I],
+ ['text', '!', B],
+ ]);
+ });
+
+ test('typing in a long text node before an entity calls getMatch a few times, however long it is', () => {
+ for (const repeat of [2000, 8000]) {
+ let calls = 0;
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(
+ editor,
+ text => {
+ calls++;
+ return keywordMatch(text.toLowerCase());
+ },
+ KeywordNode,
+ node => $create(KeywordNode).setTextContent(node.getTextContent()),
+ );
+ // "do" + "g" is a match that starts in the bold node, so the long node
+ // is left as text, and the entity after it stays one.
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createRun('*do'),
+ $createTextNode('g' + ' cat'.repeat(repeat) + ' '),
+ $create(KeywordNode).setTextContent('dog'),
+ ),
+ );
+ },
+ {discrete: true},
+ );
+ calls = 0;
+ editor.update(
+ () => {
+ ($paragraph().getChildAtIndex(1) as TextNode).spliceText(
+ 3,
+ 0,
+ 'x',
+ false,
+ );
+ },
+ {discrete: true},
+ );
+ expect(calls).toBeLessThanOrEqual(10);
+ expect(read(editor)[2]).toEqual(['test-keyword', 'dog', 0]);
+ }
+ });
+
+ test('a createNode that changes the text still makes an entity, as before', () => {
+ const editor = createEditor({
+ nodes: [KeywordNode],
+ onError: e => {
+ throw e;
+ },
+ });
+ registerLexicalTextEntity(
+ editor,
+ text => lookbehindMatch(text, 'DOG') ?? lookbehindMatch(text, 'dog'),
+ KeywordNode,
+ node =>
+ $create(KeywordNode).setTextContent(
+ node.getTextContent().toLowerCase(),
+ ),
+ );
+ setUp(editor, ['* ', 'DOGcat', '*y']);
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(1) as TextNode;
+ $select(node, 3).insertText(' ');
+ },
+ {discrete: true},
+ );
+ expect(read(editor)).toEqual([
+ ['text', ' ', B],
+ ['test-keyword', 'dog', 0],
+ ['text', ' cat', 0],
+ ['text', 'y', B],
+ ]);
+ });
+});
+
+/** Mulberry32, so a failure names a seed that reproduces it. */
+function rng(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function $markAll(): void {
+ for (const node of $paragraph().getChildren()) {
+ node.markDirty();
+ }
+}
+
+/** Runs `fn`, and returns the error it throws as a message, or null. */
+function thrown(fn: () => void): null | string {
+ try {
+ fn();
+ return null;
+ } catch (e) {
+ return String(e).slice(0, 80);
+ }
+}
+
+const SEEDS = Number(process.env.TEXT_ENTITY_SETTLE_SEEDS ?? 2000);
+const MATCHER_NAMES: Matcher[] = ['keyword', 'tag', 'lookbehind'];
+
+describe('registerLexicalTextEntity on random layouts', () => {
+ const PIECES: Record = {
+ keyword: ['dog', 'cat', ' ', 'x', 'a', '#', '😀', '!', 'do', 'g'],
+ lookbehind: ['dog', ' ', 'x', 'd', 'og', '😀', '!'],
+ tag: ['#', '#a', '#ab', 'a', 'b', ' ', '😀', '!', 'x'],
+ };
+ const TYPED = [' ', '😀', 'a', 'x', '#', 'g', 'd', '!', '\u00a0'];
+ const FORMATS = ['', '*', '_', '*_', '`'];
+
+ function randomRuns(random: () => number, matcher: Matcher): Run[] {
+ const pick = (xs: T[]): T => xs[Math.floor(random() * xs.length)];
+ const runs: Run[] = [];
+ let lastFormat: null | string = null;
+ for (let i = 2 + Math.floor(random() * 3); i > 0; i--) {
+ let format = pick(FORMATS);
+ if (format === lastFormat) {
+ format = format === '' ? '*' : '';
+ }
+ lastFormat = format;
+ let text = '';
+ for (let j = 1 + Math.floor(random() * 4); j > 0; j--) {
+ text += pick(PIECES[matcher]);
+ }
+ runs.push(format + text);
+ }
+ return runs;
+ }
+
+ for (const matcher of MATCHER_NAMES) {
+ // A fresh parse settles, and transforming every node again leaves it as
+ // it is.
+ test(`${matcher}: ${SEEDS} random layouts parse, and parse again the same`, () => {
+ const failures: string[] = [];
+ for (let seed = 0; seed < SEEDS && failures.length < 5; seed++) {
+ const random = rng(seed * 7 + MATCHER_NAMES.indexOf(matcher));
+ const runs = randomRuns(random, matcher);
+ const editor = makeEditor(matcher);
+ const label = `seed ${seed} ${JSON.stringify(runs)}`;
+ const error = thrown(() => setUp(editor, runs));
+ if (error !== null) {
+ failures.push(`${label}: ${error}`);
+ continue;
+ }
+ const parsed = read(editor);
+ const again = thrown(() => editor.update($markAll, {discrete: true}));
+ if (again !== null) {
+ failures.push(`${label} again: ${again}`);
+ } else if (JSON.stringify(read(editor)) !== JSON.stringify(parsed)) {
+ failures.push(`${label} again: ${JSON.stringify(read(editor))}`);
+ }
+ }
+ expect(failures).toEqual([]);
+ });
+
+ // #9637's shape: a node with random text between two nodes of other
+ // formats whose text has no match of its own, and starts none with it
+ // (a hashtag's `#` would). Typing or deleting anywhere in it settles on
+ // the entities a fresh parse of the result finds.
+ test(`${matcher}: ${SEEDS} random edits between two formatted nodes settle on a fresh parse`, () => {
+ const NEIGHBOURS = ['x', 'y', ' ', '!', '😀', 'a', 'xa', ' !'].concat(
+ matcher === 'tag' ? [] : ['#'],
+ );
+ const failures: string[] = [];
+ for (let seed = 0; seed < SEEDS && failures.length < 5; seed++) {
+ const random = rng(seed * 7 + 3 + MATCHER_NAMES.indexOf(matcher));
+ const pick = (xs: T[]): T => xs[Math.floor(random() * xs.length)];
+ const [middle] = randomRuns(random, matcher);
+ const runs = [
+ pick(FORMATS.slice(1)) + pick(NEIGHBOURS),
+ parseRun(middle).text,
+ pick(FORMATS.slice(1)) + pick(NEIGHBOURS),
+ ];
+ const editor = makeEditor(matcher);
+ const label0 = `seed ${seed} ${JSON.stringify(runs)}`;
+ const setUpError = thrown(() => setUp(editor, runs));
+ if (setUpError !== null) {
+ failures.push(`${label0}: ${setUpError}`);
+ continue;
+ }
+ const nodes = read(editor);
+ // An edit in one of the nodes the middle text became.
+ const index = 1 + Math.floor(random() * (nodes.length - 2));
+ const size = nodes[index][1].length;
+ const typing = size === 0 || random() < 0.7;
+ const offset = Math.floor(random() * (typing ? size + 1 : size));
+ const typed = pick(TYPED);
+ const label = `${label0} ${JSON.stringify(nodes)} ${
+ typing ? `type ${JSON.stringify(typed)}` : 'delete'
+ } at ${index}:${offset}`;
+ const error = thrown(() =>
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(index) as TextNode;
+ if (typing) {
+ $select(node, offset).insertText(typed);
+ } else {
+ node.spliceText(offset, 1, '', false);
+ }
+ },
+ {discrete: true},
+ ),
+ );
+ if (error !== null) {
+ failures.push(`${label}: ${error}`);
+ continue;
+ }
+ const out = read(editor);
+ const fresh = freshParse(toRuns(out), matcher);
+ if (JSON.stringify(out) !== JSON.stringify(fresh)) {
+ failures.push(
+ `${label}: ${JSON.stringify(out)} != ${JSON.stringify(fresh)}`,
+ );
+ }
+ }
+ expect(failures).toEqual([]);
+ });
+
+ // Any edit (typing or deleting anywhere, or bolding a node) settles, and
+ // so does transforming every node again.
+ test(`${matcher}: ${SEEDS} random sessions of 4 edits anywhere settle`, () => {
+ const failures: string[] = [];
+ for (let seed = 0; seed < SEEDS && failures.length < 5; seed++) {
+ const random = rng(seed * 7 + 5 + MATCHER_NAMES.indexOf(matcher));
+ const pick = (xs: T[]): T => xs[Math.floor(random() * xs.length)];
+ const runs = randomRuns(random, matcher);
+ const editor = makeEditor(matcher);
+ if (thrown(() => setUp(editor, runs)) !== null) {
+ continue;
+ }
+ for (let edit = 0; edit < 4; edit++) {
+ const nodes = read(editor);
+ if (nodes.length === 0) {
+ break;
+ }
+ const index = Math.floor(random() * nodes.length);
+ const size = nodes[index][1].length;
+ const op = random();
+ const offset = Math.floor(random() * (size + 1));
+ const typed = pick(TYPED);
+ const label = `seed ${seed} edit ${edit} ${JSON.stringify(nodes)} op ${op.toFixed(2)} at ${index}:${offset} ${JSON.stringify(typed)}`;
+ const error = thrown(() =>
+ editor.update(
+ () => {
+ const node = $paragraph().getChildAtIndex(index) as TextNode;
+ if (op < 0.6) {
+ $select(node, offset).insertText(typed);
+ } else if (op < 0.85) {
+ if (size > 0) {
+ node.spliceText(Math.min(offset, size - 1), 1, '', false);
+ }
+ } else {
+ node.toggleFormat('bold');
+ }
+ },
+ {discrete: true},
+ ),
+ );
+ const again =
+ error ?? thrown(() => editor.update($markAll, {discrete: true}));
+ if (again !== null) {
+ failures.push(`${label}: ${again}`);
+ break;
+ }
+ }
+ }
+ expect(failures).toEqual([]);
+ });
+ }
+});
diff --git a/packages/lexical-text/src/registerLexicalTextEntity.ts b/packages/lexical-text/src/registerLexicalTextEntity.ts
index d45aa6d3119..60fa33a504b 100644
--- a/packages/lexical-text/src/registerLexicalTextEntity.ts
+++ b/packages/lexical-text/src/registerLexicalTextEntity.ts
@@ -9,17 +9,291 @@
import invariant from '@lexical/internal/invariant';
import {
$createTextNode,
+ $getSelection,
+ $getWritableNodeState,
+ $isRangeSelection,
$isTextNode,
+ type EditorState,
INTERNAL_$recordTextMove,
INTERNAL_$recordTextRetype,
+ INTERNAL_getActiveEditorState,
type Klass,
type LexicalEditor,
type LexicalNode,
+ type NodeKey,
TextNode,
} from 'lexical';
export type EntityMatch = {end: number; start: number};
+// How many matches in a text node are passed over to check the node after it,
+// where its transform returns before the loop that checks that node.
+const MAX_MATCHES_PASSED = 8;
+
+// How many times one update turns the same token or segmented entity back into
+// text before it leaves it as an entity.
+const MAX_TURNED_INTO_TEXT = 2;
+
+/**
+ * Give `to`, the text node that takes the place of `from` with the same
+ * characters (an entity made plain text, or plain text made an entity), the
+ * NodeState of `from`: the ad-hoc data an app keeps on text, such as comment
+ * ids or block ids, flat states included, as `splitText` gives it to its
+ * pieces. The properties inherent to the entity type (its class fields and
+ * mode) are not NodeState and stay with the type. A key that `to` already
+ * holds keeps its value (when the text is made an entity: state that
+ * `createNode` gave it).
+ */
+function $carryNodeState(from: TextNode, to: TextNode): void {
+ // @internal access, like `__text`: the NodeState of the node
+ const fromState = from.getLatest().__state;
+ if (fromState === undefined || fromState.size === 0) {
+ return;
+ }
+ const held = new Set();
+ const toState = to.getLatest().__state;
+ if (toState !== undefined) {
+ const [heldUnknown, heldKnown] = toState.getInternalState();
+ for (const key in heldUnknown) {
+ held.add(key);
+ }
+ for (const stateConfig of heldKnown.keys()) {
+ held.add(stateConfig.key);
+ }
+ }
+ const [unknown, known] = fromState.getInternalState();
+ let state: undefined | ReturnType>;
+ const $state = () => state || (state = $getWritableNodeState(to));
+ for (const [stateConfig, value] of known) {
+ const key = stateConfig.key;
+ if (held.has(key)) {
+ continue;
+ }
+ held.add(key);
+ if (stateConfig.isEqual(value, stateConfig.defaultValue)) {
+ continue;
+ }
+ const writable = $state();
+ const own = writable.sharedNodeState.sharedConfigMap.get(key);
+ if (own === undefined || own === stateConfig) {
+ // The same value object (comment ids compare by identity)
+ writable.updateFromKnown(stateConfig, value);
+ } else {
+ // The type of `to` has another config for this key: parse with it
+ writable.updateFromUnknown(key, stateConfig.unparse(value));
+ }
+ }
+ if (unknown !== undefined) {
+ for (const [key, value] of Object.entries(unknown)) {
+ if (!held.has(key)) {
+ $state().updateFromUnknown(key, value);
+ }
+ }
+ }
+}
+
+type AnyNodeState = NonNullable;
+
+/**
+ * The value `state` holds for `stateConfig` (parsed from JSON if it was not
+ * read yet), without caching it, or its default
+ */
+function readStateValue(
+ state: undefined | AnyNodeState,
+ stateConfig: Parameters[0],
+): unknown {
+ if (state === undefined) {
+ return stateConfig.defaultValue;
+ }
+ const [unknown, known] = state.getInternalState();
+ if (known.has(stateConfig)) {
+ return known.get(stateConfig);
+ }
+ if (unknown !== undefined && stateConfig.key in unknown) {
+ return stateConfig.parse(unknown[stateConfig.key]);
+ }
+ return stateConfig.defaultValue;
+}
+
+/**
+ * Whether `a` and `b` hold equivalent NodeState, by the rule that decides
+ * whether two text nodes merge: known values compared with their
+ * `StateConfig.isEqual`, values not read yet compared as they are
+ */
+function haveEquivalentNodeState(a: TextNode, b: TextNode): boolean {
+ // @internal access, like `__text`: the NodeState of the nodes
+ const stateA = a.getLatest().__state;
+ const stateB = b.getLatest().__state;
+ if (stateA === stateB) {
+ return true;
+ }
+ const keys = new Set();
+ const hasUnequalKnown = (
+ source: undefined | AnyNodeState,
+ other: undefined | AnyNodeState,
+ ): boolean => {
+ if (source !== undefined) {
+ for (const [stateConfig, value] of source.getInternalState()[1]) {
+ if (keys.has(stateConfig.key)) {
+ continue;
+ }
+ keys.add(stateConfig.key);
+ const otherValue = readStateValue(other, stateConfig);
+ if (otherValue !== value && !stateConfig.isEqual(otherValue, value)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+ const hasUnequalUnknown = (
+ source: undefined | AnyNodeState,
+ other: undefined | AnyNodeState,
+ ): boolean => {
+ const unknown = source === undefined ? undefined : source.unknownState;
+ const otherUnknown = other === undefined ? undefined : other.unknownState;
+ if (unknown !== undefined) {
+ for (const [key, value] of Object.entries(unknown)) {
+ if (keys.has(key)) {
+ continue;
+ }
+ keys.add(key);
+ if (
+ value !== (otherUnknown === undefined ? undefined : otherUnknown[key])
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+ return !(
+ hasUnequalKnown(stateA, stateB) ||
+ hasUnequalKnown(stateB, stateA) ||
+ hasUnequalUnknown(stateA, stateB) ||
+ hasUnequalUnknown(stateB, stateA)
+ );
+}
+
+/**
+ * Whether `a` and `b` would merge as text: the same format, style, detail and
+ * NodeState
+ */
+function $wouldMerge(a: TextNode, b: TextNode): boolean {
+ return (
+ a.getFormat() === b.getFormat() &&
+ a.getStyle() === b.getStyle() &&
+ a.getDetail() === b.getDetail() &&
+ haveEquivalentNodeState(a, b)
+ );
+}
+
+/**
+ * Before `entity` takes the first `count` characters of `node`, the text node
+ * after it: move a selection point in those characters (or right after them)
+ * into the entity, and one in the rest of `node` back by `count`.
+ */
+function $moveSelectionIntoEntity(
+ node: TextNode,
+ entity: TextNode,
+ count: number,
+): void {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ const key = node.getKey();
+ const entitySize = entity.getTextContentSize();
+ for (const point of [selection.anchor, selection.focus]) {
+ if (point.key === key && point.type === 'text') {
+ if (point.offset <= count) {
+ point.set(entity.getKey(), entitySize + point.offset, 'text');
+ } else {
+ point.set(key, point.offset - count, 'text');
+ }
+ }
+ }
+}
+
+// The keys of the text nodes that `$reverseNodeTransform` split off the end of
+// an entity (and of their pieces), by pending editor state (one update, or the
+// updates batched into one), shared by every registration on the editor.
+// `splitText` gives such a piece a copy of the entity's NodeState, but its text
+// was inserted after the entity (typed or pasted), so that NodeState is not its
+// own, and no entity made from it (of this registration or another) takes it.
+const splitOffInUpdate = new WeakMap>();
+
+// The keys of the parents of the entities turned into text, by pending
+// editor state, shared by every registration on the editor (see
+// `$isInsertedText`).
+const demotedInUpdate = new WeakMap>();
+
+function $demotedParents(editor: LexicalEditor): Set {
+ const editorState =
+ INTERNAL_getActiveEditorState() || editor.getEditorState();
+ let keys = demotedInUpdate.get(editorState);
+ if (keys === undefined) {
+ keys = new Set();
+ demotedInUpdate.set(editorState, keys);
+ }
+ return keys;
+}
+
+/**
+ * Whether the characters [start, end) of `node`, whose text is `text`, were
+ * inserted into it in this update, at its start or its end, with no entity
+ * turned into text in its parent in this update (whose text may have merged
+ * into it): it existed in the committed state, and they are outside the text
+ * it shares with its committed version at its start and its end. Such
+ * characters have a copy of the node's NodeState (as text inserted into a
+ * node does), not NodeState of their own, so an entity made of them does not
+ * take it (`MarkStateExtension` does not add text inserted at a comment's
+ * edge to it, for example).
+ */
+function $isInsertedText(
+ editor: LexicalEditor,
+ node: TextNode,
+ text: string,
+ start: number,
+ end: number,
+): boolean {
+ // @internal access: the node in the committed state
+ const prev = editor.getEditorState()._nodeMap.get(node.getKey());
+ if (!$isTextNode(prev)) {
+ return false;
+ }
+ const parent = node.getParent();
+ if (parent !== null && $demotedParents(editor).has(parent.getKey())) {
+ return false;
+ }
+ // @internal access: the text of the committed version
+ const prevText = prev.__text;
+ const max = Math.min(prevText.length, text.length);
+ let prefix = 0;
+ while (prefix < max && prevText[prefix] === text[prefix]) {
+ prefix++;
+ }
+ let suffix = 0;
+ while (
+ suffix < max - prefix &&
+ prevText[prevText.length - 1 - suffix] === text[text.length - 1 - suffix]
+ ) {
+ suffix++;
+ }
+ return start >= prefix && end <= text.length - suffix && end > start;
+}
+
+function $splitOff(editor: LexicalEditor): Set {
+ const editorState =
+ INTERNAL_getActiveEditorState() || editor.getEditorState();
+ let keys = splitOffInUpdate.get(editorState);
+ if (keys === undefined) {
+ keys = new Set();
+ splitOffInUpdate.set(editorState, keys);
+ }
+ return keys;
+}
+
/**
* Returns a tuple that can be rested (...) into mergeRegister to clean up
* node transforms listeners that transforms text into another node, eg. a HashtagNode.
@@ -50,46 +324,268 @@ export function registerLexicalTextEntity(
};
const $replaceWithSimpleText = (node: TextNode): void => {
+ const parent = node.getParent();
+ if (parent !== null) {
+ $demotedParents(editor).add(parent.getKey());
+ }
const textNode = $createTextNode(node.getTextContent())
.setFormat(node.getFormat())
.setStyle(node.getStyle())
.setDetail(node.getDetail());
+ $carryNodeState(node, textNode);
node.replace(textNode);
// The same characters in a plain text node (for collaboration).
INTERNAL_$recordTextRetype(node, textNode);
};
+ // The keys of the text nodes the entity transform marked dirty, by pending
+ // editor state (one update, or the updates batched into one commit).
+ const markedInUpdate = new WeakMap>();
+
+ // How many times each token or segmented entity was turned back into text,
+ // by pending editor state (one update, or the updates batched into one
+ // commit) and its parent, place and text.
+ const turnedIntoTextInUpdate = new WeakMap<
+ EditorState,
+ Map
+ >();
+
+ // Whether a token or segmented entity with the same parent, place and text
+ // as `entity` was turned back into text as many times as it may be in one
+ // update; counts this time if not.
+ const $wasTurnedIntoText = (entity: TextNode, why = ''): boolean => {
+ // Told by the first node before it that is no text entity, which the
+ // transforms leave in place while they make the entity and turn it back
+ // (text that merges with it keeps its key), and the size of the text
+ // entities between (which they may make again too).
+ let before = entity.getPreviousSibling();
+ let entitiesBefore = 0;
+ while ($isTextNode(before) && before.isTextEntity()) {
+ entitiesBefore += before.getTextContentSize();
+ before = before.getPreviousSibling();
+ }
+ const at = `${before === null ? '' : before.getKey()}+${entitiesBefore}`;
+ const parent = entity.getParent();
+ const id = `${why}${parent === null ? '' : parent.getKey()}:${at}:${entity.getTextContent()}`;
+ const updateState =
+ INTERNAL_getActiveEditorState() || editor.getEditorState();
+ let turned = turnedIntoTextInUpdate.get(updateState);
+ if (turned === undefined) {
+ turned = new Map();
+ turnedIntoTextInUpdate.set(updateState, turned);
+ }
+ const count = turned.get(id) || 0;
+ if (count >= MAX_TURNED_INTO_TEXT) {
+ return true;
+ }
+ turned.set(id, count + 1);
+ return false;
+ };
+
const getMode = (node: TextNode): number => {
return node.getLatest().__mode;
};
+ // Whether the first match of `textAfter` after `text` starts where
+ // `textAfter` does, passing over the matches of `text` as the loop below
+ // does (it leaves those as text when the transform returns before it), and
+ // telling a match that ends where `textAfter` starts (which touches it) from
+ // one that starts there. Null when `text` has more matches than are worth
+ // passing over.
+ const getFirstMatchAfter = (
+ text: string,
+ textAfter: string,
+ ): boolean | null => {
+ const combinedText = text + textAfter;
+ let offset = 0;
+ for (let i = 0; i < MAX_MATCHES_PASSED; i++) {
+ const match = getMatch(combinedText.slice(offset));
+ if (match === null) {
+ return false;
+ }
+ const start = offset + match.start;
+ const end = offset + match.end;
+ if (end <= start) {
+ return null;
+ }
+ if (end >= text.length) {
+ return start === text.length;
+ }
+ offset = end;
+ }
+ return null;
+ };
+
+ // Whether the node after `node` is a text entity (an entity right after
+ // another is handled by the entity's own transform).
+ const isNextTextEntity = (node: TextNode): boolean => {
+ const after = node.getNextSibling();
+ return $isTextNode(after) && after.isTextEntity();
+ };
+
+ // Whether a text node after `entity` is not text it was split from: one in
+ // another format, style or detail, or not simple text.
+ const isTextAfterOutsideSource = (entity: TextNode): boolean => {
+ const after = entity.getNextSibling();
+ return (
+ $isTextNode(after) &&
+ !(
+ after.isSimpleText() &&
+ !isTargetNode(after) &&
+ after.getFormat() === entity.getFormat() &&
+ after.getStyle() === entity.getStyle() &&
+ after.getDetail() === entity.getDetail()
+ )
+ );
+ };
+
+ // Whether `entity`, right after a simple text node with `textBefore`, is an
+ // entity the transforms keep: the first match from `textBefore` on starts
+ // where it does, with the text after it in its source node (the simple text
+ // node after it in the same format, style and detail, which it was split
+ // from), as the check that made it an entity found it; and that match still
+ // holds with the text of any other text node after it, as the check that
+ // keeps a match at the end of a node from becoming an entity found it. When
+ // `textBefore` has too many matches to tell, `ifUnsure` is returned.
+ const isEntityAfter = (
+ textBefore: string,
+ entity: TextNode,
+ ifUnsure: boolean,
+ ): boolean => {
+ const holds = (result: boolean | null) =>
+ result === null ? ifUnsure : result;
+ const entityText = entity.getTextContent();
+ const after = entity.getNextSibling();
+ if (!$isTextNode(after)) {
+ return holds(getFirstMatchAfter(textBefore, entityText));
+ }
+ const isSuffix = !isTextAfterOutsideSource(entity);
+ const withAfter = getFirstMatchAfter(
+ textBefore,
+ entityText + after.getTextContent(),
+ );
+ if (isSuffix) {
+ return holds(withAfter);
+ }
+ return (
+ holds(withAfter) && holds(getFirstMatchAfter(textBefore, entityText))
+ );
+ };
+
+ // Checks the entity after a simple text node whose transform returned
+ // before its loop, which checks that node: it is turned back into text
+ // unless the first match from this node's text on, with the text after the
+ // entity, starts where the entity does, as it must for a match at the start
+ // of a node to be made an entity.
+ const $checkNextSibling = (node: TextNode, text: string) => {
+ const nextSibling = node.getNextSibling();
+ if (isTargetNode(nextSibling)) {
+ const entityText = nextSibling.getTextContent();
+ const entityMatch = getMatch(entityText);
+ if (
+ // Leave an entity that its own transform is still splitting or
+ // turning back into text to that transform.
+ entityMatch === null ||
+ entityMatch.start !== 0 ||
+ entityMatch.end !== entityText.length
+ ) {
+ return;
+ }
+ if (!isEntityAfter(text, nextSibling, true)) {
+ $replaceWithSimpleText(nextSibling);
+ }
+ }
+ };
+
const $textNodeTransform = (node: TextNode) => {
if (!node.isSimpleText()) {
return;
}
+ // Text split off the end of an entity in this update: its pieces do not
+ // give their NodeState to the entities made from them
+ const splitOff = $splitOff(editor).has(node.getKey())
+ ? $splitOff(editor)
+ : null;
let prevSibling = node.getPreviousSibling();
let text = node.getTextContent();
+ const nodeText = text;
let currentNode = node;
let match;
+ // The first match of the previous text node's text and this node's.
+ let firstMatch: null | EntityMatch = null;
if ($isTextNode(prevSibling)) {
const previousText = prevSibling.getTextContent();
const combinedText = previousText + text;
const prevMatch = getMatch(combinedText);
+ firstMatch = prevMatch;
if (isTargetNode(prevSibling)) {
- if (prevMatch === null || getMode(prevSibling) !== 0) {
+ if (
+ prevMatch === null ||
+ (getMode(prevSibling) !== 0 &&
+ // A token or segmented entity followed by text is turned back
+ // into text, unless the same entity was already turned back into
+ // text twice for this pending editor state: the transforms would
+ // only make it again and turn it back forever. It is kept then
+ // (below).
+ !(
+ prevMatch.start === 0 &&
+ prevMatch.end >= previousText.length &&
+ $wasTurnedIntoText(prevSibling)
+ ))
+ ) {
$replaceWithSimpleText(prevSibling);
return;
} else {
const diff = prevMatch.end - previousText.length;
-
- if (diff > 0) {
+ // Whether the entity takes the first `diff` characters of this node
+ let grows = diff > 0;
+ const textBefore = prevSibling.getPreviousSibling();
+ if (grows && $isTextNode(textBefore)) {
+ if (textBefore.isTextEntity()) {
+ // Two entities next to each other: the entities' own transforms
+ // turn them back into text (or keep them as they are); this
+ // one does not take this node's text first.
+ grows = false;
+ } else if (textBefore.isSimpleText()) {
+ const before = textBefore.getTextContent();
+ if (!isEntityAfter(before, prevSibling, true)) {
+ // An entity that the transforms do not keep after the text
+ // before it is turned back into text rather than grown over
+ // this node first (which would give this node's characters
+ // its format and NodeState before it turns into text anyway).
+ $replaceWithSimpleText(prevSibling);
+
+ return;
+ }
+ if (!isEntityAfter(before, prevSibling, false)) {
+ // Not sure (too many matches in the text before it to tell):
+ // it stays as it is, without taking this node's text.
+ grows = false;
+ }
+ }
+ }
+ if (
+ grows &&
+ // A token or segmented entity kept above takes the text its match
+ // extends into, as an entity in normal mode does, only when that
+ // text would merge with it (the same format, style, detail and
+ // NodeState); text with other NodeState or format stays apart,
+ // rather than take the entity's (or make it drop its own).
+ (getMode(prevSibling) === 0 || $wouldMerge(prevSibling, node))
+ ) {
const concatText = text.slice(0, diff);
const newTextContent = previousText + concatText;
- prevSibling.select();
+ if (getMode(prevSibling) === 0) {
+ prevSibling.select();
+ } else {
+ // A token or segmented entity kept above: the selection stays
+ // where it was in the text
+ $moveSelectionIntoEntity(node, prevSibling, diff);
+ }
// The entity takes the first `diff` characters of this node: a
// move of those characters (for collaboration).
const entityNode = prevSibling;
@@ -108,6 +604,7 @@ export function registerLexicalTextEntity(
}
}
} else if (prevMatch === null || prevMatch.start < previousText.length) {
+ $checkNextSibling(node, text);
return;
}
}
@@ -135,7 +632,11 @@ export function registerLexicalTextEntity(
if (nextMatch === null) {
if (isTargetNode(nextSibling)) {
- $replaceWithSimpleText(nextSibling);
+ // Unless the transforms keep it there, which they do for a
+ // match that needs the text after it in its source node.
+ if (!isEntityAfter(remainingText, nextSibling, false)) {
+ $replaceWithSimpleText(nextSibling);
+ }
} else {
nextSibling.markDirty();
}
@@ -154,12 +655,41 @@ export function registerLexicalTextEntity(
if (
match.start === 0 &&
$isTextNode(prevSibling) &&
- prevSibling.isTextEntity()
+ (prevSibling.isTextEntity() ||
+ // The transform of a simple text node checks the entity after it
+ // with its own text in front, and turns it back into text if that
+ // is no match. Leave a match there as text (and any match right
+ // after it) rather than make an entity that check undoes, which
+ // would make the two transforms undo each other forever (#9637).
+ (prevSibling.isSimpleText() &&
+ match.end > 0 &&
+ (prevMatchLengthToSkip > 0 ||
+ (getMatch(
+ prevSibling.getTextContent() +
+ remainingText.slice(0, match.end),
+ ) === null &&
+ // Unless it is this match with the text after it too.
+ !(
+ firstMatch !== null &&
+ firstMatch.start === prevSibling.getTextContentSize() &&
+ firstMatch.end ===
+ prevSibling.getTextContentSize() + match.end
+ )))))
) {
prevMatchLengthToSkip += match.end;
continue;
}
+ // The offsets of the match in the text the node had (`currentNode` is
+ // what is left of its end)
+ const base = nodeText.length - currentNode.getTextContentSize();
+ const [matchStart, matchEnd] =
+ match.start === 0
+ ? [base, base + match.end]
+ : [
+ base + match.start + prevMatchLengthToSkip,
+ base + match.end + prevMatchLengthToSkip,
+ ];
let nodeToReplace;
if (match.start === 0) {
[nodeToReplace, currentNode] = currentNode.splitText(match.end);
@@ -169,6 +699,9 @@ export function registerLexicalTextEntity(
match.end + prevMatchLengthToSkip,
);
}
+ if (splitOff !== null && currentNode !== undefined) {
+ splitOff.add(currentNode.getKey());
+ }
invariant(
nodeToReplace !== undefined,
@@ -181,6 +714,13 @@ export function registerLexicalTextEntity(
.setFormat(nodeToReplace.getFormat())
.setStyle(nodeToReplace.getStyle())
.setDetail(nodeToReplace.getDetail());
+ if (
+ splitOff === null &&
+ !$isInsertedText(editor, node, nodeText, matchStart, matchEnd)
+ ) {
+ // The NodeState of the text becomes the entity's
+ $carryNodeState(nodeToReplace, replacementNode);
+ }
nodeToReplace.replace(replacementNode);
// The same characters in the entity node (for collaboration), where
// createNode gave it the same text.
@@ -206,24 +746,64 @@ export function registerLexicalTextEntity(
if (text.length > match.end) {
// This will split out the rest of the text as simple text
- node.splitText(match.end);
+ const [, rest] = node.splitText(match.end);
+ if (rest !== undefined) {
+ $splitOff(editor).add(rest.getKey());
+ }
return;
}
const prevSibling = node.getPreviousSibling();
- if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) {
+ if (
+ $isTextNode(prevSibling) &&
+ prevSibling.isSimpleText() &&
+ // The text node before it checks it on its own while it is a match
+ // after its text.
+ !isNextTextEntity(node) &&
+ !isEntityAfter(prevSibling.getTextContent(), node, true)
+ ) {
+ // The text after this entity may have changed so that the transforms
+ // no longer keep it after the text before it: the transform of the text
+ // node before it turns it back into text then. Once per pending editor
+ // state, as the transforms of other entities may leave it dirty again.
+ const updateState =
+ INTERNAL_getActiveEditorState() || editor.getEditorState();
+ let marked = markedInUpdate.get(updateState);
+ if (marked === undefined) {
+ marked = new Set();
+ markedInUpdate.set(updateState, marked);
+ }
+ if (!marked.has(prevSibling.getKey())) {
+ marked.add(prevSibling.getKey());
+ prevSibling.markDirty();
+ }
+ }
+
+ // Two entities next to each other are turned back into text, unless this
+ // entity was already turned back into text for that twice for this
+ // pending editor state: entities of two registrations that touch would
+ // be made again and turned back forever. They are kept then.
+ if (
+ $isTextNode(prevSibling) &&
+ prevSibling.isTextEntity() &&
+ !$wasTurnedIntoText(node, 'adjacent:')
+ ) {
$replaceWithSimpleText(prevSibling);
$replaceWithSimpleText(node);
}
const nextSibling = node.getNextSibling();
- if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) {
+ if (
+ $isTextNode(nextSibling) &&
+ nextSibling.isTextEntity() &&
+ // This may have already been converted in the previous block
+ (!isTargetNode(node) || !$wasTurnedIntoText(node, 'adjacent:'))
+ ) {
$replaceWithSimpleText(nextSibling);
- // This may have already been converted in the previous block
if (isTargetNode(node)) {
$replaceWithSimpleText(node);
}
diff --git a/packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts b/packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts
new file mode 100644
index 00000000000..792384d2b12
--- /dev/null
+++ b/packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts
@@ -0,0 +1,190 @@
+/**
+ * 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.
+ *
+ */
+
+// @vitest-environment node
+
+// A comment must not spread onto other blocks after concurrent undos when
+// text entity transforms (a keyword and hashtags) move commented text between
+// nodes inside one undo step.
+//
+// A, in one undo step: comment c1 on the keyword "two", type "x" after it,
+// comment c3 on the node holding "two", type "#t" in front of it. Everyone
+// syncs. B removes c3; A undoes its step; sync; B undoes its removal; sync.
+//
+// Mark removal splits ownership of an open/close pair, and undo restores an
+// opener with no closer: the entity is stored as several runs with identical
+// attributes, each with its own c3 pair; B's removal deletes a run's opener but
+// keeps its closer (writing its own end instead); A's undo deletes that closer
+// with the run's text; B's undo restores the opener alone, and c3 then covers
+// everything after it on every peer. Main reaches it too (with its own entity
+// path); keeping the comments on the entity reaches it more often. See #9716.
+
+import {
+ $createHashtagNode,
+ HashtagNode,
+ registerLexicalHashtag,
+} from '@lexical/hashtag';
+import {$addMarkID, $getNodeMarkIDs, $removeMarkID} from '@lexical/mark';
+import {registerLexicalTextEntity} from '@lexical/text';
+import {
+ $create,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $setSelection,
+ type ElementNode,
+ type LexicalEditor,
+ TextNode,
+} from 'lexical';
+import {expect, test} from 'vitest';
+
+import {expectConverged, NODES, type Peer, setup} from '../utils/harness';
+import {prepareMarkState} from '../utils/sizeOps';
+
+/** A keyword entity, as the playground's keywords plugin makes them. */
+class KeywordNode extends TextNode {
+ $config() {
+ return this.config('test-keyword', {extends: TextNode});
+ }
+ isTextEntity(): true {
+ return true;
+ }
+ canInsertTextBefore(): boolean {
+ return false;
+ }
+ canInsertTextAfter(): boolean {
+ return false;
+ }
+}
+
+const $createKeywordNode = (text: string): KeywordNode =>
+ $create(KeywordNode).setTextContent(text);
+
+const KEYWORD = /(^|[^a-z0-9#])(two|five)(?=[^a-z0-9]|$)/;
+
+function prepare(editor: LexicalEditor): void {
+ prepareMarkState(editor);
+ registerLexicalHashtag(editor);
+ registerLexicalTextEntity(
+ editor,
+ text => {
+ const m = KEYWORD.exec(text);
+ return m === null
+ ? null
+ : {
+ end: m.index + m[1].length + m[2].length,
+ start: m.index + m[1].length,
+ };
+ },
+ KeywordNode,
+ node => $createKeywordNode(node.getTextContent()),
+ );
+}
+
+/** The text node of block 0 that holds "two". */
+function $nodeWithTwo(): TextNode {
+ const node = ($getRoot().getFirstChildOrThrow() as ElementNode)
+ .getAllTextNodes()
+ .find(n => n.getTextContent().includes('two'));
+ if (node === undefined) {
+ throw new Error('no node holds "two"');
+ }
+ return node;
+}
+
+function $select(node: TextNode, start: number, end: number) {
+ const selection = $createRangeSelection();
+ selection.anchor.set(node.getKey(), start, 'text');
+ selection.focus.set(node.getKey(), end, 'text');
+ $setSelection(selection);
+ return selection;
+}
+
+/** The blocks each comment id is on. */
+function blocksOf(peer: Peer): Record {
+ return peer.read(() => {
+ const out: Record = {};
+ $getRoot()
+ .getChildren()
+ .forEach((block, i) => {
+ for (const node of (block as ElementNode).getAllTextNodes()) {
+ for (const id of $getNodeMarkIDs(node)) {
+ const list = (out[id] = out[id] || []);
+ if (!list.includes(i)) {
+ list.push(i);
+ }
+ }
+ }
+ });
+ return out;
+ });
+}
+
+test.fails(
+ '#9716: undo restores a comment opener without its closer after an entity type change (spreads to the end of the document)',
+ async () => {
+ const t = await setup(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('alpha one '),
+ $createKeywordNode('two'),
+ $createTextNode(' three '),
+ $createHashtagNode('#four'),
+ $createTextNode(' 0'),
+ ),
+ $createParagraphNode().append(
+ $createTextNode('beta one two three 1'),
+ ),
+ $createParagraphNode().append(
+ $createTextNode('gamma one two three 2'),
+ ),
+ );
+ },
+ {nodes: [...NODES, HashtagNode, KeywordNode], prepare},
+ );
+ const [a, b] = t.peers;
+ // A's undo step: four updates with no step boundary between them
+ a.update(() => {
+ const two = $nodeWithTwo();
+ $addMarkID('c1', $select(two, 0, two.getTextContentSize()));
+ $setSelection(null);
+ });
+ const step = (fn: () => void) => a.editor.update(fn, {discrete: true});
+ step(() => {
+ const two = $nodeWithTwo();
+ const at = two.getTextContent().indexOf('two') + 3;
+ const selection = $select(two, at, at);
+ selection.format = 0;
+ selection.insertText('x');
+ $setSelection(null);
+ });
+ step(() => {
+ const two = $nodeWithTwo();
+ $addMarkID('c3', $select(two, 0, two.getTextContentSize()));
+ $setSelection(null);
+ });
+ step(() => {
+ const two = $nodeWithTwo();
+ const selection = $select(two, 0, 0);
+ selection.format = 0;
+ selection.insertText('#t');
+ $setSelection(null);
+ });
+ await t.sync();
+ b.update(() => $removeMarkID('c3'));
+ a.undo();
+ await t.sync();
+ b.undo();
+ await t.sync();
+ await expectConverged(t.peers);
+ const c3 = blocksOf(a).c3 || [];
+ expect(c3.filter(i => i !== 0)).toEqual([]);
+ },
+);
diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts
index 5433a032582..f47a0674415 100644
--- a/packages/lexical/src/LexicalNodeState.ts
+++ b/packages/lexical/src/LexicalNodeState.ts
@@ -1308,6 +1308,58 @@ function hasUnequalRecordEntry(
return false;
}
+/**
+ * @internal
+ *
+ * Give `to`, a node that takes the place of `from` with the same content as a
+ * node of another type, the NodeState of `from`: known values as they are,
+ * values not read yet as their JSON, parsed with the configs of the type of
+ * `to` where it has its own for a key. Default values are left out, and keys
+ * that `to` already holds (set when it was made) keep their value.
+ */
+export function $carryNodeState(from: LexicalNode, to: LexicalNode): void {
+ const fromState = from.getLatest().__state;
+ if (fromState === undefined || fromState.size === 0) {
+ return;
+ }
+ const toState = to.getLatest().__state;
+ const done = new Set();
+ if (toState !== undefined) {
+ for (const [stateConfig] of toState.knownState) {
+ done.add(stateConfig.key);
+ }
+ if (toState.unknownState !== undefined) {
+ for (const key of Object.keys(toState.unknownState)) {
+ done.add(key);
+ }
+ }
+ }
+ const state = $getWritableNodeState(to);
+ for (const [stateConfig, value] of fromState.knownState) {
+ if (done.has(stateConfig.key)) {
+ continue;
+ }
+ done.add(stateConfig.key);
+ if (stateConfig.isEqual(value, stateConfig.defaultValue)) {
+ continue;
+ }
+ const own = state.sharedNodeState.sharedConfigMap.get(stateConfig.key);
+ if (own === undefined || own === stateConfig) {
+ state.updateFromKnown(stateConfig, value);
+ } else {
+ state.updateFromUnknown(stateConfig.key, stateConfig.unparse(value));
+ }
+ }
+ const {unknownState} = fromState;
+ if (unknownState !== undefined) {
+ for (const [key, value] of Object.entries(unknownState)) {
+ if (!done.has(key)) {
+ state.updateFromUnknown(key, value);
+ }
+ }
+ }
+}
+
/**
* @internal
*
diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts
index 446318215f4..fd67eba8b74 100644
--- a/packages/lexical/src/LexicalSelection.ts
+++ b/packages/lexical/src/LexicalSelection.ts
@@ -69,6 +69,7 @@ import {
} from './LexicalEvents';
import {getIsProcessingMutations} from './LexicalMutations';
import {insertRangeAfter, type LexicalNode, type NodeKey} from './LexicalNode';
+import {$carryNodeState} from './LexicalNodeState';
import {$normalizeSelection} from './LexicalNormalization';
import {
$getSelectionSlotFrame,
@@ -454,6 +455,9 @@ function $insertText(
const replacement = $createTextNode(anchorNode.getTextContent());
replacement.setFormat(format);
replacement.setStyle(style);
+ replacement.setDetail(anchorNode.getDetail());
+ // The characters keep their NodeState (comment ids, for example)
+ $carryNodeState(anchorNode, replacement);
const isCurrentSelection = $getSelection() === selection;
anchorNode.replace(replacement);
// replace() installs a clone of the active selection, so this
diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
index a9b4a86a2bf..e80dcefc907 100644
--- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
+++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
@@ -28,6 +28,7 @@ import {$createQuoteNode, QuoteNode} from '@lexical/rich-text';
import {
$caretRangeFromSelection,
$comparePointCaretNext,
+ $create,
$createLineBreakNode,
$createParagraphNode,
$createRangeSelection,
@@ -36,6 +37,7 @@ import {
$getCaretInDirection,
$getRoot,
$getSelection,
+ $getState,
$isDecoratorNode,
$isParagraphNode,
$isRangeSelection,
@@ -43,14 +45,16 @@ import {
$selectAll,
$setCompositionKey,
$setSelection,
+ $setState,
$setTextFormat,
+ createState,
ElementNode,
getDOMSelection,
IS_BOLD,
type LexicalNode,
type ParagraphNode,
type RangeSelection,
- type TextNode,
+ TextNode,
} from 'lexical';
import {
afterEach,
@@ -69,6 +73,7 @@ import {
$createTestInlineElementNode,
$createTestShadowRootNode,
$isTestShadowRootNode,
+ createTestEditor,
TestDecoratorNode,
TestInlineElementNode,
TestShadowRootNode,
@@ -877,6 +882,121 @@ describe('Segmented node composition (#5065)', () => {
);
});
+ test('insertText without composition keeps the NodeState and detail of the segmented node', () => {
+ const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+ });
+ using editor = buildEditorFromExtensions(selectionTestExtension);
+ editor.update(
+ () => {
+ const segmented = $setState(
+ $createTextNode('JohnSmith').setMode('segmented').toggleUnmergeable(),
+ note,
+ 'c1',
+ );
+ $getRoot().clear().append($createParagraphNode().append(segmented));
+
+ segmented.select(4, 4).insertText('X');
+
+ const allText = $getRoot().getAllTextNodes();
+ expect(allText).toHaveLength(1);
+ expect(allText[0].isSegmented()).toBe(false);
+ expect(allText[0].getTextContent()).toBe('JohnXSmith');
+ expect($getState(allText[0], note)).toBe('c1');
+ expect(allText[0].isUnmergeable()).toBe(true);
+ },
+ {discrete: true},
+ );
+ });
+
+ test('a TextNode replacement keeps the NodeState it sets, and takes the rest', () => {
+ const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+ });
+ const other = createState('other', {
+ parse: v => (typeof v === 'string' ? v : ''),
+ });
+ class ReplacementTextNode extends TextNode {
+ $config() {
+ return this.config('test-replacement-text', {extends: TextNode});
+ }
+ }
+ const editor = createTestEditor({
+ nodes: [
+ ReplacementTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) =>
+ $setState(
+ $create(ReplacementTextNode).setTextContent(
+ node.getTextContent(),
+ ),
+ note,
+ 'replacement',
+ ),
+ withKlass: ReplacementTextNode,
+ },
+ ],
+ });
+ editor.update(
+ () => {
+ const segmented = $setState(
+ $setState(
+ $create(ReplacementTextNode)
+ .setTextContent('JohnSmith')
+ .setMode('segmented'),
+ note,
+ 'c1',
+ ),
+ other,
+ 'o1',
+ );
+ $getRoot().clear().append($createParagraphNode().append(segmented));
+ segmented.select(4, 4).insertText('X');
+ const [text] = $getRoot().getAllTextNodes();
+ expect(text.getTextContent()).toBe('JohnXSmith');
+ expect($getState(text, note)).toBe('replacement');
+ expect($getState(text, other)).toBe('o1');
+ },
+ {discrete: true},
+ );
+ });
+
+ test('removing text from inside a segmented node to the next block keeps its NodeState and detail', () => {
+ const note = createState('note', {
+ parse: v => (typeof v === 'string' ? v : ''),
+ });
+ using editor = buildEditorFromExtensions(selectionTestExtension);
+ editor.update(
+ () => {
+ const segmented = $setState(
+ $createTextNode('$AB').setMode('segmented').toggleUnmergeable(),
+ note,
+ 'c1',
+ );
+ const next = $createTextNode('z');
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append(segmented),
+ $createParagraphNode().append(next),
+ );
+ const selection = $createRangeSelection();
+ selection.anchor.set(segmented.getKey(), 2, 'text');
+ selection.focus.set(next.getKey(), 0, 'text');
+ $setSelection(selection);
+ selection.removeText();
+ const nodes = $getRoot().getAllTextNodes();
+ expect($getRoot().getTextContent()).toBe('$Az');
+ const kept = nodes.find(n => n.getTextContent().startsWith('$A'));
+ expect(kept).toBeDefined();
+ expect($getState(kept!, note)).toBe('c1');
+ expect(kept!.isUnmergeable()).toBe(true);
+ },
+ {discrete: true},
+ );
+ });
+
test('insertText during composition preserves format and style', () => {
using editor = buildEditorFromExtensions(selectionTestExtension);
editor.update(
diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts
index 71661cdea8c..8e4af9c162a 100644
--- a/packages/lexical/src/caret/LexicalCaretUtils.ts
+++ b/packages/lexical/src/caret/LexicalCaretUtils.ts
@@ -9,6 +9,7 @@ import type {LexicalNode, NodeKey} from '../LexicalNode';
import invariant from '@lexical/internal/invariant';
+import {$carryNodeState} from '../LexicalNodeState';
import {
$createRangeSelection,
$getSelection,
@@ -348,7 +349,10 @@ export function $removeTextFromCaretRange(
const src = nextCaret.origin;
const plainTextNode = $createTextNode(src.getTextContent())
.setStyle(src.getStyle())
- .setFormat(src.getFormat());
+ .setFormat(src.getFormat())
+ .setDetail(src.getDetail());
+ // The characters left keep their NodeState (comment ids, for example)
+ $carryNodeState(src, plainTextNode);
caretBefore.replaceOrInsert(plainTextNode);
nextCaret = $getTextPointCaret(
plainTextNode,