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.
12 $getPreviousSelection,
25 import invariant from 'lexical/shared/invariant';
27 import {CSS_TO_STYLES} from './constants';
29 getCSSFromStyleObject,
30 getStyleObjectFromCSS,
31 getStyleObjectFromRawCSS,
35 * Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
36 * it to be generated into the new TextNode.
37 * @param selection - The selection containing the node whose TextNode is to be edited.
38 * @param textNode - The TextNode to be edited.
39 * @returns The updated TextNode.
41 export function $sliceSelectedTextNodeContent(
42 selection: BaseSelection,
45 const anchorAndFocus = selection.getStartEndPoints();
47 textNode.isSelected(selection) &&
48 !textNode.isSegmented() &&
49 !textNode.isToken() &&
50 anchorAndFocus !== null
52 const [anchor, focus] = anchorAndFocus;
53 const isBackward = selection.isBackward();
54 const anchorNode = anchor.getNode();
55 const focusNode = focus.getNode();
56 const isAnchor = textNode.is(anchorNode);
57 const isFocus = textNode.is(focusNode);
59 if (isAnchor || isFocus) {
60 const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
61 const isSame = anchorNode.is(focusNode);
62 const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
63 const isLast = textNode.is(isBackward ? anchorNode : focusNode);
65 let endOffset = undefined;
68 startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
69 endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
71 const offset = isBackward ? focusOffset : anchorOffset;
73 endOffset = undefined;
75 const offset = isBackward ? anchorOffset : focusOffset;
80 textNode.__text = textNode.__text.slice(startOffset, endOffset);
88 * Determines if the current selection is at the end of the node.
89 * @param point - The point of the selection to test.
90 * @returns true if the provided point offset is in the last possible position, false otherwise.
92 export function $isAtNodeEnd(point: Point): boolean {
93 if (point.type === 'text') {
94 return point.offset === point.getNode().getTextContentSize();
96 const node = point.getNode();
99 'isAtNodeEnd: node must be a TextNode or ElementNode',
102 return point.offset === node.getChildrenSize();
106 * Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
107 * that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
108 * the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
109 * @param editor - The lexical editor.
110 * @param anchor - The anchor of the current selection, where the selection should be pointing.
111 * @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
113 export function $trimTextContentFromAnchor(
114 editor: LexicalEditor,
118 // Work from the current selection anchor point
119 let currentNode: LexicalNode | null = anchor.getNode();
120 let remaining: number = delCount;
122 if ($isElementNode(currentNode)) {
123 const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
124 if (descendantNode !== null) {
125 currentNode = descendantNode;
129 while (remaining > 0 && currentNode !== null) {
130 if ($isElementNode(currentNode)) {
131 const lastDescendant: null | LexicalNode =
132 currentNode.getLastDescendant<LexicalNode>();
133 if (lastDescendant !== null) {
134 currentNode = lastDescendant;
137 let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
138 let additionalElementWhitespace = 0;
139 if (nextNode === null) {
140 let parent: LexicalNode | null = currentNode.getParentOrThrow();
141 let parentSibling: LexicalNode | null = parent.getPreviousSibling();
143 while (parentSibling === null) {
144 parent = parent.getParent();
145 if (parent === null) {
149 parentSibling = parent.getPreviousSibling();
151 if (parent !== null) {
152 additionalElementWhitespace = parent.isInline() ? 0 : 2;
153 nextNode = parentSibling;
156 let text = currentNode.getTextContent();
157 // If the text is empty, we need to consider adding in two line breaks to match
158 // the content if we were to get it from its parent.
159 if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
160 // TODO: should this be handled in core?
163 const currentNodeSize = text.length;
165 if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
166 const parent = currentNode.getParent();
167 currentNode.remove();
170 parent.getChildrenSize() === 0 &&
175 remaining -= currentNodeSize + additionalElementWhitespace;
176 currentNode = nextNode;
178 const key = currentNode.getKey();
179 // See if we can just revert it to what was in the last editor state
180 const prevTextContent: string | null = editor
183 const prevNode = $getNodeByKey(key);
184 if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
185 return prevNode.getTextContent();
189 const offset = currentNodeSize - remaining;
190 const slicedText = text.slice(0, offset);
191 if (prevTextContent !== null && prevTextContent !== text) {
192 const prevSelection = $getPreviousSelection();
193 let target = currentNode;
194 if (!currentNode.isSimpleText()) {
195 const textNode = $createTextNode(prevTextContent);
196 currentNode.replace(textNode);
199 currentNode.setTextContent(prevTextContent);
201 if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
202 const prevOffset = prevSelection.anchor.offset;
203 target.select(prevOffset, prevOffset);
205 } else if (currentNode.isSimpleText()) {
207 const isSelected = anchor.key === key;
208 let anchorOffset = anchor.offset;
209 // Move offset to end if it's less than the remaining number, otherwise
210 // we'll have a negative splitStart.
211 if (anchorOffset < remaining) {
212 anchorOffset = currentNodeSize;
214 const splitStart = isSelected ? anchorOffset - remaining : 0;
215 const splitEnd = isSelected ? anchorOffset : offset;
216 if (isSelected && splitStart === 0) {
217 const [excessNode] = currentNode.splitText(splitStart, splitEnd);
220 const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
224 const textNode = $createTextNode(slicedText);
225 currentNode.replace(textNode);
233 * Gets the TextNode's style object and adds the styles to the CSS.
234 * @param node - The TextNode to add styles to.
236 export function $addNodeStyle(node: TextNode): void {
237 const CSSText = node.getStyle();
238 const styles = getStyleObjectFromRawCSS(CSSText);
239 CSS_TO_STYLES.set(CSSText, styles);
242 function $patchStyle(
243 target: TextNode | RangeSelection,
248 | ((currentStyleValue: string | null, _target: typeof target) => string)
251 const prevStyles = getStyleObjectFromCSS(
252 'getStyle' in target ? target.getStyle() : target.style,
254 const newStyles = Object.entries(patch).reduce<Record<string, string>>(
255 (styles, [key, value]) => {
256 if (typeof value === 'function') {
257 styles[key] = value(prevStyles[key], target);
258 } else if (value === null) {
267 const newCSSText = getCSSFromStyleObject(newStyles);
268 target.setStyle(newCSSText);
269 CSS_TO_STYLES.set(newCSSText, newStyles);
273 * Applies the provided styles to the TextNodes in the provided Selection.
274 * Will update partially selected TextNodes by splitting the TextNode and applying
275 * the styles to the appropriate one.
276 * @param selection - The selected node(s) to update.
277 * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
279 export function $patchStyleText(
280 selection: BaseSelection,
286 currentStyleValue: string | null,
287 target: TextNode | RangeSelection,
291 const selectedNodes = selection.getNodes();
292 const selectedNodesLength = selectedNodes.length;
293 const anchorAndFocus = selection.getStartEndPoints();
294 if (anchorAndFocus === null) {
297 const [anchor, focus] = anchorAndFocus;
299 const lastIndex = selectedNodesLength - 1;
300 let firstNode = selectedNodes[0];
301 let lastNode = selectedNodes[lastIndex];
303 if (selection.isCollapsed() && $isRangeSelection(selection)) {
304 $patchStyle(selection, patch);
308 const firstNodeText = firstNode.getTextContent();
309 const firstNodeTextLength = firstNodeText.length;
310 const focusOffset = focus.offset;
311 let anchorOffset = anchor.offset;
312 const isBefore = anchor.isBefore(focus);
313 let startOffset = isBefore ? anchorOffset : focusOffset;
314 let endOffset = isBefore ? focusOffset : anchorOffset;
315 const startType = isBefore ? anchor.type : focus.type;
316 const endType = isBefore ? focus.type : anchor.type;
317 const endKey = isBefore ? focus.key : anchor.key;
319 // This is the case where the user only selected the very end of the
320 // first node so we don't want to include it in the formatting change.
321 if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
322 const nextSibling = firstNode.getNextSibling();
324 if ($isTextNode(nextSibling)) {
325 // we basically make the second node the firstNode, changing offsets accordingly
328 firstNode = nextSibling;
332 // This is the case where we only selected a single node
333 if (selectedNodes.length === 1) {
334 if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
336 startType === 'element'
338 : anchorOffset > focusOffset
342 endType === 'element'
343 ? firstNodeTextLength
344 : anchorOffset > focusOffset
348 // No actual text is selected, so do nothing.
349 if (startOffset === endOffset) {
353 // The entire node is selected or a token/segment, so just format it
355 $isTokenOrSegmented(firstNode) ||
356 (startOffset === 0 && endOffset === firstNodeTextLength)
358 $patchStyle(firstNode, patch);
359 firstNode.select(startOffset, endOffset);
361 // The node is partially selected, so split it into two nodes
362 // and style the selected one.
363 const splitNodes = firstNode.splitText(startOffset, endOffset);
364 const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
365 $patchStyle(replacement, patch);
366 replacement.select(0, endOffset - startOffset);
368 } // multiple nodes selected.
371 $isTextNode(firstNode) &&
372 startOffset < firstNode.getTextContentSize() &&
373 firstNode.canHaveFormat()
375 if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
376 // the entire first node isn't selected and it isn't a token or segmented, so split it
377 firstNode = firstNode.splitText(startOffset)[1];
380 anchor.set(firstNode.getKey(), startOffset, 'text');
382 focus.set(firstNode.getKey(), startOffset, 'text');
386 $patchStyle(firstNode as TextNode, patch);
389 if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
390 const lastNodeText = lastNode.getTextContent();
391 const lastNodeTextLength = lastNodeText.length;
393 // The last node might not actually be the end node
395 // If not, assume the last node is fully-selected unless the end offset is
397 if (lastNode.__key !== endKey && endOffset !== 0) {
398 endOffset = lastNodeTextLength;
401 // if the entire last node isn't selected and it isn't a token or segmented, split it
402 if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
403 [lastNode] = lastNode.splitText(endOffset);
406 if (endOffset !== 0 || endType === 'element') {
407 $patchStyle(lastNode as TextNode, patch);
411 // style all the text nodes in between
412 for (let i = 1; i < lastIndex; i++) {
413 const selectedNode = selectedNodes[i];
414 const selectedNodeKey = selectedNode.getKey();
417 $isTextNode(selectedNode) &&
418 selectedNode.canHaveFormat() &&
419 selectedNodeKey !== firstNode.getKey() &&
420 selectedNodeKey !== lastNode.getKey() &&
421 !selectedNode.isToken()
423 $patchStyle(selectedNode, patch);