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, }) => {