583 lines
17 KiB
TypeScript
583 lines
17 KiB
TypeScript
import type {
|
|
MaybeOriginAction,
|
|
OriginAction,
|
|
OriginContext,
|
|
OriginGestureBinding,
|
|
OriginGestureBuilder,
|
|
OriginGestureCompletionContext,
|
|
OriginGestureCompletionPredicate,
|
|
OriginGestureDefinition,
|
|
OriginGestureDirection,
|
|
OriginGestureDirectionOptions,
|
|
OriginGestureDistance,
|
|
OriginGestureEdge,
|
|
OriginGestureFromBuilder,
|
|
OriginGestureFromSelection,
|
|
OriginGestureNavigationBuilder,
|
|
OriginGestureNavigationFactory,
|
|
OriginGestureOptions,
|
|
OriginGesturePoint,
|
|
OriginGestureStart,
|
|
OriginGestureStartContext,
|
|
OriginGestureStartPredicate,
|
|
OriginGestureToBuilder,
|
|
OriginOperationHandle,
|
|
OriginRect,
|
|
} from "./types";
|
|
import { useOrigin } from "./lifecycle";
|
|
|
|
function ignoreGestureTarget(target: EventTarget | null) {
|
|
return (
|
|
!(target instanceof Element) ||
|
|
Boolean(
|
|
target.closest(
|
|
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
function directedDistance(
|
|
direction: OriginGestureDirection,
|
|
dx: number,
|
|
dy: number,
|
|
) {
|
|
switch (direction) {
|
|
case "left":
|
|
return -dx;
|
|
case "right":
|
|
return dx;
|
|
case "up":
|
|
return -dy;
|
|
case "down":
|
|
return dy;
|
|
}
|
|
}
|
|
|
|
function rectOf(element: HTMLElement): OriginRect {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}
|
|
|
|
function pointOf(
|
|
event: Pick<PointerEvent, "clientX" | "clientY">,
|
|
bounds: OriginRect,
|
|
): OriginGesturePoint {
|
|
return {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
localX: event.clientX - bounds.left,
|
|
localY: event.clientY - bounds.top,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
|
*
|
|
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
|
|
* percentages, `calc()`, and `clamp()` consistently. This runs only during
|
|
* pointer-down for edge-constrained definitions.
|
|
*/
|
|
function resolveCssDistance(
|
|
distance: OriginGestureDistance,
|
|
axis: "horizontal" | "vertical",
|
|
host: HTMLElement,
|
|
bounds: OriginRect,
|
|
) {
|
|
if (typeof distance === "number")
|
|
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
|
|
|
|
const document = host.ownerDocument;
|
|
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
|
|
|
const container = document.createElement("div");
|
|
const probe = document.createElement("div");
|
|
Object.assign(container.style, {
|
|
position: "fixed",
|
|
left: "-100000px",
|
|
top: "-100000px",
|
|
width: `${bounds.width}px`,
|
|
height: `${bounds.height}px`,
|
|
visibility: "hidden",
|
|
pointerEvents: "none",
|
|
contain: "strict",
|
|
});
|
|
Object.assign(probe.style, {
|
|
position: "absolute",
|
|
width: axis === "horizontal" ? distance : "0",
|
|
height: axis === "vertical" ? distance : "0",
|
|
});
|
|
container.append(probe);
|
|
document.body.append(container);
|
|
const resolved =
|
|
axis === "horizontal"
|
|
? probe.getBoundingClientRect().width
|
|
: probe.getBoundingClientRect().height;
|
|
container.remove();
|
|
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
|
|
}
|
|
|
|
function matchesStart(
|
|
start: OriginGestureStart,
|
|
event: PointerEvent,
|
|
host: HTMLElement,
|
|
origin: OriginContext,
|
|
bounds: OriginRect,
|
|
) {
|
|
if (start.kind === "anywhere") return true;
|
|
const point = pointOf(event, bounds);
|
|
if (start.kind === "when") {
|
|
const context: OriginGestureStartContext = {
|
|
event,
|
|
origin,
|
|
host,
|
|
bounds,
|
|
point,
|
|
};
|
|
return start.predicate(context);
|
|
}
|
|
|
|
const horizontal = start.edge === "left" || start.edge === "right";
|
|
const distance = resolveCssDistance(
|
|
start.distance,
|
|
horizontal ? "horizontal" : "vertical",
|
|
host,
|
|
bounds,
|
|
);
|
|
switch (start.edge) {
|
|
case "left":
|
|
return point.localX >= 0 && point.localX <= distance;
|
|
case "right":
|
|
return (
|
|
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
|
);
|
|
case "top":
|
|
return point.localY >= 0 && point.localY <= distance;
|
|
case "bottom":
|
|
return (
|
|
point.localY <= bounds.height &&
|
|
bounds.height - point.localY <= distance
|
|
);
|
|
}
|
|
}
|
|
|
|
function edgeStart(
|
|
edge: OriginGestureEdge,
|
|
distance: OriginGestureDistance,
|
|
): OriginGestureStart {
|
|
return Object.freeze({ kind: "edge", edge, distance });
|
|
}
|
|
|
|
function createNavigationBuilder(
|
|
start: OriginGestureStart,
|
|
direction: OriginGestureDirection,
|
|
recognition: Readonly<OriginGestureDirectionOptions>,
|
|
completion: OriginGestureCompletionPredicate | undefined,
|
|
navigation: OriginGestureNavigationFactory,
|
|
): OriginGestureNavigationBuilder {
|
|
return Object.freeze({
|
|
animate(choreography: OriginGestureDefinition["choreography"]) {
|
|
return Object.freeze({
|
|
kind: "origin-gesture-definition",
|
|
start,
|
|
direction,
|
|
recognition,
|
|
completion,
|
|
navigation,
|
|
choreography,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
function createDirectedBuilder(
|
|
start: OriginGestureStart,
|
|
direction: OriginGestureDirection,
|
|
options: OriginGestureDirectionOptions = {},
|
|
) {
|
|
const recognition = Object.freeze({ ...options });
|
|
const navigate = (
|
|
navigation: OriginGestureNavigationFactory,
|
|
completion?: OriginGestureCompletionPredicate,
|
|
) =>
|
|
createNavigationBuilder(
|
|
start,
|
|
direction,
|
|
recognition,
|
|
completion,
|
|
navigation,
|
|
);
|
|
|
|
return Object.freeze({
|
|
complete(completion: OriginGestureCompletionPredicate) {
|
|
return Object.freeze({
|
|
navigate: (navigation: OriginGestureNavigationFactory) =>
|
|
navigate(navigation, completion),
|
|
});
|
|
},
|
|
navigate,
|
|
});
|
|
}
|
|
|
|
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
|
|
return Object.freeze({
|
|
left: (options?: OriginGestureDirectionOptions) =>
|
|
createDirectedBuilder(start, "left", options),
|
|
right: (options?: OriginGestureDirectionOptions) =>
|
|
createDirectedBuilder(start, "right", options),
|
|
up: (options?: OriginGestureDirectionOptions) =>
|
|
createDirectedBuilder(start, "up", options),
|
|
down: (options?: OriginGestureDirectionOptions) =>
|
|
createDirectedBuilder(start, "down", options),
|
|
});
|
|
}
|
|
|
|
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
|
|
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
|
|
}
|
|
|
|
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
|
left: (distance: OriginGestureDistance) =>
|
|
selectStart(edgeStart("left", distance)),
|
|
right: (distance: OriginGestureDistance) =>
|
|
selectStart(edgeStart("right", distance)),
|
|
top: (distance: OriginGestureDistance) =>
|
|
selectStart(edgeStart("top", distance)),
|
|
bottom: (distance: OriginGestureDistance) =>
|
|
selectStart(edgeStart("bottom", distance)),
|
|
anywhere: () => selectStart({ kind: "anywhere" }),
|
|
when: (predicate: OriginGestureStartPredicate) =>
|
|
selectStart({ kind: "when", predicate }),
|
|
});
|
|
|
|
/**
|
|
* Root of the immutable gesture builder.
|
|
*
|
|
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
|
|
* bound host, exactly like `.from.anywhere().to`.
|
|
*
|
|
* @example Anywhere-to-right back gesture
|
|
* ```ts
|
|
* const swipeBack = gesture
|
|
* .to.right()
|
|
* .navigate((context) => context.canGoBack ? back() : null)
|
|
* .animate(slideRight);
|
|
* ```
|
|
*
|
|
* @example Predicate-gated gesture with custom completion
|
|
* ```ts
|
|
* const openPanel = gesture
|
|
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
|
|
* .to.down()
|
|
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
|
|
* .navigate(() => above(originView(PanelView)))
|
|
* .animate(dropPanel);
|
|
* ```
|
|
*/
|
|
export const gesture: OriginGestureBuilder = Object.freeze({
|
|
from: fromBuilder,
|
|
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
|
|
});
|
|
|
|
function isDefinition(
|
|
value: OriginGestureOptions | OriginGestureDefinition,
|
|
): value is OriginGestureDefinition {
|
|
return "kind" in value && value.kind === "origin-gesture-definition";
|
|
}
|
|
|
|
interface RuntimeGesturePolicy {
|
|
readonly direction: OriginGestureDirection;
|
|
readonly start: OriginGestureStart;
|
|
readonly threshold: number;
|
|
readonly axisDominance: number;
|
|
readonly completion?: OriginGestureCompletionPredicate;
|
|
readonly action: (context: OriginContext) => MaybeOriginAction;
|
|
}
|
|
|
|
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
|
|
if (options.edge === undefined) return { kind: "anywhere" };
|
|
switch (options.direction) {
|
|
case "left":
|
|
return edgeStart("right", options.edge);
|
|
case "right":
|
|
return edgeStart("left", options.edge);
|
|
case "up":
|
|
return edgeStart("bottom", options.edge);
|
|
case "down":
|
|
return edgeStart("top", options.edge);
|
|
}
|
|
}
|
|
|
|
function runtimePolicy(
|
|
options: OriginGestureOptions | OriginGestureDefinition,
|
|
): RuntimeGesturePolicy {
|
|
if (!isDefinition(options)) {
|
|
return {
|
|
direction: options.direction,
|
|
start: legacyStart(options),
|
|
threshold: options.threshold ?? 8,
|
|
axisDominance: 1.15,
|
|
action: options.action,
|
|
};
|
|
}
|
|
|
|
return {
|
|
direction: options.direction,
|
|
start: options.start,
|
|
threshold: options.recognition.threshold ?? 8,
|
|
axisDominance: options.recognition.axisDominance ?? 1.15,
|
|
completion: options.completion,
|
|
action: async (context) => {
|
|
const navigation = await options.navigation(context);
|
|
if (!navigation) return navigation;
|
|
const action: OriginAction = {
|
|
...navigation,
|
|
choreography: options.choreography,
|
|
};
|
|
return action;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Install a component-owned pointer recognizer.
|
|
*
|
|
* Recognition is local to the element receiving these handlers. There is no
|
|
* application-wide gesture table and no lookup of a currently active view.
|
|
*
|
|
* Builder definitions may independently describe their pointer-down region
|
|
* and movement direction. Omitting `.from` recognizes pointer-down across the
|
|
* whole element. The legacy options object remains supported; its `edge` is
|
|
* inferred from the opposite side of its movement direction.
|
|
*
|
|
* Interactive controls and anything inside
|
|
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
|
|
* preserves native scrolling on the cross-axis through its returned style.
|
|
*
|
|
* @param definition - An immutable builder result or legacy recognizer options.
|
|
* @returns Pointer handlers and required host styles.
|
|
* @throws If called outside a component rendered by `OriginScene`.
|
|
*
|
|
* @example Builder-defined backward gesture
|
|
* ```ts
|
|
* const swipeBack = useOriginGesture(
|
|
* gesture
|
|
* .from.left("32px")
|
|
* .to.right()
|
|
* .navigate((context) => context.canGoBack ? back() : null)
|
|
* .animate(slideRight),
|
|
* );
|
|
* ```
|
|
*/
|
|
export function useOriginGesture(
|
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
|
): OriginGestureBinding {
|
|
const origin = useOrigin();
|
|
const policy = runtimePolicy(definition);
|
|
let pointerId = -1;
|
|
let element: HTMLElement | null = null;
|
|
let bounds: OriginRect | null = null;
|
|
let originContext: OriginContext | null = null;
|
|
let startPoint: OriginGesturePoint | null = null;
|
|
let startTime = 0;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let lastCoordinate = 0;
|
|
let lastTime = 0;
|
|
let captured = false;
|
|
let generation = 0;
|
|
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
|
|
let bufferedProgress = 0;
|
|
let bufferedVelocity = 0;
|
|
let bufferedDistance = 0;
|
|
let bufferedCrossDistance = 0;
|
|
|
|
const horizontal =
|
|
policy.direction === "left" || policy.direction === "right";
|
|
|
|
function reset() {
|
|
pointerId = -1;
|
|
element = null;
|
|
bounds = null;
|
|
originContext = null;
|
|
startPoint = null;
|
|
captured = false;
|
|
handlePromise = null;
|
|
bufferedProgress = 0;
|
|
bufferedVelocity = 0;
|
|
bufferedDistance = 0;
|
|
bufferedCrossDistance = 0;
|
|
}
|
|
|
|
function onPointerdown(event: PointerEvent) {
|
|
const current = event.currentTarget;
|
|
if (
|
|
!event.isPrimary ||
|
|
event.button !== 0 ||
|
|
!(current instanceof HTMLElement) ||
|
|
ignoreGestureTarget(event.target)
|
|
)
|
|
return;
|
|
|
|
const nextBounds = rectOf(current);
|
|
const nextOriginContext = origin.context.value;
|
|
if (
|
|
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
|
|
)
|
|
return;
|
|
|
|
// The component containing this declaration is the operation's origin.
|
|
event.stopPropagation();
|
|
generation += 1;
|
|
pointerId = event.pointerId;
|
|
element = current;
|
|
bounds = nextBounds;
|
|
originContext = nextOriginContext;
|
|
startPoint = pointOf(event, nextBounds);
|
|
startTime = event.timeStamp;
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
lastCoordinate = horizontal ? event.clientX : event.clientY;
|
|
lastTime = event.timeStamp;
|
|
captured = false;
|
|
handlePromise = null;
|
|
}
|
|
|
|
function updateMetrics(event: PointerEvent, release = false) {
|
|
if (!element) return;
|
|
const dx = event.clientX - startX;
|
|
const dy = event.clientY - startY;
|
|
bufferedDistance = directedDistance(policy.direction, dx, dy);
|
|
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
|
const size = Math.max(
|
|
1,
|
|
horizontal ? element.clientWidth : element.clientHeight,
|
|
);
|
|
const coordinate = horizontal ? event.clientX : event.clientY;
|
|
const coordinateDelta =
|
|
policy.direction === "left" || policy.direction === "up"
|
|
? lastCoordinate - coordinate
|
|
: coordinate - lastCoordinate;
|
|
const rawElapsed = event.timeStamp - lastTime;
|
|
const elapsed = Math.max(8, rawElapsed);
|
|
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / 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 || coordinateDelta !== 0 || rawElapsed > 80)
|
|
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
|
|
lastCoordinate = coordinate;
|
|
lastTime = event.timeStamp;
|
|
}
|
|
|
|
async function onPointermove(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId || !element) return;
|
|
const dx = event.clientX - startX;
|
|
const dy = event.clientY - startY;
|
|
const distance = directedDistance(policy.direction, dx, dy);
|
|
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
|
let metricsUpdated = false;
|
|
|
|
if (!captured) {
|
|
if (
|
|
distance < policy.threshold ||
|
|
distance < crossDistance * policy.axisDominance
|
|
)
|
|
return;
|
|
captured = true;
|
|
element.setPointerCapture?.(pointerId);
|
|
event.preventDefault();
|
|
updateMetrics(event);
|
|
metricsUpdated = true;
|
|
const recognitionGeneration = generation;
|
|
const action = await Promise.resolve(
|
|
policy.action(origin.context.value),
|
|
).catch(() => null);
|
|
// An asynchronous target resolver may finish after the pointer was
|
|
// released or cancelled. It must not create an orphan scene operation.
|
|
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
|
|
return;
|
|
if (!action) return reset();
|
|
handlePromise = origin.begin(action).catch(() => null);
|
|
}
|
|
|
|
event.preventDefault();
|
|
if (!metricsUpdated) updateMetrics(event);
|
|
const pending = handlePromise;
|
|
const handle = pending ? await pending : null;
|
|
if (pending === handlePromise)
|
|
handle?.update(bufferedProgress, bufferedVelocity);
|
|
}
|
|
|
|
async function onPointerup(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
if (captured) updateMetrics(event, true);
|
|
|
|
const pending = handlePromise;
|
|
const shouldFinish = captured;
|
|
const progress = bufferedProgress;
|
|
const velocity = bufferedVelocity;
|
|
const completion =
|
|
shouldFinish &&
|
|
policy.completion &&
|
|
element &&
|
|
bounds &&
|
|
originContext &&
|
|
startPoint
|
|
? policy.completion({
|
|
origin: originContext,
|
|
direction: policy.direction,
|
|
progress,
|
|
velocity,
|
|
distance: bufferedDistance,
|
|
crossDistance: bufferedCrossDistance,
|
|
duration: Math.max(0, event.timeStamp - startTime),
|
|
event,
|
|
host: element,
|
|
bounds,
|
|
start: startPoint,
|
|
current: pointOf(event, bounds),
|
|
} satisfies OriginGestureCompletionContext)
|
|
: undefined;
|
|
generation += 1;
|
|
reset();
|
|
|
|
const handle = pending ? await pending : null;
|
|
if (!shouldFinish || !handle) return;
|
|
handle.update(progress, velocity);
|
|
await handle.finish(
|
|
completion === undefined ? undefined : { commit: completion },
|
|
);
|
|
}
|
|
|
|
async function onPointercancel() {
|
|
generation += 1;
|
|
const pending = handlePromise;
|
|
const shouldCancel = captured;
|
|
reset();
|
|
const handle = pending ? await pending : null;
|
|
if (shouldCancel) await handle?.cancel();
|
|
}
|
|
|
|
return {
|
|
style: {
|
|
// Preserve native scrolling perpendicular to the declared gesture.
|
|
touchAction: horizontal ? "pan-y" : "pan-x",
|
|
// OriginGesture is commonly the root returned by a view component.
|
|
width: "100%",
|
|
height: "100%",
|
|
},
|
|
onPointerdown,
|
|
onPointermove: (event) => void onPointermove(event),
|
|
onPointerup: (event) => void onPointerup(event),
|
|
onPointercancel: () => void onPointercancel(),
|
|
};
|
|
}
|