Files
siftlode/frontend/src/components/ui/useContextMenu.ts
T
peter 75ae810866 feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
2026-09-26 01:23:44 +02:00

261 lines
12 KiB
TypeScript

import {
useCallback,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { matchesShortcut, useKeyScope } from "../../lib/keymap";
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),
// - 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>;
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
popover: ReturnType<typeof useAnchoredPopover>;
}
export function useContextMenu<T>(): 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>) => {
e.preventDefault();
// A row inside another context-menu target (a table inside a panel) claims the event.
e.stopPropagation();
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 && openFor.current.payload === payload) ||
(dismissed !== null &&
dismissed.event === e.nativeEvent &&
dismissed.payload === 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],
);
return { target, anchor, open, close, triggerProps, buttonProps, 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]);
}