[lexical-playground] Refactor: Use @floating-ui/react for FloatingLinkEditorPlugin positioning#8388
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
The PR description mentions videos but doesn't include anything. |
|
Sorry. I drafted the PR first and recorded the demo videos after, but forgot to add them. Updated now. |
|
It looks like some of the e2e tests are failing with firefox |
|
this one in particular: |
|
Ran the failing test locally on Firefox — it passes. Pushed an empty commit to re-run CI. |
|
For whatever reason the failures only occur on linux, here's the recording and test context from a test run that I reproduced video.webmInstructions
Test info
Error detailsPage 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', |
|
I set up an Ubuntu 24.04 VM and tested with Firefox in multiple ways:
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.movIs there anything specific about the CI environment I should try to match? |
8195909 to
ee8e10e
Compare
e684ea7 to
15750a4
Compare
|
Found the root cause — on Linux Firefox, 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 |
etrepum
left a comment
There was a problem hiding this comment.
This looks great! One small clean-up would be to remove the unnecessary virtualReference ref
| const virtualReference = useRef<VirtualElement>({ | ||
| getBoundingClientRect: () => new DOMRect(), | ||
| }); | ||
|
|
There was a problem hiding this comment.
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
| const virtualReference = useRef<VirtualElement>({ | |
| getBoundingClientRect: () => new DOMRect(), | |
| }); |
There was a problem hiding this comment.
Right, thanks for catching that. removed.
| virtualReference.current = { | ||
| getBoundingClientRect: () => domRect, | ||
| }; | ||
| refs.setPositionReference(virtualReference.current); |
There was a problem hiding this comment.
| virtualReference.current = { | |
| getBoundingClientRect: () => domRect, | |
| }; | |
| refs.setPositionReference(virtualReference.current); | |
| refs.setPositionReference({ | |
| getBoundingClientRect: () => domRect, | |
| }); |
There was a problem hiding this comment.
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
15750a4 to
fdc3fca
Compare
|
Done — removed the ref and force-pushed. Thanks for the review and for helping debug the CI issue! |
Description
Fixes #8362
Refactors FloatingLinkEditorPlugin to use
@floating-ui/reactfor positioning, as suggested by @etrepum in #8387.What changed
setFloatingElemPositionForLinkEditor) withuseFloatinghook usingoffset,flip, andshiftmiddlewarescroll/resizeevent listeners (replaced byautoUpdate)getRangeAt(0).getBoundingClientRect()instead offocusNode.parentElement.getBoundingClientRect()for reference positioning — on Linux Firefox,focusNode.parentElementcan return a large element (like<p>) that fills the entire editor, causing the popup to overflow outside the editor boundsFloatingTextFormatToolbarwhen a link is selected to prevent overlap when the link editor flips above the textWhy
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/reactwhich 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
flipmiddleware 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