@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.42.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.42.0",
49
- "@gjsify/adwaita-fonts": "^0.42.0",
50
- "@gjsify/adwaita-icons": "^0.42.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.42.0",
55
- "@gjsify/unit": "^0.42.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 three attribute defaults HTML cannot spell: `allow-scroll-wheel` and
13
- // `interactive` default TRUE, so `="false"` is the off switch; `allow-long-swipes`
14
- // is an ordinary presence attribute. `position` is the INITIAL page only —
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
+ }