@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.
- 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,667 @@
|
|
|
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
|
+
|
|
9
|
+
import type {
|
|
10
|
+
BaseSelection,
|
|
11
|
+
DecoratorNode,
|
|
12
|
+
ElementNode,
|
|
13
|
+
LexicalNode,
|
|
14
|
+
NodeKey,
|
|
15
|
+
Point,
|
|
16
|
+
RangeSelection,
|
|
17
|
+
TextNode,
|
|
18
|
+
} from 'lexical';
|
|
19
|
+
|
|
20
|
+
import invariant from '@lexical/internal/invariant';
|
|
21
|
+
import {
|
|
22
|
+
$caretFromPoint,
|
|
23
|
+
$extendCaretToRange,
|
|
24
|
+
$findMatchingParent,
|
|
25
|
+
$getPreviousSelection,
|
|
26
|
+
$hasAncestor,
|
|
27
|
+
$isChildCaret,
|
|
28
|
+
$isDecoratorNode,
|
|
29
|
+
$isElementNode,
|
|
30
|
+
$isExtendableTextPointCaret,
|
|
31
|
+
$isLeafNode,
|
|
32
|
+
$isRangeSelection,
|
|
33
|
+
$isRootOrShadowRoot,
|
|
34
|
+
$isTextNode,
|
|
35
|
+
$setSelection,
|
|
36
|
+
getStyleObjectFromCSS,
|
|
37
|
+
INTERNAL_$isBlock,
|
|
38
|
+
} from 'lexical';
|
|
39
|
+
|
|
40
|
+
import {$getComputedStyleForElement, $getComputedStyleForParent} from './utils';
|
|
41
|
+
|
|
42
|
+
export function $copyBlockFormatIndent(
|
|
43
|
+
srcNode: ElementNode,
|
|
44
|
+
destNode: ElementNode,
|
|
45
|
+
): void {
|
|
46
|
+
const format = srcNode.getFormatType();
|
|
47
|
+
const indent = srcNode.getIndent();
|
|
48
|
+
if (format !== destNode.getFormatType()) {
|
|
49
|
+
destNode.setFormat(format);
|
|
50
|
+
}
|
|
51
|
+
if (indent !== destNode.getIndent()) {
|
|
52
|
+
destNode.setIndent(indent);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function $isPointAtBlockStart(point: Point, block: ElementNode): boolean {
|
|
57
|
+
if (point.offset !== 0) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
let node: LexicalNode = point.getNode();
|
|
61
|
+
// When an ElementNode is empty it's not possible to distinguish if
|
|
62
|
+
// the selection's intent is the entire block or the edge so we consider
|
|
63
|
+
// it to be the entire block
|
|
64
|
+
if ($isElementNode(node) && node.isEmpty()) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
while (!node.is(block)) {
|
|
68
|
+
if (node.getPreviousSibling() !== null) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const parent = node.getParent();
|
|
72
|
+
if (parent === null) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
node = parent;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Converts all nodes in the selection that are of one block type to another.
|
|
82
|
+
* @param selection - The selected blocks to be converted.
|
|
83
|
+
* @param $createElement - The function that creates the node. eg. $createParagraphNode.
|
|
84
|
+
* @param $afterCreateElement - The function that updates the new node based on the previous one ($copyBlockFormatIndent by default)
|
|
85
|
+
*/
|
|
86
|
+
export function $setBlocksType<T extends ElementNode>(
|
|
87
|
+
selection: BaseSelection | null,
|
|
88
|
+
$createElement: () => T,
|
|
89
|
+
$afterCreateElement: (
|
|
90
|
+
prevNodeSrc: ElementNode,
|
|
91
|
+
newNodeDest: T,
|
|
92
|
+
) => void = $copyBlockFormatIndent,
|
|
93
|
+
): void {
|
|
94
|
+
if (!selection) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Selections tend to not include their containing blocks so we effectively
|
|
98
|
+
// expand it here
|
|
99
|
+
const anchorAndFocus = selection.getStartEndPoints();
|
|
100
|
+
let skipFocusAtBlockStart = false;
|
|
101
|
+
let focusBlock: ElementNode | DecoratorNode<unknown> | null = null;
|
|
102
|
+
const blockMap = new Map<NodeKey, ElementNode>();
|
|
103
|
+
if (anchorAndFocus) {
|
|
104
|
+
const [anchor, focus] = anchorAndFocus;
|
|
105
|
+
const anchorBlock = $findMatchingParent(
|
|
106
|
+
anchor.getNode(),
|
|
107
|
+
INTERNAL_$isBlock,
|
|
108
|
+
);
|
|
109
|
+
focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock);
|
|
110
|
+
skipFocusAtBlockStart =
|
|
111
|
+
$isElementNode(focusBlock) &&
|
|
112
|
+
!focusBlock.is(anchorBlock) &&
|
|
113
|
+
$isPointAtBlockStart(focus, focusBlock);
|
|
114
|
+
if ($isElementNode(anchorBlock)) {
|
|
115
|
+
blockMap.set(anchorBlock.getKey(), anchorBlock);
|
|
116
|
+
}
|
|
117
|
+
if ($isElementNode(focusBlock) && !skipFocusAtBlockStart) {
|
|
118
|
+
blockMap.set(focusBlock.getKey(), focusBlock);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const node of selection.getNodes()) {
|
|
122
|
+
if ($isElementNode(node) && INTERNAL_$isBlock(node)) {
|
|
123
|
+
if (skipFocusAtBlockStart && node.is(focusBlock)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
blockMap.set(node.getKey(), node);
|
|
127
|
+
} else if (!anchorAndFocus) {
|
|
128
|
+
const ancestorBlock = $findMatchingParent(node, INTERNAL_$isBlock);
|
|
129
|
+
if ($isElementNode(ancestorBlock)) {
|
|
130
|
+
blockMap.set(ancestorBlock.getKey(), ancestorBlock);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Selection remapping is delegated to LexicalNode.replace (and the
|
|
135
|
+
// ListItemNode.replace override): both remap an element-anchored point
|
|
136
|
+
// on the replaced block to {key: replacement, offset: prevSize + offset}.
|
|
137
|
+
for (const prevNode of blockMap.values()) {
|
|
138
|
+
const element = $createElement();
|
|
139
|
+
$afterCreateElement(prevNode, element);
|
|
140
|
+
prevNode.replace(element, true);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPointAttached(point: Point): boolean {
|
|
145
|
+
return point.getNode().isAttached();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function $removeParentEmptyElements(startingNode: ElementNode): void {
|
|
149
|
+
let node: ElementNode | null = startingNode;
|
|
150
|
+
|
|
151
|
+
while (node !== null && !$isRootOrShadowRoot(node)) {
|
|
152
|
+
const latest = node.getLatest();
|
|
153
|
+
const parentNode: ElementNode | null = node.getParent<ElementNode>();
|
|
154
|
+
|
|
155
|
+
if (latest.getChildrenSize() === 0) {
|
|
156
|
+
node.remove(true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
node = parentNode;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @deprecated In favor of $setBlockTypes
|
|
165
|
+
* Wraps all nodes in the selection into another node of the type returned by createElement.
|
|
166
|
+
* @param selection - The selection of nodes to be wrapped.
|
|
167
|
+
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
|
168
|
+
* @param wrappingElement - An element to append the wrapped selection and its children to.
|
|
169
|
+
*/
|
|
170
|
+
export function $wrapNodes(
|
|
171
|
+
selection: BaseSelection,
|
|
172
|
+
createElement: () => ElementNode,
|
|
173
|
+
wrappingElement: null | ElementNode = null,
|
|
174
|
+
): void {
|
|
175
|
+
const anchorAndFocus = selection.getStartEndPoints();
|
|
176
|
+
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
|
|
177
|
+
const nodes = selection.getNodes();
|
|
178
|
+
const nodesLength = nodes.length;
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
anchor !== null &&
|
|
182
|
+
(nodesLength === 0 ||
|
|
183
|
+
(nodesLength === 1 &&
|
|
184
|
+
anchor.type === 'element' &&
|
|
185
|
+
anchor.getNode().getChildrenSize() === 0))
|
|
186
|
+
) {
|
|
187
|
+
const target =
|
|
188
|
+
anchor.type === 'text'
|
|
189
|
+
? anchor.getNode().getParentOrThrow()
|
|
190
|
+
: anchor.getNode();
|
|
191
|
+
const children = target.getChildren();
|
|
192
|
+
let element = createElement();
|
|
193
|
+
element.setFormat(target.getFormatType());
|
|
194
|
+
element.setIndent(target.getIndent());
|
|
195
|
+
children.forEach(child => element.append(child));
|
|
196
|
+
|
|
197
|
+
if (wrappingElement) {
|
|
198
|
+
element = wrappingElement.append(element);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
target.replace(element);
|
|
202
|
+
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let topLevelNode = null;
|
|
207
|
+
let descendants: LexicalNode[] = [];
|
|
208
|
+
for (let i = 0; i < nodesLength; i++) {
|
|
209
|
+
const node = nodes[i];
|
|
210
|
+
// Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
|
|
211
|
+
// user selected multiple Root-like nodes that have to be treated separately as if they are
|
|
212
|
+
// their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
|
|
213
|
+
// of each of the cell nodes.
|
|
214
|
+
if ($isRootOrShadowRoot(node)) {
|
|
215
|
+
$wrapNodesImpl(
|
|
216
|
+
selection,
|
|
217
|
+
descendants,
|
|
218
|
+
descendants.length,
|
|
219
|
+
createElement,
|
|
220
|
+
wrappingElement,
|
|
221
|
+
);
|
|
222
|
+
descendants = [];
|
|
223
|
+
topLevelNode = node;
|
|
224
|
+
} else if (
|
|
225
|
+
topLevelNode === null ||
|
|
226
|
+
(topLevelNode !== null && $hasAncestor(node, topLevelNode))
|
|
227
|
+
) {
|
|
228
|
+
descendants.push(node);
|
|
229
|
+
} else {
|
|
230
|
+
$wrapNodesImpl(
|
|
231
|
+
selection,
|
|
232
|
+
descendants,
|
|
233
|
+
descendants.length,
|
|
234
|
+
createElement,
|
|
235
|
+
wrappingElement,
|
|
236
|
+
);
|
|
237
|
+
descendants = [node];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
$wrapNodesImpl(
|
|
241
|
+
selection,
|
|
242
|
+
descendants,
|
|
243
|
+
descendants.length,
|
|
244
|
+
createElement,
|
|
245
|
+
wrappingElement,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Wraps each node into a new ElementNode.
|
|
251
|
+
* @param selection - The selection of nodes to wrap.
|
|
252
|
+
* @param nodes - An array of nodes, generally the descendants of the selection.
|
|
253
|
+
* @param nodesLength - The length of nodes.
|
|
254
|
+
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
|
255
|
+
* @param wrappingElement - An element to wrap all the nodes into.
|
|
256
|
+
* @returns
|
|
257
|
+
*/
|
|
258
|
+
export function $wrapNodesImpl(
|
|
259
|
+
selection: BaseSelection,
|
|
260
|
+
nodes: LexicalNode[],
|
|
261
|
+
nodesLength: number,
|
|
262
|
+
createElement: () => ElementNode,
|
|
263
|
+
wrappingElement: null | ElementNode = null,
|
|
264
|
+
): void {
|
|
265
|
+
if (nodes.length === 0) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const firstNode = nodes[0];
|
|
270
|
+
const elementMapping: Map<NodeKey, ElementNode> = new Map();
|
|
271
|
+
const elements = [];
|
|
272
|
+
// The below logic is to find the right target for us to
|
|
273
|
+
// either insertAfter/insertBefore/append the corresponding
|
|
274
|
+
// elements to. This is made more complicated due to nested
|
|
275
|
+
// structures.
|
|
276
|
+
let target = $isElementNode(firstNode)
|
|
277
|
+
? firstNode
|
|
278
|
+
: firstNode.getParentOrThrow();
|
|
279
|
+
|
|
280
|
+
if (target.isInline()) {
|
|
281
|
+
target = target.getParentOrThrow();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let targetIsPrevSibling = false;
|
|
285
|
+
while (target !== null) {
|
|
286
|
+
const prevSibling = target.getPreviousSibling<ElementNode>();
|
|
287
|
+
|
|
288
|
+
if (prevSibling !== null) {
|
|
289
|
+
target = prevSibling;
|
|
290
|
+
targetIsPrevSibling = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
target = target.getParentOrThrow();
|
|
295
|
+
|
|
296
|
+
if ($isRootOrShadowRoot(target)) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const emptyElements = new Set();
|
|
302
|
+
|
|
303
|
+
// Find any top level empty elements
|
|
304
|
+
for (let i = 0; i < nodesLength; i++) {
|
|
305
|
+
const node = nodes[i];
|
|
306
|
+
|
|
307
|
+
if ($isElementNode(node) && node.getChildrenSize() === 0) {
|
|
308
|
+
emptyElements.add(node.getKey());
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const movedNodes: Set<NodeKey> = new Set();
|
|
313
|
+
|
|
314
|
+
// Move out all leaf nodes into our elements array.
|
|
315
|
+
// If we find a top level empty element, also move make
|
|
316
|
+
// an element for that.
|
|
317
|
+
for (let i = 0; i < nodesLength; i++) {
|
|
318
|
+
const node = nodes[i];
|
|
319
|
+
let parent = node.getParent();
|
|
320
|
+
|
|
321
|
+
if (parent !== null && parent.isInline()) {
|
|
322
|
+
parent = parent.getParent();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (
|
|
326
|
+
parent !== null &&
|
|
327
|
+
$isLeafNode(node) &&
|
|
328
|
+
!movedNodes.has(node.getKey())
|
|
329
|
+
) {
|
|
330
|
+
const parentKey = parent.getKey();
|
|
331
|
+
|
|
332
|
+
if (elementMapping.get(parentKey) === undefined) {
|
|
333
|
+
const targetElement = createElement();
|
|
334
|
+
targetElement.setFormat(parent.getFormatType());
|
|
335
|
+
targetElement.setIndent(parent.getIndent());
|
|
336
|
+
elements.push(targetElement);
|
|
337
|
+
elementMapping.set(parentKey, targetElement);
|
|
338
|
+
// Move node and its siblings to the new
|
|
339
|
+
// element.
|
|
340
|
+
const children = parent.getChildren();
|
|
341
|
+
targetElement.splice(targetElement.getChildrenSize(), 0, children);
|
|
342
|
+
for (const child of children) {
|
|
343
|
+
movedNodes.add(child.getKey());
|
|
344
|
+
if ($isElementNode(child)) {
|
|
345
|
+
// Skip nested leaf nodes if the parent has already been moved
|
|
346
|
+
for (const key of child.getChildrenKeys()) {
|
|
347
|
+
movedNodes.add(key);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
$removeParentEmptyElements(parent);
|
|
352
|
+
}
|
|
353
|
+
} else if (emptyElements.has(node.getKey())) {
|
|
354
|
+
invariant(
|
|
355
|
+
$isElementNode(node),
|
|
356
|
+
'Expected node in emptyElements to be an ElementNode',
|
|
357
|
+
);
|
|
358
|
+
const targetElement = createElement();
|
|
359
|
+
targetElement.setFormat(node.getFormatType());
|
|
360
|
+
targetElement.setIndent(node.getIndent());
|
|
361
|
+
elements.push(targetElement);
|
|
362
|
+
node.remove(true);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (wrappingElement !== null) {
|
|
367
|
+
for (let i = 0; i < elements.length; i++) {
|
|
368
|
+
const element = elements[i];
|
|
369
|
+
wrappingElement.append(element);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
let lastElement = null;
|
|
373
|
+
|
|
374
|
+
// If our target is Root-like, let's see if we can re-adjust
|
|
375
|
+
// so that the target is the first child instead.
|
|
376
|
+
if ($isRootOrShadowRoot(target)) {
|
|
377
|
+
if (targetIsPrevSibling) {
|
|
378
|
+
if (wrappingElement !== null) {
|
|
379
|
+
target.insertAfter(wrappingElement);
|
|
380
|
+
} else {
|
|
381
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
382
|
+
const element = elements[i];
|
|
383
|
+
target.insertAfter(element);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
const firstChild = target.getFirstChild();
|
|
388
|
+
|
|
389
|
+
if ($isElementNode(firstChild)) {
|
|
390
|
+
target = firstChild;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (firstChild === null) {
|
|
394
|
+
if (wrappingElement) {
|
|
395
|
+
target.append(wrappingElement);
|
|
396
|
+
} else {
|
|
397
|
+
for (let i = 0; i < elements.length; i++) {
|
|
398
|
+
const element = elements[i];
|
|
399
|
+
target.append(element);
|
|
400
|
+
lastElement = element;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
if (wrappingElement !== null) {
|
|
405
|
+
firstChild.insertBefore(wrappingElement);
|
|
406
|
+
} else {
|
|
407
|
+
for (let i = 0; i < elements.length; i++) {
|
|
408
|
+
const element = elements[i];
|
|
409
|
+
firstChild.insertBefore(element);
|
|
410
|
+
lastElement = element;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
if (wrappingElement) {
|
|
417
|
+
target.insertAfter(wrappingElement);
|
|
418
|
+
} else {
|
|
419
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
420
|
+
const element = elements[i];
|
|
421
|
+
target.insertAfter(element);
|
|
422
|
+
lastElement = element;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const prevSelection = $getPreviousSelection();
|
|
428
|
+
|
|
429
|
+
if (
|
|
430
|
+
$isRangeSelection(prevSelection) &&
|
|
431
|
+
isPointAttached(prevSelection.anchor) &&
|
|
432
|
+
isPointAttached(prevSelection.focus)
|
|
433
|
+
) {
|
|
434
|
+
$setSelection(prevSelection.clone());
|
|
435
|
+
} else if (lastElement !== null) {
|
|
436
|
+
lastElement.selectEnd();
|
|
437
|
+
} else {
|
|
438
|
+
selection.dirty = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Tests if the selection's parent element has vertical writing mode.
|
|
444
|
+
* @param selection - The selection whose parent to test.
|
|
445
|
+
* @returns true if the selection's parent has vertical writing mode (writing-mode: vertical-rl), false otherwise.
|
|
446
|
+
*/
|
|
447
|
+
function $isEditorVerticalOrientation(selection: RangeSelection): boolean {
|
|
448
|
+
const computedStyle = $getComputedStyle(selection);
|
|
449
|
+
return computedStyle !== null && computedStyle.writingMode === 'vertical-rl';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Gets the computed DOM styles of the parent of the selection's anchor node.
|
|
454
|
+
* @param selection - The selection to check the styles for.
|
|
455
|
+
* @returns the computed styles of the node or null if there is no DOM element or no default view for the document.
|
|
456
|
+
*/
|
|
457
|
+
function $getComputedStyle(
|
|
458
|
+
selection: RangeSelection,
|
|
459
|
+
): CSSStyleDeclaration | null {
|
|
460
|
+
const anchorNode = selection.anchor.getNode();
|
|
461
|
+
if ($isElementNode(anchorNode)) {
|
|
462
|
+
return $getComputedStyleForElement(anchorNode);
|
|
463
|
+
}
|
|
464
|
+
return $getComputedStyleForParent(anchorNode);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Determines if the default character selection should be overridden. Used with DecoratorNodes
|
|
469
|
+
* @param selection - The selection whose default character selection may need to be overridden.
|
|
470
|
+
* @param isBackward - Is the selection backwards (the focus comes before the anchor)?
|
|
471
|
+
* @returns true if it should be overridden, false if not.
|
|
472
|
+
*/
|
|
473
|
+
export function $shouldOverrideDefaultCharacterSelection(
|
|
474
|
+
selection: RangeSelection,
|
|
475
|
+
isBackward: boolean,
|
|
476
|
+
): boolean {
|
|
477
|
+
const isVertical = $isEditorVerticalOrientation(selection);
|
|
478
|
+
|
|
479
|
+
// In vertical writing mode, we adjust the direction for correct caret movement
|
|
480
|
+
let adjustedIsBackward = isVertical ? !isBackward : isBackward;
|
|
481
|
+
|
|
482
|
+
// In right-to-left writing mode, we invert the direction for correct caret movement
|
|
483
|
+
if ($isParentElementRTL(selection)) {
|
|
484
|
+
adjustedIsBackward = !adjustedIsBackward;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const focusCaret = $caretFromPoint(
|
|
488
|
+
selection.focus,
|
|
489
|
+
adjustedIsBackward ? 'previous' : 'next',
|
|
490
|
+
);
|
|
491
|
+
if ($isExtendableTextPointCaret(focusCaret)) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
for (const nextCaret of $extendCaretToRange(focusCaret)) {
|
|
495
|
+
if ($isChildCaret(nextCaret)) {
|
|
496
|
+
return !nextCaret.origin.isInline();
|
|
497
|
+
} else if ($isElementNode(nextCaret.origin)) {
|
|
498
|
+
continue;
|
|
499
|
+
} else if ($isDecoratorNode(nextCaret.origin)) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Moves the selection according to the arguments.
|
|
509
|
+
* @param selection - The selected text or nodes.
|
|
510
|
+
* @param isHoldingShift - Is the shift key being held down during the operation.
|
|
511
|
+
* @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
|
|
512
|
+
* @param granularity - The distance to adjust the current selection.
|
|
513
|
+
*/
|
|
514
|
+
export function $moveCaretSelection(
|
|
515
|
+
selection: RangeSelection,
|
|
516
|
+
isHoldingShift: boolean,
|
|
517
|
+
isBackward: boolean,
|
|
518
|
+
granularity: 'character' | 'word' | 'lineboundary',
|
|
519
|
+
): void {
|
|
520
|
+
selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Tests a parent element for right to left direction.
|
|
525
|
+
* @param selection - The selection whose parent is to be tested.
|
|
526
|
+
* @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
|
|
527
|
+
*/
|
|
528
|
+
export function $isParentElementRTL(selection: RangeSelection): boolean {
|
|
529
|
+
const computedStyle = $getComputedStyle(selection);
|
|
530
|
+
return computedStyle !== null && computedStyle.direction === 'rtl';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Moves selection by character according to arguments.
|
|
535
|
+
* @param selection - The selection of the characters to move.
|
|
536
|
+
* @param isHoldingShift - Is the shift key being held down during the operation.
|
|
537
|
+
* @param isBackward - Is the selection backward (the focus comes before the anchor)?
|
|
538
|
+
*/
|
|
539
|
+
export function $moveCharacter(
|
|
540
|
+
selection: RangeSelection,
|
|
541
|
+
isHoldingShift: boolean,
|
|
542
|
+
isBackward: boolean,
|
|
543
|
+
): void {
|
|
544
|
+
const isRTL = $isParentElementRTL(selection);
|
|
545
|
+
const isVertical = $isEditorVerticalOrientation(selection);
|
|
546
|
+
|
|
547
|
+
// In vertical-rl writing mode, arrow key directions need to be flipped
|
|
548
|
+
// to match the visual flow of text (top to bottom, right to left)
|
|
549
|
+
let adjustedIsBackward;
|
|
550
|
+
|
|
551
|
+
if (isVertical) {
|
|
552
|
+
// In vertical-rl mode, we need to completely invert the direction
|
|
553
|
+
// Left arrow (backward) should move down (forward)
|
|
554
|
+
// Right arrow (forward) should move up (backward)
|
|
555
|
+
adjustedIsBackward = !isBackward;
|
|
556
|
+
} else if (isRTL) {
|
|
557
|
+
// In horizontal RTL mode, use the standard RTL behavior
|
|
558
|
+
adjustedIsBackward = !isBackward;
|
|
559
|
+
} else {
|
|
560
|
+
// Standard LTR horizontal text
|
|
561
|
+
adjustedIsBackward = isBackward;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Apply the direction adjustment to move the caret
|
|
565
|
+
$moveCaretSelection(
|
|
566
|
+
selection,
|
|
567
|
+
isHoldingShift,
|
|
568
|
+
adjustedIsBackward,
|
|
569
|
+
'character',
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
|
|
575
|
+
* @param node - The node whose style value to get.
|
|
576
|
+
* @param styleProperty - The CSS style property.
|
|
577
|
+
* @param defaultValue - The default value for the property.
|
|
578
|
+
* @returns The value of the property for node.
|
|
579
|
+
*/
|
|
580
|
+
function $getNodeStyleValueForProperty(
|
|
581
|
+
node: TextNode,
|
|
582
|
+
styleProperty: string,
|
|
583
|
+
defaultValue: string,
|
|
584
|
+
): string {
|
|
585
|
+
const css = node.getStyle();
|
|
586
|
+
const styleObject = getStyleObjectFromCSS(css);
|
|
587
|
+
|
|
588
|
+
if (styleObject !== null) {
|
|
589
|
+
return styleObject[styleProperty] || defaultValue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return defaultValue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
|
|
597
|
+
* If all TextNodes do not have the same value, it returns an empty string.
|
|
598
|
+
* @param selection - The selection of TextNodes whose value to find.
|
|
599
|
+
* @param styleProperty - The CSS style property.
|
|
600
|
+
* @param defaultValue - The default value for the property, defaults to an empty string.
|
|
601
|
+
* @returns The value of the property for the selected TextNodes.
|
|
602
|
+
*/
|
|
603
|
+
export function $getSelectionStyleValueForProperty(
|
|
604
|
+
selection: BaseSelection,
|
|
605
|
+
styleProperty: string,
|
|
606
|
+
defaultValue = '',
|
|
607
|
+
): string {
|
|
608
|
+
let styleValue: string | null = null;
|
|
609
|
+
const nodes = selection.getNodes();
|
|
610
|
+
|
|
611
|
+
// The anchor/focus boundary handling below is specific to RangeSelection;
|
|
612
|
+
// other selection types (e.g. table) style every node they contain.
|
|
613
|
+
let startNode: LexicalNode | undefined;
|
|
614
|
+
let endNode: LexicalNode | undefined;
|
|
615
|
+
if ($isRangeSelection(selection)) {
|
|
616
|
+
if (selection.isCollapsed() && selection.style !== '') {
|
|
617
|
+
const styleObject = getStyleObjectFromCSS(selection.style);
|
|
618
|
+
|
|
619
|
+
if (styleObject !== null && styleProperty in styleObject) {
|
|
620
|
+
return styleObject[styleProperty];
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const {anchor, focus} = selection;
|
|
624
|
+
const isBackward = selection.isBackward();
|
|
625
|
+
const firstNode = isBackward ? focus.getNode() : anchor.getNode();
|
|
626
|
+
const lastNode = isBackward ? anchor.getNode() : focus.getNode();
|
|
627
|
+
const startOffset = isBackward ? focus.offset : anchor.offset;
|
|
628
|
+
const endOffset = isBackward ? anchor.offset : focus.offset;
|
|
629
|
+
// A boundary node contributes no styled text when the selection merely
|
|
630
|
+
// touches its edge: the first node when the start offset is at its very
|
|
631
|
+
// end, and the last node when the end offset is at its very beginning.
|
|
632
|
+
if (
|
|
633
|
+
$isTextNode(firstNode) &&
|
|
634
|
+
startOffset === firstNode.getTextContentSize()
|
|
635
|
+
) {
|
|
636
|
+
startNode = firstNode;
|
|
637
|
+
}
|
|
638
|
+
if (endOffset === 0) {
|
|
639
|
+
endNode = lastNode;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
644
|
+
const node = nodes[i];
|
|
645
|
+
|
|
646
|
+
// Skip the excluded boundary node for this position (startNode at the
|
|
647
|
+
// head, endNode elsewhere); both are undefined when nothing is excluded.
|
|
648
|
+
if ($isTextNode(node) && !node.is(i === 0 ? startNode : endNode)) {
|
|
649
|
+
const nodeStyleValue = $getNodeStyleValueForProperty(
|
|
650
|
+
node,
|
|
651
|
+
styleProperty,
|
|
652
|
+
defaultValue,
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
if (styleValue === null) {
|
|
656
|
+
styleValue = nodeStyleValue;
|
|
657
|
+
} else if (styleValue !== nodeStyleValue) {
|
|
658
|
+
// multiple text nodes are in the selection and they don't all
|
|
659
|
+
// have the same style.
|
|
660
|
+
styleValue = '';
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return styleValue === null ? defaultValue : styleValue;
|
|
667
|
+
}
|