Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2: Scrolling (#3, #4, #14) - Each recognizer locks onto a touch within its first 6px: along its axis and direction it holds the touch, otherwise it lets go. A new onTouchmove binding cancels native scrolling only while a recognizer holds the touch, so hosts use touch-action: manipulation and one vertical gesture no longer disables vertical scrolling. Several gestures on one host share touches through the same rule. - .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a scroller is at the edge (#5). Feel - recognition.distance sets what counts as full progress: pixels, a CSS length, or a measuring function (#6). - Choreographies receive overshoot, the drag past either end of the clamped progress, and the settling spring starts from it (#7). - Velocity is the least-squares trend of the last 100ms (#8). - Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the slide presets use them (#9). - Choreographies may set spring stiffness and damping (#10). Hooks and helpers - .onArm()/.onDisarm() run during a drag when releasing would start or stop committing (#12). - startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15). - Development builds warn when an operation's first frame moves a node that was visibly at rest, the one-frame "pop" of mismatched open and close choreographies (#16). Docs: scrolling, ignored elements, animation performance, haptics, and operating-system edge gestures including CloseWatcher/popstate for PWAs (#11, #17, #18). Verification: a Chromium harness with emulated touch (verify/browser) checks swipes over scrollers, native scrolling beside gestures, scroll-edge dismissal, onCommit inside pointerup, and the continuity warning. Reverting the earlier capture fix makes it fail exactly as reported ("none of the handles work"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import type { OriginAction } from "../navigation";
|
||||
import { OriginSceneError } from "../scene/graph";
|
||||
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
|
||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
|
||||
@@ -10,7 +10,9 @@ import type {
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureDirection,
|
||||
OriginGestureDistance,
|
||||
OriginGestureEdge,
|
||||
OriginGesturePoint,
|
||||
OriginGestureProgressDistance,
|
||||
OriginGestureStart,
|
||||
} from "./definition";
|
||||
|
||||
@@ -53,21 +55,15 @@ export const crossDistance = (
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS `touch-action` for a host recognizing the given directions: native
|
||||
* scrolling is preserved on any axis no gesture claims.
|
||||
* CSS `touch-action` for a gesture host. Recognizers arbitrate with native
|
||||
* scrolling themselves (see `onTouchmove`), so the browser may pan either axis
|
||||
* and is only kept from double-tap zooming; a host without gestures is left
|
||||
* alone.
|
||||
*/
|
||||
export const touchActionFor = (
|
||||
directions: Iterable<OriginGestureDirection>,
|
||||
): "none" | "pan-x" | "pan-y" | "auto" => {
|
||||
let horizontal = false;
|
||||
let vertical = false;
|
||||
for (const direction of directions) {
|
||||
if (isHorizontal(direction)) horizontal = true;
|
||||
else vertical = true;
|
||||
}
|
||||
if (horizontal && vertical) return "none";
|
||||
if (horizontal) return "pan-y";
|
||||
if (vertical) return "pan-x";
|
||||
): "manipulation" | "auto" => {
|
||||
for (const _ of directions) return "manipulation";
|
||||
return "auto";
|
||||
};
|
||||
|
||||
@@ -168,26 +164,36 @@ export const matchesStart = (
|
||||
if (start.kind === "when")
|
||||
return start.predicate({ event, origin, host, bounds, point });
|
||||
|
||||
const horizontal = start.edge === "left" || start.edge === "right";
|
||||
const distance = resolveCssDistance(
|
||||
start.distance,
|
||||
return withinEdge(start.edge, start.distance, host, bounds, point);
|
||||
};
|
||||
|
||||
/** Whether `point` lies within `distance` of one side of the host. */
|
||||
export const withinEdge = (
|
||||
edge: OriginGestureEdge,
|
||||
distance: OriginGestureDistance,
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
point: OriginGesturePoint,
|
||||
): boolean => {
|
||||
const horizontal = edge === "left" || edge === "right";
|
||||
const limit = resolveCssDistance(
|
||||
distance,
|
||||
horizontal ? "horizontal" : "vertical",
|
||||
host,
|
||||
bounds,
|
||||
);
|
||||
switch (start.edge) {
|
||||
switch (edge) {
|
||||
case "left":
|
||||
return point.localX >= 0 && point.localX <= distance;
|
||||
return point.localX >= 0 && point.localX <= limit;
|
||||
case "right":
|
||||
return (
|
||||
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
||||
point.localX <= bounds.width && bounds.width - point.localX <= limit
|
||||
);
|
||||
case "top":
|
||||
return point.localY >= 0 && point.localY <= distance;
|
||||
return point.localY >= 0 && point.localY <= limit;
|
||||
case "bottom":
|
||||
return (
|
||||
point.localY <= bounds.height &&
|
||||
bounds.height - point.localY <= distance
|
||||
point.localY <= bounds.height && bounds.height - point.localY <= limit
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -196,50 +202,45 @@ export const matchesStart = (
|
||||
|
||||
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
|
||||
|
||||
/** Shortest frame interval assumed when computing velocity, in ms. */
|
||||
const MIN_SAMPLE_INTERVAL = 8;
|
||||
/**
|
||||
* A release without movement after this many ms is treated as a hold, so the
|
||||
* last move's flick velocity no longer applies.
|
||||
*/
|
||||
const HOLD_RELEASE_INTERVAL = 80;
|
||||
/** Velocity is the trend of the samples in this many recent milliseconds. */
|
||||
const VELOCITY_WINDOW = 100;
|
||||
/** Shortest span, in ms, velocity is measured over, so bursts stay finite. */
|
||||
const MIN_VELOCITY_SPAN = 8;
|
||||
|
||||
/**
|
||||
* Measures one pointer sequence's movement along a gesture direction.
|
||||
*
|
||||
* Progress and velocity are normalized by the host's size along the gesture
|
||||
* axis, so `1` means one full host width (or height).
|
||||
* Progress and velocity are normalized by the gesture distance (by default
|
||||
* the host's size along the gesture axis), so `1` means one full distance.
|
||||
*/
|
||||
export class DirectionalTracker {
|
||||
/** Directed distance from the start, in CSS pixels. */
|
||||
distance = 0;
|
||||
/** Absolute cross-axis distance from the start, in CSS pixels. */
|
||||
crossDistance = 0;
|
||||
/** Directed distance as a fraction of host size, clamped to `0..1`. */
|
||||
progress = 0;
|
||||
/** Latest directed velocity in progress units per second. */
|
||||
/** Directed distance ÷ gesture distance, unclamped so it can overshoot. */
|
||||
rawProgress = 0;
|
||||
/**
|
||||
* Directed velocity in progress units per second: the least-squares trend
|
||||
* of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a
|
||||
* final held-still moment do not distort a flick.
|
||||
*/
|
||||
velocity = 0;
|
||||
|
||||
private readonly horizontal: boolean;
|
||||
/** Whether progress grows as the axis coordinate decreases. */
|
||||
private readonly reversed: boolean;
|
||||
private lastCoordinate: number;
|
||||
private lastTime: number;
|
||||
private readonly samples: { time: number; position: number }[];
|
||||
|
||||
constructor(
|
||||
private readonly direction: OriginGestureDirection,
|
||||
private readonly start: PointerSample,
|
||||
/** Host size along the gesture axis, read at every sample. */
|
||||
/** Gesture distance in CSS pixels, read at every sample. */
|
||||
private readonly axisSize: () => number,
|
||||
) {
|
||||
this.horizontal = isHorizontal(direction);
|
||||
this.reversed = direction === "left" || direction === "up";
|
||||
this.lastCoordinate = this.coordinateOf(start);
|
||||
this.lastTime = start.timeStamp;
|
||||
this.samples = [{ time: start.timeStamp, position: 0 }];
|
||||
}
|
||||
|
||||
private coordinateOf(event: PointerSample) {
|
||||
return this.horizontal ? event.clientX : event.clientY;
|
||||
/** Directed distance ÷ gesture distance, clamped to `0..1`. */
|
||||
get progress(): number {
|
||||
return clamp01(this.rawProgress);
|
||||
}
|
||||
|
||||
/** Movement from the start without recording a sample. */
|
||||
@@ -253,27 +254,38 @@ export class DirectionalTracker {
|
||||
}
|
||||
|
||||
/** Record a sample, updating distance, progress, and velocity. */
|
||||
sample(event: PointerSample, release = false): void {
|
||||
sample(event: PointerSample): void {
|
||||
const { distance, crossDistance } = this.displacement(event);
|
||||
this.distance = distance;
|
||||
this.crossDistance = crossDistance;
|
||||
|
||||
const size = Math.max(1, this.axisSize());
|
||||
const coordinate = this.coordinateOf(event);
|
||||
const step = this.reversed
|
||||
? this.lastCoordinate - coordinate
|
||||
: coordinate - this.lastCoordinate;
|
||||
const interval = event.timeStamp - this.lastTime;
|
||||
this.progress = clamp01(distance / size);
|
||||
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
||||
// that move's flick velocity for a prompt release, but decay it when the
|
||||
// pointer was held still long enough for the flick to have ended.
|
||||
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
|
||||
this.velocity =
|
||||
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
|
||||
this.rawProgress = distance / size;
|
||||
|
||||
this.lastCoordinate = coordinate;
|
||||
this.lastTime = event.timeStamp;
|
||||
const samples = this.samples;
|
||||
samples.push({ time: event.timeStamp, position: distance });
|
||||
// Keep the window, but always at least two samples to measure between.
|
||||
const since = event.timeStamp - VELOCITY_WINDOW;
|
||||
while (samples.length > 2 && samples[1]!.time <= since) samples.shift();
|
||||
this.velocity = (DirectionalTracker.trend(samples) * 1000) / size;
|
||||
}
|
||||
|
||||
/** Least-squares slope of position over time, in pixels per ms. */
|
||||
private static trend(samples: readonly { time: number; position: number }[]) {
|
||||
const first = samples[0]!;
|
||||
const last = samples[samples.length - 1]!;
|
||||
if (last.time - first.time < MIN_VELOCITY_SPAN)
|
||||
return (last.position - first.position) / MIN_VELOCITY_SPAN;
|
||||
const meanTime =
|
||||
samples.reduce((sum, { time }) => sum + time, 0) / samples.length;
|
||||
const meanPosition =
|
||||
samples.reduce((sum, { position }) => sum + position, 0) / samples.length;
|
||||
let covariance = 0;
|
||||
let variance = 0;
|
||||
for (const { time, position } of samples) {
|
||||
covariance += (time - meanTime) * (position - meanPosition);
|
||||
variance += (time - meanTime) ** 2;
|
||||
}
|
||||
return covariance / variance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +295,34 @@ export class DirectionalTracker {
|
||||
const DEFAULT_THRESHOLD = 8;
|
||||
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
||||
/**
|
||||
* Movement in CSS pixels after which a recognizer decides whether a touch is
|
||||
* along its axis. Browsers hold back the first cancelable `touchmove` for
|
||||
* roughly 15–20px, so the decision is made before native scrolling can start.
|
||||
*/
|
||||
const AXIS_LOCK_DISTANCE = 6;
|
||||
|
||||
/** Resolve a definition's progress distance at pointer-down, in pixels. */
|
||||
export const resolveProgressDistance = (
|
||||
distance: OriginGestureProgressDistance | undefined,
|
||||
direction: OriginGestureDirection,
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
): (() => number) => {
|
||||
const horizontal = isHorizontal(direction);
|
||||
if (distance === undefined)
|
||||
return () => (horizontal ? host.clientWidth : host.clientHeight);
|
||||
const pixels =
|
||||
typeof distance === "function"
|
||||
? distance({ host, bounds })
|
||||
: resolveCssDistance(
|
||||
distance,
|
||||
horizontal ? "horizontal" : "vertical",
|
||||
host,
|
||||
bounds,
|
||||
);
|
||||
return () => pixels;
|
||||
};
|
||||
|
||||
/** The origin capabilities a recognizer needs. */
|
||||
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||
@@ -299,8 +339,16 @@ interface PointerSession {
|
||||
readonly start: OriginGesturePoint;
|
||||
readonly startTime: number;
|
||||
readonly tracker: DirectionalTracker;
|
||||
/**
|
||||
* Whether the movement so far is along this gesture's axis and direction
|
||||
* (`ours`) or still too small or too diagonal to tell (`pending`). A touch
|
||||
* that turns out to be someone else's closes the session instead.
|
||||
*/
|
||||
axis: "pending" | "ours";
|
||||
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||
captured: boolean;
|
||||
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
|
||||
armed: boolean;
|
||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | null;
|
||||
/**
|
||||
@@ -338,6 +386,14 @@ export class GestureRecognizer {
|
||||
private readonly origin: RecognizerOrigin,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether this recognizer currently holds the touch, so native scrolling
|
||||
* must not start: its session is open and has not ruled the touch out.
|
||||
*/
|
||||
get intercepts(): boolean {
|
||||
return this.session !== null;
|
||||
}
|
||||
|
||||
pointerDown(event: PointerEvent): void {
|
||||
if (!event.isPrimary) return;
|
||||
// Only one primary pointer exists at a time, so any session still open
|
||||
@@ -359,7 +415,6 @@ export class GestureRecognizer {
|
||||
|
||||
// The component containing this declaration is the operation's origin.
|
||||
event.stopPropagation();
|
||||
const horizontal = isHorizontal(definition.direction);
|
||||
this.session = {
|
||||
definition,
|
||||
pointerId: event.pointerId,
|
||||
@@ -368,10 +423,19 @@ export class GestureRecognizer {
|
||||
origin,
|
||||
start: pointOf(event, bounds),
|
||||
startTime: event.timeStamp,
|
||||
tracker: new DirectionalTracker(definition.direction, event, () =>
|
||||
horizontal ? host.clientWidth : host.clientHeight,
|
||||
tracker: new DirectionalTracker(
|
||||
definition.direction,
|
||||
event,
|
||||
resolveProgressDistance(
|
||||
definition.recognition.distance,
|
||||
definition.direction,
|
||||
host,
|
||||
bounds,
|
||||
),
|
||||
),
|
||||
axis: "pending",
|
||||
captured: false,
|
||||
armed: false,
|
||||
operation: null,
|
||||
handle: null,
|
||||
};
|
||||
@@ -382,11 +446,13 @@ export class GestureRecognizer {
|
||||
if (!session) return;
|
||||
|
||||
if (!session.captured) {
|
||||
if (!this.lockAxis(session, event)) return;
|
||||
if (!GestureRecognizer.recognizes(session, event)) return;
|
||||
session.captured = true;
|
||||
session.host.setPointerCapture?.(session.pointerId);
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
this.updateArming(session, event);
|
||||
const action = await this.resolveAction(session.definition);
|
||||
// The pointer may have been released or cancelled meanwhile.
|
||||
if (this.session !== session) return;
|
||||
@@ -408,13 +474,14 @@ export class GestureRecognizer {
|
||||
} else {
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
this.updateArming(session, event);
|
||||
}
|
||||
|
||||
// Progress is read after the await so the latest buffered sample wins.
|
||||
const pending = session.operation;
|
||||
const handle = pending ? await pending : null;
|
||||
if (this.session === session && session.operation === pending)
|
||||
handle?.update(session.tracker.progress, session.tracker.velocity);
|
||||
handle?.update(session.tracker.rawProgress, session.tracker.velocity);
|
||||
}
|
||||
|
||||
async pointerUp(event: PointerEvent): Promise<void> {
|
||||
@@ -424,23 +491,10 @@ export class GestureRecognizer {
|
||||
if (!session.captured) return;
|
||||
|
||||
const { tracker, definition } = session;
|
||||
tracker.sample(event, true);
|
||||
const context: OriginGestureCompletionContext = {
|
||||
origin: session.origin,
|
||||
direction: definition.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
crossDistance: tracker.crossDistance,
|
||||
duration: Math.max(0, event.timeStamp - session.startTime),
|
||||
event,
|
||||
host: session.host,
|
||||
bounds: session.bounds,
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
};
|
||||
tracker.sample(event);
|
||||
const context = GestureRecognizer.contextOf(session, event);
|
||||
const commit = definition.completion?.(context);
|
||||
const { progress, velocity } = tracker;
|
||||
const { rawProgress: progress, velocity } = tracker;
|
||||
|
||||
// Usually the operation began long ago: finish it without awaiting, so
|
||||
// commit handlers run inside this pointer-up (iOS only allows haptics,
|
||||
@@ -454,7 +508,7 @@ export class GestureRecognizer {
|
||||
commit === undefined ? undefined : { commit },
|
||||
);
|
||||
if (handle.intent === "commit")
|
||||
GestureRecognizer.notifyCommit(definition, context);
|
||||
GestureRecognizer.notify(definition.commitHandlers ?? [], context);
|
||||
await settled;
|
||||
}
|
||||
|
||||
@@ -483,6 +537,74 @@ export class GestureRecognizer {
|
||||
await handle?.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, early in the movement, whether this touch is along the gesture's
|
||||
* axis and direction. A touch that is clearly someone else's (the other
|
||||
* axis, or the opposite way) closes the session, which hands it to native
|
||||
* scrolling or to another recognizer.
|
||||
*
|
||||
* @returns Whether the session is still open.
|
||||
*/
|
||||
private lockAxis(session: PointerSession, event: PointerEvent): boolean {
|
||||
if (session.axis === "ours") return true;
|
||||
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||
const along = Math.abs(distance);
|
||||
if (Math.max(along, crossDistance) < AXIS_LOCK_DISTANCE) return true;
|
||||
const dominance =
|
||||
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
|
||||
if (along >= crossDistance * dominance && distance > 0) {
|
||||
session.axis = "ours";
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
along >= crossDistance * dominance ||
|
||||
crossDistance >= along * dominance
|
||||
) {
|
||||
this.session = null;
|
||||
return false;
|
||||
}
|
||||
return true; // Diagonal: keep watching.
|
||||
}
|
||||
|
||||
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
|
||||
private updateArming(session: PointerSession, event: PointerEvent) {
|
||||
const { definition, tracker } = session;
|
||||
const armHandlers = definition.armHandlers ?? [];
|
||||
const disarmHandlers = definition.disarmHandlers ?? [];
|
||||
if (!armHandlers.length && !disarmHandlers.length) return;
|
||||
const context = GestureRecognizer.contextOf(session, event);
|
||||
// Distance decides arming unless the definition supplies .complete():
|
||||
// velocity changes too quickly to drive a cue without flickering.
|
||||
const armed = definition.completion
|
||||
? definition.completion(context)
|
||||
: tracker.progress >=
|
||||
(definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
|
||||
if (armed === session.armed) return;
|
||||
session.armed = armed;
|
||||
GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context);
|
||||
}
|
||||
|
||||
private static contextOf(
|
||||
session: PointerSession,
|
||||
event: PointerEvent,
|
||||
): OriginGestureCompletionContext {
|
||||
const { tracker } = session;
|
||||
return {
|
||||
origin: session.origin,
|
||||
direction: session.definition.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
crossDistance: tracker.crossDistance,
|
||||
duration: Math.max(0, event.timeStamp - session.startTime),
|
||||
event,
|
||||
host: session.host,
|
||||
bounds: session.bounds,
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
};
|
||||
}
|
||||
|
||||
private sessionFor(event: PointerEvent) {
|
||||
const session = this.session;
|
||||
return session && session.pointerId === event.pointerId ? session : null;
|
||||
@@ -526,12 +648,12 @@ export class GestureRecognizer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||
private static notifyCommit(
|
||||
definition: OriginGestureDefinition,
|
||||
/** Run handlers in order; a failing handler never interrupts the others. */
|
||||
private static notify(
|
||||
handlers: readonly ((context: OriginGestureCompletionContext) => void)[],
|
||||
context: OriginGestureCompletionContext,
|
||||
) {
|
||||
for (const handler of definition.commitHandlers ?? []) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
@@ -603,6 +725,12 @@ export const useOriginGestures = (
|
||||
for (const recognizer of current())
|
||||
void recognizer.pointerCaptureLost(event);
|
||||
},
|
||||
onTouchmove: (event) => {
|
||||
// Native scrolling starts at the first touchmove not cancelled, so keep
|
||||
// cancelling while any recognizer still holds the touch.
|
||||
if (event.cancelable && current().some(({ intercepts }) => intercepts))
|
||||
event.preventDefault();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user