Skip to content

[lexical-playground] Refactor: Use @floating-ui/react for FloatingLinkEditorPlugin positioning#8388

Merged
etrepum merged 1 commit intofacebook:mainfrom
mayrang:refactor/floating-link-editor-floating-ui
Apr 25, 2026
Merged

[lexical-playground] Refactor: Use @floating-ui/react for FloatingLinkEditorPlugin positioning#8388
etrepum merged 1 commit intofacebook:mainfrom
mayrang:refactor/floating-link-editor-floating-ui

Conversation

@mayrang
Copy link
Copy Markdown
Contributor

@mayrang mayrang commented Apr 24, 2026

Description

Fixes #8362

Refactors FloatingLinkEditorPlugin to use @floating-ui/react for positioning, as suggested by @etrepum in #8387.

What changed

  • Replaced manual position calculation (setFloatingElemPositionForLinkEditor) with useFloating hook using offset, flip, and shift middleware
  • Removed manual scroll/resize event listeners (replaced by autoUpdate)
  • Used getRangeAt(0).getBoundingClientRect() instead of focusNode.parentElement.getBoundingClientRect() for reference positioning — on Linux Firefox, focusNode.parentElement can return a large element (like <p>) that fills the entire editor, causing the popup to overflow outside the editor bounds
  • Hidden FloatingTextFormatToolbar when a link is selected to prevent overlap when the link editor flips above the text

Why

The previous implementation manually handled boundary checks for top and right edges but missed bottom and viewport boundaries. Rather than patching each edge case individually, this refactors to @floating-ui/react which handles all boundary cases automatically. This is consistent with other playground plugins (DateTimeComponent, TableHoverActionsV2, NodeContextMenuPlugin) that already use the library.

Note on FloatingTextFormatToolbar change

The flip middleware introduces a new scenario that didn't exist before: when a link is near the bottom of the editor, the link editor popup flips above the text, overlapping with the FloatingTextFormatToolbar that also appears above the selected text.

To prevent this overlap, this PR hides the FloatingTextFormatToolbar when a link is selected (isLink). The tradeoff is that users can no longer apply text formatting (bold, italic, etc.) via the floating toolbar while a link is selected — though this is still possible via the main toolbar or keyboard shortcuts.

Open to alternative approaches if this tradeoff is not acceptable.

Test Plan

  1. Click a link in the middle of the editor → popup appears below the link
  2. Click a link near the bottom of the editor → popup flips above the link
  3. Click a link near the right edge → popup shifts left
  4. Resize browser window so editor is near viewport edge → popup stays within bounds
  5. Click a link and scroll → popup stays attached to the link
  6. Edit/delete link via popup buttons → works as before
  7. Press ESC → popup closes
  8. Select link text → FloatingTextFormatToolbar is hidden, only link popup shows

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Apr 25, 2026 2:46pm
lexical-playground Ready Ready Preview, Comment Apr 25, 2026 2:46pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Apr 24, 2026
@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 24, 2026

The PR description mentions videos but doesn't include anything.

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Apr 24, 2026
@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 24, 2026

Sorry. I drafted the PR first and recorded the demo videos after, but forgot to add them. Updated now.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 24, 2026

It looks like some of the e2e tests are failing with firefox

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 24, 2026

this one in particular:

  1 failed
    [firefox] › packages/lexical-playground/__tests__/e2e/Links.spec.mjs:44:3 › Links › Can convert a text node into a link

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 24, 2026

Ran the failing test locally on Firefox — it passes. Pushed an empty commit to re-run CI.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 24, 2026

For whatever reason the failures only occur on linux, here's the recording and test context from a test run that I reproduced


video.webm

Instructions

  • Following Playwright test failed.
  • Explain why, be concise, respect Playwright best practices.
  • Provide a snippet of code with the fix, if possible.

Test info

  • Name: packages/lexical-playground/tests/e2e/Links.spec.mjs >> Links >> Can convert a text node into a link
  • Location: packages/lexical-playground/tests/e2e/Links.spec.mjs:44:3

Error details

