@lexical/selection
Version:
This package contains utilities and helpers for handling Lexical selection.
435 lines (412 loc) • 14.5 kB
text/typescript
/**
* 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.
*
*/
import invariant from '@lexical/internal/invariant';
import warnOnlyOnce from '@lexical/internal/warnOnlyOnce';
import {
$caretRangeFromSelection,
$cloneWithPropertiesEphemeral,
$createTextNode,
$getCharacterOffsets,
$getNodeByKey,
$getPreviousSelection,
$getSelection,
$isElementNode,
$isRangeSelection,
$isRootNode,
$isTextNode,
$isTokenOrSegmented,
BaseSelection,
ElementNode,
getStyleObjectFromCSS,
LexicalEditor,
LexicalNode,
NodeKey,
Point,
RangeSelection,
TextNode,
} from 'lexical';
import {getCSSFromStyleObject} from './utils';
/**
* Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
* it to be generated into the new TextNode.
* @param selection - The selection containing the node whose TextNode is to be edited.
* @param textNode - The TextNode to be edited.
* @param mutates - 'clone' to return a clone before mutating, 'self' to update in-place
* @returns The updated TextNode or clone.
*/
export function $sliceSelectedTextNodeContent<T extends TextNode>(
selection: BaseSelection,
textNode: T,
mutates: 'clone' | 'self' = 'self',
): T {
const anchorAndFocus = selection.getStartEndPoints();
if (
textNode.isSelected(selection) &&
!$isTokenOrSegmented(textNode) &&
anchorAndFocus !== null
) {
const [anchor, focus] = anchorAndFocus;
const isBackward = selection.isBackward();
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
const isAnchor = textNode.is(anchorNode);
const isFocus = textNode.is(focusNode);
if (isAnchor || isFocus) {
const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
const isSame = anchorNode.is(focusNode);
const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
const isLast = textNode.is(isBackward ? anchorNode : focusNode);
let startOffset = 0;
let endOffset = undefined;
if (isSame) {
startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
} else if (isFirst) {
const offset = isBackward ? focusOffset : anchorOffset;
startOffset = offset;
endOffset = undefined;
} else if (isLast) {
const offset = isBackward ? anchorOffset : focusOffset;
startOffset = 0;
endOffset = offset;
}
// NOTE: This mutates __text directly because the primary use case is to
// modify a $cloneWithProperties node that should never be added
// to the EditorState so we must not call getWritable via setTextContent
const text = textNode.__text.slice(startOffset, endOffset);
if (text !== textNode.__text) {
if (mutates === 'clone') {
textNode = $cloneWithPropertiesEphemeral(textNode);
}
textNode.__text = text;
}
}
}
return textNode;
}
/**
* Determines if the current selection is at the end of the node.
* @param point - The point of the selection to test.
* @returns true if the provided point offset is in the last possible position, false otherwise.
*/
export function $isAtNodeEnd(point: Point): boolean {
if (point.type === 'text') {
return point.offset === point.getNode().getTextContentSize();
}
const node = point.getNode();
invariant(
$isElementNode(node),
'isAtNodeEnd: node must be a TextNode or ElementNode',
);
return point.offset === node.getChildrenSize();
}
/**
* Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
* that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
* the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
* @param editor - The lexical editor.
* @param anchor - The anchor of the current selection, where the selection should be pointing.
* @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
*/
export function $trimTextContentFromAnchor(
editor: LexicalEditor,
anchor: Point,
delCount: number,
): void {
// Work from the current selection anchor point
let currentNode: LexicalNode | null = anchor.getNode();
let remaining: number = delCount;
if ($isElementNode(currentNode)) {
const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
if (descendantNode !== null) {
currentNode = descendantNode;
}
}
while (remaining > 0 && currentNode !== null) {
if ($isElementNode(currentNode)) {
const lastDescendant: null | LexicalNode =
currentNode.getLastDescendant<LexicalNode>();
if (lastDescendant !== null) {
currentNode = lastDescendant;
}
}
let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
let additionalElementWhitespace = 0;
if (nextNode === null) {
let parent: LexicalNode | null = currentNode.getParentOrThrow();
let parentSibling: LexicalNode | null = parent.getPreviousSibling();
while (parentSibling === null) {
parent = parent.getParent();
if (parent === null) {
nextNode = null;
break;
}
parentSibling = parent.getPreviousSibling();
}
if (parent !== null) {
additionalElementWhitespace = parent.isInline() ? 0 : 2;
nextNode = parentSibling;
}
}
let text = currentNode.getTextContent();
// If the text is empty, we need to consider adding in two line breaks to match
// the content if we were to get it from its parent.
if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
// TODO: should this be handled in core?
text = '\n\n';
}
const currentNodeSize = text.length;
if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
const parent = currentNode.getParent();
currentNode.remove();
if (
parent != null &&
parent.getChildrenSize() === 0 &&
!$isRootNode(parent)
) {
parent.remove();
}
remaining -= currentNodeSize + additionalElementWhitespace;
currentNode = nextNode;
} else {
const key = currentNode.getKey();
// See if we can just revert it to what was in the last editor state
const prevTextContent: string | null = editor
.getEditorState()
.read(() => {
const prevNode = $getNodeByKey(key);
if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
return prevNode.getTextContent();
}
return null;
});
const offset = currentNodeSize - remaining;
const slicedText = text.slice(0, offset);
if (prevTextContent !== null && prevTextContent !== text) {
const prevSelection = $getPreviousSelection();
let target = currentNode;
if (!currentNode.isSimpleText()) {
const textNode = $createTextNode(prevTextContent);
currentNode.replace(textNode);
target = textNode;
} else {
currentNode.setTextContent(prevTextContent);
}
if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
const prevOffset = prevSelection.anchor.offset;
target.select(prevOffset, prevOffset);
}
} else if (currentNode.isSimpleText()) {
// Split text
const isSelected = anchor.key === key;
let anchorOffset = anchor.offset;
// Move offset to end if it's less than the remaining number, otherwise
// we'll have a negative splitStart.
if (anchorOffset < remaining) {
anchorOffset = currentNodeSize;
}
const splitStart = isSelected ? anchorOffset - remaining : 0;
const splitEnd = isSelected ? anchorOffset : offset;
if (isSelected && splitStart === 0) {
const [excessNode] = currentNode.splitText(splitStart, splitEnd);
excessNode.remove();
} else {
const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
excessNode.remove();
}
} else {
const textNode = $createTextNode(slicedText);
currentNode.replace(textNode);
}
remaining = 0;
}
}
}
/**
* @deprecated node styles are parsed on demand and not cached eternally
*/
export const $addNodeStyle: (_node: TextNode) => void = warnOnlyOnce(
'$addNodeStyle is a deprecated no-op and calls should be removed',
);
/**
* Applies the provided styles to the given TextNode, ElementNode, or
* collapsed RangeSelection.
*
* @param target - The TextNode, ElementNode, or collapsed RangeSelection to apply the styles to
* @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
*/
export function $patchStyle(
target: TextNode | RangeSelection | ElementNode,
patch: Record<
string,
| string
| null
| ((currentStyleValue: string | null, _target: typeof target) => string)
>,
): void {
invariant(
$isRangeSelection(target)
? target.isCollapsed()
: $isTextNode(target) || $isElementNode(target),
'$patchStyle must only be called with a TextNode, ElementNode, or collapsed RangeSelection',
);
const prevStyles = getStyleObjectFromCSS(
$isRangeSelection(target)
? target.style
: $isTextNode(target)
? target.getStyle()
: target.getTextStyle(),
);
const newStyles = Object.entries(patch).reduce<Record<string, string>>(
(styles, [key, value]) => {
if (typeof value === 'function') {
styles[key] = value(prevStyles[key], target);
} else if (value === null) {
delete styles[key];
} else {
styles[key] = value;
}
return styles;
},
{...prevStyles},
);
const newCSSText = getCSSFromStyleObject(newStyles);
if ($isRangeSelection(target) || $isTextNode(target)) {
target.setStyle(newCSSText);
} else {
target.setTextStyle(newCSSText);
}
}
/**
* Applies the provided styles to the TextNodes in the provided Selection.
* Will update partially selected TextNodes by splitting the TextNode and applying
* the styles to the appropriate one.
* @param selection - The selected node(s) to update.
* @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
*/
export function $patchStyleText(
selection: BaseSelection,
patch: Record<
string,
| string
| null
| ((
currentStyleValue: string | null,
target: TextNode | RangeSelection | ElementNode,
) => string)
>,
): void {
if ($isRangeSelection(selection) && selection.isCollapsed()) {
$patchStyle(selection, patch);
const emptyNode = selection.anchor.getNode();
if ($isElementNode(emptyNode) && emptyNode.isEmpty()) {
$patchStyle(emptyNode, patch);
}
}
$forEachSelectedTextNode(textNode => {
$patchStyle(textNode, patch);
});
const nodes = selection.getNodes();
if (nodes.length > 0) {
const patchedElementKeys = new Set<NodeKey>();
for (const node of nodes) {
if (
!$isElementNode(node) ||
!node.canBeEmpty() ||
node.getChildrenSize() !== 0
) {
continue;
}
const key = node.getKey();
if (patchedElementKeys.has(key)) {
continue;
}
patchedElementKeys.add(key);
$patchStyle(node, patch);
}
}
}
export function $forEachSelectedTextNode(
fn: (textNode: TextNode) => void,
): void {
const selection = $getSelection();
if (!selection) {
return;
}
const slicedTextNodes = new Map<
NodeKey,
[startIndex: number, endIndex: number]
>();
const getSliceIndices = (
node: TextNode,
): [startIndex: number, endIndex: number] =>
slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()];
if ($isRangeSelection(selection)) {
for (const slice of $caretRangeFromSelection(selection).getTextSlices()) {
if (slice) {
slicedTextNodes.set(
slice.caret.origin.getKey(),
slice.getSliceIndices(),
);
}
}
}
const selectedNodes = selection.getNodes();
for (const selectedNode of selectedNodes) {
if (!($isTextNode(selectedNode) && selectedNode.canHaveFormat())) {
continue;
}
const [startOffset, endOffset] = getSliceIndices(selectedNode);
// No actual text is selected, so do nothing.
if (endOffset === startOffset) {
continue;
}
// The entire node is selected or a token/segment, so just format it
if (
$isTokenOrSegmented(selectedNode) ||
(startOffset === 0 && endOffset === selectedNode.getTextContentSize())
) {
fn(selectedNode);
} else {
// The node is partially selected, so split it into two or three nodes
// and style the selected one.
const splitNodes = selectedNode.splitText(startOffset, endOffset);
const replacement = splitNodes[startOffset === 0 ? 0 : 1];
fn(replacement);
}
}
// Prior to NodeCaret #7046 this would have been a side-effect
// so we do this for test compatibility.
// TODO: we may want to consider simplifying by removing this
if (
$isRangeSelection(selection) &&
selection.anchor.type === 'text' &&
selection.focus.type === 'text' &&
selection.anchor.key === selection.focus.key
) {
$ensureForwardRangeSelection(selection);
}
}
/**
* Ensure that the given RangeSelection is not backwards. If it
* is backwards, then the anchor and focus points will be swapped
* in-place. Ensuring that the selection is a writable RangeSelection
* is the responsibility of the caller (e.g. in a read-only context
* you will want to clone $getSelection() before using this).
*
* @param selection a writable RangeSelection
*/
export function $ensureForwardRangeSelection(selection: RangeSelection): void {
if (selection.isBackward()) {
const {anchor, focus} = selection;
// stash for the in-place swap
const {key, offset, type} = anchor;
anchor.set(focus.key, focus.offset, focus.type);
focus.set(key, offset, type);
}
}