@gjsify/adwaita-web 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -34,6 +34,8 @@ export declare class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
34
34
|
* index while the target page stays the target.
|
|
35
35
|
*/
|
|
36
36
|
private _scrollTargetId;
|
|
37
|
+
/** The pointer-drag gesture, asked whether it owns the strip. See `_onWheel`. */
|
|
38
|
+
private _drag;
|
|
37
39
|
static get observedAttributes(): string[];
|
|
38
40
|
constructor();
|
|
39
41
|
get nPages(): number;
|
|
@@ -47,6 +49,12 @@ export declare class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
47
49
|
*/
|
|
48
50
|
set position(value: number);
|
|
49
51
|
get currentPage(): number;
|
|
52
|
+
/**
|
|
53
|
+
* `AdwCarousel:allow-mouse-drag` — whether the strip can be dragged with the pointer.
|
|
54
|
+
* Defaults to TRUE, as in libadwaita, where FALSE leaves dragging to touch only.
|
|
55
|
+
*/
|
|
56
|
+
get allowMouseDrag(): boolean;
|
|
57
|
+
set allowMouseDrag(value: boolean);
|
|
50
58
|
/** Whether the scroll wheel navigates between pages. Defaults to TRUE, as in libadwaita. */
|
|
51
59
|
get allowScrollWheel(): boolean;
|
|
52
60
|
set allowScrollWheel(value: boolean);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Which way the widget pages, and therefore which axis claims a drag. */
|
|
2
|
+
export type AdwSwipeOrientation = 'horizontal' | 'vertical';
|
|
3
|
+
export interface AdwSwipeDragInit {
|
|
4
|
+
/** The element the listeners sit on. Items rebuilt under it stay covered. */
|
|
5
|
+
host: HTMLElement;
|
|
6
|
+
orientation: AdwSwipeOrientation;
|
|
7
|
+
/** Re-read on every press: may a mouse drag start right now? */
|
|
8
|
+
enabled: () => boolean;
|
|
9
|
+
/** Page pitch in px. The raw pixel delta is divided by it to reach progress. */
|
|
10
|
+
pitch: () => number;
|
|
11
|
+
/** Snap points, ascending, as `adw_swipeable_get_snap_points` returns them. */
|
|
12
|
+
points: () => readonly number[];
|
|
13
|
+
/** Progress now — the gesture is seeded from it and bounded by it. */
|
|
14
|
+
progress: () => number;
|
|
15
|
+
/** `AdwCarousel:allow-long-swipes`. */
|
|
16
|
+
allowLongSwipes: () => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* `adw_swipeable_get_cancel_progress` — where a CANCELLED gesture goes.
|
|
19
|
+
*
|
|
20
|
+
* Not optional by accident: the tracker's own fallback is where the gesture began,
|
|
21
|
+
* and `AdwCarousel`'s answer is `get_closest_snap_point` — the point nearest where
|
|
22
|
+
* the finger is NOW (adw-carousel.c:1294-1299). The two differ for exactly the
|
|
23
|
+
* gesture that matters, one cancelled past the halfway mark: upstream commits to the
|
|
24
|
+
* page under the cursor, the fallback yanks it back.
|
|
25
|
+
*/
|
|
26
|
+
cancelProgress: () => number;
|
|
27
|
+
/** Show this progress. Called on every move that the gesture owns. */
|
|
28
|
+
onUpdate: (progress: number) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a gesture may be claimed at all right now, on top of the refusals this
|
|
31
|
+
* module makes itself. `AdwCarousel` needs none — its `interactive` and
|
|
32
|
+
* `allow-mouse-drag` are {@link enabled} — but `is_in_swipe_area` lives here for the
|
|
33
|
+
* widgets that implement `get_swipe_area`, which the carousel does not.
|
|
34
|
+
*/
|
|
35
|
+
claimable?: (start: {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
}) => boolean;
|
|
39
|
+
/** Settle on this progress. `velocity` is raw px/ms, for an animation duration. */
|
|
40
|
+
onEnd: (progress: number, velocity: number) => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Make `host` draggable with a mouse, deciding where it lands the way `AdwSwipeTracker`
|
|
44
|
+
* does.
|
|
45
|
+
*
|
|
46
|
+
* The listener is the element's own and is bound once, without teardown: it survives a
|
|
47
|
+
* re-parent and is collected with the element.
|
|
48
|
+
*/
|
|
49
|
+
/** What a caller gets back, so it can tell whether a gesture owns the widget. */
|
|
50
|
+
export interface AdwSwipeDrag {
|
|
51
|
+
/**
|
|
52
|
+
* Whether a gesture is currently claimed.
|
|
53
|
+
*
|
|
54
|
+
* A widget needs this to keep its OTHER inputs out of the offset while a drag owns
|
|
55
|
+
* it. Measured on `<adw-carousel>`: one wheel notch during a claimed drag ran the
|
|
56
|
+
* whole wheel path and jumped `scrollLeft` 80 → 440 in a frame, which the next move
|
|
57
|
+
* then yanked back. Upstream's `scroll_cb` is ungated too, but there the external
|
|
58
|
+
* request is a spring the next frame quietly overrides — here `claim()` sets
|
|
59
|
+
* `scroll-behavior: auto`, which turns it into an instant jump.
|
|
60
|
+
*/
|
|
61
|
+
readonly dragging: boolean;
|
|
62
|
+
}
|
|
63
|
+
export declare function attachSwipeDrag(init: AdwSwipeDragInit): AdwSwipeDrag;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"@codemirror/language": "^6.12.4",
|
|
46
46
|
"@codemirror/state": "^6.7.0",
|
|
47
47
|
"@codemirror/view": "^6.43.4",
|
|
48
|
-
"@gjsify/adwaita-core": "^0.
|
|
49
|
-
"@gjsify/adwaita-fonts": "^0.
|
|
50
|
-
"@gjsify/adwaita-icons": "^0.
|
|
48
|
+
"@gjsify/adwaita-core": "^0.43.0",
|
|
49
|
+
"@gjsify/adwaita-fonts": "^0.43.0",
|
|
50
|
+
"@gjsify/adwaita-icons": "^0.43.0",
|
|
51
51
|
"@lezer/highlight": "^1.2.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
54
|
+
"@gjsify/cli": "^0.43.0",
|
|
55
|
+
"@gjsify/unit": "^0.43.0",
|
|
56
56
|
"sass": "^1.101.0",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
// position compensation. This element is the DOM half only — scroll-snap strip,
|
|
10
10
|
// offset↔position, DOM events.
|
|
11
11
|
//
|
|
12
|
-
// The
|
|
13
|
-
// `interactive` default TRUE, so `="false"` is the off switch;
|
|
14
|
-
// is an ordinary presence attribute.
|
|
12
|
+
// The four attribute defaults HTML cannot spell: `allow-scroll-wheel`,
|
|
13
|
+
// `allow-mouse-drag` and `interactive` default TRUE, so `="false"` is the off switch;
|
|
14
|
+
// `allow-long-swipes` is an ordinary presence attribute.
|
|
15
|
+
//
|
|
16
|
+
// A MOUSE DRAG is `elements/swipe-drag.ts`, and it is the one gesture a browser gives
|
|
17
|
+
// nothing for: touch and touchpad scroll this strip natively, a held mouse button does
|
|
18
|
+
// not. Upstream turns it on in `init` (:1209) and exposes `allow-mouse-drag`, default
|
|
19
|
+
// TRUE (:1091). Measured before: a 300 px drag moved `position` 0 → 0. `position` is the INITIAL page only —
|
|
15
20
|
// write-only, with the live value on the `position` property and `notify::position`.
|
|
16
21
|
//
|
|
17
22
|
// `page-changed` (`detail = { index }`) fires on every settle, including back
|
|
@@ -27,9 +32,11 @@
|
|
|
27
32
|
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
28
33
|
// Modifications: Implemented as Web Components for @gjsify/adwaita-web.
|
|
29
34
|
|
|
30
|
-
import { CarouselState, carouselPageAllocation } from '@gjsify/adwaita-core';
|
|
35
|
+
import { CarouselState, carouselClosestSnapPoint, carouselPageAllocation } from '@gjsify/adwaita-core';
|
|
31
36
|
import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
|
|
32
37
|
|
|
38
|
+
import { attachSwipeDrag, type AdwSwipeDrag } from './swipe-drag.js';
|
|
39
|
+
|
|
33
40
|
/** Common surface an indicator binds to. The concrete element is AdwCarousel. */
|
|
34
41
|
interface CarouselHost extends HTMLElement {
|
|
35
42
|
readonly nPages: number;
|
|
@@ -94,9 +101,11 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
94
101
|
* index while the target page stays the target.
|
|
95
102
|
*/
|
|
96
103
|
private _scrollTargetId: string | null = null;
|
|
104
|
+
/** The pointer-drag gesture, asked whether it owns the strip. See `_onWheel`. */
|
|
105
|
+
private _drag: AdwSwipeDrag | undefined;
|
|
97
106
|
|
|
98
107
|
static get observedAttributes() {
|
|
99
|
-
return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
|
|
108
|
+
return ['allow-scroll-wheel', 'allow-mouse-drag', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
constructor() {
|
|
@@ -132,6 +141,18 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
132
141
|
return this._state.pageAt(this._state.position);
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/**
|
|
145
|
+
* `AdwCarousel:allow-mouse-drag` — whether the strip can be dragged with the pointer.
|
|
146
|
+
* Defaults to TRUE, as in libadwaita, where FALSE leaves dragging to touch only.
|
|
147
|
+
*/
|
|
148
|
+
get allowMouseDrag(): boolean {
|
|
149
|
+
return this._state.allowMouseDrag;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
set allowMouseDrag(value: boolean) {
|
|
153
|
+
this.setAttribute('allow-mouse-drag', value ? 'true' : 'false');
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
/** Whether the scroll wheel navigates between pages. Defaults to TRUE, as in libadwaita. */
|
|
136
157
|
get allowScrollWheel(): boolean {
|
|
137
158
|
return this._state.allowScrollWheel;
|
|
@@ -187,6 +208,7 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
187
208
|
// Declared properties BEFORE children: `spacing` is part of the page pitch,
|
|
188
209
|
// so a page added first would be measured against the wrong distance.
|
|
189
210
|
this._applyAttribute('allow-scroll-wheel', this.getAttribute('allow-scroll-wheel'));
|
|
211
|
+
this._applyAttribute('allow-mouse-drag', this.getAttribute('allow-mouse-drag'));
|
|
190
212
|
this._applyAttribute('allow-long-swipes', this.getAttribute('allow-long-swipes'));
|
|
191
213
|
this._applyAttribute('interactive', this.getAttribute('interactive'));
|
|
192
214
|
this._applyAttribute('spacing', this.getAttribute('spacing'));
|
|
@@ -200,6 +222,52 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
200
222
|
this._trackEl.addEventListener('scroll', this._onScroll, { passive: true });
|
|
201
223
|
this._trackEl.addEventListener('wheel', this._onWheel, { passive: false });
|
|
202
224
|
|
|
225
|
+
this._drag = attachSwipeDrag({
|
|
226
|
+
// On the TRACK, not on the host: the track is the scroll container the drag
|
|
227
|
+
// writes `scrollLeft` on, and it is what the pages live in.
|
|
228
|
+
host: this._trackEl,
|
|
229
|
+
orientation: 'horizontal',
|
|
230
|
+
// `interactive` gates it because that is exactly what it does upstream —
|
|
231
|
+
// `adw_carousel_set_interactive` is `adw_swipe_tracker_set_enabled` (:1705).
|
|
232
|
+
enabled: () => this._state.interactive && this._state.allowMouseDrag,
|
|
233
|
+
pitch: () => this._distance(),
|
|
234
|
+
points: () => this._state.snapPoints,
|
|
235
|
+
progress: () => this._state.position,
|
|
236
|
+
allowLongSwipes: () => this._state.allowLongSwipes,
|
|
237
|
+
// `adw_carousel_get_cancel_progress` is `get_closest_snap_point`
|
|
238
|
+
// (adw-carousel.c:1294-1299) — the page nearest where the gesture was
|
|
239
|
+
// interrupted, NOT the one it started from.
|
|
240
|
+
//
|
|
241
|
+
// `carouselClosestSnapPoint` and NOT `_state.pageAt`, for two reasons that
|
|
242
|
+
// both make the answer a different page. `pageAt` searches the VISIBLE snap
|
|
243
|
+
// points (it filters out children being removed) and returns an index into
|
|
244
|
+
// that filtered array, so subscripting the unfiltered list with it shifts by
|
|
245
|
+
// one for every removing child ahead of the target. And it is the
|
|
246
|
+
// `count_removing = FALSE` variant, while `get_closest_snap_point` passes
|
|
247
|
+
// TRUE (adw-carousel.c:1298) — this is the one call site that counts them.
|
|
248
|
+
//
|
|
249
|
+
// The POINT, not an index: a page mid-reveal has a fractional size, so
|
|
250
|
+
// `carouselSnapPoints` (cumulative size − 1) hands back non-integers.
|
|
251
|
+
cancelProgress: () => {
|
|
252
|
+
const points = this._state.snapPoints;
|
|
253
|
+
return points[carouselClosestSnapPoint(this._state.position, points)] ?? this._state.position;
|
|
254
|
+
},
|
|
255
|
+
// The DRAG writes the offset directly and the existing scroll handler reads
|
|
256
|
+
// the position back off it — the same path a touch scroll takes, so there is
|
|
257
|
+
// no second route into `position` to drift from this one.
|
|
258
|
+
onUpdate: (progress) => {
|
|
259
|
+
const distance = this._distance();
|
|
260
|
+
if (distance > 0) this._trackEl.scrollLeft = progress * distance;
|
|
261
|
+
},
|
|
262
|
+
// `scroll_to` with the swipe's velocity is what `end_swipe_cb` does (:434).
|
|
263
|
+
// Here the settle is the browser's smooth scroll, so the velocity is unused
|
|
264
|
+
// — snap is already restored, and `to` is a snap point by construction.
|
|
265
|
+
onEnd: (progress) => {
|
|
266
|
+
const distance = this._distance();
|
|
267
|
+
if (distance > 0) this._trackEl.scrollTo({ left: progress * distance });
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
203
271
|
// Applied last, once the pages it names exist. Custom-element upgrade
|
|
204
272
|
// delivers attributes BEFORE `connectedCallback`, so reading it here is the
|
|
205
273
|
// only way `<adw-carousel position="2">` works at all.
|
|
@@ -246,6 +314,10 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
246
314
|
// usual "presence = on" spelling would invert it.
|
|
247
315
|
this._state.setAllowScrollWheel(value !== 'false');
|
|
248
316
|
return;
|
|
317
|
+
case 'allow-mouse-drag':
|
|
318
|
+
// Absent means TRUE, like `allow-scroll-wheel` and for the same reason.
|
|
319
|
+
this._state.setAllowMouseDrag(value !== 'false');
|
|
320
|
+
return;
|
|
249
321
|
case 'allow-long-swipes':
|
|
250
322
|
this._state.setAllowLongSwipes(value !== null && value !== 'false');
|
|
251
323
|
this._applySnapStop();
|
|
@@ -546,6 +618,15 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
|
|
|
546
618
|
};
|
|
547
619
|
|
|
548
620
|
private _onWheel = (event: WheelEvent): void => {
|
|
621
|
+
// A drag owns the offset while it is claimed. Without this a single wheel notch
|
|
622
|
+
// mid-drag ran the whole paging path and moved `scrollLeft` a full page in one
|
|
623
|
+
// frame — measured 80 → 440 — which the next pointer move then yanked back, and
|
|
624
|
+
// it left `_scrollTargetId` pointing at a page the drag overrode. `GDK_EVENT_STOP`
|
|
625
|
+
// rather than propagate, because the gesture IS handling this widget's scrolling.
|
|
626
|
+
if (this._drag?.dragging === true) {
|
|
627
|
+
event.preventDefault();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
549
630
|
const step = this._state.handleWheel({
|
|
550
631
|
deltaX: event.deltaX,
|
|
551
632
|
deltaY: event.deltaY,
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Dragging a swipeable widget with the pointer — the DOM half of `AdwSwipeTracker`.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Measured in Firefox on a bare `<adw-carousel>` with three 440 px pages: a 300 px mouse
|
|
6
|
+
// drag across it moved `position` 0 → 0 and `scrollLeft` 0 → 0. Nothing. A touchpad
|
|
7
|
+
// two-finger swipe moved 0 → 2 and writing `scrollLeft = 440` moved `position` to 1, so
|
|
8
|
+
// the strip was scrollable and the position feedback worked — the gesture was simply not
|
|
9
|
+
// implemented. Upstream it is not optional: `AdwCarousel` turns it on in `init`
|
|
10
|
+
// (`adw_swipe_tracker_set_allow_mouse_drag (self->tracker, TRUE)`, adw-carousel.c:1209)
|
|
11
|
+
// and exposes it as a public property whose default is TRUE (`allow-mouse-drag`, :1091),
|
|
12
|
+
// documented "If the value is FALSE, dragging is only available on touch."
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS DOES *NOT* TOUCH, and why that is the compatible answer
|
|
15
|
+
//
|
|
16
|
+
// Touch and touchpad swiping already work, because the widget is a real scroll container
|
|
17
|
+
// with `scroll-snap-type` and the browser owns those gestures — momentum, rubber-band and
|
|
18
|
+
// snapping included. GTK is likewise the platform there. Taking them over to run the
|
|
19
|
+
// tracker on every touchmove would replace a native gesture with an imitation of one, so
|
|
20
|
+
// this module handles the ONE input the browser gives no gesture for: a held mouse button.
|
|
21
|
+
//
|
|
22
|
+
// The consequence to know about is a real divergence and it is ledgered, not hidden: GTK
|
|
23
|
+
// runs its touchpad scroll through the same tracker, so `allow-long-swipes: false` bounds
|
|
24
|
+
// a touchpad flick to the adjacent page there and does not here — measured, a two-finger
|
|
25
|
+
// flick crossed two pages.
|
|
26
|
+
//
|
|
27
|
+
// WHY POINTER EVENTS AND NOT touch/mouse PAIRS
|
|
28
|
+
//
|
|
29
|
+
// `pointerType` is a fact about the event; `'ontouchstart' in window` is a fact about the
|
|
30
|
+
// device. A laptop with a touchscreen is both, so a listener set chosen from the device —
|
|
31
|
+
// the shape `@ribajs/extras`' touch-events service uses, ported from jQuery Mobile — picks
|
|
32
|
+
// one path for a machine that needs both. One set of pointer listeners and a check on
|
|
33
|
+
// `pointerType` cannot get that wrong.
|
|
34
|
+
//
|
|
35
|
+
// THE CLAIM IS A STATE MACHINE, NOT A BOOLEAN, and compressing it into one cost three
|
|
36
|
+
// behaviours. `drag_update_cb` (adw-swipe-tracker.c:640-750) runs in this order, and the
|
|
37
|
+
// order is the point:
|
|
38
|
+
//
|
|
39
|
+
// 1. `append_to_history` — UNCONDITIONAL (:670), ahead of everything below. A move made
|
|
40
|
+
// before the gesture is claimed still counts toward the velocity.
|
|
41
|
+
// 2. at STATE_NONE, the axis is checked and the gesture is DENIED on the wrong one
|
|
42
|
+
// (:673) — on the FIRST move, at any distance, not only once 16 px are travelled.
|
|
43
|
+
// 3. at STATE_PENDING, past the threshold, four more refusals before claiming: outside
|
|
44
|
+
// the swipe area, the wrong axis again (:715), a strip whose first and last snap
|
|
45
|
+
// point are the same (:720), and overshooting either end with overshoot disabled
|
|
46
|
+
// (:725-739) — which `AdwCarousel` always has.
|
|
47
|
+
// 4. only at STATE_SCROLLING does `gesture_update` apply the delta (:749).
|
|
48
|
+
//
|
|
49
|
+
// Skipping 3 is what made a backwards drag on page 0 swallow the click of the button
|
|
50
|
+
// under the cursor: the gesture claimed, clamped to the bound so nothing moved, and then
|
|
51
|
+
// suppressed the click anyway. Skipping 1 cost a whole event — with the first record
|
|
52
|
+
// discarded as the clock-starter, a two-move flick measured zero velocity and settled
|
|
53
|
+
// back instead of paging.
|
|
54
|
+
//
|
|
55
|
+
// HOW A DRAG BEATS A CLICK
|
|
56
|
+
//
|
|
57
|
+
// GTK does not blocklist widgets under the pointer: `should_suppress_drag`
|
|
58
|
+
// (adw-swipe-tracker.c:551) only refuses window handles, and a button inside a carousel
|
|
59
|
+
// loses its click because the drag gesture CLAIMS the event sequence once it passes
|
|
60
|
+
// `DRAG_THRESHOLD_DISTANCE`. This mirrors that: every `pointerdown` is tracked, nothing is
|
|
61
|
+
// pre-excluded, and the claim at 16 px is what suppresses the `click` that would otherwise
|
|
62
|
+
// follow. {@link NATIVELY_DRAGGABLE} is the one place a blocklist is unavoidable — a
|
|
63
|
+
// `<select>` or a range slider runs its own drag inside the browser, where there is no
|
|
64
|
+
// sequence to lose the race for.
|
|
65
|
+
|
|
66
|
+
import { ADW_SWIPE_DRAG_THRESHOLD, SwipeTracker } from '@gjsify/adwaita-core';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Controls that drag themselves, inside the browser, with no sequence for a claim to win.
|
|
70
|
+
*
|
|
71
|
+
* GTK needs no such list (see the header); this is the DOM's missing half of gesture
|
|
72
|
+
* claiming and nothing more. A `<button>` is deliberately ABSENT: losing its click to a
|
|
73
|
+
* deliberate drag is the upstream behaviour.
|
|
74
|
+
*/
|
|
75
|
+
const NATIVELY_DRAGGABLE = 'select, textarea, input[type="range"], [contenteditable=""], [contenteditable="true"]';
|
|
76
|
+
|
|
77
|
+
/** Which way the widget pages, and therefore which axis claims a drag. */
|
|
78
|
+
export type AdwSwipeOrientation = 'horizontal' | 'vertical';
|
|
79
|
+
|
|
80
|
+
export interface AdwSwipeDragInit {
|
|
81
|
+
/** The element the listeners sit on. Items rebuilt under it stay covered. */
|
|
82
|
+
host: HTMLElement;
|
|
83
|
+
orientation: AdwSwipeOrientation;
|
|
84
|
+
/** Re-read on every press: may a mouse drag start right now? */
|
|
85
|
+
enabled: () => boolean;
|
|
86
|
+
/** Page pitch in px. The raw pixel delta is divided by it to reach progress. */
|
|
87
|
+
pitch: () => number;
|
|
88
|
+
/** Snap points, ascending, as `adw_swipeable_get_snap_points` returns them. */
|
|
89
|
+
points: () => readonly number[];
|
|
90
|
+
/** Progress now — the gesture is seeded from it and bounded by it. */
|
|
91
|
+
progress: () => number;
|
|
92
|
+
/** `AdwCarousel:allow-long-swipes`. */
|
|
93
|
+
allowLongSwipes: () => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* `adw_swipeable_get_cancel_progress` — where a CANCELLED gesture goes.
|
|
96
|
+
*
|
|
97
|
+
* Not optional by accident: the tracker's own fallback is where the gesture began,
|
|
98
|
+
* and `AdwCarousel`'s answer is `get_closest_snap_point` — the point nearest where
|
|
99
|
+
* the finger is NOW (adw-carousel.c:1294-1299). The two differ for exactly the
|
|
100
|
+
* gesture that matters, one cancelled past the halfway mark: upstream commits to the
|
|
101
|
+
* page under the cursor, the fallback yanks it back.
|
|
102
|
+
*/
|
|
103
|
+
cancelProgress: () => number;
|
|
104
|
+
/** Show this progress. Called on every move that the gesture owns. */
|
|
105
|
+
onUpdate: (progress: number) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Whether a gesture may be claimed at all right now, on top of the refusals this
|
|
108
|
+
* module makes itself. `AdwCarousel` needs none — its `interactive` and
|
|
109
|
+
* `allow-mouse-drag` are {@link enabled} — but `is_in_swipe_area` lives here for the
|
|
110
|
+
* widgets that implement `get_swipe_area`, which the carousel does not.
|
|
111
|
+
*/
|
|
112
|
+
claimable?: (start: { x: number; y: number }) => boolean;
|
|
113
|
+
/** Settle on this progress. `velocity` is raw px/ms, for an animation duration. */
|
|
114
|
+
onEnd: (progress: number, velocity: number) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a claimed gesture had to override on the host, so releasing it can put it back. */
|
|
118
|
+
interface FrozenStyles {
|
|
119
|
+
scrollSnapType: string;
|
|
120
|
+
scrollBehavior: string;
|
|
121
|
+
userSelect: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Make `host` draggable with a mouse, deciding where it lands the way `AdwSwipeTracker`
|
|
126
|
+
* does.
|
|
127
|
+
*
|
|
128
|
+
* The listener is the element's own and is bound once, without teardown: it survives a
|
|
129
|
+
* re-parent and is collected with the element.
|
|
130
|
+
*/
|
|
131
|
+
/** What a caller gets back, so it can tell whether a gesture owns the widget. */
|
|
132
|
+
export interface AdwSwipeDrag {
|
|
133
|
+
/**
|
|
134
|
+
* Whether a gesture is currently claimed.
|
|
135
|
+
*
|
|
136
|
+
* A widget needs this to keep its OTHER inputs out of the offset while a drag owns
|
|
137
|
+
* it. Measured on `<adw-carousel>`: one wheel notch during a claimed drag ran the
|
|
138
|
+
* whole wheel path and jumped `scrollLeft` 80 → 440 in a frame, which the next move
|
|
139
|
+
* then yanked back. Upstream's `scroll_cb` is ungated too, but there the external
|
|
140
|
+
* request is a spring the next frame quietly overrides — here `claim()` sets
|
|
141
|
+
* `scroll-behavior: auto`, which turns it into an instant jump.
|
|
142
|
+
*/
|
|
143
|
+
readonly dragging: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function attachSwipeDrag(init: AdwSwipeDragInit): AdwSwipeDrag {
|
|
147
|
+
const tracker = new SwipeTracker({
|
|
148
|
+
points: init.points,
|
|
149
|
+
allowLongSwipes: init.allowLongSwipes,
|
|
150
|
+
cancelProgress: init.cancelProgress,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let pointerId: number | null = null;
|
|
154
|
+
let startX = 0;
|
|
155
|
+
let startY = 0;
|
|
156
|
+
let previousOffset = 0;
|
|
157
|
+
/** Where the pointer is now, for the second half of the swipe-area test. */
|
|
158
|
+
let lastX = 0;
|
|
159
|
+
let lastY = 0;
|
|
160
|
+
/** `ADW_SWIPE_TRACKER_STATE_*`: nothing pressed, a press that may become one, ours. */
|
|
161
|
+
let state: 'none' | 'pending' | 'scrolling' = 'none';
|
|
162
|
+
let frozen: FrozenStyles | null = null;
|
|
163
|
+
/**
|
|
164
|
+
* Set when a claimed drag ends, cleared by the click it eats — or by the next press,
|
|
165
|
+
* because a drag that ended OUTSIDE the host produces no click at all and a one-shot
|
|
166
|
+
* listener would then sit armed and swallow the next real one.
|
|
167
|
+
*/
|
|
168
|
+
let suppressNextClick = false;
|
|
169
|
+
|
|
170
|
+
const axisOffset = (dx: number, dy: number) => (init.orientation === 'vertical' ? dy : dx);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Dragging LEFT must advance, so the offset is negated — `drag_update_cb`'s
|
|
174
|
+
* `offset = -offset`.
|
|
175
|
+
*
|
|
176
|
+
* LTR ONLY, deliberately. Upstream flips this sign for RTL through
|
|
177
|
+
* `adw_swipe_tracker_set_reversed` (`adw-carousel.c:455-465`), and taking a `reversed`
|
|
178
|
+
* flag here would be an afternoon's work — but the web carousel does not work in RTL
|
|
179
|
+
* at ALL yet: its offset model is `scrollLeft = position * distance`, and an RTL
|
|
180
|
+
* scroll container counts `scrollLeft` DOWN from 0, so even `scrollToPage(1)` moves
|
|
181
|
+
* nothing (measured). A reversed branch would compute a correct progress and write it
|
|
182
|
+
* to a container that ignores it. `status/open-todos.md` carries the whole gap; the
|
|
183
|
+
* flag belongs in the change that can test it.
|
|
184
|
+
*/
|
|
185
|
+
const progressOffset = (dx: number, dy: number) => -axisOffset(dx, dy);
|
|
186
|
+
|
|
187
|
+
const release = () => {
|
|
188
|
+
// Listeners off and state cleared BEFORE the capture is released, because
|
|
189
|
+
// `releasePointerCapture` fires `lostpointercapture` synchronously and that is
|
|
190
|
+
// one of the endings this listens for — doing it in the other order re-enters
|
|
191
|
+
// `finish` in the middle of itself.
|
|
192
|
+
const captured = pointerId;
|
|
193
|
+
pointerId = null;
|
|
194
|
+
state = 'none';
|
|
195
|
+
tracker.reset();
|
|
196
|
+
window.removeEventListener('pointermove', onMove, true);
|
|
197
|
+
window.removeEventListener('pointerup', onFinish, true);
|
|
198
|
+
window.removeEventListener('pointercancel', onCancel, true);
|
|
199
|
+
window.removeEventListener('lostpointercapture', onLostCapture, true);
|
|
200
|
+
|
|
201
|
+
if (captured !== null && init.host.hasPointerCapture(captured)) {
|
|
202
|
+
init.host.releasePointerCapture(captured);
|
|
203
|
+
}
|
|
204
|
+
if (frozen !== null) {
|
|
205
|
+
init.host.style.scrollSnapType = frozen.scrollSnapType;
|
|
206
|
+
init.host.style.scrollBehavior = frozen.scrollBehavior;
|
|
207
|
+
init.host.style.userSelect = frozen.userSelect;
|
|
208
|
+
frozen = null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `:720-739` — the refusals that happen at the threshold, after the axis and before
|
|
214
|
+
* the claim. Returns whether the gesture may be claimed at all.
|
|
215
|
+
*/
|
|
216
|
+
const claimAllowed = (offset: number): boolean => {
|
|
217
|
+
const points = init.points();
|
|
218
|
+
if (points.length === 0) return false;
|
|
219
|
+
const first = points[0];
|
|
220
|
+
const last = points[points.length - 1];
|
|
221
|
+
// A strip with one snap point has nowhere to go (:720). The C cannot reach an
|
|
222
|
+
// EMPTY one — `adw_carousel_get_snap_points` returns `MAX (n, 1)` points
|
|
223
|
+
// (adw-carousel.c:1269) — so the length test above stands in for it.
|
|
224
|
+
if (Math.abs(first - last) < Number.EPSILON) return false;
|
|
225
|
+
|
|
226
|
+
const progress = init.progress();
|
|
227
|
+
// Overshooting an end with overshoot disabled, which AdwCarousel always is
|
|
228
|
+
// (:725-739). Without this a backwards drag on the first page claims, moves
|
|
229
|
+
// nothing, and still eats the click of whatever it started on.
|
|
230
|
+
const overshootingLower = offset < 0 && (Math.abs(progress - first) < Number.EPSILON || progress < first);
|
|
231
|
+
const overshootingUpper = offset > 0 && (Math.abs(progress - last) < Number.EPSILON || progress > last);
|
|
232
|
+
if (overshootingLower || overshootingUpper) return false;
|
|
233
|
+
|
|
234
|
+
// BOTH ends, as `is_in_swipe_area (start) || is_in_swipe_area (start + offset)`
|
|
235
|
+
// does (:709-712): a gesture that began outside the grabbable strip and has
|
|
236
|
+
// arrived inside it is still allowed.
|
|
237
|
+
if (init.claimable === undefined) return true;
|
|
238
|
+
return init.claimable({ x: startX, y: startY }) || init.claimable({ x: lastX, y: lastY });
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const claim = () => {
|
|
242
|
+
state = 'scrolling';
|
|
243
|
+
// Captured HERE and not on the press. With capture set at `pointerdown`, the
|
|
244
|
+
// `click` that follows is dispatched at the CAPTURING element — so it never
|
|
245
|
+
// reaches the button the user actually pressed, and measured, every button inside
|
|
246
|
+
// a carousel stopped working. From the claim on it is harmless, because a claimed
|
|
247
|
+
// gesture suppresses that click on purpose.
|
|
248
|
+
if (pointerId !== null) init.host.setPointerCapture(pointerId);
|
|
249
|
+
// Snap and smooth scrolling both fight a drag: with them on, every write to
|
|
250
|
+
// `scrollLeft` is a request the browser re-animates and re-snaps, so the strip
|
|
251
|
+
// lags the cursor and lands somewhere the tracker did not choose. They go back
|
|
252
|
+
// on before the settle, which is what makes the release animate.
|
|
253
|
+
frozen = {
|
|
254
|
+
scrollSnapType: init.host.style.scrollSnapType,
|
|
255
|
+
scrollBehavior: init.host.style.scrollBehavior,
|
|
256
|
+
userSelect: init.host.style.userSelect,
|
|
257
|
+
};
|
|
258
|
+
init.host.style.scrollSnapType = 'none';
|
|
259
|
+
init.host.style.scrollBehavior = 'auto';
|
|
260
|
+
init.host.style.userSelect = 'none';
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* MOVE AND UP ARE LISTENED FOR ON THE WINDOW, and this is not belt-and-braces.
|
|
265
|
+
*
|
|
266
|
+
* Measured: with the listeners on the host and `setPointerCapture` alone, a drag that
|
|
267
|
+
* travelled past the left edge of the viewport never delivered `pointerup` to the
|
|
268
|
+
* track at all — `scroll-snap-type` stayed `none` and the strip sat at `scrollLeft`
|
|
269
|
+
* 387 of a 440 px page, forever, with the gesture still believing it was in flight.
|
|
270
|
+
* That is worse than the missing feature was. Capture is kept as well, because it is
|
|
271
|
+
* what keeps the moves targeted while the pointer is still inside.
|
|
272
|
+
*/
|
|
273
|
+
const arm = () => {
|
|
274
|
+
window.addEventListener('pointermove', onMove, true);
|
|
275
|
+
window.addEventListener('pointerup', onFinish, true);
|
|
276
|
+
window.addEventListener('pointercancel', onCancel, true);
|
|
277
|
+
// The ending of last resort, for a CLAIMED gesture whose `pointerup` this never
|
|
278
|
+
// sees — the window losing focus mid-drag, an OS-level cancel. Without it such a
|
|
279
|
+
// gesture leaves the strip stopped mid-page with snapping switched off, which was
|
|
280
|
+
// measured and is worse than the feature being absent.
|
|
281
|
+
//
|
|
282
|
+
// It does NOT cover the host being removed from the document, which an earlier
|
|
283
|
+
// draft of this comment claimed: the implicit release dispatches
|
|
284
|
+
// `lostpointercapture` at the now-detached element, and an event dispatched in a
|
|
285
|
+
// detached tree never reaches `window`. Measured — the styles stayed frozen until
|
|
286
|
+
// a later `pointerup` cleaned up. The button check in `onMove` is the net that
|
|
287
|
+
// actually spans both, since a removed host stops delivering moves anyway.
|
|
288
|
+
window.addEventListener('lostpointercapture', onLostCapture, true);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
init.host.addEventListener('pointerdown', (event: PointerEvent) => {
|
|
292
|
+
// FIRST, before any guard below can return. A cancelled gesture produces no click
|
|
293
|
+
// to consume the flag, so leaving it set until the next SUCCESSFUL press meant the
|
|
294
|
+
// next click was eaten instead — measured: cancel a drag, then click a `<textarea>`
|
|
295
|
+
// inside the carousel (which `NATIVELY_DRAGGABLE` refuses to drag) and the click
|
|
296
|
+
// never arrived.
|
|
297
|
+
suppressNextClick = false;
|
|
298
|
+
|
|
299
|
+
// A gesture still open under the SAME pointer means its end was never seen: close
|
|
300
|
+
// it, or one lost `pointerup` disables dragging for good. A DIFFERENT pointer is
|
|
301
|
+
// the opposite case and upstream is explicit about it — `drag_begin_cb` denies the
|
|
302
|
+
// NEW sequence and keeps the one in flight (adw-swipe-tracker.c:617-620). Tearing
|
|
303
|
+
// the live gesture down instead left the strip rubber-banding back with no
|
|
304
|
+
// `onEnd` and no tracker decision at all.
|
|
305
|
+
if (pointerId !== null) {
|
|
306
|
+
if (event.pointerId !== pointerId) return;
|
|
307
|
+
release();
|
|
308
|
+
}
|
|
309
|
+
// Mouse only. Touch and touchpad are the browser's own gestures, and a pen
|
|
310
|
+
// behaves like touch on every platform that has one.
|
|
311
|
+
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
|
312
|
+
if (!init.enabled()) return;
|
|
313
|
+
if ((event.target as Element | null)?.closest(NATIVELY_DRAGGABLE) !== null) return;
|
|
314
|
+
|
|
315
|
+
pointerId = event.pointerId;
|
|
316
|
+
startX = event.clientX;
|
|
317
|
+
startY = event.clientY;
|
|
318
|
+
lastX = event.clientX;
|
|
319
|
+
lastY = event.clientY;
|
|
320
|
+
previousOffset = 0;
|
|
321
|
+
state = 'pending';
|
|
322
|
+
// No capture yet — see `claim`. The moves that DECIDE the claim come from the
|
|
323
|
+
// window listeners instead, which is why they are armed here and not there: a
|
|
324
|
+
// 16 px threshold near an edge is otherwise unreachable.
|
|
325
|
+
arm();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function onMove(event: PointerEvent): void {
|
|
329
|
+
if (pointerId !== event.pointerId) return;
|
|
330
|
+
|
|
331
|
+
lastX = event.clientX;
|
|
332
|
+
lastY = event.clientY;
|
|
333
|
+
const dx = event.clientX - startX;
|
|
334
|
+
const dy = event.clientY - startY;
|
|
335
|
+
const offset = progressOffset(dx, dy);
|
|
336
|
+
const delta = offset - previousOffset;
|
|
337
|
+
previousOffset = offset;
|
|
338
|
+
// TRUNCATED to whole milliseconds, because the C's clock is a `guint32` and its
|
|
339
|
+
// `first_time == last_time` guard is a real low-pass filter: two events inside
|
|
340
|
+
// one millisecond yield NO velocity there. Left as a float, that guard is dead
|
|
341
|
+
// and two coalesced moves 0.1 ms apart read as tens of pages per second.
|
|
342
|
+
const time = Math.trunc(event.timeStamp);
|
|
343
|
+
// UNCONDITIONAL, and ahead of every refusal below — `append_to_history` at :670.
|
|
344
|
+
tracker.record(delta, time);
|
|
345
|
+
|
|
346
|
+
// NO BUTTON HELD means the press this is tracking ended somewhere unseen. Before
|
|
347
|
+
// the claim there is no pointer capture and therefore no `lostpointercapture`, so
|
|
348
|
+
// this is the only net that window: measured, one lost `pointerup` left the strip
|
|
349
|
+
// FOLLOWING a cursor with nothing pressed, snapping off, until the next click
|
|
350
|
+
// anywhere. Checked for every state, since a claimed gesture can lose its up too.
|
|
351
|
+
if ((event.buttons & 1) === 0) {
|
|
352
|
+
release();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (state === 'pending') {
|
|
357
|
+
// Whichever axis moved MORE decides whose gesture this is
|
|
358
|
+
// (`is_offset_vertical`, :661). Tested here and ONLY here: the C tests it at
|
|
359
|
+
// STATE_NONE, on the first move at any distance (:673), and again inside the
|
|
360
|
+
// threshold block (:715) — never at STATE_SCROLLING. One test in the pending
|
|
361
|
+
// branch covers both, because `dx`/`dy` are cumulative from the press.
|
|
362
|
+
//
|
|
363
|
+
// Hoisting it above this branch, as a first draft did, re-tested it on every
|
|
364
|
+
// claimed move — and `dx`/`dy` being cumulative means a drag that comes BACK
|
|
365
|
+
// toward its start ("changed my mind, with a little vertical wander") flips
|
|
366
|
+
// the dominant axis and cancelled itself mid-gesture.
|
|
367
|
+
const wantsVertical = Math.abs(dy) > Math.abs(dx);
|
|
368
|
+
if (wantsVertical !== (init.orientation === 'vertical')) {
|
|
369
|
+
release();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (Math.hypot(dx, dy) < ADW_SWIPE_DRAG_THRESHOLD) return;
|
|
373
|
+
if (!claimAllowed(offset)) {
|
|
374
|
+
release();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// `gesture_prepare` is the C's first-move step and sets `initial_progress`
|
|
378
|
+
// (:202-204). It runs here rather than at `pointerdown` because until a move
|
|
379
|
+
// arrives there is no direction, and until the axis passes there is no
|
|
380
|
+
// gesture to prepare.
|
|
381
|
+
tracker.prepare(init.progress());
|
|
382
|
+
claim();
|
|
383
|
+
// From the claim, not from the press: the threshold travel is what BOUGHT
|
|
384
|
+
// the gesture and must not also be applied as progress (`prev_offset = offset`
|
|
385
|
+
// at :744), or the content jumps 16 px the instant it follows the cursor.
|
|
386
|
+
previousOffset = offset;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (state !== 'scrolling') return;
|
|
391
|
+
// The standard signal that this move is consumed. Deliberately NOT credited with
|
|
392
|
+
// the two things it usually is: text selection is prevented by the `user-select`
|
|
393
|
+
// in `claim`, and a native image or link drag begins from `dragstart` BEFORE the
|
|
394
|
+
// claim, so this line has never run by then — measured, an image dragged inside a
|
|
395
|
+
// page is never refused here. Kept because consuming the move is what it is for,
|
|
396
|
+
// not because a specific default was observed to need it.
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
init.onUpdate(tracker.advance(delta, init.pitch()));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function finish(event: PointerEvent, cancelled: boolean): void {
|
|
402
|
+
if (pointerId !== event.pointerId) return;
|
|
403
|
+
if (state !== 'scrolling') {
|
|
404
|
+
release();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (cancelled) tracker.cancel();
|
|
408
|
+
const { to, velocity } = tracker.end(Math.trunc(event.timeStamp));
|
|
409
|
+
release();
|
|
410
|
+
// A deliberate drag must not also activate whatever it ended on — the DOM's
|
|
411
|
+
// stand-in for GTK's claimed event sequence.
|
|
412
|
+
suppressNextClick = true;
|
|
413
|
+
init.onEnd(to, velocity);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function onFinish(event: PointerEvent): void {
|
|
417
|
+
finish(event, false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function onCancel(event: PointerEvent): void {
|
|
421
|
+
finish(event, true);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function onLostCapture(event: PointerEvent): void {
|
|
425
|
+
// Settled, not cancelled: the pointer is gone, so whatever the user did they are
|
|
426
|
+
// done doing it, and dropping them back where they started would undo a drag
|
|
427
|
+
// they can see on screen.
|
|
428
|
+
finish(event, false);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// CAPTURE phase and permanent, rather than a one-shot armed at the end of a drag: a
|
|
432
|
+
// listener that is only ever removed by the click it is waiting for outlives a drag
|
|
433
|
+
// that produced none.
|
|
434
|
+
init.host.addEventListener(
|
|
435
|
+
'click',
|
|
436
|
+
(event: Event) => {
|
|
437
|
+
if (!suppressNextClick) return;
|
|
438
|
+
suppressNextClick = false;
|
|
439
|
+
event.stopPropagation();
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
},
|
|
442
|
+
{ capture: true },
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
get dragging(): boolean {
|
|
447
|
+
return state === 'scrolling';
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|