229 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
		
		
			
		
	
	
			229 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
|  | /** | ||
|  |  * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|  |  * | ||
|  |  * This source code is licensed under the MIT license found in the | ||
|  |  * LICENSE file in the root directory of this source tree. | ||
|  |  * | ||
|  |  */ | ||
|  | import type {LexicalEditor, LexicalNode} from 'lexical'; | ||
|  | 
 | ||
|  | import {$isTextNode} from 'lexical'; | ||
|  | 
 | ||
|  | import {CSS_TO_STYLES} from './constants'; | ||
|  | 
 | ||
|  | function getDOMTextNode(element: Node | null): Text | null { | ||
|  |   let node = element; | ||
|  | 
 | ||
|  |   while (node != null) { | ||
|  |     if (node.nodeType === Node.TEXT_NODE) { | ||
|  |       return node as Text; | ||
|  |     } | ||
|  | 
 | ||
|  |     node = node.firstChild; | ||
|  |   } | ||
|  | 
 | ||
|  |   return null; | ||
|  | } | ||
|  | 
 | ||
|  | function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] { | ||
|  |   const parent = node.parentNode; | ||
|  | 
 | ||
|  |   if (parent == null) { | ||
|  |     throw new Error('Should never happen'); | ||
|  |   } | ||
|  | 
 | ||
|  |   return [parent, Array.from(parent.childNodes).indexOf(node)]; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Creates a selection range for the DOM. | ||
|  |  * @param editor - The lexical editor. | ||
|  |  * @param anchorNode - The anchor node of a selection. | ||
|  |  * @param _anchorOffset - The amount of space offset from the anchor to the focus. | ||
|  |  * @param focusNode - The current focus. | ||
|  |  * @param _focusOffset - The amount of space offset from the focus to the anchor. | ||
|  |  * @returns The range of selection for the DOM that was created. | ||
|  |  */ | ||
|  | export function createDOMRange( | ||
|  |   editor: LexicalEditor, | ||
|  |   anchorNode: LexicalNode, | ||
|  |   _anchorOffset: number, | ||
|  |   focusNode: LexicalNode, | ||
|  |   _focusOffset: number, | ||
|  | ): Range | null { | ||
|  |   const anchorKey = anchorNode.getKey(); | ||
|  |   const focusKey = focusNode.getKey(); | ||
|  |   const range = document.createRange(); | ||
|  |   let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey); | ||
|  |   let focusDOM: Node | Text | null = editor.getElementByKey(focusKey); | ||
|  |   let anchorOffset = _anchorOffset; | ||
|  |   let focusOffset = _focusOffset; | ||
|  | 
 | ||
|  |   if ($isTextNode(anchorNode)) { | ||
|  |     anchorDOM = getDOMTextNode(anchorDOM); | ||
|  |   } | ||
|  | 
 | ||
|  |   if ($isTextNode(focusNode)) { | ||
|  |     focusDOM = getDOMTextNode(focusDOM); | ||
|  |   } | ||
|  | 
 | ||
|  |   if ( | ||
|  |     anchorNode === undefined || | ||
|  |     focusNode === undefined || | ||
|  |     anchorDOM === null || | ||
|  |     focusDOM === null | ||
|  |   ) { | ||
|  |     return null; | ||
|  |   } | ||
|  | 
 | ||
|  |   if (anchorDOM.nodeName === 'BR') { | ||
|  |     [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode); | ||
|  |   } | ||
|  | 
 | ||
|  |   if (focusDOM.nodeName === 'BR') { | ||
|  |     [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode); | ||
|  |   } | ||
|  | 
 | ||
|  |   const firstChild = anchorDOM.firstChild; | ||
|  | 
 | ||
|  |   if ( | ||
|  |     anchorDOM === focusDOM && | ||
|  |     firstChild != null && | ||
|  |     firstChild.nodeName === 'BR' && | ||
|  |     anchorOffset === 0 && | ||
|  |     focusOffset === 0 | ||
|  |   ) { | ||
|  |     focusOffset = 1; | ||
|  |   } | ||
|  | 
 | ||
|  |   try { | ||
|  |     range.setStart(anchorDOM, anchorOffset); | ||
|  |     range.setEnd(focusDOM, focusOffset); | ||
|  |   } catch (e) { | ||
|  |     return null; | ||
|  |   } | ||
|  | 
 | ||
|  |   if ( | ||
|  |     range.collapsed && | ||
|  |     (anchorOffset !== focusOffset || anchorKey !== focusKey) | ||
|  |   ) { | ||
|  |     // Range is backwards, we need to reverse it
 | ||
|  |     range.setStart(focusDOM, focusOffset); | ||
|  |     range.setEnd(anchorDOM, anchorOffset); | ||
|  |   } | ||
|  | 
 | ||
|  |   return range; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Creates DOMRects, generally used to help the editor find a specific location on the screen. | ||
|  |  * @param editor - The lexical editor | ||
|  |  * @param range - A fragment of a document that can contain nodes and parts of text nodes. | ||
|  |  * @returns The selectionRects as an array. | ||
|  |  */ | ||
|  | export function createRectsFromDOMRange( | ||
|  |   editor: LexicalEditor, | ||
|  |   range: Range, | ||
|  | ): Array<ClientRect> { | ||
|  |   const rootElement = editor.getRootElement(); | ||
|  | 
 | ||
|  |   if (rootElement === null) { | ||
|  |     return []; | ||
|  |   } | ||
|  |   const rootRect = rootElement.getBoundingClientRect(); | ||
|  |   const computedStyle = getComputedStyle(rootElement); | ||
|  |   const rootPadding = | ||
|  |     parseFloat(computedStyle.paddingLeft) + | ||
|  |     parseFloat(computedStyle.paddingRight); | ||
|  |   const selectionRects = Array.from(range.getClientRects()); | ||
|  |   let selectionRectsLength = selectionRects.length; | ||
|  |   //sort rects from top left to bottom right.
 | ||
|  |   selectionRects.sort((a, b) => { | ||
|  |     const top = a.top - b.top; | ||
|  |     // Some rects match position closely, but not perfectly,
 | ||
|  |     // so we give a 3px tolerance.
 | ||
|  |     if (Math.abs(top) <= 3) { | ||
|  |       return a.left - b.left; | ||
|  |     } | ||
|  |     return top; | ||
|  |   }); | ||
|  |   let prevRect; | ||
|  |   for (let i = 0; i < selectionRectsLength; i++) { | ||
|  |     const selectionRect = selectionRects[i]; | ||
|  |     // Exclude rects that overlap preceding Rects in the sorted list.
 | ||
|  |     const isOverlappingRect = | ||
|  |       prevRect && | ||
|  |       prevRect.top <= selectionRect.top && | ||
|  |       prevRect.top + prevRect.height > selectionRect.top && | ||
|  |       prevRect.left + prevRect.width > selectionRect.left; | ||
|  |     // Exclude selections that span the entire element
 | ||
|  |     const selectionSpansElement = | ||
|  |       selectionRect.width + rootPadding === rootRect.width; | ||
|  |     if (isOverlappingRect || selectionSpansElement) { | ||
|  |       selectionRects.splice(i--, 1); | ||
|  |       selectionRectsLength--; | ||
|  |       continue; | ||
|  |     } | ||
|  |     prevRect = selectionRect; | ||
|  |   } | ||
|  |   return selectionRects; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Creates an object containing all the styles and their values provided in the CSS string. | ||
|  |  * @param css - The CSS string of styles and their values. | ||
|  |  * @returns The styleObject containing all the styles and their values. | ||
|  |  */ | ||
|  | export function getStyleObjectFromRawCSS(css: string): Record<string, string> { | ||
|  |   const styleObject: Record<string, string> = {}; | ||
|  |   const styles = css.split(';'); | ||
|  | 
 | ||
|  |   for (const style of styles) { | ||
|  |     if (style !== '') { | ||
|  |       const [key, value] = style.split(/:([^]+)/); // split on first colon
 | ||
|  |       if (key && value) { | ||
|  |         styleObject[key.trim()] = value.trim(); | ||
|  |       } | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   return styleObject; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Given a CSS string, returns an object from the style cache. | ||
|  |  * @param css - The CSS property as a string. | ||
|  |  * @returns The value of the given CSS property. | ||
|  |  */ | ||
|  | export function getStyleObjectFromCSS(css: string): Record<string, string> { | ||
|  |   let value = CSS_TO_STYLES.get(css); | ||
|  |   if (value === undefined) { | ||
|  |     value = getStyleObjectFromRawCSS(css); | ||
|  |     CSS_TO_STYLES.set(css, value); | ||
|  |   } | ||
|  | 
 | ||
|  |   if (__DEV__) { | ||
|  |     // Freeze the value in DEV to prevent accidental mutations
 | ||
|  |     Object.freeze(value); | ||
|  |   } | ||
|  | 
 | ||
|  |   return value; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Gets the CSS styles from the style object. | ||
|  |  * @param styles - The style object containing the styles to get. | ||
|  |  * @returns A string containing the CSS styles and their values. | ||
|  |  */ | ||
|  | export function getCSSFromStyleObject(styles: Record<string, string>): string { | ||
|  |   let css = ''; | ||
|  | 
 | ||
|  |   for (const style in styles) { | ||
|  |     if (style) { | ||
|  |       css += `${style}: ${styles[style]};`; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   return css; | ||
|  | } |