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:
Claude
2026-10-31 08:22:11 +00:00
parent d4d7feb3b7
commit 6889fc0112
20 changed files with 1547 additions and 163 deletions
+215 -87
View File
@@ -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();
},
};
};