Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 80 additions & 14 deletions packages/lexical/src/plugins/DraggableBlockPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,54 @@ function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
}

/**
* Helper function to find the scrolling container and get scroll offsets.
* Checks if the element itself has scrollable overflow, then checks parent.
*/
function getScrollingContainer(elem: HTMLElement): {
element: HTMLElement;
scrollTop: number;
scrollLeft: number;
} {
// Check if the element itself is scrollable
const style = window.getComputedStyle(elem);
const hasScrollY = style.overflowY === 'auto' || style.overflowY === 'scroll';
const hasScrollX = style.overflowX === 'auto' || style.overflowX === 'scroll';

if (hasScrollY || hasScrollX) {
return {
element: elem,
scrollTop: elem.scrollTop || 0,
scrollLeft: elem.scrollLeft || 0,
};
}

// Check parent element
const parent = elem.parentElement;
if (parent) {
const parentStyle = window.getComputedStyle(parent);
const parentHasScrollY =
parentStyle.overflowY === 'auto' || parentStyle.overflowY === 'scroll';
const parentHasScrollX =
parentStyle.overflowX === 'auto' || parentStyle.overflowX === 'scroll';

if (parentHasScrollY || parentHasScrollX) {
return {
element: parent,
scrollTop: parent.scrollTop || 0,
scrollLeft: parent.scrollLeft || 0,
};
}
}

// No scrolling container found
return {
element: elem,
scrollTop: 0,
scrollLeft: 0,
};
}

function setMenuPosition(
targetElem: HTMLElement | null,
floatingElem: HTMLElement,
Expand All @@ -191,11 +239,12 @@ function setMenuPosition(
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();

const top =
targetRect.top +
(parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
anchorElementRect.top;
const { scrollTop } = getScrollingContainer(anchorElem);

// Calculate position with scroll compensation
const lineHeight = parseInt(targetStyle.lineHeight, 10);
const topOffset = (lineHeight - floatingElemRect.height) / 2;
const top = targetRect.top + topOffset - anchorElementRect.top + scrollTop;
const left = SPACE;

floatingElem.style.opacity = '1';
Expand Down Expand Up @@ -228,14 +277,17 @@ function setTargetLine(
const { top: anchorTop, width: anchorWidth } =
anchorElem.getBoundingClientRect();
const { marginTop, marginBottom } = getCollapsedMargins(targetBlockElem);

const { scrollTop } = getScrollingContainer(anchorElem);

let lineTop = targetBlockElemTop;
if (mouseY >= targetBlockElemTop) {
lineTop += targetBlockElemHeight + marginBottom / 2;
} else {
lineTop -= marginTop / 2;
}

const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT + scrollTop;
const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;

targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
Expand All @@ -257,14 +309,17 @@ function useDraggableBlockMenu(
anchorElem: HTMLElement,
isEditable: boolean,
): JSX.Element {
const scrollerElem = anchorElem.parentElement;

const menuRef = useRef<HTMLDivElement>(null);
const targetLineRef = useRef<HTMLDivElement>(null);
const isDraggingBlockRef = useRef<boolean>(false);
const [draggableBlockElem, setDraggableBlockElem] =
useState<HTMLElement | null>(null);

// Get theme classes from editor config
const theme = editor._config.theme;
const menuThemeClass = theme.draggableBlockMenu || '';
const targetLineThemeClass = theme.draggableBlockTargetLine || '';

useEffect(() => {
function onMouseMove(event: MouseEvent) {
const target = event.target;
Expand All @@ -273,6 +328,11 @@ function useDraggableBlockMenu(
return;
}

// Performance optimization: Early exit if mouse is outside editor area
if (!anchorElem.contains(target)) {
return;
}

if (isOnMenu(target)) {
return;
}
Expand All @@ -286,14 +346,17 @@ function useDraggableBlockMenu(
setDraggableBlockElem(null);
}

scrollerElem?.addEventListener('mousemove', onMouseMove);
scrollerElem?.addEventListener('mouseleave', onMouseLeave);
// BUGFIX: Attach to document instead of scrollerElem to ensure mouse events
// work even after scrolling. The scrollerElem approach fails when content scrolls
// because the mouse event coordinates become misaligned with element positions.
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseleave', onMouseLeave);

return () => {
scrollerElem?.removeEventListener('mousemove', onMouseMove);
scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseleave', onMouseLeave);
};
}, [scrollerElem, anchorElem, editor]);
}, [anchorElem, editor]);

useEffect(() => {
if (menuRef.current) {
Expand Down Expand Up @@ -412,15 +475,18 @@ function useDraggableBlockMenu(
return createPortal(
<>
<div
className="icon draggable-block-menu"
className={`icon draggable-block-menu ${menuThemeClass}`}
ref={menuRef}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className={isEditable ? 'icon' : ''} />
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />
<div
className={`draggable-block-target-line ${targetLineThemeClass}`}
ref={targetLineRef}
/>
</>,
anchorElem,
);
Expand Down
12 changes: 10 additions & 2 deletions packages/lexical/style/lexical/DraggableBlockPlugin.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,24 @@
.draggable-block-menu .icon {
width: 16px;
height: 16px;
opacity: 0.3;
opacity: 0.4;
background-image: url(./icons/draggable-block-menu.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}

.draggable-block-menu:active {
cursor: grabbing;
}

.draggable-block-menu:hover {
background-color: #efefef;
background-color: rgba(128, 128, 128, 0.1);
opacity: 1;
}

.draggable-block-menu:hover .icon {
opacity: 0.8;
}

.draggable-block-target-line {
Expand Down
Loading