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
+140
View File
@@ -0,0 +1,140 @@
import { createApp, defineComponent, h, ref } from "vue";
import {
OriginGesture,
OriginGestureSurface,
OriginScene,
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
originView,
slideLeft,
useOrigin,
} from "../../../src";
declare global {
interface Window {
log: string[];
inPointerup: boolean;
scene: ReturnType<typeof createOriginScene>;
scrollTopOf: (selector: string) => number;
begin: (name: "matched" | "mismatched") => Promise<void>;
}
}
window.log = [];
window.inPointerup = false;
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
window.addEventListener("pointerup", () =>
setTimeout(() => (window.inPointerup = false)),
);
const warn = console.warn;
console.warn = (...args: unknown[]) => {
window.log.push("warn: " + String(args[0]));
warn(...args);
};
const rows = (n: number) =>
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
const Detail = defineComponent({
name: "Detail",
setup() {
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((c) => (c.canGoBack ? back() : null))
.onCommit(() => window.log.push("dismiss committed"))
.animate(
defineOriginChoreography({
name: "dismiss",
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 100}%)` },
}),
}),
);
return () =>
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
);
},
});
const Home = defineComponent({
name: "Home",
setup() {
const open = gesture.to
.left()
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
.onCommit(() =>
window.log.push(`open committed inPointerup=${window.inPointerup}`),
)
.animate(slideLeft);
return () =>
h(
OriginGestureSurface,
{ gestures: [open], class: "page", id: "home" },
() => h("div", { class: "list", id: "home-list" }, rows(40)),
);
},
});
// Continuity check pair: a drawer that rests at 80% and a close that starts
// either from the same pose (matched) or from 0 (mismatched).
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 80}%)` },
}),
});
const closes = {
matched: defineOriginChoreography({
name: "drawer-close-matched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 80}%)` },
}),
}),
mismatched: defineOriginChoreography({
name: "drawer-close-mismatched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 50}%)` },
}),
}),
};
const Drawer = defineComponent({
name: "Drawer",
render: () => h("div", { class: "page" }, "drawer"),
});
const Plain = defineComponent({
name: "Plain",
setup() {
const origin = useOrigin();
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
() =>
origin.perform(
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
);
return () => h("div", { class: "page", id: "plain" }, "plain");
},
});
const params = new URLSearchParams(location.search);
const initial = params.get("view") === "plain" ? Plain : Home;
const scene = createOriginScene({
initial: originView(initial, undefined, { key: "root" }),
});
window.scene = scene;
window.scrollTopOf = (selector) =>
document.querySelector(selector)?.scrollTop ?? -1;
window.begin = async (name) => {
await (
window as unknown as { openDrawer: () => Promise<boolean> }
).openDrawer();
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
const handle = await scene.begin(drawerKey, back(closes[name]));
await handle.cancel({ animate: false });
await handle.finish({ commit: false, animate: false });
};
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");