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;
							 | 
						||
| 
								 | 
							
								}
							 |