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.
8 import type {LexicalEditor, LexicalNode} from 'lexical';
10 import {$isTextNode} from 'lexical';
12 import {CSS_TO_STYLES} from './constants';
14 function getDOMTextNode(element: Node | null): Text | null {
17 while (node != null) {
18 if (node.nodeType === Node.TEXT_NODE) {
22 node = node.firstChild;
28 function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {
29 const parent = node.parentNode;
32 throw new Error('Should never happen');
35 return [parent, Array.from(parent.childNodes).indexOf(node)];
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.
47 export function createDOMRange(
48 editor: LexicalEditor,
49 anchorNode: LexicalNode,
50 _anchorOffset: number,
51 focusNode: LexicalNode,
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;
62 if ($isTextNode(anchorNode)) {
63 anchorDOM = getDOMTextNode(anchorDOM);
66 if ($isTextNode(focusNode)) {
67 focusDOM = getDOMTextNode(focusDOM);
71 anchorNode === undefined ||
72 focusNode === undefined ||
79 if (anchorDOM.nodeName === 'BR') {
80 [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);
83 if (focusDOM.nodeName === 'BR') {
84 [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);
87 const firstChild = anchorDOM.firstChild;
90 anchorDOM === focusDOM &&
92 firstChild.nodeName === 'BR' &&
100 range.setStart(anchorDOM, anchorOffset);
101 range.setEnd(focusDOM, focusOffset);
108 (anchorOffset !== focusOffset || anchorKey !== focusKey)
110 // Range is backwards, we need to reverse it
111 range.setStart(focusDOM, focusOffset);
112 range.setEnd(anchorDOM, anchorOffset);
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.
124 export function createRectsFromDOMRange(
125 editor: LexicalEditor,
127 ): Array<ClientRect> {
128 const rootElement = editor.getRootElement();
130 if (rootElement === null) {
133 const rootRect = rootElement.getBoundingClientRect();
134 const computedStyle = getComputedStyle(rootElement);
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;
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 =
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--;
167 prevRect = selectionRect;
169 return selectionRects;
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.
177 export function getStyleObjectFromRawCSS(css: string): Record<string, string> {
178 const styleObject: Record<string, string> = {};
179 const styles = css.split(';');
181 for (const style of styles) {
183 const [key, value] = style.split(/:([^]+)/); // split on first colon
185 styleObject[key.trim()] = value.trim();
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.
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);
206 // Freeze the value in DEV to prevent accidental mutations
207 Object.freeze(value);
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.
218 export function getCSSFromStyleObject(styles: Record<string, string>): string {
221 for (const style in styles) {
223 css += `${style}: ${styles[style]};`;