Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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<MenuResolution>(
() => ({
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(
<LexicalExtensionComposer extension={extension}>
<MenuAnchorProbe />
</LexicalExtensionComposer>,
);
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(
<LexicalExtensionComposer extension={extension}>
<MenuAnchorProbe parent={parent} />
</LexicalExtensionComposer>,
);
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(
<LexicalExtensionComposer extension={extension}>
<MenuAnchorProbe parent={parent} />
</LexicalExtensionComposer>,
);
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);
});
});
78 changes: 58 additions & 20 deletions packages/lexical-react/src/shared/LexicalMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getDOMShadowRoots,
getRootOwnerDocument,
isDOMShadowRoot,
isHTMLElement,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -695,24 +740,17 @@ 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
(top + menuHeight > window.innerHeight ||
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`;
}
}

Expand Down
Loading