@lexical/selection
Version:
This package contains utilities and helpers for handling Lexical selection.
740 lines (680 loc) • 24.4 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 {
$caretFromPoint,
$extendCaretToRange,
$findMatchingParent,
$getPreviousSelection,
$hasAncestor,
$isChildCaret,
$isDecoratorNode,
$isElementNode,
$isExtendableTextPointCaret,
$isLeafNode,
$isRangeSelection,
$isRootOrShadowRoot,
$isTabNode,
$isTextNode,
$isTextPointCaret,
$setSelection,
type BaseSelection,
type CaretDirection,
type DecoratorNode,
type ElementNode,
flipDirection,
getStyleObjectFromCSS,
INTERNAL_$isBlock,
type LexicalNode,
type NodeKey,
type Point,
type PointCaret,
type RangeSelection,
type TextNode,
} from 'lexical';
import {$getComputedStyleForElement, $getComputedStyleForParent} from './utils';
export function $copyBlockFormatIndent(
srcNode: ElementNode,
destNode: ElementNode,
): void {
const format = srcNode.getFormatType();
const indent = srcNode.getIndent();
if (format !== destNode.getFormatType()) {
destNode.setFormat(format);
}
if (indent !== destNode.getIndent()) {
destNode.setIndent(indent);
}
}
/**
* Determine whether a point sits at the leading ('previous') or trailing
* ('next') edge of `element`'s content — i.e. there is no content between the
* point and that edge of the element.
*
* This is the caret-based generalization of {@link $isAtNodeEnd}. An empty
* `element` is considered to be at both of its edges. `@lexical/utils`
* re-exports this as the direction-specific `$isAtStartOfNode` /
* `$isAtEndOfNode` helpers.
*
* @param point - The point to test.
* @param element - The ancestor element whose edge is tested.
* @param direction - 'previous' for the start of `element`, 'next' for the end.
*/
export function $isAtEdgeOfElement(
point: Point,
element: ElementNode,
direction: CaretDirection,
): boolean {
// An extendable TextPointCaret has text remaining in `direction`, so the
// point is in the middle of a TextNode rather than at the element edge.
let caret: PointCaret<typeof direction> | null = $caretFromPoint(
point,
direction,
);
if ($isExtendableTextPointCaret(caret)) {
return false;
}
// Walk up towards element: the point is at the edge only when nothing
// precedes it in `direction` at every level up to element. The match is read
// from getParentAtCaret (origin.getParent()) rather than from a CaretRange
// iteration, because iterating ascends via getParentCaret, which stops at the
// document root and at shadow-root/slot boundaries — so it would never yield
// `element` when `element` is itself such a boundary (e.g. a named slot's
// value, a shadow root).
for (; caret; caret = caret.getParentCaret()) {
const parent = caret.getParentAtCaret();
if (!parent || caret.getNodeAtCaret()) {
return false;
}
if (element.is(parent)) {
return true;
}
}
return false;
}
/**
* Determine whether a point sits at the edge of a block in the given
* direction: 'previous' for the start of the block, 'next' for the end.
*
* Unlike {@link $isAtEdgeOfElement}, an empty block is treated as not being at
* the edge: when an ElementNode is empty it's not possible to distinguish if
* the selection's intent is the entire block or the edge so we consider it to
* be the entire block.
*/
function $isPointAtBlockEdge(
point: Point,
block: ElementNode,
direction: CaretDirection,
): boolean {
const node = point.getNode();
if ($isElementNode(node) && node.isEmpty()) {
return false;
}
return $isAtEdgeOfElement(point, block, direction);
}
/**
* Converts all nodes in the selection that are of one block type to another.
* @param selection - The selected blocks to be converted.
* @param $createElement - The function that creates the node. eg. $createParagraphNode.
* @param $afterCreateElement - The function that updates the new node based on the previous one ($copyBlockFormatIndent by default)
*/
export function $setBlocksType<T extends ElementNode>(
selection: BaseSelection | null,
$createElement: () => T,
$afterCreateElement: (
prevNodeSrc: ElementNode,
newNodeDest: T,
) => void = $copyBlockFormatIndent,
): void {
if (!selection) {
return;
}
// Selections tend to not include their containing blocks so we effectively
// expand it here
const anchorAndFocus = selection.getStartEndPoints();
let skipFocus = false;
let focusBlock: ElementNode | DecoratorNode<unknown> | null = null;
const blockMap = new Map<NodeKey, ElementNode>();
if (anchorAndFocus) {
const [anchor, focus] = anchorAndFocus;
const anchorBlock = $findMatchingParent(
anchor.getNode(),
INTERNAL_$isBlock,
);
focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock);
// The focus is the moving edge of the selection, travelling in `direction`
// (towards the end of the document for a forward selection). When a
// selection overshoots, its focus lands at the leading edge of focusBlock
// in that direction — the edge opposite to travel — so focusBlock holds
// none of the selection and is skipped.
const direction = selection.isBackward() ? 'previous' : 'next';
skipFocus =
$isElementNode(focusBlock) &&
!focusBlock.is(anchorBlock) &&
$isPointAtBlockEdge(focus, focusBlock, flipDirection(direction));
if ($isElementNode(anchorBlock)) {
blockMap.set(anchorBlock.getKey(), anchorBlock);
}
if ($isElementNode(focusBlock) && !skipFocus) {
blockMap.set(focusBlock.getKey(), focusBlock);
}
}
for (const node of selection.getNodes()) {
if ($isElementNode(node) && INTERNAL_$isBlock(node)) {
if (skipFocus && node.is(focusBlock)) {
continue;
}
blockMap.set(node.getKey(), node);
} else if (!anchorAndFocus) {
const ancestorBlock = $findMatchingParent(node, INTERNAL_$isBlock);
if ($isElementNode(ancestorBlock)) {
blockMap.set(ancestorBlock.getKey(), ancestorBlock);
}
}
}
// Selection remapping is delegated to LexicalNode.replace (and the
// ListItemNode.replace override): both remap an element-anchored point
// on the replaced block to {key: replacement, offset: prevSize + offset}.
for (const prevNode of blockMap.values()) {
const element = $createElement();
$afterCreateElement(prevNode, element);
prevNode.replace(element, true);
}
}
function isPointAttached(point: Point): boolean {
return point.getNode().isAttached();
}
function $removeParentEmptyElements(startingNode: ElementNode): void {
let node: ElementNode | null = startingNode;
while (node !== null && !$isRootOrShadowRoot(node)) {
const latest = node.getLatest();
// Annotation breaks a circular inference through the loop (TS7022),
// remove when the deprecated generic signatures from #8661 are removed
const parentNode: ElementNode | null = node.getParent();
if (latest.getChildrenSize() === 0) {
node.remove(true);
}
node = parentNode;
}
}
/**
* @deprecated In favor of $setBlockTypes
* Wraps all nodes in the selection into another node of the type returned by createElement.
* @param selection - The selection of nodes to be wrapped.
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
* @param wrappingElement - An element to append the wrapped selection and its children to.
*/
export function $wrapNodes(
selection: BaseSelection,
createElement: () => ElementNode,
wrappingElement: null | ElementNode = null,
): void {
const anchorAndFocus = selection.getStartEndPoints();
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
const nodes = selection.getNodes();
const nodesLength = nodes.length;
if (
anchor !== null &&
(nodesLength === 0 ||
(nodesLength === 1 &&
anchor.type === 'element' &&
anchor.getNode().getChildrenSize() === 0))
) {
const target =
anchor.type === 'text'
? anchor.getNode().getParentOrThrow()
: anchor.getNode();
const children = target.getChildren();
let element = createElement();
element.setFormat(target.getFormatType());
element.setIndent(target.getIndent());
children.forEach(child => element.append(child));
if (wrappingElement) {
element = wrappingElement.append(element);
}
target.replace(element);
return;
}
let topLevelNode = null;
let descendants: LexicalNode[] = [];
for (let i = 0; i < nodesLength; i++) {
const node = nodes[i];
// Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
// user selected multiple Root-like nodes that have to be treated separately as if they are
// their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
// of each of the cell nodes.
if ($isRootOrShadowRoot(node)) {
$wrapNodesImpl(
selection,
descendants,
descendants.length,
createElement,
wrappingElement,
);
descendants = [];
topLevelNode = node;
} else if (
topLevelNode === null ||
(topLevelNode !== null && $hasAncestor(node, topLevelNode))
) {
descendants.push(node);
} else {
$wrapNodesImpl(
selection,
descendants,
descendants.length,
createElement,
wrappingElement,
);
descendants = [node];
}
}
$wrapNodesImpl(
selection,
descendants,
descendants.length,
createElement,
wrappingElement,
);
}
/**
* Wraps each node into a new ElementNode.
* @param selection - The selection of nodes to wrap.
* @param nodes - An array of nodes, generally the descendants of the selection.
* @param nodesLength - The length of nodes.
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
* @param wrappingElement - An element to wrap all the nodes into.
* @returns
*/
export function $wrapNodesImpl(
selection: BaseSelection,
nodes: LexicalNode[],
nodesLength: number,
createElement: () => ElementNode,
wrappingElement: null | ElementNode = null,
): void {
if (nodes.length === 0) {
return;
}
const firstNode = nodes[0];
const elementMapping: Map<NodeKey, ElementNode> = new Map();
const elements = [];
// The below logic is to find the right target for us to
// either insertAfter/insertBefore/append the corresponding
// elements to. This is made more complicated due to nested
// structures.
const firstNodeBlock = $isElementNode(firstNode)
? firstNode
: firstNode.getParentOrThrow();
let target: LexicalNode = firstNodeBlock.isInline()
? firstNodeBlock.getParentOrThrow()
: firstNodeBlock;
let targetIsPrevSibling = false;
while (target !== null) {
// Annotation breaks a circular inference through the loop (TS7022),
// remove when the deprecated generic signatures from #8661 are removed
const prevSibling: LexicalNode | null = target.getPreviousSibling();
if (prevSibling !== null) {
target = prevSibling;
targetIsPrevSibling = true;
break;
}
target = target.getParentOrThrow();
if ($isRootOrShadowRoot(target)) {
break;
}
}
const emptyElements = new Set();
// Find any top level empty elements
for (let i = 0; i < nodesLength; i++) {
const node = nodes[i];
if ($isElementNode(node) && node.getChildrenSize() === 0) {
emptyElements.add(node.getKey());
}
}
const movedNodes: Set<NodeKey> = new Set();
// Move out all leaf nodes into our elements array.
// If we find a top level empty element, also move make
// an element for that.
for (let i = 0; i < nodesLength; i++) {
const node = nodes[i];
let parent = node.getParent();
if (parent !== null && parent.isInline()) {
parent = parent.getParent();
}
if (
parent !== null &&
$isLeafNode(node) &&
!movedNodes.has(node.getKey())
) {
const parentKey = parent.getKey();
if (elementMapping.get(parentKey) === undefined) {
const targetElement = createElement();
targetElement.setFormat(parent.getFormatType());
targetElement.setIndent(parent.getIndent());
elements.push(targetElement);
elementMapping.set(parentKey, targetElement);
// Move node and its siblings to the new
// element.
const children = parent.getChildren();
targetElement.splice(targetElement.getChildrenSize(), 0, children);
for (const child of children) {
movedNodes.add(child.getKey());
if ($isElementNode(child)) {
// Skip nested leaf nodes if the parent has already been moved
for (const key of child.getChildrenKeys()) {
movedNodes.add(key);
}
}
}
$removeParentEmptyElements(parent);
}
} else if (emptyElements.has(node.getKey())) {
invariant(
$isElementNode(node),
'Expected node in emptyElements to be an ElementNode',
);
const targetElement = createElement();
targetElement.setFormat(node.getFormatType());
targetElement.setIndent(node.getIndent());
elements.push(targetElement);
node.remove(true);
}
}
if (wrappingElement !== null) {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
wrappingElement.append(element);
}
}
let lastElement = null;
// If our target is Root-like, let's see if we can re-adjust
// so that the target is the first child instead.
if ($isRootOrShadowRoot(target)) {
if (targetIsPrevSibling) {
if (wrappingElement !== null) {
target.insertAfter(wrappingElement);
} else {
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
target.insertAfter(element);
}
}
} else {
// Capture the narrowed type, the reassignment of target below would
// otherwise widen it back to LexicalNode
const rootTarget = target;
const firstChild = rootTarget.getFirstChild();
if ($isElementNode(firstChild)) {
target = firstChild;
}
if (firstChild === null) {
if (wrappingElement) {
rootTarget.append(wrappingElement);
} else {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
rootTarget.append(element);
lastElement = element;
}
}
} else {
if (wrappingElement !== null) {
firstChild.insertBefore(wrappingElement);
} else {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
firstChild.insertBefore(element);
lastElement = element;
}
}
}
}
} else {
if (wrappingElement) {
target.insertAfter(wrappingElement);
} else {
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
target.insertAfter(element);
lastElement = element;
}
}
}
const prevSelection = $getPreviousSelection();
if (
$isRangeSelection(prevSelection) &&
isPointAttached(prevSelection.anchor) &&
isPointAttached(prevSelection.focus)
) {
$setSelection(prevSelection.clone());
} else if (lastElement !== null) {
lastElement.selectEnd();
} else {
selection.dirty = true;
}
}
/**
* Tests if the selection's parent element has vertical writing mode.
* @param selection - The selection whose parent to test.
* @returns true if the selection's parent has vertical writing mode (writing-mode: vertical-rl), false otherwise.
*/
function $isEditorVerticalOrientation(selection: RangeSelection): boolean {
const computedStyle = $getComputedStyle(selection);
return computedStyle !== null && computedStyle.writingMode === 'vertical-rl';
}
/**
* Gets the computed DOM styles of the parent of the selection's anchor node.
* @param selection - The selection to check the styles for.
* @returns the computed styles of the node or null if there is no DOM element or no default view for the document.
*/
function $getComputedStyle(
selection: RangeSelection,
): CSSStyleDeclaration | null {
const anchorNode = selection.anchor.getNode();
if ($isElementNode(anchorNode)) {
return $getComputedStyleForElement(anchorNode);
}
return $getComputedStyleForParent(anchorNode);
}
/**
* Determines if the default character selection should be overridden. Used with DecoratorNodes
* @param selection - The selection whose default character selection may need to be overridden.
* @param isBackward - Is the selection backwards (the focus comes before the anchor)?
* @returns true if it should be overridden, false if not.
*/
export function $shouldOverrideDefaultCharacterSelection(
selection: RangeSelection,
isBackward: boolean,
): boolean {
const isVertical = $isEditorVerticalOrientation(selection);
// In vertical writing mode, we adjust the direction for correct caret movement
let adjustedIsBackward = isVertical ? !isBackward : isBackward;
// In right-to-left writing mode, we invert the direction for correct caret movement
if ($isParentElementRTL(selection)) {
adjustedIsBackward = !adjustedIsBackward;
}
const focusCaret = $caretFromPoint(
selection.focus,
adjustedIsBackward ? 'previous' : 'next',
);
if ($isExtendableTextPointCaret(focusCaret)) {
return false;
}
// At an unmergeable TextNode boundary adjacent to another plain TextNode,
// override so Lexical's modify() can pre-normalize across inline-grid/flex
// spans (#7301). Restricted to unmergeable nodes to avoid disrupting
// format-affinity at normal bold/italic boundaries.
if (
$isTextPointCaret(focusCaret) &&
!$isTabNode(focusCaret.origin) &&
focusCaret.origin.isUnmergeable()
) {
const sibling = focusCaret.getNodeAtCaret();
if ($isTextNode(sibling) && !$isTabNode(sibling)) {
return true;
}
}
for (const nextCaret of $extendCaretToRange(focusCaret)) {
if ($isChildCaret(nextCaret)) {
return !nextCaret.origin.isInline();
} else if ($isElementNode(nextCaret.origin)) {
continue;
} else if ($isDecoratorNode(nextCaret.origin)) {
return true;
}
break;
}
return false;
}
/**
* Moves the selection according to the arguments.
* @param selection - The selected text or nodes.
* @param isHoldingShift - Is the shift key being held down during the operation.
* @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
* @param granularity - The distance to adjust the current selection.
*/
export function $moveCaretSelection(
selection: RangeSelection,
isHoldingShift: boolean,
isBackward: boolean,
granularity: 'character' | 'word' | 'lineboundary',
): void {
selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
}
/**
* Tests a parent element for right to left direction.
* @param selection - The selection whose parent is to be tested.
* @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
*/
export function $isParentElementRTL(selection: RangeSelection): boolean {
const computedStyle = $getComputedStyle(selection);
return computedStyle !== null && computedStyle.direction === 'rtl';
}
/**
* Moves selection by character according to arguments.
* @param selection - The selection of the characters to move.
* @param isHoldingShift - Is the shift key being held down during the operation.
* @param isBackward - Is the selection backward (the focus comes before the anchor)?
*/
export function $moveCharacter(
selection: RangeSelection,
isHoldingShift: boolean,
isBackward: boolean,
): void {
const isRTL = $isParentElementRTL(selection);
const isVertical = $isEditorVerticalOrientation(selection);
// In vertical-rl writing mode, arrow key directions need to be flipped
// to match the visual flow of text (top to bottom, right to left)
let adjustedIsBackward;
if (isVertical) {
// In vertical-rl mode, we need to completely invert the direction
// Left arrow (backward) should move down (forward)
// Right arrow (forward) should move up (backward)
adjustedIsBackward = !isBackward;
} else if (isRTL) {
// In horizontal RTL mode, use the standard RTL behavior
adjustedIsBackward = !isBackward;
} else {
// Standard LTR horizontal text
adjustedIsBackward = isBackward;
}
// Apply the direction adjustment to move the caret
$moveCaretSelection(
selection,
isHoldingShift,
adjustedIsBackward,
'character',
);
}
/**
* Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
* @param node - The node whose style value to get.
* @param styleProperty - The CSS style property.
* @param defaultValue - The default value for the property.
* @returns The value of the property for node.
*/
function $getNodeStyleValueForProperty(
node: TextNode,
styleProperty: string,
defaultValue: string,
): string {
const css = node.getStyle();
const styleObject = getStyleObjectFromCSS(css);
if (styleObject !== null) {
return styleObject[styleProperty] || defaultValue;
}
return defaultValue;
}
/**
* Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
* If all TextNodes do not have the same value, it returns an empty string.
* @param selection - The selection of TextNodes whose value to find.
* @param styleProperty - The CSS style property.
* @param defaultValue - The default value for the property, defaults to an empty string.
* @returns The value of the property for the selected TextNodes.
*/
export function $getSelectionStyleValueForProperty(
selection: BaseSelection,
styleProperty: string,
defaultValue = '',
): string {
let styleValue: string | null = null;
const nodes = selection.getNodes();
// The anchor/focus boundary handling below is specific to RangeSelection;
// other selection types (e.g. table) style every node they contain.
let startNode: LexicalNode | undefined;
let endNode: LexicalNode | undefined;
if ($isRangeSelection(selection)) {
if (selection.isCollapsed() && selection.style !== '') {
const styleObject = getStyleObjectFromCSS(selection.style);
if (styleObject !== null && styleProperty in styleObject) {
return styleObject[styleProperty];
}
}
const {anchor, focus} = selection;
const isBackward = selection.isBackward();
const firstNode = isBackward ? focus.getNode() : anchor.getNode();
const lastNode = isBackward ? anchor.getNode() : focus.getNode();
const startOffset = isBackward ? focus.offset : anchor.offset;
const endOffset = isBackward ? anchor.offset : focus.offset;
// A boundary node contributes no styled text when the selection merely
// touches its edge: the first node when the start offset is at its very
// end, and the last node when the end offset is at its very beginning.
if (
$isTextNode(firstNode) &&
startOffset === firstNode.getTextContentSize()
) {
startNode = firstNode;
}
if (endOffset === 0) {
endNode = lastNode;
}
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
// Skip the excluded boundary node for this position (startNode at the
// head, endNode elsewhere); both are undefined when nothing is excluded.
if ($isTextNode(node) && !node.is(i === 0 ? startNode : endNode)) {
const nodeStyleValue = $getNodeStyleValueForProperty(
node,
styleProperty,
defaultValue,
);
if (styleValue === null) {
styleValue = nodeStyleValue;
} else if (styleValue !== nodeStyleValue) {
// multiple text nodes are in the selection and they don't all
// have the same style.
styleValue = '';
break;
}
}
}
return styleValue === null ? defaultValue : styleValue;
}