react-sway 0.2.6 → 0.3.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.
- package/README.md +138 -12
- package/dist/{index.d.cts → ReactSway.d.ts} +30 -10
- package/dist/index.cjs +649 -489
- package/dist/index.d.ts +3 -60
- package/dist/index.js +646 -460
- package/package.json +14 -13
- package/src/ReactSway.tsx +296 -64
- package/src/index.ts +1 -1
- package/src/__tests__/ReactSway.test.tsx +0 -573
- package/src/__tests__/setup.ts +0 -20
package/src/ReactSway.tsx
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core ReactSway component implementing axis-aware infinite scrolling interactions.
|
|
3
|
+
*/
|
|
1
4
|
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
5
|
|
|
3
6
|
/** Velocity applied per arrow key press (pixels). */
|
|
4
7
|
const ARROW_KEY_VELOCITY = 15;
|
|
5
8
|
|
|
9
|
+
/** Default edge-hover activation thickness in pixels. */
|
|
10
|
+
const DEFAULT_EDGE_HOVER_SIZE = 96;
|
|
11
|
+
|
|
6
12
|
/** Default friction coefficient applied to velocity each frame. */
|
|
7
13
|
const DEFAULT_FRICTION = 0.95;
|
|
8
14
|
|
|
@@ -19,7 +25,7 @@ const DEFAULT_RESUME_DELAY = 2000;
|
|
|
19
25
|
const DEFAULT_SPEED = 0.5;
|
|
20
26
|
|
|
21
27
|
/** Number of stacked content groups used to build the seamless loop. */
|
|
22
|
-
const
|
|
28
|
+
const MIN_LOOP_SEGMENTS = 3;
|
|
23
29
|
|
|
24
30
|
/** Maximum deltaTime cap to prevent physics instability during frame drops. */
|
|
25
31
|
const MAX_DELTA_TIME = 3;
|
|
@@ -57,11 +63,21 @@ const WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
|
|
|
57
63
|
/** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
|
|
58
64
|
const WHEEL_LEGACY_NOTCH_DELTA = 120;
|
|
59
65
|
|
|
66
|
+
/** Minimum touch movement before a gesture is classified by axis. */
|
|
67
|
+
const TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
|
|
68
|
+
|
|
60
69
|
interface WheelEventWithLegacyDelta extends globalThis.WheelEvent {
|
|
61
70
|
wheelDelta?: number;
|
|
62
71
|
wheelDeltaY?: number;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
export type SwayAxis = 'horizontal' | 'vertical';
|
|
75
|
+
export type SwayDirection = 'down' | 'left' | 'right' | 'up';
|
|
76
|
+
export type SwayWheelMode = 'axis' | 'capture';
|
|
77
|
+
|
|
78
|
+
type WheelDeltaAxis = 'x' | 'y';
|
|
79
|
+
type TouchInteractionState = 'active' | 'idle' | 'ignored' | 'pending';
|
|
80
|
+
|
|
65
81
|
function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
|
|
66
82
|
const { wheelDelta, wheelDeltaY } = event as WheelEventWithLegacyDelta;
|
|
67
83
|
const legacyWheelDelta = typeof wheelDeltaY === 'number' ? wheelDeltaY : wheelDelta;
|
|
@@ -77,49 +93,100 @@ function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
|
|
|
77
93
|
* Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
|
|
78
94
|
* devices feed the same Sway velocity system.
|
|
79
95
|
*/
|
|
80
|
-
function
|
|
96
|
+
function normalizeWheelDelta(event: globalThis.WheelEvent, container: HTMLElement, axis: WheelDeltaAxis) {
|
|
97
|
+
const rawDelta = axis === 'x' ? event.deltaX : event.deltaY;
|
|
98
|
+
|
|
81
99
|
if (event.deltaMode === WHEEL_MODE_LINE) {
|
|
82
100
|
const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
|
|
83
101
|
const lineHeight = Number.isFinite(computedLineHeight)
|
|
84
102
|
? computedLineHeight
|
|
85
103
|
: WHEEL_LINE_HEIGHT_FALLBACK_PX;
|
|
86
104
|
|
|
87
|
-
return
|
|
105
|
+
return rawDelta * lineHeight;
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
if (event.deltaMode === WHEEL_MODE_PAGE) {
|
|
91
|
-
const
|
|
109
|
+
const pageSize = axis === 'x'
|
|
110
|
+
? Math.max(container.clientWidth, window.innerWidth, 1)
|
|
111
|
+
: Math.max(container.clientHeight, window.innerHeight, 1);
|
|
92
112
|
|
|
93
|
-
return
|
|
113
|
+
return rawDelta * pageSize;
|
|
94
114
|
}
|
|
95
115
|
|
|
116
|
+
if (axis === 'x') return rawDelta;
|
|
117
|
+
|
|
96
118
|
const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
|
|
97
119
|
|
|
98
120
|
if (
|
|
99
|
-
Math.abs(
|
|
121
|
+
Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX &&
|
|
100
122
|
Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX
|
|
101
123
|
) {
|
|
102
124
|
return legacyWheelPixelDeltaY;
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
return
|
|
127
|
+
return rawDelta;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getOwnedWheelDelta(
|
|
131
|
+
event: globalThis.WheelEvent,
|
|
132
|
+
container: HTMLElement,
|
|
133
|
+
isHorizontal: boolean,
|
|
134
|
+
wheelMode: SwayWheelMode,
|
|
135
|
+
) {
|
|
136
|
+
// The ownership function is intentionally partial: null means native scroll
|
|
137
|
+
// chaining remains the browser's responsibility for cross-axis gestures.
|
|
138
|
+
const deltaX = normalizeWheelDelta(event, container, 'x');
|
|
139
|
+
const deltaY = normalizeWheelDelta(event, container, 'y');
|
|
140
|
+
const absDeltaX = Math.abs(deltaX);
|
|
141
|
+
const absDeltaY = Math.abs(deltaY);
|
|
142
|
+
|
|
143
|
+
if (isHorizontal) {
|
|
144
|
+
if (wheelMode === 'axis' && absDeltaX <= absDeltaY && !event.shiftKey) return null;
|
|
145
|
+
return absDeltaX > absDeltaY ? deltaX : deltaY;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (wheelMode === 'axis' && absDeltaX > absDeltaY) return null;
|
|
149
|
+
return deltaY;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getVisibleEdgeInterval(rect: DOMRect, isHorizontal: boolean) {
|
|
153
|
+
const rectEnd = isHorizontal ? rect.right : rect.bottom;
|
|
154
|
+
const rectStart = isHorizontal ? rect.left : rect.top;
|
|
155
|
+
const viewportEnd = isHorizontal ? window.innerWidth : window.innerHeight;
|
|
156
|
+
|
|
157
|
+
const end = Math.min(rectEnd, viewportEnd);
|
|
158
|
+
const start = Math.max(rectStart, 0);
|
|
159
|
+
|
|
160
|
+
if (end <= start) return null;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
end,
|
|
164
|
+
size: end - start,
|
|
165
|
+
start,
|
|
166
|
+
};
|
|
106
167
|
}
|
|
107
168
|
|
|
108
169
|
/**
|
|
109
170
|
* Props for the ReactSway infinite scrolling component.
|
|
110
171
|
*/
|
|
111
172
|
export interface ReactSwayProps {
|
|
173
|
+
/** Scroll axis. Horizontal directions imply `horizontal` when omitted. @default 'vertical' */
|
|
174
|
+
axis?: SwayAxis;
|
|
112
175
|
/** Enable/disable auto-scrolling. @default true */
|
|
113
176
|
autoScroll?: boolean;
|
|
114
177
|
/** Content elements to render in the infinite scroll container. */
|
|
115
178
|
children: ReactNode;
|
|
116
179
|
/** Auto-scroll direction. @default 'up' */
|
|
117
|
-
direction?:
|
|
180
|
+
direction?: SwayDirection;
|
|
118
181
|
/** Enable mouse/touch drag interaction. @default true */
|
|
119
182
|
draggable?: boolean;
|
|
183
|
+
/** Only auto-scroll while the pointer hovers an axis boundary. @default false */
|
|
184
|
+
edgeHoverScroll?: boolean;
|
|
185
|
+
/** Edge-hover activation thickness in pixels. @default 96 */
|
|
186
|
+
edgeHoverSize?: number;
|
|
120
187
|
/** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
|
|
121
188
|
friction?: number;
|
|
122
|
-
/** Enable keyboard controls (Space,
|
|
189
|
+
/** Enable keyboard controls (Space, Arrow keys, Home/End). @default true */
|
|
123
190
|
keyboard?: boolean;
|
|
124
191
|
/** Enable lazy visibility detection via IntersectionObserver. @default true */
|
|
125
192
|
lazy?: boolean;
|
|
@@ -141,13 +208,24 @@ export interface ReactSwayProps {
|
|
|
141
208
|
speed?: number;
|
|
142
209
|
/** Enable mouse wheel scrolling. @default true */
|
|
143
210
|
wheelEnabled?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Defines which wheel gestures ReactSway may cancel.
|
|
213
|
+
*
|
|
214
|
+
* `axis` only consumes wheel gestures aligned with the Sway axis, letting
|
|
215
|
+
* cross-axis page scroll pass through. `capture` preserves legacy behavior
|
|
216
|
+
* by consuming every wheel event that reaches the component.
|
|
217
|
+
*
|
|
218
|
+
* @default 'axis'
|
|
219
|
+
*/
|
|
220
|
+
wheelMode?: SwayWheelMode;
|
|
144
221
|
}
|
|
145
222
|
|
|
146
223
|
/**
|
|
147
224
|
* A smooth, infinite scrolling container component.
|
|
148
225
|
*
|
|
149
226
|
* Renders children in a continuously looping scroll area with support for
|
|
150
|
-
* auto-scrolling, mouse drag, touch swipe, wheel,
|
|
227
|
+
* auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
|
|
228
|
+
* and optional edge-hover scrolling.
|
|
151
229
|
* Content is duplicated to create a seamless loop effect. Duplicate content
|
|
152
230
|
* is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
|
|
153
231
|
*
|
|
@@ -163,10 +241,13 @@ export interface ReactSwayProps {
|
|
|
163
241
|
* ```
|
|
164
242
|
*/
|
|
165
243
|
function ReactSway({
|
|
244
|
+
axis,
|
|
166
245
|
autoScroll = true,
|
|
167
246
|
children,
|
|
168
247
|
direction = 'up',
|
|
169
248
|
draggable = true,
|
|
249
|
+
edgeHoverScroll = false,
|
|
250
|
+
edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE,
|
|
170
251
|
friction = DEFAULT_FRICTION,
|
|
171
252
|
keyboard = true,
|
|
172
253
|
lazy = true,
|
|
@@ -179,7 +260,17 @@ function ReactSway({
|
|
|
179
260
|
resumeDelay = DEFAULT_RESUME_DELAY,
|
|
180
261
|
speed = DEFAULT_SPEED,
|
|
181
262
|
wheelEnabled = true,
|
|
263
|
+
wheelMode = 'axis',
|
|
182
264
|
}: ReactSwayProps) {
|
|
265
|
+
const scrollAxis = useMemo<SwayAxis>(
|
|
266
|
+
() => axis ?? (direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical'),
|
|
267
|
+
[axis, direction],
|
|
268
|
+
);
|
|
269
|
+
const isHorizontal = scrollAxis === 'horizontal';
|
|
270
|
+
const normalizedEdgeHoverSize = useMemo(
|
|
271
|
+
() => (Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : DEFAULT_EDGE_HOVER_SIZE),
|
|
272
|
+
[edgeHoverSize],
|
|
273
|
+
);
|
|
183
274
|
const normalizedFriction = useMemo(
|
|
184
275
|
() => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
|
|
185
276
|
[friction],
|
|
@@ -192,11 +283,19 @@ function ReactSway({
|
|
|
192
283
|
() => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
|
|
193
284
|
[speed],
|
|
194
285
|
);
|
|
286
|
+
const touchAction = useMemo(() => {
|
|
287
|
+
if (!draggable) return 'auto';
|
|
288
|
+
return isHorizontal ? 'pan-y' : 'pan-x';
|
|
289
|
+
}, [draggable, isHorizontal]);
|
|
290
|
+
// Containment is sound only when ReactSway owns every wheel event it receives;
|
|
291
|
+
// otherwise cross-axis and disabled-wheel page scroll must remain chainable.
|
|
292
|
+
const overscrollBehavior = wheelEnabled && wheelMode === 'capture' ? 'contain' : 'auto';
|
|
195
293
|
|
|
196
294
|
const [isDragging, setIsDragging] = useState(false);
|
|
197
295
|
const [isPaused, setIsPaused] = useState(false);
|
|
198
296
|
const [isTabActive, setIsTabActive] = useState(true);
|
|
199
297
|
const [loopPoint, setLoopPoint] = useState(0);
|
|
298
|
+
const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
|
|
200
299
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
|
201
300
|
if (typeof window === 'undefined') return false;
|
|
202
301
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
@@ -209,14 +308,21 @@ function ReactSway({
|
|
|
209
308
|
const isDraggingRef = useRef(false);
|
|
210
309
|
const isPausedRef = useRef(false);
|
|
211
310
|
const lastFrameTimeRef = useRef(0);
|
|
311
|
+
const lastMouseXRef = useRef(0);
|
|
212
312
|
const lastMouseYRef = useRef(0);
|
|
313
|
+
const lastTouchXRef = useRef(0);
|
|
213
314
|
const lastTouchYRef = useRef(0);
|
|
214
315
|
const loopPointRef = useRef(0);
|
|
316
|
+
const edgeHoverDirectionRef = useRef(0);
|
|
215
317
|
const onPauseRef = useRef(onPause);
|
|
216
318
|
const onResumeRef = useRef(onResume);
|
|
217
319
|
const onScrollRef = useRef(onScroll);
|
|
320
|
+
const originalGroupRef = useRef<HTMLDivElement>(null);
|
|
218
321
|
const positionRef = useRef(0);
|
|
219
322
|
const resizeDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
323
|
+
const touchInteractionStateRef = useRef<TouchInteractionState>('idle');
|
|
324
|
+
const touchStartXRef = useRef(0);
|
|
325
|
+
const touchStartYRef = useRef(0);
|
|
220
326
|
const velocityRef = useRef(0);
|
|
221
327
|
|
|
222
328
|
// Keep callback refs in sync without triggering re-renders
|
|
@@ -252,8 +358,10 @@ function ReactSway({
|
|
|
252
358
|
if (visualPosition > 0 && currentLoopPoint > 0) {
|
|
253
359
|
visualPosition -= currentLoopPoint;
|
|
254
360
|
}
|
|
255
|
-
containerRef.current.style.transform =
|
|
256
|
-
|
|
361
|
+
containerRef.current.style.transform = isHorizontal
|
|
362
|
+
? `translate3d(${visualPosition}px, 0, 0)`
|
|
363
|
+
: `translate3d(0, ${visualPosition}px, 0)`;
|
|
364
|
+
}, [isHorizontal]);
|
|
257
365
|
|
|
258
366
|
const commitPosition = useCallback((nextPosition: number) => {
|
|
259
367
|
if (positionRef.current === nextPosition) return;
|
|
@@ -277,15 +385,26 @@ function ReactSway({
|
|
|
277
385
|
}, []);
|
|
278
386
|
|
|
279
387
|
const recalculateLoopPoint = useCallback(() => {
|
|
280
|
-
if (!containerRef.current) return;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
388
|
+
if (!containerRef.current || !originalGroupRef.current) return;
|
|
389
|
+
const originalContentSize = isHorizontal
|
|
390
|
+
? originalGroupRef.current.scrollWidth
|
|
391
|
+
: originalGroupRef.current.scrollHeight;
|
|
392
|
+
if (originalContentSize <= 0) return;
|
|
393
|
+
|
|
394
|
+
const viewport = containerRef.current.parentElement ?? containerRef.current;
|
|
395
|
+
const viewportSize = isHorizontal
|
|
396
|
+
? Math.max(viewport.clientWidth, 1)
|
|
397
|
+
: Math.max(viewport.clientHeight, 1);
|
|
398
|
+
const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 2);
|
|
399
|
+
const nextLoopPoint = originalContentSize;
|
|
400
|
+
|
|
401
|
+
setLoopSegmentCount((previousLoopSegmentCount) =>
|
|
402
|
+
previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount
|
|
403
|
+
);
|
|
285
404
|
loopPointRef.current = nextLoopPoint;
|
|
286
405
|
setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
|
|
287
406
|
renderPosition(positionRef.current);
|
|
288
|
-
}, [renderPosition]);
|
|
407
|
+
}, [isHorizontal, renderPosition]);
|
|
289
408
|
|
|
290
409
|
// Sync autoScroll prop changes with internal state
|
|
291
410
|
useEffect(() => {
|
|
@@ -357,17 +476,33 @@ function ReactSway({
|
|
|
357
476
|
togglePause();
|
|
358
477
|
break;
|
|
359
478
|
case 'ArrowDown':
|
|
479
|
+
if (isHorizontal) break;
|
|
360
480
|
e.preventDefault();
|
|
361
481
|
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
362
482
|
pauseAutoScroll();
|
|
363
483
|
scheduleAutoScrollResume();
|
|
364
484
|
break;
|
|
365
485
|
case 'ArrowUp':
|
|
486
|
+
if (isHorizontal) break;
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
489
|
+
pauseAutoScroll();
|
|
490
|
+
scheduleAutoScrollResume();
|
|
491
|
+
break;
|
|
492
|
+
case 'ArrowLeft':
|
|
493
|
+
if (!isHorizontal) break;
|
|
366
494
|
e.preventDefault();
|
|
367
495
|
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
368
496
|
pauseAutoScroll();
|
|
369
497
|
scheduleAutoScrollResume();
|
|
370
498
|
break;
|
|
499
|
+
case 'ArrowRight':
|
|
500
|
+
if (!isHorizontal) break;
|
|
501
|
+
e.preventDefault();
|
|
502
|
+
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
503
|
+
pauseAutoScroll();
|
|
504
|
+
scheduleAutoScrollResume();
|
|
505
|
+
break;
|
|
371
506
|
case 'End':
|
|
372
507
|
e.preventDefault();
|
|
373
508
|
if (loopPointRef.current > 0) {
|
|
@@ -387,7 +522,7 @@ function ReactSway({
|
|
|
387
522
|
default:
|
|
388
523
|
break;
|
|
389
524
|
}
|
|
390
|
-
}, [commitPosition, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
|
|
525
|
+
}, [commitPosition, isHorizontal, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
|
|
391
526
|
|
|
392
527
|
const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
|
|
393
528
|
if (!draggable) return;
|
|
@@ -395,6 +530,7 @@ function ReactSway({
|
|
|
395
530
|
containerRef.current?.focus();
|
|
396
531
|
setIsDragging(true);
|
|
397
532
|
isDraggingRef.current = true;
|
|
533
|
+
lastMouseXRef.current = e.clientX;
|
|
398
534
|
lastMouseYRef.current = e.clientY;
|
|
399
535
|
velocityRef.current = 0;
|
|
400
536
|
pauseAutoScroll();
|
|
@@ -403,12 +539,13 @@ function ReactSway({
|
|
|
403
539
|
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
|
|
404
540
|
if (!isDraggingRef.current) return;
|
|
405
541
|
e.preventDefault();
|
|
406
|
-
const
|
|
407
|
-
const nextPosition = wrapPosition(positionRef.current +
|
|
542
|
+
const delta = isHorizontal ? e.clientX - lastMouseXRef.current : e.clientY - lastMouseYRef.current;
|
|
543
|
+
const nextPosition = wrapPosition(positionRef.current + delta);
|
|
408
544
|
commitPosition(nextPosition);
|
|
409
|
-
velocityRef.current =
|
|
545
|
+
velocityRef.current = delta;
|
|
546
|
+
lastMouseXRef.current = e.clientX;
|
|
410
547
|
lastMouseYRef.current = e.clientY;
|
|
411
|
-
}, [commitPosition, wrapPosition]);
|
|
548
|
+
}, [commitPosition, isHorizontal, wrapPosition]);
|
|
412
549
|
|
|
413
550
|
const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
|
|
414
551
|
if (!isDraggingRef.current) return;
|
|
@@ -419,48 +556,113 @@ function ReactSway({
|
|
|
419
556
|
}, [scheduleAutoScrollResume]);
|
|
420
557
|
|
|
421
558
|
const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
559
|
+
const wasActive = touchInteractionStateRef.current === 'active';
|
|
560
|
+
|
|
561
|
+
touchInteractionStateRef.current = 'idle';
|
|
562
|
+
if (isDraggingRef.current) {
|
|
563
|
+
setIsDragging(false);
|
|
564
|
+
isDraggingRef.current = false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (wasActive) {
|
|
568
|
+
scheduleAutoScrollResume();
|
|
569
|
+
}
|
|
426
570
|
}, [scheduleAutoScrollResume]);
|
|
427
571
|
|
|
428
572
|
const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
|
|
429
|
-
if (
|
|
430
|
-
e.
|
|
573
|
+
if (touchInteractionStateRef.current === 'idle' || touchInteractionStateRef.current === 'ignored') return;
|
|
574
|
+
if (e.touches.length !== 1) return;
|
|
575
|
+
|
|
431
576
|
const touch = e.touches[0];
|
|
432
|
-
|
|
433
|
-
|
|
577
|
+
|
|
578
|
+
if (touchInteractionStateRef.current === 'pending') {
|
|
579
|
+
const totalDeltaX = touch.clientX - touchStartXRef.current;
|
|
580
|
+
const totalDeltaY = touch.clientY - touchStartYRef.current;
|
|
581
|
+
const primaryDelta = isHorizontal ? Math.abs(totalDeltaX) : Math.abs(totalDeltaY);
|
|
582
|
+
const crossDelta = isHorizontal ? Math.abs(totalDeltaY) : Math.abs(totalDeltaX);
|
|
583
|
+
|
|
584
|
+
if (Math.max(primaryDelta, crossDelta) < TOUCH_AXIS_LOCK_THRESHOLD_PX) return;
|
|
585
|
+
|
|
586
|
+
if (crossDelta > primaryDelta) {
|
|
587
|
+
touchInteractionStateRef.current = 'ignored';
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
touchInteractionStateRef.current = 'active';
|
|
592
|
+
setIsDragging(true);
|
|
593
|
+
isDraggingRef.current = true;
|
|
594
|
+
pauseAutoScroll();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
e.preventDefault();
|
|
598
|
+
const delta = isHorizontal ? touch.clientX - lastTouchXRef.current : touch.clientY - lastTouchYRef.current;
|
|
599
|
+
const nextPosition = wrapPosition(positionRef.current + delta);
|
|
434
600
|
commitPosition(nextPosition);
|
|
435
|
-
velocityRef.current =
|
|
601
|
+
velocityRef.current = delta;
|
|
602
|
+
lastTouchXRef.current = touch.clientX;
|
|
436
603
|
lastTouchYRef.current = touch.clientY;
|
|
437
|
-
}, [commitPosition, wrapPosition]);
|
|
604
|
+
}, [commitPosition, isHorizontal, pauseAutoScroll, wrapPosition]);
|
|
438
605
|
|
|
439
606
|
const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
|
|
440
607
|
if (!draggable || e.touches.length !== 1) return;
|
|
441
608
|
containerRef.current?.focus();
|
|
442
|
-
|
|
443
|
-
|
|
609
|
+
touchInteractionStateRef.current = 'pending';
|
|
610
|
+
lastTouchXRef.current = e.touches[0].clientX;
|
|
444
611
|
lastTouchYRef.current = e.touches[0].clientY;
|
|
612
|
+
touchStartXRef.current = e.touches[0].clientX;
|
|
613
|
+
touchStartYRef.current = e.touches[0].clientY;
|
|
445
614
|
velocityRef.current = 0;
|
|
446
|
-
|
|
447
|
-
}, [draggable, pauseAutoScroll]);
|
|
615
|
+
}, [draggable]);
|
|
448
616
|
|
|
449
617
|
const handleWheel = useCallback((e: globalThis.WheelEvent) => {
|
|
450
618
|
if (!wheelEnabled) return;
|
|
451
619
|
const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
|
|
452
620
|
if (!currentContainer) return;
|
|
453
621
|
|
|
622
|
+
const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
|
|
623
|
+
if (ownedDelta === null) return;
|
|
624
|
+
|
|
454
625
|
e.preventDefault();
|
|
455
|
-
const
|
|
456
|
-
const wheelDelta = -normalizedDeltaY;
|
|
626
|
+
const wheelDelta = -ownedDelta;
|
|
457
627
|
const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
|
|
458
628
|
commitPosition(nextPosition);
|
|
459
629
|
velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
|
|
460
630
|
velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
|
|
461
631
|
pauseAutoScroll();
|
|
462
632
|
scheduleAutoScrollResume();
|
|
463
|
-
}, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
|
|
633
|
+
}, [commitPosition, isHorizontal, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wheelMode, wrapPosition]);
|
|
634
|
+
|
|
635
|
+
const handleEdgeHoverMove = useCallback((e: globalThis.MouseEvent) => {
|
|
636
|
+
if (!edgeHoverScroll) return;
|
|
637
|
+
const viewport = containerRef.current?.parentElement ?? containerRef.current;
|
|
638
|
+
if (!viewport) return;
|
|
639
|
+
|
|
640
|
+
const rect = viewport.getBoundingClientRect();
|
|
641
|
+
const visibleInterval = getVisibleEdgeInterval(rect, isHorizontal);
|
|
642
|
+
if (!visibleInterval) {
|
|
643
|
+
edgeHoverDirectionRef.current = 0;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const pointerPosition = isHorizontal ? e.clientX : e.clientY;
|
|
648
|
+
const effectiveEdgeHoverSize = Math.min(normalizedEdgeHoverSize, visibleInterval.size / 2);
|
|
649
|
+
|
|
650
|
+
if (pointerPosition <= visibleInterval.start + effectiveEdgeHoverSize) {
|
|
651
|
+
edgeHoverDirectionRef.current = 1;
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (pointerPosition >= visibleInterval.end - effectiveEdgeHoverSize) {
|
|
656
|
+
edgeHoverDirectionRef.current = -1;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
edgeHoverDirectionRef.current = 0;
|
|
661
|
+
}, [edgeHoverScroll, isHorizontal, normalizedEdgeHoverSize]);
|
|
662
|
+
|
|
663
|
+
const handleEdgeHoverLeave = useCallback(() => {
|
|
664
|
+
edgeHoverDirectionRef.current = 0;
|
|
665
|
+
}, []);
|
|
464
666
|
|
|
465
667
|
// Event listener registration
|
|
466
668
|
useEffect(() => {
|
|
@@ -470,21 +672,34 @@ function ReactSway({
|
|
|
470
672
|
currentContainer.addEventListener('mousedown', handleMouseDown);
|
|
471
673
|
window.addEventListener('mousemove', handleMouseMove);
|
|
472
674
|
window.addEventListener('mouseup', handleMouseUp);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
675
|
+
if (draggable) {
|
|
676
|
+
currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
677
|
+
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
678
|
+
window.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
679
|
+
}
|
|
680
|
+
if (wheelEnabled) {
|
|
681
|
+
currentContainer.addEventListener('wheel', handleWheel, { passive: false });
|
|
682
|
+
}
|
|
683
|
+
const edgeHoverTarget = currentContainer.parentElement ?? currentContainer;
|
|
684
|
+
edgeHoverTarget.addEventListener('mousemove', handleEdgeHoverMove);
|
|
685
|
+
edgeHoverTarget.addEventListener('mouseleave', handleEdgeHoverLeave);
|
|
477
686
|
|
|
478
687
|
return () => {
|
|
479
688
|
currentContainer.removeEventListener('mousedown', handleMouseDown);
|
|
480
689
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
481
690
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
691
|
+
if (draggable) {
|
|
692
|
+
currentContainer.removeEventListener('touchstart', handleTouchStart);
|
|
693
|
+
window.removeEventListener('touchmove', handleTouchMove);
|
|
694
|
+
window.removeEventListener('touchend', handleTouchEnd);
|
|
695
|
+
}
|
|
696
|
+
if (wheelEnabled) {
|
|
697
|
+
currentContainer.removeEventListener('wheel', handleWheel);
|
|
698
|
+
}
|
|
699
|
+
edgeHoverTarget.removeEventListener('mousemove', handleEdgeHoverMove);
|
|
700
|
+
edgeHoverTarget.removeEventListener('mouseleave', handleEdgeHoverLeave);
|
|
486
701
|
};
|
|
487
|
-
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel]);
|
|
702
|
+
}, [draggable, handleEdgeHoverLeave, handleEdgeHoverMove, handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel, wheelEnabled]);
|
|
488
703
|
|
|
489
704
|
// Debounced resize handler shared by ResizeObserver and window resize fallback
|
|
490
705
|
const debouncedRecalculate = useCallback(() => {
|
|
@@ -565,7 +780,7 @@ function ReactSway({
|
|
|
565
780
|
// Reset so the first frame uses deltaTime=1 instead of a stale timestamp
|
|
566
781
|
lastFrameTimeRef.current = 0;
|
|
567
782
|
|
|
568
|
-
const directionMultiplier = direction === 'down' ? 1 : -1;
|
|
783
|
+
const directionMultiplier = direction === 'down' || direction === 'right' ? 1 : -1;
|
|
569
784
|
|
|
570
785
|
const animate = (currentTime: number) => {
|
|
571
786
|
let deltaTime = lastFrameTimeRef.current
|
|
@@ -590,9 +805,16 @@ function ReactSway({
|
|
|
590
805
|
? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
|
|
591
806
|
: normalizedSpeed;
|
|
592
807
|
|
|
808
|
+
const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
|
|
809
|
+
|
|
593
810
|
// Auto-scroll when enabled and not dragging
|
|
594
|
-
if (
|
|
595
|
-
|
|
811
|
+
if (
|
|
812
|
+
autoScrollRef.current.desired &&
|
|
813
|
+
autoScrollRef.current.active &&
|
|
814
|
+
!isDraggingRef.current &&
|
|
815
|
+
(!edgeHoverScroll || edgeHoverMultiplier !== 0)
|
|
816
|
+
) {
|
|
817
|
+
nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
|
|
596
818
|
}
|
|
597
819
|
|
|
598
820
|
// Apply velocity momentum from user interaction
|
|
@@ -613,7 +835,7 @@ function ReactSway({
|
|
|
613
835
|
cancelAnimationFrame(animationFrameRef.current);
|
|
614
836
|
}
|
|
615
837
|
};
|
|
616
|
-
}, [commitPosition, direction, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
|
|
838
|
+
}, [commitPosition, direction, edgeHoverScroll, isHorizontal, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
|
|
617
839
|
|
|
618
840
|
// Intersection Observer for lazy visibility detection
|
|
619
841
|
useEffect(() => {
|
|
@@ -649,32 +871,42 @@ function ReactSway({
|
|
|
649
871
|
ref={containerRef}
|
|
650
872
|
style={{
|
|
651
873
|
cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
|
874
|
+
display: isHorizontal ? 'flex' : undefined,
|
|
875
|
+
flexDirection: isHorizontal ? 'row' : undefined,
|
|
876
|
+
height: isHorizontal ? '100%' : 'max-content',
|
|
652
877
|
MozUserSelect: 'none',
|
|
653
878
|
msUserSelect: 'none',
|
|
879
|
+
minHeight: isHorizontal ? undefined : '100%',
|
|
654
880
|
overflow: 'hidden',
|
|
655
|
-
overscrollBehavior
|
|
881
|
+
overscrollBehavior,
|
|
656
882
|
pointerEvents: 'auto',
|
|
657
883
|
position: 'absolute',
|
|
658
|
-
touchAction
|
|
659
|
-
transform: 'translate3d(0,
|
|
884
|
+
touchAction,
|
|
885
|
+
transform: 'translate3d(0, 0, 0)',
|
|
660
886
|
userSelect: 'none',
|
|
661
887
|
WebkitUserSelect: 'none',
|
|
662
|
-
width: '100%',
|
|
888
|
+
width: isHorizontal ? 'max-content' : '100%',
|
|
663
889
|
willChange: 'transform',
|
|
664
890
|
zIndex: 1,
|
|
665
891
|
}}
|
|
666
892
|
onKeyDown={keyboard ? handleKeyDown : undefined}
|
|
667
893
|
tabIndex={keyboard ? 0 : undefined}
|
|
668
894
|
>
|
|
669
|
-
<div className="content-group original">
|
|
895
|
+
<div className="content-group original" ref={originalGroupRef} style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}>
|
|
670
896
|
{children}
|
|
671
897
|
</div>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
898
|
+
{Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => (
|
|
899
|
+
<aside
|
|
900
|
+
aria-hidden="true"
|
|
901
|
+
className="content-group duplicate"
|
|
902
|
+
data-duplicate="true"
|
|
903
|
+
key={`duplicate-${duplicateIndex}`}
|
|
904
|
+
role="presentation"
|
|
905
|
+
style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}
|
|
906
|
+
>
|
|
907
|
+
{children}
|
|
908
|
+
</aside>
|
|
909
|
+
))}
|
|
678
910
|
</div>
|
|
679
911
|
);
|
|
680
912
|
}
|
package/src/index.ts
CHANGED