diff --git a/.changeset/clamp-selection-on-text-shrink.md b/.changeset/clamp-selection-on-text-shrink.md new file mode 100644 index 00000000000..0d724f495ee --- /dev/null +++ b/.changeset/clamp-selection-on-text-shrink.md @@ -0,0 +1,7 @@ +--- +'lexical': patch +--- + +[fix] Clamp committed selection points when a text node is shrunk + +Verify: after `spliceText`/`setTextContent` shortens a selected text node, the committed selection sits at the node's new end and a following `splitText` no longer throws in dev. diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 053a62a9300..adaab2bc6e0 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -8,6 +8,7 @@ import type { LexicalNode, + NodeKey, ParsableSerializedNode, SerializedLexicalNode, SerializedPartialNode, @@ -59,6 +60,7 @@ import { $updateDOMSelection, applySelectionTransforms, type BaseSelection, + type RangeSelection, } from './LexicalSelection'; import {$isSlotHost, $setSlot} from './LexicalSlot'; import { @@ -1303,6 +1305,28 @@ function $discardPendingUpdate( updates.length = kept; } +function $clampTextPoints( + pendingSelection: RangeSelection, + pendingNodeMap: Map, +): void { + const anchorNode = pendingNodeMap.get(pendingSelection.anchor.key); + const focusNode = pendingNodeMap.get(pendingSelection.focus.key); + + for (const [point, node] of [ + [pendingSelection.anchor, anchorNode], + [pendingSelection.focus, focusNode], + ] as const) { + if (node !== undefined && point.type === 'text' && $isTextNode(node)) { + const size = node.getTextContentSize(); + if (point.offset > size) { + point.set(point.key, size, 'text'); + } else if (point.offset < 0) { + point.set(point.key, 0, 'text'); + } + } + } +} + function $beginUpdate( editor: LexicalEditor, updateFn: () => void, @@ -1370,6 +1394,12 @@ function $beginUpdate( const startingCompositionKey = editor._compositionKey; updateFn(); skipTransforms = $processNestedUpdates(editor, skipTransforms); + if ($isRangeSelection(pendingEditorState._selection)) { + $clampTextPoints( + pendingEditorState._selection, + pendingEditorState._nodeMap, + ); + } applySelectionTransforms(pendingEditorState, editor); if (editor._dirtyType !== NO_DIRTY_NODES) { @@ -1407,17 +1437,18 @@ function $beginUpdate( const pendingNodeMap = pendingEditorState._nodeMap; const anchorKey = pendingSelection.anchor.key; const focusKey = pendingSelection.focus.key; + const anchorNode = pendingNodeMap.get(anchorKey); + const focusNode = pendingNodeMap.get(focusKey); - if ( - pendingNodeMap.get(anchorKey) === undefined || - pendingNodeMap.get(focusKey) === undefined - ) { + if (anchorNode === undefined || focusNode === undefined) { invariant( false, 'updateEditor: selection has been lost because the previously selected nodes have been removed and ' + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", ); } + + $clampTextPoints(pendingSelection, pendingNodeMap); } else if ($isNodeSelection(pendingSelection)) { // TODO: we should also validate node selection? if (pendingSelection._nodes.size === 0) { diff --git a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts new file mode 100644 index 00000000000..3122ed1adb2 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts @@ -0,0 +1,144 @@ +/** + * 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 +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, + type LexicalEditor, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +// A programmatic shrink of a text node (spliceText without +// moveSelection, setTextContent) leaves a RangeSelection point past the end +// of the node; the update commits it, and a later splitText on that node +// throws (dev) from $getTextNodeOffset. + +const ext = defineExtension({ + dependencies: [RichTextExtension], + name: '[selection-past-end]', +}); + +function setup(): LexicalEditor { + const editor = buildEditorFromExtensions(ext); + editor.update( + () => { + const a = $createTextNode('ab').toggleFormat('bold'); + const t = $createTextNode('cde'); + $getRoot().clear().append($createParagraphNode().append(a, t)); + const s = t.select(3, 3); + s.anchor.set(a.getKey(), 1, 'text'); + }, + {discrete: true}, + ); + return editor; +} + +const $first = (): TextNode => { + const t = $getRoot().getFirstDescendant(); + if (!$isTextNode(t)) { + throw new Error('no text'); + } + return t; +}; + +const $text = (): TextNode => { + const t = $getRoot().getLastDescendant(); + if (!$isTextNode(t)) { + throw new Error('no text'); + } + return t; +}; + +const shrink = { + setTextContent: () => $text().setTextContent('a'), + spliceText: () => $text().spliceText(1, 2, ''), + 'spliceText, moveSelection (control)': () => + $text().spliceText(1, 2, '', true), +} as const; + +describe.each(Object.entries(shrink))('%s', (name, $shrink) => { + test('the committed selection stays inside the node', () => { + const editor = setup(); + editor.update($shrink, {discrete: true}); + const out = editor.getEditorState().read(() => { + const s = $getSelection(); + const a = $first(); + const t = $text(); + return s && $isRangeSelection(s) + ? { + anchorKey: s.anchor.key, + anchorOffset: s.anchor.offset, + firstKey: a.getKey(), + focusKey: s.focus.key, + focusOffset: s.focus.offset, + size: t.getTextContentSize(), + textKey: t.getKey(), + } + : null; + }); + expect(out).not.toBeNull(); + if (name.includes('moveSelection')) { + expect(out!.anchorKey).toBe(out!.textKey); + } else { + expect(out!.anchorKey).toBe(out!.firstKey); + } + expect(out!.anchorOffset).toBe(1); + expect(out!.focusKey).toBe(out!.textKey); + expect(out!.focusOffset).toBe(out!.size); + }); + + test('a later splitText on the node does not throw', () => { + const editor = setup(); + editor.update($shrink, {discrete: true}); + let error: unknown = null; + editor.update( + () => { + try { + $first().splitText(1); + } catch (e) { + error = e; + } + }, + {discrete: true}, + ); + expect(String(error)).toBe('null'); + void name; + }); +}); + +test('control: an in-bounds selection and the same splitText', () => { + const editor = setup(); + editor.update( + () => { + const s = $text().select(1, 1); + s.anchor.set($first().getKey(), 1, 'text'); + }, + {discrete: true}, + ); + editor.update(() => $text().spliceText(1, 2, ''), {discrete: true}); + let error: unknown = null; + editor.update( + () => { + try { + $first().splitText(1); + } catch (e) { + error = e; + } + }, + {discrete: true}, + ); + expect(String(error)).toBe('null'); +});