Skip to content

Commit 6ebcb07

Browse files
authored
Merge pull request #536 from jsenv/route_travel
work
2 parents 9baf6e4 + 9d10119 commit 6ebcb07

56 files changed

Lines changed: 10165 additions & 2645 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/animations/SKILL.md

Lines changed: 281 additions & 18 deletions
Large diffs are not rendered by default.

packages/frontend/dom/dist/jsenv_dom.js

Lines changed: 2450 additions & 1244 deletions
Large diffs are not rendered by default.

packages/frontend/dom/index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,20 @@ export { trapScrollInside } from "./src/interaction/scroll/scroll_trap.js";
112112
export { measureScrollbar } from "./src/interaction/scroll/scrollbar_size.js";
113113
export { allowWheelThrough } from "./src/interaction/scroll/wheel_through.js";
114114
// interaction/drag
115+
export { dragAfterIntent } from "./src/interaction/drag/drag_after_intent.js";
115116
export { getDragCoordinates } from "./src/interaction/drag/drag_element_positioner.js";
116117
export {
117118
createDragGestureController,
118-
dragAfterThreshold,
119+
isPrimaryButtonEvent,
119120
} from "./src/interaction/drag/drag_gesture.js";
120121
export { createDragToMoveGestureController } from "./src/interaction/drag/drag_to_move.js";
121122
export { startDragToReorder } from "./src/interaction/drag/drag_to_reorder.js";
122123
export { startDragToResizeGesture } from "./src/interaction/drag/drag_to_resize_gesture.js";
124+
export {
125+
scrollRoomTowards,
126+
startDragToTravel,
127+
watchWheelTravel,
128+
} from "./src/interaction/drag/drag_to_travel.js";
123129
export { getDropTargetInfo } from "./src/interaction/drag/drop_target_detection.js";
124130

125131
// position

