Skip to content

Commit 611c900

Browse files
YousefEDclaude
andcommitted
fix: prevent FloatingFocusManager from resetting editor selection (#2525)
When FloatingFocusManager is used inside a FloatingPortal, floating-ui inserts a hidden <span> 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 <noreply@anthropic.com>
1 parent 40ddec4 commit 611c900

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

packages/react/src/components/Popovers/GenericPopover.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,18 @@ export const GenericPopover = (
155155
const element =
156156
"element" in props.reference ? props.reference.element : undefined;
157157

158-
if (element !== undefined) {
158+
if (
159+
element !== undefined &&
160+
(props.focusManagerProps?.disabled !== false ||
161+
!editor.isWithinEditor(element))
162+
) {
163+
// Only set domReference when FloatingFocusManager is disabled.
164+
// When FloatingFocusManager is active (disabled !== false) and the
165+
// reference is inside the ProseMirror editor, setting domReference
166+
// causes floating-ui to call insertAdjacentElement on the reference,
167+
// inserting a focus-return <span> into the PM contenteditable. This
168+
// triggers PM's MutationObserver and resets the editor selection.
169+
// (issue #2525)
159170
refs.setReference(element);
160171
}
161172

@@ -166,7 +177,7 @@ export const GenericPopover = (
166177
contextElement: element,
167178
});
168179
}
169-
}, [props.reference, refs]);
180+
}, [props.reference, refs, props.focusManagerProps?.disabled, editor]);
170181

171182
// Stores the last rendered `innerHTML` of the popover while it was open. The
172183
// `innerHTML` is used while the popover is closing, as the React children
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect } from "@playwright/test";
2+
import { test } from "../../setup/setupScript.js";
3+
import { AI_URL } from "../../utils/const.js";
4+
import { focusOnEditor } from "../../utils/editor.js";
5+
6+
const AI_BUTTON_SELECTOR = `[data-test="editwithAI"]`;
7+
8+
test.beforeEach(async ({ page }) => {
9+
await page.goto(AI_URL);
10+
});
11+
12+
test.describe("AI toolbar button should preserve selection (issue #2525)", () => {
13+
test("Editor selection must be preserved after clicking the AI toolbar button", async ({
14+
page,
15+
}) => {
16+
await focusOnEditor(page);
17+
18+
// Select text in the first paragraph
19+
await page.keyboard.press("Home");
20+
await page.keyboard.press("Shift+End");
21+
await page.waitForTimeout(500);
22+
23+
// Record the PM selection before clicking
24+
const selBefore = await page.evaluate(() => {
25+
const pm = (window as any).ProseMirror;
26+
return { from: pm.state.selection.from, to: pm.state.selection.to };
27+
});
28+
expect(selBefore.to - selBefore.from).toBeGreaterThan(0);
29+
30+
// Click the AI button using page.mouse to trigger real browser
31+
// focus-shift behavior (Playwright's locator.click() bypasses it)
32+
const aiButton = page.locator(AI_BUTTON_SELECTOR);
33+
await expect(aiButton).toBeVisible();
34+
const box = (await aiButton.boundingBox())!;
35+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
36+
37+
// Wait for AI menu to appear
38+
await page
39+
.locator(".bn-combobox-input input, .bn-combobox input")
40+
.waitFor({ state: "visible", timeout: 3000 });
41+
42+
// The PM selection must match what we had before clicking.
43+
// Without the preventDefault fix on toolbar buttons, the browser
44+
// moves focus to the portaled button on mousedown, which blurs the
45+
// editor and clears the selection.
46+
const selAfter = await page.evaluate(() => {
47+
const pm = (window as any).ProseMirror;
48+
return { from: pm.state.selection.from, to: pm.state.selection.to };
49+
});
50+
expect(selAfter.from).toBe(selBefore.from);
51+
expect(selAfter.to).toBe(selBefore.to);
52+
});
53+
});

0 commit comments

Comments
 (0)