@lexical/react
Version:
This package provides Lexical components and hooks for React applications.
633 lines (620 loc) • 21.4 kB
JavaScript
/**
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalEditable } from '@lexical/react/useLexicalEditable';
import { eventFiles } from '@lexical/rich-text';
import { calculateZoomLevel } from '@lexical/utils';
import { getParentElement, registerEventListeners, mergeRegister, DRAGOVER_COMMAND, COMMAND_PRIORITY_LOW, DROP_COMMAND, COMMAND_PRIORITY_HIGH, IS_FIREFOX, registerEventListener, BLUR_COMMAND, getActiveElementDeep, getRootOwnerDocument, isHTMLElement, getComposedEventTarget, $getNodeByKey, $getNearestNodeFromDOMNode, $onUpdate, $getSelection, getActiveElement, $getRoot } from 'lexical';
import { useRef, useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
/**
* 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.
*
*/
class Point {
_x;
_y;
constructor(x, y) {
this._x = x;
this._y = y;
}
get x() {
return this._x;
}
get y() {
return this._y;
}
equals({
x,
y
}) {
return this.x === x && this.y === y;
}
calcDeltaXTo({
x
}) {
return this.x - x;
}
calcDeltaYTo({
y
}) {
return this.y - y;
}
calcHorizontalDistanceTo(point) {
return Math.abs(this.calcDeltaXTo(point));
}
calcVerticalDistance(point) {
return Math.abs(this.calcDeltaYTo(point));
}
calcDistanceTo(point) {
return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2));
}
}
function isPoint(x) {
return x instanceof Point;
}
/**
* 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.
*
*/
class Rectangle {
_left;
_top;
_right;
_bottom;
constructor(left, top, right, bottom) {
const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top];
const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left];
this._top = physicTop;
this._right = physicRight;
this._left = physicLeft;
this._bottom = physicBottom;
}
get top() {
return this._top;
}
get right() {
return this._right;
}
get bottom() {
return this._bottom;
}
get left() {
return this._left;
}
get width() {
return Math.abs(this._left - this._right);
}
get height() {
return Math.abs(this._bottom - this._top);
}
equals({
top,
left,
bottom,
right
}) {
return top === this._top && bottom === this._bottom && left === this._left && right === this._right;
}
contains(target) {
if (isPoint(target)) {
const {
x,
y
} = target;
const isOnTopSide = y < this._top;
const isOnBottomSide = y > this._bottom;
const isOnLeftSide = x < this._left;
const isOnRightSide = x > this._right;
const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
return {
reason: {
isOnBottomSide,
isOnLeftSide,
isOnRightSide,
isOnTopSide
},
result
};
} else {
const {
top,
left,
bottom,
right
} = target;
return top >= this._top && top <= this._bottom && bottom >= this._top && bottom <= this._bottom && left >= this._left && left <= this._right && right >= this._left && right <= this._right;
}
}
intersectsWith(rect) {
const {
left: x1,
top: y1,
width: w1,
height: h1
} = rect;
const {
left: x2,
top: y2,
width: w2,
height: h2
} = this;
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
const minX = x1 <= x2 ? x1 : x2;
const minY = y1 <= y2 ? y1 : y2;
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
}
generateNewRect({
left = this.left,
top = this.top,
right = this.right,
bottom = this.bottom
}) {
return new Rectangle(left, top, right, bottom);
}
static fromLTRB(left, top, right, bottom) {
return new Rectangle(left, top, right, bottom);
}
static fromLWTH(left, width, top, height) {
return new Rectangle(left, top, left + width, top + height);
}
static fromPoints(startPoint, endPoint) {
const {
y: top,
x: left
} = startPoint;
const {
y: bottom,
x: right
} = endPoint;
return Rectangle.fromLTRB(left, top, right, bottom);
}
static fromDOM(dom) {
const {
top,
width,
left,
height
} = dom.getBoundingClientRect();
return Rectangle.fromLWTH(left, width, top, height);
}
}
/**
* 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 SPACE = 4;
const TARGET_LINE_HALF_HEIGHT = 2;
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
const TEXT_BOX_HORIZONTAL_PADDING = 28;
const Downward = 1;
const Upward = -1;
const Indeterminate = 0;
let prevIndex = Infinity;
function getCurrentIndex(keysLength) {
if (keysLength === 0) {
return Infinity;
}
if (prevIndex >= 0 && prevIndex < keysLength) {
return prevIndex;
}
return Math.floor(keysLength / 2);
}
function getTopLevelNodeKeys(editor) {
return editor.read('latest', () => $getRoot().getChildrenKeys());
}
function restoreEditorFocus(editor, rootElement) {
rootElement.focus({
preventScroll: true
});
editor.update(() => {
const selection = $getSelection();
if (selection !== null && !selection.dirty) {
selection.dirty = true;
}
});
}
function getCollapsedMargins(elem) {
const view = elem.ownerDocument.defaultView;
const getMargin = (element, margin) => element && view ? parseFloat(view.getComputedStyle(element)[margin]) : 0;
const {
marginTop = '0',
marginBottom = '0'
} = view ? view.getComputedStyle(elem) : {};
const prevElemSiblingMarginBottom = getMargin(elem.previousElementSibling, 'marginBottom');
const nextElemSiblingMarginTop = getMargin(elem.nextElementSibling, 'marginTop');
const collapsedTopMargin = Math.max(parseFloat(marginTop), prevElemSiblingMarginBottom);
const collapsedBottomMargin = Math.max(parseFloat(marginBottom), nextElemSiblingMarginTop);
return {
marginBottom: collapsedBottomMargin,
marginTop: collapsedTopMargin
};
}
function getBlockElement(anchorElem, editor, event, useEdgeAsDefault = false) {
const anchorElementRect = anchorElem.getBoundingClientRect();
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
let blockElem = null;
editor.read('latest', () => {
if (useEdgeAsDefault) {
const [firstNode, lastNode] = [editor.getElementByKey(topLevelNodeKeys[0]), editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1])];
const [firstNodeRect, lastNodeRect] = [firstNode != null ? firstNode.getBoundingClientRect() : undefined, lastNode != null ? lastNode.getBoundingClientRect() : undefined];
if (firstNodeRect && lastNodeRect) {
const firstNodeZoom = calculateZoomLevel(firstNode);
const lastNodeZoom = calculateZoomLevel(lastNode);
if (event.y / firstNodeZoom < firstNodeRect.top) {
blockElem = firstNode;
} else if (event.y / lastNodeZoom > lastNodeRect.bottom) {
blockElem = lastNode;
}
if (blockElem) {
return;
}
}
}
let index = getCurrentIndex(topLevelNodeKeys.length);
let direction = Indeterminate;
while (index >= 0 && index < topLevelNodeKeys.length) {
const key = topLevelNodeKeys[index];
const elem = editor.getElementByKey(key);
if (elem === null) {
break;
}
const zoom = calculateZoomLevel(elem);
const point = new Point(event.x / zoom, event.y / zoom);
const domRect = Rectangle.fromDOM(elem);
const {
marginTop,
marginBottom
} = getCollapsedMargins(elem);
const rect = domRect.generateNewRect({
bottom: domRect.bottom + marginBottom,
left: anchorElementRect.left,
right: anchorElementRect.right,
top: domRect.top - marginTop
});
const {
result,
reason: {
isOnTopSide,
isOnBottomSide
}
} = rect.contains(point);
if (result) {
blockElem = elem;
prevIndex = index;
break;
}
if (direction === Indeterminate) {
if (isOnTopSide) {
direction = Upward;
} else if (isOnBottomSide) {
direction = Downward;
} else {
// stop search block element
direction = Infinity;
}
}
index += direction;
}
});
return blockElem;
}
function setMenuPosition(targetElem, floatingElem, anchorElem, zoomLevel) {
if (!targetElem) {
floatingElem.style.display = 'none';
return;
}
const targetRect = targetElem.getBoundingClientRect();
const targetView = targetElem.ownerDocument.defaultView;
const targetStyle = targetView ? targetView.getComputedStyle(targetElem) : null;
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
// top left
let targetCalculateHeight = targetStyle ? parseInt(targetStyle.lineHeight, 10) : NaN;
if (isNaN(targetCalculateHeight)) {
// middle
targetCalculateHeight = targetRect.bottom - targetRect.top;
}
const top = (targetRect.top + (targetCalculateHeight - (floatingElemRect.height || targetCalculateHeight)) / 2 - anchorElementRect.top + anchorElem.scrollTop) / zoomLevel;
const left = SPACE;
floatingElem.style.display = 'flex';
floatingElem.style.opacity = '1';
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
}
function setDragImage(dataTransfer, draggableBlockElem) {
const {
transform
} = draggableBlockElem.style;
// Remove dragImage borders
draggableBlockElem.style.transform = 'translateZ(0)';
dataTransfer.setDragImage(draggableBlockElem, 0, 0);
setTimeout(() => {
draggableBlockElem.style.transform = transform;
});
}
function setTargetLine(targetLineElem, targetBlockElem, mouseY, anchorElem) {
const {
top: targetBlockElemTop,
height: targetBlockElemHeight
} = targetBlockElem.getBoundingClientRect();
const {
top: anchorTop,
width: anchorWidth
} = anchorElem.getBoundingClientRect();
const {
marginTop,
marginBottom
} = getCollapsedMargins(targetBlockElem);
let lineTop = targetBlockElemTop;
if (mouseY >= targetBlockElemTop) {
lineTop += targetBlockElemHeight + marginBottom / 2;
} else {
lineTop -= marginTop / 2;
}
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT + anchorElem.scrollTop;
const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2}px`;
targetLineElem.style.opacity = '.4';
}
function hideTargetLine(targetLineElem) {
if (targetLineElem) {
targetLineElem.style.opacity = '0';
targetLineElem.style.transform = 'translate(-10000px, -10000px)';
}
}
function useDraggableBlockMenu(editor, anchorElem, menuRef, targetLineRef, isEditable, menuComponent, targetLineComponent, isOnMenu, onElementChanged) {
const scrollerElem = getParentElement(anchorElem);
const isDraggingBlockRef = useRef(false);
const [draggableBlockElem, setDraggableBlockElemState] = useState(null);
const setDraggableBlockElem = useCallback(elem => {
setDraggableBlockElemState(elem);
if (onElementChanged) {
onElementChanged(elem);
}
}, [onElementChanged]);
useEffect(() => {
function onMouseMove(event) {
const target = getComposedEventTarget(event);
if (!isHTMLElement(target)) {
setDraggableBlockElem(null);
return;
}
if (isOnMenu(target)) {
return;
}
const _draggableBlockElem = getBlockElement(anchorElem, editor, event);
setDraggableBlockElem(_draggableBlockElem);
}
function onMouseLeave() {
setDraggableBlockElem(null);
}
if (scrollerElem != null) {
return registerEventListeners(scrollerElem, {
mouseleave: onMouseLeave,
mousemove: onMouseMove
});
}
}, [scrollerElem, anchorElem, editor, isOnMenu, setDraggableBlockElem]);
useEffect(() => {
const zoomLevel = calculateZoomLevel(editor.getRootElement(), true);
if (menuRef.current) {
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem, zoomLevel);
}
}, [editor, anchorElem, draggableBlockElem, menuRef]);
useEffect(() => {
function onDragover(event) {
if (!isDraggingBlockRef.current) {
return false;
}
const [isFileTransfer] = eventFiles(event);
if (isFileTransfer) {
return false;
}
const pageY = event.pageY;
// Composed target so the zoom level is read from the real element rather
// than the shadow host when the editor is in a shadow tree.
const target = getComposedEventTarget(event);
if (!isHTMLElement(target)) {
return false;
}
const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
const targetLineElem = targetLineRef.current;
if (targetBlockElem === null || targetLineElem === null) {
return false;
}
setTargetLine(targetLineElem, targetBlockElem, pageY / calculateZoomLevel(target), anchorElem);
// Prevent default event to be able to trigger onDrop events
event.preventDefault();
return true;
}
function $onDrop(event) {
if (!isDraggingBlockRef.current) {
return false;
}
const [isFileTransfer] = eventFiles(event);
if (isFileTransfer) {
return false;
}
const {
dataTransfer,
pageY
} = event;
// Composed target so the zoom level is read from the real element rather
// than the shadow host when the editor is in a shadow tree.
const target = getComposedEventTarget(event);
const dragData = dataTransfer != null ? dataTransfer.getData(DRAG_DATA_FORMAT) : '';
const draggedNode = $getNodeByKey(dragData);
if (!draggedNode) {
return false;
}
if (!isHTMLElement(target)) {
return false;
}
const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
if (!targetBlockElem) {
return false;
}
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
if (!targetNode) {
return false;
}
if (targetNode === draggedNode) {
// Firefox-specific fix: Even when no move occurs, restore focus to ensure cursor visibility
if (IS_FIREFOX) {
editor.focus();
}
return true;
}
const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top;
if (pageY / calculateZoomLevel(target) >= targetBlockElemTop) {
targetNode.insertAfter(draggedNode);
} else {
targetNode.insertBefore(draggedNode);
}
setDraggableBlockElem(null);
// Firefox-specific fix: Use editor.focus() after drop to properly restore
// both focus and selection. This ensures cursor visibility immediately.
if (IS_FIREFOX) {
// Using $onUpdate ensures this happens after the current update cycle finishes
$onUpdate(() => {
editor.focus();
});
}
return true;
}
return mergeRegister(editor.registerCommand(DRAGOVER_COMMAND, event => {
return onDragover(event);
}, COMMAND_PRIORITY_LOW), editor.registerCommand(DROP_COMMAND, event => {
return $onDrop(event);
}, COMMAND_PRIORITY_HIGH));
}, [anchorElem, editor, targetLineRef, setDraggableBlockElem]);
// Firefox-specific: Prevent blur when clicking on drag handle to maintain cursor visibility.
// Firefox fires blur before dragstart, causing focus loss. We detect this by checking if
// the blur's relatedTarget is on the menu using isOnMenu, then restore focus synchronously.
useEffect(() => {
if (!IS_FIREFOX || !isEditable) {
return;
}
return mergeRegister(editor.registerRootListener(rootElement => {
function onBlur(event) {
const relatedTarget = event.relatedTarget;
if (isHTMLElement(relatedTarget) && isOnMenu(relatedTarget)) {
// Blur is caused by clicking on drag handle - restore focus immediately
// to prevent cursor from disappearing. This must be synchronous to work.
if (rootElement) {
restoreEditorFocus(editor, rootElement);
}
// Prevent the event from propagating to LexicalEvents handler
event.stopImmediatePropagation();
}
}
if (rootElement) {
return registerEventListener(rootElement, 'blur', onBlur, true);
}
}),
// Intercept BLUR_COMMAND if focus is on the menu (fallback in case event propagation wasn't stopped)
editor.registerCommand(BLUR_COMMAND, () => {
const rootElement = editor.getRootElement();
// getActiveElementDeep so the menu is found whether it is portaled
// into the light DOM or inside a shadow root (where
// document.activeElement only reports the shadow host).
const activeElement = getActiveElementDeep(getRootOwnerDocument(rootElement));
if (rootElement && isHTMLElement(activeElement) && isOnMenu(activeElement)) {
// Focus is on menu - restore to root and prevent blur command
restoreEditorFocus(editor, rootElement);
return true; // Prevent command from propagating
}
return false;
}, COMMAND_PRIORITY_HIGH));
}, [editor, isEditable, isOnMenu]);
function onDragStart(event) {
const dataTransfer = event.dataTransfer;
if (!dataTransfer || !draggableBlockElem) {
return;
}
setDragImage(dataTransfer, draggableBlockElem);
let nodeKey = '';
editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
if (node) {
nodeKey = node.getKey();
}
});
isDraggingBlockRef.current = true;
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
// Firefox-specific: Restore focus synchronously after drag starts to prevent cursor loss.
// The blur handler should have already restored focus, but we do it here as a fallback
// and to ensure selection is properly maintained during drag.
if (IS_FIREFOX) {
const rootElement = editor.getRootElement();
// getActiveElement rather than document.activeElement, which reports the
// shadow host when the editor is in a shadow root.
if (rootElement !== null && getActiveElement(rootElement) !== rootElement) {
// Restore focus synchronously - don't use requestAnimationFrame as blur already happened
// and we need immediate focus restoration to maintain cursor visibility
restoreEditorFocus(editor, rootElement);
}
}
}
function onDragEnd() {
isDraggingBlockRef.current = false;
hideTargetLine(targetLineRef.current);
const rootElement = editor.getRootElement();
if (rootElement !== null && getActiveElement(rootElement) !== rootElement) {
restoreEditorFocus(editor, rootElement);
}
}
return /*#__PURE__*/createPortal(/*#__PURE__*/jsxs(Fragment, {
children: [/*#__PURE__*/jsx("div", {
draggable: true,
onDragStart: onDragStart,
onDragEnd: onDragEnd,
children: isEditable && menuComponent
}), targetLineComponent]
}), anchorElem);
}
/**
* Renders a draggable handle and drop-target line that let users reorder
* top-level blocks by dragging. You supply the handle and target-line elements
* via `menuComponent`/`menuRef` and `targetLineComponent`/`targetLineRef`, an
* `anchorElem` to portal them into (defaults to `document.body`), and an
* `isOnMenu` predicate used to detect interactions with the handle.
*
* @experimental The API may change in a future release.
* @returns A portal containing the drag handle and target line.
*/
function DraggableBlockPlugin_EXPERIMENTAL({
// eslint-disable-next-line no-restricted-syntax
anchorElem = document.body,
menuRef,
targetLineRef,
menuComponent,
targetLineComponent,
isOnMenu,
onElementChanged
}) {
const [editor] = useLexicalComposerContext();
// Subscribed rather than read off the editor, so that setEditable() actually
// re-renders the handle. useLexicalEditable also handles StrictMode and
// concurrent rendering correctly.
const isEditable = useLexicalEditable();
return useDraggableBlockMenu(editor, anchorElem, menuRef, targetLineRef, isEditable, menuComponent, targetLineComponent, isOnMenu, onElementChanged);
}
export { DraggableBlockPlugin_EXPERIMENTAL };