mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user