From 611c900ec050b1afd0fcbc372ca60882a4d608f0 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 20 Apr 2026 19:04:27 +0200 Subject: [PATCH 1/3] fix: prevent FloatingFocusManager from resetting editor selection (#2525) When FloatingFocusManager is used inside a FloatingPortal, floating-ui inserts a hidden after the domReference via insertAdjacentElement. If the reference element is inside the ProseMirror contenteditable, this triggers PM's MutationObserver and resets the editor selection. Skip setting domReference when FloatingFocusManager is active and the reference is within the editor DOM. Positioning still works via the separate setPositionReference call. Co-Authored-By: Claude Opus 4.6 --- .../components/Popovers/GenericPopover.tsx | 15 +++++- tests/src/end-to-end/ai/ai-selection.test.ts | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/src/end-to-end/ai/ai-selection.test.ts diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 88defa0607..3c4021f301 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -155,7 +155,18 @@ export const GenericPopover = ( const element = "element" in props.reference ? props.reference.element : undefined; - if (element !== undefined) { + if ( + element !== undefined && + (props.focusManagerProps?.disabled !== false || + !editor.isWithinEditor(element)) + ) { + // Only set domReference when FloatingFocusManager is disabled. + // When FloatingFocusManager is active (disabled !== false) and the + // reference is inside the ProseMirror editor, setting domReference + // causes floating-ui to call insertAdjacentElement on the reference, + // inserting a focus-return into the PM contenteditable. This + // triggers PM's MutationObserver and resets the editor selection. + // (issue #2525) refs.setReference(element); } @@ -166,7 +177,7 @@ export const GenericPopover = ( contextElement: element, }); } - }, [props.reference, refs]); + }, [props.reference, refs, props.focusManagerProps?.disabled, editor]); // Stores the last rendered `innerHTML` of the popover while it was open. The // `innerHTML` is used while the popover is closing, as the React children diff --git a/tests/src/end-to-end/ai/ai-selection.test.ts b/tests/src/end-to-end/ai/ai-selection.test.ts new file mode 100644 index 0000000000..f2e8b6a804 --- /dev/null +++ b/tests/src/end-to-end/ai/ai-selection.test.ts @@ -0,0 +1,53 @@ +import { expect } from "@playwright/test"; +import { test } from "../../setup/setupScript.js"; +import { AI_URL } from "../../utils/const.js"; +import { focusOnEditor } from "../../utils/editor.js"; + +const AI_BUTTON_SELECTOR = `[data-test="editwithAI"]`; + +test.beforeEach(async ({ page }) => { + await page.goto(AI_URL); +}); + +test.describe("AI toolbar button should preserve selection (issue #2525)", () => { + test("Editor selection must be preserved after clicking the AI toolbar button", async ({ + page, + }) => { + await focusOnEditor(page); + + // Select text in the first paragraph + await page.keyboard.press("Home"); + await page.keyboard.press("Shift+End"); + await page.waitForTimeout(500); + + // Record the PM selection before clicking + const selBefore = await page.evaluate(() => { + const pm = (window as any).ProseMirror; + return { from: pm.state.selection.from, to: pm.state.selection.to }; + }); + expect(selBefore.to - selBefore.from).toBeGreaterThan(0); + + // Click the AI button using page.mouse to trigger real browser + // focus-shift behavior (Playwright's locator.click() bypasses it) + const aiButton = page.locator(AI_BUTTON_SELECTOR); + await expect(aiButton).toBeVisible(); + const box = (await aiButton.boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + // Wait for AI menu to appear + await page + .locator(".bn-combobox-input input, .bn-combobox input") + .waitFor({ state: "visible", timeout: 3000 }); + + // The PM selection must match what we had before clicking. + // Without the preventDefault fix on toolbar buttons, the browser + // moves focus to the portaled button on mousedown, which blurs the + // editor and clears the selection. + const selAfter = await page.evaluate(() => { + const pm = (window as any).ProseMirror; + return { from: pm.state.selection.from, to: pm.state.selection.to }; + }); + expect(selAfter.from).toBe(selBefore.from); + expect(selAfter.to).toBe(selBefore.to); + }); +}); From b086c13f236af8d8c2d47a4fccf96a72c5eaeaed Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 20 Apr 2026 19:24:24 +0200 Subject: [PATCH 2/3] fix: update focus manager condition to correctly handle disabled state in GenericPopover --- packages/react/src/components/Popovers/GenericPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 3c4021f301..5eb08edc4d 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -157,7 +157,7 @@ export const GenericPopover = ( if ( element !== undefined && - (props.focusManagerProps?.disabled !== false || + (props.focusManagerProps?.disabled || !editor.isWithinEditor(element)) ) { // Only set domReference when FloatingFocusManager is disabled. From a56afeea3de44e8738968e3a1c06c54cf6667315 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 20 Apr 2026 19:46:36 +0200 Subject: [PATCH 3/3] test: update comment explaining selection reset issue caused by floating-ui focus-return elements --- tests/src/end-to-end/ai/ai-selection.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/src/end-to-end/ai/ai-selection.test.ts b/tests/src/end-to-end/ai/ai-selection.test.ts index f2e8b6a804..b27dd8dbc4 100644 --- a/tests/src/end-to-end/ai/ai-selection.test.ts +++ b/tests/src/end-to-end/ai/ai-selection.test.ts @@ -40,9 +40,10 @@ test.describe("AI toolbar button should preserve selection (issue #2525)", () => .waitFor({ state: "visible", timeout: 3000 }); // The PM selection must match what we had before clicking. - // Without the preventDefault fix on toolbar buttons, the browser - // moves focus to the portaled button on mousedown, which blurs the - // editor and clears the selection. + // Without skipping refs.setReference for in-editor references while + // FloatingFocusManager is active, floating-ui inserts a focus-return + // element into the PM contenteditable, triggering its MutationObserver + // and resetting the selection. const selAfter = await page.evaluate(() => { const pm = (window as any).ProseMirror; return { from: pm.state.selection.from, to: pm.state.selection.to };