/** * 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 { $cloneWithProperties, $createParagraphNode, $getPreviousSelection, $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, $setSelection, $splitNode, EditorState, ElementNode, Klass, LexicalEditor, LexicalNode, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not // export shared types from this module #5918 import {CAN_USE_DOM as CAN_USE_DOM_} from 'lexical/shared/canUseDOM'; import { CAN_USE_BEFORE_INPUT as CAN_USE_BEFORE_INPUT_, IS_ANDROID as IS_ANDROID_, IS_ANDROID_CHROME as IS_ANDROID_CHROME_, IS_APPLE as IS_APPLE_, IS_APPLE_WEBKIT as IS_APPLE_WEBKIT_, IS_CHROME as IS_CHROME_, IS_FIREFOX as IS_FIREFOX_, IS_IOS as IS_IOS_, IS_SAFARI as IS_SAFARI_, } from 'lexical/shared/environment'; import invariant from 'lexical/shared/invariant'; import normalizeClassNames from 'lexical/shared/normalizeClassNames'; export {default as markSelection} from './markSelection'; export {default as mergeRegister} from './mergeRegister'; export {default as positionNodeOnRange} from './positionNodeOnRange'; export { $splitNode, isBlockDomNode, isHTMLAnchorElement, isHTMLElement, isInlineDomNode, } from 'lexical'; // Hotfix to export these with inlined types #5918 export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_; export const CAN_USE_DOM: boolean = CAN_USE_DOM_; export const IS_ANDROID: boolean = IS_ANDROID_; export const IS_ANDROID_CHROME: boolean = IS_ANDROID_CHROME_; export const IS_APPLE: boolean = IS_APPLE_; export const IS_APPLE_WEBKIT: boolean = IS_APPLE_WEBKIT_; export const IS_CHROME: boolean = IS_CHROME_; export const IS_FIREFOX: boolean = IS_FIREFOX_; export const IS_IOS: boolean = IS_IOS_; export const IS_SAFARI: boolean = IS_SAFARI_; export type DFSNode = Readonly<{ depth: number; node: LexicalNode; }>; /** * Takes an HTML element and adds the classNames passed within an array, * ignoring any non-string types. A space can be used to add multiple classes * eg. addClassNamesToElement(element, ['element-inner active', true, null]) * will add both 'element-inner' and 'active' as classes to that element. * @param element - The element in which the classes are added * @param classNames - An array defining the class names to add to the element */ export function addClassNamesToElement( element: HTMLElement, ...classNames: Array ): void { const classesToAdd = normalizeClassNames(...classNames); if (classesToAdd.length > 0) { element.classList.add(...classesToAdd); } } /** * Takes an HTML element and removes the classNames passed within an array, * ignoring any non-string types. A space can be used to remove multiple classes * eg. removeClassNamesFromElement(element, ['active small', true, null]) * will remove both the 'active' and 'small' classes from that element. * @param element - The element in which the classes are removed * @param classNames - An array defining the class names to remove from the element */ export function removeClassNamesFromElement( element: HTMLElement, ...classNames: Array ): void { const classesToRemove = normalizeClassNames(...classNames); if (classesToRemove.length > 0) { element.classList.remove(...classesToRemove); } } /** * Returns true if the file type matches the types passed within the acceptableMimeTypes array, false otherwise. * The types passed must be strings and are CASE-SENSITIVE. * eg. if file is of type 'text' and acceptableMimeTypes = ['TEXT', 'IMAGE'] the function will return false. * @param file - The file you want to type check. * @param acceptableMimeTypes - An array of strings of types which the file is checked against. * @returns true if the file is an acceptable mime type, false otherwise. */ export function isMimeType( file: File, acceptableMimeTypes: Array, ): boolean { for (const acceptableType of acceptableMimeTypes) { if (file.type.startsWith(acceptableType)) { return true; } } return false; } /** * Lexical File Reader with: * 1. MIME type support * 2. batched results (HistoryPlugin compatibility) * 3. Order aware (respects the order when multiple Files are passed) * * const filesResult = await mediaFileReader(files, ['image/']); * filesResult.forEach(file => editor.dispatchCommand('INSERT_IMAGE', \\{ * src: file.result, * \\})); */ export function mediaFileReader( files: Array, acceptableMimeTypes: Array, ): Promise> { const filesIterator = files[Symbol.iterator](); return new Promise((resolve, reject) => { const processed: Array<{file: File; result: string}> = []; const handleNextFile = () => { const {done, value: file} = filesIterator.next(); if (done) { return resolve(processed); } const fileReader = new FileReader(); fileReader.addEventListener('error', reject); fileReader.addEventListener('load', () => { const result = fileReader.result; if (typeof result === 'string') { processed.push({file, result}); } handleNextFile(); }); if (isMimeType(file, acceptableMimeTypes)) { fileReader.readAsDataURL(file); } else { handleNextFile(); } }; handleNextFile(); }); } /** * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end * before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a * branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat. * It will then return all the nodes found in the search in an array of objects. * @param startingNode - The node to start the search, if ommitted, it will start at the root node. * @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode. * @returns An array of objects of all the nodes found by the search, including their depth into the tree. * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists */ export function $dfs( startingNode?: LexicalNode, endingNode?: LexicalNode, ): Array { const nodes = []; const start = (startingNode || $getRoot()).getLatest(); const end = endingNode || ($isElementNode(start) ? start.getLastDescendant() || start : start); let node: LexicalNode | null = start; let depth = $getDepth(node); while (node !== null && !node.is(end)) { nodes.push({depth, node}); if ($isElementNode(node) && node.getChildrenSize() > 0) { node = node.getFirstChild(); depth++; } else { // Find immediate sibling or nearest parent sibling let sibling = null; while (sibling === null && node !== null) { sibling = node.getNextSibling(); if (sibling === null) { node = node.getParent(); depth--; } else { node = sibling; } } } } if (node !== null && node.is(end)) { nodes.push({depth, node}); } return nodes; } function $getDepth(node: LexicalNode): number { let innerNode: LexicalNode | null = node; let depth = 0; while ((innerNode = innerNode.getParent()) !== null) { depth++; } return depth; } /** * Performs a right-to-left preorder tree traversal. * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path. * It will return the next node in traversal sequence after the startingNode. * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right. * @param startingNode - The node to start the search. * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist */ export function $getNextRightPreorderNode( startingNode: LexicalNode, ): LexicalNode | null { let node: LexicalNode | null = startingNode; if ($isElementNode(node) && node.getChildrenSize() > 0) { node = node.getLastChild(); } else { let sibling = null; while (sibling === null && node !== null) { sibling = node.getPreviousSibling(); if (sibling === null) { node = node.getParent(); } else { node = sibling; } } } return node; } /** * Takes a node and traverses up its ancestors (toward the root node) * in order to find a specific type of node. * @param node - the node to begin searching. * @param klass - an instance of the type of node to look for. * @returns the node of type klass that was passed, or null if none exist. */ export function $getNearestNodeOfType( node: LexicalNode, klass: Klass, ): T | null { let parent: ElementNode | LexicalNode | null = node; while (parent != null) { if (parent instanceof klass) { return parent as T; } parent = parent.getParent(); } return null; } /** * Returns the element node of the nearest ancestor, otherwise throws an error. * @param startNode - The starting node of the search * @returns The ancestor node found */ export function $getNearestBlockElementAncestorOrThrow( startNode: LexicalNode, ): ElementNode { const blockNode = $findMatchingParent( startNode, (node) => $isElementNode(node) && !node.isInline(), ); if (!$isElementNode(blockNode)) { invariant( false, 'Expected node %s to have closest block element node.', startNode.__key, ); } return blockNode; } export type DOMNodeToLexicalConversion = (element: Node) => LexicalNode; export type DOMNodeToLexicalConversionMap = Record< string, DOMNodeToLexicalConversion >; /** * Starts with a node and moves up the tree (toward the root node) to find a matching node based on * the search parameters of the findFn. (Consider JavaScripts' .find() function where a testing function must be * passed as an argument. eg. if( (node) => node.__type === 'div') ) return true; otherwise return false * @param startingNode - The node where the search starts. * @param findFn - A testing function that returns true if the current node satisfies the testing parameters. * @returns A parent node that matches the findFn parameters, or null if one wasn't found. */ export const $findMatchingParent: { ( startingNode: LexicalNode, findFn: (node: LexicalNode) => node is T, ): T | null; ( startingNode: LexicalNode, findFn: (node: LexicalNode) => boolean, ): LexicalNode | null; } = ( startingNode: LexicalNode, findFn: (node: LexicalNode) => boolean, ): LexicalNode | null => { let curr: ElementNode | LexicalNode | null = startingNode; while (curr !== $getRoot() && curr != null) { if (findFn(curr)) { return curr; } curr = curr.getParent(); } return null; }; /** * Attempts to resolve nested element nodes of the same type into a single node of that type. * It is generally used for marks/commenting * @param editor - The lexical editor * @param targetNode - The target for the nested element to be extracted from. * @param cloneNode - See {@link $createMarkNode} * @param handleOverlap - Handles any overlap between the node to extract and the targetNode * @returns The lexical editor */ export function registerNestedElementResolver( editor: LexicalEditor, targetNode: Klass, cloneNode: (from: N) => N, handleOverlap: (from: N, to: N) => void, ): () => void { const $isTargetNode = (node: LexicalNode | null | undefined): node is N => { return node instanceof targetNode; }; const $findMatch = (node: N): {child: ElementNode; parent: N} | null => { // First validate we don't have any children that are of the target, // as we need to handle them first. const children = node.getChildren(); for (let i = 0; i < children.length; i++) { const child = children[i]; if ($isTargetNode(child)) { return null; } } let parentNode: N | null = node; let childNode = node; while (parentNode !== null) { childNode = parentNode; parentNode = parentNode.getParent(); if ($isTargetNode(parentNode)) { return {child: childNode, parent: parentNode}; } } return null; }; const $elementNodeTransform = (node: N) => { const match = $findMatch(node); if (match !== null) { const {child, parent} = match; // Simple path, we can move child out and siblings into a new parent. if (child.is(node)) { handleOverlap(parent, node); const nextSiblings = child.getNextSiblings(); const nextSiblingsLength = nextSiblings.length; parent.insertAfter(child); if (nextSiblingsLength !== 0) { const newParent = cloneNode(parent); child.insertAfter(newParent); for (let i = 0; i < nextSiblingsLength; i++) { newParent.append(nextSiblings[i]); } } if (!parent.canBeEmpty() && parent.getChildrenSize() === 0) { parent.remove(); } } else { // Complex path, we have a deep node that isn't a child of the // target parent. // TODO: implement this functionality } } }; return editor.registerNodeTransform(targetNode, $elementNodeTransform); } /** * Clones the editor and marks it as dirty to be reconciled. If there was a selection, * it would be set back to its previous state, or null otherwise. * @param editor - The lexical editor * @param editorState - The editor's state */ export function $restoreEditorState( editor: LexicalEditor, editorState: EditorState, ): void { const FULL_RECONCILE = 2; const nodeMap = new Map(); const activeEditorState = editor._pendingEditorState; for (const [key, node] of editorState._nodeMap) { nodeMap.set(key, $cloneWithProperties(node)); } if (activeEditorState) { activeEditorState._nodeMap = nodeMap; } editor._dirtyType = FULL_RECONCILE; const selection = editorState._selection; $setSelection(selection === null ? null : selection.clone()); } /** * If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}), * the node will be appended there, otherwise, it will be inserted before the insertion area. * If there is no selection where the node is to be inserted, it will be appended after any current nodes * within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected. * @param node - The node to be inserted * @returns The node after its insertion */ export function $insertNodeToNearestRoot(node: T): T { const selection = $getSelection() || $getPreviousSelection(); if ($isRangeSelection(selection)) { const {focus} = selection; const focusNode = focus.getNode(); const focusOffset = focus.offset; if ($isRootOrShadowRoot(focusNode)) { const focusChild = focusNode.getChildAtIndex(focusOffset); if (focusChild == null) { focusNode.append(node); } else { focusChild.insertBefore(node); } node.selectNext(); } else { let splitNode: ElementNode; let splitOffset: number; if ($isTextNode(focusNode)) { splitNode = focusNode.getParentOrThrow(); splitOffset = focusNode.getIndexWithinParent(); if (focusOffset > 0) { splitOffset += 1; focusNode.splitText(focusOffset); } } else { splitNode = focusNode; splitOffset = focusOffset; } const [, rightTree] = $splitNode(splitNode, splitOffset); rightTree.insertBefore(node); rightTree.selectStart(); } } else { if (selection != null) { const nodes = selection.getNodes(); nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node); } else { const root = $getRoot(); root.append(node); } const paragraphNode = $createParagraphNode(); node.insertAfter(paragraphNode); paragraphNode.select(); } return node.getLatest(); } /** * Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode * @param node - Node to be wrapped. * @param createElementNode - Creates a new lexical element to wrap the to-be-wrapped node and returns it. * @returns A new lexical element with the previous node appended within (as a child, including its children). */ export function $wrapNodeInElement( node: LexicalNode, createElementNode: () => ElementNode, ): ElementNode { const elementNode = createElementNode(); node.replace(elementNode); elementNode.append(node); return elementNode; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type ObjectKlass = new (...args: any[]) => T; /** * @param object = The instance of the type * @param objectClass = The class of the type * @returns Whether the object is has the same Klass of the objectClass, ignoring the difference across window (e.g. different iframs) */ export function objectKlassEquals( object: unknown, objectClass: ObjectKlass, ): boolean { return object !== null ? Object.getPrototypeOf(object).constructor.name === objectClass.name : false; } /** * Filter the nodes * @param nodes Array of nodes that needs to be filtered * @param filterFn A filter function that returns node if the current node satisfies the condition otherwise null * @returns Array of filtered nodes */ export function $filter( nodes: Array, filterFn: (node: LexicalNode) => null | T, ): Array { const result: T[] = []; for (let i = 0; i < nodes.length; i++) { const node = filterFn(nodes[i]); if (node !== null) { result.push(node); } } return result; } /** * Appends the node before the first child of the parent node * @param parent A parent node * @param node Node that needs to be appended */ export function $insertFirst(parent: ElementNode, node: LexicalNode): void { const firstChild = parent.getFirstChild(); if (firstChild !== null) { firstChild.insertBefore(node); } else { parent.append(node); } } /** * Calculates the zoom level of an element as a result of using * css zoom property. * @param element */ export function calculateZoomLevel(element: Element | null): number { if (IS_FIREFOX) { return 1; } let zoom = 1; while (element) { zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom')); element = element.parentElement; } return zoom; } /** * Checks if the editor is a nested editor created by LexicalNestedComposer */ export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean { return editor._parentEditor !== null; }