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 targetElement.setFormat(node.getFormatType());
85 targetElement.setIndent(node.getIndent());
86 node.replace(targetElement, true);
90 function isPointAttached(point: Point): boolean {
91 return point.getNode().isAttached();
94 function $removeParentEmptyElements(startingNode: ElementNode): void {
95 let node: ElementNode | null = startingNode;
97 while (node !== null && !$isRootOrShadowRoot(node)) {
98 const latest = node.getLatest();
99 const parentNode: ElementNode | null = node.getParent<ElementNode>();
101 if (latest.getChildrenSize() === 0) {
111 * Wraps all nodes in the selection into another node of the type returned by createElement.
112 * @param selection - The selection of nodes to be wrapped.
113 * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
114 * @param wrappingElement - An element to append the wrapped selection and its children to.
116 export function $wrapNodes(
117 selection: BaseSelection,
118 createElement: () => ElementNode,
119 wrappingElement: null | ElementNode = null,
121 const anchorAndFocus = selection.getStartEndPoints();
122 const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
123 const nodes = selection.getNodes();
124 const nodesLength = nodes.length;
128 (nodesLength === 0 ||
129 (nodesLength === 1 &&
130 anchor.type === 'element' &&
131 anchor.getNode().getChildrenSize() === 0))
134 anchor.type === 'text'
135 ? anchor.getNode().getParentOrThrow()
137 const children = target.getChildren();
138 let element = createElement();
139 element.setFormat(target.getFormatType());
140 element.setIndent(target.getIndent());
141 children.forEach((child) => element.append(child));
143 if (wrappingElement) {
144 element = wrappingElement.append(element);
147 target.replace(element);
152 let topLevelNode = null;
153 let descendants: LexicalNode[] = [];
154 for (let i = 0; i < nodesLength; i++) {
155 const node = nodes[i];
156 // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
157 // user selected multiple Root-like nodes that have to be treated separately as if they are
158 // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
159 // of each of the cell nodes.
160 if ($isRootOrShadowRoot(node)) {
171 topLevelNode === null ||
172 (topLevelNode !== null && $hasAncestor(node, topLevelNode))
174 descendants.push(node);
183 descendants = [node];
196 * Wraps each node into a new ElementNode.
197 * @param selection - The selection of nodes to wrap.
198 * @param nodes - An array of nodes, generally the descendants of the selection.
199 * @param nodesLength - The length of nodes.
200 * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
201 * @param wrappingElement - An element to wrap all the nodes into.
204 export function $wrapNodesImpl(
205 selection: BaseSelection,
206 nodes: LexicalNode[],
208 createElement: () => ElementNode,
209 wrappingElement: null | ElementNode = null,
211 if (nodes.length === 0) {
215 const firstNode = nodes[0];
216 const elementMapping: Map<NodeKey, ElementNode> = new Map();
218 // The below logic is to find the right target for us to
219 // either insertAfter/insertBefore/append the corresponding
220 // elements to. This is made more complicated due to nested
222 let target = $isElementNode(firstNode)
224 : firstNode.getParentOrThrow();
226 if (target.isInline()) {
227 target = target.getParentOrThrow();
230 let targetIsPrevSibling = false;
231 while (target !== null) {
232 const prevSibling = target.getPreviousSibling<ElementNode>();
234 if (prevSibling !== null) {
235 target = prevSibling;
236 targetIsPrevSibling = true;
240 target = target.getParentOrThrow();
242 if ($isRootOrShadowRoot(target)) {
247 const emptyElements = new Set();
249 // Find any top level empty elements
250 for (let i = 0; i < nodesLength; i++) {
251 const node = nodes[i];
253 if ($isElementNode(node) && node.getChildrenSize() === 0) {
254 emptyElements.add(node.getKey());
258 const movedNodes: Set<NodeKey> = new Set();
260 // Move out all leaf nodes into our elements array.
261 // If we find a top level empty element, also move make
262 // an element for that.
263 for (let i = 0; i < nodesLength; i++) {
264 const node = nodes[i];
265 let parent = node.getParent();
267 if (parent !== null && parent.isInline()) {
268 parent = parent.getParent();
274 !movedNodes.has(node.getKey())
276 const parentKey = parent.getKey();
278 if (elementMapping.get(parentKey) === undefined) {
279 const targetElement = createElement();
280 targetElement.setFormat(parent.getFormatType());
281 targetElement.setIndent(parent.getIndent());
282 elements.push(targetElement);
283 elementMapping.set(parentKey, targetElement);
284 // Move node and its siblings to the new
286 parent.getChildren().forEach((child) => {
287 targetElement.append(child);
288 movedNodes.add(child.getKey());
289 if ($isElementNode(child)) {
290 // Skip nested leaf nodes if the parent has already been moved
291 child.getChildrenKeys().forEach((key) => movedNodes.add(key));
294 $removeParentEmptyElements(parent);
296 } else if (emptyElements.has(node.getKey())) {
298 $isElementNode(node),
299 'Expected node in emptyElements to be an ElementNode',
301 const targetElement = createElement();
302 targetElement.setFormat(node.getFormatType());
303 targetElement.setIndent(node.getIndent());
304 elements.push(targetElement);
309 if (wrappingElement !== null) {
310 for (let i = 0; i < elements.length; i++) {
311 const element = elements[i];
312 wrappingElement.append(element);
315 let lastElement = null;
317 // If our target is Root-like, let's see if we can re-adjust
318 // so that the target is the first child instead.
319 if ($isRootOrShadowRoot(target)) {
320 if (targetIsPrevSibling) {
321 if (wrappingElement !== null) {
322 target.insertAfter(wrappingElement);
324 for (let i = elements.length - 1; i >= 0; i--) {
325 const element = elements[i];
326 target.insertAfter(element);
330 const firstChild = target.getFirstChild();
332 if ($isElementNode(firstChild)) {
336 if (firstChild === null) {
337 if (wrappingElement) {
338 target.append(wrappingElement);
340 for (let i = 0; i < elements.length; i++) {
341 const element = elements[i];
342 target.append(element);
343 lastElement = element;
347 if (wrappingElement !== null) {
348 firstChild.insertBefore(wrappingElement);
350 for (let i = 0; i < elements.length; i++) {
351 const element = elements[i];
352 firstChild.insertBefore(element);
353 lastElement = element;
359 if (wrappingElement) {
360 target.insertAfter(wrappingElement);
362 for (let i = elements.length - 1; i >= 0; i--) {
363 const element = elements[i];
364 target.insertAfter(element);
365 lastElement = element;
370 const prevSelection = $getPreviousSelection();
373 $isRangeSelection(prevSelection) &&
374 isPointAttached(prevSelection.anchor) &&
375 isPointAttached(prevSelection.focus)
377 $setSelection(prevSelection.clone());
378 } else if (lastElement !== null) {
379 lastElement.selectEnd();
381 selection.dirty = true;
386 * Determines if the default character selection should be overridden. Used with DecoratorNodes
387 * @param selection - The selection whose default character selection may need to be overridden.
388 * @param isBackward - Is the selection backwards (the focus comes before the anchor)?
389 * @returns true if it should be overridden, false if not.
391 export function $shouldOverrideDefaultCharacterSelection(
392 selection: RangeSelection,
395 const possibleNode = $getAdjacentNode(selection.focus, isBackward);
398 ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
399 ($isElementNode(possibleNode) &&
400 !possibleNode.isInline() &&
401 !possibleNode.canBeEmpty())
406 * Moves the selection according to the arguments.
407 * @param selection - The selected text or nodes.
408 * @param isHoldingShift - Is the shift key being held down during the operation.
409 * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
410 * @param granularity - The distance to adjust the current selection.
412 export function $moveCaretSelection(
413 selection: RangeSelection,
414 isHoldingShift: boolean,
416 granularity: 'character' | 'word' | 'lineboundary',
418 selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
422 * Tests a parent element for right to left direction.
423 * @param selection - The selection whose parent is to be tested.
424 * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
426 export function $isParentElementRTL(selection: RangeSelection): boolean {
427 const anchorNode = selection.anchor.getNode();
428 const parent = $isRootNode(anchorNode)
430 : anchorNode.getParentOrThrow();
432 return parent.getDirection() === 'rtl';
436 * Moves selection by character according to arguments.
437 * @param selection - The selection of the characters to move.
438 * @param isHoldingShift - Is the shift key being held down during the operation.
439 * @param isBackward - Is the selection backward (the focus comes before the anchor)?
441 export function $moveCharacter(
442 selection: RangeSelection,
443 isHoldingShift: boolean,
446 const isRTL = $isParentElementRTL(selection);
450 isBackward ? !isRTL : isRTL,
456 * Expands the current Selection to cover all of the content in the editor.
457 * @param selection - The current selection.
459 export function $selectAll(selection: RangeSelection): void {
460 const anchor = selection.anchor;
461 const focus = selection.focus;
462 const anchorNode = anchor.getNode();
463 const topParent = anchorNode.getTopLevelElementOrThrow();
464 const root = topParent.getParentOrThrow();
465 let firstNode = root.getFirstDescendant();
466 let lastNode = root.getLastDescendant();
467 let firstType: 'element' | 'text' = 'element';
468 let lastType: 'element' | 'text' = 'element';
471 if ($isTextNode(firstNode)) {
473 } else if (!$isElementNode(firstNode) && firstNode !== null) {
474 firstNode = firstNode.getParentOrThrow();
477 if ($isTextNode(lastNode)) {
479 lastOffset = lastNode.getTextContentSize();
480 } else if (!$isElementNode(lastNode) && lastNode !== null) {
481 lastNode = lastNode.getParentOrThrow();
484 if (firstNode && lastNode) {
485 anchor.set(firstNode.getKey(), 0, firstType);
486 focus.set(lastNode.getKey(), lastOffset, lastType);
491 * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
492 * @param node - The node whose style value to get.
493 * @param styleProperty - The CSS style property.
494 * @param defaultValue - The default value for the property.
495 * @returns The value of the property for node.
497 function $getNodeStyleValueForProperty(
499 styleProperty: string,
500 defaultValue: string,
502 const css = node.getStyle();
503 const styleObject = getStyleObjectFromCSS(css);
505 if (styleObject !== null) {
506 return styleObject[styleProperty] || defaultValue;
513 * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
514 * If all TextNodes do not have the same value, it returns an empty string.
515 * @param selection - The selection of TextNodes whose value to find.
516 * @param styleProperty - The CSS style property.
517 * @param defaultValue - The default value for the property, defaults to an empty string.
518 * @returns The value of the property for the selected TextNodes.
520 export function $getSelectionStyleValueForProperty(
521 selection: RangeSelection | TableSelection,
522 styleProperty: string,
525 let styleValue: string | null = null;
526 const nodes = selection.getNodes();
527 const anchor = selection.anchor;
528 const focus = selection.focus;
529 const isBackward = selection.isBackward();
530 const endOffset = isBackward ? focus.offset : anchor.offset;
531 const endNode = isBackward ? focus.getNode() : anchor.getNode();
534 $isRangeSelection(selection) &&
535 selection.isCollapsed() &&
536 selection.style !== ''
538 const css = selection.style;
539 const styleObject = getStyleObjectFromCSS(css);
541 if (styleObject !== null && styleProperty in styleObject) {
542 return styleObject[styleProperty];
546 for (let i = 0; i < nodes.length; i++) {
547 const node = nodes[i];
549 // if no actual characters in the end node are selected, we don't
550 // include it in the selection for purposes of determining style
552 if (i !== 0 && endOffset === 0 && node.is(endNode)) {
556 if ($isTextNode(node)) {
557 const nodeStyleValue = $getNodeStyleValueForProperty(
563 if (styleValue === null) {
564 styleValue = nodeStyleValue;
565 } else if (styleValue !== nodeStyleValue) {
566 // multiple text nodes are in the selection and they don't all
567 // have the same style.
574 return styleValue === null ? defaultValue : styleValue;
578 * This function is for internal use of the library.
579 * Please do not use it as it may change in the future.
581 export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
582 if ($isDecoratorNode(node)) {
585 if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
589 const firstChild = node.getFirstChild();
590 const isLeafElement =
591 firstChild === null ||
592 $isLineBreakNode(firstChild) ||
593 $isTextNode(firstChild) ||
594 firstChild.isInline();
596 return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
599 export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
601 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
604 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
605 parent = parent.getParentOrThrow();
607 return predicate(parent) ? parent : null;