From ecb70aca6410aa5e8d3265f5ec24b23cb1039ab8 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 2 Oct 2024 03:12:24 -0700 Subject: [PATCH] [lexical] Bug Fix: TextNode in token mode should not be split by removeText (#6690) --- packages/lexical/src/LexicalSelection.ts | 11 + .../__tests__/unit/LexicalSelection.test.ts | 289 ++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 423a11cdf98..8f851d4cfa4 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1117,6 +1117,17 @@ export class RangeSelection implements BaseSelection { let lastNode = lastPoint.getNode(); const firstBlock = $getAncestor(firstNode, INTERNAL_$isBlock); const lastBlock = $getAncestor(lastNode, INTERNAL_$isBlock); + // If a token is partially selected then move the selection to cover the whole selection + if ( + $isTextNode(firstNode) && + firstNode.isToken() && + firstPoint.offset < firstNode.getTextContentSize() + ) { + firstPoint.offset = 0; + } + if (lastPoint.offset > 0 && $isTextNode(lastNode) && lastNode.isToken()) { + lastPoint.offset = lastNode.getTextContentSize(); + } selectedNodes.forEach((node) => { if ( diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 66963d52cf0..87b20b797b3 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -344,6 +344,295 @@ describe('LexicalSelection tests', () => { }); }); describe('removeText', () => { + describe('with a leading TextNode and a trailing token TextNode', () => { + let leadingText: TextNode; + let trailingTokenText: TextNode; + let paragraph: ParagraphNode; + beforeEach(() => { + testEnv.editor.update( + () => { + leadingText = $createTextNode('leading text'); + trailingTokenText = + $createTextNode('token text').setMode('token'); + paragraph = $createParagraphNode().append( + leadingText, + trailingTokenText, + ); + $getRoot().clear().append(paragraph); + }, + {discrete: true}, + ); + }); + test('remove all text', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingText.getKey(), 0, 'text'); + sel.focus.set( + trailingTokenText.getKey(), + trailingTokenText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingText.isAttached()).toBe(false); + expect(trailingTokenText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(0); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(paragraph.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove initial TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingText.getKey(), 0, 'text'); + sel.focus.set( + leadingText.getKey(), + leadingText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingText.isAttached()).toBe(false); + expect(trailingTokenText.isAttached()).toBe(true); + expect($getRoot().getAllTextNodes()).toHaveLength(1); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(trailingTokenText.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove trailing token TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(trailingTokenText.getKey(), 0, 'text'); + sel.focus.set( + trailingTokenText.getKey(), + trailingTokenText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingText.isAttached()).toBe(true); + expect(trailingTokenText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(1); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(leadingText.getKey()); + expect(selection.anchor.offset).toBe( + leadingText.getTextContentSize(), + ); + }, + {discrete: true}, + ); + }); + test('remove initial TextNode and partial token TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingText.getKey(), 0, 'text'); + sel.focus.set( + trailingTokenText.getKey(), + 'token '.length, + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingText.isAttached()).toBe(false); + // expecting no node since it was token + expect(trailingTokenText.isAttached()).toBe(false); + const allTextNodes = $getRoot().getAllTextNodes(); + expect(allTextNodes).toHaveLength(0); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(paragraph.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove partial initial TextNode and partial token TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingText.getKey(), 'lead'.length, 'text'); + sel.focus.set( + trailingTokenText.getKey(), + 'token '.length, + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingText.isAttached()).toBe(true); + expect(trailingTokenText.isAttached()).toBe(false); + const allTextNodes = $getRoot().getAllTextNodes(); + // The token node will be completely removed + expect(allTextNodes.map((node) => node.getTextContent())).toEqual( + ['lead'], + ); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(leadingText.getKey()); + expect(selection.anchor.offset).toBe('lead'.length); + }, + {discrete: true}, + ); + }); + }); + describe('with a leading token TextNode and a trailing TextNode', () => { + let leadingTokenText: TextNode; + let trailingText: TextNode; + let paragraph: ParagraphNode; + beforeEach(() => { + testEnv.editor.update( + () => { + leadingTokenText = $createTextNode('token text').setMode('token'); + trailingText = $createTextNode('trailing text'); + paragraph = $createParagraphNode().append( + leadingTokenText, + trailingText, + ); + $getRoot().clear().append(paragraph); + }, + {discrete: true}, + ); + }); + test('remove all text', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingTokenText.getKey(), 0, 'text'); + sel.focus.set( + trailingText.getKey(), + trailingText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingTokenText.isAttached()).toBe(false); + expect(trailingText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(0); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(paragraph.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove trailing TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(trailingText.getKey(), 0, 'text'); + sel.focus.set( + trailingText.getKey(), + trailingText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingTokenText.isAttached()).toBe(true); + expect(trailingText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(1); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(leadingTokenText.getKey()); + expect(selection.anchor.offset).toBe( + leadingTokenText.getTextContentSize(), + ); + }, + {discrete: true}, + ); + }); + test('remove leading token TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set(leadingTokenText.getKey(), 0, 'text'); + sel.focus.set( + leadingTokenText.getKey(), + leadingTokenText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(leadingTokenText.isAttached()).toBe(false); + expect(trailingText.isAttached()).toBe(true); + expect($getRoot().getAllTextNodes()).toHaveLength(1); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(trailingText.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove partial leading token TextNode and trailing TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set( + leadingTokenText.getKey(), + 'token '.length, + 'text', + ); + sel.focus.set( + trailingText.getKey(), + trailingText.getTextContentSize(), + 'text', + ); + $setSelection(sel); + sel.removeText(); + expect(trailingText.isAttached()).toBe(false); + // expecting no node since it was token + expect(leadingTokenText.isAttached()).toBe(false); + const allTextNodes = $getRoot().getAllTextNodes(); + expect(allTextNodes).toHaveLength(0); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(paragraph.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + test('remove partial token TextNode and partial trailing TextNode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + sel.anchor.set( + leadingTokenText.getKey(), + 'token '.length, + 'text', + ); + sel.focus.set(trailingText.getKey(), 'trail'.length, 'text'); + $setSelection(sel); + sel.removeText(); + expect(leadingTokenText.isAttached()).toBe(false); + expect(trailingText.isAttached()).toBe(true); + const allTextNodes = $getRoot().getAllTextNodes(); + // The token node will be completely removed + expect(allTextNodes.map((node) => node.getTextContent())).toEqual( + ['ing text'], + ); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(trailingText.getKey()); + expect(selection.anchor.offset).toBe(0); + }, + {discrete: true}, + ); + }); + }); describe('with a leading TextNode and a trailing segmented TextNode', () => { let leadingText: TextNode; let trailingSegmentedText: TextNode;