TimeoutError: page.click: Timeout 10000ms exceeded.
Call log:
  - waiting for locator('.link-edit')
    - locator resolved to <div tabindex="0" role="button" class="link-edit"></div>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <pre> root↵  └ (1) paragraph ↵    └ (4) link "https://…</pre> from <div class="tree-view-output">…</div> subtree intercepts pointer events
    - retrying click action
    - waiting 20ms
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <pre> root↵  └ (1) paragraph ↵    └ (4) link "https://…</pre> from <div class="tree-view-output">…</div> subtree intercepts pointer events
    - retrying click action
      - waiting 100ms
    19 × waiting for element to be visible, enabled and stable
       - element is visible, enabled and stable
       - scrolling into view if needed
       - done scrolling
       - <pre> root↵  └ (1) paragraph ↵    └ (4) link "https://…</pre> from <div class="tree-view-output">…</div> subtree intercepts pointer events
     - retrying click action
       - waiting 500ms

Page snapshot

- generic [ref=e1]:
  - generic [ref=e2]:
    - banner [ref=e3]:
      - link "Lexical Logo" [ref=e4] [cursor=pointer]:
        - /url: https://lexical.dev
        - img "Lexical Logo" [ref=e5]
    - generic [ref=e6]:
      - generic [ref=e7]:
        - button "Undo" [ref=e8] [cursor=pointer]
        - button "Redo" [disabled] [ref=e10]
        - button "Formatting options for text style" [ref=e12] [cursor=pointer]:
          - generic [ref=e14]: Normal
        - button "Formatting options for font family" [ref=e16] [cursor=pointer]:
          - generic [ref=e18]: Arial
        - button "Decrease font size" [ref=e20] [cursor=pointer]
        - spinbutton "Font size" [ref=e22]: "15"
        - button "Increase font size" [ref=e23] [cursor=pointer]
        - 'button "Format text as bold. Shortcut: Ctrl+B" [ref=e25] [cursor=pointer]'
        - 'button "Format text as italics. Shortcut: Ctrl+I" [ref=e27] [cursor=pointer]'
        - 'button "Format text to underlined. Shortcut: Ctrl+U" [ref=e29] [cursor=pointer]'
        - button "Insert code block" [ref=e31] [cursor=pointer]
        - button "Insert link" [ref=e33] [cursor=pointer]
        - button "Formatting text color" [ref=e35] [cursor=pointer]
        - button "Formatting background color" [ref=e38] [cursor=pointer]
        - button "Formatting options for additional text styles" [ref=e41] [cursor=pointer]
        - 'button "Page setup: size, orientation, and layout" [ref=e44] [cursor=pointer]'
        - button "Insert specialized editor node" [ref=e47] [cursor=pointer]:
          - generic [ref=e49]: Insert
        - button "Formatting options for text alignment" [ref=e51] [cursor=pointer]:
          - generic [ref=e53]: Left Align
      - generic [ref=e55]:
        - generic [ref=e57]:
          - textbox [active] [ref=e58]:
            - paragraph [ref=e59]:
              - link "Hello" [ref=e60]:
                - /url: https://
          - generic [ref=e62]:
            - link "https://" [ref=e63] [cursor=pointer]:
              - /url: https://
            - button [ref=e64] [cursor=pointer]
            - button [ref=e65] [cursor=pointer]
          - generic [ref=e66]:
            - button "Drag to reorder column" [ref=e67]
            - button "Sort column" [ref=e68] [cursor=pointer]
            - button "Add column" [ref=e69] [cursor=pointer]
          - button "Add row" [ref=e70] [cursor=pointer]
        - generic [ref=e71]:
          - button "Import editor state from JSON" [ref=e72] [cursor=pointer]
          - button "Export editor state to JSON" [ref=e74] [cursor=pointer]
          - button "Share Playground link to current editor state" [ref=e76] [cursor=pointer]
          - button "Clear editor contents" [ref=e78] [cursor=pointer]
          - button "Lock read-only mode" [ref=e80] [cursor=pointer]
          - button "Convert To Markdown" [ref=e82] [cursor=pointer]
          - button "Convert to html" [ref=e84] [cursor=pointer]
      - generic [ref=e86]:
        - button "Export DOM" [ref=e87]
        - button "Time Travel" [ref=e88]
        - generic [ref=e89]: "root └ (1) paragraph └ (4) link \"https://\" { rel: noreferrer } > └ (3) text \"Hello\" > ^^^^^ selection: range ├ anchor { key: 3, offset: 0, type: text } └ focus { key: 3, offset: 5, type: text } commands: └ 23. { type: SELECTION_CHANGE_COMMAND, payload: undefined } └ 24. { type: SELECTION_CHANGE_COMMAND, payload: undefined } └ 25. { type: TOGGLE_LINK_COMMAND, payload: https:// } └ 26. { type: FOCUS_COMMAND, payload: FocusEvent } └ 27. { type: CAN_UNDO_COMMAND, payload: true } └ 28. { type: TOGGLE_LINK_COMMAND, payload: https:// } └ 29. { type: FOCUS_COMMAND, payload: FocusEvent } └ 30. { type: CAN_UNDO_COMMAND, payload: true } └ 31. { type: SELECTION_CHANGE_COMMAND, payload: undefined } └ 32. { type: SELECTION_CHANGE_COMMAND, payload: undefined } editor (v0.43.0+git): └ namespace Playground └ editable true"
    - button [ref=e90] [cursor=pointer]
    - link:
      - /url: https://lexical.dev/docs/intro
      - button "Lexical Docs" [ref=e91] [cursor=pointer]
    - button "Enable paste log" [ref=e92] [cursor=pointer]
    - button "Enable test recorder" [ref=e93] [cursor=pointer]
    - link "View source on GitHub":
      - /url: https://github.com/facebook/lexical/tree/main/packages/lexical-playground
      - img [ref=e94] [cursor=pointer]
  - button "Show Comments" [ref=e98] [cursor=pointer]
  - button [ref=e101] [cursor=pointer]

