]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/selection/lexical-node.ts
Dependancies: Updated php & JS deps, updated license lists
[bookstack] / resources / js / wysiwyg / lexical / selection / lexical-node.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8 import {
9   $createTextNode,
10   $getCharacterOffsets,
11   $getNodeByKey,
12   $getPreviousSelection,
13   $isElementNode,
14   $isRangeSelection,
15   $isRootNode,
16   $isTextNode,
17   $isTokenOrSegmented,
18   BaseSelection,
19   LexicalEditor,
20   LexicalNode,
21   Point,
22   RangeSelection,
23   TextNode,
24 } from 'lexical';
25 import invariant from 'lexical/shared/invariant';
26
27 import {CSS_TO_STYLES} from './constants';
28 import {
29   getCSSFromStyleObject,
30   getStyleObjectFromCSS,
31   getStyleObjectFromRawCSS,
32 } from './utils';
33
34 /**
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.
40  */
41 export function $sliceSelectedTextNodeContent(
42   selection: BaseSelection,
43   textNode: TextNode,
44 ): LexicalNode {
45   const anchorAndFocus = selection.getStartEndPoints();
46   if (
47     textNode.isSelected(selection) &&
48     !textNode.isSegmented() &&
49     !textNode.isToken() &&
50     anchorAndFocus !== null
51   ) {
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);
58
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);
64       let startOffset = 0;
65       let endOffset = undefined;
66
67       if (isSame) {
68         startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
69         endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
70       } else if (isFirst) {
71         const offset = isBackward ? focusOffset : anchorOffset;
72         startOffset = offset;
73         endOffset = undefined;
74       } else if (isLast) {
75         const offset = isBackward ? anchorOffset : focusOffset;
76         startOffset = 0;
77         endOffset = offset;
78       }
79
80       textNode.__text = textNode.__text.slice(startOffset, endOffset);
81       return textNode;
82     }
83   }
84   return textNode;
85 }
86
87 /**
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.
91  */
92 export function $isAtNodeEnd(point: Point): boolean {
93   if (point.type === 'text') {
94     return point.offset === point.getNode().getTextContentSize();
95   }
96   const node = point.getNode();
97   invariant(
98     $isElementNode(node),
99     'isAtNodeEnd: node must be a TextNode or ElementNode',
100   );
101
102   return point.offset === node.getChildrenSize();
103 }
104
105 /**
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;
112  */
113 export function $trimTextContentFromAnchor(
114   editor: LexicalEditor,
115   anchor: Point,
116   delCount: number,
117 ): void {
118   // Work from the current selection anchor point
119   let currentNode: LexicalNode | null = anchor.getNode();
120   let remaining: number = delCount;
121
122   if ($isElementNode(currentNode)) {
123     const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
124     if (descendantNode !== null) {
125       currentNode = descendantNode;
126     }
127   }
128
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;
135       }
136     }
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();
142
143       while (parentSibling === null) {
144         parent = parent.getParent();
145         if (parent === null) {
146           nextNode = null;
147           break;
148         }
149         parentSibling = parent.getPreviousSibling();
150       }
151       if (parent !== null) {
152         additionalElementWhitespace = parent.isInline() ? 0 : 2;
153         nextNode = parentSibling;
154       }
155     }
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?
161       text = '\n\n';
162     }
163     const currentNodeSize = text.length;
164
165     if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
166       const parent = currentNode.getParent();
167       currentNode.remove();
168       if (
169         parent != null &&
170         parent.getChildrenSize() === 0 &&
171         !$isRootNode(parent)
172       ) {
173         parent.remove();
174       }
175       remaining -= currentNodeSize + additionalElementWhitespace;
176       currentNode = nextNode;
177     } else {
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
181         .getEditorState()
182         .read(() => {
183           const prevNode = $getNodeByKey(key);
184           if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
185             return prevNode.getTextContent();
186           }
187           return null;
188         });
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);
197           target = textNode;
198         } else {
199           currentNode.setTextContent(prevTextContent);
200         }
201         if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
202           const prevOffset = prevSelection.anchor.offset;
203           target.select(prevOffset, prevOffset);
204         }
205       } else if (currentNode.isSimpleText()) {
206         // Split text
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;
213         }
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);
218           excessNode.remove();
219         } else {
220           const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
221           excessNode.remove();
222         }
223       } else {
224         const textNode = $createTextNode(slicedText);
225         currentNode.replace(textNode);
226       }
227       remaining = 0;
228     }
229   }
230 }
231
232 /**
233  * Gets the TextNode's style object and adds the styles to the CSS.
234  * @param node - The TextNode to add styles to.
235  */
236 export function $addNodeStyle(node: TextNode): void {
237   const CSSText = node.getStyle();
238   const styles = getStyleObjectFromRawCSS(CSSText);
239   CSS_TO_STYLES.set(CSSText, styles);
240 }
241
242 function $patchStyle(
243   target: TextNode | RangeSelection,
244   patch: Record<
245     string,
246     | string
247     | null
248     | ((currentStyleValue: string | null, _target: typeof target) => string)
249   >,
250 ): void {
251   const prevStyles = getStyleObjectFromCSS(
252     'getStyle' in target ? target.getStyle() : target.style,
253   );
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) {
259         delete styles[key];
260       } else {
261         styles[key] = value;
262       }
263       return styles;
264     },
265     {...prevStyles},
266   );
267   const newCSSText = getCSSFromStyleObject(newStyles);
268   target.setStyle(newCSSText);
269   CSS_TO_STYLES.set(newCSSText, newStyles);
270 }
271
272 /**
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.
278  */
279 export function $patchStyleText(
280   selection: BaseSelection,
281   patch: Record<
282     string,
283     | string
284     | null
285     | ((
286         currentStyleValue: string | null,
287         target: TextNode | RangeSelection,
288       ) => string)
289   >,
290 ): void {
291   const selectedNodes = selection.getNodes();
292   const selectedNodesLength = selectedNodes.length;
293   const anchorAndFocus = selection.getStartEndPoints();
294   if (anchorAndFocus === null) {
295     return;
296   }
297   const [anchor, focus] = anchorAndFocus;
298
299   const lastIndex = selectedNodesLength - 1;
300   let firstNode = selectedNodes[0];
301   let lastNode = selectedNodes[lastIndex];
302
303   if (selection.isCollapsed() && $isRangeSelection(selection)) {
304     $patchStyle(selection, patch);
305     return;
306   }
307
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;
318
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();
323
324     if ($isTextNode(nextSibling)) {
325       // we basically make the second node the firstNode, changing offsets accordingly
326       anchorOffset = 0;
327       startOffset = 0;
328       firstNode = nextSibling;
329     }
330   }
331
332   // This is the case where we only selected a single node
333   if (selectedNodes.length === 1) {
334     if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
335       startOffset =
336         startType === 'element'
337           ? 0
338           : anchorOffset > focusOffset
339           ? focusOffset
340           : anchorOffset;
341       endOffset =
342         endType === 'element'
343           ? firstNodeTextLength
344           : anchorOffset > focusOffset
345           ? anchorOffset
346           : focusOffset;
347
348       // No actual text is selected, so do nothing.
349       if (startOffset === endOffset) {
350         return;
351       }
352
353       // The entire node is selected or a token/segment, so just format it
354       if (
355         $isTokenOrSegmented(firstNode) ||
356         (startOffset === 0 && endOffset === firstNodeTextLength)
357       ) {
358         $patchStyle(firstNode, patch);
359         firstNode.select(startOffset, endOffset);
360       } else {
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);
367       }
368     } // multiple nodes selected.
369   } else {
370     if (
371       $isTextNode(firstNode) &&
372       startOffset < firstNode.getTextContentSize() &&
373       firstNode.canHaveFormat()
374     ) {
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];
378         startOffset = 0;
379         if (isBefore) {
380           anchor.set(firstNode.getKey(), startOffset, 'text');
381         } else {
382           focus.set(firstNode.getKey(), startOffset, 'text');
383         }
384       }
385
386       $patchStyle(firstNode as TextNode, patch);
387     }
388
389     if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
390       const lastNodeText = lastNode.getTextContent();
391       const lastNodeTextLength = lastNodeText.length;
392
393       // The last node might not actually be the end node
394       //
395       // If not, assume the last node is fully-selected unless the end offset is
396       // zero.
397       if (lastNode.__key !== endKey && endOffset !== 0) {
398         endOffset = lastNodeTextLength;
399       }
400
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);
404       }
405
406       if (endOffset !== 0 || endType === 'element') {
407         $patchStyle(lastNode as TextNode, patch);
408       }
409     }
410
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();
415
416       if (
417         $isTextNode(selectedNode) &&
418         selectedNode.canHaveFormat() &&
419         selectedNodeKey !== firstNode.getKey() &&
420         selectedNodeKey !== lastNode.getKey() &&
421         !selectedNode.isToken()
422       ) {
423         $patchStyle(selectedNode, patch);
424       }
425     }
426   }
427 }