Skip to content

Commit 20dae45

Browse files
committed
WIP selection fixes
1 parent 7f5950a commit 20dae45

File tree

7 files changed

+333
-183
lines changed

7 files changed

+333
-183
lines changed

packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -1290,7 +1290,8 @@ describe('LexicalSelection tests', () => {
12901290
paragraph.append(elementNode);
12911291
elementNode.append(text);
12921292

1293-
const selectedNodes = $getSelection()!.getNodes();
1293+
const selection = $getSelection()!;
1294+
const selectedNodes = selection.getNodes();
12941295

12951296
expect(selectedNodes.length).toBe(1);
12961297
expect(selectedNodes[0].getKey()).toBe(text.getKey());

packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3155,11 +3155,11 @@ describe('$patchStyleText', () => {
31553155
type: 'text',
31563156
});
31573157

3158-
const selection = $getSelection();
3158+
const selection = $getSelection()!;
31593159

3160-
$patchStyleText(selection!, {'font-size': '11px'});
3160+
$patchStyleText(selection, {'font-size': '11px'});
31613161

3162-
const [newAnchor, newFocus] = selection!.getStartEndPoints()!;
3162+
const [newAnchor, newFocus] = selection.getStartEndPoints()!;
31633163

31643164
const newAnchorNode: LexicalNode = newAnchor.getNode();
31653165
expect(newAnchorNode.getTextContent()).toBe('sec');

packages/lexical-selection/src/lexical-node.ts

+51-117
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
BaseSelection,
2020
LexicalEditor,
2121
LexicalNode,
22+
NodeKey,
2223
Point,
2324
RangeSelection,
2425
TextNode,
@@ -302,134 +303,67 @@ export function $forEachSelectedTextNode(
302303
fn: (textNode: TextNode) => void,
303304
): void {
304305
const selection = $getSelection();
305-
if (!$isRangeSelection(selection)) {
306+
if (!selection) {
306307
return;
307308
}
308-
const selectedNodes = selection.getNodes();
309-
const selectedNodesLength = selectedNodes.length;
310-
const {anchor, focus} = selection;
311-
312-
const lastIndex = selectedNodesLength - 1;
313-
let firstNode = selectedNodes[0];
314-
let lastNode = selectedNodes[lastIndex];
315309

316-
const firstNodeText = firstNode.getTextContent();
317-
const firstNodeTextLength = firstNodeText.length;
318-
const focusOffset = focus.offset;
319-
let anchorOffset = anchor.offset;
320-
const isBefore = anchor.isBefore(focus);
321-
let startOffset = isBefore ? anchorOffset : focusOffset;
322-
let endOffset = isBefore ? focusOffset : anchorOffset;
323-
const startType = isBefore ? anchor.type : focus.type;
324-
const endType = isBefore ? focus.type : anchor.type;
325-
const endKey = isBefore ? focus.key : anchor.key;
310+
const slicedTextNodes = new Map<
311+
NodeKey,
312+
[startIndex: number, endIndex: number]
313+
>();
314+
const getSliceIndices = (
315+
node: TextNode,
316+
): [startIndex: number, endIndex: number] =>
317+
slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()];
326318

327-
// This is the case where the user only selected the very end of the
328-
// first node so we don't want to include it in the formatting change.
329-
if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
330-
const nextSibling = firstNode.getNextSibling();
319+
if ($isRangeSelection(selection)) {
320+
const {anchor, focus} = selection;
321+
const isBackwards = focus.isBefore(anchor);
322+
const [startPoint, endPoint] = isBackwards
323+
? [focus, anchor]
324+
: [anchor, focus];
331325

332-
if ($isTextNode(nextSibling)) {
333-
// we basically make the second node the firstNode, changing offsets accordingly
334-
anchorOffset = 0;
335-
startOffset = 0;
336-
firstNode = nextSibling;
326+
if (startPoint.type === 'text' && startPoint.offset > 0) {
327+
const endIndex = getSliceIndices(startPoint.getNode())[1];
328+
slicedTextNodes.set(startPoint.key, [
329+
Math.min(startPoint.offset, endIndex),
330+
endIndex,
331+
]);
337332
}
338-
}
339-
340-
// This is the case where we only selected a single node
341-
if (selectedNodes.length === 1) {
342-
if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
343-
startOffset =
344-
startType === 'element'
345-
? 0
346-
: anchorOffset > focusOffset
347-
? focusOffset
348-
: anchorOffset;
349-
endOffset =
350-
endType === 'element'
351-
? firstNodeTextLength
352-
: anchorOffset > focusOffset
353-
? anchorOffset
354-
: focusOffset;
355-
356-
// No actual text is selected, so do nothing.
357-
if (startOffset === endOffset) {
358-
return;
359-
}
360-
361-
// The entire node is selected or a token/segment, so just format it
362-
if (
363-
$isTokenOrSegmented(firstNode) ||
364-
(startOffset === 0 && endOffset === firstNodeTextLength)
365-
) {
366-
fn(firstNode);
367-
firstNode.select(startOffset, endOffset);
368-
} else {
369-
// The node is partially selected, so split it into two nodes
370-
// and style the selected one.
371-
const splitNodes = firstNode.splitText(startOffset, endOffset);
372-
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
373-
fn(replacement);
374-
replacement.select(0, endOffset - startOffset);
375-
}
376-
} // multiple nodes selected.
377-
} else {
378-
if (
379-
$isTextNode(firstNode) &&
380-
startOffset < firstNode.getTextContentSize() &&
381-
firstNode.canHaveFormat()
382-
) {
383-
if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
384-
// the entire first node isn't selected and it isn't a token or segmented, so split it
385-
firstNode = firstNode.splitText(startOffset)[1];
386-
startOffset = 0;
387-
if (isBefore) {
388-
anchor.set(firstNode.getKey(), startOffset, 'text');
389-
} else {
390-
focus.set(firstNode.getKey(), startOffset, 'text');
391-
}
333+
if (endPoint.type === 'text') {
334+
const [startIndex, size] = getSliceIndices(endPoint.getNode());
335+
if (endPoint.offset < size) {
336+
slicedTextNodes.set(endPoint.key, [
337+
startIndex,
338+
Math.max(startIndex, endPoint.offset),
339+
]);
392340
}
393-
394-
fn(firstNode as TextNode);
395341
}
342+
}
396343

397-
if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
398-
const lastNodeText = lastNode.getTextContent();
399-
const lastNodeTextLength = lastNodeText.length;
400-
401-
// The last node might not actually be the end node
402-
//
403-
// If not, assume the last node is fully-selected unless the end offset is
404-
// zero.
405-
if (lastNode.__key !== endKey && endOffset !== 0) {
406-
endOffset = lastNodeTextLength;
407-
}
408-
409-
// if the entire last node isn't selected and it isn't a token or segmented, split it
410-
if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
411-
[lastNode] = lastNode.splitText(endOffset);
412-
}
413-
414-
if (endOffset !== 0 || endType === 'element') {
415-
fn(lastNode as TextNode);
416-
}
344+
const selectedNodes = selection.getNodes();
345+
for (const selectedNode of selectedNodes) {
346+
if (!($isTextNode(selectedNode) && selectedNode.canHaveFormat())) {
347+
continue;
348+
}
349+
const [startOffset, endOffset] = getSliceIndices(selectedNode);
350+
// No actual text is selected, so do nothing.
351+
if (endOffset === startOffset) {
352+
continue;
417353
}
418354

419-
// style all the text nodes in between
420-
for (let i = 1; i < lastIndex; i++) {
421-
const selectedNode = selectedNodes[i];
422-
const selectedNodeKey = selectedNode.getKey();
423-
424-
if (
425-
$isTextNode(selectedNode) &&
426-
selectedNode.canHaveFormat() &&
427-
selectedNodeKey !== firstNode.getKey() &&
428-
selectedNodeKey !== lastNode.getKey() &&
429-
!selectedNode.isToken()
430-
) {
431-
fn(selectedNode as TextNode);
432-
}
355+
// The entire node is selected or a token/segment, so just format it
356+
if (
357+
$isTokenOrSegmented(selectedNode) ||
358+
(startOffset === 0 && endOffset === selectedNode.getTextContentSize())
359+
) {
360+
fn(selectedNode);
361+
} else {
362+
// The node is partially selected, so split it into two or three nodes
363+
// and style the selected one.
364+
const splitNodes = selectedNode.splitText(startOffset, endOffset);
365+
const replacement = splitNodes[startOffset === 0 ? 0 : 1];
366+
fn(replacement);
433367
}
434368
}
435369
}

packages/lexical-utils/src/index.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,10 @@ export function $getNextSiblingOrParentSibling(
269269
return rval && [rval[0].origin, rval[1]];
270270
}
271271

272-
function $getNextSiblingOrParentSiblingCaret(
273-
startCaret: NodeCaret<'next'>,
272+
function $getNextSiblingOrParentSiblingCaret<D extends CaretDirection>(
273+
startCaret: NodeCaret<D>,
274274
rootMode: RootMode = 'root',
275-
): null | [NodeCaret<'next'>, number] {
275+
): null | [NodeCaret<D>, number] {
276276
let depthDiff = 0;
277277
let caret = startCaret;
278278
let nextCaret = $getAdjacentDepthCaret(caret);
@@ -310,10 +310,11 @@ export function $getDepth(node: LexicalNode): number {
310310
export function $getNextRightPreorderNode(
311311
startingNode: LexicalNode,
312312
): LexicalNode | null {
313-
const caret = $getAdjacentDepthCaret(
314-
$getChildCaretOrSelf($getBreadthCaret(startingNode, 'previous')),
313+
const startCaret = $getChildCaretOrSelf(
314+
$getBreadthCaret(startingNode, 'previous'),
315315
);
316-
return caret && caret.origin;
316+
const next = $getNextSiblingOrParentSiblingCaret(startCaret, 'root');
317+
return next && next[0].origin;
317318
}
318319

319320
/**

packages/lexical/src/LexicalSelection.ts

+63-27
Original file line numberDiff line numberDiff line change
@@ -496,33 +496,61 @@ export class RangeSelection implements BaseSelection {
496496
const isBefore = anchor.isBefore(focus);
497497
const firstPoint = isBefore ? anchor : focus;
498498
const lastPoint = isBefore ? focus : anchor;
499-
let firstNode = firstPoint.getNode();
500-
let lastNode = lastPoint.getNode();
501-
const overselectedFirstNode =
502-
$isElementNode(firstNode) &&
503-
firstPoint.offset > 0 &&
504-
firstPoint.offset >= firstNode.getChildrenSize();
505-
const startOffset = firstPoint.offset;
506-
const endOffset = lastPoint.offset;
507-
508-
if ($isElementNode(firstNode)) {
509-
const firstNodeDescendant =
510-
firstNode.getDescendantByIndex<ElementNode>(startOffset);
511-
firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
512-
}
513-
if ($isElementNode(lastNode)) {
514-
let lastNodeDescendant =
515-
lastNode.getDescendantByIndex<ElementNode>(endOffset);
516-
// We don't want to over-select, as node selection infers the child before
517-
// the last descendant, not including that descendant.
518-
if (
519-
lastNodeDescendant !== null &&
520-
lastNodeDescendant !== firstNode &&
521-
lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
522-
) {
523-
lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
499+
const firstPointNode = firstPoint.getNode();
500+
const lastPointNode = lastPoint.getNode();
501+
let firstNode: LexicalNode = firstPointNode;
502+
let lastNode: LexicalNode = lastPointNode;
503+
let overselectedFirstNode = false;
504+
const overselectedLastNodes = new Set<NodeKey>();
505+
506+
if ($isElementNode(firstPointNode)) {
507+
overselectedFirstNode =
508+
firstPoint.offset > 0 &&
509+
firstPoint.offset >= firstPointNode.getChildrenSize();
510+
firstNode =
511+
firstPointNode.getDescendantByIndex(firstPoint.offset) ||
512+
firstPointNode;
513+
}
514+
if ($isElementNode(lastPointNode)) {
515+
const lastPointChild = lastPointNode.getChildAtIndex(lastPoint.offset);
516+
if (lastPointChild) {
517+
overselectedLastNodes.add(lastPointChild.getKey());
518+
lastNode =
519+
($isElementNode(lastPointChild) &&
520+
lastPointChild.getFirstDescendant()) ||
521+
lastPointChild;
522+
for (
523+
let overselected: LexicalNode | null = lastNode;
524+
overselected && !overselected.is(lastPointChild);
525+
overselected = overselected.getParent()
526+
) {
527+
overselectedLastNodes.add(overselected.getKey());
528+
}
529+
} else {
530+
const beforeChild =
531+
lastPoint.offset > 0 &&
532+
lastPointNode.getChildAtIndex(lastPoint.offset - 1);
533+
if (beforeChild) {
534+
// This case is not an overselection
535+
lastNode =
536+
($isElementNode(beforeChild) && beforeChild.getLastDescendant()) ||
537+
beforeChild;
538+
} else {
539+
// It's the last node and we have to find something at or after lastNode
540+
// and mark all of the ancestors inbetween as overselected
541+
lastNode = firstNode;
542+
let parent = lastPointNode.getParent();
543+
for (; parent !== null; parent = parent.getParent()) {
544+
overselectedLastNodes.add(parent.getKey());
545+
const parentSibling = parent.getNextSibling();
546+
if (parentSibling) {
547+
lastNode = parentSibling;
548+
break;
549+
}
550+
}
551+
overselectedLastNodes.add(lastNode.getKey());
552+
}
524553
}
525-
lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
526554
}
527555

528556
let nodes: Array<LexicalNode>;
@@ -534,8 +562,16 @@ export class RangeSelection implements BaseSelection {
534562
nodes = [firstNode];
535563
}
536564
} else {
537-
nodes = firstNode.getNodesBetween(lastNode);
538565
// Prevent over-selection due to the edge case of getDescendantByIndex always returning something #6974
566+
nodes = firstNode.getNodesBetween(lastNode);
567+
if (overselectedLastNodes.size > 0) {
568+
while (
569+
nodes.length > 0 &&
570+
overselectedLastNodes.has(nodes[nodes.length - 1].getKey())
571+
) {
572+
nodes.pop();
573+
}
574+
}
539575
if (overselectedFirstNode) {
540576
const deleteCount = nodes.findIndex(
541577
(node) => !node.is(firstNode) && !node.isBefore(firstNode),

0 commit comments

Comments
 (0)