packages/frontend/dom/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jsenv/dom",
3-
"version": "0.17.5",
3+
"version": "0.17.6",
44
"type": "module",
55
"description": "DOM utilities for writing frontend code",
66
"repository": {
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* When a press becomes a drag.
3+
*
4+
* A pointer going down on a draggable element is ambiguous — it may be a click,
5+
* a text selection, a scroll, or a drag — and starting the gesture right away
6+
* would steal all the others. This module owns the wait that resolves the
7+
* ambiguity, and only then hands over to the real gesture.
8+
*
9+
* There is one gesture, with a trigger per pointer:
10+
* - a dedicated handle ([data-drag-handle]) says it outright: drag on contact
11+
* - a mouse resolves it by distance — a mouse scrolls with its wheel, so travel
12+
* can only mean drag
13+
* - a finger resolves it by time — travel is exactly what a scroll looks like,
14+
* so the only unambiguous signal left is a finger that does NOT move
15+
*
16+
* Whichever trigger fired, it has established the intent: the gesture then
17+
* starts at the first pixel, without a second threshold to cross.
18+
*/
19+
20+
import {
21+
createDragGestureController,
22+
isPrimaryButtonEvent,
23+
} from "./drag_gesture.js";
24+
25+
/* iOS shows its callout (Copy / Look Up) and selects the text under the finger
26+
on a long press, and does not always route that through an event that can be
27+
refused — see preventContextMenu below for the half that is an event.
28+
At module scope, and on the markers rather than on the pressed element: it has
29+
to be true before the finger lands. */
30+
const css = /* css */ `
31+
[data-drag-handle],
32+
[data-drag-source] {
33+
-webkit-touch-callout: none;
34+
}
35+
[data-drag-ignore] {
36+
-webkit-touch-callout: default;
37+
}
38+
`;
39+
import.meta.css = css;
40+
41+
/**
42+
* Waits for the user to mean it, then starts a drag gesture.
43+
*
44+
* @param {PointerEvent} grabEvent
45+
* The `pointerdown` event that may become a drag.
46+
* @param {function} dragGestureInitializer
47+
* Called once the intent is established; must create and return the real drag
48+
* gesture (typically via `grabViaPointer(grabEvent)`). Returning a falsy value
49+
* aborts the gesture.
50+
* @param {object} [options]
51+
* @param {number} [options.threshold=5]
52+
* Distance (px) the pointer must travel to start a drag, when the trigger is
53+
* distance-based.
54+
* @param {boolean|"if-touch"} [options.longPress="if-touch"]
55+
* Which pointers start a drag by holding still instead of by travelling.
56+
* @param {number} [options.longPressDelay=400]
57+
* How long (ms) the pointer must stay down. Kept under the system context-menu
58+
* delay so the object is picked up before the menu would have opened.
59+
* @param {number} [options.longPressSlop=8]
60+
* How far (px) the pointer may drift during the wait before the press is
61+
* abandoned — beyond it, the finger is scrolling, not holding.
62+
* @param {function} [options.onPressStart]
63+
* The pointer went down and the wait began (a cue that the press counts).
64+
* @param {function} [options.onPressCancel]
65+
* The pointer moved or lifted before the wait was over.
66+
* @param {function} [options.onPress]
67+
* The wait completed and the object is now held (haptics, scale…).
68+
*/
69+
export const dragAfterIntent = (
70+
grabEvent,
71+
dragGestureInitializer,
72+
{
73+
threshold = 5,
74+
longPress = "if-touch",
75+
longPressDelay = 400,
76+
longPressSlop = 8,
77+
onPressStart,
78+
onPressCancel,
79+
onPress,
80+
} = {},
81+
) => {
82+
if (!isPrimaryButtonEvent(grabEvent)) {
83+
return;
84+
}
85+
const target = grabEvent.target;
86+
const isDedicatedHandle =
87+
target.closest && target.closest("[data-drag-handle]");
88+
if (isDedicatedHandle) {
89+
startDragGesture(dragGestureInitializer);
90+
return;
91+
}
92+
const startsOnLongPress =
93+
longPress === true ||
94+
(longPress === "if-touch" && grabEvent.pointerType === "touch");
95+
if (startsOnLongPress) {
96+
dragAfterLongPress(grabEvent, dragGestureInitializer, {
97+
longPressDelay,
98+
longPressSlop,
99+
onPressStart,
100+
onPressCancel,
101+
onPress,
102+
});
103+
return;
104+
}
105+
dragAfterDistance(grabEvent, dragGestureInitializer, threshold);
106+
};
107+
108+
const startDragGesture = (dragGestureInitializer, catchUpEvent) => {
109+
const dragGesture = dragGestureInitializer();
110+
if (!dragGesture) {
111+
return null;
112+
}
113+
// The wait is what established the intent; a distance threshold on top of it
114+
// would ask the user to prove the same thing twice.
115+
dragGesture.start();
116+
if (catchUpEvent) {
117+
dragGesture.dragViaPointer(catchUpEvent);
118+
}
119+
return dragGesture;
120+
};
121+
122+
const dragAfterDistance = (grabEvent, dragGestureInitializer, threshold) => {
123+
const significantDragGestureController = createDragGestureController({
124+
threshold,
125+
// allow interaction for this intermediate gesture:
126+
// user should still be able to scroll or interact with the document
127+
// only once the gesture is significant we take control
128+
documentInteractions: "manual",
129+
onDragStart: (gestureInfo) => {
130+
significantDragGesture.release(); // kill that gesture
131+
startDragGesture(dragGestureInitializer, gestureInfo.dragEvent);
132+
},
133+
});
134+
const significantDragGesture =
135+
significantDragGestureController.grabViaPointer(grabEvent, {
136+
element: grabEvent.target,
137+
});
138+
};
139+
140+
const dragAfterLongPress = (
141+
grabEvent,
142+
dragGestureInitializer,
143+
{ longPressDelay, longPressSlop, onPressStart, onPressCancel, onPress },
144+
) => {
145+
const { pointerId, clientX, clientY } = grabEvent;
146+
147+
const pressCleanupCallbacks = [];
148+
const endPress = () => {
149+
for (const pressCleanupCallback of pressCleanupCallbacks) {
150+
pressCleanupCallback();
151+
}
152+
pressCleanupCallbacks.length = 0;
153+
};
154+
155+
/*
156+
* A press held long enough IS the system's context-menu gesture: Android opens
157+
* its menu around 500ms, iOS its callout — both a tenth of a second after the
158+
* object has been picked up, landing on top of something the finger is already
159+
* carrying.
160+
* The listener goes on window, in capture: once the gesture runs, the drag
161+
* backdrop covers the page, so the contextmenu event is aimed at the backdrop
162+
* and never reaches the element being dragged.
163+
* It is removed on release — a right click with a mouse remains a right click.
164+
*/
165+
const preventContextMenu = (contextMenuEvent) => {
166+
contextMenuEvent.preventDefault();
167+
};
168+
window.addEventListener("contextmenu", preventContextMenu, true);
169+
pressCleanupCallbacks.push(() => {
170+
window.removeEventListener("contextmenu", preventContextMenu, true);
171+
});
172+
173+
const countdownCleanupCallbacks = [];
174+
const endCountdown = () => {
175+
for (const countdownCleanupCallback of countdownCleanupCallbacks) {
176+
countdownCleanupCallback();
177+
}
178+
countdownCleanupCallbacks.length = 0;
179+
};
180+
181+
const timeout = setTimeout(() => {
182+
endCountdown();
183+
onPress?.(grabEvent);
184+
// Scrolling is taken away by the gesture itself, from the moment it starts
185+
// (see markAsStarted in drag_gesture.js) — one place refuses the touchmove,
186+
// for every way a drag can begin.
187+
const dragGesture = startDragGesture(dragGestureInitializer);
188+
if (!dragGesture) {
189+
endPress();
190+
return;
191+
}
192+
dragGesture.addReleaseCallback(endPress);
193+
}, longPressDelay);
194+
countdownCleanupCallbacks.push(() => {
195+
clearTimeout(timeout);
196+
});
197+
198+
const cancelPress = (pointerEvent) => {
199+
endCountdown();
200+
endPress();
201+
onPressCancel?.(pointerEvent);
202+
};
203+
const onPointerMove = (pointerMoveEvent) => {
204+
if (pointerMoveEvent.pointerId !== pointerId) {
205+
return;
206+
}
207+
const xDrift = Math.abs(pointerMoveEvent.clientX - clientX);
208+
const yDrift = Math.abs(pointerMoveEvent.clientY - clientY);
209+
if (xDrift < longPressSlop && yDrift < longPressSlop) {
210+
return;
211+
}
212+
// The finger is going somewhere: it is scrolling the page, or running down
213+
// the list. Letting the countdown survive would unhook an object in passing.
214+
cancelPress(pointerMoveEvent);
215+
};
216+
const onPointerEnd = (pointerEndEvent) => {
217+
if (pointerEndEvent.pointerId !== pointerId) {
218+
return;
219+
}
220+
cancelPress(pointerEndEvent);
221+
};
222+
// On window rather than on the element: the finger can leave it, and the
223+
// element itself can be taken out of the document while the press is waiting.
224+
window.addEventListener("pointermove", onPointerMove);
225+
window.addEventListener("pointerup", onPointerEnd);
226+
window.addEventListener("pointercancel", onPointerEnd);
227+
countdownCleanupCallbacks.push(() => {
228+
window.removeEventListener("pointermove", onPointerMove);
229+
window.removeEventListener("pointerup", onPointerEnd);
230+
window.removeEventListener("pointercancel", onPointerEnd);
231+
});
232+
233+
onPressStart?.(grabEvent);
234+
};

0 commit comments

Comments
 (0)