- 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.
261 lines
12 KiB
TypeScript
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]);
|
|
}
|