From ef2a6d9f13cd6d1261108f08aa9d8f56811edeaa Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Fri, 7 Aug 2026 03:41:33 -0700 Subject: [PATCH] [lexical-react] Bug Fix: Position the typeahead menu relative to its containing block useMenuAnchorRef absolutely positions the menu anchor and computes its top and left in document coordinates (the caret viewport rect plus window.pageXOffset / pageYOffset). Those coordinates only describe the anchor position while the containing block is the initial containing block, which is the case for the default document.body parent. When a `parent` is passed to LexicalTypeaheadMenuPlugin or LexicalNodeMenuPlugin it is normally positioned so that it can contain the menu, and the menu is then displaced by the parent offset and by the parent scroll position. Translate the caret viewport rect into the coordinate space the anchor is actually laid out in, and keep the previous document-coordinate path when no ancestor establishes a containing block. Adds browser tests, since jsdom reports every rect as zero. --- .../browser/useMenuAnchorRefPosition.test.tsx | 131 ++++++++++++++++++ .../lexical-react/src/shared/LexicalMenu.tsx | 78 ++++++++--- 2 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx diff --git a/packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx b/packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx new file mode 100644 index 00000000000..8d35466dedc --- /dev/null +++ b/packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx @@ -0,0 +1,131 @@ +/** + * 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 {defineExtension} from '@lexical/extension'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import {act, type ReactElement, useMemo} from 'react'; +import {createRoot} from 'react-dom/client'; +import {describe, expect, onTestFinished, test} from 'vitest'; + +import {type MenuResolution, useMenuAnchorRef} from '../../shared/LexicalMenu'; + +// The anchor is absolutely positioned, so where it lands depends on the +// containing block its `parent` sits in. jsdom reports every rect as zero, so +// this has to run against a real layout engine. +const extension = defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', +}); + +// Where the caret would be, in viewport coordinates. +const CARET_RECT = {height: 18, left: 150, top: 250, width: 2}; + +function renderReact(ui: ReactElement): void { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(ui); + }); + onTestFinished(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); +} + +let anchorElement: HTMLElement | null = null; + +function MenuAnchorProbe({parent}: {parent?: HTMLElement}): null { + const resolution = useMemo( + () => ({ + getRect: () => + new DOMRect( + CARET_RECT.left, + CARET_RECT.top, + CARET_RECT.width, + CARET_RECT.height, + ), + }), + [], + ); + anchorElement = useMenuAnchorRef( + resolution, + () => {}, + 'test-menu-anchor', + parent, + ).current; + return null; +} + +function createPositionedParent(scrollable = false): HTMLElement { + const parent = document.createElement('div'); + // A positioned ancestor establishes a containing block for the absolutely + // positioned anchor, so anchor coordinates are relative to this box. + parent.style.position = 'relative'; + parent.style.marginLeft = '120px'; + parent.style.marginTop = '200px'; + parent.style.width = '400px'; + parent.style.height = '300px'; + if (scrollable) { + parent.style.overflow = 'auto'; + const spacer = document.createElement('div'); + spacer.style.height = '1000px'; + parent.appendChild(spacer); + } + document.body.appendChild(parent); + onTestFinished(() => parent.remove()); + return parent; +} + +describe('useMenuAnchorRef positioning (browser)', () => { + test('anchors the menu at the caret when parent is the document body', () => { + renderReact( + + + , + ); + expect(anchorElement).not.toBeNull(); + const rect = anchorElement!.getBoundingClientRect(); + expect(Math.round(rect.left)).toBe(CARET_RECT.left); + expect(Math.round(rect.top)).toBe(CARET_RECT.top + 3); + }); + + test('anchors the menu at the caret when parent is a positioned element', () => { + const parent = createPositionedParent(); + renderReact( + + + , + ); + expect(anchorElement).not.toBeNull(); + expect(parent.contains(anchorElement!)).toBe(true); + // Without accounting for the containing block the anchor is pushed down + // and right by the parent's own offset. + const rect = anchorElement!.getBoundingClientRect(); + expect(Math.round(rect.left)).toBe(CARET_RECT.left); + expect(Math.round(rect.top)).toBe(CARET_RECT.top + 3); + }); + + test('anchors the menu at the caret when the positioned parent is scrolled', () => { + const parent = createPositionedParent(true); + parent.scrollTop = 250; + expect(parent.scrollTop).toBe(250); + renderReact( + + + , + ); + expect(anchorElement).not.toBeNull(); + const rect = anchorElement!.getBoundingClientRect(); + expect(Math.round(rect.left)).toBe(CARET_RECT.left); + expect(Math.round(rect.top)).toBe(CARET_RECT.top + 3); + }); +}); diff --git a/packages/lexical-react/src/shared/LexicalMenu.tsx b/packages/lexical-react/src/shared/LexicalMenu.tsx index 0559926a4ff..2408a7fd74a 100644 --- a/packages/lexical-react/src/shared/LexicalMenu.tsx +++ b/packages/lexical-react/src/shared/LexicalMenu.tsx @@ -18,6 +18,7 @@ import { getDOMShadowRoots, getRootOwnerDocument, isDOMShadowRoot, + isHTMLElement, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, @@ -631,6 +632,43 @@ function setContainerDivAttributes( containerDiv.style.position = 'absolute'; } +/** + * The anchor is absolutely positioned, so its `top`/`left` are resolved + * against its containing block. That is the initial containing block — i.e. + * document coordinates, which is why the page scroll offsets are added — only + * while the anchor's ancestors are all statically positioned, as is the case + * for the default `document.body` parent. A `parent` passed to + * {@link useMenuAnchorRef} is usually positioned so that it can contain the + * menu, and document coordinates then place the menu at the parent's own + * offset instead of at the caret. + * + * @returns The viewport coordinates of the origin that the anchor's `top`/ + * `left` are measured from, or `null` when that origin is the initial + * containing block and document coordinates apply. + */ +function getContainingBlockOrigin( + containerDiv: HTMLElement, +): null | {left: number; top: number} { + const {offsetParent} = containerDiv; + if (!isHTMLElement(offsetParent)) { + return null; + } + const view = offsetParent.ownerDocument.defaultView; + // `offsetParent` falls back to the body when nothing above the anchor is + // positioned, in which case the containing block is still the initial one. + if ( + view === null || + view.getComputedStyle(offsetParent).position === 'static' + ) { + return null; + } + const rect = offsetParent.getBoundingClientRect(); + return { + left: rect.left + offsetParent.clientLeft - offsetParent.scrollLeft, + top: rect.top + offsetParent.clientTop - offsetParent.scrollTop, + }; +} + function resolveMenuParent( editor: LexicalEditor, ): HTMLElement | ShadowRoot | undefined { @@ -675,15 +713,22 @@ export function useMenuAnchorRef( if (rootElement !== null && resolution !== null) { const {left, top, width, height} = resolution.getRect(); const anchorHeight = anchorElementRef.current.offsetHeight; // use to position under anchor - containerDiv.style.top = `${ - top + - anchorHeight + - 3 + - // eslint-disable-next-line no-restricted-syntax - (shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0) - }px`; - // eslint-disable-next-line no-restricted-syntax - containerDiv.style.left = `${left + window.pageXOffset}px`; + // `left`/`top` from getRect() are viewport coordinates; translate them + // into the coordinate space the anchor is actually positioned in. + const origin = getContainingBlockOrigin(containerDiv); + const toAnchorLeft = (viewportLeft: number) => + origin !== null + ? viewportLeft - origin.left + : // eslint-disable-next-line no-restricted-syntax + viewportLeft + window.pageXOffset; + const toAnchorTop = (viewportTop: number) => + origin !== null + ? viewportTop - origin.top + : viewportTop + + // eslint-disable-next-line no-restricted-syntax + (shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0); + containerDiv.style.top = `${toAnchorTop(top + anchorHeight + 3)}px`; + containerDiv.style.left = `${toAnchorLeft(left)}px`; containerDiv.style.height = `${height}px`; containerDiv.style.width = `${width}px`; if (menuEle !== null) { @@ -695,10 +740,9 @@ export function useMenuAnchorRef( const rootElementRect = rootElement.getBoundingClientRect(); if (left + menuWidth > rootElementRect.right) { - containerDiv.style.left = `${ - // eslint-disable-next-line no-restricted-syntax - rootElementRect.right - menuWidth + window.pageXOffset - }px`; + containerDiv.style.left = `${toAnchorLeft( + rootElementRect.right - menuWidth, + )}px`; } if ( // eslint-disable-next-line no-restricted-syntax @@ -706,13 +750,7 @@ export function useMenuAnchorRef( top + menuHeight > rootElementRect.bottom) && top - rootElementRect.top > menuHeight + height ) { - containerDiv.style.top = `${ - top - - menuHeight - - height + - // eslint-disable-next-line no-restricted-syntax - (shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0) - }px`; + containerDiv.style.top = `${toAnchorTop(top - menuHeight - height)}px`; } }