fix(desktop): hide selection formatting tray on composer right-click (#6683)

## Summary

Right-clicking a text selection in the message composer left the
selection formatting tray floating over the native context menu. This
suppresses the tray for the duration of the right-click interaction.

## Changes

- `SelectionFormattingTray.tsx`: a `contextmenu` listener on the editor
DOM sets a suppression ref, cancels any queued rAF reposition, and hides
the tray. Suppression clears on the next left-click `pointerdown` or
`keydown` in the editor, which reschedules a normal position update.
- `scheduleUpdate`/`updatePosition` both honor the suppression ref, so
editor `selectionUpdate`/`transaction`/`focus` events fired during the
right-click can't bring the tray back.
- Extracted `cancelScheduledUpdate` to replace the duplicated rAF-cancel
logic, and reset suppression on editor change / cleanup.
- E2E coverage in `composer-selection-formatting.spec.ts`: double-click
to select, assert the tray shows, right-click and assert the tray hides
*and* that `contextmenu` is not `defaultPrevented` (the native menu
still opens), then re-select and assert the tray returns.

## Testing

`just` pre-push gate ran green: `desktop-check`, `desktop-typecheck`,
`desktop-test` (5397 passing), `file-size-check`.

## Demo


https://github.com/user-attachments/assets/2fdfced9-6cd6-4eb2-a6df-c03164ab1c42

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-25 01:39:55 +10:00
committed by GitHub
parent 72ba987c36
commit 2f13e30e88
2 changed files with 132 additions and 9 deletions

View File

@@ -115,11 +115,24 @@ export function SelectionFormattingTray({
}: SelectionFormattingTrayProps) {
const [position, setPosition] = React.useState<TrayPosition | null>(null);
const rafRef = React.useRef<number | null>(null);
const suppressRightClickUpdatesRef = React.useRef(false);
const trayRef = React.useRef<HTMLDivElement | null>(null);
const [trayWidth, setTrayWidth] = React.useState(0);
const cancelScheduledUpdate = React.useCallback(() => {
if (rafRef.current === null) return;
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}, []);
const updatePosition = React.useCallback(() => {
if (!editor || disabled || !editor.isEditable || !editor.isFocused) {
if (
suppressRightClickUpdatesRef.current ||
!editor ||
disabled ||
!editor.isEditable ||
!editor.isFocused
) {
setPosition(null);
return;
}
@@ -127,44 +140,66 @@ export function SelectionFormattingTray({
}, [disabled, editor, trayWidth]);
const scheduleUpdate = React.useCallback(() => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
if (suppressRightClickUpdatesRef.current) {
cancelScheduledUpdate();
setPosition(null);
return;
}
cancelScheduledUpdate();
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updatePosition();
});
}, [updatePosition]);
}, [cancelScheduledUpdate, updatePosition]);
React.useEffect(() => {
suppressRightClickUpdatesRef.current = false;
if (!editor) {
cancelScheduledUpdate();
setPosition(null);
return;
}
const editorDom = editor.view.dom;
const hide = () => setPosition(null);
const handleContextMenu = () => {
suppressRightClickUpdatesRef.current = true;
cancelScheduledUpdate();
setPosition(null);
};
const clearSuppression = () => {
suppressRightClickUpdatesRef.current = false;
scheduleUpdate();
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0) clearSuppression();
};
scheduleUpdate();
editor.on("selectionUpdate", scheduleUpdate);
editor.on("transaction", scheduleUpdate);
editor.on("focus", scheduleUpdate);
editor.on("blur", hide);
editorDom.addEventListener("contextmenu", handleContextMenu);
editorDom.addEventListener("pointerdown", handlePointerDown);
editorDom.addEventListener("keydown", clearSuppression);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
cancelScheduledUpdate();
editor.off("selectionUpdate", scheduleUpdate);
editor.off("transaction", scheduleUpdate);
editor.off("focus", scheduleUpdate);
editor.off("blur", hide);
editorDom.removeEventListener("contextmenu", handleContextMenu);
editorDom.removeEventListener("pointerdown", handlePointerDown);
editorDom.removeEventListener("keydown", clearSuppression);
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
};
}, [editor, scheduleUpdate]);
}, [cancelScheduledUpdate, editor, scheduleUpdate]);
React.useLayoutEffect(() => {
if (!position || !trayRef.current) return;

View File

@@ -38,6 +38,41 @@ async function selectText(input: Locator, selectedText: string) {
}, selectedText);
}
async function doubleClickText(
page: Page,
input: Locator,
selectedText: string,
) {
const point = await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
const value = node.textContent ?? "";
const index = value.indexOf(text);
if (index < 0) continue;
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + text.length);
const rect = range.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
throw new Error(`Could not locate "${text}" for double-click selection`);
}, selectedText);
await page.mouse.dblclick(point.x, point.y);
await expect
.poll(() => page.evaluate(() => window.getSelection()?.toString()))
.toBe(selectedText);
return point;
}
async function selectTextRange(
input: Locator,
firstText: string,
@@ -621,6 +656,59 @@ test("block formatting preserves a backward native selection", async ({
.toBe(true);
});
test("right-clicking selected composer text hides the selection formatter", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
const rightClickPoint = await doubleClickText(page, input, "selected");
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
await input.evaluate((element) => {
(
window as Window & {
__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean;
}
).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__ = false;
element.addEventListener(
"contextmenu",
(event) => {
(
window as Window & {
__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean;
}
).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__ = event.defaultPrevented;
},
{ once: true },
);
});
await page.mouse.click(rightClickPoint.x, rightClickPoint.y, {
button: "right",
});
await expect(tray).toBeHidden();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean;
}
).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__,
),
)
.toBe(false);
await doubleClickText(page, input, "selected");
await expect(tray).toBeVisible();
});
test("Buzz theme uses the primary color for the selection formatter", async ({
page,
}) => {