@lexical/selection 0.44.1-nightly.20260519.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+ import invariant from '@lexical/internal/invariant';
9
+ import warnOnlyOnce from '@lexical/internal/warnOnlyOnce';
10
+ import {
11
+ $caretRangeFromSelection,
12
+ $cloneWithPropertiesEphemeral,
13
+ $createTextNode,
14
+ $getCharacterOffsets,
15
+ $getNodeByKey,
16
+ $getPreviousSelection,
17
+ $getSelection,
18
+ $isElementNode,
19
+ $isRangeSelection,
20
+ $isRootNode,
21
+ $isTextNode,
22
+ $isTokenOrSegmented,
23
+ BaseSelection,
24
+ ElementNode,
25
+ getStyleObjectFromCSS,
26
+ LexicalEditor,
27
+ LexicalNode,
28
+ NodeKey,
29
+ Point,
30
+ RangeSelection,
31
+ TextNode,
32
+ } from 'lexical';
33
+
34
+ import {getCSSFromStyleObject} from './utils';
35
+
36
+ /**
37
+ * Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
38
+ * it to be generated into the new TextNode.
39
+ * @param selection - The selection containing the node whose TextNode is to be edited.
40
+ * @param textNode - The TextNode to be edited.
41
+ * @param mutates - 'clone' to return a clone before mutating, 'self' to update in-place
42
+ * @returns The updated TextNode or clone.
43
+ */
44
+ export function $sliceSelectedTextNodeContent<T extends TextNode>(
45
+ selection: BaseSelection,
46
+ textNode: T,
47
+ mutates: 'clone' | 'self' = 'self',
48
+ ): T {
49
+ const anchorAndFocus = selection.getStartEndPoints();
50
+ if (
51
+ textNode.isSelected(selection) &&
52
+ !$isTokenOrSegmented(textNode) &&
53
+ anchorAndFocus !== null
54
+ ) {
55
+ const [anchor, focus] = anchorAndFocus;
56
+ const isBackward = selection.isBackward();
57
+ const anchorNode = anchor.getNode();
58
+ const focusNode = focus.getNode();
59
+ const isAnchor = textNode.is(anchorNode);
60
+ const isFocus = textNode.is(focusNode);
61
+
62
+ if (isAnchor || isFocus) {
63
+ const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
64
+ const isSame = anchorNode.is(focusNode);
65
+ const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
66
+ const isLast = textNode.is(isBackward ? anchorNode : focusNode);
67
+ let startOffset = 0;
68
+ let endOffset = undefined;
69
+
70
+ if (isSame) {
71
+ startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
72
+ endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
73
+ } else if (isFirst) {
74
+ const offset = isBackward ? focusOffset : anchorOffset;
75
+ startOffset = offset;
76
+ endOffset = undefined;
77
+ } else if (isLast) {
78
+ const offset = isBackward ? anchorOffset : focusOffset;
79
+ startOffset = 0;
80
+ endOffset = offset;
81
+ }
82
+
83
+ // NOTE: This mutates __text directly because the primary use case is to
84
+ // modify a $cloneWithProperties node that should never be added
85
+ // to the EditorState so we must not call getWritable via setTextContent
86
+ const text = textNode.__text.slice(startOffset, endOffset);
87
+ if (text !== textNode.__text) {
88
+ if (mutates === 'clone') {
89
+ textNode = $cloneWithPropertiesEphemeral(textNode);
90
+ }
91
+ textNode.__text = text;
92
+ }
93
+ }
94
+ }
95
+ return textNode;
96
+ }
97
+
98
+ /**
99
+ * Determines if the current selection is at the end of the node.
100
+ * @param point - The point of the selection to test.
101
+ * @returns true if the provided point offset is in the last possible position, false otherwise.
102
+ */
103
+ export function $isAtNodeEnd(point: Point): boolean {
104
+ if (point.type === 'text') {
105
+ return point.offset === point.getNode().getTextContentSize();
106
+ }
107
+ const node = point.getNode();
108
+ invariant(
109
+ $isElementNode(node),
110
+ 'isAtNodeEnd: node must be a TextNode or ElementNode',
111
+ );
112
+
113
+ return point.offset === node.getChildrenSize();
114
+ }
115
+
116
+ /**
117
+ * Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
118
+ * that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
119
+ * the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
120
+ * @param editor - The lexical editor.
121
+ * @param anchor - The anchor of the current selection, where the selection should be pointing.
122
+ * @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
123
+ */
124
+ export function $trimTextContentFromAnchor(
125
+ editor: LexicalEditor,
126
+ anchor: Point,
127
+ delCount: number,
128
+ ): void {
129
+ // Work from the current selection anchor point
130
+ let currentNode: LexicalNode | null = anchor.getNode();
131
+ let remaining: number = delCount;
132
+
133
+ if ($isElementNode(currentNode)) {
134
+ const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
135
+ if (descendantNode !== null) {
136
+ currentNode = descendantNode;
137
+ }
138
+ }
139
+
140
+ while (remaining > 0 && currentNode !== null) {
141
+ if ($isElementNode(currentNode)) {
142
+ const lastDescendant: null | LexicalNode =
143
+ currentNode.getLastDescendant<LexicalNode>();
144
+ if (lastDescendant !== null) {
145
+ currentNode = lastDescendant;
146
+ }
147
+ }
148
+ let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
149
+ let additionalElementWhitespace = 0;
150
+ if (nextNode === null) {
151
+ let parent: LexicalNode | null = currentNode.getParentOrThrow();
152
+ let parentSibling: LexicalNode | null = parent.getPreviousSibling();
153
+
154
+ while (parentSibling === null) {
155
+ parent = parent.getParent();
156
+ if (parent === null) {
157
+ nextNode = null;
158
+ break;
159
+ }
160
+ parentSibling = parent.getPreviousSibling();
161
+ }
162
+ if (parent !== null) {
163
+ additionalElementWhitespace = parent.isInline() ? 0 : 2;
164
+ nextNode = parentSibling;
165
+ }
166
+ }
167
+ let text = currentNode.getTextContent();
168
+ // If the text is empty, we need to consider adding in two line breaks to match
169
+ // the content if we were to get it from its parent.
170
+ if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
171
+ // TODO: should this be handled in core?
172
+ text = '\n\n';
173
+ }
174
+ const currentNodeSize = text.length;
175
+
176
+ if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
177
+ const parent = currentNode.getParent();
178
+ currentNode.remove();
179
+ if (
180
+ parent != null &&
181
+ parent.getChildrenSize() === 0 &&
182
+ !$isRootNode(parent)
183
+ ) {
184
+ parent.remove();
185
+ }
186
+ remaining -= currentNodeSize + additionalElementWhitespace;
187
+ currentNode = nextNode;
188
+ } else {
189
+ const key = currentNode.getKey();
190
+ // See if we can just revert it to what was in the last editor state
191
+ const prevTextContent: string | null = editor
192
+ .getEditorState()
193
+ .read(() => {
194
+ const prevNode = $getNodeByKey(key);
195
+ if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
196
+ return prevNode.getTextContent();
197
+ }
198
+ return null;
199
+ });
200
+ const offset = currentNodeSize - remaining;
201
+ const slicedText = text.slice(0, offset);
202
+ if (prevTextContent !== null && prevTextContent !== text) {
203
+ const prevSelection = $getPreviousSelection();
204
+ let target = currentNode;
205
+ if (!currentNode.isSimpleText()) {
206
+ const textNode = $createTextNode(prevTextContent);
207
+ currentNode.replace(textNode);
208
+ target = textNode;
209
+ } else {
210
+ currentNode.setTextContent(prevTextContent);
211
+ }
212
+ if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
213
+ const prevOffset = prevSelection.anchor.offset;
214
+ target.select(prevOffset, prevOffset);
215
+ }
216
+ } else if (currentNode.isSimpleText()) {
217
+ // Split text
218
+ const isSelected = anchor.key === key;
219
+ let anchorOffset = anchor.offset;
220
+ // Move offset to end if it's less than the remaining number, otherwise
221
+ // we'll have a negative splitStart.
222
+ if (anchorOffset < remaining) {
223
+ anchorOffset = currentNodeSize;
224
+ }
225
+ const splitStart = isSelected ? anchorOffset - remaining : 0;
226
+ const splitEnd = isSelected ? anchorOffset : offset;
227
+ if (isSelected && splitStart === 0) {
228
+ const [excessNode] = currentNode.splitText(splitStart, splitEnd);
229
+ excessNode.remove();
230
+ } else {
231
+ const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
232
+ excessNode.remove();
233
+ }
234
+ } else {
235
+ const textNode = $createTextNode(slicedText);
236
+ currentNode.replace(textNode);
237
+ }
238
+ remaining = 0;
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * @deprecated node styles are parsed on demand and not cached eternally
245
+ */
246
+ export const $addNodeStyle: (_node: TextNode) => void = warnOnlyOnce(
247
+ '$addNodeStyle is a deprecated no-op and calls should be removed',
248
+ );
249
+
250
+ /**
251
+ * Applies the provided styles to the given TextNode, ElementNode, or
252
+ * collapsed RangeSelection.
253
+ *
254
+ * @param target - The TextNode, ElementNode, or collapsed RangeSelection to apply the styles to
255
+ * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
256
+ */
257
+ export function $patchStyle(
258
+ target: TextNode | RangeSelection | ElementNode,
259
+ patch: Record<
260
+ string,
261
+ | string
262
+ | null
263
+ | ((currentStyleValue: string | null, _target: typeof target) => string)
264
+ >,
265
+ ): void {
266
+ invariant(
267
+ $isRangeSelection(target)
268
+ ? target.isCollapsed()
269
+ : $isTextNode(target) || $isElementNode(target),
270
+ '$patchStyle must only be called with a TextNode, ElementNode, or collapsed RangeSelection',
271
+ );
272
+ const prevStyles = getStyleObjectFromCSS(
273
+ $isRangeSelection(target)
274
+ ? target.style
275
+ : $isTextNode(target)
276
+ ? target.getStyle()
277
+ : target.getTextStyle(),
278
+ );
279
+ const newStyles = Object.entries(patch).reduce<Record<string, string>>(
280
+ (styles, [key, value]) => {
281
+ if (typeof value === 'function') {
282
+ styles[key] = value(prevStyles[key], target);
283
+ } else if (value === null) {
284
+ delete styles[key];
285
+ } else {
286
+ styles[key] = value;
287
+ }
288
+ return styles;
289
+ },
290
+ {...prevStyles},
291
+ );
292
+ const newCSSText = getCSSFromStyleObject(newStyles);
293
+ if ($isRangeSelection(target) || $isTextNode(target)) {
294
+ target.setStyle(newCSSText);
295
+ } else {
296
+ target.setTextStyle(newCSSText);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Applies the provided styles to the TextNodes in the provided Selection.
302
+ * Will update partially selected TextNodes by splitting the TextNode and applying
303
+ * the styles to the appropriate one.
304
+ * @param selection - The selected node(s) to update.
305
+ * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
306
+ */
307
+ export function $patchStyleText(
308
+ selection: BaseSelection,
309
+ patch: Record<
310
+ string,
311
+ | string
312
+ | null
313
+ | ((
314
+ currentStyleValue: string | null,
315
+ target: TextNode | RangeSelection | ElementNode,
316
+ ) => string)
317
+ >,
318
+ ): void {
319
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
320
+ $patchStyle(selection, patch);
321
+ const emptyNode = selection.anchor.getNode();
322
+ if ($isElementNode(emptyNode) && emptyNode.isEmpty()) {
323
+ $patchStyle(emptyNode, patch);
324
+ }
325
+ }
326
+ $forEachSelectedTextNode(textNode => {
327
+ $patchStyle(textNode, patch);
328
+ });
329
+
330
+ const nodes = selection.getNodes();
331
+ if (nodes.length > 0) {
332
+ const patchedElementKeys = new Set<NodeKey>();
333
+ for (const node of nodes) {
334
+ if (
335
+ !$isElementNode(node) ||
336
+ !node.canBeEmpty() ||
337
+ node.getChildrenSize() !== 0
338
+ ) {
339
+ continue;
340
+ }
341
+ const key = node.getKey();
342
+ if (patchedElementKeys.has(key)) {
343
+ continue;
344
+ }
345
+ patchedElementKeys.add(key);
346
+ $patchStyle(node, patch);
347
+ }
348
+ }
349
+ }
350
+
351
+ export function $forEachSelectedTextNode(
352
+ fn: (textNode: TextNode) => void,
353
+ ): void {
354
+ const selection = $getSelection();
355
+ if (!selection) {
356
+ return;
357
+ }
358
+
359
+ const slicedTextNodes = new Map<
360
+ NodeKey,
361
+ [startIndex: number, endIndex: number]
362
+ >();
363
+ const getSliceIndices = (
364
+ node: TextNode,
365
+ ): [startIndex: number, endIndex: number] =>
366
+ slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()];
367
+
368
+ if ($isRangeSelection(selection)) {
369
+ for (const slice of $caretRangeFromSelection(selection).getTextSlices()) {
370
+ if (slice) {
371
+ slicedTextNodes.set(
372
+ slice.caret.origin.getKey(),
373
+ slice.getSliceIndices(),
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ const selectedNodes = selection.getNodes();
380
+ for (const selectedNode of selectedNodes) {
381
+ if (!($isTextNode(selectedNode) && selectedNode.canHaveFormat())) {
382
+ continue;
383
+ }
384
+ const [startOffset, endOffset] = getSliceIndices(selectedNode);
385
+ // No actual text is selected, so do nothing.
386
+ if (endOffset === startOffset) {
387
+ continue;
388
+ }
389
+
390
+ // The entire node is selected or a token/segment, so just format it
391
+ if (
392
+ $isTokenOrSegmented(selectedNode) ||
393
+ (startOffset === 0 && endOffset === selectedNode.getTextContentSize())
394
+ ) {
395
+ fn(selectedNode);
396
+ } else {
397
+ // The node is partially selected, so split it into two or three nodes
398
+ // and style the selected one.
399
+ const splitNodes = selectedNode.splitText(startOffset, endOffset);
400
+ const replacement = splitNodes[startOffset === 0 ? 0 : 1];
401
+ fn(replacement);
402
+ }
403
+ }
404
+ // Prior to NodeCaret #7046 this would have been a side-effect
405
+ // so we do this for test compatibility.
406
+ // TODO: we may want to consider simplifying by removing this
407
+ if (
408
+ $isRangeSelection(selection) &&
409
+ selection.anchor.type === 'text' &&
410
+ selection.focus.type === 'text' &&
411
+ selection.anchor.key === selection.focus.key
412
+ ) {
413
+ $ensureForwardRangeSelection(selection);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Ensure that the given RangeSelection is not backwards. If it
419
+ * is backwards, then the anchor and focus points will be swapped
420
+ * in-place. Ensuring that the selection is a writable RangeSelection
421
+ * is the responsibility of the caller (e.g. in a read-only context
422
+ * you will want to clone $getSelection() before using this).
423
+ *
424
+ * @param selection a writable RangeSelection
425
+ */
426
+ export function $ensureForwardRangeSelection(selection: RangeSelection): void {
427
+ if (selection.isBackward()) {
428
+ const {anchor, focus} = selection;
429
+ // stash for the in-place swap
430
+ const {key, offset, type} = anchor;
431
+ anchor.set(focus.key, focus.offset, focus.type);
432
+ focus.set(key, offset, type);
433
+ }
434
+ }