Every context menu in the app opens from a `contextmenu` event, bound three different ways across six components -- delegated on a virtualizer, per row, per card. A phone has no right-click, so a phone reached none of them (plan 016 B2 phase 3). This is one document-capture listener installed once from `index.ts`, not six components' worth of touch handling: a touch that holds still for 500ms dispatches a synthetic `contextmenu` at the touch point, and every existing handler runs unchanged. A seam no component has to opt into is one no future component can forget. Four details are load-bearing, each a way the obvious version fails. The target is `composedPath()[0]`, not `elementFromPoint`, which stops at the outermost shadow host -- every menu here is bound inside one, so a host-targeted event reaches a delegated listener and no per-row one. A browser that fires its own long-press `contextmenu` (Chromium does; WebKit and the Android WebView vary) wins, and ours is told from theirs by identity rather than `isTrusted`: `isTrusted` works in the app and is untestable, which would leave the suppression path as the one thing with no coverage. And the click ending the gesture is swallowed, keyed on the gesture rather than a time window, or the first tap on the menu it just opened is eaten too. The e2e spec presses `.track-row`, not `[role="row"]`: the column header is a row too, and it is the first one -- a press on it is correctly ignored, which reads exactly like the gesture not working.
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
/**
|
|
* Long-press as the touch equivalent of a right-click (plan 016 B2,
|
|
* phase 3).
|
|
*
|
|
* Every context menu in the app opens from a `contextmenu` event —
|
|
* `track-list` and `queue-panel` delegate one on their virtualizer,
|
|
* the card grids and both playlist detail views bind one per row, and
|
|
* `explore-artist-details` binds three. A phone has no right-click, so
|
|
* a phone reached none of them.
|
|
*
|
|
* **This is one document listener, not six components' worth of touch
|
|
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
|
|
* synthetic `contextmenu` at the touch point on the element the touch
|
|
* actually landed on, and every existing handler — delegated or
|
|
* per-row, in any shadow root — runs unchanged. Six implementations of
|
|
* a gesture is exactly the fault `ContextMenuController` exists to
|
|
* prevent, and a seam that needs no component to opt in cannot be
|
|
* forgotten by the next component.
|
|
*
|
|
* Three things about it are load-bearing.
|
|
*
|
|
* **The target comes from `composedPath()[0]`, not from
|
|
* `elementFromPoint`**, which stops at the outermost shadow host: every
|
|
* menu in this app is bound inside one, so a synthetic event dispatched
|
|
* on the host reaches a delegated listener and no per-row one.
|
|
*
|
|
* **A browser that already does this must win.** Chromium fires a
|
|
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
|
|
* vary. So one arriving during the press cancels ours, and one arriving
|
|
* just after ours is swallowed at document capture — where nothing else
|
|
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
|
|
* of the events this module made) rather than by `isTrusted`, so the
|
|
* suppressor cannot eat the event it exists to deliver, the rule holds
|
|
* for anything else in the app that synthesises one, and a test can
|
|
* stand in for a browser that fires its own.
|
|
*
|
|
* **The click that ends the gesture is swallowed.** A row's click
|
|
* selects, and a card's plays; without this, opening a menu also
|
|
* activates the thing under it. It is keyed on the gesture (cleared by
|
|
* the next `pointerdown`) rather than on a time window, so a quick tap
|
|
* on the menu that just opened is not eaten too.
|
|
*/
|
|
|
|
/** How long a press must hold still to mean "menu". */
|
|
export const LONG_PRESS_MS = 500;
|
|
|
|
/**
|
|
* How far a press may drift and still count. Below a finger's own
|
|
* jitter is a gesture nobody can perform; above ~12px it starts
|
|
* stealing the first frames of a scroll.
|
|
*/
|
|
export const MOVE_TOLERANCE_PX = 10;
|
|
|
|
/** The active installation, so a second call is a no-op rather than a
|
|
* second listener set. */
|
|
let uninstall: (() => void) | null = null;
|
|
|
|
/** The events this module dispatched. Identity, not `isTrusted`: see
|
|
* the note above. */
|
|
const ours = new WeakSet<Event>();
|
|
|
|
/**
|
|
* Install the gesture. Idempotent; returns the uninstaller (which the
|
|
* tests use — the app installs once and never removes it).
|
|
*/
|
|
export function installLongPressContextMenu(): () => void {
|
|
if (uninstall) return uninstall;
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let originX = 0;
|
|
let originY = 0;
|
|
let target: EventTarget | null = null;
|
|
|
|
/** A trusted `contextmenu` arrived for this press: the browser has
|
|
* it covered. */
|
|
let nativeSeen = false;
|
|
|
|
/** We opened a menu, and the click ending that gesture is not a
|
|
* click on anything. */
|
|
let swallowClick = false;
|
|
|
|
/** We dispatched one, so a trusted one arriving now is a duplicate. */
|
|
let justFired = false;
|
|
|
|
const cancel = (): void => {
|
|
if (timer !== null) clearTimeout(timer);
|
|
|
|
timer = null;
|
|
target = null;
|
|
};
|
|
|
|
const fire = (): void => {
|
|
timer = null;
|
|
|
|
const el = target;
|
|
|
|
target = null;
|
|
|
|
if (nativeSeen || !el) return;
|
|
|
|
justFired = true;
|
|
swallowClick = true;
|
|
|
|
const menu = new MouseEvent('contextmenu', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
// Or it stops at the shadow root the row lives in, and the
|
|
// delegated listeners never see it.
|
|
composed: true,
|
|
clientX: originX,
|
|
clientY: originY,
|
|
button: 2,
|
|
});
|
|
|
|
ours.add(menu);
|
|
el.dispatchEvent(menu);
|
|
};
|
|
|
|
const onPointerDown = (e: PointerEvent): void => {
|
|
// A new gesture: whatever the last one left behind is stale.
|
|
swallowClick = false;
|
|
justFired = false;
|
|
nativeSeen = false;
|
|
cancel();
|
|
|
|
if (e.pointerType !== 'touch' || !e.isPrimary) return;
|
|
|
|
originX = e.clientX;
|
|
originY = e.clientY;
|
|
target = e.composedPath()[0] ?? e.target;
|
|
timer = setTimeout(fire, LONG_PRESS_MS);
|
|
};
|
|
|
|
const onPointerMove = (e: PointerEvent): void => {
|
|
if (timer === null) return;
|
|
|
|
const drifted =
|
|
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
|
|
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
|
|
|
|
if (drifted) cancel();
|
|
};
|
|
|
|
const onContextMenu = (e: Event): void => {
|
|
// Ours. Everything below is about somebody else's.
|
|
if (ours.has(e)) return;
|
|
|
|
if (timer !== null) {
|
|
// The browser got there first, so stand down rather than
|
|
// opening the same menu twice.
|
|
nativeSeen = true;
|
|
cancel();
|
|
|
|
return;
|
|
}
|
|
|
|
if (justFired) {
|
|
justFired = false;
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
};
|
|
|
|
const onClick = (e: Event): void => {
|
|
if (!swallowClick) return;
|
|
|
|
swallowClick = false;
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
};
|
|
|
|
// Capture throughout: a component handler that stops propagation
|
|
// (every context-menu handler in the app does) must not be able to
|
|
// hide the gesture from this, and the suppressors have to run
|
|
// before anything that would act on the event.
|
|
const opts = { capture: true } as const;
|
|
|
|
document.addEventListener('pointerdown', onPointerDown, opts);
|
|
document.addEventListener('pointermove', onPointerMove, opts);
|
|
document.addEventListener('pointerup', cancel, opts);
|
|
document.addEventListener('pointercancel', cancel, opts);
|
|
document.addEventListener('contextmenu', onContextMenu, opts);
|
|
document.addEventListener('click', onClick, opts);
|
|
// A scroll started by something other than the finger (momentum, a
|
|
// programmatic reveal) still means the press was not a press.
|
|
document.addEventListener('scroll', cancel, { capture: true, passive: true });
|
|
|
|
uninstall = () => {
|
|
cancel();
|
|
document.removeEventListener('pointerdown', onPointerDown, opts);
|
|
document.removeEventListener('pointermove', onPointerMove, opts);
|
|
document.removeEventListener('pointerup', cancel, opts);
|
|
document.removeEventListener('pointercancel', cancel, opts);
|
|
document.removeEventListener('contextmenu', onContextMenu, opts);
|
|
document.removeEventListener('click', onClick, opts);
|
|
document.removeEventListener('scroll', cancel, opts);
|
|
uninstall = null;
|
|
};
|
|
|
|
return uninstall;
|
|
}
|