react-sway 0.2.7 → 0.3.1
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/dist/index.js
CHANGED
|
@@ -1,478 +1,664 @@
|
|
|
1
|
-
// src/ReactSway.tsx
|
|
2
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
//#region src/ReactSway.tsx
|
|
4
|
+
/**
|
|
5
|
+
* Core ReactSway component implementing axis-aware infinite scrolling interactions.
|
|
6
|
+
*/
|
|
7
|
+
/** Velocity applied per arrow key press (pixels). */
|
|
4
8
|
var ARROW_KEY_VELOCITY = 15;
|
|
5
|
-
|
|
9
|
+
/** Default edge-hover activation thickness in pixels. */
|
|
10
|
+
var DEFAULT_EDGE_HOVER_SIZE = 96;
|
|
11
|
+
/** Default friction coefficient applied to velocity each frame. */
|
|
12
|
+
var DEFAULT_FRICTION = .95;
|
|
13
|
+
/** Default IntersectionObserver rootMargin for lazy visibility detection. */
|
|
6
14
|
var DEFAULT_LAZY_ROOT_MARGIN = "100px";
|
|
7
|
-
|
|
15
|
+
/** Default IntersectionObserver threshold for lazy visibility detection. */
|
|
16
|
+
var DEFAULT_LAZY_THRESHOLD = .01;
|
|
17
|
+
/** Default delay in milliseconds before auto-scroll resumes after user interaction. */
|
|
8
18
|
var DEFAULT_RESUME_DELAY = 2e3;
|
|
9
|
-
|
|
10
|
-
var
|
|
19
|
+
/** Default scroll speed in pixels per frame at 60fps. */
|
|
20
|
+
var DEFAULT_SPEED = .5;
|
|
21
|
+
/** Number of stacked content groups used to build the seamless loop. */
|
|
22
|
+
var MIN_LOOP_SEGMENTS = 3;
|
|
23
|
+
/** Maximum deltaTime cap to prevent physics instability during frame drops. */
|
|
11
24
|
var MAX_DELTA_TIME = 3;
|
|
25
|
+
/** Maximum allowed velocity magnitude to prevent runaway scrolling. */
|
|
12
26
|
var MAX_VELOCITY = 150;
|
|
27
|
+
/** Duration of a single frame at 60fps in milliseconds. */
|
|
13
28
|
var MS_PER_FRAME_60FPS = 16.667;
|
|
14
|
-
|
|
29
|
+
/** Speed multiplier when user prefers reduced motion (25% of normal). */
|
|
30
|
+
var REDUCED_MOTION_SPEED_FACTOR = .25;
|
|
31
|
+
/** Debounce delay in milliseconds for ResizeObserver callbacks. */
|
|
15
32
|
var RESIZE_DEBOUNCE_MS = 150;
|
|
33
|
+
/** Fallback pixel height for wheel events reported in line units. */
|
|
16
34
|
var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
|
|
35
|
+
/** WheelEvent deltaMode value for line-based deltas. */
|
|
17
36
|
var WHEEL_MODE_LINE = 1;
|
|
37
|
+
/** WheelEvent deltaMode value for page-based deltas. */
|
|
18
38
|
var WHEEL_MODE_PAGE = 2;
|
|
39
|
+
/** Minimum pixel impulse for discrete wheel hardware reporting tiny pixel deltas. */
|
|
19
40
|
var WHEEL_PIXEL_MIN_DELTA_PX = 48;
|
|
20
|
-
|
|
21
|
-
var
|
|
41
|
+
/** Multiplier applied to wheel deltaY to convert to scroll velocity. */
|
|
42
|
+
var WHEEL_VELOCITY_MULTIPLIER = .14;
|
|
43
|
+
/** Small immediate wheel movement used to keep input responsive without jumping. */
|
|
44
|
+
var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
|
|
45
|
+
/** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
|
|
22
46
|
var WHEEL_LEGACY_NOTCH_DELTA = 120;
|
|
47
|
+
/** Minimum touch movement before a gesture is classified by axis. */
|
|
48
|
+
var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
|
|
23
49
|
function getLegacyWheelPixelDeltaY(event) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
|
|
50
|
+
const { wheelDelta, wheelDeltaY } = event;
|
|
51
|
+
const legacyWheelDelta = typeof wheelDeltaY === "number" ? wheelDeltaY : wheelDelta;
|
|
52
|
+
if (typeof legacyWheelDelta !== "number" || !Number.isFinite(legacyWheelDelta)) return 0;
|
|
53
|
+
return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
|
|
30
54
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
|
|
57
|
+
* devices feed the same Sway velocity system.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeWheelDelta(event, container, axis) {
|
|
60
|
+
const rawDelta = axis === "x" ? event.deltaX : event.deltaY;
|
|
61
|
+
if (event.deltaMode === WHEEL_MODE_LINE) {
|
|
62
|
+
const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
|
|
63
|
+
return rawDelta * (Number.isFinite(computedLineHeight) ? computedLineHeight : WHEEL_LINE_HEIGHT_FALLBACK_PX);
|
|
64
|
+
}
|
|
65
|
+
if (event.deltaMode === WHEEL_MODE_PAGE) return rawDelta * (axis === "x" ? Math.max(container.clientWidth, window.innerWidth, 1) : Math.max(container.clientHeight, window.innerHeight, 1));
|
|
66
|
+
if (axis === "x") return rawDelta;
|
|
67
|
+
const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
|
|
68
|
+
if (Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX && Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX) return legacyWheelPixelDeltaY;
|
|
69
|
+
return rawDelta;
|
|
46
70
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
onResume,
|
|
59
|
-
onScroll,
|
|
60
|
-
pauseOnInteraction = true,
|
|
61
|
-
resumeDelay = DEFAULT_RESUME_DELAY,
|
|
62
|
-
speed = DEFAULT_SPEED,
|
|
63
|
-
wheelEnabled = true
|
|
64
|
-
}) {
|
|
65
|
-
const normalizedFriction = useMemo(
|
|
66
|
-
() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION,
|
|
67
|
-
[friction]
|
|
68
|
-
);
|
|
69
|
-
const normalizedResumeDelay = useMemo(
|
|
70
|
-
() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY,
|
|
71
|
-
[resumeDelay]
|
|
72
|
-
);
|
|
73
|
-
const normalizedSpeed = useMemo(
|
|
74
|
-
() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED,
|
|
75
|
-
[speed]
|
|
76
|
-
);
|
|
77
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
78
|
-
const [isPaused, setIsPaused] = useState(false);
|
|
79
|
-
const [isTabActive, setIsTabActive] = useState(true);
|
|
80
|
-
const [loopPoint, setLoopPoint] = useState(0);
|
|
81
|
-
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
|
82
|
-
if (typeof window === "undefined") return false;
|
|
83
|
-
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
84
|
-
});
|
|
85
|
-
const animationFrameRef = useRef(null);
|
|
86
|
-
const autoScrollRef = useRef({ active: autoScroll, desired: autoScroll });
|
|
87
|
-
const containerRef = useRef(null);
|
|
88
|
-
const inactivityTimerRef = useRef(null);
|
|
89
|
-
const isDraggingRef = useRef(false);
|
|
90
|
-
const isPausedRef = useRef(false);
|
|
91
|
-
const lastFrameTimeRef = useRef(0);
|
|
92
|
-
const lastMouseYRef = useRef(0);
|
|
93
|
-
const lastTouchYRef = useRef(0);
|
|
94
|
-
const loopPointRef = useRef(0);
|
|
95
|
-
const onPauseRef = useRef(onPause);
|
|
96
|
-
const onResumeRef = useRef(onResume);
|
|
97
|
-
const onScrollRef = useRef(onScroll);
|
|
98
|
-
const positionRef = useRef(0);
|
|
99
|
-
const resizeDebounceTimerRef = useRef(null);
|
|
100
|
-
const velocityRef = useRef(0);
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
onPauseRef.current = onPause;
|
|
103
|
-
onResumeRef.current = onResume;
|
|
104
|
-
onScrollRef.current = onScroll;
|
|
105
|
-
}, [onPause, onResume, onScroll]);
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
if (typeof window === "undefined") return;
|
|
108
|
-
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
109
|
-
const handleChange = (e) => {
|
|
110
|
-
setPrefersReducedMotion(e.matches);
|
|
111
|
-
};
|
|
112
|
-
mediaQuery.addEventListener("change", handleChange);
|
|
113
|
-
return () => {
|
|
114
|
-
mediaQuery.removeEventListener("change", handleChange);
|
|
115
|
-
};
|
|
116
|
-
}, []);
|
|
117
|
-
const clearInactivityTimer = useCallback(() => {
|
|
118
|
-
if (!inactivityTimerRef.current) return;
|
|
119
|
-
clearTimeout(inactivityTimerRef.current);
|
|
120
|
-
inactivityTimerRef.current = null;
|
|
121
|
-
}, []);
|
|
122
|
-
const renderPosition = useCallback((rawPosition) => {
|
|
123
|
-
if (!containerRef.current) return;
|
|
124
|
-
const currentLoopPoint = loopPointRef.current;
|
|
125
|
-
let visualPosition = rawPosition % (currentLoopPoint || 1);
|
|
126
|
-
if (visualPosition > 0 && currentLoopPoint > 0) {
|
|
127
|
-
visualPosition -= currentLoopPoint;
|
|
128
|
-
}
|
|
129
|
-
containerRef.current.style.transform = `translate3d(0, ${visualPosition}px, 0)`;
|
|
130
|
-
}, []);
|
|
131
|
-
const commitPosition = useCallback((nextPosition) => {
|
|
132
|
-
if (positionRef.current === nextPosition) return;
|
|
133
|
-
positionRef.current = nextPosition;
|
|
134
|
-
renderPosition(nextPosition);
|
|
135
|
-
onScrollRef.current?.(nextPosition);
|
|
136
|
-
}, [renderPosition]);
|
|
137
|
-
const wrapPosition = useCallback((rawPosition) => {
|
|
138
|
-
const currentLoopPoint = loopPointRef.current;
|
|
139
|
-
if (currentLoopPoint <= 0) return rawPosition;
|
|
140
|
-
let wrappedPosition = rawPosition;
|
|
141
|
-
while (wrappedPosition > 0) {
|
|
142
|
-
wrappedPosition -= currentLoopPoint;
|
|
143
|
-
}
|
|
144
|
-
while (wrappedPosition < -currentLoopPoint * 2) {
|
|
145
|
-
wrappedPosition += currentLoopPoint;
|
|
146
|
-
}
|
|
147
|
-
return wrappedPosition;
|
|
148
|
-
}, []);
|
|
149
|
-
const recalculateLoopPoint = useCallback(() => {
|
|
150
|
-
if (!containerRef.current) return;
|
|
151
|
-
const currentContentHeight = containerRef.current.scrollHeight;
|
|
152
|
-
if (currentContentHeight <= 0) return;
|
|
153
|
-
const nextLoopPoint = currentContentHeight / LOOP_SEGMENTS;
|
|
154
|
-
loopPointRef.current = nextLoopPoint;
|
|
155
|
-
setLoopPoint((previousLoopPoint) => Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint);
|
|
156
|
-
renderPosition(positionRef.current);
|
|
157
|
-
}, [renderPosition]);
|
|
158
|
-
useEffect(() => {
|
|
159
|
-
autoScrollRef.current.desired = autoScroll;
|
|
160
|
-
if (!autoScroll) {
|
|
161
|
-
clearInactivityTimer();
|
|
162
|
-
autoScrollRef.current.active = true;
|
|
163
|
-
}
|
|
164
|
-
}, [autoScroll, clearInactivityTimer]);
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
loopPointRef.current = loopPoint;
|
|
167
|
-
renderPosition(positionRef.current);
|
|
168
|
-
}, [loopPoint, renderPosition]);
|
|
169
|
-
useEffect(() => {
|
|
170
|
-
const rafId = requestAnimationFrame(recalculateLoopPoint);
|
|
171
|
-
return () => {
|
|
172
|
-
cancelAnimationFrame(rafId);
|
|
173
|
-
};
|
|
174
|
-
}, [children, recalculateLoopPoint]);
|
|
175
|
-
const pauseAutoScroll = useCallback(() => {
|
|
176
|
-
if (!pauseOnInteraction) return;
|
|
177
|
-
autoScrollRef.current.active = false;
|
|
178
|
-
onPauseRef.current?.();
|
|
179
|
-
clearInactivityTimer();
|
|
180
|
-
}, [clearInactivityTimer, pauseOnInteraction]);
|
|
181
|
-
const scheduleAutoScrollResume = useCallback(() => {
|
|
182
|
-
if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
|
|
183
|
-
clearInactivityTimer();
|
|
184
|
-
inactivityTimerRef.current = setTimeout(() => {
|
|
185
|
-
inactivityTimerRef.current = null;
|
|
186
|
-
if (!autoScrollRef.current.desired || isPausedRef.current) return;
|
|
187
|
-
autoScrollRef.current.active = true;
|
|
188
|
-
onResumeRef.current?.();
|
|
189
|
-
}, normalizedResumeDelay);
|
|
190
|
-
}, [clearInactivityTimer, normalizedResumeDelay, pauseOnInteraction]);
|
|
191
|
-
const togglePause = useCallback(() => {
|
|
192
|
-
const newPaused = !isPausedRef.current;
|
|
193
|
-
isPausedRef.current = newPaused;
|
|
194
|
-
setIsPaused(newPaused);
|
|
195
|
-
if (newPaused) {
|
|
196
|
-
clearInactivityTimer();
|
|
197
|
-
autoScrollRef.current.active = false;
|
|
198
|
-
onPauseRef.current?.();
|
|
199
|
-
} else {
|
|
200
|
-
clearInactivityTimer();
|
|
201
|
-
autoScrollRef.current.active = true;
|
|
202
|
-
if (autoScrollRef.current.desired) {
|
|
203
|
-
onResumeRef.current?.();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}, [clearInactivityTimer]);
|
|
207
|
-
const handleKeyDown = useCallback((e) => {
|
|
208
|
-
if (!keyboard) return;
|
|
209
|
-
switch (e.key) {
|
|
210
|
-
case " ":
|
|
211
|
-
e.preventDefault();
|
|
212
|
-
togglePause();
|
|
213
|
-
break;
|
|
214
|
-
case "ArrowDown":
|
|
215
|
-
e.preventDefault();
|
|
216
|
-
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
217
|
-
pauseAutoScroll();
|
|
218
|
-
scheduleAutoScrollResume();
|
|
219
|
-
break;
|
|
220
|
-
case "ArrowUp":
|
|
221
|
-
e.preventDefault();
|
|
222
|
-
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
223
|
-
pauseAutoScroll();
|
|
224
|
-
scheduleAutoScrollResume();
|
|
225
|
-
break;
|
|
226
|
-
case "End":
|
|
227
|
-
e.preventDefault();
|
|
228
|
-
if (loopPointRef.current > 0) {
|
|
229
|
-
commitPosition(-loopPointRef.current);
|
|
230
|
-
}
|
|
231
|
-
velocityRef.current = 0;
|
|
232
|
-
pauseAutoScroll();
|
|
233
|
-
scheduleAutoScrollResume();
|
|
234
|
-
break;
|
|
235
|
-
case "Home":
|
|
236
|
-
e.preventDefault();
|
|
237
|
-
commitPosition(0);
|
|
238
|
-
velocityRef.current = 0;
|
|
239
|
-
pauseAutoScroll();
|
|
240
|
-
scheduleAutoScrollResume();
|
|
241
|
-
break;
|
|
242
|
-
default:
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
}, [commitPosition, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
|
|
246
|
-
const handleMouseDown = useCallback((e) => {
|
|
247
|
-
if (!draggable) return;
|
|
248
|
-
e.preventDefault();
|
|
249
|
-
containerRef.current?.focus();
|
|
250
|
-
setIsDragging(true);
|
|
251
|
-
isDraggingRef.current = true;
|
|
252
|
-
lastMouseYRef.current = e.clientY;
|
|
253
|
-
velocityRef.current = 0;
|
|
254
|
-
pauseAutoScroll();
|
|
255
|
-
}, [draggable, pauseAutoScroll]);
|
|
256
|
-
const handleMouseMove = useCallback((e) => {
|
|
257
|
-
if (!isDraggingRef.current) return;
|
|
258
|
-
e.preventDefault();
|
|
259
|
-
const deltaY = e.clientY - lastMouseYRef.current;
|
|
260
|
-
const nextPosition = wrapPosition(positionRef.current + deltaY);
|
|
261
|
-
commitPosition(nextPosition);
|
|
262
|
-
velocityRef.current = deltaY;
|
|
263
|
-
lastMouseYRef.current = e.clientY;
|
|
264
|
-
}, [commitPosition, wrapPosition]);
|
|
265
|
-
const handleMouseUp = useCallback((e) => {
|
|
266
|
-
if (!isDraggingRef.current) return;
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
setIsDragging(false);
|
|
269
|
-
isDraggingRef.current = false;
|
|
270
|
-
scheduleAutoScrollResume();
|
|
271
|
-
}, [scheduleAutoScrollResume]);
|
|
272
|
-
const handleTouchEnd = useCallback((_e) => {
|
|
273
|
-
if (!isDraggingRef.current) return;
|
|
274
|
-
setIsDragging(false);
|
|
275
|
-
isDraggingRef.current = false;
|
|
276
|
-
scheduleAutoScrollResume();
|
|
277
|
-
}, [scheduleAutoScrollResume]);
|
|
278
|
-
const handleTouchMove = useCallback((e) => {
|
|
279
|
-
if (!isDraggingRef.current || e.touches.length !== 1) return;
|
|
280
|
-
e.preventDefault();
|
|
281
|
-
const touch = e.touches[0];
|
|
282
|
-
const deltaY = touch.clientY - lastTouchYRef.current;
|
|
283
|
-
const nextPosition = wrapPosition(positionRef.current + deltaY);
|
|
284
|
-
commitPosition(nextPosition);
|
|
285
|
-
velocityRef.current = deltaY;
|
|
286
|
-
lastTouchYRef.current = touch.clientY;
|
|
287
|
-
}, [commitPosition, wrapPosition]);
|
|
288
|
-
const handleTouchStart = useCallback((e) => {
|
|
289
|
-
if (!draggable || e.touches.length !== 1) return;
|
|
290
|
-
containerRef.current?.focus();
|
|
291
|
-
setIsDragging(true);
|
|
292
|
-
isDraggingRef.current = true;
|
|
293
|
-
lastTouchYRef.current = e.touches[0].clientY;
|
|
294
|
-
velocityRef.current = 0;
|
|
295
|
-
pauseAutoScroll();
|
|
296
|
-
}, [draggable, pauseAutoScroll]);
|
|
297
|
-
const handleWheel = useCallback((e) => {
|
|
298
|
-
if (!wheelEnabled) return;
|
|
299
|
-
const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
|
|
300
|
-
if (!currentContainer) return;
|
|
301
|
-
e.preventDefault();
|
|
302
|
-
const normalizedDeltaY = normalizeWheelDeltaY(e, currentContainer);
|
|
303
|
-
const wheelDelta = -normalizedDeltaY;
|
|
304
|
-
const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
|
|
305
|
-
commitPosition(nextPosition);
|
|
306
|
-
velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
|
|
307
|
-
velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
|
|
308
|
-
pauseAutoScroll();
|
|
309
|
-
scheduleAutoScrollResume();
|
|
310
|
-
}, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
|
|
311
|
-
useEffect(() => {
|
|
312
|
-
const currentContainer = containerRef.current;
|
|
313
|
-
if (!currentContainer) return;
|
|
314
|
-
currentContainer.addEventListener("mousedown", handleMouseDown);
|
|
315
|
-
window.addEventListener("mousemove", handleMouseMove);
|
|
316
|
-
window.addEventListener("mouseup", handleMouseUp);
|
|
317
|
-
currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
318
|
-
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
319
|
-
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
320
|
-
currentContainer.addEventListener("wheel", handleWheel, { passive: false });
|
|
321
|
-
return () => {
|
|
322
|
-
currentContainer.removeEventListener("mousedown", handleMouseDown);
|
|
323
|
-
window.removeEventListener("mousemove", handleMouseMove);
|
|
324
|
-
window.removeEventListener("mouseup", handleMouseUp);
|
|
325
|
-
currentContainer.removeEventListener("touchstart", handleTouchStart);
|
|
326
|
-
window.removeEventListener("touchmove", handleTouchMove);
|
|
327
|
-
window.removeEventListener("touchend", handleTouchEnd);
|
|
328
|
-
currentContainer.removeEventListener("wheel", handleWheel);
|
|
329
|
-
};
|
|
330
|
-
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel]);
|
|
331
|
-
const debouncedRecalculate = useCallback(() => {
|
|
332
|
-
if (resizeDebounceTimerRef.current) {
|
|
333
|
-
clearTimeout(resizeDebounceTimerRef.current);
|
|
334
|
-
}
|
|
335
|
-
resizeDebounceTimerRef.current = setTimeout(() => {
|
|
336
|
-
resizeDebounceTimerRef.current = null;
|
|
337
|
-
recalculateLoopPoint();
|
|
338
|
-
}, RESIZE_DEBOUNCE_MS);
|
|
339
|
-
}, [recalculateLoopPoint]);
|
|
340
|
-
useEffect(() => {
|
|
341
|
-
window.addEventListener("resize", debouncedRecalculate);
|
|
342
|
-
return () => {
|
|
343
|
-
window.removeEventListener("resize", debouncedRecalculate);
|
|
344
|
-
};
|
|
345
|
-
}, [debouncedRecalculate]);
|
|
346
|
-
useEffect(() => {
|
|
347
|
-
if (!containerRef.current || typeof ResizeObserver === "undefined") return;
|
|
348
|
-
const observer = new ResizeObserver(() => {
|
|
349
|
-
debouncedRecalculate();
|
|
350
|
-
});
|
|
351
|
-
observer.observe(containerRef.current);
|
|
352
|
-
return () => {
|
|
353
|
-
observer.disconnect();
|
|
354
|
-
};
|
|
355
|
-
}, [debouncedRecalculate]);
|
|
356
|
-
useEffect(() => {
|
|
357
|
-
return () => {
|
|
358
|
-
if (resizeDebounceTimerRef.current) {
|
|
359
|
-
clearTimeout(resizeDebounceTimerRef.current);
|
|
360
|
-
resizeDebounceTimerRef.current = null;
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
}, []);
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
const handleVisibilityChange = () => {
|
|
366
|
-
setIsTabActive(!document.hidden);
|
|
367
|
-
if (!document.hidden) {
|
|
368
|
-
lastFrameTimeRef.current = performance.now();
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
372
|
-
return () => {
|
|
373
|
-
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
374
|
-
};
|
|
375
|
-
}, []);
|
|
376
|
-
useEffect(() => {
|
|
377
|
-
return () => {
|
|
378
|
-
clearInactivityTimer();
|
|
379
|
-
};
|
|
380
|
-
}, [clearInactivityTimer]);
|
|
381
|
-
useEffect(() => {
|
|
382
|
-
if (!isTabActive || isPaused) {
|
|
383
|
-
if (animationFrameRef.current) {
|
|
384
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
385
|
-
animationFrameRef.current = null;
|
|
386
|
-
}
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
lastFrameTimeRef.current = 0;
|
|
390
|
-
const directionMultiplier = direction === "down" ? 1 : -1;
|
|
391
|
-
const animate = (currentTime) => {
|
|
392
|
-
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS : 1;
|
|
393
|
-
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
394
|
-
lastFrameTimeRef.current = currentTime;
|
|
395
|
-
if (prefersReducedMotion) {
|
|
396
|
-
velocityRef.current = 0;
|
|
397
|
-
} else if (Math.abs(velocityRef.current) > 0.1) {
|
|
398
|
-
velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
|
|
399
|
-
} else {
|
|
400
|
-
velocityRef.current = 0;
|
|
401
|
-
}
|
|
402
|
-
let nextPosition = positionRef.current;
|
|
403
|
-
const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
|
|
404
|
-
if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current) {
|
|
405
|
-
nextPosition += directionMultiplier * effectiveSpeed * deltaTime;
|
|
406
|
-
}
|
|
407
|
-
if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
|
|
408
|
-
nextPosition += velocityRef.current * deltaTime;
|
|
409
|
-
}
|
|
410
|
-
nextPosition = wrapPosition(nextPosition);
|
|
411
|
-
commitPosition(nextPosition);
|
|
412
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
413
|
-
};
|
|
414
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
415
|
-
return () => {
|
|
416
|
-
if (animationFrameRef.current) {
|
|
417
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
}, [commitPosition, direction, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
|
|
421
|
-
useEffect(() => {
|
|
422
|
-
if (!lazy || !containerRef.current || typeof IntersectionObserver === "undefined") return;
|
|
423
|
-
const observer = new IntersectionObserver(
|
|
424
|
-
(entries) => {
|
|
425
|
-
entries.forEach((entry) => {
|
|
426
|
-
if (entry.isIntersecting) {
|
|
427
|
-
entry.target.classList.add("visible");
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
},
|
|
431
|
-
{
|
|
432
|
-
root: null,
|
|
433
|
-
rootMargin: lazyRootMargin,
|
|
434
|
-
threshold: lazyThreshold
|
|
435
|
-
}
|
|
436
|
-
);
|
|
437
|
-
const items = containerRef.current.querySelectorAll(".content-item");
|
|
438
|
-
items.forEach((item) => observer.observe(item));
|
|
439
|
-
return () => {
|
|
440
|
-
items.forEach((item) => observer.unobserve(item));
|
|
441
|
-
observer.disconnect();
|
|
442
|
-
};
|
|
443
|
-
}, [children, lazy, lazyRootMargin, lazyThreshold]);
|
|
444
|
-
return /* @__PURE__ */ jsxs(
|
|
445
|
-
"div",
|
|
446
|
-
{
|
|
447
|
-
className: "react-sway-container scroller-content",
|
|
448
|
-
ref: containerRef,
|
|
449
|
-
style: {
|
|
450
|
-
cursor: draggable ? isDragging ? "grabbing" : "grab" : "default",
|
|
451
|
-
MozUserSelect: "none",
|
|
452
|
-
msUserSelect: "none",
|
|
453
|
-
overflow: "hidden",
|
|
454
|
-
overscrollBehavior: "contain",
|
|
455
|
-
pointerEvents: "auto",
|
|
456
|
-
position: "absolute",
|
|
457
|
-
touchAction: "none",
|
|
458
|
-
transform: "translate3d(0, 0px, 0)",
|
|
459
|
-
userSelect: "none",
|
|
460
|
-
WebkitUserSelect: "none",
|
|
461
|
-
width: "100%",
|
|
462
|
-
willChange: "transform",
|
|
463
|
-
zIndex: 1
|
|
464
|
-
},
|
|
465
|
-
onKeyDown: keyboard ? handleKeyDown : void 0,
|
|
466
|
-
tabIndex: keyboard ? 0 : void 0,
|
|
467
|
-
children: [
|
|
468
|
-
/* @__PURE__ */ jsx("div", { className: "content-group original", children }),
|
|
469
|
-
/* @__PURE__ */ jsx("aside", { "aria-hidden": "true", className: "content-group duplicate", "data-duplicate": "true", role: "presentation", children }),
|
|
470
|
-
/* @__PURE__ */ jsx("aside", { "aria-hidden": "true", className: "content-group duplicate", "data-duplicate": "true", role: "presentation", children })
|
|
471
|
-
]
|
|
472
|
-
}
|
|
473
|
-
);
|
|
71
|
+
function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
|
|
72
|
+
const deltaX = normalizeWheelDelta(event, container, "x");
|
|
73
|
+
const deltaY = normalizeWheelDelta(event, container, "y");
|
|
74
|
+
const absDeltaX = Math.abs(deltaX);
|
|
75
|
+
const absDeltaY = Math.abs(deltaY);
|
|
76
|
+
if (isHorizontal) {
|
|
77
|
+
if (wheelMode === "axis" && absDeltaX <= absDeltaY && !event.shiftKey) return null;
|
|
78
|
+
return absDeltaX > absDeltaY ? deltaX : deltaY;
|
|
79
|
+
}
|
|
80
|
+
if (wheelMode === "axis" && absDeltaX > absDeltaY) return null;
|
|
81
|
+
return deltaY;
|
|
474
82
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
83
|
+
function getVisibleEdgeInterval(rect, isHorizontal) {
|
|
84
|
+
const rectEnd = isHorizontal ? rect.right : rect.bottom;
|
|
85
|
+
const rectStart = isHorizontal ? rect.left : rect.top;
|
|
86
|
+
const viewportEnd = isHorizontal ? window.innerWidth : window.innerHeight;
|
|
87
|
+
const end = Math.min(rectEnd, viewportEnd);
|
|
88
|
+
const start = Math.max(rectStart, 0);
|
|
89
|
+
if (end <= start) return null;
|
|
90
|
+
return {
|
|
91
|
+
end,
|
|
92
|
+
size: end - start,
|
|
93
|
+
start
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A smooth, infinite scrolling container component.
|
|
98
|
+
*
|
|
99
|
+
* Renders children in a continuously looping scroll area with support for
|
|
100
|
+
* auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
|
|
101
|
+
* and optional edge-hover scrolling.
|
|
102
|
+
* Content is duplicated to create a seamless loop effect. Duplicate content
|
|
103
|
+
* is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
|
|
104
|
+
*
|
|
105
|
+
* Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
|
|
106
|
+
* and disabling momentum effects.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* <ReactSway direction="up" speed={1} friction={0.9}>
|
|
111
|
+
* <div className="content-item">Item 1</div>
|
|
112
|
+
* <div className="content-item">Item 2</div>
|
|
113
|
+
* </ReactSway>
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
function ReactSway({ axis, autoScroll = true, children, direction = "up", draggable = true, edgeHoverScroll = false, edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE, friction = DEFAULT_FRICTION, keyboard = true, lazy = true, lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN, lazyThreshold = DEFAULT_LAZY_THRESHOLD, onPause, onResume, onScroll, pauseOnInteraction = true, resumeDelay = DEFAULT_RESUME_DELAY, speed = DEFAULT_SPEED, wheelEnabled = true, wheelMode = "axis" }) {
|
|
117
|
+
const isHorizontal = useMemo(() => axis ?? (direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]) === "horizontal";
|
|
118
|
+
const normalizedEdgeHoverSize = useMemo(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : DEFAULT_EDGE_HOVER_SIZE, [edgeHoverSize]);
|
|
119
|
+
const normalizedFriction = useMemo(() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION, [friction]);
|
|
120
|
+
const normalizedResumeDelay = useMemo(() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY, [resumeDelay]);
|
|
121
|
+
const normalizedSpeed = useMemo(() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED, [speed]);
|
|
122
|
+
const touchAction = useMemo(() => {
|
|
123
|
+
if (!draggable) return "auto";
|
|
124
|
+
return isHorizontal ? "pan-y" : "pan-x";
|
|
125
|
+
}, [draggable, isHorizontal]);
|
|
126
|
+
const overscrollBehavior = wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
|
|
127
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
128
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
129
|
+
const [isTabActive, setIsTabActive] = useState(true);
|
|
130
|
+
const [loopPoint, setLoopPoint] = useState(0);
|
|
131
|
+
const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
|
|
132
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
|
133
|
+
if (typeof window === "undefined") return false;
|
|
134
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
135
|
+
});
|
|
136
|
+
const animationFrameRef = useRef(null);
|
|
137
|
+
const autoScrollRef = useRef({
|
|
138
|
+
active: autoScroll,
|
|
139
|
+
desired: autoScroll
|
|
140
|
+
});
|
|
141
|
+
const containerRef = useRef(null);
|
|
142
|
+
const inactivityTimerRef = useRef(null);
|
|
143
|
+
const isDraggingRef = useRef(false);
|
|
144
|
+
const isPausedRef = useRef(false);
|
|
145
|
+
const lastFrameTimeRef = useRef(0);
|
|
146
|
+
const lastMouseXRef = useRef(0);
|
|
147
|
+
const lastMouseYRef = useRef(0);
|
|
148
|
+
const lastTouchXRef = useRef(0);
|
|
149
|
+
const lastTouchYRef = useRef(0);
|
|
150
|
+
const loopPointRef = useRef(0);
|
|
151
|
+
const edgeHoverDirectionRef = useRef(0);
|
|
152
|
+
const onPauseRef = useRef(onPause);
|
|
153
|
+
const onResumeRef = useRef(onResume);
|
|
154
|
+
const onScrollRef = useRef(onScroll);
|
|
155
|
+
const originalGroupRef = useRef(null);
|
|
156
|
+
const positionRef = useRef(0);
|
|
157
|
+
const resizeDebounceTimerRef = useRef(null);
|
|
158
|
+
const touchInteractionStateRef = useRef("idle");
|
|
159
|
+
const touchStartXRef = useRef(0);
|
|
160
|
+
const touchStartYRef = useRef(0);
|
|
161
|
+
const velocityRef = useRef(0);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
onPauseRef.current = onPause;
|
|
164
|
+
onResumeRef.current = onResume;
|
|
165
|
+
onScrollRef.current = onScroll;
|
|
166
|
+
}, [
|
|
167
|
+
onPause,
|
|
168
|
+
onResume,
|
|
169
|
+
onScroll
|
|
170
|
+
]);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (typeof window === "undefined") return;
|
|
173
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
174
|
+
const handleChange = (e) => {
|
|
175
|
+
setPrefersReducedMotion(e.matches);
|
|
176
|
+
};
|
|
177
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
178
|
+
return () => {
|
|
179
|
+
mediaQuery.removeEventListener("change", handleChange);
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
182
|
+
const clearInactivityTimer = useCallback(() => {
|
|
183
|
+
if (!inactivityTimerRef.current) return;
|
|
184
|
+
clearTimeout(inactivityTimerRef.current);
|
|
185
|
+
inactivityTimerRef.current = null;
|
|
186
|
+
}, []);
|
|
187
|
+
const renderPosition = useCallback((rawPosition) => {
|
|
188
|
+
if (!containerRef.current) return;
|
|
189
|
+
const currentLoopPoint = loopPointRef.current;
|
|
190
|
+
let visualPosition = rawPosition % (currentLoopPoint || 1);
|
|
191
|
+
if (visualPosition > 0 && currentLoopPoint > 0) visualPosition -= currentLoopPoint;
|
|
192
|
+
containerRef.current.style.transform = isHorizontal ? `translate3d(${visualPosition}px, 0, 0)` : `translate3d(0, ${visualPosition}px, 0)`;
|
|
193
|
+
}, [isHorizontal]);
|
|
194
|
+
const commitPosition = useCallback((nextPosition) => {
|
|
195
|
+
if (positionRef.current === nextPosition) return;
|
|
196
|
+
positionRef.current = nextPosition;
|
|
197
|
+
renderPosition(nextPosition);
|
|
198
|
+
onScrollRef.current?.(nextPosition);
|
|
199
|
+
}, [renderPosition]);
|
|
200
|
+
const wrapPosition = useCallback((rawPosition) => {
|
|
201
|
+
const currentLoopPoint = loopPointRef.current;
|
|
202
|
+
if (currentLoopPoint <= 0) return rawPosition;
|
|
203
|
+
let wrappedPosition = rawPosition;
|
|
204
|
+
while (wrappedPosition > 0) wrappedPosition -= currentLoopPoint;
|
|
205
|
+
while (wrappedPosition < -currentLoopPoint * 2) wrappedPosition += currentLoopPoint;
|
|
206
|
+
return wrappedPosition;
|
|
207
|
+
}, []);
|
|
208
|
+
const recalculateLoopPoint = useCallback(() => {
|
|
209
|
+
if (!containerRef.current || !originalGroupRef.current) return;
|
|
210
|
+
const originalContentSize = isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
|
|
211
|
+
if (originalContentSize <= 0) return;
|
|
212
|
+
const viewport = containerRef.current.parentElement ?? containerRef.current;
|
|
213
|
+
const viewportSize = isHorizontal ? Math.max(viewport.clientWidth, 1) : Math.max(viewport.clientHeight, 1);
|
|
214
|
+
const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 2);
|
|
215
|
+
const nextLoopPoint = originalContentSize;
|
|
216
|
+
setLoopSegmentCount((previousLoopSegmentCount) => previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount);
|
|
217
|
+
loopPointRef.current = nextLoopPoint;
|
|
218
|
+
setLoopPoint((previousLoopPoint) => Math.abs(previousLoopPoint - nextLoopPoint) < .5 ? previousLoopPoint : nextLoopPoint);
|
|
219
|
+
renderPosition(positionRef.current);
|
|
220
|
+
}, [isHorizontal, renderPosition]);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
autoScrollRef.current.desired = autoScroll;
|
|
223
|
+
if (!autoScroll) {
|
|
224
|
+
clearInactivityTimer();
|
|
225
|
+
autoScrollRef.current.active = true;
|
|
226
|
+
}
|
|
227
|
+
}, [autoScroll, clearInactivityTimer]);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
loopPointRef.current = loopPoint;
|
|
230
|
+
renderPosition(positionRef.current);
|
|
231
|
+
}, [loopPoint, renderPosition]);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const rafId = requestAnimationFrame(recalculateLoopPoint);
|
|
234
|
+
return () => {
|
|
235
|
+
cancelAnimationFrame(rafId);
|
|
236
|
+
};
|
|
237
|
+
}, [children, recalculateLoopPoint]);
|
|
238
|
+
const pauseAutoScroll = useCallback(() => {
|
|
239
|
+
if (!pauseOnInteraction) return;
|
|
240
|
+
autoScrollRef.current.active = false;
|
|
241
|
+
onPauseRef.current?.();
|
|
242
|
+
clearInactivityTimer();
|
|
243
|
+
}, [clearInactivityTimer, pauseOnInteraction]);
|
|
244
|
+
const scheduleAutoScrollResume = useCallback(() => {
|
|
245
|
+
if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
|
|
246
|
+
clearInactivityTimer();
|
|
247
|
+
inactivityTimerRef.current = setTimeout(() => {
|
|
248
|
+
inactivityTimerRef.current = null;
|
|
249
|
+
if (!autoScrollRef.current.desired || isPausedRef.current) return;
|
|
250
|
+
autoScrollRef.current.active = true;
|
|
251
|
+
onResumeRef.current?.();
|
|
252
|
+
}, normalizedResumeDelay);
|
|
253
|
+
}, [
|
|
254
|
+
clearInactivityTimer,
|
|
255
|
+
normalizedResumeDelay,
|
|
256
|
+
pauseOnInteraction
|
|
257
|
+
]);
|
|
258
|
+
const togglePause = useCallback(() => {
|
|
259
|
+
const newPaused = !isPausedRef.current;
|
|
260
|
+
isPausedRef.current = newPaused;
|
|
261
|
+
setIsPaused(newPaused);
|
|
262
|
+
if (newPaused) {
|
|
263
|
+
clearInactivityTimer();
|
|
264
|
+
autoScrollRef.current.active = false;
|
|
265
|
+
onPauseRef.current?.();
|
|
266
|
+
} else {
|
|
267
|
+
clearInactivityTimer();
|
|
268
|
+
autoScrollRef.current.active = true;
|
|
269
|
+
if (autoScrollRef.current.desired) onResumeRef.current?.();
|
|
270
|
+
}
|
|
271
|
+
}, [clearInactivityTimer]);
|
|
272
|
+
const handleKeyDown = useCallback((e) => {
|
|
273
|
+
if (!keyboard) return;
|
|
274
|
+
switch (e.key) {
|
|
275
|
+
case " ":
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
togglePause();
|
|
278
|
+
break;
|
|
279
|
+
case "ArrowDown":
|
|
280
|
+
if (isHorizontal) break;
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
283
|
+
pauseAutoScroll();
|
|
284
|
+
scheduleAutoScrollResume();
|
|
285
|
+
break;
|
|
286
|
+
case "ArrowUp":
|
|
287
|
+
if (isHorizontal) break;
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
290
|
+
pauseAutoScroll();
|
|
291
|
+
scheduleAutoScrollResume();
|
|
292
|
+
break;
|
|
293
|
+
case "ArrowLeft":
|
|
294
|
+
if (!isHorizontal) break;
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
297
|
+
pauseAutoScroll();
|
|
298
|
+
scheduleAutoScrollResume();
|
|
299
|
+
break;
|
|
300
|
+
case "ArrowRight":
|
|
301
|
+
if (!isHorizontal) break;
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
304
|
+
pauseAutoScroll();
|
|
305
|
+
scheduleAutoScrollResume();
|
|
306
|
+
break;
|
|
307
|
+
case "End":
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
if (loopPointRef.current > 0) commitPosition(-loopPointRef.current);
|
|
310
|
+
velocityRef.current = 0;
|
|
311
|
+
pauseAutoScroll();
|
|
312
|
+
scheduleAutoScrollResume();
|
|
313
|
+
break;
|
|
314
|
+
case "Home":
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
commitPosition(0);
|
|
317
|
+
velocityRef.current = 0;
|
|
318
|
+
pauseAutoScroll();
|
|
319
|
+
scheduleAutoScrollResume();
|
|
320
|
+
break;
|
|
321
|
+
default: break;
|
|
322
|
+
}
|
|
323
|
+
}, [
|
|
324
|
+
commitPosition,
|
|
325
|
+
isHorizontal,
|
|
326
|
+
keyboard,
|
|
327
|
+
pauseAutoScroll,
|
|
328
|
+
scheduleAutoScrollResume,
|
|
329
|
+
togglePause
|
|
330
|
+
]);
|
|
331
|
+
const handleMouseDown = useCallback((e) => {
|
|
332
|
+
if (!draggable) return;
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
containerRef.current?.focus();
|
|
335
|
+
setIsDragging(true);
|
|
336
|
+
isDraggingRef.current = true;
|
|
337
|
+
lastMouseXRef.current = e.clientX;
|
|
338
|
+
lastMouseYRef.current = e.clientY;
|
|
339
|
+
velocityRef.current = 0;
|
|
340
|
+
pauseAutoScroll();
|
|
341
|
+
}, [draggable, pauseAutoScroll]);
|
|
342
|
+
const handleMouseMove = useCallback((e) => {
|
|
343
|
+
if (!isDraggingRef.current) return;
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
const delta = isHorizontal ? e.clientX - lastMouseXRef.current : e.clientY - lastMouseYRef.current;
|
|
346
|
+
const nextPosition = wrapPosition(positionRef.current + delta);
|
|
347
|
+
commitPosition(nextPosition);
|
|
348
|
+
velocityRef.current = delta;
|
|
349
|
+
lastMouseXRef.current = e.clientX;
|
|
350
|
+
lastMouseYRef.current = e.clientY;
|
|
351
|
+
}, [
|
|
352
|
+
commitPosition,
|
|
353
|
+
isHorizontal,
|
|
354
|
+
wrapPosition
|
|
355
|
+
]);
|
|
356
|
+
const handleMouseUp = useCallback((e) => {
|
|
357
|
+
if (!isDraggingRef.current) return;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
setIsDragging(false);
|
|
360
|
+
isDraggingRef.current = false;
|
|
361
|
+
scheduleAutoScrollResume();
|
|
362
|
+
}, [scheduleAutoScrollResume]);
|
|
363
|
+
const handleTouchEnd = useCallback((_e) => {
|
|
364
|
+
const wasActive = touchInteractionStateRef.current === "active";
|
|
365
|
+
touchInteractionStateRef.current = "idle";
|
|
366
|
+
if (isDraggingRef.current) {
|
|
367
|
+
setIsDragging(false);
|
|
368
|
+
isDraggingRef.current = false;
|
|
369
|
+
}
|
|
370
|
+
if (wasActive) scheduleAutoScrollResume();
|
|
371
|
+
}, [scheduleAutoScrollResume]);
|
|
372
|
+
const handleTouchMove = useCallback((e) => {
|
|
373
|
+
if (touchInteractionStateRef.current === "idle" || touchInteractionStateRef.current === "ignored") return;
|
|
374
|
+
if (e.touches.length !== 1) return;
|
|
375
|
+
const touch = e.touches[0];
|
|
376
|
+
if (touchInteractionStateRef.current === "pending") {
|
|
377
|
+
const totalDeltaX = touch.clientX - touchStartXRef.current;
|
|
378
|
+
const totalDeltaY = touch.clientY - touchStartYRef.current;
|
|
379
|
+
const primaryDelta = isHorizontal ? Math.abs(totalDeltaX) : Math.abs(totalDeltaY);
|
|
380
|
+
const crossDelta = isHorizontal ? Math.abs(totalDeltaY) : Math.abs(totalDeltaX);
|
|
381
|
+
if (Math.max(primaryDelta, crossDelta) < TOUCH_AXIS_LOCK_THRESHOLD_PX) return;
|
|
382
|
+
if (crossDelta > primaryDelta) {
|
|
383
|
+
touchInteractionStateRef.current = "ignored";
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
touchInteractionStateRef.current = "active";
|
|
387
|
+
setIsDragging(true);
|
|
388
|
+
isDraggingRef.current = true;
|
|
389
|
+
pauseAutoScroll();
|
|
390
|
+
}
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
const delta = isHorizontal ? touch.clientX - lastTouchXRef.current : touch.clientY - lastTouchYRef.current;
|
|
393
|
+
const nextPosition = wrapPosition(positionRef.current + delta);
|
|
394
|
+
commitPosition(nextPosition);
|
|
395
|
+
velocityRef.current = delta;
|
|
396
|
+
lastTouchXRef.current = touch.clientX;
|
|
397
|
+
lastTouchYRef.current = touch.clientY;
|
|
398
|
+
}, [
|
|
399
|
+
commitPosition,
|
|
400
|
+
isHorizontal,
|
|
401
|
+
pauseAutoScroll,
|
|
402
|
+
wrapPosition
|
|
403
|
+
]);
|
|
404
|
+
const handleTouchStart = useCallback((e) => {
|
|
405
|
+
if (!draggable || e.touches.length !== 1) return;
|
|
406
|
+
containerRef.current?.focus();
|
|
407
|
+
touchInteractionStateRef.current = "pending";
|
|
408
|
+
lastTouchXRef.current = e.touches[0].clientX;
|
|
409
|
+
lastTouchYRef.current = e.touches[0].clientY;
|
|
410
|
+
touchStartXRef.current = e.touches[0].clientX;
|
|
411
|
+
touchStartYRef.current = e.touches[0].clientY;
|
|
412
|
+
velocityRef.current = 0;
|
|
413
|
+
}, [draggable]);
|
|
414
|
+
const handleWheel = useCallback((e) => {
|
|
415
|
+
if (!wheelEnabled) return;
|
|
416
|
+
const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
|
|
417
|
+
if (!currentContainer) return;
|
|
418
|
+
const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
|
|
419
|
+
if (ownedDelta === null) return;
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
const wheelDelta = -ownedDelta;
|
|
422
|
+
const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
|
|
423
|
+
commitPosition(nextPosition);
|
|
424
|
+
velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
|
|
425
|
+
velocityRef.current = Math.max(-150, Math.min(MAX_VELOCITY, velocityRef.current));
|
|
426
|
+
pauseAutoScroll();
|
|
427
|
+
scheduleAutoScrollResume();
|
|
428
|
+
}, [
|
|
429
|
+
commitPosition,
|
|
430
|
+
isHorizontal,
|
|
431
|
+
pauseAutoScroll,
|
|
432
|
+
scheduleAutoScrollResume,
|
|
433
|
+
wheelEnabled,
|
|
434
|
+
wheelMode,
|
|
435
|
+
wrapPosition
|
|
436
|
+
]);
|
|
437
|
+
const handleEdgeHoverMove = useCallback((e) => {
|
|
438
|
+
if (!edgeHoverScroll) return;
|
|
439
|
+
const viewport = containerRef.current?.parentElement ?? containerRef.current;
|
|
440
|
+
if (!viewport) return;
|
|
441
|
+
const visibleInterval = getVisibleEdgeInterval(viewport.getBoundingClientRect(), isHorizontal);
|
|
442
|
+
if (!visibleInterval) {
|
|
443
|
+
edgeHoverDirectionRef.current = 0;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const pointerPosition = isHorizontal ? e.clientX : e.clientY;
|
|
447
|
+
const effectiveEdgeHoverSize = Math.min(normalizedEdgeHoverSize, visibleInterval.size / 2);
|
|
448
|
+
if (pointerPosition <= visibleInterval.start + effectiveEdgeHoverSize) {
|
|
449
|
+
edgeHoverDirectionRef.current = 1;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (pointerPosition >= visibleInterval.end - effectiveEdgeHoverSize) {
|
|
453
|
+
edgeHoverDirectionRef.current = -1;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
edgeHoverDirectionRef.current = 0;
|
|
457
|
+
}, [
|
|
458
|
+
edgeHoverScroll,
|
|
459
|
+
isHorizontal,
|
|
460
|
+
normalizedEdgeHoverSize
|
|
461
|
+
]);
|
|
462
|
+
const handleEdgeHoverLeave = useCallback(() => {
|
|
463
|
+
edgeHoverDirectionRef.current = 0;
|
|
464
|
+
}, []);
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
const currentContainer = containerRef.current;
|
|
467
|
+
if (!currentContainer) return;
|
|
468
|
+
currentContainer.addEventListener("mousedown", handleMouseDown);
|
|
469
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
470
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
471
|
+
if (draggable) {
|
|
472
|
+
currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
473
|
+
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
474
|
+
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
475
|
+
}
|
|
476
|
+
if (wheelEnabled) currentContainer.addEventListener("wheel", handleWheel, { passive: false });
|
|
477
|
+
const edgeHoverTarget = currentContainer.parentElement ?? currentContainer;
|
|
478
|
+
edgeHoverTarget.addEventListener("mousemove", handleEdgeHoverMove);
|
|
479
|
+
edgeHoverTarget.addEventListener("mouseleave", handleEdgeHoverLeave);
|
|
480
|
+
return () => {
|
|
481
|
+
currentContainer.removeEventListener("mousedown", handleMouseDown);
|
|
482
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
483
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
484
|
+
if (draggable) {
|
|
485
|
+
currentContainer.removeEventListener("touchstart", handleTouchStart);
|
|
486
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
487
|
+
window.removeEventListener("touchend", handleTouchEnd);
|
|
488
|
+
}
|
|
489
|
+
if (wheelEnabled) currentContainer.removeEventListener("wheel", handleWheel);
|
|
490
|
+
edgeHoverTarget.removeEventListener("mousemove", handleEdgeHoverMove);
|
|
491
|
+
edgeHoverTarget.removeEventListener("mouseleave", handleEdgeHoverLeave);
|
|
492
|
+
};
|
|
493
|
+
}, [
|
|
494
|
+
draggable,
|
|
495
|
+
handleEdgeHoverLeave,
|
|
496
|
+
handleEdgeHoverMove,
|
|
497
|
+
handleMouseDown,
|
|
498
|
+
handleMouseMove,
|
|
499
|
+
handleMouseUp,
|
|
500
|
+
handleTouchEnd,
|
|
501
|
+
handleTouchMove,
|
|
502
|
+
handleTouchStart,
|
|
503
|
+
handleWheel,
|
|
504
|
+
wheelEnabled
|
|
505
|
+
]);
|
|
506
|
+
const debouncedRecalculate = useCallback(() => {
|
|
507
|
+
if (resizeDebounceTimerRef.current) clearTimeout(resizeDebounceTimerRef.current);
|
|
508
|
+
resizeDebounceTimerRef.current = setTimeout(() => {
|
|
509
|
+
resizeDebounceTimerRef.current = null;
|
|
510
|
+
recalculateLoopPoint();
|
|
511
|
+
}, RESIZE_DEBOUNCE_MS);
|
|
512
|
+
}, [recalculateLoopPoint]);
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
window.addEventListener("resize", debouncedRecalculate);
|
|
515
|
+
return () => {
|
|
516
|
+
window.removeEventListener("resize", debouncedRecalculate);
|
|
517
|
+
};
|
|
518
|
+
}, [debouncedRecalculate]);
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (!containerRef.current || typeof ResizeObserver === "undefined") return;
|
|
521
|
+
const observer = new ResizeObserver(() => {
|
|
522
|
+
debouncedRecalculate();
|
|
523
|
+
});
|
|
524
|
+
observer.observe(containerRef.current);
|
|
525
|
+
return () => {
|
|
526
|
+
observer.disconnect();
|
|
527
|
+
};
|
|
528
|
+
}, [debouncedRecalculate]);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
return () => {
|
|
531
|
+
if (resizeDebounceTimerRef.current) {
|
|
532
|
+
clearTimeout(resizeDebounceTimerRef.current);
|
|
533
|
+
resizeDebounceTimerRef.current = null;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}, []);
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
const handleVisibilityChange = () => {
|
|
539
|
+
setIsTabActive(!document.hidden);
|
|
540
|
+
if (!document.hidden) lastFrameTimeRef.current = performance.now();
|
|
541
|
+
};
|
|
542
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
543
|
+
return () => {
|
|
544
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
545
|
+
};
|
|
546
|
+
}, []);
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
return () => {
|
|
549
|
+
clearInactivityTimer();
|
|
550
|
+
};
|
|
551
|
+
}, [clearInactivityTimer]);
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!isTabActive || isPaused) {
|
|
554
|
+
if (animationFrameRef.current) {
|
|
555
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
556
|
+
animationFrameRef.current = null;
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
lastFrameTimeRef.current = 0;
|
|
561
|
+
const directionMultiplier = direction === "down" || direction === "right" ? 1 : -1;
|
|
562
|
+
const animate = (currentTime) => {
|
|
563
|
+
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS : 1;
|
|
564
|
+
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
565
|
+
lastFrameTimeRef.current = currentTime;
|
|
566
|
+
if (prefersReducedMotion) velocityRef.current = 0;
|
|
567
|
+
else if (Math.abs(velocityRef.current) > .1) velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
|
|
568
|
+
else velocityRef.current = 0;
|
|
569
|
+
let nextPosition = positionRef.current;
|
|
570
|
+
const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
|
|
571
|
+
const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
|
|
572
|
+
if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current && (!edgeHoverScroll || edgeHoverMultiplier !== 0)) nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
|
|
573
|
+
if (!isDraggingRef.current && Math.abs(velocityRef.current) > .1) nextPosition += velocityRef.current * deltaTime;
|
|
574
|
+
nextPosition = wrapPosition(nextPosition);
|
|
575
|
+
commitPosition(nextPosition);
|
|
576
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
577
|
+
};
|
|
578
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
579
|
+
return () => {
|
|
580
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
581
|
+
};
|
|
582
|
+
}, [
|
|
583
|
+
commitPosition,
|
|
584
|
+
direction,
|
|
585
|
+
edgeHoverScroll,
|
|
586
|
+
isHorizontal,
|
|
587
|
+
isPaused,
|
|
588
|
+
isTabActive,
|
|
589
|
+
normalizedFriction,
|
|
590
|
+
normalizedSpeed,
|
|
591
|
+
prefersReducedMotion,
|
|
592
|
+
wrapPosition
|
|
593
|
+
]);
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
if (!lazy || !containerRef.current || typeof IntersectionObserver === "undefined") return;
|
|
596
|
+
const observer = new IntersectionObserver((entries) => {
|
|
597
|
+
entries.forEach((entry) => {
|
|
598
|
+
if (entry.isIntersecting) entry.target.classList.add("visible");
|
|
599
|
+
});
|
|
600
|
+
}, {
|
|
601
|
+
root: null,
|
|
602
|
+
rootMargin: lazyRootMargin,
|
|
603
|
+
threshold: lazyThreshold
|
|
604
|
+
});
|
|
605
|
+
const items = containerRef.current.querySelectorAll(".content-item");
|
|
606
|
+
items.forEach((item) => observer.observe(item));
|
|
607
|
+
return () => {
|
|
608
|
+
items.forEach((item) => observer.unobserve(item));
|
|
609
|
+
observer.disconnect();
|
|
610
|
+
};
|
|
611
|
+
}, [
|
|
612
|
+
children,
|
|
613
|
+
lazy,
|
|
614
|
+
lazyRootMargin,
|
|
615
|
+
lazyThreshold
|
|
616
|
+
]);
|
|
617
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
618
|
+
className: "react-sway-container scroller-content",
|
|
619
|
+
ref: containerRef,
|
|
620
|
+
style: {
|
|
621
|
+
cursor: draggable ? isDragging ? "grabbing" : "grab" : "default",
|
|
622
|
+
display: isHorizontal ? "flex" : void 0,
|
|
623
|
+
flexDirection: isHorizontal ? "row" : void 0,
|
|
624
|
+
height: isHorizontal ? "100%" : "max-content",
|
|
625
|
+
MozUserSelect: "none",
|
|
626
|
+
msUserSelect: "none",
|
|
627
|
+
minHeight: isHorizontal ? void 0 : "100%",
|
|
628
|
+
overflow: "hidden",
|
|
629
|
+
overscrollBehavior,
|
|
630
|
+
pointerEvents: "auto",
|
|
631
|
+
position: "absolute",
|
|
632
|
+
touchAction,
|
|
633
|
+
transform: "translate3d(0, 0, 0)",
|
|
634
|
+
userSelect: "none",
|
|
635
|
+
WebkitUserSelect: "none",
|
|
636
|
+
width: isHorizontal ? "max-content" : "100%",
|
|
637
|
+
willChange: "transform",
|
|
638
|
+
zIndex: 1
|
|
639
|
+
},
|
|
640
|
+
onKeyDown: keyboard ? handleKeyDown : void 0,
|
|
641
|
+
tabIndex: keyboard ? 0 : void 0,
|
|
642
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
643
|
+
className: "content-group original",
|
|
644
|
+
ref: originalGroupRef,
|
|
645
|
+
style: isHorizontal ? {
|
|
646
|
+
display: "flex",
|
|
647
|
+
flex: "0 0 auto"
|
|
648
|
+
} : void 0,
|
|
649
|
+
children
|
|
650
|
+
}), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ jsx("aside", {
|
|
651
|
+
"aria-hidden": "true",
|
|
652
|
+
className: "content-group duplicate",
|
|
653
|
+
"data-duplicate": "true",
|
|
654
|
+
role: "presentation",
|
|
655
|
+
style: isHorizontal ? {
|
|
656
|
+
display: "flex",
|
|
657
|
+
flex: "0 0 auto"
|
|
658
|
+
} : void 0,
|
|
659
|
+
children
|
|
660
|
+
}, `duplicate-${duplicateIndex}`))]
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
//#endregion
|
|
664
|
+
export { ReactSway };
|