@lexical/utils
Version:
This package contains misc utilities for Lexical.
165 lines (154 loc) • 5.08 kB
text/typescript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import invariant from '@lexical/internal/invariant';
import {createRectsFromDOMRange} from '@lexical/selection';
import {getRootOwnerDocument, isHTMLElement, type LexicalEditor} from 'lexical';
import dedupeSelectionRects from './dedupeSelectionRects';
import px from './px';
const mutationObserverConfig = {
attributes: true,
characterData: true,
childList: true,
subtree: true,
};
function prependDOMNode(parent: HTMLElement, node: HTMLElement) {
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.
*/
export default function mlcPositionNodeOnRange(
editor: LexicalEditor,
range: Range,
onReposition: (node: HTMLElement[]) => void,
): () => void {
let rootDOMNode: null | HTMLElement = null;
let parentDOMNode: null | HTMLElement = null;
let observer: null | MutationObserver = null;
let lastNodes: HTMLElement[] = [];
const wrapperNode = getRootOwnerDocument(
editor.getRootElement(),
).createElement('div');
wrapperNode.style.position = 'relative';
function position(): void {
invariant(rootDOMNode !== null, 'Unexpected null rootDOMNode');
invariant(parentDOMNode !== null, 'Unexpected null parentDOMNode');
const {left: parentLeft, top: parentTop} =
parentDOMNode.getBoundingClientRect();
const rects = dedupeSelectionRects(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] || 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(): void {
parentDOMNode = null;
rootDOMNode = null;
if (observer !== null) {
observer.disconnect();
}
observer = null;
wrapperNode.remove();
for (const node of lastNodes) {
node.remove();
}
lastNodes = [];
}
function restart(): void {
const currentRootDOMNode = editor.getRootElement();
if (currentRootDOMNode === null) {
return stop();
}
const currentParentDOMNode = currentRootDOMNode.parentElement;
if (!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();
};
}