feat(android): swipe a track row right to queue it

Plan 019 phase 2. A finger on a track row now drags a reveal out from
under it and queues the track on release, with the affordance saying
what it will do before it does it.

Two things the device said that the plan did not predict, and both
change the implementation rather than decorate it.

The gesture runs on touch events, not pointer events. Chrome 113's
WebView cancels the pointer stream ~16px into any drag whatever
touch-action says -- measured at auto, pan-y and none alike -- while
touchmove keeps firing. So touch-action: pan-y is half the fix and a
non-passive touchmove calling preventDefault is the other half, and
neither works alone: with the preventDefault in place and touch-action
back at auto the gesture died after one move. Both are correct in
Chromium either way, which is why the module's header carries the
measurement and the component tier asserts the stylesheet.

And a phase 1 defect the device found on the way past: the native
contextmenu arrives in either order and only one was handled. Our
500ms timer firing first, a component claiming it, and Chrome
delivering its own menu 50-70ms later was suppressed by nothing -- so
the context menu opened over the selection bar, two holds in four, on
the one surface this issue exists to have changed. Six holds clean
after.

draggable="true" is not a competitor: no dragstart fires from a touch
drag on this WebView at all.
This commit is contained in:
2026-08-22 01:23:19 -04:00
parent ff3875b55d
commit 4e667759c4
7 changed files with 1390 additions and 19 deletions
@@ -10,7 +10,7 @@ import {
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import type { GestureEvent } from '@utils/touch-gestures';
import type { GestureEvent, SwipeEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
@@ -282,6 +282,37 @@ export class TrackList
* path into it at all (H-5). */
@state() private focusedIndex = 0;
// --- swipe right to queue (plan 019 phase 2, #63) ----------------
/** How far along the row a swipe has to reach to mean it. */
private static readonly SWIPE_COMMIT_FRACTION = 0.3;
/** … and a floor, for a narrow list embedded in a detail page. */
private static readonly SWIPE_COMMIT_MIN_PX = 72;
/** How long the reveal holds its confirmation before snapping. */
private static readonly SWIPE_CONFIRM_MS = 550;
/** The snap itself, which the stylesheet also states. */
private static readonly SWIPE_SETTLE_MS = 180;
/** Which row is being swiped, and therefore which draws a reveal. */
@state() private swipeIndex: number | null = null;
/** Past the commit threshold: the reveal says so, in words. */
@state() private swipeArmed = false;
/** Committed, and holding the confirmation. */
@state() private swipeDone = false;
/** What the gesture did, for anyone not watching the row. */
@state() private swipeAnnouncement = '';
private swipeRow: HTMLElement | null = null;
private swipeKeys: string[] = [];
private swipeCommitPx = 0;
private swipeSettleTimer = 0;
private handleSelectAll = (): void => {
this.selection.selectAll();
};
@@ -1135,6 +1166,18 @@ export class TrackList
height: 33px;
box-sizing: border-box;
contain: strict;
/* Swipe right to queue (plan 019 phase 2, #63). Half of what
makes the gesture reach us on the device: auto lets Chrome
113's WebView commit to a horizontal pan on the first move
past slop, and the pointer stream is cancelled before any
threshold can be crossed. The other half is the non-passive
preventDefault in utils/touch-gestures.ts, and neither works
alone -- both were measured three ways on the phone.
Never none: that takes the list's own vertical scrolling with
it. The cost is that a finger starting on a row can no longer
pan the shell sideways in the 600-899 band, where the shell
can still overflow; anywhere else on the page still can. */
touch-action: pan-y;
}
/* A phone row is two lines, and this height must equal
@@ -1213,6 +1256,62 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The reveal behind a swiped row (plan 019 phase 2, #63).
The row itself does not move -- its *cells* do. Moving the row
and counter-translating the pane inside it is the obvious
arrangement and does not work here: .track-row is contain:
strict with overflow: hidden, so a pane held at the row's
original position is a pane at a negative offset inside a
clipping box, and it is simply not painted. Sliding the cells
instead leaves the pane where it was drawn, clips the cells off
the right edge, and needs no wrapper element in a row that is
already a grid.
It is not only a colour (WCAG 1.4.1, the rule the playing-row
marker is here for): the pane carries an icon and words, the
words change at the commit threshold, and the outcome is
announced in the list's live region. */
.swipe-reveal {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--yj-swipe-dx, 0px);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.4em;
padding-left: 8px;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
font-size: var(--yj-text-xs);
background-color: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-secondary, #b3b3b3);
}
.swipe-reveal.armed {
background-color: var(--yj-success, #2f9e44);
color: var(--yj-success-fg, #fff);
}
.track-row.swiping > :not(.swipe-reveal) {
transform: translateX(var(--yj-swipe-dx, 0px));
}
.track-row.settling > * {
transition:
transform 160ms ease-out,
width 160ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.track-row.settling > * {
transition: none;
}
}
.cell {
overflow: hidden;
text-overflow: ellipsis;
@@ -1311,6 +1410,9 @@ export class TrackList
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
virt.removeEventListener('yj-swipe-start', this.onRowSwipeStart);
virt.removeEventListener('yj-swipe-move', this.onRowSwipeMove);
virt.removeEventListener('yj-swipe-end', this.onRowSwipeEnd);
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
@@ -1457,6 +1559,9 @@ export class TrackList
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
virt.addEventListener('yj-swipe-start', this.onRowSwipeStart);
virt.addEventListener('yj-swipe-move', this.onRowSwipeMove);
virt.addEventListener('yj-swipe-end', this.onRowSwipeEnd);
this.delegationAttached = true;
}
@@ -1776,6 +1881,193 @@ export class TrackList
this.virtualizer?.requestUpdate();
};
// =================================================================
// Swipe right to queue (plan 019 phase 2, #63)
// =================================================================
/**
* What a swipe on this row would queue.
*
* The same rule the context menu answers with, and it has to be:
* **one row is a position, several rows are an explicit choice.**
* A finger that swipes a row which is part of a selection of forty
* has not un-made that selection, and queueing the one row it
* touched would quietly contradict the bar above saying forty are
* selected. A swipe on a row *outside* the selection is a statement
* about that row, exactly as a right-click on one is -- and unlike
* a right-click it does not move the selection, because a swipe is
* not a way of selecting anything.
*/
private swipeTargetKeys(filePath: string): string[] {
if (
this.selection.selectionCount > 1 &&
this.selection.isSelected(filePath)
) {
return this.selection.getSelectedKeysOrdered();
}
return [filePath];
}
private onRowSwipeStart = (e: SwipeEvent) => {
// Rightward only. Nothing is bound to a leftward swipe, and
// claiming one would take a gesture away to do nothing with it.
if (e.detail.dx <= 0) return;
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
const row = (e.target as HTMLElement).closest(
'.track-row',
) as HTMLElement | null;
if (!row) return;
e.preventDefault();
this.swipeRow = row;
this.swipeKeys = this.swipeTargetKeys(hit.track.FilePath);
// A fraction of the row, with a floor: the row is 424x52 on the
// reference device, so a threshold in bare pixels is a fraction
// of a row height on one screen and a third of the width on
// another.
this.swipeCommitPx = Math.max(
TrackList.SWIPE_COMMIT_MIN_PX,
row.getBoundingClientRect().width *
TrackList.SWIPE_COMMIT_FRACTION,
);
this.swipeArmed = false;
this.swipeDone = false;
this.swipeIndex = hit.index;
this.virtualizer?.requestUpdate();
this.setSwipeOffset(0);
};
private onRowSwipeMove = (e: SwipeEvent) => {
if (this.swipeIndex === null) return;
const dx = Math.min(
Math.max(e.detail.dx, 0),
this.swipeCommitPx * 2,
);
const armed = dx >= this.swipeCommitPx;
// Crossing the threshold is the only thing here that renders.
// The offset itself is written straight to the row's style, or
// a virtualized list would re-render every visible row for
// every frame of one finger's travel.
if (armed !== this.swipeArmed) {
this.swipeArmed = armed;
this.virtualizer?.requestUpdate();
}
this.setSwipeOffset(dx);
};
private onRowSwipeEnd = (e: SwipeEvent) => {
if (this.swipeIndex === null) return;
const commit =
!e.detail.canceled && e.detail.dx >= this.swipeCommitPx;
if (!commit) {
this.settleSwipe(0);
return;
}
queueStore.addTracksToQueue(this.swipeKeys);
const count = this.swipeKeys.length;
const only =
count === 1
? tracksByFilePath(this.tracks).get(this.swipeKeys[0]!)
: undefined;
// The reveal is the only thing on screen that says this
// happened -- the queue panel may well be closed -- so it holds
// its confirmation for a moment rather than snapping back the
// instant the finger lifts. The live region is the same
// sentence for anyone not watching it.
this.swipeDone = true;
this.swipeAnnouncement =
count === 1
? `Added ${only?.TrackName ?? 'the track'} to the queue.`
: `Added ${count} tracks to the queue.`;
this.virtualizer?.requestUpdate();
this.settleSwipe(TrackList.SWIPE_CONFIRM_MS);
};
/** Write the travel to the row itself, with no render. */
private setSwipeOffset(dx: number) {
this.swipeRow?.style.setProperty('--yj-swipe-dx', `${dx}px`);
}
/**
* Put the row back, after `delay`, and forget the swipe.
*
* The row element is held rather than looked up again: a
* virtualizer recycles its rows, and by the time this runs the
* element may be drawing a different track. Clearing the property
* off whatever it holds now is right either way, since
* `swipeIndex` is what decides who draws the reveal.
*/
private settleSwipe(delay: number) {
const row = this.swipeRow;
window.clearTimeout(this.swipeSettleTimer);
this.swipeSettleTimer = window.setTimeout(() => {
row?.classList.add('settling');
this.setSwipeOffset(0);
this.swipeSettleTimer = window.setTimeout(() => {
row?.classList.remove('settling');
row?.style.removeProperty('--yj-swipe-dx');
this.swipeRow = null;
this.swipeIndex = null;
this.swipeArmed = false;
this.swipeDone = false;
this.virtualizer?.requestUpdate();
}, TrackList.SWIPE_SETTLE_MS);
}, delay);
}
/**
* What is revealed behind the row, in three states.
*
* One glyph throughout, and the words carry the state. A tick
* would read better for the last of them and is `ICON_IN_LIBRARY`
* -- it means *you own this* -- and `icon-language.ts` exists
* because `plus` came to mean four things that way.
*/
private renderSwipeReveal() {
const count = this.swipeKeys.length;
const what =
count === 1 ? 'to queue' : `${count} tracks to queue`;
return html`
<div
class=${classMap({
'swipe-reveal': true,
armed: this.swipeArmed,
})}
aria-hidden="true"
data-testid="swipe-reveal"
>
<wa-icon name=${ICON_QUEUE}></wa-icon>
<span
>${this.swipeDone
? 'Added'
: this.swipeArmed
? 'Release to add'
: `Add ${what}`}</span
>
</div>
`;
}
private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e);
@@ -2197,6 +2489,7 @@ export class TrackList
'track-row': true,
active,
selected,
swiping: this.swipeIndex === index,
})}
role="row"
aria-rowindex=${index + 1}
@@ -2208,6 +2501,7 @@ export class TrackList
data-testid="track-row"
data-file-path=${track.FilePath}
>
${this.swipeIndex === index ? this.renderSwipeReveal() : nothing}
<div
role="gridcell"
class=${classMap({
@@ -2354,6 +2648,9 @@ export class TrackList
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus(visibleTracks.length)}
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipeAnnouncement}
</div>
${this.tracks.length === 0
? this.renderPlaceholder()
: html`
+297 -5
View File
@@ -14,6 +14,13 @@
*
* `yj-tap` a short press that did not drift
* `yj-long-press` a press that held still for LONG_PRESS_MS
* `yj-swipe-start` a press that has travelled decisively sideways
*
* A claimed swipe is then followed by `yj-swipe-move` and one
* `yj-swipe-end`, which is guaranteed: a swipe that the browser or a
* second finger takes away still ends, with `canceled` set, so the
* affordance a component put on screen always has something to snap
* back from.
*
* A component that wants the gesture handles it and calls
* `preventDefault()`. Nothing else changes. That shape is what lets
@@ -80,6 +87,62 @@
* **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.
*
* ## The swipe runs on touch events, and that is not a style choice
*
* Everything above is Pointer Events. The swipe is not, and the reason
* is measured on the reference device rather than reasoned about:
* **Chrome 113's Android WebView cancels the pointer stream ~16px into
* any drag, whatever `touch-action` says.** Three values were tried on
* a track row, driving a real finger with `adb shell input swipe`:
*
* ```
* touch-action: auto pointerdown, 1 move, pointercancel
* touch-action: pan-y pointerdown, 2 moves, pointercancel
* touch-action: none pointerdown, 2 moves, pointercancel
* ```
*
* `touchmove` kept firing throughout all three. So a swipe recognised
* from `pointermove` is a swipe that dies 16px in — plan 019 predicted
* the class of failure ("works in Chromium and not on the phone") and
* named `touch-action: pan-y` as the fix; it is half of it.
*
* The other half is that **a non-passive `touchmove` that calls
* `preventDefault()` is what keeps the gesture ours**. With it, the
* same swipe ran to 12 moves and a `pointerup` at full travel.
*
* Both halves are required, and that was measured too: with the
* `preventDefault` in place but `touch-action` back at `auto`, the
* gesture died after **one** move. The reading is that `auto` lets the
* browser commit to a horizontal pan on the first move past slop —
* before any threshold of ours can have been crossed — while `pan-y`
* leaves it undecided long enough for the second move to claim it.
*
* So a surface that wants a horizontal swipe declares
* `touch-action: pan-y` (`track-list`'s `.track-row` does) *and* gets
* this module's `preventDefault`. Neither alone works on the device,
* and **both work in Chromium either way**, which is exactly why this
* paragraph exists rather than a test.
*
* `touch-action: none` is the one value to avoid: it also takes the
* list's vertical scrolling away, which was measured as a list that
* would not move.
*
* Two consequences of the touch listener worth knowing.
*
* **It is non-passive, which costs the compositor's scroll fast path**
* for the first touchmoves of every scroll, until the browser starts
* scrolling and stops waiting on us. That is the standard price of a
* horizontal gesture in a scroller and it is paid once per gesture,
* not per frame; a vertical drag on the device still scrolls the
* virtualizer 81px on the same measurement that the horizontal one
* survives.
*
* **The tie breaks toward scrolling**, deliberately and in that order:
* vertical drift past the tolerance vetoes the swipe outright, and a
* gesture that is not *strictly* more horizontal than vertical is the
* scroller's. A list that will not scroll is unusable; a swipe that
* needs a second try is not.
*/
/** How long a press must hold still to mean "long press". */
@@ -92,6 +155,18 @@ export const LONG_PRESS_MS = 500;
*/
export const MOVE_TOLERANCE_PX = 10;
/**
* How far a press must travel sideways before it is a swipe.
*
* It has a ceiling the other constants do not: the browser's own
* decision is made a little past this, so a threshold much higher is a
* gesture the device never delivers. Measured, the second `touchmove`
* of an `adb input swipe` lands at ~19px and the pointer stream dies
* just after it, so 12 is inside that window with room for a slower
* finger.
*/
export const SWIPE_START_PX = 12;
/** Detail carried by both gesture events. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
@@ -99,12 +174,30 @@ export interface GestureDetail {
y: number;
}
/** Detail carried by the three swipe events. */
export interface SwipeDetail {
/** Travel from where the finger landed. Signed: right is positive. */
dx: number;
dy: number;
/**
* The gesture was taken away rather than finished — a second
* finger, a `touchcancel`, a scroll underneath. Only ever true on
* `yj-swipe-end`, and it is the difference between "do the thing"
* and "put the row back".
*/
canceled: boolean;
}
export type GestureEvent = CustomEvent<GestureDetail>;
export type SwipeEvent = CustomEvent<SwipeDetail>;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
'yj-swipe-start': SwipeEvent;
'yj-swipe-move': SwipeEvent;
'yj-swipe-end': SwipeEvent;
}
}
@@ -139,10 +232,44 @@ export function installTouchGestures(): () => void {
* anything. */
let swallowClick = false;
/** We dispatched a `contextmenu`, so a trusted one arriving now is
* a duplicate. */
/**
* This press has already produced its outcome, so a trusted
* `contextmenu` arriving now is a duplicate of it.
*
* It covers **both** outcomes, and that is a fix rather than a
* tidy-up. `nativeSeen` handles the browser's menu arriving
* *during* the hold; the reverse order was never handled, and it
* happens: measured on the reference device over four holds, two
* of them fired our 500ms timer and then delivered a trusted
* `contextmenu` 50-70ms later, which nothing suppressed — so the
* context menu opened on top of the selection bar, intermittently,
* on exactly the surface #63 exists to have changed. Neither the
* component tier nor the e2e tier can see it: no browser they run
* in synthesises a `contextmenu` from a dispatched press at all.
*/
let justFired = false;
// --- the swipe, which runs on touch events; see the header ------
/** Where the finger landed, and what it landed on. */
let swipeTarget: EventTarget | null = null;
let swipeOriginX = 0;
let swipeOriginY = 0;
/** The last travel, kept so a `touchcancel` — which carries no
* coordinates for a touch that is already gone — can still say how
* far the row had moved. */
let lastDx = 0;
let lastDy = 0;
/** A component claimed the swipe: it is ours until the finger
* lifts, and every `touchmove` is prevented. */
let swiping = false;
/** This press can no longer become a swipe — it went vertical, a
* second finger arrived, or nobody claimed it. */
let swipeVetoed = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
@@ -202,6 +329,12 @@ export function installTouchGestures(): () => void {
// selection mode or a card grid let it fall through to a menu.
swallowClick = true;
// The press is answered, so a trusted `contextmenu` for it is
// late rather than new. `fireContextMenu` sets this too; it is
// set here as well so the *claimed* branch is covered, which
// is the branch that was showing a menu over the bar.
justFired = 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);
@@ -223,6 +356,145 @@ export function installTouchGestures(): () => void {
timer = setTimeout(onLongPress, LONG_PRESS_MS);
};
/**
* Announce a swipe on the element the finger landed on.
* Returns whether a component claimed it (only `start` asks).
*/
const announceSwipe = (
name: 'yj-swipe-start' | 'yj-swipe-move' | 'yj-swipe-end',
el: EventTarget,
canceled = false,
): boolean => {
const event: SwipeEvent = new CustomEvent<SwipeDetail>(name, {
bubbles: true,
cancelable: name === 'yj-swipe-start',
composed: true,
detail: { dx: lastDx, dy: lastDy, canceled },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
/**
* End a claimed swipe, once.
*
* Every exit from a swipe comes through here so that `yj-swipe-end`
* is guaranteed: a component that has put a reveal on screen and a
* row half off its own left edge has no other way to learn the
* gesture is over.
*/
const endSwipe = (canceled: boolean): void => {
const el = swipeTarget;
swipeTarget = null;
if (!swiping) return;
swiping = false;
if (!el) return;
// The gesture happened, so the click that ends it is not a
// click on the row it ended over.
swallowClick = true;
announceSwipe('yj-swipe-end', el, canceled);
};
const onTouchStart = (e: TouchEvent): void => {
endSwipe(true);
lastDx = 0;
lastDy = 0;
// A second finger is a pinch or a scroll, never one of ours.
swipeVetoed = e.touches.length !== 1;
if (swipeVetoed) return;
const touch = e.touches[0];
if (!touch) return;
swipeOriginX = touch.clientX;
swipeOriginY = touch.clientY;
// `composedPath()[0]` for the reason the press path uses it: a
// list delegates inside its own shadow root.
swipeTarget = e.composedPath()[0] ?? e.target;
};
const onTouchMove = (e: TouchEvent): void => {
if (swipeVetoed || !swipeTarget) return;
if (e.touches.length !== 1) {
endSwipe(true);
swipeVetoed = true;
return;
}
const touch = e.touches[0];
if (!touch) return;
lastDx = touch.clientX - swipeOriginX;
lastDy = touch.clientY - swipeOriginY;
if (swiping) {
// This is what keeps the stream alive on the device. It is
// only ever reached for a *claimed* swipe, so nothing that
// scrolls is ever prevented.
e.preventDefault();
announceSwipe('yj-swipe-move', swipeTarget);
return;
}
// Vertical first: past the tolerance the list has it, and a
// gesture that is exactly diagonal is the list's too.
if (
Math.abs(lastDy) > MOVE_TOLERANCE_PX &&
Math.abs(lastDy) >= Math.abs(lastDx)
) {
swipeVetoed = true;
swipeTarget = null;
return;
}
if (
Math.abs(lastDx) < SWIPE_START_PX ||
Math.abs(lastDx) <= Math.abs(lastDy)
) {
return;
}
if (!announceSwipe('yj-swipe-start', swipeTarget)) {
// Nobody wants it. Leave the gesture to the browser rather
// than holding it open for the rest of the press.
swipeVetoed = true;
swipeTarget = null;
return;
}
swiping = true;
// It is not a tap and it is not a hold.
cancel();
e.preventDefault();
};
const onTouchEnd = (): void => {
endSwipe(false);
};
const onTouchCancel = (): void => {
endSwipe(true);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
@@ -301,25 +573,45 @@ export function installTouchGestures(): () => void {
// before anything that would act on the event.
const opts = { capture: true } as const;
// Non-passive, because `onTouchMove` has to be able to prevent the
// default for a claimed swipe -- see the header. The other three
// are passive: they only read.
const blocking = { capture: true, passive: false } as const;
const listening = { capture: true, passive: true } as const;
/** A surface moved under the finger: neither gesture survives it. */
const abort = (): void => {
endSwipe(true);
cancel();
};
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);
document.addEventListener('touchstart', onTouchStart, listening);
document.addEventListener('touchmove', onTouchMove, blocking);
document.addEventListener('touchend', onTouchEnd, listening);
document.addEventListener('touchcancel', onTouchCancel, listening);
// 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 });
document.addEventListener('scroll', abort, listening);
uninstall = () => {
cancel();
abort();
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);
document.removeEventListener('touchstart', onTouchStart, opts);
document.removeEventListener('touchmove', onTouchMove, opts);
document.removeEventListener('touchend', onTouchEnd, opts);
document.removeEventListener('touchcancel', onTouchCancel, opts);
document.removeEventListener('scroll', abort, opts);
uninstall = null;
};