react-sway 0.3.0 → 0.4.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +196 -48
- package/dist/ReactSway.d.ts +46 -4
- package/dist/edge-hover-velocity.d.ts +30 -0
- package/dist/index.cjs +428 -161
- package/dist/index.d.ts +2 -1
- package/dist/index.js +428 -163
- package/dist/wheel-input.d.ts +14 -0
- package/package.json +20 -15
- package/src/ReactSway.tsx +0 -914
- package/src/index.ts +0 -5
package/src/ReactSway.tsx
DELETED
|
@@ -1,914 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core ReactSway component implementing axis-aware infinite scrolling interactions.
|
|
3
|
-
*/
|
|
4
|
-
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
-
|
|
6
|
-
/** Velocity applied per arrow key press (pixels). */
|
|
7
|
-
const ARROW_KEY_VELOCITY = 15;
|
|
8
|
-
|
|
9
|
-
/** Default edge-hover activation thickness in pixels. */
|
|
10
|
-
const DEFAULT_EDGE_HOVER_SIZE = 96;
|
|
11
|
-
|
|
12
|
-
/** Default friction coefficient applied to velocity each frame. */
|
|
13
|
-
const DEFAULT_FRICTION = 0.95;
|
|
14
|
-
|
|
15
|
-
/** Default IntersectionObserver rootMargin for lazy visibility detection. */
|
|
16
|
-
const DEFAULT_LAZY_ROOT_MARGIN = '100px';
|
|
17
|
-
|
|
18
|
-
/** Default IntersectionObserver threshold for lazy visibility detection. */
|
|
19
|
-
const DEFAULT_LAZY_THRESHOLD = 0.01;
|
|
20
|
-
|
|
21
|
-
/** Default delay in milliseconds before auto-scroll resumes after user interaction. */
|
|
22
|
-
const DEFAULT_RESUME_DELAY = 2000;
|
|
23
|
-
|
|
24
|
-
/** Default scroll speed in pixels per frame at 60fps. */
|
|
25
|
-
const DEFAULT_SPEED = 0.5;
|
|
26
|
-
|
|
27
|
-
/** Number of stacked content groups used to build the seamless loop. */
|
|
28
|
-
const MIN_LOOP_SEGMENTS = 3;
|
|
29
|
-
|
|
30
|
-
/** Maximum deltaTime cap to prevent physics instability during frame drops. */
|
|
31
|
-
const MAX_DELTA_TIME = 3;
|
|
32
|
-
|
|
33
|
-
/** Maximum allowed velocity magnitude to prevent runaway scrolling. */
|
|
34
|
-
const MAX_VELOCITY = 150;
|
|
35
|
-
|
|
36
|
-
/** Duration of a single frame at 60fps in milliseconds. */
|
|
37
|
-
const MS_PER_FRAME_60FPS = 16.667;
|
|
38
|
-
|
|
39
|
-
/** Speed multiplier when user prefers reduced motion (25% of normal). */
|
|
40
|
-
const REDUCED_MOTION_SPEED_FACTOR = 0.25;
|
|
41
|
-
|
|
42
|
-
/** Debounce delay in milliseconds for ResizeObserver callbacks. */
|
|
43
|
-
const RESIZE_DEBOUNCE_MS = 150;
|
|
44
|
-
|
|
45
|
-
/** Fallback pixel height for wheel events reported in line units. */
|
|
46
|
-
const WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
|
|
47
|
-
|
|
48
|
-
/** WheelEvent deltaMode value for line-based deltas. */
|
|
49
|
-
const WHEEL_MODE_LINE = 1;
|
|
50
|
-
|
|
51
|
-
/** WheelEvent deltaMode value for page-based deltas. */
|
|
52
|
-
const WHEEL_MODE_PAGE = 2;
|
|
53
|
-
|
|
54
|
-
/** Minimum pixel impulse for discrete wheel hardware reporting tiny pixel deltas. */
|
|
55
|
-
const WHEEL_PIXEL_MIN_DELTA_PX = 48;
|
|
56
|
-
|
|
57
|
-
/** Multiplier applied to wheel deltaY to convert to scroll velocity. */
|
|
58
|
-
const WHEEL_VELOCITY_MULTIPLIER = 0.14;
|
|
59
|
-
|
|
60
|
-
/** Small immediate wheel movement used to keep input responsive without jumping. */
|
|
61
|
-
const WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
|
|
62
|
-
|
|
63
|
-
/** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
|
|
64
|
-
const WHEEL_LEGACY_NOTCH_DELTA = 120;
|
|
65
|
-
|
|
66
|
-
/** Minimum touch movement before a gesture is classified by axis. */
|
|
67
|
-
const TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
|
|
68
|
-
|
|
69
|
-
interface WheelEventWithLegacyDelta extends globalThis.WheelEvent {
|
|
70
|
-
wheelDelta?: number;
|
|
71
|
-
wheelDeltaY?: number;
|
|
72
|
-
}
|
|
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
|
-
|
|
81
|
-
function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
|
|
82
|
-
const { wheelDelta, wheelDeltaY } = event as WheelEventWithLegacyDelta;
|
|
83
|
-
const legacyWheelDelta = typeof wheelDeltaY === 'number' ? wheelDeltaY : wheelDelta;
|
|
84
|
-
|
|
85
|
-
if (typeof legacyWheelDelta !== 'number' || !Number.isFinite(legacyWheelDelta)) {
|
|
86
|
-
return 0;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
|
|
94
|
-
* devices feed the same Sway velocity system.
|
|
95
|
-
*/
|
|
96
|
-
function normalizeWheelDelta(event: globalThis.WheelEvent, container: HTMLElement, axis: WheelDeltaAxis) {
|
|
97
|
-
const rawDelta = axis === 'x' ? event.deltaX : event.deltaY;
|
|
98
|
-
|
|
99
|
-
if (event.deltaMode === WHEEL_MODE_LINE) {
|
|
100
|
-
const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
|
|
101
|
-
const lineHeight = Number.isFinite(computedLineHeight)
|
|
102
|
-
? computedLineHeight
|
|
103
|
-
: WHEEL_LINE_HEIGHT_FALLBACK_PX;
|
|
104
|
-
|
|
105
|
-
return rawDelta * lineHeight;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (event.deltaMode === WHEEL_MODE_PAGE) {
|
|
109
|
-
const pageSize = axis === 'x'
|
|
110
|
-
? Math.max(container.clientWidth, window.innerWidth, 1)
|
|
111
|
-
: Math.max(container.clientHeight, window.innerHeight, 1);
|
|
112
|
-
|
|
113
|
-
return rawDelta * pageSize;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (axis === 'x') return rawDelta;
|
|
117
|
-
|
|
118
|
-
const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX &&
|
|
122
|
-
Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX
|
|
123
|
-
) {
|
|
124
|
-
return legacyWheelPixelDeltaY;
|
|
125
|
-
}
|
|
126
|
-
|
|
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
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Props for the ReactSway infinite scrolling component.
|
|
171
|
-
*/
|
|
172
|
-
export interface ReactSwayProps {
|
|
173
|
-
/** Scroll axis. Horizontal directions imply `horizontal` when omitted. @default 'vertical' */
|
|
174
|
-
axis?: SwayAxis;
|
|
175
|
-
/** Enable/disable auto-scrolling. @default true */
|
|
176
|
-
autoScroll?: boolean;
|
|
177
|
-
/** Content elements to render in the infinite scroll container. */
|
|
178
|
-
children: ReactNode;
|
|
179
|
-
/** Auto-scroll direction. @default 'up' */
|
|
180
|
-
direction?: SwayDirection;
|
|
181
|
-
/** Enable mouse/touch drag interaction. @default true */
|
|
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;
|
|
187
|
-
/** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
|
|
188
|
-
friction?: number;
|
|
189
|
-
/** Enable keyboard controls (Space, Arrow keys, Home/End). @default true */
|
|
190
|
-
keyboard?: boolean;
|
|
191
|
-
/** Enable lazy visibility detection via IntersectionObserver. @default true */
|
|
192
|
-
lazy?: boolean;
|
|
193
|
-
/** IntersectionObserver rootMargin for lazy visibility detection. @default '100px' */
|
|
194
|
-
lazyRootMargin?: string;
|
|
195
|
-
/** IntersectionObserver threshold for lazy visibility detection. @default 0.01 */
|
|
196
|
-
lazyThreshold?: number;
|
|
197
|
-
/** Fired when scrolling pauses (user interaction or Space key). */
|
|
198
|
-
onPause?: () => void;
|
|
199
|
-
/** Fired when scrolling resumes after pause. */
|
|
200
|
-
onResume?: () => void;
|
|
201
|
-
/** Fired on every position change with the current scroll position. */
|
|
202
|
-
onScroll?: (position: number) => void;
|
|
203
|
-
/** Pause auto-scroll during user interaction. @default true */
|
|
204
|
-
pauseOnInteraction?: boolean;
|
|
205
|
-
/** Milliseconds before auto-scroll resumes after interaction. @default 2000 */
|
|
206
|
-
resumeDelay?: number;
|
|
207
|
-
/** Auto-scroll speed in pixels per frame at 60fps. @default 0.5 */
|
|
208
|
-
speed?: number;
|
|
209
|
-
/** Enable mouse wheel scrolling. @default true */
|
|
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;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* A smooth, infinite scrolling container component.
|
|
225
|
-
*
|
|
226
|
-
* Renders children in a continuously looping scroll area with support for
|
|
227
|
-
* auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
|
|
228
|
-
* and optional edge-hover scrolling.
|
|
229
|
-
* Content is duplicated to create a seamless loop effect. Duplicate content
|
|
230
|
-
* is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
|
|
231
|
-
*
|
|
232
|
-
* Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
|
|
233
|
-
* and disabling momentum effects.
|
|
234
|
-
*
|
|
235
|
-
* @example
|
|
236
|
-
* ```tsx
|
|
237
|
-
* <ReactSway direction="up" speed={1} friction={0.9}>
|
|
238
|
-
* <div className="content-item">Item 1</div>
|
|
239
|
-
* <div className="content-item">Item 2</div>
|
|
240
|
-
* </ReactSway>
|
|
241
|
-
* ```
|
|
242
|
-
*/
|
|
243
|
-
function ReactSway({
|
|
244
|
-
axis,
|
|
245
|
-
autoScroll = true,
|
|
246
|
-
children,
|
|
247
|
-
direction = 'up',
|
|
248
|
-
draggable = true,
|
|
249
|
-
edgeHoverScroll = false,
|
|
250
|
-
edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE,
|
|
251
|
-
friction = DEFAULT_FRICTION,
|
|
252
|
-
keyboard = true,
|
|
253
|
-
lazy = true,
|
|
254
|
-
lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN,
|
|
255
|
-
lazyThreshold = DEFAULT_LAZY_THRESHOLD,
|
|
256
|
-
onPause,
|
|
257
|
-
onResume,
|
|
258
|
-
onScroll,
|
|
259
|
-
pauseOnInteraction = true,
|
|
260
|
-
resumeDelay = DEFAULT_RESUME_DELAY,
|
|
261
|
-
speed = DEFAULT_SPEED,
|
|
262
|
-
wheelEnabled = true,
|
|
263
|
-
wheelMode = 'axis',
|
|
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
|
-
);
|
|
274
|
-
const normalizedFriction = useMemo(
|
|
275
|
-
() => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
|
|
276
|
-
[friction],
|
|
277
|
-
);
|
|
278
|
-
const normalizedResumeDelay = useMemo(
|
|
279
|
-
() => (Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY),
|
|
280
|
-
[resumeDelay],
|
|
281
|
-
);
|
|
282
|
-
const normalizedSpeed = useMemo(
|
|
283
|
-
() => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
|
|
284
|
-
[speed],
|
|
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';
|
|
293
|
-
|
|
294
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
295
|
-
const [isPaused, setIsPaused] = useState(false);
|
|
296
|
-
const [isTabActive, setIsTabActive] = useState(true);
|
|
297
|
-
const [loopPoint, setLoopPoint] = useState(0);
|
|
298
|
-
const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
|
|
299
|
-
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
|
300
|
-
if (typeof window === 'undefined') return false;
|
|
301
|
-
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const animationFrameRef = useRef<number | null>(null);
|
|
305
|
-
const autoScrollRef = useRef({ active: autoScroll, desired: autoScroll });
|
|
306
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
307
|
-
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
308
|
-
const isDraggingRef = useRef(false);
|
|
309
|
-
const isPausedRef = useRef(false);
|
|
310
|
-
const lastFrameTimeRef = useRef(0);
|
|
311
|
-
const lastMouseXRef = useRef(0);
|
|
312
|
-
const lastMouseYRef = useRef(0);
|
|
313
|
-
const lastTouchXRef = useRef(0);
|
|
314
|
-
const lastTouchYRef = useRef(0);
|
|
315
|
-
const loopPointRef = useRef(0);
|
|
316
|
-
const edgeHoverDirectionRef = useRef(0);
|
|
317
|
-
const onPauseRef = useRef(onPause);
|
|
318
|
-
const onResumeRef = useRef(onResume);
|
|
319
|
-
const onScrollRef = useRef(onScroll);
|
|
320
|
-
const originalGroupRef = useRef<HTMLDivElement>(null);
|
|
321
|
-
const positionRef = useRef(0);
|
|
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);
|
|
326
|
-
const velocityRef = useRef(0);
|
|
327
|
-
|
|
328
|
-
// Keep callback refs in sync without triggering re-renders
|
|
329
|
-
useEffect(() => {
|
|
330
|
-
onPauseRef.current = onPause;
|
|
331
|
-
onResumeRef.current = onResume;
|
|
332
|
-
onScrollRef.current = onScroll;
|
|
333
|
-
}, [onPause, onResume, onScroll]);
|
|
334
|
-
|
|
335
|
-
// Listen for prefers-reduced-motion media query changes
|
|
336
|
-
useEffect(() => {
|
|
337
|
-
if (typeof window === 'undefined') return;
|
|
338
|
-
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
339
|
-
const handleChange = (e: MediaQueryListEvent) => {
|
|
340
|
-
setPrefersReducedMotion(e.matches);
|
|
341
|
-
};
|
|
342
|
-
mediaQuery.addEventListener('change', handleChange);
|
|
343
|
-
return () => {
|
|
344
|
-
mediaQuery.removeEventListener('change', handleChange);
|
|
345
|
-
};
|
|
346
|
-
}, []);
|
|
347
|
-
|
|
348
|
-
const clearInactivityTimer = useCallback(() => {
|
|
349
|
-
if (!inactivityTimerRef.current) return;
|
|
350
|
-
clearTimeout(inactivityTimerRef.current);
|
|
351
|
-
inactivityTimerRef.current = null;
|
|
352
|
-
}, []);
|
|
353
|
-
|
|
354
|
-
const renderPosition = useCallback((rawPosition: number) => {
|
|
355
|
-
if (!containerRef.current) return;
|
|
356
|
-
const currentLoopPoint = loopPointRef.current;
|
|
357
|
-
let visualPosition = rawPosition % (currentLoopPoint || 1);
|
|
358
|
-
if (visualPosition > 0 && currentLoopPoint > 0) {
|
|
359
|
-
visualPosition -= currentLoopPoint;
|
|
360
|
-
}
|
|
361
|
-
containerRef.current.style.transform = isHorizontal
|
|
362
|
-
? `translate3d(${visualPosition}px, 0, 0)`
|
|
363
|
-
: `translate3d(0, ${visualPosition}px, 0)`;
|
|
364
|
-
}, [isHorizontal]);
|
|
365
|
-
|
|
366
|
-
const commitPosition = useCallback((nextPosition: number) => {
|
|
367
|
-
if (positionRef.current === nextPosition) return;
|
|
368
|
-
positionRef.current = nextPosition;
|
|
369
|
-
renderPosition(nextPosition);
|
|
370
|
-
onScrollRef.current?.(nextPosition);
|
|
371
|
-
}, [renderPosition]);
|
|
372
|
-
|
|
373
|
-
const wrapPosition = useCallback((rawPosition: number) => {
|
|
374
|
-
const currentLoopPoint = loopPointRef.current;
|
|
375
|
-
if (currentLoopPoint <= 0) return rawPosition;
|
|
376
|
-
|
|
377
|
-
let wrappedPosition = rawPosition;
|
|
378
|
-
while (wrappedPosition > 0) {
|
|
379
|
-
wrappedPosition -= currentLoopPoint;
|
|
380
|
-
}
|
|
381
|
-
while (wrappedPosition < -currentLoopPoint * 2) {
|
|
382
|
-
wrappedPosition += currentLoopPoint;
|
|
383
|
-
}
|
|
384
|
-
return wrappedPosition;
|
|
385
|
-
}, []);
|
|
386
|
-
|
|
387
|
-
const recalculateLoopPoint = useCallback(() => {
|
|
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
|
-
);
|
|
404
|
-
loopPointRef.current = nextLoopPoint;
|
|
405
|
-
setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
|
|
406
|
-
renderPosition(positionRef.current);
|
|
407
|
-
}, [isHorizontal, renderPosition]);
|
|
408
|
-
|
|
409
|
-
// Sync autoScroll prop changes with internal state
|
|
410
|
-
useEffect(() => {
|
|
411
|
-
autoScrollRef.current.desired = autoScroll;
|
|
412
|
-
if (!autoScroll) {
|
|
413
|
-
clearInactivityTimer();
|
|
414
|
-
autoScrollRef.current.active = true;
|
|
415
|
-
}
|
|
416
|
-
}, [autoScroll, clearInactivityTimer]);
|
|
417
|
-
|
|
418
|
-
// Sync loopPoint ref after state updates from observers/resizes
|
|
419
|
-
useEffect(() => {
|
|
420
|
-
loopPointRef.current = loopPoint;
|
|
421
|
-
renderPosition(positionRef.current);
|
|
422
|
-
}, [loopPoint, renderPosition]);
|
|
423
|
-
|
|
424
|
-
// Dimension calculation
|
|
425
|
-
useEffect(() => {
|
|
426
|
-
// Use RAF to ensure layout is complete
|
|
427
|
-
const rafId = requestAnimationFrame(recalculateLoopPoint);
|
|
428
|
-
|
|
429
|
-
return () => {
|
|
430
|
-
cancelAnimationFrame(rafId);
|
|
431
|
-
};
|
|
432
|
-
}, [children, recalculateLoopPoint]);
|
|
433
|
-
|
|
434
|
-
const pauseAutoScroll = useCallback(() => {
|
|
435
|
-
if (!pauseOnInteraction) return;
|
|
436
|
-
autoScrollRef.current.active = false;
|
|
437
|
-
onPauseRef.current?.();
|
|
438
|
-
clearInactivityTimer();
|
|
439
|
-
}, [clearInactivityTimer, pauseOnInteraction]);
|
|
440
|
-
|
|
441
|
-
const scheduleAutoScrollResume = useCallback(() => {
|
|
442
|
-
if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
|
|
443
|
-
clearInactivityTimer();
|
|
444
|
-
|
|
445
|
-
inactivityTimerRef.current = setTimeout(() => {
|
|
446
|
-
inactivityTimerRef.current = null;
|
|
447
|
-
if (!autoScrollRef.current.desired || isPausedRef.current) return;
|
|
448
|
-
autoScrollRef.current.active = true;
|
|
449
|
-
onResumeRef.current?.();
|
|
450
|
-
}, normalizedResumeDelay);
|
|
451
|
-
}, [clearInactivityTimer, normalizedResumeDelay, pauseOnInteraction]);
|
|
452
|
-
|
|
453
|
-
const togglePause = useCallback(() => {
|
|
454
|
-
const newPaused = !isPausedRef.current;
|
|
455
|
-
isPausedRef.current = newPaused;
|
|
456
|
-
setIsPaused(newPaused);
|
|
457
|
-
if (newPaused) {
|
|
458
|
-
clearInactivityTimer();
|
|
459
|
-
autoScrollRef.current.active = false;
|
|
460
|
-
onPauseRef.current?.();
|
|
461
|
-
} else {
|
|
462
|
-
clearInactivityTimer();
|
|
463
|
-
autoScrollRef.current.active = true;
|
|
464
|
-
if (autoScrollRef.current.desired) {
|
|
465
|
-
onResumeRef.current?.();
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}, [clearInactivityTimer]);
|
|
469
|
-
|
|
470
|
-
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
471
|
-
if (!keyboard) return;
|
|
472
|
-
|
|
473
|
-
switch (e.key) {
|
|
474
|
-
case ' ':
|
|
475
|
-
e.preventDefault();
|
|
476
|
-
togglePause();
|
|
477
|
-
break;
|
|
478
|
-
case 'ArrowDown':
|
|
479
|
-
if (isHorizontal) break;
|
|
480
|
-
e.preventDefault();
|
|
481
|
-
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
482
|
-
pauseAutoScroll();
|
|
483
|
-
scheduleAutoScrollResume();
|
|
484
|
-
break;
|
|
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;
|
|
494
|
-
e.preventDefault();
|
|
495
|
-
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
496
|
-
pauseAutoScroll();
|
|
497
|
-
scheduleAutoScrollResume();
|
|
498
|
-
break;
|
|
499
|
-
case 'ArrowRight':
|
|
500
|
-
if (!isHorizontal) break;
|
|
501
|
-
e.preventDefault();
|
|
502
|
-
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
503
|
-
pauseAutoScroll();
|
|
504
|
-
scheduleAutoScrollResume();
|
|
505
|
-
break;
|
|
506
|
-
case 'End':
|
|
507
|
-
e.preventDefault();
|
|
508
|
-
if (loopPointRef.current > 0) {
|
|
509
|
-
commitPosition(-loopPointRef.current);
|
|
510
|
-
}
|
|
511
|
-
velocityRef.current = 0;
|
|
512
|
-
pauseAutoScroll();
|
|
513
|
-
scheduleAutoScrollResume();
|
|
514
|
-
break;
|
|
515
|
-
case 'Home':
|
|
516
|
-
e.preventDefault();
|
|
517
|
-
commitPosition(0);
|
|
518
|
-
velocityRef.current = 0;
|
|
519
|
-
pauseAutoScroll();
|
|
520
|
-
scheduleAutoScrollResume();
|
|
521
|
-
break;
|
|
522
|
-
default:
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
}, [commitPosition, isHorizontal, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
|
|
526
|
-
|
|
527
|
-
const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
|
|
528
|
-
if (!draggable) return;
|
|
529
|
-
e.preventDefault();
|
|
530
|
-
containerRef.current?.focus();
|
|
531
|
-
setIsDragging(true);
|
|
532
|
-
isDraggingRef.current = true;
|
|
533
|
-
lastMouseXRef.current = e.clientX;
|
|
534
|
-
lastMouseYRef.current = e.clientY;
|
|
535
|
-
velocityRef.current = 0;
|
|
536
|
-
pauseAutoScroll();
|
|
537
|
-
}, [draggable, pauseAutoScroll]);
|
|
538
|
-
|
|
539
|
-
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
|
|
540
|
-
if (!isDraggingRef.current) return;
|
|
541
|
-
e.preventDefault();
|
|
542
|
-
const delta = isHorizontal ? e.clientX - lastMouseXRef.current : e.clientY - lastMouseYRef.current;
|
|
543
|
-
const nextPosition = wrapPosition(positionRef.current + delta);
|
|
544
|
-
commitPosition(nextPosition);
|
|
545
|
-
velocityRef.current = delta;
|
|
546
|
-
lastMouseXRef.current = e.clientX;
|
|
547
|
-
lastMouseYRef.current = e.clientY;
|
|
548
|
-
}, [commitPosition, isHorizontal, wrapPosition]);
|
|
549
|
-
|
|
550
|
-
const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
|
|
551
|
-
if (!isDraggingRef.current) return;
|
|
552
|
-
e.preventDefault();
|
|
553
|
-
setIsDragging(false);
|
|
554
|
-
isDraggingRef.current = false;
|
|
555
|
-
scheduleAutoScrollResume();
|
|
556
|
-
}, [scheduleAutoScrollResume]);
|
|
557
|
-
|
|
558
|
-
const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
|
|
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
|
-
}
|
|
570
|
-
}, [scheduleAutoScrollResume]);
|
|
571
|
-
|
|
572
|
-
const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
|
|
573
|
-
if (touchInteractionStateRef.current === 'idle' || touchInteractionStateRef.current === 'ignored') return;
|
|
574
|
-
if (e.touches.length !== 1) return;
|
|
575
|
-
|
|
576
|
-
const touch = e.touches[0];
|
|
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);
|
|
600
|
-
commitPosition(nextPosition);
|
|
601
|
-
velocityRef.current = delta;
|
|
602
|
-
lastTouchXRef.current = touch.clientX;
|
|
603
|
-
lastTouchYRef.current = touch.clientY;
|
|
604
|
-
}, [commitPosition, isHorizontal, pauseAutoScroll, wrapPosition]);
|
|
605
|
-
|
|
606
|
-
const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
|
|
607
|
-
if (!draggable || e.touches.length !== 1) return;
|
|
608
|
-
containerRef.current?.focus();
|
|
609
|
-
touchInteractionStateRef.current = 'pending';
|
|
610
|
-
lastTouchXRef.current = e.touches[0].clientX;
|
|
611
|
-
lastTouchYRef.current = e.touches[0].clientY;
|
|
612
|
-
touchStartXRef.current = e.touches[0].clientX;
|
|
613
|
-
touchStartYRef.current = e.touches[0].clientY;
|
|
614
|
-
velocityRef.current = 0;
|
|
615
|
-
}, [draggable]);
|
|
616
|
-
|
|
617
|
-
const handleWheel = useCallback((e: globalThis.WheelEvent) => {
|
|
618
|
-
if (!wheelEnabled) return;
|
|
619
|
-
const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
|
|
620
|
-
if (!currentContainer) return;
|
|
621
|
-
|
|
622
|
-
const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
|
|
623
|
-
if (ownedDelta === null) return;
|
|
624
|
-
|
|
625
|
-
e.preventDefault();
|
|
626
|
-
const wheelDelta = -ownedDelta;
|
|
627
|
-
const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
|
|
628
|
-
commitPosition(nextPosition);
|
|
629
|
-
velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
|
|
630
|
-
velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
|
|
631
|
-
pauseAutoScroll();
|
|
632
|
-
scheduleAutoScrollResume();
|
|
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
|
-
}, []);
|
|
666
|
-
|
|
667
|
-
// Event listener registration
|
|
668
|
-
useEffect(() => {
|
|
669
|
-
const currentContainer = containerRef.current;
|
|
670
|
-
if (!currentContainer) return;
|
|
671
|
-
|
|
672
|
-
currentContainer.addEventListener('mousedown', handleMouseDown);
|
|
673
|
-
window.addEventListener('mousemove', handleMouseMove);
|
|
674
|
-
window.addEventListener('mouseup', handleMouseUp);
|
|
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);
|
|
686
|
-
|
|
687
|
-
return () => {
|
|
688
|
-
currentContainer.removeEventListener('mousedown', handleMouseDown);
|
|
689
|
-
window.removeEventListener('mousemove', handleMouseMove);
|
|
690
|
-
window.removeEventListener('mouseup', handleMouseUp);
|
|
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);
|
|
701
|
-
};
|
|
702
|
-
}, [draggable, handleEdgeHoverLeave, handleEdgeHoverMove, handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel, wheelEnabled]);
|
|
703
|
-
|
|
704
|
-
// Debounced resize handler shared by ResizeObserver and window resize fallback
|
|
705
|
-
const debouncedRecalculate = useCallback(() => {
|
|
706
|
-
if (resizeDebounceTimerRef.current) {
|
|
707
|
-
clearTimeout(resizeDebounceTimerRef.current);
|
|
708
|
-
}
|
|
709
|
-
resizeDebounceTimerRef.current = setTimeout(() => {
|
|
710
|
-
resizeDebounceTimerRef.current = null;
|
|
711
|
-
recalculateLoopPoint();
|
|
712
|
-
}, RESIZE_DEBOUNCE_MS);
|
|
713
|
-
}, [recalculateLoopPoint]);
|
|
714
|
-
|
|
715
|
-
// Resize listener fallback for browsers without ResizeObserver
|
|
716
|
-
useEffect(() => {
|
|
717
|
-
window.addEventListener('resize', debouncedRecalculate);
|
|
718
|
-
|
|
719
|
-
return () => {
|
|
720
|
-
window.removeEventListener('resize', debouncedRecalculate);
|
|
721
|
-
};
|
|
722
|
-
}, [debouncedRecalculate]);
|
|
723
|
-
|
|
724
|
-
// ResizeObserver keeps loop measurements in sync with async content changes
|
|
725
|
-
useEffect(() => {
|
|
726
|
-
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
|
727
|
-
|
|
728
|
-
const observer = new ResizeObserver(() => {
|
|
729
|
-
debouncedRecalculate();
|
|
730
|
-
});
|
|
731
|
-
observer.observe(containerRef.current);
|
|
732
|
-
|
|
733
|
-
return () => {
|
|
734
|
-
observer.disconnect();
|
|
735
|
-
};
|
|
736
|
-
}, [debouncedRecalculate]);
|
|
737
|
-
|
|
738
|
-
// Clean up resize debounce timer on unmount
|
|
739
|
-
useEffect(() => {
|
|
740
|
-
return () => {
|
|
741
|
-
if (resizeDebounceTimerRef.current) {
|
|
742
|
-
clearTimeout(resizeDebounceTimerRef.current);
|
|
743
|
-
resizeDebounceTimerRef.current = null;
|
|
744
|
-
}
|
|
745
|
-
};
|
|
746
|
-
}, []);
|
|
747
|
-
|
|
748
|
-
// Tab visibility handling
|
|
749
|
-
useEffect(() => {
|
|
750
|
-
const handleVisibilityChange = () => {
|
|
751
|
-
setIsTabActive(!document.hidden);
|
|
752
|
-
if (!document.hidden) {
|
|
753
|
-
lastFrameTimeRef.current = performance.now();
|
|
754
|
-
}
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
758
|
-
return () => {
|
|
759
|
-
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
760
|
-
};
|
|
761
|
-
}, []);
|
|
762
|
-
|
|
763
|
-
// Clean up inactivity timer on unmount
|
|
764
|
-
useEffect(() => {
|
|
765
|
-
return () => {
|
|
766
|
-
clearInactivityTimer();
|
|
767
|
-
};
|
|
768
|
-
}, [clearInactivityTimer]);
|
|
769
|
-
|
|
770
|
-
// Animation loop
|
|
771
|
-
useEffect(() => {
|
|
772
|
-
if (!isTabActive || isPaused) {
|
|
773
|
-
if (animationFrameRef.current) {
|
|
774
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
775
|
-
animationFrameRef.current = null;
|
|
776
|
-
}
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Reset so the first frame uses deltaTime=1 instead of a stale timestamp
|
|
781
|
-
lastFrameTimeRef.current = 0;
|
|
782
|
-
|
|
783
|
-
const directionMultiplier = direction === 'down' || direction === 'right' ? 1 : -1;
|
|
784
|
-
|
|
785
|
-
const animate = (currentTime: number) => {
|
|
786
|
-
let deltaTime = lastFrameTimeRef.current
|
|
787
|
-
? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS
|
|
788
|
-
: 1;
|
|
789
|
-
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
790
|
-
lastFrameTimeRef.current = currentTime;
|
|
791
|
-
|
|
792
|
-
// Apply velocity damping (skip momentum in reduced-motion mode)
|
|
793
|
-
if (prefersReducedMotion) {
|
|
794
|
-
velocityRef.current = 0;
|
|
795
|
-
} else if (Math.abs(velocityRef.current) > 0.1) {
|
|
796
|
-
velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
|
|
797
|
-
} else {
|
|
798
|
-
velocityRef.current = 0;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
let nextPosition = positionRef.current;
|
|
802
|
-
|
|
803
|
-
// Calculate effective speed (reduced when user prefers reduced motion)
|
|
804
|
-
const effectiveSpeed = prefersReducedMotion
|
|
805
|
-
? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
|
|
806
|
-
: normalizedSpeed;
|
|
807
|
-
|
|
808
|
-
const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
|
|
809
|
-
|
|
810
|
-
// Auto-scroll when enabled and not dragging
|
|
811
|
-
if (
|
|
812
|
-
autoScrollRef.current.desired &&
|
|
813
|
-
autoScrollRef.current.active &&
|
|
814
|
-
!isDraggingRef.current &&
|
|
815
|
-
(!edgeHoverScroll || edgeHoverMultiplier !== 0)
|
|
816
|
-
) {
|
|
817
|
-
nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// Apply velocity momentum from user interaction
|
|
821
|
-
if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
|
|
822
|
-
nextPosition += velocityRef.current * deltaTime;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
nextPosition = wrapPosition(nextPosition);
|
|
826
|
-
commitPosition(nextPosition);
|
|
827
|
-
|
|
828
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
829
|
-
};
|
|
830
|
-
|
|
831
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
832
|
-
|
|
833
|
-
return () => {
|
|
834
|
-
if (animationFrameRef.current) {
|
|
835
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
836
|
-
}
|
|
837
|
-
};
|
|
838
|
-
}, [commitPosition, direction, edgeHoverScroll, isHorizontal, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
|
|
839
|
-
|
|
840
|
-
// Intersection Observer for lazy visibility detection
|
|
841
|
-
useEffect(() => {
|
|
842
|
-
if (!lazy || !containerRef.current || typeof IntersectionObserver === 'undefined') return;
|
|
843
|
-
|
|
844
|
-
const observer = new IntersectionObserver(
|
|
845
|
-
(entries) => {
|
|
846
|
-
entries.forEach((entry) => {
|
|
847
|
-
if (entry.isIntersecting) {
|
|
848
|
-
entry.target.classList.add('visible');
|
|
849
|
-
}
|
|
850
|
-
});
|
|
851
|
-
},
|
|
852
|
-
{
|
|
853
|
-
root: null,
|
|
854
|
-
rootMargin: lazyRootMargin,
|
|
855
|
-
threshold: lazyThreshold,
|
|
856
|
-
}
|
|
857
|
-
);
|
|
858
|
-
|
|
859
|
-
const items = containerRef.current.querySelectorAll('.content-item');
|
|
860
|
-
items.forEach((item) => observer.observe(item));
|
|
861
|
-
|
|
862
|
-
return () => {
|
|
863
|
-
items.forEach((item) => observer.unobserve(item));
|
|
864
|
-
observer.disconnect();
|
|
865
|
-
};
|
|
866
|
-
}, [children, lazy, lazyRootMargin, lazyThreshold]);
|
|
867
|
-
|
|
868
|
-
return (
|
|
869
|
-
<div
|
|
870
|
-
className="react-sway-container scroller-content"
|
|
871
|
-
ref={containerRef}
|
|
872
|
-
style={{
|
|
873
|
-
cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
|
874
|
-
display: isHorizontal ? 'flex' : undefined,
|
|
875
|
-
flexDirection: isHorizontal ? 'row' : undefined,
|
|
876
|
-
height: isHorizontal ? '100%' : 'max-content',
|
|
877
|
-
MozUserSelect: 'none',
|
|
878
|
-
msUserSelect: 'none',
|
|
879
|
-
minHeight: isHorizontal ? undefined : '100%',
|
|
880
|
-
overflow: 'hidden',
|
|
881
|
-
overscrollBehavior,
|
|
882
|
-
pointerEvents: 'auto',
|
|
883
|
-
position: 'absolute',
|
|
884
|
-
touchAction,
|
|
885
|
-
transform: 'translate3d(0, 0, 0)',
|
|
886
|
-
userSelect: 'none',
|
|
887
|
-
WebkitUserSelect: 'none',
|
|
888
|
-
width: isHorizontal ? 'max-content' : '100%',
|
|
889
|
-
willChange: 'transform',
|
|
890
|
-
zIndex: 1,
|
|
891
|
-
}}
|
|
892
|
-
onKeyDown={keyboard ? handleKeyDown : undefined}
|
|
893
|
-
tabIndex={keyboard ? 0 : undefined}
|
|
894
|
-
>
|
|
895
|
-
<div className="content-group original" ref={originalGroupRef} style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}>
|
|
896
|
-
{children}
|
|
897
|
-
</div>
|
|
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
|
-
))}
|
|
910
|
-
</div>
|
|
911
|
-
);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
export default ReactSway;
|