diff --git a/frontend/src/components/ui/ContextMenu.dom.test.tsx b/frontend/src/components/ui/ContextMenu.dom.test.tsx index c611c792..6656c0ce 100644 --- a/frontend/src/components/ui/ContextMenu.dom.test.tsx +++ b/frontend/src/components/ui/ContextMenu.dom.test.tsx @@ -8,7 +8,7 @@ // What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu: // the left button keeps the row's own action, and the gesture that opens it doesn't also fire that // action underneath. -import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeAll, onTestFinished } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { type ActionMenuItem } from "./ActionMenu"; @@ -108,6 +108,50 @@ describe("useContextMenu / ContextMenu", () => { expect(menuitems()).toEqual([]); }); + describe("on selected words the browser's own menu opens (DESIGN: Text stays selectable)", () => { + const selectText = (el: Element) => { + const range = document.createRange(); + range.selectNodeContents(el); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + }; + /** Right-click `el` at (x, y); true when the app left the event to the browser (not prevented). */ + const rightClick = (el: Element, x = 10, y = 10) => + fireEvent.contextMenu(el, { clientX: x, clientY: y }); + afterEach(() => { + window.getSelection()!.removeAllRanges(); + }); + + it("lets the browser have a right-click on the selection, and keeps the app menu elsewhere", () => { + renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); + selectText(row("beta")); + expect(rightClick(row("beta"))).toBe(true); // not prevented: the native menu opens + expect(menuitems()).toEqual([]); + expect(rightClick(row("alpha"))).toBe(false); + expect(menuitems()).toEqual(["Play alpha"]); + }); + + it("where ranges are laid out, only a point ON the selected words counts", () => { + renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); + // A real engine: the selected words' line box. + // jsdom lays nothing out and has no Range.getClientRects at all; lend it one for this test. + const box = { left: 0, top: 0, right: 50, bottom: 20 } as DOMRect; + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [box], + }); + onTestFinished(() => { + delete (Range.prototype as { getClientRects?: unknown }).getClientRects; + }); + selectText(row("beta")); + expect(rightClick(row("beta"), 10, 10)).toBe(true); + expect(menuitems()).toEqual([]); + // The same row, beside its selected words (its picture, its padding): the object's menu. + expect(rightClick(row("beta"), 200, 10)).toBe(false); + expect(menuitems()).toEqual(["Play beta"]); + }); + }); + it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => { const onSelect = vi.fn(); renderList(() => [{ id: "a", label: "Remove", onSelect }]); diff --git a/frontend/src/components/ui/useContextMenu.ts b/frontend/src/components/ui/useContextMenu.ts index 44097df3..8946dc7c 100644 --- a/frontend/src/components/ui/useContextMenu.ts +++ b/frontend/src/components/ui/useContextMenu.ts @@ -7,6 +7,7 @@ import { } from "react"; import { matchesShortcut, useKeyScope } from "../../lib/keymap"; +import { pointOnSelection } from "../../lib/textSelection"; import { useAnchoredPopover } from "./anchoredPopoverHooks"; // E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they @@ -19,7 +20,8 @@ import { useAnchoredPopover } from "./anchoredPopoverHooks"; // cannot grow a right-click menu that looks or behaves unlike its own ⋯ menu. // // Three ways in, because a menu that only opens one way does not exist on the device that lacks it: -// - right-click anywhere on the row (the desktop browser, and the reason this sprint exists), +// - right-click anywhere on the row (the desktop browser, and the reason this sprint exists) — +// except on selected words, where the browser's own menu opens so they can be copied, // - long-press (touch/pen only) — the tablet has no right button, // - Shift+F10 / the Menu key, which is free once the row is in the tab order. // The left button is untouched: it keeps the row's own default action (select / play). @@ -158,9 +160,11 @@ export function useContextMenu({ // menu instead of Safari's own link/image callout on top of it. "data-context-menu-target": "", onContextMenu: (e: React.MouseEvent) => { - e.preventDefault(); // A row inside another context-menu target (a table inside a panel) claims the event. e.stopPropagation(); + // On selected words the browser's own menu opens (Copy, Search): DESIGN "Text stays selectable". + if (pointOnSelection(e.target, e.clientX, e.clientY)) return; + e.preventDefault(); openAt(e.clientX, e.clientY, payload, e.currentTarget); }, onPointerDown: (e: ReactPointerEvent) => { diff --git a/frontend/src/lib/textSelection.ts b/frontend/src/lib/textSelection.ts index 432abdfb..3189797b 100644 --- a/frontend/src/lib/textSelection.ts +++ b/frontend/src/lib/textSelection.ts @@ -29,3 +29,24 @@ export function movedPastSlop( if (!from || e.detail === 0) return false; return Math.abs(e.clientX - from.x) > CLICK_SLOP || Math.abs(e.clientY - from.y) > CLICK_SLOP; } + +// The copy half: a right-click on selected words is a request for the browser's own menu (Copy, +// Search, Translate), not the object's. An app menu there would leave Ctrl+C as the only way to copy +// what the reader just selected. Only ON the selection: a right-click beside it (the row's picture, +// its padding) still means the object. + +/** True when the page's selection is non-empty and the point (x, y) over `target` lies on it. Where + * the engine lays out ranges (a browser), "on it" is inside one of the selection's line boxes; where + * it does not (jsdom), it is `target` touching the selection. */ +export function pointOnSelection(target: EventTarget | null, x: number, y: number): boolean { + const sel = typeof window === "undefined" ? null : window.getSelection(); + if (!sel || sel.isCollapsed || !sel.toString().trim() || !(target instanceof Node)) return false; + for (let i = 0; i < sel.rangeCount; i++) { + const range = sel.getRangeAt(i); + if (!range.intersectsNode(target)) continue; + if (typeof range.getClientRects !== "function") return true; + for (const r of range.getClientRects()) + if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true; + } + return false; +}