feat(android): tap to play, hold to select
Phase 1 of #63, and the design the issue asks for as one piece is .planning/plans/active/019-android-touch-model.md. **A finger has no second button and no modifier keys**, so the primary action has to be the primary gesture: tap plays the row, and the hold that opened a context menu now enters selection mode with that row selected. Three decisions in it, and two diverge from the report. **The predicate is the pointer, not the platform or the viewport.** `pointerType === 'touch'`, per event, which is already how long-press.ts decided and is the only such test in the frontend. This is #64's rule -- named after the capability -- and it carries #64's warning: keyed on a width, an Android *tablet* at 600px gets click-selects/double-click-plays on a touchscreen, which is the inversion this issue exists to fix, on the platform it exists for. A touchscreen laptop cannot be described by a width at all. Per event, a mouse keeps desktop semantics on the very same row, and there is no second declaration of what a phone does. **There is no double-tap, and the number is why.** The report asks for single tap to play *and* double tap for the menu. Those cannot both be honoured: the first tap of a double tap is indistinguishable from a single tap until the interval expires, so "tap plays" becomes "tap waits". Measured on the device, the play command to TrackChanged is 155/123/85/56/91 ms -- median ~100 -- and the app's own DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction, 250ms of it spent deliberately doing nothing, on every track anyone plays, to reach a menu the hold already reaches. So the menu and the selection action bar are the same surface, which is also the platform's convention and removes a concept rather than adding one. **Tap-to-play and selection mode ship together**, because splitting them is a regression dressed as an increment: a touch user selects by tapping today and acts through the long-press menu, so moving tap to play on its own would leave a window with no way to select forty tracks at all. **What lets this reassign the hold without touching one of the fourteen context menus**: the layer announces `yj-tap` / `yj-long-press` (composed, cancelable) and acts on nothing. A component claims one with preventDefault. An **unclaimed long press still becomes a `contextmenu`**, so the card grids, Explore, the playlist rows and every other menu behave exactly as they did, and only lists that opt in get selection mode. An unclaimed *tap* does nothing at all and the click follows normally, which is what leaves every button in the app alone -- only a claimed tap has its click swallowed, or playing a track would also select it. **And the device found the one thing no browser tier can see.** Chrome 113's Android WebView fires its own `contextmenu` on a long press. long-press.ts stood down when a trusted one arrived, which was right while both paths ended in a context menu; they no longer do, so standing down means the gesture silently does the *old* thing. Measured, before the fix, holding a track row: {"log":["contextmenu isTrusted=true"], "state":{"bar":null,"menuActive":true,"selected":1}} `yj-long-press` was never announced, the menu opened, and all 26 tests passed -- dispatched pointer events do not make a browser synthesise one. So the native event is a **trigger, not a competitor**: the gesture is announced from it and only a claim suppresses it. Unclaimed it propagates untouched, which is the same "browser wins" outcome reached by asking instead of assuming. The tier could not find that and can hold it, because this module has always told its own events apart by identity rather than isTrusted, so an untrusted one from a test takes exactly the browser's path. Verified on the device by *performing* the gestures rather than describing the page -- `adb shell input tap` and `input swipe x y x y 700` reach the WebView as real pointer events, which is new here and is written down in the plan with the pixel mapping. Tap plays; a hold raises the bar with one selected and no menu; a tap toggles to two, back to one, and the mode ends with the last row; an album card still opens its context menu. 29 new tests. The e2e spec is rewritten to assert **both** halves -- the row selects, and a card elsewhere still opens the real menu -- because a spec that only checked the row would pass on a build that had silently broken the other thirteen. Phases 2-4 (swipe to queue, the other three surfaces, and what #67 inherits) are in the plan and not in this commit.
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController {
|
||||
private host: SelectionHost;
|
||||
private _selectedItems: Set<string> = new Set();
|
||||
private lastSelectedIndex: number | null = null;
|
||||
private _mode = false;
|
||||
|
||||
/**
|
||||
* Whether the list is in *selection mode* (plan 019, #63).
|
||||
*
|
||||
* A finger has no modifier keys, so the ctrl/shift semantics this
|
||||
* controller was written for cannot be expressed by touch at all.
|
||||
* Selection mode is the platform's answer: a long press enters it,
|
||||
* and while it is on, a tap toggles a row instead of playing it.
|
||||
*
|
||||
* It is a flag *here* rather than a fifth concept beside the
|
||||
* controller because all four surfaces that select
|
||||
* (`track-list`, `queue-panel` and both playlist detail views)
|
||||
* already share this class -- so "is this list selecting" has one
|
||||
* answer per list, in the object that already owns the selection
|
||||
* it would otherwise contradict.
|
||||
*
|
||||
* A mouse never sets it. Desktop selection is unchanged and stays
|
||||
* modeless, which is what `handleItemClick` still implements.
|
||||
*/
|
||||
get selectionMode(): boolean {
|
||||
return this._mode;
|
||||
}
|
||||
|
||||
constructor(host: SelectionHost) {
|
||||
this.host = host;
|
||||
@@ -136,8 +159,64 @@ export class SelectionController implements ReactiveController {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter selection mode with `key` selected.
|
||||
*
|
||||
* The row the gesture was made on is selected, rather than the
|
||||
* mode opening empty: a long press is a statement about *that*
|
||||
* row, and an action bar with nothing in it is a mode the user has
|
||||
* to make a second gesture to escape.
|
||||
*/
|
||||
enterSelectionMode(key: string, index: number): void {
|
||||
this._mode = true;
|
||||
this._selectedItems = new Set([key]);
|
||||
this.lastSelectedIndex = index;
|
||||
this.host.requestUpdate();
|
||||
this.host.onSelectionChanged?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one row, and leave the mode when the last one goes.
|
||||
*
|
||||
* Deselecting everything is how Android's own list surfaces exit
|
||||
* selection mode, and it matters more here than convention: the
|
||||
* mode changes what a tap *means*, so a mode with an empty
|
||||
* selection is a list where tapping does nothing and nothing on
|
||||
* screen says why.
|
||||
*/
|
||||
toggleInMode(key: string, index: number): void {
|
||||
const next = new Set(this._selectedItems);
|
||||
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
|
||||
this._selectedItems = next;
|
||||
this.lastSelectedIndex = index;
|
||||
|
||||
if (next.size === 0) this._mode = false;
|
||||
|
||||
this.host.requestUpdate();
|
||||
this.host.onSelectionChanged?.();
|
||||
}
|
||||
|
||||
/** Leave selection mode, dropping the selection with it. */
|
||||
exitSelectionMode(): void {
|
||||
if (!this._mode && this._selectedItems.size === 0) return;
|
||||
|
||||
this._mode = false;
|
||||
this._selectedItems = new Set();
|
||||
this.lastSelectedIndex = null;
|
||||
this.host.requestUpdate();
|
||||
this.host.onSelectionChanged?.();
|
||||
}
|
||||
|
||||
/** Clear the entire selection. */
|
||||
clear(): void {
|
||||
// The mode goes with it: every caller of this means "the
|
||||
// selection is no longer meaningful", and a mode outliving the
|
||||
// selection it was showing is the empty-mode trap above.
|
||||
this._mode = false;
|
||||
|
||||
if (this._selectedItems.size === 0) return;
|
||||
|
||||
this._selectedItems = new Set();
|
||||
@@ -176,6 +255,13 @@ export class SelectionController implements ReactiveController {
|
||||
if (next.size === this._selectedItems.size) return;
|
||||
|
||||
this._selectedItems = next;
|
||||
|
||||
// A refetch that emptied the selection also ends the mode --
|
||||
// otherwise removing the last selected track from the library
|
||||
// leaves the list in a state where a tap selects and the bar
|
||||
// is gone.
|
||||
if (next.size === 0) this._mode = false;
|
||||
|
||||
this.host.requestUpdate();
|
||||
this.host.onSelectionChanged?.();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* The touch gestures, as one document listener (plan 019, #63).
|
||||
*
|
||||
* This replaces `utils/long-press.ts` rather than sitting beside it,
|
||||
* and that is the point: two document listeners both claiming the
|
||||
* 500ms hold is exactly the fault that file's own header warns about.
|
||||
* What it did — one capture listener, the target from
|
||||
* `composedPath()[0]`, the browser's own gesture winning, the trailing
|
||||
* click swallowed — is kept whole. What changes is what the gesture
|
||||
* *means*.
|
||||
*
|
||||
* **A gesture is announced, not acted on.** Two composed, cancelable
|
||||
* events are dispatched on the element the finger actually landed on:
|
||||
*
|
||||
* `yj-tap` a short press that did not drift
|
||||
* `yj-long-press` a press that held still for LONG_PRESS_MS
|
||||
*
|
||||
* A component that wants the gesture handles it and calls
|
||||
* `preventDefault()`. Nothing else changes. That shape is what lets
|
||||
* this reassign the hold without touching a single one of the fourteen
|
||||
* context menus downstream of it: **an unclaimed `yj-long-press` still
|
||||
* becomes a synthetic `contextmenu`**, so a card grid, an Explore
|
||||
* result or a playlist row behaves exactly as it did, and only the
|
||||
* lists that opt in get selection mode.
|
||||
*
|
||||
* The same rule keeps taps honest. An unclaimed `yj-tap` does nothing
|
||||
* at all and the browser's click follows normally, so every button,
|
||||
* link and checkbox in the app is untouched by this file. Only a
|
||||
* claimed tap has its click swallowed — otherwise playing a track
|
||||
* would also select it.
|
||||
*
|
||||
* Five things are load-bearing.
|
||||
*
|
||||
* **The predicate is the pointer, not the platform** (plan 019,
|
||||
* decision 1). `pointerType === 'touch'`, per event — so an Android
|
||||
* tablet over 600px, a touchscreen laptop with a mouse also plugged
|
||||
* in, and a narrow desktop window are all right for free, and there is
|
||||
* no second declaration of what a phone does. Keyed on a viewport
|
||||
* width, the first of those three gets desktop semantics on a
|
||||
* touchscreen, which is the inversion #63 exists to fix, on the
|
||||
* platform it exists for.
|
||||
*
|
||||
* **There is no double-tap**, and it is not an omission — see plan
|
||||
* 019, decision 2. Measured on the reference device, the play command
|
||||
* to `TrackChanged` is ~100ms; a double-tap discriminator has to hold
|
||||
* every tap for the app's own `DOUBLE_CLICK_GRACE_MS` of 250 before it
|
||||
* can act, which is 3.5x the primary interaction in the app to reach a
|
||||
* menu that long-press already reaches.
|
||||
*
|
||||
* **The target comes from `composedPath()[0]`**, not
|
||||
* `elementFromPoint`, which stops at the outermost shadow host: every
|
||||
* list in this app delegates inside one, so an event dispatched on the
|
||||
* host reaches a delegated listener and no per-row one.
|
||||
*
|
||||
* **A browser that fires its own `contextmenu` is a trigger, not a
|
||||
* competitor**, and that is a change from `long-press.ts` rather than
|
||||
* an inherited rule. It used to stand down when a trusted
|
||||
* `contextmenu` arrived, because both paths ended in the same place: a
|
||||
* context menu. They no longer do — ours may end in selection mode —
|
||||
* so standing down means the gesture silently does the *old* thing.
|
||||
*
|
||||
* Measured on the reference device, which is the only tier that can
|
||||
* see this: Chrome 113's WebView fires its own `contextmenu` on a long
|
||||
* press, so a hold on a track row opened the context menu and
|
||||
* `yj-long-press` was never announced at all. Every test in the
|
||||
* component tier passed, because dispatched pointer events do not make
|
||||
* a browser synthesise one.
|
||||
*
|
||||
* So a trusted `contextmenu` arriving mid-press *becomes* the long
|
||||
* press: `yj-long-press` is announced from it, and only if a component
|
||||
* claims it is the native event suppressed. Unclaimed, it propagates
|
||||
* untouched and opens the menu it always did — which is the same
|
||||
* "browser wins" outcome, now reached by asking rather than assuming.
|
||||
*
|
||||
* Ours and the browser's are still told apart by identity rather than
|
||||
* `isTrusted` — a `WeakSet` of the events this module made — so the
|
||||
* suppressor cannot eat the event it exists to deliver, and a test can
|
||||
* stand in for a browser that fires one.
|
||||
*
|
||||
* **The click swallow is keyed on the gesture**, cleared by the next
|
||||
* `pointerdown` rather than by a time window, so the first tap on a
|
||||
* sheet that just opened is not eaten too.
|
||||
*/
|
||||
|
||||
/** How long a press must hold still to mean "long press". */
|
||||
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;
|
||||
|
||||
/** Detail carried by both gesture events. */
|
||||
export interface GestureDetail {
|
||||
/** Where the finger was, in client coordinates — a menu opens here. */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type GestureEvent = CustomEvent<GestureDetail>;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'yj-tap': GestureEvent;
|
||||
'yj-long-press': GestureEvent;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`. */
|
||||
const ours = new WeakSet<Event>();
|
||||
|
||||
/**
|
||||
* Install the gestures. Idempotent; returns the uninstaller (which the
|
||||
* tests use — the app installs once and never removes it).
|
||||
*/
|
||||
export function installTouchGestures(): () => void {
|
||||
if (uninstall) return uninstall;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
let target: EventTarget | null = null;
|
||||
|
||||
/** The press is still a candidate for a tap: it has neither
|
||||
* drifted nor become a long press. */
|
||||
let tapCandidate = false;
|
||||
|
||||
/** A trusted `contextmenu` arrived for this press: the browser has
|
||||
* it covered. */
|
||||
let nativeSeen = false;
|
||||
|
||||
/** A gesture was claimed, and the click ending it is not a click on
|
||||
* anything. */
|
||||
let swallowClick = false;
|
||||
|
||||
/** We dispatched a `contextmenu`, so a trusted one arriving now is
|
||||
* a duplicate. */
|
||||
let justFired = false;
|
||||
|
||||
const cancel = (): void => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
|
||||
timer = null;
|
||||
target = null;
|
||||
tapCandidate = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Announce a gesture on the element the finger landed on.
|
||||
* Returns whether a component claimed it.
|
||||
*/
|
||||
const announce = (name: 'yj-tap' | 'yj-long-press', el: EventTarget): boolean => {
|
||||
const event: GestureEvent = new CustomEvent<GestureDetail>(name, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
// Or it stops at the shadow root the row lives in, and the
|
||||
// delegated listeners never see it.
|
||||
composed: true,
|
||||
detail: { x: originX, y: originY },
|
||||
});
|
||||
|
||||
ours.add(event);
|
||||
el.dispatchEvent(event);
|
||||
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
|
||||
const fireContextMenu = (el: EventTarget): void => {
|
||||
justFired = true;
|
||||
|
||||
const menu = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX: originX,
|
||||
clientY: originY,
|
||||
button: 2,
|
||||
});
|
||||
|
||||
ours.add(menu);
|
||||
el.dispatchEvent(menu);
|
||||
};
|
||||
|
||||
const onLongPress = (): void => {
|
||||
timer = null;
|
||||
tapCandidate = false;
|
||||
|
||||
const el = target;
|
||||
|
||||
target = null;
|
||||
|
||||
if (nativeSeen || !el) return;
|
||||
|
||||
// The gesture happened either way, so the click that ends it is
|
||||
// never a click on anything -- whether a list claimed it for
|
||||
// selection mode or a card grid let it fall through to a menu.
|
||||
swallowClick = true;
|
||||
|
||||
// An unclaimed long press is what it has always been. This is
|
||||
// the whole reason the fourteen context menus need no change.
|
||||
if (!announce('yj-long-press', el)) fireContextMenu(el);
|
||||
};
|
||||
|
||||
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;
|
||||
tapCandidate = true;
|
||||
timer = setTimeout(onLongPress, 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;
|
||||
|
||||
// A drifted press is neither gesture -- it is a scroll, and the
|
||||
// virtualizer's, not ours.
|
||||
if (drifted) cancel();
|
||||
};
|
||||
|
||||
const onPointerUp = (): void => {
|
||||
const el = target;
|
||||
const wasTap = tapCandidate && timer !== null;
|
||||
|
||||
// Clears the long-press timer, so a tap cannot also become one.
|
||||
cancel();
|
||||
|
||||
if (!wasTap || !el) return;
|
||||
|
||||
// Only a *claimed* tap swallows its click. An unclaimed one has
|
||||
// to fall through untouched, or every button in the app stops
|
||||
// working.
|
||||
if (announce('yj-tap', el)) swallowClick = true;
|
||||
};
|
||||
|
||||
const onContextMenu = (e: Event): void => {
|
||||
// Ours. Everything below is about somebody else's.
|
||||
if (ours.has(e)) return;
|
||||
|
||||
if (timer !== null) {
|
||||
// The browser recognised the same hold this was timing.
|
||||
// Use its event as the trigger rather than racing it --
|
||||
// and rather than standing down, which is what the old
|
||||
// rule did and which now silently means "do the thing this
|
||||
// gesture used to do".
|
||||
const el = e.composedPath()[0] ?? e.target;
|
||||
|
||||
nativeSeen = true;
|
||||
cancel();
|
||||
|
||||
if (!el) return;
|
||||
|
||||
swallowClick = true;
|
||||
|
||||
// Claimed: the component wants selection mode, so the
|
||||
// browser's menu must not also open. Unclaimed: let it
|
||||
// through exactly as before.
|
||||
if (announce('yj-long-press', el)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
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', onPointerUp, 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', onPointerUp, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user