Test source

  583 |             return _clipboardData[type];
  584 |           },
  585 |           types: Object.keys(_clipboardData),
  586 |         };
  587 |       }
  588 | 
  589 |       const editor =
  590 |         document.activeElement && document.activeElement.isContentEditable
  591 |           ? document.activeElement
  592 |           : document.querySelector(editorSelector);
  593 |       const pasteEvent = new ClipboardEvent('paste', {
  594 |         bubbles: true,
  595 |         cancelable: true,
  596 |       });
  597 |       Object.defineProperty(pasteEvent, 'clipboardData', {
  598 |         value: eventClipboardData,
  599 |       });
  600 |       editor.dispatchEvent(pasteEvent);
  601 |       if (!pasteEvent.defaultPrevented) {
  602 |         if (_canUseBeforeInput) {
  603 |           const inputEvent = new InputEvent('beforeinput', {
  604 |             bubbles: true,
  605 |             cancelable: true,
  606 |           });
  607 |           Object.defineProperty(inputEvent, 'inputType', {
  608 |             value: 'insertFromPaste',
  609 |           });
  610 |           Object.defineProperty(inputEvent, 'dataTransfer', {
  611 |             value: eventClipboardData,
  612 |           });
  613 |           editor.dispatchEvent(inputEvent);
  614 |         }
  615 |       }
  616 |     },
  617 |     {canUseBeforeInput, clipboardData},
  618 |   );
  619 | }
  620 | 
  621 | /**
  622 |  * @param {import('@playwright/test').Page} page
  623 |  */
  624 | export async function pasteFromClipboard(
  625 |   page,
  626 |   clipboardData,
  627 |   editorSelector = 'div[contenteditable="true"]',
  628 | ) {
  629 |   if (clipboardData === undefined) {
  630 |     await keyDownCtrlOrMeta(page);
  631 |     await page.keyboard.press('v');
  632 |     await keyUpCtrlOrMeta(page);
  633 |     return;
  634 |   }
  635 |   await pasteWithClipboardDataFromPageOrFrame(
  636 |     getPageOrFrame(page),
  637 |     clipboardData,
  638 |     editorSelector,
  639 |   );
  640 | }
  641 | 
  642 | export async function sleep(delay) {
  643 |   await new Promise((resolve) => setTimeout(resolve, delay));
  644 | }
  645 | 
  646 | // Fair time for the browser to process a newly inserted image
  647 | export async function sleepInsertImage(count = 1) {
  648 |   return await sleep(1000 * count);
  649 | }
  650 | 
  651 | /**
  652 |  * @param {import('@playwright/test').Page} page
  653 |  */
  654 | export async function focusEditor(page, parentSelector = '.editor-shell') {
  655 |   const locator = getEditorElement(page, parentSelector);
  656 |   await locator.focus();
  657 | }
  658 | 
  659 | export async function getHTML(page, selector = 'div[contenteditable="true"]') {
  660 |   return await locate(page, selector).innerHTML();
  661 | }
  662 | 
  663 | export function getEditorElement(page, parentSelector = '.editor-shell') {
  664 |   const selector = `${parentSelector} div[contenteditable="true"]`;
  665 |   return locate(page, selector).first();
  666 | }
  667 | 
  668 | export async function waitForSelector(page, selector, options) {
  669 |   await getPageOrFrame(page).waitForSelector(selector, options);
  670 | }
  671 | 
  672 | export function locate(page, selector) {
  673 |   return getPageOrFrame(page).locator(selector);
  674 | }
  675 | 
  676 | export async function selectorBoundingBox(page, selector) {
  677 |   return await locate(page, selector).boundingBox();
  678 | }
  679 | 
  680 | export async function click(page, selector, options) {
  681 |   const frame = getPageOrFrame(page);
  682 |   await frame.waitForSelector(selector, options);
> 683 |   await frame.click(selector, options);
      |               ^ TimeoutError: page.click: Timeout 10000ms exceeded.
  684 | }
  685 | 
  686 | export async function doubleClick(page, selector, options) {
  687 |   const frame = getPageOrFrame(page);
  688 |   await frame.waitForSelector(selector, options);
  689 |   await frame.dblclick(selector, options);
  690 | }
  691 | 
  692 | export async function focus(page, selector, options) {
  693 |   await locate(page, selector).focus(options);
  694 | }
  695 | 
  696 | export async function fill(page, selector, value) {
  697 |   await locate(page, selector).fill(value);
  698 | }
  699 | 
  700 | export async function selectOption(page, selector, options) {
  701 |   await getPageOrFrame(page).selectOption(selector, options);
  702 | }
  703 | 
  704 | export async function textContent(page, selector, options) {
  705 |   return await getPageOrFrame(page).textContent(selector, options);
  706 | }
  707 | 
  708 | export async function evaluate(page, fn, args) {
  709 |   return await getPageOrFrame(page).evaluate(fn, args);
  710 | }
  711 | 
  712 | export async function clearEditor(page) {
  713 |   await selectAll(page);
  714 |   await page.keyboard.press('Backspace');
  715 |   await page.keyboard.press('Backspace');
  716 | }
  717 | 
  718 | export async function insertSampleImage(page, modifier) {
  719 |   await selectFromInsertDropdown(page, '.image');
  720 |   if (modifier === 'alt') {
  721 |     await page.keyboard.down('Alt');
  722 |   }
  723 |   await click(page, 'button[data-test-id="image-modal-option-sample"]');
  724 |   if (modifier === 'alt') {
  725 |     await page.keyboard.up('Alt');
  726 |   }
  727 | }
  728 | 
  729 | export async function insertUrlImage(page, url, altText) {
  730 |   await selectFromInsertDropdown(page, '.image');
  731 |   await click(page, 'button[data-test-id="image-modal-option-url"]');
  732 |   await focus(page, 'input[data-test-id="image-modal-url-input"]');
  733 |   await page.keyboard.type(url);
  734 |   if (altText) {
  735 |     await focus(page, 'input[data-test-id="image-modal-alt-text-input"]');
  736 |     await page.keyboard.type(altText);
  737 |   }
  738 |   await click(page, 'button[data-test-id="image-modal-confirm-btn"]');
  739 | }
  740 | 
  741 | export async function insertUploadImage(page, files, altText) {
  742 |   await selectFromInsertDropdown(page, '.image');
  743 |   await click(page, 'button[data-test-id="image-modal-option-file"]');
  744 | 
  745 |   const frame = getPageOrFrame(page);
  746 |   await frame.setInputFiles(
  747 |     'input[data-test-id="image-modal-file-upload"]',
  748 |     files,
  749 |   );
  750 | 
  751 |   if (altText) {
  752 |     await focus(page, 'input[data-test-id="image-modal-alt-text-input"]');
  753 |     await page.keyboard.type(altText);
  754 |   }
  755 |   await click(page, 'button[data-test-id="image-modal-file-upload-btn"]');
  756 | }
  757 | 
  758 | export async function insertYouTubeEmbed(page, url) {
  759 |   await selectFromInsertDropdown(page, '.youtube');
  760 |   await focus(page, 'input[data-test-id="youtube-video-embed-modal-url"]');
  761 |   await page.keyboard.type(url);
  762 |   await click(
  763 |     page,
  764 |     'button[data-test-id="youtube-video-embed-modal-submit-btn"]',
  765 |   );
  766 | }
  767 | 
  768 | export async function insertHorizontalRule(page) {
  769 |   await selectFromInsertDropdown(page, '.horizontal-rule');
  770 | }
  771 | 
  772 | export async function insertDateTime(page) {
  773 |   await selectFromInsertDropdown(page, '.calendar');
  774 |   await sleep(500);
  775 | }
  776 | 
  777 | export function getExpectedDateTimeHtml({selected = false, formats = []} = {}) {
  778 |   const now = new Date();
  779 |   const date = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  780 | 
  781 |   // DateTimeNode displays a limited set of formats
  782 |   const formatToClassname = {
  783 |     bold: 'bold',

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 25, 2026

I set up an Ubuntu 24.04 VM and tested with Firefox in multiple ways:

  • Playwright headless
  • Playwright --headed mode (video below)
  • Manual testing in Firefox GUI

All pass — the popup positions correctly and doesn't overlap with the tree-view. Couldn't reproduce the CI failure.

2026-04-25.3.28.51.mov

Is there anything specific about the CI environment I should try to match?

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 25, 2026

Found the root cause — on Linux Firefox, focusNode.parentElement returns a <p> that fills the whole editor height instead of the link's <span>. This made @floating-ui position the popup below the editor bounds, right behind the tree-view.

Couldn't reproduce locally even on an Ubuntu VM, so I added debug logging to CI to check the actual element positions. That's how I spotted it.

Fixed by switching to getRangeAt(0).getBoundingClientRect(). Squashed commits and updated the PR description.

Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! One small clean-up would be to remove the unnecessary virtualReference ref

Comment on lines +79 to +82
const virtualReference = useRef<VirtualElement>({
getBoundingClientRect: () => new DOMRect(),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's any reason to make this a ref, it's not used anywhere other than the scope it's defined in

Suggested change
const virtualReference = useRef<VirtualElement>({
getBoundingClientRect: () => new DOMRect(),
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, thanks for catching that. removed.

Comment on lines +162 to +165
virtualReference.current = {
getBoundingClientRect: () => domRect,
};
refs.setPositionReference(virtualReference.current);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
virtualReference.current = {
getBoundingClientRect: () => domRect,
};
refs.setPositionReference(virtualReference.current);
refs.setPositionReference({
getBoundingClientRect: () => domRect,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, thanks for the suggestion.

…kEditorPlugin positioning

Replace manual position calculation with @floating-ui/react's useFloating
hook. This eliminates the custom setFloatingElemPositionForLinkEditor
utility and manual scroll/resize listeners in favor of floating-ui's
built-in middleware (offset, flip, shift) and autoUpdate.

Also use getRangeAt(0).getBoundingClientRect() instead of
focusNode.parentElement.getBoundingClientRect() for more accurate
reference positioning across browsers, and hide
FloatingTextFormatToolbar when a link is selected to prevent overlap
when the link editor flips above the text.

Fixes facebook#8362
@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 25, 2026

Done — removed the ref and force-pushed. Thanks for the review and for helping debug the CI issue!

@etrepum etrepum added this pull request to the merge queue Apr 25, 2026
Merged via the queue into facebook:main with commit f9c00af Apr 25, 2026
47 checks passed
@etrepum etrepum mentioned this pull request Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Floating link editor can overflow editor bounds and be clipped

2 participants