Files
siftlode/frontend/src/components/ui/useContextMenu.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

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";
// 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 (DESIGN.md 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. 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]);
}