]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/selection/utils.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / selection / utils.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 type {LexicalEditor, LexicalNode} from 'lexical';
9
10 import {$isTextNode} from 'lexical';
11
12 import {CSS_TO_STYLES} from './constants';
13
14 function getDOMTextNode(element: Node | null): Text | null {
15   let node = element;
16
17   while (node != null) {
18     if (node.nodeType === Node.TEXT_NODE) {
19       return node as Text;
20     }
21
22     node = node.firstChild;
23   }
24
25   return null;
26 }
27
28 function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {
29   const parent = node.parentNode;
30
31   if (parent == null) {
32     throw new Error('Should never happen');
33   }
34
35   return [parent, Array.from(parent.childNodes).indexOf(node)];
36 }
37
38 /**
39  * Creates a selection range for the DOM.
40  * @param editor - The lexical editor.
41  * @param anchorNode - The anchor node of a selection.
42  * @param _anchorOffset - The amount of space offset from the anchor to the focus.
43  * @param focusNode - The current focus.
44  * @param _focusOffset - The amount of space offset from the focus to the anchor.
45  * @returns The range of selection for the DOM that was created.
46  */
47 export function createDOMRange(
48   editor: LexicalEditor,
49   anchorNode: LexicalNode,
50   _anchorOffset: number,
51   focusNode: LexicalNode,
52   _focusOffset: number,
53 ): Range | null {
54   const anchorKey = anchorNode.getKey();
55   const focusKey = focusNode.getKey();
56   const range = document.createRange();
57   let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey);
58   let focusDOM: Node | Text | null = editor.getElementByKey(focusKey);
59   let anchorOffset = _anchorOffset;
60   let focusOffset = _focusOffset;
61
62   if ($isTextNode(anchorNode)) {
63     anchorDOM = getDOMTextNode(anchorDOM);
64   }
65
66   if ($isTextNode(focusNode)) {
67     focusDOM = getDOMTextNode(focusDOM);
68   }
69
70   if (
71     anchorNode === undefined ||
72     focusNode === undefined ||
73     anchorDOM === null ||
74     focusDOM === null
75   ) {
76     return null;
77   }
78
79   if (anchorDOM.nodeName === 'BR') {
80     [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);
81   }
82
83   if (focusDOM.nodeName === 'BR') {
84     [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);
85   }
86
87   const firstChild = anchorDOM.firstChild;
88
89   if (
90     anchorDOM === focusDOM &&
91     firstChild != null &&
92     firstChild.nodeName === 'BR' &&
93     anchorOffset === 0 &&
94     focusOffset === 0
95   ) {
96     focusOffset = 1;
97   }
98
99   try {
100     range.setStart(anchorDOM, anchorOffset);
101     range.setEnd(focusDOM, focusOffset);
102   } catch (e) {
103     return null;
104   }
105
106   if (
107     range.collapsed &&
108     (anchorOffset !== focusOffset || anchorKey !== focusKey)
109   ) {
110     // Range is backwards, we need to reverse it
111     range.setStart(focusDOM, focusOffset);
112     range.setEnd(anchorDOM, anchorOffset);
113   }
114
115   return range;
116 }
117
118 /**
119  * Creates DOMRects, generally used to help the editor find a specific location on the screen.
120  * @param editor - The lexical editor
121  * @param range - A fragment of a document that can contain nodes and parts of text nodes.
122  * @returns The selectionRects as an array.
123  */
124 export function createRectsFromDOMRange(
125   editor: LexicalEditor,
126   range: Range,
127 ): Array<ClientRect> {
128   const rootElement = editor.getRootElement();
129
130   if (rootElement === null) {
131     return [];
132   }
133   const rootRect = rootElement.getBoundingClientRect();
134   const computedStyle = getComputedStyle(rootElement);
135   const rootPadding =
136     parseFloat(computedStyle.paddingLeft) +
137     parseFloat(computedStyle.paddingRight);
138   const selectionRects = Array.from(range.getClientRects());
139   let selectionRectsLength = selectionRects.length;
140   //sort rects from top left to bottom right.
141   selectionRects.sort((a, b) => {
142     const top = a.top - b.top;
143     // Some rects match position closely, but not perfectly,
144     // so we give a 3px tolerance.
145     if (Math.abs(top) <= 3) {
146       return a.left - b.left;
147     }
148     return top;
149   });
150   let prevRect;
151   for (let i = 0; i < selectionRectsLength; i++) {
152     const selectionRect = selectionRects[i];
153     // Exclude rects that overlap preceding Rects in the sorted list.
154     const isOverlappingRect =
155       prevRect &&
156       prevRect.top <= selectionRect.top &&
157       prevRect.top + prevRect.height > selectionRect.top &&
158       prevRect.left + prevRect.width > selectionRect.left;
159     // Exclude selections that span the entire element
160     const selectionSpansElement =
161       selectionRect.width + rootPadding === rootRect.width;
162     if (isOverlappingRect || selectionSpansElement) {
163       selectionRects.splice(i--, 1);
164       selectionRectsLength--;
165       continue;
166     }
167     prevRect = selectionRect;
168   }
169   return selectionRects;
170 }
171
172 /**
173  * Creates an object containing all the styles and their values provided in the CSS string.
174  * @param css - The CSS string of styles and their values.
175  * @returns The styleObject containing all the styles and their values.
176  */
177 export function getStyleObjectFromRawCSS(css: string): Record<string, string> {
178   const styleObject: Record<string, string> = {};
179   const styles = css.split(';');
180
181   for (const style of styles) {
182     if (style !== '') {
183       const [key, value] = style.split(/:([^]+)/); // split on first colon
184       if (key && value) {
185         styleObject[key.trim()] = value.trim();
186       }
187     }
188   }
189
190   return styleObject;
191 }
192
193 /**
194  * Given a CSS string, returns an object from the style cache.
195  * @param css - The CSS property as a string.
196  * @returns The value of the given CSS property.
197  */
198 export function getStyleObjectFromCSS(css: string): Record<string, string> {
199   let value = CSS_TO_STYLES.get(css);
200   if (value === undefined) {
201     value = getStyleObjectFromRawCSS(css);
202     CSS_TO_STYLES.set(css, value);
203   }
204
205   if (__DEV__) {
206     // Freeze the value in DEV to prevent accidental mutations
207     Object.freeze(value);
208   }
209
210   return value;
211 }
212
213 /**
214  * Gets the CSS styles from the style object.
215  * @param styles - The style object containing the styles to get.
216  * @returns A string containing the CSS styles and their values.
217  */
218 export function getCSSFromStyleObject(styles: Record<string, string>): string {
219   let css = '';
220
221   for (const style in styles) {
222     if (style) {
223       css += `${style}: ${styles[style]};`;
224     }
225   }
226
227   return css;
228 }