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.
12 $getPreviousSelection,
27 // This underscore postfixing is used as a hotfix so we do not
28 // export shared types from this module #5918
29 import {CAN_USE_DOM as CAN_USE_DOM_} from 'lexical/shared/canUseDOM';
31 CAN_USE_BEFORE_INPUT as CAN_USE_BEFORE_INPUT_,
32 IS_ANDROID as IS_ANDROID_,
33 IS_ANDROID_CHROME as IS_ANDROID_CHROME_,
34 IS_APPLE as IS_APPLE_,
35 IS_APPLE_WEBKIT as IS_APPLE_WEBKIT_,
36 IS_CHROME as IS_CHROME_,
37 IS_FIREFOX as IS_FIREFOX_,
39 IS_SAFARI as IS_SAFARI_,
40 } from 'lexical/shared/environment';
41 import invariant from 'lexical/shared/invariant';
42 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
44 export {default as markSelection} from './markSelection';
45 export {default as mergeRegister} from './mergeRegister';
46 export {default as positionNodeOnRange} from './positionNodeOnRange';
54 // Hotfix to export these with inlined types #5918
55 export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_;
56 export const CAN_USE_DOM: boolean = CAN_USE_DOM_;
57 export const IS_ANDROID: boolean = IS_ANDROID_;
58 export const IS_ANDROID_CHROME: boolean = IS_ANDROID_CHROME_;
59 export const IS_APPLE: boolean = IS_APPLE_;
60 export const IS_APPLE_WEBKIT: boolean = IS_APPLE_WEBKIT_;
61 export const IS_CHROME: boolean = IS_CHROME_;
62 export const IS_FIREFOX: boolean = IS_FIREFOX_;
63 export const IS_IOS: boolean = IS_IOS_;
64 export const IS_SAFARI: boolean = IS_SAFARI_;
66 export type DFSNode = Readonly<{
72 * Takes an HTML element and adds the classNames passed within an array,
73 * ignoring any non-string types. A space can be used to add multiple classes
74 * eg. addClassNamesToElement(element, ['element-inner active', true, null])
75 * will add both 'element-inner' and 'active' as classes to that element.
76 * @param element - The element in which the classes are added
77 * @param classNames - An array defining the class names to add to the element
79 export function addClassNamesToElement(
81 ...classNames: Array<typeof undefined | boolean | null | string>
83 const classesToAdd = normalizeClassNames(...classNames);
84 if (classesToAdd.length > 0) {
85 element.classList.add(...classesToAdd);
90 * Takes an HTML element and removes the classNames passed within an array,
91 * ignoring any non-string types. A space can be used to remove multiple classes
92 * eg. removeClassNamesFromElement(element, ['active small', true, null])
93 * will remove both the 'active' and 'small' classes from that element.
94 * @param element - The element in which the classes are removed
95 * @param classNames - An array defining the class names to remove from the element
97 export function removeClassNamesFromElement(
99 ...classNames: Array<typeof undefined | boolean | null | string>
101 const classesToRemove = normalizeClassNames(...classNames);
102 if (classesToRemove.length > 0) {
103 element.classList.remove(...classesToRemove);
108 * Returns true if the file type matches the types passed within the acceptableMimeTypes array, false otherwise.
109 * The types passed must be strings and are CASE-SENSITIVE.
110 * eg. if file is of type 'text' and acceptableMimeTypes = ['TEXT', 'IMAGE'] the function will return false.
111 * @param file - The file you want to type check.
112 * @param acceptableMimeTypes - An array of strings of types which the file is checked against.
113 * @returns true if the file is an acceptable mime type, false otherwise.
115 export function isMimeType(
117 acceptableMimeTypes: Array<string>,
119 for (const acceptableType of acceptableMimeTypes) {
120 if (file.type.startsWith(acceptableType)) {
128 * Lexical File Reader with:
129 * 1. MIME type support
130 * 2. batched results (HistoryPlugin compatibility)
131 * 3. Order aware (respects the order when multiple Files are passed)
133 * const filesResult = await mediaFileReader(files, ['image/']);
134 * filesResult.forEach(file => editor.dispatchCommand('INSERT_IMAGE', \\{
138 export function mediaFileReader(
140 acceptableMimeTypes: Array<string>,
141 ): Promise<Array<{file: File; result: string}>> {
142 const filesIterator = files[Symbol.iterator]();
143 return new Promise((resolve, reject) => {
144 const processed: Array<{file: File; result: string}> = [];
145 const handleNextFile = () => {
146 const {done, value: file} = filesIterator.next();
148 return resolve(processed);
150 const fileReader = new FileReader();
151 fileReader.addEventListener('error', reject);
152 fileReader.addEventListener('load', () => {
153 const result = fileReader.result;
154 if (typeof result === 'string') {
155 processed.push({file, result});
159 if (isMimeType(file, acceptableMimeTypes)) {
160 fileReader.readAsDataURL(file);
170 * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end
171 * before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a
172 * branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat.
173 * It will then return all the nodes found in the search in an array of objects.
174 * @param startingNode - The node to start the search, if ommitted, it will start at the root node.
175 * @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode.
176 * @returns An array of objects of all the nodes found by the search, including their depth into the tree.
177 * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists
179 export function $dfs(
180 startingNode?: LexicalNode,
181 endingNode?: LexicalNode,
184 const start = (startingNode || $getRoot()).getLatest();
187 ($isElementNode(start) ? start.getLastDescendant() || start : start);
188 let node: LexicalNode | null = start;
189 let depth = $getDepth(node);
191 while (node !== null && !node.is(end)) {
192 nodes.push({depth, node});
194 if ($isElementNode(node) && node.getChildrenSize() > 0) {
195 node = node.getFirstChild();
198 // Find immediate sibling or nearest parent sibling
201 while (sibling === null && node !== null) {
202 sibling = node.getNextSibling();
204 if (sibling === null) {
205 node = node.getParent();
214 if (node !== null && node.is(end)) {
215 nodes.push({depth, node});
221 function $getDepth(node: LexicalNode): number {
222 let innerNode: LexicalNode | null = node;
225 while ((innerNode = innerNode.getParent()) !== null) {
233 * Performs a right-to-left preorder tree traversal.
234 * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path.
235 * It will return the next node in traversal sequence after the startingNode.
236 * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right.
237 * @param startingNode - The node to start the search.
238 * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist
240 export function $getNextRightPreorderNode(
241 startingNode: LexicalNode,
242 ): LexicalNode | null {
243 let node: LexicalNode | null = startingNode;
245 if ($isElementNode(node) && node.getChildrenSize() > 0) {
246 node = node.getLastChild();
250 while (sibling === null && node !== null) {
251 sibling = node.getPreviousSibling();
253 if (sibling === null) {
254 node = node.getParent();
264 * Takes a node and traverses up its ancestors (toward the root node)
265 * in order to find a specific type of node.
266 * @param node - the node to begin searching.
267 * @param klass - an instance of the type of node to look for.
268 * @returns the node of type klass that was passed, or null if none exist.
270 export function $getNearestNodeOfType<T extends ElementNode>(
274 let parent: ElementNode | LexicalNode | null = node;
276 while (parent != null) {
277 if (parent instanceof klass) {
281 parent = parent.getParent();
288 * Returns the element node of the nearest ancestor, otherwise throws an error.
289 * @param startNode - The starting node of the search
290 * @returns The ancestor node found
292 export function $getNearestBlockElementAncestorOrThrow(
293 startNode: LexicalNode,
295 const blockNode = $findMatchingParent(
297 (node) => $isElementNode(node) && !node.isInline(),
299 if (!$isElementNode(blockNode)) {
302 'Expected node %s to have closest block element node.',
309 export type DOMNodeToLexicalConversion = (element: Node) => LexicalNode;
311 export type DOMNodeToLexicalConversionMap = Record<
313 DOMNodeToLexicalConversion
317 * Starts with a node and moves up the tree (toward the root node) to find a matching node based on
318 * the search parameters of the findFn. (Consider JavaScripts' .find() function where a testing function must be
319 * passed as an argument. eg. if( (node) => node.__type === 'div') ) return true; otherwise return false
320 * @param startingNode - The node where the search starts.
321 * @param findFn - A testing function that returns true if the current node satisfies the testing parameters.
322 * @returns A parent node that matches the findFn parameters, or null if one wasn't found.
324 export const $findMatchingParent: {
325 <T extends LexicalNode>(
326 startingNode: LexicalNode,
327 findFn: (node: LexicalNode) => node is T,
330 startingNode: LexicalNode,
331 findFn: (node: LexicalNode) => boolean,
332 ): LexicalNode | null;
334 startingNode: LexicalNode,
335 findFn: (node: LexicalNode) => boolean,
336 ): LexicalNode | null => {
337 let curr: ElementNode | LexicalNode | null = startingNode;
339 while (curr !== $getRoot() && curr != null) {
344 curr = curr.getParent();
351 * Attempts to resolve nested element nodes of the same type into a single node of that type.
352 * It is generally used for marks/commenting
353 * @param editor - The lexical editor
354 * @param targetNode - The target for the nested element to be extracted from.
355 * @param cloneNode - See {@link $createMarkNode}
356 * @param handleOverlap - Handles any overlap between the node to extract and the targetNode
357 * @returns The lexical editor
359 export function registerNestedElementResolver<N extends ElementNode>(
360 editor: LexicalEditor,
361 targetNode: Klass<N>,
362 cloneNode: (from: N) => N,
363 handleOverlap: (from: N, to: N) => void,
365 const $isTargetNode = (node: LexicalNode | null | undefined): node is N => {
366 return node instanceof targetNode;
369 const $findMatch = (node: N): {child: ElementNode; parent: N} | null => {
370 // First validate we don't have any children that are of the target,
371 // as we need to handle them first.
372 const children = node.getChildren();
374 for (let i = 0; i < children.length; i++) {
375 const child = children[i];
377 if ($isTargetNode(child)) {
382 let parentNode: N | null = node;
383 let childNode = node;
385 while (parentNode !== null) {
386 childNode = parentNode;
387 parentNode = parentNode.getParent();
389 if ($isTargetNode(parentNode)) {
390 return {child: childNode, parent: parentNode};
397 const $elementNodeTransform = (node: N) => {
398 const match = $findMatch(node);
400 if (match !== null) {
401 const {child, parent} = match;
403 // Simple path, we can move child out and siblings into a new parent.
405 if (child.is(node)) {
406 handleOverlap(parent, node);
407 const nextSiblings = child.getNextSiblings();
408 const nextSiblingsLength = nextSiblings.length;
409 parent.insertAfter(child);
411 if (nextSiblingsLength !== 0) {
412 const newParent = cloneNode(parent);
413 child.insertAfter(newParent);
415 for (let i = 0; i < nextSiblingsLength; i++) {
416 newParent.append(nextSiblings[i]);
420 if (!parent.canBeEmpty() && parent.getChildrenSize() === 0) {
424 // Complex path, we have a deep node that isn't a child of the
426 // TODO: implement this functionality
431 return editor.registerNodeTransform(targetNode, $elementNodeTransform);
435 * Clones the editor and marks it as dirty to be reconciled. If there was a selection,
436 * it would be set back to its previous state, or null otherwise.
437 * @param editor - The lexical editor
438 * @param editorState - The editor's state
440 export function $restoreEditorState(
441 editor: LexicalEditor,
442 editorState: EditorState,
444 const FULL_RECONCILE = 2;
445 const nodeMap = new Map();
446 const activeEditorState = editor._pendingEditorState;
448 for (const [key, node] of editorState._nodeMap) {
449 nodeMap.set(key, $cloneWithProperties(node));
452 if (activeEditorState) {
453 activeEditorState._nodeMap = nodeMap;
456 editor._dirtyType = FULL_RECONCILE;
457 const selection = editorState._selection;
458 $setSelection(selection === null ? null : selection.clone());
462 * If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}),
463 * the node will be appended there, otherwise, it will be inserted before the insertion area.
464 * If there is no selection where the node is to be inserted, it will be appended after any current nodes
465 * within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected.
466 * @param node - The node to be inserted
467 * @returns The node after its insertion
469 export function $insertNodeToNearestRoot<T extends LexicalNode>(node: T): T {
470 const selection = $getSelection() || $getPreviousSelection();
472 if ($isRangeSelection(selection)) {
473 const {focus} = selection;
474 const focusNode = focus.getNode();
475 const focusOffset = focus.offset;
477 if ($isRootOrShadowRoot(focusNode)) {
478 const focusChild = focusNode.getChildAtIndex(focusOffset);
479 if (focusChild == null) {
480 focusNode.append(node);
482 focusChild.insertBefore(node);
486 let splitNode: ElementNode;
487 let splitOffset: number;
488 if ($isTextNode(focusNode)) {
489 splitNode = focusNode.getParentOrThrow();
490 splitOffset = focusNode.getIndexWithinParent();
491 if (focusOffset > 0) {
493 focusNode.splitText(focusOffset);
496 splitNode = focusNode;
497 splitOffset = focusOffset;
499 const [, rightTree] = $splitNode(splitNode, splitOffset);
500 rightTree.insertBefore(node);
501 rightTree.selectStart();
504 if (selection != null) {
505 const nodes = selection.getNodes();
506 nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node);
508 const root = $getRoot();
511 const paragraphNode = $createParagraphNode();
512 node.insertAfter(paragraphNode);
513 paragraphNode.select();
515 return node.getLatest();
519 * Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode
520 * @param node - Node to be wrapped.
521 * @param createElementNode - Creates a new lexical element to wrap the to-be-wrapped node and returns it.
522 * @returns A new lexical element with the previous node appended within (as a child, including its children).
524 export function $wrapNodeInElement(
526 createElementNode: () => ElementNode,
528 const elementNode = createElementNode();
529 node.replace(elementNode);
530 elementNode.append(node);
534 // eslint-disable-next-line @typescript-eslint/no-explicit-any
535 type ObjectKlass<T> = new (...args: any[]) => T;
538 * @param object = The instance of the type
539 * @param objectClass = The class of the type
540 * @returns Whether the object is has the same Klass of the objectClass, ignoring the difference across window (e.g. different iframs)
542 export function objectKlassEquals<T>(
544 objectClass: ObjectKlass<T>,
546 return object !== null
547 ? Object.getPrototypeOf(object).constructor.name === objectClass.name
553 * @param nodes Array of nodes that needs to be filtered
554 * @param filterFn A filter function that returns node if the current node satisfies the condition otherwise null
555 * @returns Array of filtered nodes
558 export function $filter<T>(
559 nodes: Array<LexicalNode>,
560 filterFn: (node: LexicalNode) => null | T,
562 const result: T[] = [];
563 for (let i = 0; i < nodes.length; i++) {
564 const node = filterFn(nodes[i]);
572 * Appends the node before the first child of the parent node
573 * @param parent A parent node
574 * @param node Node that needs to be appended
576 export function $insertFirst(parent: ElementNode, node: LexicalNode): void {
577 const firstChild = parent.getFirstChild();
578 if (firstChild !== null) {
579 firstChild.insertBefore(node);
586 * Calculates the zoom level of an element as a result of using
590 export function calculateZoomLevel(element: Element | null): number {
596 zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom'));
597 element = element.parentElement;
603 * Checks if the editor is a nested editor created by LexicalNestedComposer
605 export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean {
606 return editor._parentEditor !== null;