A right-click on the EMPTY area of a page opens a menu of that page's own toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear filters), the Plex library grid (sort, watch state, clear), the channel manager (layout, the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions), a playlist (play all, group by, reset view, save order, undo/redo, the list's own menu) and the notification center (mark all read). No destructive row and no backfill. PageShell listens on the page area; lib/backgroundTarget decides empty (not text, a control, a link, media, or a row/card with its own menu). Shift+right- click, a touch long-press and a right-click on selected words keep the browser's menu. ui/PageMenu registers while mounted, so a loading or error state keeps the browser's menu too. The rows mirror each control through ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu). pageMenuScope.contract.test.ts pins the five pages.
278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
import {
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type PointerEvent as ReactPointerEvent,
|
|
} 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
|
|
// belong to. The surface itself is `<ContextMenu>` (ContextMenu.tsx); they are two files only so
|
|
// that the component file exports a component and nothing else (react-refresh).
|
|
//
|
|
// This is an INPUT PATH onto the menu the app already has rather
|
|
// than a second menu component. `ActionMenu` (the ⋯ overflow menu) and this one render the same
|
|
// `ActionMenuItem[]` through the same `MenuItemList`, on the same `.glass-menu` surface, so a row
|
|
// 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) —
|
|
// 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).
|
|
//
|
|
// ONE menu per list, not one per row. The popover is anchored to a VIRTUAL element at the pointer
|
|
// (native-menu behaviour: it opens where you clicked, not at the row's corner), so a table of 200
|
|
// rows mounts a single closed popover and `target` says which row it belongs to. Mounting one per
|
|
// row would build ~200 floating contexts to keep 199 of them shut.
|
|
|
|
/** Long-press timing. 500 ms is the platform convention on both Android and iOS; below ~400 ms it
|
|
* fires while the user is still deciding whether to scroll. */
|
|
const LONG_PRESS_MS = 500;
|
|
/** Movement (px) that reclassifies a press as a scroll and cancels the pending menu. */
|
|
const LONG_PRESS_SLOP = 10;
|
|
|
|
type Rect = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
};
|
|
const pointRect = (x: number, y: number): Rect => ({
|
|
x,
|
|
y,
|
|
width: 0,
|
|
height: 0,
|
|
top: y,
|
|
right: x,
|
|
bottom: y,
|
|
left: x,
|
|
});
|
|
|
|
export interface ContextMenuController<T> {
|
|
/** The row the open menu belongs to; `null` while closed. Build the items from it. */
|
|
target: T | null;
|
|
open: boolean;
|
|
close: () => void;
|
|
/** The element the open menu came from — the row, or its "…" button. A follow-up an item opens
|
|
* (a panel anchored beside the object) anchors here. `null` while closed. */
|
|
anchor: Element | null;
|
|
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
|
|
triggerProps: (payload: T) => Record<string, unknown>;
|
|
/** Spread on the row's visible "…" button (E28 C1: nothing is reachable only by a gesture). A
|
|
* click opens the SAME menu under the button; a second click on it while its menu is open closes
|
|
* it, like any menu button. Pair it with `aria-expanded` from the caller, which knows its row. */
|
|
buttonProps: (payload: T) => Record<string, unknown>;
|
|
/** Opens the menu at a viewport point for `payload`, anchored to `contextElement` — for an input
|
|
* path the two props above do not cover (the page's background menu, `ui/PageMenu`). */
|
|
openAt: (x: number, y: number, payload: T, contextElement?: Element) => void;
|
|
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
|
|
popover: ReturnType<typeof useAnchoredPopover>;
|
|
}
|
|
|
|
const identity = (payload: unknown): unknown => payload;
|
|
|
|
export function useContextMenu<T>({
|
|
keyOf = identity,
|
|
}: {
|
|
/** What makes two payloads the SAME row, for the "…" closing its own menu. Identity by default;
|
|
* a list whose rows are rebuilt each render (a wrapper object) or refetched while the menu is
|
|
* open (a poll replaces the job it lists) passes its id, or a second press would reopen. A
|
|
* module-scope function: a fresh one per render would rebuild `buttonProps` on every render. */
|
|
keyOf?: (payload: T) => unknown;
|
|
} = {}): ContextMenuController<T> {
|
|
// Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it.
|
|
useKeyScope("contextMenu");
|
|
const [target, setTarget] = useState<T | null>(null);
|
|
const [anchor, setAnchor] = useState<Element | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
// What is open right now, for the "…" button's toggle. Read in a handler only, so a ref: state
|
|
// would change `buttonProps` on every open and re-render every memoized row that holds it.
|
|
const openFor = useRef<{ payload: T } | null>(null);
|
|
// The press that dismissed the menu, and whose menu it was: floating-ui's outside-press runs in
|
|
// the CAPTURE phase, so a press on the open row's own "…" closes the menu before the button's
|
|
// handler sees it (see `buttonProps`).
|
|
const dismissedBy = useRef<{ event: Event; payload: T } | null>(null);
|
|
const pop = useAnchoredPopover({
|
|
open,
|
|
onOpenChange: (next, event) => {
|
|
setOpen(next);
|
|
// Drop the target on close so a stale row can't drive a re-open's first paint.
|
|
if (!next) {
|
|
dismissedBy.current =
|
|
event && openFor.current ? { event, payload: openFor.current.payload } : null;
|
|
setTarget(null);
|
|
setAnchor(null);
|
|
openFor.current = null;
|
|
}
|
|
},
|
|
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
|
|
// way near an edge.
|
|
placement: "bottom-start",
|
|
offset: 0,
|
|
fitHeight: true,
|
|
// The anchor is a fixed POINT, not an element, so scrolling cannot move the panel with its row —
|
|
// it would sit over a different row than the one its actions target. Native menus close; so do we.
|
|
dismissOnScroll: true,
|
|
});
|
|
|
|
const { setPositionReference } = pop.refs;
|
|
const openAt = useCallback(
|
|
(x: number, y: number, payload: T, contextElement?: Element) => {
|
|
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
|
|
setTarget(payload);
|
|
setAnchor(contextElement ?? null);
|
|
openFor.current = { payload };
|
|
setOpen(true);
|
|
},
|
|
[setPositionReference],
|
|
);
|
|
|
|
// Long-press bookkeeping. Refs, not state: none of it paints, and a re-render per pointermove
|
|
// during a scroll would be a real cost on the device this path exists for.
|
|
const timer = useRef<number | null>(null);
|
|
const startedAt = useRef<{ x: number; y: number } | null>(null);
|
|
// A long-press is followed by a click on release. Without this the menu would open and the row's
|
|
// own default action (play / select) would fire underneath it.
|
|
//
|
|
// It is armed by the timer and disarmed by the click it was armed FOR — but plenty of long presses
|
|
// never produce that click (a pan ends in pointercancel, the finger lifts over the portalled menu,
|
|
// Android Chrome suppresses the trailing click after its own contextmenu). The flag then stayed
|
|
// armed, and since it is one ref per LIST, the next genuine tap on ANY row of that list was eaten
|
|
// and the row looked dead until tapped twice (E10 review round 1). So a fresh press also disarms
|
|
// it: whatever the last gesture left behind cannot outlive the start of the next one.
|
|
const swallowClick = useRef(false);
|
|
const cancelPress = useCallback(() => {
|
|
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
timer.current = null;
|
|
startedAt.current = null;
|
|
}, []);
|
|
|
|
const triggerProps = useCallback(
|
|
(payload: T) => ({
|
|
// Carries the `-webkit-touch-callout: none` rule (index.css) so an iOS long-press opens THIS
|
|
// menu instead of Safari's own link/image callout on top of it.
|
|
"data-context-menu-target": "",
|
|
onContextMenu: (e: React.MouseEvent<HTMLElement>) => {
|
|
// 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>) => {
|
|
// Before the pointerType guard: a stale arm left by an earlier touch press must not survive
|
|
// into an unrelated interaction, mouse click included.
|
|
swallowClick.current = false;
|
|
// Mouse has a right button; only touch/pen need the timer.
|
|
if (e.pointerType === "mouse") return;
|
|
const { clientX: x, clientY: y } = e;
|
|
const el = e.currentTarget; // read now: currentTarget is null by the time the timer fires
|
|
startedAt.current = { x, y };
|
|
timer.current = window.setTimeout(() => {
|
|
timer.current = null;
|
|
swallowClick.current = true;
|
|
openAt(x, y, payload, el);
|
|
}, LONG_PRESS_MS);
|
|
},
|
|
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {
|
|
const from = startedAt.current;
|
|
if (!from || timer.current === null) return;
|
|
if (
|
|
Math.abs(e.clientX - from.x) > LONG_PRESS_SLOP ||
|
|
Math.abs(e.clientY - from.y) > LONG_PRESS_SLOP
|
|
)
|
|
cancelPress(); // they are scrolling, not pressing
|
|
},
|
|
onPointerUp: cancelPress,
|
|
onPointerCancel: () => {
|
|
// The gesture was taken over (a pan, the browser's own menu): no click is coming, so the
|
|
// arm has nothing left to swallow and must not wait around for someone else's.
|
|
swallowClick.current = false;
|
|
cancelPress();
|
|
},
|
|
onClickCapture: (e: React.MouseEvent<HTMLElement>) => {
|
|
if (!swallowClick.current) return;
|
|
swallowClick.current = false;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
|
|
// The keyboard route every desktop menu has. Anchored to the row's bottom-left rather than a
|
|
// pointer that isn't there.
|
|
// Through the shared parser, against the table's own row: the chords live in ONE place, and
|
|
// `matches` is exact on modifiers (the hand-rolled version answered Ctrl+Shift+F10 too).
|
|
if (!matchesShortcut(e.nativeEvent, "contextMenu", "contextMenu")) return;
|
|
e.preventDefault();
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
openAt(r.left, r.bottom, payload, e.currentTarget);
|
|
},
|
|
}),
|
|
[openAt, cancelPress],
|
|
);
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false);
|
|
setTarget(null);
|
|
setAnchor(null);
|
|
openFor.current = null;
|
|
}, []);
|
|
|
|
// Set on the "…" button's pointerdown when ITS menu was the open one. The press lands outside the
|
|
// panel, so floating-ui's dismiss may already have closed it; either way the click that follows
|
|
// closes rather than opens, or the button could never close its own menu.
|
|
const pressedWhileOpen = useRef(false);
|
|
const buttonProps = useCallback(
|
|
(payload: T) => ({
|
|
"aria-haspopup": "menu",
|
|
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
|
|
const dismissed = dismissedBy.current;
|
|
pressedWhileOpen.current =
|
|
(openFor.current !== null && keyOf(openFor.current.payload) === keyOf(payload)) ||
|
|
(dismissed !== null &&
|
|
dismissed.event === e.nativeEvent &&
|
|
keyOf(dismissed.payload) === keyOf(payload));
|
|
},
|
|
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
|
// The button sits on a card that plays on click, and inside a row that may be a link.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Only a pointer click (detail > 0) reads the press: a keyboard click has no pointerdown, so
|
|
// a flag left by a press that never became a click must not turn Enter into a close.
|
|
const toggleOff = e.detail > 0 && pressedWhileOpen.current;
|
|
pressedWhileOpen.current = false;
|
|
if (toggleOff) {
|
|
close();
|
|
return;
|
|
}
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
openAt(r.left, r.bottom, payload, e.currentTarget);
|
|
},
|
|
}),
|
|
[openAt, close, keyOf],
|
|
);
|
|
|
|
return { target, anchor, open, close, triggerProps, buttonProps, openAt, popover: pop };
|
|
}
|
|
|
|
/** One trigger per item, stable while the items and the menu are — for a list whose card component
|
|
* takes `menuProps: () => props` but whose menu acts on a WRAPPER of what the card holds (Home's
|
|
* unified shelf card around a `Video`). A fresh `() => menu.triggerProps(c)` per render defeats a
|
|
* `memo` card on every tile each time the menu's own state changes. Index-aligned with `items`. */
|
|
export function useItemTriggers<T>(
|
|
items: readonly T[],
|
|
triggerProps: (payload: T) => Record<string, unknown>,
|
|
): (() => Record<string, unknown>)[] {
|
|
return useMemo(() => items.map((it) => () => triggerProps(it)), [items, triggerProps]);
|
|
}
|