@lexical/selection 0.44.1-nightly.20260518.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.
- package/{LexicalSelection.dev.js → dist/LexicalSelection.dev.js} +68 -24
- package/{LexicalSelection.dev.mjs → dist/LexicalSelection.dev.mjs} +68 -24
- package/{LexicalSelection.js.flow → dist/LexicalSelection.js.flow} +1 -1
- package/dist/LexicalSelection.prod.js +9 -0
- package/dist/LexicalSelection.prod.mjs +9 -0
- package/{lexical-node.d.ts → dist/lexical-node.d.ts} +0 -7
- package/{range-selection.d.ts → dist/range-selection.d.ts} +1 -2
- package/package.json +30 -15
- package/src/index.ts +47 -0
- package/src/lexical-node.ts +434 -0
- package/src/range-selection.ts +667 -0
- package/src/utils.ts +238 -0
- package/LexicalSelection.prod.js +0 -9
- package/LexicalSelection.prod.mjs +0 -9
- /package/{LexicalSelection.js → dist/LexicalSelection.js} +0 -0
- /package/{LexicalSelection.mjs → dist/LexicalSelection.mjs} +0 -0
- /package/{LexicalSelection.node.mjs → dist/LexicalSelection.node.mjs} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{utils.d.ts → dist/utils.d.ts} +0 -0
|
@@ -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
|
+
}
|