fix(design): a right-click on selected words opens the browser's own menu (E28 S3e4b)
The row menu took every right-click, so selected text could only be copied with Ctrl+C. On the selection (its laid-out line boxes) the event is left to the browser; beside it the object's menu opens as before.
This commit is contained in:
@@ -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 }]);
|
||||
|
||||
@@ -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<T>({
|
||||
// menu instead of Safari's own link/image callout on top of it.
|
||||
"data-context-menu-target": "",
|
||||
onContextMenu: (e: React.MouseEvent<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user