/** * 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. */ 'use strict'; var selection = require('@lexical/selection'); var lexical = require('lexical'); /** * 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. * */ /** * Returns a function that will execute all functions passed when called. It is generally used * to register multiple lexical listeners and then tear them down with a single function call, such * as React's useEffect hook. * @example * ```ts * useEffect(() => { * return mergeRegister( * editor.registerCommand(...registerCommand1 logic), * editor.registerCommand(...registerCommand2 logic), * editor.registerCommand(...registerCommand3 logic) * ) * }, [editor]) * ``` * In this case, useEffect is returning the function returned by mergeRegister as a cleanup * function to be executed after either the useEffect runs again (due to one of its dependencies * updating) or the component it resides in unmounts. * Note the functions don't neccesarily need to be in an array as all arguements * are considered to be the func argument and spread from there. * @param func - An array of functions meant to be executed by the returned function. * @returns the function which executes all the passed register command functions. */ function mergeRegister(...func) { return () => { func.forEach(f => f()); }; } /** * 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. * */ function px(value) { return `${value}px`; } /** * 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. * */ const mutationObserverConfig = { attributes: true, characterData: true, childList: true, subtree: true }; function positionNodeOnRange(editor, range, onReposition) { let rootDOMNode = null; let parentDOMNode = null; let observer = null; let lastNodes = []; const wrapperNode = document.createElement('div'); function position() { if (!(rootDOMNode !== null)) { throw Error(`Unexpected null rootDOMNode`); } if (!(parentDOMNode !== null)) { throw Error(`Unexpected null parentDOMNode`); } const { left: rootLeft, top: rootTop } = rootDOMNode.getBoundingClientRect(); const parentDOMNode_ = parentDOMNode; const rects = selection.createRectsFromDOMRange(editor, range); if (!wrapperNode.isConnected) { parentDOMNode_.append(wrapperNode); } let hasRepositioned = false; for (let i = 0; i < rects.length; i++) { const rect = rects[i]; // Try to reuse the previously created Node when possible, no need to // remove/create on the most common case reposition case const rectNode = lastNodes[i] || document.createElement('div'); const rectNodeStyle = rectNode.style; if (rectNodeStyle.position !== 'absolute') { rectNodeStyle.position = 'absolute'; hasRepositioned = true; } const left = px(rect.left - rootLeft); if (rectNodeStyle.left !== left) { rectNodeStyle.left = left; hasRepositioned = true; } const top = px(rect.top - rootTop); if (rectNodeStyle.top !== top) { rectNode.style.top = top; hasRepositioned = true; } const width = px(rect.width); if (rectNodeStyle.width !== width) { rectNode.style.width = width; hasRepositioned = true; } const height = px(rect.height); if (rectNodeStyle.height !== height) { rectNode.style.height = height; hasRepositioned = true; } if (rectNode.parentNode !== wrapperNode) { wrapperNode.append(rectNode); hasRepositioned = true; } lastNodes[i] = rectNode; } while (lastNodes.length > rects.length) { lastNodes.pop(); } if (hasRepositioned) { onReposition(lastNodes); } } function stop() { parentDOMNode = null; rootDOMNode = null; if (observer !== null) { observer.disconnect(); } observer = null; wrapperNode.remove(); for (const node of lastNodes) { node.remove(); } lastNodes = []; } function restart() { const currentRootDOMNode = editor.getRootElement(); if (currentRootDOMNode === null) { return stop(); } const currentParentDOMNode = currentRootDOMNode.parentElement; if (!(currentParentDOMNode instanceof HTMLElement)) { return stop(); } stop(); rootDOMNode = currentRootDOMNode; parentDOMNode = currentParentDOMNode; observer = new MutationObserver(mutations => { const nextRootDOMNode = editor.getRootElement(); const nextParentDOMNode = nextRootDOMNode && nextRootDOMNode.parentElement; if (nextRootDOMNode !== rootDOMNode || nextParentDOMNode !== parentDOMNode) { return restart(); } for (const mutation of mutations) { if (!wrapperNode.contains(mutation.target)) { // TODO throttle return position(); } } }); observer.observe(currentParentDOMNode, mutationObserverConfig); position(); } const removeRootListener = editor.registerRootListener(restart); return () => { removeRootListener(); stop(); }; } /** * 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. * */ function markSelection(editor, onReposition) { let previousAnchorNode = null; let previousAnchorOffset = null; let previousFocusNode = null; let previousFocusOffset = null; let removeRangeListener = () => {}; function compute(editorState) { editorState.read(() => { const selection = lexical.$getSelection(); if (!lexical.$isRangeSelection(selection)) { // TODO previousAnchorNode = null; previousAnchorOffset = null; previousFocusNode = null; previousFocusOffset = null; removeRangeListener(); removeRangeListener = () => {}; return; } const { anchor, focus } = selection; const currentAnchorNode = anchor.getNode(); const currentAnchorNodeKey = currentAnchorNode.getKey(); const currentAnchorOffset = anchor.offset; const currentFocusNode = focus.getNode(); const currentFocusNodeKey = currentFocusNode.getKey(); const currentFocusOffset = focus.offset; const currentAnchorNodeDOM = editor.getElementByKey(currentAnchorNodeKey); const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey); const differentAnchorDOM = previousAnchorNode === null || currentAnchorNodeDOM === null || currentAnchorOffset !== previousAnchorOffset || currentAnchorNodeKey !== previousAnchorNode.getKey() || currentAnchorNode !== previousAnchorNode && (!(previousAnchorNode instanceof lexical.TextNode) || currentAnchorNode.updateDOM(previousAnchorNode, currentAnchorNodeDOM, editor._config)); const differentFocusDOM = previousFocusNode === null || currentFocusNodeDOM === null || currentFocusOffset !== previousFocusOffset || currentFocusNodeKey !== previousFocusNode.getKey() || currentFocusNode !== previousFocusNode && (!(previousFocusNode instanceof lexical.TextNode) || currentFocusNode.updateDOM(previousFocusNode, currentFocusNodeDOM, editor._config)); if (differentAnchorDOM || differentFocusDOM) { const anchorHTMLElement = editor.getElementByKey(anchor.getNode().getKey()); const focusHTMLElement = editor.getElementByKey(focus.getNode().getKey()); // TODO handle selection beyond the common TextNode if (anchorHTMLElement !== null && focusHTMLElement !== null && anchorHTMLElement.tagName === 'SPAN' && focusHTMLElement.tagName === 'SPAN') { const range = document.createRange(); let firstHTMLElement; let firstOffset; let lastHTMLElement; let lastOffset; if (focus.isBefore(anchor)) { firstHTMLElement = focusHTMLElement; firstOffset = focus.offset; lastHTMLElement = anchorHTMLElement; lastOffset = anchor.offset; } else { firstHTMLElement = anchorHTMLElement; firstOffset = anchor.offset; lastHTMLElement = focusHTMLElement; lastOffset = focus.offset; } const firstTextNode = firstHTMLElement.firstChild; if (!(firstTextNode !== null)) { throw Error(`Expected text node to be first child of span`); } const lastTextNode = lastHTMLElement.firstChild; if (!(lastTextNode !== null)) { throw Error(`Expected text node to be first child of span`); } range.setStart(firstTextNode, firstOffset); range.setEnd(lastTextNode, lastOffset); removeRangeListener(); removeRangeListener = positionNodeOnRange(editor, range, domNodes => { for (const domNode of domNodes) { const domNodeStyle = domNode.style; if (domNodeStyle.background !== 'Highlight') { domNodeStyle.background = 'Highlight'; } if (domNodeStyle.color !== 'HighlightText') { domNodeStyle.color = 'HighlightText'; } if (domNodeStyle.zIndex !== '-1') { domNodeStyle.zIndex = '-1'; } if (domNodeStyle.pointerEvents !== 'none') { domNodeStyle.pointerEvents = 'none'; } if (domNodeStyle.marginTop !== px(-1.5)) { domNodeStyle.marginTop = px(-1.5); } if (domNodeStyle.paddingTop !== px(4)) { domNodeStyle.paddingTop = px(4); } if (domNodeStyle.paddingBottom !== px(0)) { domNodeStyle.paddingBottom = px(0); } } if (onReposition !== undefined) { onReposition(domNodes); } }); } } previousAnchorNode = currentAnchorNode; previousAnchorOffset = currentAnchorOffset; previousFocusNode = currentFocusNode; previousFocusOffset = currentFocusOffset; }); } compute(editor.getEditorState()); return mergeRegister(editor.registerUpdateListener(({ editorState }) => compute(editorState)), removeRangeListener, () => { removeRangeListener(); }); } /** @module @lexical/utils */ /** * 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 */ function addClassNamesToElement(element, ...classNames) { classNames.forEach(className => { if (typeof className === 'string') { const classesToAdd = className.split(' ').filter(n => n !== ''); 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 */ function removeClassNamesFromElement(element, ...classNames) { classNames.forEach(className => { if (typeof className === 'string') { element.classList.remove(...className.split(' ')); } }); } /** * 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. */ function isMimeType(file, acceptableMimeTypes) { 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, * })); */ function mediaFileReader(files, acceptableMimeTypes) { const filesIterator = files[Symbol.iterator](); return new Promise((resolve, reject) => { const processed = []; 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 */ function $dfs(startingNode, endingNode) { const nodes = []; const start = (startingNode || lexical.$getRoot()).getLatest(); const end = endingNode || (lexical.$isElementNode(start) ? start.getLastDescendant() : start); let node = start; let depth = $getDepth(node); while (node !== null && !node.is(end)) { nodes.push({ depth, node }); if (lexical.$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) { let innerNode = node; let depth = 0; while ((innerNode = innerNode.getParent()) !== null) { depth++; } return depth; } /** * 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. */ function $getNearestNodeOfType(node, klass) { let parent = node; while (parent != null) { if (parent instanceof klass) { return parent; } 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 */ function $getNearestBlockElementAncestorOrThrow(startNode) { const blockNode = $findMatchingParent(startNode, node => lexical.$isElementNode(node) && !node.isInline()); if (!lexical.$isElementNode(blockNode)) { { throw Error(`Expected node ${startNode.__key} to have closest block element node.`); } } return blockNode; } /** * 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. */ const $findMatchingParent = (startingNode, findFn) => { let curr = startingNode; while (curr !== lexical.$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 */ function registerNestedElementResolver(editor, targetNode, cloneNode, handleOverlap) { const $isTargetNode = node => { return node instanceof targetNode; }; const $findMatch = node => { // 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 = 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 => { 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(); } } } }; 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 */ function $restoreEditorState(editor, editorState) { const FULL_RECONCILE = 2; const nodeMap = new Map(); const activeEditorState = editor._pendingEditorState; for (const [key, node] of editorState._nodeMap) { const clone = selection.$cloneWithProperties(node); if (lexical.$isTextNode(clone)) { if (!lexical.$isTextNode(node)) { throw Error(`Expected node be a TextNode`); } clone.__text = node.__text; } nodeMap.set(key, clone); } if (activeEditorState) { activeEditorState._nodeMap = nodeMap; } editor._dirtyType = FULL_RECONCILE; const selection$1 = editorState._selection; lexical.$setSelection(selection$1 === null ? null : selection$1.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 */ function $insertNodeToNearestRoot(node) { const selection = lexical.$getSelection() || lexical.$getPreviousSelection(); if (lexical.$isRangeSelection(selection)) { const { focus } = selection; const focusNode = focus.getNode(); const focusOffset = focus.offset; if (lexical.$isRootOrShadowRoot(focusNode)) { const focusChild = focusNode.getChildAtIndex(focusOffset); if (focusChild == null) { focusNode.append(node); } else { focusChild.insertBefore(node); } node.selectNext(); } else { let splitNode; let splitOffset; if (lexical.$isTextNode(focusNode)) { splitNode = focusNode.getParentOrThrow(); splitOffset = focusNode.getIndexWithinParent(); if (focusOffset > 0) { splitOffset += 1; focusNode.splitText(focusOffset); } } else { splitNode = focusNode; splitOffset = focusOffset; } const [, rightTree] = lexical.$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 = lexical.$getRoot(); root.append(node); } const paragraphNode = lexical.$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). */ function $wrapNodeInElement(node, createElementNode) { const elementNode = createElementNode(); node.replace(elementNode); elementNode.append(node); return elementNode; } // eslint-disable-next-line @typescript-eslint/no-explicit-any /** * @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) */ function objectKlassEquals(object, objectClass) { 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 */ function $filter(nodes, filterFn) { const result = []; 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 */ function $insertFirst(parent, node) { const firstChild = parent.getFirstChild(); if (firstChild !== null) { firstChild.insertBefore(node); } else { parent.append(node); } } exports.$splitNode = lexical.$splitNode; exports.isHTMLAnchorElement = lexical.isHTMLAnchorElement; exports.isHTMLElement = lexical.isHTMLElement; exports.$dfs = $dfs; exports.$filter = $filter; exports.$findMatchingParent = $findMatchingParent; exports.$getNearestBlockElementAncestorOrThrow = $getNearestBlockElementAncestorOrThrow; exports.$getNearestNodeOfType = $getNearestNodeOfType; exports.$insertFirst = $insertFirst; exports.$insertNodeToNearestRoot = $insertNodeToNearestRoot; exports.$restoreEditorState = $restoreEditorState; exports.$wrapNodeInElement = $wrapNodeInElement; exports.addClassNamesToElement = addClassNamesToElement; exports.isMimeType = isMimeType; exports.markSelection = markSelection; exports.mediaFileReader = mediaFileReader; exports.mergeRegister = mergeRegister; exports.objectKlassEquals = objectKlassEquals; exports.positionNodeOnRange = positionNodeOnRange; exports.registerNestedElementResolver = registerNestedElementResolver; exports.removeClassNamesFromElement = removeClassNamesFromElement;