Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/gestures-context-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: one shared context request from a long press, a right click, or Shift+F10 and the context-menu key (AST-094 FR43), reporting which input made it, one per gesture. A held finger's hold loses to a claim another gesture already made on that finger, and a control that keeps its press for a hold-repeat owns only its hold, so its right click and Shift+F10 still reach the request. A held pen's own context menu event is refused like a held finger's. The hold is UIKit's long press and requests as it begins. Not exported from the package entry point.

@vjeux
119 changes: 118 additions & 1 deletion packages/core/src/gestures/GestureHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ import {
resolvePresented,
watchPresentedElements,
} from '../motion/presentation';
import {UIKIT_PRESS, UIKIT_TAP} from '../motion/uikit/gestures';
import {
UIKIT_LONG_PRESS,
UIKIT_PRESS,
UIKIT_TAP,
} from '../motion/uikit/gestures';
import {
browserClock,
GestureArena,
Expand Down Expand Up @@ -779,6 +783,13 @@ export class GestureHost {
*/
private readonly layoutWatched = new Set<Element>();
private layoutObserver: ResizeObserver | null = null;
/** Where and when the last pen hold the host tracked ended. */
private lastPenLift: {target: unknown; time: number} | null = null;
/**
* Arena ids of the pen contacts that left the long press's tolerance
* before its duration. No such contact is a hold.
*/
private readonly driftedPens = new Set<number>();
/** Fingers whose first uncancelable `touchmove` was already judged. */
private readonly judgedUncancelable = new Set<number>();
/**
Expand Down Expand Up @@ -1408,6 +1419,37 @@ export class GestureHost {
);
}

/**
* @internal Whether a pen that went down inside `node` is down now,
* whichever participant keeps it. A pen's own menu event is that hold's
* (contextRequest.ts).
*/
isPenDownIn(node: Node): boolean {
return this.arena.touches.some(
touch =>
touch.pointerType === 'pen' &&
// A lifted pen that a recognizer still decides on is no longer down.
touch.phase !== 'ended' &&
touch.phase !== 'cancelled' &&
touch.target instanceof Node &&
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
containsAlongFlatTree(node, touch.target),
);
}

/**
* @internal When a pen hold that went down inside `node` last ended, at
* its lift or a cancel. The time is on the host's clock: negative infinity
* when none did.
*/
lastPenLiftIn(node: Node): number {
const lift = this.lastPenLift;
return lift != null &&
lift.target instanceof Node &&
containsAlongFlatTree(node, lift.target)
? lift.time
: Number.NEGATIVE_INFINITY;
}

private registrationFor(element: Element): Registration {
this.watchPointer();
let registration = this.registry.get(element);
Expand Down Expand Up @@ -2964,6 +3006,33 @@ export class GestureHost {
};
}

/**
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
* @internal Whether a press consumer that keeps its press (a hold-repeat
* control, `keepsTouch`) sits on the path from `target` up to `boundary`,
* both included. Such a control keeps its long press its own. Its right
* click and Shift+F10 still reach a context request around it
* (contextRequest.ts).
*/
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
keepsPressBetween(target: unknown, boundary: Element): boolean {
for (
let node: Node | null = target instanceof Node ? target : null;
node != null;
node = parentAcrossShadow(node)
) {
if (!(node instanceof Element)) {
continue;
}
const consumer = this.registry.get(node)?.consumer;
if (consumer?.keepsTouch != null && consumer.isEnabled?.() !== false) {
return true;
}
if (node === boundary) {
break;
}
}
return false;
}

/**
* How many fingers are on the screen: the length of `touches` in the last
* touch event the host's listeners heard (`touchstart`, `touchmove`,
Expand Down Expand Up @@ -3812,6 +3881,9 @@ export class GestureHost {
}
const id = this.pointerIds.get(event.pointerId);
if (id != null) {
if (event.pointerType === 'pen') {
this.notePenDrift(id, event.clientX, event.clientY, eventTime(event));
}
this.arena.moved([
{
id,
Expand Down Expand Up @@ -3854,11 +3926,18 @@ export class GestureHost {
this.pointerKinds.delete(event.pointerId);
const hold = this.claimHolds.get(id);
this.unlockSelection(id);
const target = this.arena.getTouch(id)?.target;
const isPenHold =
event.pointerType === 'pen' && this.isPenHold(id, eventTime(event));
this.driftedPens.delete(id);
const dispositions = this.arena.ended([
{id, x: event.clientX, y: event.clientY, time: eventTime(event)},
]);
// A selection the lift's own handlers made stays, as a click's does.
hold?.keepLiftSelection();
if (isPenHold) {
this.lastPenLift = {target, time: this.arena.now()};
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
}
// A retargeted tap's own click arms the click guard for the engine's.
const landed = this.retargeted.get(id);
this.retargeted.delete(id);
Expand Down Expand Up @@ -3966,10 +4045,47 @@ export class GestureHost {
this.pointerKinds.delete(event.pointerId);
this.unlockSelection(id, true);
this.retargeted.delete(id);
const target = this.arena.getTouch(id)?.target;
const isPenHold =
event.pointerType === 'pen' && this.isPenHold(id, eventTime(event));
this.driftedPens.delete(id);
this.arena.cancelled([id], eventTime(event));
if (isPenHold) {
this.lastPenLift = {target, time: this.arena.now()};
}
}
};

/**
* Whether a pen contact is a hold: it stays within the long press's
* tolerance for its duration. Only such a hold fires the platform's own
* menu event, so the host notes no other pen's lift.
*/
private isPenHold(id: number, time: number): boolean {
const touch = this.arena.getTouch(id);
return (
touch != null &&
!this.driftedPens.has(id) &&
time - touch.startTime >= UIKIT_LONG_PRESS.minimumPressDuration.value
);
}
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

/**
* A pen contact that leaves the long press's tolerance before its duration
* is no hold, so its lift arms no menu refusal.
*/
private notePenDrift(id: number, x: number, y: number, time: number): void {
const touch = this.arena.getTouch(id);
if (
touch != null &&
time - touch.startTime < UIKIT_LONG_PRESS.minimumPressDuration.value &&
Math.hypot(x - touch.startX, y - touch.startY) >
UIKIT_LONG_PRESS.allowableMovement.value
) {
this.driftedPens.add(id);
}
}

/**
* Which live finger a touch `pointercancel` is about. The pointer keeps
* the target the finger landed on (implicit capture), as the Touch does;
Expand Down Expand Up @@ -4245,6 +4361,7 @@ export class GestureHost {
}
this.pointerIds.clear();
this.pointerKinds.clear();
this.driftedPens.clear();
this.retargeted.clear();
this.fingerPointers.clear();
this.pendingPointers.clear();
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/gestures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,27 @@ the rest of that stream is the page's. A ctrl-wheel pinch keeps its stream,
so the page never zooms for it, and a pan its lift released keeps its
momentum latched until the stream ends.

## Context requests

`attachContextRequest(element, {onRequest})` (`contextRequest.ts`, internal)
is the one request a context menu answers (`spec:AST-094` FR43): a finger or
a pen held still for UIKit's long press, a pointer's context click (a right
click, a ctrl-click on macOS, a pen's barrel button), or Shift+F10 and the
context-menu key. Each gesture makes one request, reporting its `kind`
(`hold`, `pointer`, `keyboard`), its pointer, and its point (none from
the keyboard). The hold is an arena participant, so it loses to a claim
another recognizer made on that finger first (a reorder handle's hold); the
browser's own menu that a held finger, a held pen or a key also fires is
refused, never requested again; and a mouse held still makes no request. A
control that keeps its press for a hold-repeat owns only its hold: its right
click and Shift+F10 still reach the request. A right click or a ctrl-click on macOS starts no press.
The request listens on its own element only, and only while enabled.

The hold is UIKit's long press, 500 ms and 10 px as a distance, and it
requests as it begins; `onHold` reports it committed, then ended at its lift
or cancelled. A hold disabled before it requests reports nothing, and a held
key's repeats make no request.

## Time

Every input carries its own timestamp (`event.timeStamp`), and timers are
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/gestures/__tests__/browser/bareFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
* @input Nothing: bundled by esbuild and injected into a blank page
* @output A page with nothing attached. `window.__api` holds the document's
* gesture host and the host lookup for another document. It also holds
* the recognizers (pan, screen-edge pan, tap, long press) and the press
* tracker, for a test to attach what it needs. `window.__pageTouch` is
* the count of touch listeners on the window and the document right now.
* the recognizers (pan, screen-edge pan, tap, long press), the press
* tracker and the context request. A test attaches what it needs.
* `window.__pageTouch` is the count of touch listeners on the window and
* the document right now.
* @position Browser fixture of gestures.interaction.browser.spec.ts for the
* checks that need a page with no other gesture on it (FR19, FR24);
* test-only
*/

import {attachContextRequest} from '../../contextRequest';
import {getGestureHost} from '../../GestureHost';
import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer';
import {PanGestureRecognizer} from '../../PanGestureRecognizer';
Expand All @@ -26,7 +28,9 @@ declare global {
interface Window {
__pageTouch: number;
__wheelLog: string[];
__requests: string[];
__api: {
attachContextRequest: typeof attachContextRequest;
host: ReturnType<typeof getGestureHost>;
getGestureHost: typeof getGestureHost;
LongPressGestureRecognizer: typeof LongPressGestureRecognizer;
Expand Down Expand Up @@ -96,7 +100,9 @@ EventTarget.prototype.removeEventListener = function (
};

document.body.style.margin = '0';
window.__requests = [];
window.__api = {
attachContextRequest,
host: getGestureHost(document),
getGestureHost,
LongPressGestureRecognizer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,49 @@ test('a zero-delay hold that brings its own lock turns selection off only for a
expect(held.lens).toEqual([true, false]);
});

test('one context request per gesture from a finger held still, a right click and Shift+F10, each naming its input (spec:AST-094 FR43)', async ({
page,
browserName,
}) => {
const driver = await openBare(page, browserName);
await page.evaluate(() => {
const {attachContextRequest} = window.__api;
document.body.innerHTML =
'<div id="row" tabindex="0" style="height: 80px">Row</div>';
attachContextRequest(document.getElementById('row') as HTMLElement, {
onRequest: request =>
window.__requests.push(
`${request.kind}:${request.pointerType ?? 'none'}`,
),
});
});
const at = await center(page, 'row');
// Held past the long press: one request. Android fires its own context
// menu during the hold too, as a touch's. The request refuses that one and
// makes no second request.
await driver.play([{t: 0, down: [{id: 1, ...at}]}]);
await page.waitForTimeout(900);
const nativeMenuRefused = await page.evaluate(() => {
const menu = new PointerEvent('contextmenu', {
bubbles: true,
cancelable: true,
pointerType: 'touch',
});
document.getElementById('row')?.dispatchEvent(menu);
return menu.defaultPrevented;
});
await driver.play([{t: 0, down: []}]);
expect(nativeMenuRefused).toBe(true);
await page.mouse.click(at.x, at.y, {button: 'right'});
await page.focus('#row');
await page.keyboard.press('Shift+F10');
expect(await page.evaluate(() => window.__requests)).toEqual([
'hold:touch',
'pointer:mouse',
'keyboard:none',
]);
});

test('a tap recognizer replaces the click: no ghost click, no compatibility mouse events', async ({
page,
browserName,
Expand Down
Loading