2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
19 import {TableSelection} from '@lexical/table';
22 $getPreviousSelection,
35 import invariant from 'lexical/shared/invariant';
37 import {getStyleObjectFromCSS} from './utils';
40 * Converts all nodes in the selection that are of one block type to another.
41 * @param selection - The selected blocks to be converted.
42 * @param createElement - The function that creates the node. eg. $createParagraphNode.
44 export function $setBlocksType(
45 selection: BaseSelection | null,
46 createElement: () => ElementNode,
48 if (selection === null) {
51 const anchorAndFocus = selection.getStartEndPoints();
52 const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
54 if (anchor !== null && anchor.key === 'root') {
55 const element = createElement();
56 const root = $getRoot();
57 const firstChild = root.getFirstChild();
60 firstChild.replace(element, true);
68 const nodes = selection.getNodes();
69 const firstSelectedBlock =
70 anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false;
71 if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) {
72 nodes.push(firstSelectedBlock);
75 for (let i = 0; i < nodes.length; i++) {
76 const node = nodes[i];
78 if (!INTERNAL_$isBlock(node)) {
81 invariant($isElementNode(node), 'Expected block node to be an ElementNode');
83 const targetElement = createElement();
84 node.replace(targetElement, true);
88 function isPointAttached(point: Point): boolean {
89 return point.getNode().isAttached();
92 function $removeParentEmptyElements(startingNode: ElementNode): void {
93 let node: ElementNode | null = startingNode;
95 while (node !== null && !$isRootOrShadowRoot(node)) {
96 const latest = node.getLatest();
97 const parentNode: ElementNode | null = node.getParent<ElementNode>();
99 if (latest.getChildrenSize() === 0) {
109 * Wraps all nodes in the selection into another node of the type returned by createElement.
110 * @param selection - The selection of nodes to be wrapped.
111 * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
112 * @param wrappingElement - An element to append the wrapped selection and its children to.
114 export function $wrapNodes(
115 selection: BaseSelection,
116 createElement: () => ElementNode,
117 wrappingElement: null | ElementNode = null,
119 const anchorAndFocus = selection.getStartEndPoints();
120 const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
121 const nodes = selection.getNodes();
122 const nodesLength = nodes.length;
126 (nodesLength === 0 ||
127 (nodesLength === 1 &&
128 anchor.type === 'element' &&
129 anchor.getNode().getChildrenSize() === 0))
132 anchor.type === 'text'
133 ? anchor.getNode().getParentOrThrow()
135 const children = target.getChildren();
136 let element = createElement();
137 children.forEach((child) => element.append(child));
139 if (wrappingElement) {
140 element = wrappingElement.append(element);
143 target.replace(element);
148 let topLevelNode = null;
149 let descendants: LexicalNode[] = [];
150 for (let i = 0; i < nodesLength; i++) {
151 const node = nodes[i];
152 // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
153 // user selected multiple Root-like nodes that have to be treated separately as if they are
154 // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
155 // of each of the cell nodes.
156 if ($isRootOrShadowRoot(node)) {
167 topLevelNode === null ||
168 (topLevelNode !== null && $hasAncestor(node, topLevelNode))
170 descendants.push(node);
179 descendants = [node];
192 * Wraps each node into a new ElementNode.
193 * @param selection - The selection of nodes to wrap.
194 * @param nodes - An array of nodes, generally the descendants of the selection.
195 * @param nodesLength - The length of nodes.
196 * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
197 * @param wrappingElement - An element to wrap all the nodes into.
200 export function $wrapNodesImpl(
201 selection: BaseSelection,
202 nodes: LexicalNode[],
204 createElement: () => ElementNode,
205 wrappingElement: null | ElementNode = null,
207 if (nodes.length === 0) {
211 const firstNode = nodes[0];
212 const elementMapping: Map<NodeKey, ElementNode> = new Map();
214 // The below logic is to find the right target for us to
215 // either insertAfter/insertBefore/append the corresponding
216 // elements to. This is made more complicated due to nested
218 let target = $isElementNode(firstNode)
220 : firstNode.getParentOrThrow();
222 if (target.isInline()) {
223 target = target.getParentOrThrow();
226 let targetIsPrevSibling = false;
227 while (target !== null) {
228 const prevSibling = target.getPreviousSibling<ElementNode>();
230 if (prevSibling !== null) {
231 target = prevSibling;
232 targetIsPrevSibling = true;
236 target = target.getParentOrThrow();
238 if ($isRootOrShadowRoot(target)) {
243 const emptyElements = new Set();
245 // Find any top level empty elements
246 for (let i = 0; i < nodesLength; i++) {
247 const node = nodes[i];
249 if ($isElementNode(node) && node.getChildrenSize() === 0) {
250 emptyElements.add(node.getKey());
254 const movedNodes: Set<NodeKey> = new Set();
256 // Move out all leaf nodes into our elements array.
257 // If we find a top level empty element, also move make
258 // an element for that.
259 for (let i = 0; i < nodesLength; i++) {
260 const node = nodes[i];
261 let parent = node.getParent();
263 if (parent !== null && parent.isInline()) {
264 parent = parent.getParent();
270 !movedNodes.has(node.getKey())
272 const parentKey = parent.getKey();
274 if (elementMapping.get(parentKey) === undefined) {
275 const targetElement = createElement();
276 elements.push(targetElement);
277 elementMapping.set(parentKey, targetElement);
278 // Move node and its siblings to the new
280 parent.getChildren().forEach((child) => {
281 targetElement.append(child);
282 movedNodes.add(child.getKey());
283 if ($isElementNode(child)) {
284 // Skip nested leaf nodes if the parent has already been moved
285 child.getChildrenKeys().forEach((key) => movedNodes.add(key));
288 $removeParentEmptyElements(parent);
290 } else if (emptyElements.has(node.getKey())) {
292 $isElementNode(node),
293 'Expected node in emptyElements to be an ElementNode',
295 const targetElement = createElement();
296 elements.push(targetElement);
301 if (wrappingElement !== null) {
302 for (let i = 0; i < elements.length; i++) {
303 const element = elements[i];
304 wrappingElement.append(element);
307 let lastElement = null;
309 // If our target is Root-like, let's see if we can re-adjust
310 // so that the target is the first child instead.
311 if ($isRootOrShadowRoot(target)) {
312 if (targetIsPrevSibling) {
313 if (wrappingElement !== null) {
314 target.insertAfter(wrappingElement);
316 for (let i = elements.length - 1; i >= 0; i--) {
317 const element = elements[i];
318 target.insertAfter(element);
322 const firstChild = target.getFirstChild();
324 if ($isElementNode(firstChild)) {
328 if (firstChild === null) {
329 if (wrappingElement) {
330 target.append(wrappingElement);
332 for (let i = 0; i < elements.length; i++) {
333 const element = elements[i];
334 target.append(element);
335 lastElement = element;
339 if (wrappingElement !== null) {
340 firstChild.insertBefore(wrappingElement);
342 for (let i = 0; i < elements.length; i++) {
343 const element = elements[i];
344 firstChild.insertBefore(element);
345 lastElement = element;
351 if (wrappingElement) {
352 target.insertAfter(wrappingElement);
354 for (let i = elements.length - 1; i >= 0; i--) {
355 const element = elements[i];
356 target.insertAfter(element);
357 lastElement = element;
362 const prevSelection = $getPreviousSelection();
365 $isRangeSelection(prevSelection) &&
366 isPointAttached(prevSelection.anchor) &&
367 isPointAttached(prevSelection.focus)
369 $setSelection(prevSelection.clone());
370 } else if (lastElement !== null) {
371 lastElement.selectEnd();
373 selection.dirty = true;
378 * Determines if the default character selection should be overridden. Used with DecoratorNodes
379 * @param selection - The selection whose default character selection may need to be overridden.
380 * @param isBackward - Is the selection backwards (the focus comes before the anchor)?
381 * @returns true if it should be overridden, false if not.
383 export function $shouldOverrideDefaultCharacterSelection(
384 selection: RangeSelection,
387 const possibleNode = $getAdjacentNode(selection.focus, isBackward);
390 ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
391 ($isElementNode(possibleNode) &&
392 !possibleNode.isInline() &&
393 !possibleNode.canBeEmpty())
398 * Moves the selection according to the arguments.
399 * @param selection - The selected text or nodes.
400 * @param isHoldingShift - Is the shift key being held down during the operation.
401 * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
402 * @param granularity - The distance to adjust the current selection.
404 export function $moveCaretSelection(
405 selection: RangeSelection,
406 isHoldingShift: boolean,
408 granularity: 'character' | 'word' | 'lineboundary',
410 selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
414 * Tests a parent element for right to left direction.
415 * @param selection - The selection whose parent is to be tested.
416 * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
418 export function $isParentElementRTL(selection: RangeSelection): boolean {
419 const anchorNode = selection.anchor.getNode();
420 const parent = $isRootNode(anchorNode)
422 : anchorNode.getParentOrThrow();
424 return parent.getDirection() === 'rtl';
428 * Moves selection by character according to arguments.
429 * @param selection - The selection of the characters to move.
430 * @param isHoldingShift - Is the shift key being held down during the operation.
431 * @param isBackward - Is the selection backward (the focus comes before the anchor)?
433 export function $moveCharacter(
434 selection: RangeSelection,
435 isHoldingShift: boolean,
438 const isRTL = $isParentElementRTL(selection);
442 isBackward ? !isRTL : isRTL,
448 * Expands the current Selection to cover all of the content in the editor.
449 * @param selection - The current selection.
451 export function $selectAll(selection: RangeSelection): void {
452 const anchor = selection.anchor;
453 const focus = selection.focus;
454 const anchorNode = anchor.getNode();
455 const topParent = anchorNode.getTopLevelElementOrThrow();
456 const root = topParent.getParentOrThrow();
457 let firstNode = root.getFirstDescendant();
458 let lastNode = root.getLastDescendant();
459 let firstType: 'element' | 'text' = 'element';
460 let lastType: 'element' | 'text' = 'element';
463 if ($isTextNode(firstNode)) {
465 } else if (!$isElementNode(firstNode) && firstNode !== null) {
466 firstNode = firstNode.getParentOrThrow();
469 if ($isTextNode(lastNode)) {
471 lastOffset = lastNode.getTextContentSize();
472 } else if (!$isElementNode(lastNode) && lastNode !== null) {
473 lastNode = lastNode.getParentOrThrow();
476 if (firstNode && lastNode) {
477 anchor.set(firstNode.getKey(), 0, firstType);
478 focus.set(lastNode.getKey(), lastOffset, lastType);
483 * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
484 * @param node - The node whose style value to get.
485 * @param styleProperty - The CSS style property.
486 * @param defaultValue - The default value for the property.
487 * @returns The value of the property for node.
489 function $getNodeStyleValueForProperty(
491 styleProperty: string,
492 defaultValue: string,
494 const css = node.getStyle();
495 const styleObject = getStyleObjectFromCSS(css);
497 if (styleObject !== null) {
498 return styleObject[styleProperty] || defaultValue;
505 * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
506 * If all TextNodes do not have the same value, it returns an empty string.
507 * @param selection - The selection of TextNodes whose value to find.
508 * @param styleProperty - The CSS style property.
509 * @param defaultValue - The default value for the property, defaults to an empty string.
510 * @returns The value of the property for the selected TextNodes.
512 export function $getSelectionStyleValueForProperty(
513 selection: RangeSelection | TableSelection,
514 styleProperty: string,
517 let styleValue: string | null = null;
518 const nodes = selection.getNodes();
519 const anchor = selection.anchor;
520 const focus = selection.focus;
521 const isBackward = selection.isBackward();
522 const endOffset = isBackward ? focus.offset : anchor.offset;
523 const endNode = isBackward ? focus.getNode() : anchor.getNode();
526 $isRangeSelection(selection) &&
527 selection.isCollapsed() &&
528 selection.style !== ''
530 const css = selection.style;
531 const styleObject = getStyleObjectFromCSS(css);
533 if (styleObject !== null && styleProperty in styleObject) {
534 return styleObject[styleProperty];
538 for (let i = 0; i < nodes.length; i++) {
539 const node = nodes[i];
541 // if no actual characters in the end node are selected, we don't
542 // include it in the selection for purposes of determining style
544 if (i !== 0 && endOffset === 0 && node.is(endNode)) {
548 if ($isTextNode(node)) {
549 const nodeStyleValue = $getNodeStyleValueForProperty(
555 if (styleValue === null) {
556 styleValue = nodeStyleValue;
557 } else if (styleValue !== nodeStyleValue) {
558 // multiple text nodes are in the selection and they don't all
559 // have the same style.
566 return styleValue === null ? defaultValue : styleValue;
570 * This function is for internal use of the library.
571 * Please do not use it as it may change in the future.
573 export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
574 if ($isDecoratorNode(node)) {
577 if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
581 const firstChild = node.getFirstChild();
582 const isLeafElement =
583 firstChild === null ||
584 $isLineBreakNode(firstChild) ||
585 $isTextNode(firstChild) ||
586 firstChild.isInline();
588 return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
591 export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
593 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
596 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
597 parent = parent.getParentOrThrow();
599 return predicate(parent) ? parent : null;