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.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 { /** 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; /** 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; /** 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 ``; not part of the call site's vocabulary. */ popover: ReturnType; } const identity = (payload: unknown): unknown => payload; export function useContextMenu({ 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 { // Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it. useKeyScope("contextMenu"); const [target, setTarget] = useState(null); const [anchor, setAnchor] = useState(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(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) => { // 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) => { // 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) => { 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) => { if (!swallowClick.current) return; swallowClick.current = false; e.preventDefault(); e.stopPropagation(); }, onKeyDown: (e: React.KeyboardEvent) => { // 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) => { 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) => { // 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( items: readonly T[], triggerProps: (payload: T) => Record, ): (() => Record)[] { return useMemo(() => items.map((it) => () => triggerProps(it)), [items, triggerProps]); }