UNPKG

@lexical/utils

Version:

This package contains misc utilities for Lexical.

1,284 lines (1,227 loc) 58.9 kB
/** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * 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. * */ /** * Dedupe a list of selection client-rects before they are drawn as fills. * * `Range.getClientRects()` can return rects that are duplicated or contained * within another, and WebKit in particular emits a spurious wider rect alongside * the real text rect on some blocks (balanced or letter-spaced headings are the * reproducible case). Drawn as semi-transparent fills, duplicates read brighter * than a single rect, and the wider rect paints the "extra empty selection area" * reported in facebook/lexical#7106. * * This drops zero-area rects and any rect that contains another, keeping the * smaller text-hugging one, with a 1px tolerance for sub-pixel jitter. The * assumption is that genuine same-line rects are horizontally disjoint (one rect * per visual row; inline boxes on a row tile side by side), so containment only * holds for the duplicate or spurious-wider cases, never for a legitimate * sub-fragment that should be kept. * * On the `createRectsFromDOMRange` path (e.g. `positionNodeOnRange`): that * helper's own `selectionSpansElement` filter already drops the full-block-width * spurious rect, so there this mainly prevents the duplicate-doubling. The #7106 * extra-area paint is addressed for consumers that feed raw `getClientRects()`, * where `selectionSpansElement` does not run — that is the path that needs it. * * Known limitation: the disjoint assumption holds for normal flow. Overlapping * inline content — a negative margin, a transform, or a baseline-shifted inline * decorator — can place a real sub-fragment inside a wider real-text rect on the * same row; if a sub-pixel top offset also lets both clear * `createRectsFromDOMRange`'s asymmetric overlap filter, keep-smaller drops the * wider rect and under-paints the glyphs it uniquely covered. There is no * rect-only fix: that wider rect is geometrically indistinguishable from the * spurious-wider (#7106) rect, so keeping it would re-introduce the extra-area * paint. See the under-paint characterization browser test. * * Typed on the structural subset of `DOMRect` it reads, so it accepts a live * `DOMRectList` from `getClientRects()` as well as the `DOMRect[]` returned by * `createRectsFromDOMRange`, and is unit-testable without a DOM. */ function dedupeSelectionRects(rects) { const contains = (a, b) => b.left >= a.left - 1 && b.top >= a.top - 1 && b.right <= a.right + 1 && b.bottom <= a.bottom + 1; const kept = []; for (const rect of Array.from(rects)) { if (rect.width < 0.5 || rect.height < 0.5) { continue; } // `rect` contains a smaller rect already kept: keep the smaller one. if (kept.some(keptRect => contains(rect, keptRect))) { continue; } // A kept rect contains `rect`: drop the larger, keep the smaller `rect`. for (let i = kept.length - 1; i >= 0; i--) { if (contains(kept[i], rect)) { kept.splice(i, 1); } } kept.push(rect); } return kept; } /** * 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`; } const mutationObserverConfig = { attributes: true, characterData: true, childList: true, subtree: true }; function prependDOMNode(parent, node) { parent.insertBefore(node, parent.firstChild); } /** * Place one or multiple newly created Nodes at the passed Range's position. * Multiple nodes will only be created when the Range spans multiple lines (aka * client rects). * * This function can come particularly useful to highlight particular parts of * the text without interfering with the EditorState, that will often replicate * the state across collab and clipboard. * * This function accounts for DOM updates which can modify the passed Range. * Hence, the function return to remove the listener. */ function mlcPositionNodeOnRange(editor, range, onReposition) { let rootDOMNode = null; let parentDOMNode = null; let observer = null; let lastNodes = []; const wrapperNode = lexical.getRootOwnerDocument(editor.getRootElement()).createElement('div'); wrapperNode.style.position = 'relative'; function position() { if (!(rootDOMNode !== null)) { formatDevErrorMessage(`Unexpected null rootDOMNode`); } if (!(parentDOMNode !== null)) { formatDevErrorMessage(`Unexpected null parentDOMNode`); } const { left: parentLeft, top: parentTop } = parentDOMNode.getBoundingClientRect(); const rects = dedupeSelectionRects(selection.createRectsFromDOMRange(editor, range)); if (!wrapperNode.isConnected) { prependDOMNode(parentDOMNode, 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] || lexical.getRootOwnerDocument(rootDOMNode).createElement('div'); const rectNodeStyle = rectNode.style; if (rectNodeStyle.position !== 'absolute') { rectNodeStyle.position = 'absolute'; hasRepositioned = true; } const left = px(rect.left - parentLeft); if (rectNodeStyle.left !== left) { rectNodeStyle.left = left; hasRepositioned = true; } const top = px(rect.top - parentTop); 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) { const node = lastNodes.pop(); if (node != null) { node.remove(); } } 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 (!lexical.isHTMLElement(currentParentDOMNode)) { 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 $getOrderedSelectionPoints(selection) { const points = selection.getStartEndPoints(); return selection.isBackward() ? [points[1], points[0]] : points; } function $rangeTargetFromPoint(editor, point, node, dom) { if (point.type === 'text' || !lexical.$isElementNode(node)) { const textDOM = (lexical.$isTextNode(node) ? lexical.$getDOMTextNode(node, dom, editor) : lexical.getDOMTextNode(dom)) || dom; return [textDOM, point.offset]; } else { const slot = lexical.$getDOMSlot(node, dom, editor); return [slot.element, slot.getFirstChildOffset() + point.offset]; } } function $rangeFromPoints(editor, start, startNode, startDOM, end, endNode, endDOM) { const editorDocument = editor._window ? editor._window.document : document; const range = editorDocument.createRange(); range.setStart(...$rangeTargetFromPoint(editor, start, startNode, startDOM)); range.setEnd(...$rangeTargetFromPoint(editor, end, endNode, endDOM)); return range; } function defaultOnReposition(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.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); } } } /** * Place one or multiple newly created Nodes at the current selection. Multiple * nodes will only be created when the selection spans multiple lines (aka * client rects). * * This function can come useful when you want to show the selection but the * editor has been focused away. */ function markSelection(editor, onReposition = defaultOnReposition) { let previousAnchorNode = null; let previousAnchorNodeDOM = null; let previousAnchorOffset = null; let previousFocusNode = null; let previousFocusNodeDOM = 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 [start, end] = $getOrderedSelectionPoints(selection); const currentStartNode = start.getNode(); const currentStartNodeKey = currentStartNode.getKey(); const currentStartOffset = start.offset; const currentEndNode = end.getNode(); const currentEndNodeKey = currentEndNode.getKey(); const currentEndOffset = end.offset; const currentStartNodeDOM = editor.getElementByKey(currentStartNodeKey); const currentEndNodeDOM = editor.getElementByKey(currentEndNodeKey); const differentStartDOM = previousAnchorNode === null || currentStartNodeDOM !== previousAnchorNodeDOM || currentStartOffset !== previousAnchorOffset || currentStartNodeKey !== previousAnchorNode.getKey(); const differentEndDOM = previousFocusNode === null || currentEndNodeDOM !== previousFocusNodeDOM || currentEndOffset !== previousFocusOffset || currentEndNodeKey !== previousFocusNode.getKey(); if ((differentStartDOM || differentEndDOM) && currentStartNodeDOM !== null && currentEndNodeDOM !== null) { const range = $rangeFromPoints(editor, start, currentStartNode, currentStartNodeDOM, end, currentEndNode, currentEndNodeDOM); removeRangeListener(); removeRangeListener = mlcPositionNodeOnRange(editor, range, onReposition); } previousAnchorNode = currentStartNode; previousAnchorNodeDOM = currentStartNodeDOM; previousAnchorOffset = currentStartOffset; previousFocusNode = currentEndNode; previousFocusNodeDOM = currentEndNodeDOM; previousFocusOffset = currentEndOffset; // Pass {editor} so the active editor is set: $rangeTargetFromPoint reads // the slot (getFirstChildOffset), which consults the active editor to // skip the block cursor. }, { editor }); } compute(editor.getEditorState()); return lexical.mergeRegister(editor.registerUpdateListener(({ editorState }) => compute(editorState)), () => { removeRangeListener(); }); } /** * 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 selectionAlwaysOnDisplay(editor, onReposition) { let removeSelectionMark = null; const onSelectionChange = () => { const editorRootElement = editor.getRootElement(); // Read the selection from the editor's own document/window so iframe- // mounted editors don't fall back to the global one. The selectionchange // listener below is registered on rootElement.ownerDocument, so this // matches the event's source. const targetWindow = editorRootElement !== null ? editorRootElement.ownerDocument.defaultView : null; const domSelection = targetWindow !== null ? targetWindow.getSelection() : null; // Shadow-aware anchor so the contains() check below isn't fooled by the // retargeted host. const domAnchorNode = domSelection !== null ? lexical.getDOMSelectionPoints(domSelection, editorRootElement).anchorNode : null; const isSelectionInsideEditor = domAnchorNode !== null && editorRootElement !== null && editorRootElement.contains(domAnchorNode); if (isSelectionInsideEditor) { if (removeSelectionMark !== null) { removeSelectionMark(); removeSelectionMark = null; } } else { if (removeSelectionMark === null) { removeSelectionMark = markSelection(editor, onReposition); } } }; return editor.registerRootListener(rootElement => { if (rootElement) { const document = rootElement.ownerDocument; const cleanup = lexical.mergeRegister(lexical.registerEventListener(document, 'selectionchange', onSelectionChange), () => { if (removeSelectionMark !== null) { removeSelectionMark(); } }); onSelectionChange(); return cleanup; } }); } /** * 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. * */ // Got from https://stackoverflow.com/a/42543908/2013580 /** * Walks up from `element` and returns the nearest scrollable ancestor (or * `ownerDocument.body` if none is found), used to keep the active typeahead * option scrolled into view. Set `includeHidden` to also treat * `overflow: hidden` ancestors as scroll parents. * * The walk crosses ShadowRoot→host (via `getParentElement`) so a * shadow-mounted editor's scroll parent is found in the enclosing light-DOM * ancestor chain, and the styles / body are resolved through the element's * own realm so an iframe-mounted editor stays inside its document. */ function getScrollParent(element, includeHidden) { // Resolve through the element's own realm so an iframe-mounted editor's // scroll parent and computed styles come from its document, not the top one. const ownerDocument = element.ownerDocument; const win = ownerDocument.defaultView || window; let style = win.getComputedStyle(element); const excludeStaticParent = style.position === 'absolute'; const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/; if (style.position === 'fixed') { return ownerDocument.body; } for (let parent = element; parent = lexical.getParentElement(parent);) { style = win.getComputedStyle(parent); if (excludeStaticParent && style.position === 'static') { continue; } if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) { return parent; } } return ownerDocument.body; } /** * 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. * Preorder traversal is used, meaning that nodes are listed in the order of when they are FIRST encountered. * * Children-only spine: named slot subtrees are skipped. Use {@link $dfsWithSlots} * when you need to descend into slots (e.g. character counting, slot-aware * content extraction). * * @param startNode - The node to start the search (inclusive), if omitted, it will start at the root node. * @param endNode - The node to end the search (inclusive), if omitted, it will find all descendants of the startingNode. If endNode * is an ElementNode, it will stop before visiting any of its children. * @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 start node). */ function $dfs(startNode, endNode) { return Array.from($dfsIterator(startNode, endNode)); } /** * Get the adjacent caret in the same direction * * @param caret A caret or null * @returns `caret.getAdjacentCaret()` or `null` */ function $getAdjacentCaret(caret) { return caret ? caret.getAdjacentCaret() : null; } /** * $dfs iterator (right to left). Tree traversal is done on the fly as new values are requested with O(1) memory. * @param startNode - The node to start the search, if omitted, it will start at the root node. * @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ function $reverseDfs(startNode, endNode) { return Array.from($reverseDfsIterator(startNode, endNode)); } /** * $dfs iterator (left to right). Tree traversal is done on the fly as new values are requested with O(1) memory. * Preorder traversal is used, meaning that nodes are iterated over in the order of when they are FIRST encountered. * * Children-only spine: named slot subtrees are skipped. Use {@link $dfsWithSlotsIterator} * (or {@link $dfsWithSlots}) when you need to descend into slots — e.g. character * counting, content extraction, or any cross-tree analysis where slotted content * should be visited. * * @param startNode - The node to start the search (inclusive), if omitted, it will start at the root node. * @param endNode - The node to end the search (inclusive), if omitted, it will find all descendants of the startingNode. * If endNode is an ElementNode, the iterator will end as soon as it reaches the endNode (no children will be visited). * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ function $dfsIterator(startNode, endNode) { return $dfsCaretIterator('next', startNode, endNode); } /** * Like {@link $dfs}, but also descends into named slots. Slots are not on the * linked-list spine, so each host's slot subtrees are emitted slots-first, * right after the host node and before its linked-list children. * @experimental * @param startNode - The node to start the search (inclusive), defaults to the root node. * @param endNode - The node to end the search (inclusive), defaults to all descendants of startNode. * Like {@link $dfs}, reaching endNode stops the traversal before visiting any of its * children — including its slot subtrees. An endNode strictly inside a slot subtree * is never reached (slot subtrees are spliced in whole), so it does not truncate * the traversal. * @returns An array of DFSNodes. It will always return at least 1 node (the start node). */ function $dfsWithSlots(startNode, endNode) { return Array.from($dfsWithSlotsIterator(startNode, endNode)); } /** * Slot-aware {@link $dfsIterator}: a host's slot subtrees are emitted * slots-first, right after the host node and before its linked-list children. * The caret iterator drives the linked-list spine untouched. * @experimental * @param startNode - The node to start the search (inclusive), defaults to the root node. * @param endNode - The node to end the search (inclusive), defaults to all descendants of startNode. * Like {@link $dfs}, reaching endNode stops the traversal before visiting any of its * children — including its slot subtrees. An endNode strictly inside a slot subtree * is never reached (slot subtrees are spliced in whole), so it does not truncate * the traversal. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ function* $dfsWithSlotsIterator(startNode, endNode) { for (const dfsNode of $dfsCaretIterator('next', startNode, endNode)) { yield dfsNode; const { node, depth } = dfsNode; // endNode is an inclusive stop: none of its children are visited, so its // slot subtrees must not be either. if (lexical.$isSlotHost(node) && !node.is(endNode)) { for (const name of lexical.$getSlotNames(node)) { const slot = lexical.$getSlot(node, name); if (slot !== null) { yield* $dfsSubtreeIterator(slot, depth + 1); } } } } } /** * Slots-first preorder traversal of a self-contained subtree (a slot node and * everything it owns). Used to splice slot subtrees into $dfsWithSlotsIterator. */ function* $dfsSubtreeIterator(node, depth) { yield { depth, node }; const childDepth = depth + 1; if (lexical.$isSlotHost(node)) { for (const name of lexical.$getSlotNames(node)) { const slot = lexical.$getSlot(node, name); if (slot !== null) { yield* $dfsSubtreeIterator(slot, childDepth); } } } if (lexical.$isElementNode(node)) { for (const child of node.getChildren()) { yield* $dfsSubtreeIterator(child, childDepth); } } } function $getEndCaret(startNode, direction) { const rval = lexical.$getAdjacentSiblingOrParentSiblingCaret(lexical.$getSiblingCaret(startNode, direction)); return rval && rval[0]; } function $dfsCaretIterator(direction, startNode, endNode) { const root = lexical.$getRoot(); const start = startNode || root; const startCaret = lexical.$isElementNode(start) ? lexical.$getChildCaret(start, direction) : lexical.$getSiblingCaret(start, direction); const startDepth = $getDepth(start); const endCaret = endNode ? lexical.$getAdjacentChildCaret(lexical.$getChildCaretOrSelf(lexical.$getSiblingCaret(endNode, direction))) || $getEndCaret(endNode, direction) : $getEndCaret(start, direction); let depth = startDepth; return lexical.makeStepwiseIterator({ hasNext: state => state !== null, initial: startCaret, map: state => ({ depth, node: state.origin }), step: state => { if (state.isSameNodeCaret(endCaret)) { return null; } if (lexical.$isChildCaret(state)) { depth++; } const rval = lexical.$getAdjacentSiblingOrParentSiblingCaret(state); if (!rval || rval[0].isSameNodeCaret(endCaret)) { return null; } depth += rval[1]; return rval[0]; } }); } /** * Returns the Node sibling when this exists, otherwise the closest parent sibling. For example * R -> P -> T1, T2 * -> P2 * returns T2 for node T1, P2 for node T2, and null for node P2. * @param node LexicalNode. * @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist. */ function $getNextSiblingOrParentSibling(node) { const rval = lexical.$getAdjacentSiblingOrParentSiblingCaret(lexical.$getSiblingCaret(node, 'next')); return rval && [rval[0].origin, rval[1]]; } function $getDepth(node) { let depth = -1; for (let innerNode = node; innerNode !== null; // A slotted node has no parent; climb its slot host instead. innerNode = innerNode.getParent() ?? lexical.$getSlotHost(innerNode)) { depth++; } return depth; } /** * Performs a right-to-left preorder tree traversal. * From the starting node it goes to the rightmost child, than backtracks to parent 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 */ function $getNextRightPreorderNode(startingNode) { const startCaret = lexical.$getChildCaretOrSelf(lexical.$getSiblingCaret(startingNode, 'previous')); const next = lexical.$getAdjacentSiblingOrParentSiblingCaret(startCaret, 'root'); return next && next[0].origin; } /** * $dfs iterator (right to left). Tree traversal is done on the fly as new values are requested with O(1) memory. * @param startNode - The node to start the search, if omitted, it will start at the root node. * @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ function $reverseDfsIterator(startNode, endNode) { return $dfsCaretIterator('previous', startNode, endNode); } /** * Like {@link $reverseDfs}, but also descends into named slots. Mirror of * {@link $dfsWithSlots}. * @experimental * @param startNode - The node to start the search (inclusive), defaults to the root node. * @param endNode - The node to end the search (inclusive), defaults to all descendants of startNode. * Mirroring {@link $dfsWithSlots}, reaching endNode stops the traversal without * emitting its slot subtrees. An endNode strictly inside a slot subtree is never * reached (slot subtrees are spliced in whole), so it does not truncate the * traversal. * @returns An array of DFSNodes. It will always return at least 1 node (the start node). */ function $reverseDfsWithSlots(startNode, endNode) { return Array.from($reverseDfsWithSlotsIterator(startNode, endNode)); } /** * Right-to-left mirror of {@link $dfsWithSlotsIterator}. Forward visits slots * before children, so the mirror visits them last: a host's slot subtrees are * emitted (in reverse slot order) only once its linked-list subtree is fully * traversed. Because the caret spine streams nodes, "left the host subtree" is * detected when a node at the host's depth or shallower arrives, flushing the * host's pending slots. The caret iterator drives the spine untouched. * @experimental * @param startNode - The node to start the search (inclusive), defaults to the root node. * @param endNode - The node to end the search (inclusive), defaults to all descendants of startNode. * Mirroring {@link $dfsWithSlotsIterator}, reaching endNode stops the traversal * without emitting its slot subtrees. An endNode strictly inside a slot subtree is * never reached (slot subtrees are spliced in whole), so it does not truncate the * traversal. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ function* $reverseDfsWithSlotsIterator(startNode, endNode) { const pending = []; for (const dfsNode of $dfsCaretIterator('previous', startNode, endNode)) { while (pending.length > 0 && dfsNode.depth <= pending[pending.length - 1].depth) { const host = pending.pop(); yield* $reverseSlotsOf(host.node, host.depth + 1); } yield dfsNode; const { node, depth } = dfsNode; // endNode is an inclusive stop: mirror the forward iterator and leave its // slot subtrees unvisited rather than flushing them after the stop. if (lexical.$isSlotHost(node) && lexical.$getSlotNames(node).length > 0 && !node.is(endNode)) { pending.push({ depth, node }); } } while (pending.length > 0) { const host = pending.pop(); yield* $reverseSlotsOf(host.node, host.depth + 1); } } /** Emit a host's slot subtrees in reverse slot order (mirror of slots-first). */ function* $reverseSlotsOf(host, childDepth) { const names = lexical.$getSlotNames(host); for (let i = names.length - 1; i >= 0; i--) { const slot = lexical.$getSlot(host, names[i]); if (slot !== null) { yield* $reverseDfsSubtreeIterator(slot, childDepth); } } } /** * Right-to-left slots-last preorder of a self-contained subtree: children in * reverse order, then slots in reverse order. Mirror of $dfsSubtreeIterator. */ function* $reverseDfsSubtreeIterator(node, depth) { yield { depth, node }; const childDepth = depth + 1; if (lexical.$isElementNode(node)) { const children = node.getChildren(); for (let i = children.length - 1; i >= 0; i--) { yield* $reverseDfsSubtreeIterator(children[i], childDepth); } } if (lexical.$isSlotHost(node)) { yield* $reverseSlotsOf(node, childDepth); } } /** * 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 = lexical.$findMatchingParent(startNode, node => lexical.$isElementNode(node) && !node.isInline()); if (!lexical.$isElementNode(blockNode)) { { formatDevErrorMessage(`Expected node ${startNode.__key} to have closest block element node.`); } } return blockNode; } /** * Checks whether the selection covers the entire block: the selection's * start point is at or before the first position inside blockNode and its * end point is at or after the last position inside blockNode. A selection * that extends beyond the block's boundaries still fully selects the block, * and an empty block is fully selected by any selection that touches or * surrounds it. * * @param blockNode - The ElementNode to check, typically a top-level block or the RootNode * @param selectionOrRange - The RangeSelection or CaretRange to check * @returns true if the selection covers the entire blockNode */ function $isBlockFullySelected(blockNode, selectionOrRange) { const range = lexical.$getCaretRangeInDirection(lexical.$isRangeSelection(selectionOrRange) ? lexical.$caretRangeFromSelection(selectionOrRange) : selectionOrRange, 'next'); // A named-slot subtree is isolated from its host through a parentless // up-link, so a range inside a slot can never cover a block outside that // slot frame (and vice versa) — and the caret comparison below has no // common ancestor to walk across the boundary. Different frames are // never fully selected; the same frame compares safely within it. const anchorFrame = lexical.$getSlotFrame(range.anchor.origin); const blockFrame = lexical.$getSlotFrame(blockNode.getLatest()); if (anchorFrame === null ? blockFrame !== null : !anchorFrame.is(blockFrame)) { return false; } const blockStart = lexical.$normalizeCaret(lexical.$getChildCaret(blockNode, 'next')); const blockEnd = lexical.$getCaretInDirection(lexical.$normalizeCaret(lexical.$getChildCaret(blockNode, 'previous')), 'next'); return lexical.$comparePointCaretNext(range.anchor, blockStart) <= 0 && lexical.$comparePointCaretNext(range.focus, blockEnd) >= 0; } /** * 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 nodeMap = new Map(); const activeEditorState = editor._pendingEditorState; for (const [key, node] of editorState._nodeMap) { nodeMap.set(key, lexical.$cloneWithProperties(node)); } if (activeEditorState) { activeEditorState._nodeMap = nodeMap; } lexical.$fullReconcile(); const selection = editorState._selection; lexical.$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 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(); let initialCaret; if (lexical.$isRangeSelection(selection)) { initialCaret = lexical.$caretFromPoint(selection.focus, 'next'); } else { if (selection != null) { const nodes = selection.getNodes(); const lastNode = nodes[nodes.length - 1]; if (lastNode) { initialCaret = lexical.$getSiblingCaret(lastNode, 'next'); } } initialCaret = initialCaret || lexical.$getChildCaret(lexical.$getRoot(), 'previous').getFlipped().insert(lexical.$createParagraphNode()); } const insertCaret = lexical.$insertNodeToNearestRootAtCaret(node, initialCaret); const adjacent = lexical.$getAdjacentChildCaret(insertCaret); const selectionCaret = lexical.$isChildCaret(adjacent) ? lexical.$normalizeCaret(adjacent) : insertCaret; lexical.$setSelectionFromCaretRange(lexical.$getCollapsedCaretRange(selectionCaret)); return node.getLatest(); } /** * Inserts a node into leaf — the deepest accessible node at the carriage position * @param node - The node to be inserted */ function $insertNodeIntoLeaf(node) { const selection = lexical.$getSelection(); if (!lexical.$isRangeSelection(selection)) { if (selection) { selection.insertNodes([node]); } return; } const caretRange = lexical.$caretRangeFromSelection(selection); let insertCaret = lexical.$getCaretRangeInDirection(lexical.$removeTextFromCaretRange(caretRange), 'next').anchor; if (lexical.$isTextPointCaret(insertCaret)) { const nextAnchor = lexical.$splitAtPointCaretNext(insertCaret); if (!nextAnchor) { return; } insertCaret = nextAnchor; } const focus = insertCaret.getFlipped(); focus.insert(node); lexical.$setSelectionFromCaretRange(lexical.$getCaretRange(focus, focus)); } /** * 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 iframes) */ function objectKlassEquals(object, objectClass) { return object !== null ? Object.getPrototypeOf(object).constructor.name === objectClass.name : false; } // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless, // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We // control this with the first boolean flag. function eventFiles(event) { let dataTransfer = null; if (objectKlassEquals(event, DragEvent)) { dataTransfer = event.dataTransfer; } else if (objectKlassEquals(event, ClipboardEvent)) { dataTransfer = event.clipboardData; } if (dataTransfer === null) { return [false, [], false]; } const types = dataTransfer.types; const hasFiles = types.includes('Files'); const hasContent = types.includes('text/html') || types.includes('text/plain'); return [hasFiles, Array.from(dataTransfer.files), hasContent]; } /** * @deprecated Use Array filter or flatMap * * 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; } /** * Applies the provided callback to each indentable block element in the Selection * * @param indentOrOutdent callback for performing the indent or outdent action * on a given block element. * @returns true if at least one block was handled, false otherwise. */ function $handleIndentAndOutdent(indentOrOutdent) { const selection = lexical.$getSelection(); if (!lexical.$isRangeSelection(selection)) { return false; } const alreadyHandled = new Set(); const nodes = selection.getNodes(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const key = node.getKey(); if (alreadyHandled.has(key)) { continue; } const parentBlock = lexical.$findMatchingParent(node, parentNode => lexical.$isElementNode(parentNode) && !parentNode.isInline()); if (parentBlock === null) { continue; } const parentKey = parentBlock.getKey(); if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) { alreadyHandled.add(parentKey); indentOrOutdent(parentBlock); } } return alreadyHandled.size > 0; } /** * 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) { lexical.$getChildCaret(parent, 'next').insert(node); } let NEEDS_MANUAL_ZOOM = lexical.IS_FIREFOX || !lexical.CAN_USE_DOM ? false : undefined; function needsManualZoom() { if (NEEDS_MANUAL_ZOOM === undefined) { // If the browser implements standardized CSS zoom, then the client rect // will be wider after zoom is applied // https://chromestatus.com/feature/5198254868529152 // https://github.com/facebook/lexical/issues/6863 // eslint-disable-next-line no-restricted-syntax const div = document.createElement('div'); div.style.position = 'absolute'; div.style.opacity = '0'; div.style.width = '100px'; div.style.left = '-1000px'; // eslint-disable-next-line no-restricted-syntax document.body.appendChild(div); const noZoom = div.getBoundingClientRect(); div.style.setProperty('zoom', '2'); NEEDS_MANUAL_ZOOM = div.getBoundingClientRect().width === noZoom.width; // eslint-disable-next-line no-restricted-syntax document.body.removeChild(div); } return NEEDS_MANUAL_ZOOM; } /** * Calculates the zoom level of an element as a result of using * css zoom property. For browsers that implement standardized CSS * zoom (Firefox, Chrome >= 128), this will always return 1. * @param element * @param useManualZoom - If true, always use zoom level will be calculated manually, otherwise it will be calculated on as needed basis. */ function calculateZoomLevel(element, useManualZoom = false) { let zoom = 1; if (needsManualZoom() || useManualZoom) { // Read styles from the element's own realm so an iframe-mounted editor's // zoom isn't computed through the top-level window (cross-realm // getComputedStyle can return an empty zoom). const win = element && element.ownerDocument.defaultView || window; while (element) { zoom *= Number(win.getComputedStyle(element).getPropertyValue('zoom')); element = lexical.getParentElement(element); } } return zoom; } /** * Checks if the editor is a nested editor created by LexicalNestedComposer */ function $isEditorIsNestedEditor(editor) { return editor._parentEditor !== null; } /** * A depth first last-to-first traversal of root that stops at each node that matches * $predicate and ensures that its parent is root. This is typically used to discard * invalid or unsupported wrapping nodes. For example, a TableNode must only have * TableRowNode as children, but an importer might add invalid nodes based on * caption, tbody, thead, etc. and this will unwrap and discard those. * * @param root The root to start the traversal * @param $predicate Should return true for nodes that are permitted to be children of root * @returns true if this unwrapped or removed any nodes */ function $unwrapAndFilterDescendants(root, $predicate) { return $unwrapAndFilterDescendantsImpl(root, $predicate, null); } function $unwrapAndFilterDescendantsImpl(root, $predicate, $onSuccess) { let didMutate = false; for (const node of $lastToFirstIterator(root)) { if ($predicate(node)) { if ($onSuccess !== null) { $onSuccess(node); } continue; } didMutate = true; if (lexical.$isElementNode(node)) { $unwrapAndFilterDescendantsImpl(node, $predicate, $onSuccess || (child => node.insertAfter(child))); } node.remove(); } return didMutate; } /** * A depth first traversal of the children array that stops at and collects * each node that `$predicate` matches. This is typically used to discard * invalid or unsupported wrapping nodes on a children array in the `after` * of an {@link lexical!DOMConversionOutput}. For example, a TableNode must only have * TableRowNode as children, but an importer might add invalid nodes based on * caption, tbody, thead, etc. and this will unwrap and discard those. * * This function is read-only and performs no mutation operations, which makes * it suitable for import and export purposes but likely not for any in-place * mutation. You should use {@link $unwrapAndFilterDescendants} for in-place * mutations such as node transforms. * * @param children The children to traverse * @param $predicate Should return true for nodes that are permitted to be children of root * @returns The children or their descendants that match $predicate */ function $descendantsMatching(children, $predicate) { const result = []; const stack = Array.from(children).reverse(); for (let child = stack.pop(); child !== undefined; child = stack.pop()) { if ($predicate(child)) { result.push(child); } else if (lexical.$isElementNode(child)) { for (const grandchild of $lastToFirstIterator(child)) { stack.push(grandchild); } } } return result; } /** * Return an iterator that yields each child of node from first to last, taking * care to preserve the next sibling before yielding the value in case the caller * removes the yielded node. * * @param node The node whose children to iterate * @returns An iterator of the node's children */ function $firstToLastIterator(node) { return $childIterator(lexical.$getChildCaret(node, 'next')); } /** * Return an iterator that yields each child of node from last to first, taking * care to preserve the previous sibling before yielding the value in case the caller * removes the yielded node. * * @param node The node whose children to iterate * @returns An iterator of the node's children */ function $lastToFirstIterator(node) { return $childIterator(lexical.$getChildCaret(node, 'previous')); } function $childIterator(startCaret) { const seen = new Set() ; return lexical.makeStepwiseIterator({ hasNext: lexical.$isSiblingCaret, initial: startCaret.getAdjacentCaret(), map: caret => { const origin = caret.origin.getLatest(); if (seen !== null) { const key = origin.getKey(); if (!!seen.has(key)) { formatDevErrorMessage(`$childIterator: Cycle detected, node with key ${String(key)} has already been traversed`); } seen.add(key); } return origin; }, step: ca