From 2f13e30e88e84851e7ad336364dd3cfd547b8c16 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 25 Aug 2026 01:39:55 +1000 Subject: [PATCH] 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 --- .../messages/ui/SelectionFormattingTray.tsx | 53 +++++++++-- .../e2e/composer-selection-formatting.spec.ts | 88 +++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 9eb24a970c..84934d1826 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -115,11 +115,24 @@ export function SelectionFormattingTray({ }: SelectionFormattingTrayProps) { const [position, setPosition] = React.useState(null); const rafRef = React.useRef(null); + const suppressRightClickUpdatesRef = React.useRef(false); const trayRef = React.useRef(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; diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 202ec96869..25d3cbaf9a 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -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, }) => {