react-sway 0.1.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/dist/index.cjs +364 -0
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +337 -0
- package/package.json +63 -0
- package/src/ReactSway.tsx +411 -0
- package/src/index.ts +3 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ReactSway: () => ReactSway_default
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/ReactSway.tsx
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
30
|
+
var SCROLL_SPEED = 0.5;
|
|
31
|
+
var INACTIVITY_DELAY = 2e3;
|
|
32
|
+
var FRICTION = 0.95;
|
|
33
|
+
var MAX_DELTA_TIME = 3;
|
|
34
|
+
function ReactSway({ children }) {
|
|
35
|
+
const [position, setPosition] = (0, import_react.useState)(0);
|
|
36
|
+
const [, setVelocity] = (0, import_react.useState)(0);
|
|
37
|
+
const [isDragging, setIsDragging] = (0, import_react.useState)(false);
|
|
38
|
+
const [isPaused, setIsPaused] = (0, import_react.useState)(false);
|
|
39
|
+
const [autoScrollEnabled, setAutoScrollEnabled] = (0, import_react.useState)(true);
|
|
40
|
+
const [isTabActive, setIsTabActive] = (0, import_react.useState)(true);
|
|
41
|
+
const [contentHeight, setContentHeight] = (0, import_react.useState)(0);
|
|
42
|
+
const [loopPoint, setLoopPoint] = (0, import_react.useState)(0);
|
|
43
|
+
const [, setContainerHeight] = (0, import_react.useState)(0);
|
|
44
|
+
const containerRef = (0, import_react.useRef)(null);
|
|
45
|
+
const animationFrameRef = (0, import_react.useRef)(null);
|
|
46
|
+
const inactivityTimerRef = (0, import_react.useRef)(null);
|
|
47
|
+
const lastTouchYRef = (0, import_react.useRef)(0);
|
|
48
|
+
const lastMouseYRef = (0, import_react.useRef)(0);
|
|
49
|
+
const lastFrameTimeRef = (0, import_react.useRef)(0);
|
|
50
|
+
let visualPosition = position % (loopPoint || 1);
|
|
51
|
+
if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint;
|
|
52
|
+
(0, import_react.useEffect)(() => {
|
|
53
|
+
const calculateDimensions = () => {
|
|
54
|
+
if (containerRef.current) {
|
|
55
|
+
containerRef.current.offsetHeight;
|
|
56
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
57
|
+
const calculatedLoopPoint = currentContentHeight / 3;
|
|
58
|
+
console.log("Calculating dimensions:", { currentContentHeight, calculatedLoopPoint });
|
|
59
|
+
if (currentContentHeight > 0) {
|
|
60
|
+
setContentHeight(currentContentHeight);
|
|
61
|
+
setLoopPoint(calculatedLoopPoint);
|
|
62
|
+
}
|
|
63
|
+
setContainerHeight(window.innerHeight);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const rafId = requestAnimationFrame(() => {
|
|
67
|
+
calculateDimensions();
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
cancelAnimationFrame(rafId);
|
|
71
|
+
};
|
|
72
|
+
}, [children]);
|
|
73
|
+
const pauseAutoScroll = (0, import_react.useCallback)(() => {
|
|
74
|
+
setAutoScrollEnabled(false);
|
|
75
|
+
if (inactivityTimerRef.current) {
|
|
76
|
+
clearTimeout(inactivityTimerRef.current);
|
|
77
|
+
}
|
|
78
|
+
}, []);
|
|
79
|
+
const scheduleAutoScrollResume = (0, import_react.useCallback)(() => {
|
|
80
|
+
if (inactivityTimerRef.current) {
|
|
81
|
+
clearTimeout(inactivityTimerRef.current);
|
|
82
|
+
}
|
|
83
|
+
inactivityTimerRef.current = setTimeout(() => {
|
|
84
|
+
setAutoScrollEnabled(true);
|
|
85
|
+
}, INACTIVITY_DELAY);
|
|
86
|
+
}, []);
|
|
87
|
+
const handleMouseDown = (0, import_react.useCallback)((e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setIsDragging(true);
|
|
90
|
+
lastMouseYRef.current = e.clientY;
|
|
91
|
+
setVelocity(0);
|
|
92
|
+
pauseAutoScroll();
|
|
93
|
+
}, [pauseAutoScroll]);
|
|
94
|
+
const handleMouseMove = (0, import_react.useCallback)((e) => {
|
|
95
|
+
if (!isDragging) return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
const deltaY = e.clientY - lastMouseYRef.current;
|
|
98
|
+
setPosition((prev) => prev + deltaY);
|
|
99
|
+
setVelocity(deltaY);
|
|
100
|
+
lastMouseYRef.current = e.clientY;
|
|
101
|
+
}, [isDragging]);
|
|
102
|
+
const handleMouseUp = (0, import_react.useCallback)((e) => {
|
|
103
|
+
if (!isDragging) return;
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
setIsDragging(false);
|
|
106
|
+
scheduleAutoScrollResume();
|
|
107
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
108
|
+
const handleTouchStart = (0, import_react.useCallback)((e) => {
|
|
109
|
+
if (e.touches.length === 1) {
|
|
110
|
+
setIsDragging(true);
|
|
111
|
+
lastTouchYRef.current = e.touches[0].clientY;
|
|
112
|
+
setVelocity(0);
|
|
113
|
+
pauseAutoScroll();
|
|
114
|
+
}
|
|
115
|
+
}, [pauseAutoScroll]);
|
|
116
|
+
const handleTouchMove = (0, import_react.useCallback)((e) => {
|
|
117
|
+
if (!isDragging || e.touches.length !== 1) return;
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
const touch = e.touches[0];
|
|
120
|
+
const deltaY = touch.clientY - lastTouchYRef.current;
|
|
121
|
+
setPosition((prev) => prev + deltaY);
|
|
122
|
+
setVelocity(deltaY);
|
|
123
|
+
lastTouchYRef.current = touch.clientY;
|
|
124
|
+
}, [isDragging]);
|
|
125
|
+
const handleTouchEnd = (0, import_react.useCallback)((_e) => {
|
|
126
|
+
if (!isDragging) return;
|
|
127
|
+
setIsDragging(false);
|
|
128
|
+
scheduleAutoScrollResume();
|
|
129
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
130
|
+
const handleWheel = (0, import_react.useCallback)((e) => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setVelocity((prev) => prev - e.deltaY * 0.3);
|
|
133
|
+
pauseAutoScroll();
|
|
134
|
+
scheduleAutoScrollResume();
|
|
135
|
+
}, [pauseAutoScroll, scheduleAutoScrollResume]);
|
|
136
|
+
const togglePause = (0, import_react.useCallback)(() => {
|
|
137
|
+
setIsPaused((prev) => {
|
|
138
|
+
const newPausedState = !prev;
|
|
139
|
+
if (newPausedState) {
|
|
140
|
+
pauseAutoScroll();
|
|
141
|
+
} else {
|
|
142
|
+
setAutoScrollEnabled(true);
|
|
143
|
+
}
|
|
144
|
+
return newPausedState;
|
|
145
|
+
});
|
|
146
|
+
}, [pauseAutoScroll]);
|
|
147
|
+
const handleKeyDown = (0, import_react.useCallback)((e) => {
|
|
148
|
+
switch (e.key) {
|
|
149
|
+
case " ":
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
togglePause();
|
|
152
|
+
break;
|
|
153
|
+
case "ArrowUp":
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
setVelocity((prev) => prev + 15);
|
|
156
|
+
pauseAutoScroll();
|
|
157
|
+
scheduleAutoScrollResume();
|
|
158
|
+
break;
|
|
159
|
+
case "ArrowDown":
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
setVelocity((prev) => prev - 15);
|
|
162
|
+
pauseAutoScroll();
|
|
163
|
+
scheduleAutoScrollResume();
|
|
164
|
+
break;
|
|
165
|
+
case "Home":
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
setPosition(0);
|
|
168
|
+
setVelocity(0);
|
|
169
|
+
pauseAutoScroll();
|
|
170
|
+
scheduleAutoScrollResume();
|
|
171
|
+
break;
|
|
172
|
+
case "End":
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
if (loopPoint > 0) {
|
|
175
|
+
setPosition(-loopPoint);
|
|
176
|
+
}
|
|
177
|
+
setVelocity(0);
|
|
178
|
+
pauseAutoScroll();
|
|
179
|
+
scheduleAutoScrollResume();
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]);
|
|
185
|
+
const handleResize = (0, import_react.useCallback)(() => {
|
|
186
|
+
setContainerHeight(window.innerHeight);
|
|
187
|
+
if (containerRef.current) {
|
|
188
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
189
|
+
setContentHeight(currentContentHeight);
|
|
190
|
+
setLoopPoint(currentContentHeight / 3);
|
|
191
|
+
}
|
|
192
|
+
}, []);
|
|
193
|
+
(0, import_react.useEffect)(() => {
|
|
194
|
+
const currentContainer = containerRef.current;
|
|
195
|
+
if (!currentContainer) return;
|
|
196
|
+
const boundHandlers = {
|
|
197
|
+
mouseDown: handleMouseDown,
|
|
198
|
+
mouseMove: handleMouseMove,
|
|
199
|
+
mouseUp: handleMouseUp,
|
|
200
|
+
touchStart: handleTouchStart,
|
|
201
|
+
touchMove: handleTouchMove,
|
|
202
|
+
touchEnd: handleTouchEnd,
|
|
203
|
+
wheel: handleWheel
|
|
204
|
+
};
|
|
205
|
+
currentContainer.addEventListener("mousedown", boundHandlers.mouseDown);
|
|
206
|
+
window.addEventListener("mousemove", boundHandlers.mouseMove);
|
|
207
|
+
window.addEventListener("mouseup", boundHandlers.mouseUp);
|
|
208
|
+
currentContainer.addEventListener("touchstart", boundHandlers.touchStart, { passive: true });
|
|
209
|
+
window.addEventListener("touchmove", boundHandlers.touchMove, { passive: false });
|
|
210
|
+
window.addEventListener("touchend", boundHandlers.touchEnd, { passive: true });
|
|
211
|
+
currentContainer.addEventListener("wheel", boundHandlers.wheel, { passive: false });
|
|
212
|
+
return () => {
|
|
213
|
+
currentContainer.removeEventListener("mousedown", boundHandlers.mouseDown);
|
|
214
|
+
window.removeEventListener("mousemove", boundHandlers.mouseMove);
|
|
215
|
+
window.removeEventListener("mouseup", boundHandlers.mouseUp);
|
|
216
|
+
currentContainer.removeEventListener("touchstart", boundHandlers.touchStart);
|
|
217
|
+
window.removeEventListener("touchmove", boundHandlers.touchMove);
|
|
218
|
+
window.removeEventListener("touchend", boundHandlers.touchEnd);
|
|
219
|
+
currentContainer.removeEventListener("wheel", boundHandlers.wheel);
|
|
220
|
+
};
|
|
221
|
+
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]);
|
|
222
|
+
(0, import_react.useEffect)(() => {
|
|
223
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
224
|
+
window.addEventListener("resize", handleResize);
|
|
225
|
+
return () => {
|
|
226
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
227
|
+
window.removeEventListener("resize", handleResize);
|
|
228
|
+
};
|
|
229
|
+
}, [handleKeyDown, handleResize]);
|
|
230
|
+
(0, import_react.useEffect)(() => {
|
|
231
|
+
const handleVisibilityChange = () => {
|
|
232
|
+
setIsTabActive(!document.hidden);
|
|
233
|
+
if (!document.hidden) {
|
|
234
|
+
lastFrameTimeRef.current = performance.now();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
238
|
+
return () => {
|
|
239
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
242
|
+
(0, import_react.useEffect)(() => {
|
|
243
|
+
if (!isTabActive || isPaused) {
|
|
244
|
+
if (animationFrameRef.current) {
|
|
245
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
246
|
+
animationFrameRef.current = null;
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const animate = (currentTime) => {
|
|
251
|
+
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1;
|
|
252
|
+
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
253
|
+
lastFrameTimeRef.current = currentTime;
|
|
254
|
+
setPosition((prevPosition) => {
|
|
255
|
+
let newPosition = prevPosition;
|
|
256
|
+
if (autoScrollEnabled && !isDragging && !isPaused) {
|
|
257
|
+
newPosition -= SCROLL_SPEED * deltaTime;
|
|
258
|
+
}
|
|
259
|
+
return newPosition;
|
|
260
|
+
});
|
|
261
|
+
setVelocity((prevVelocity) => {
|
|
262
|
+
let newVelocity = prevVelocity;
|
|
263
|
+
if (Math.abs(newVelocity) > 0.1) {
|
|
264
|
+
if (!isDragging) {
|
|
265
|
+
setPosition((prev) => prev + newVelocity * deltaTime);
|
|
266
|
+
}
|
|
267
|
+
newVelocity *= Math.pow(FRICTION, deltaTime);
|
|
268
|
+
} else {
|
|
269
|
+
newVelocity = 0;
|
|
270
|
+
}
|
|
271
|
+
return newVelocity;
|
|
272
|
+
});
|
|
273
|
+
setPosition((prevPosition) => {
|
|
274
|
+
let newPosition = prevPosition;
|
|
275
|
+
if (loopPoint > 0) {
|
|
276
|
+
while (newPosition > 0) {
|
|
277
|
+
newPosition -= loopPoint;
|
|
278
|
+
}
|
|
279
|
+
while (newPosition < -loopPoint * 2) {
|
|
280
|
+
newPosition += loopPoint;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return newPosition;
|
|
284
|
+
});
|
|
285
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
286
|
+
};
|
|
287
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
288
|
+
return () => {
|
|
289
|
+
if (animationFrameRef.current) {
|
|
290
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]);
|
|
294
|
+
(0, import_react.useEffect)(() => {
|
|
295
|
+
if (!containerRef.current) return;
|
|
296
|
+
const observer = new IntersectionObserver(
|
|
297
|
+
(entries) => {
|
|
298
|
+
entries.forEach((entry) => {
|
|
299
|
+
if (entry.isIntersecting) {
|
|
300
|
+
entry.target.classList.add("visible");
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
root: null,
|
|
306
|
+
rootMargin: "100px",
|
|
307
|
+
threshold: 0.01
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
const items = containerRef.current.querySelectorAll(".content-item");
|
|
311
|
+
items.forEach((item) => observer.observe(item));
|
|
312
|
+
return () => {
|
|
313
|
+
items.forEach((item) => observer.unobserve(item));
|
|
314
|
+
observer.disconnect();
|
|
315
|
+
};
|
|
316
|
+
}, [children, contentHeight]);
|
|
317
|
+
(0, import_react.useEffect)(() => {
|
|
318
|
+
const originalBodyStyle = {
|
|
319
|
+
touchAction: document.body.style.touchAction,
|
|
320
|
+
overflow: document.body.style.overflow
|
|
321
|
+
};
|
|
322
|
+
document.body.style.touchAction = "none";
|
|
323
|
+
document.body.style.overflow = "hidden";
|
|
324
|
+
return () => {
|
|
325
|
+
document.body.style.touchAction = originalBodyStyle.touchAction;
|
|
326
|
+
document.body.style.overflow = originalBodyStyle.overflow;
|
|
327
|
+
};
|
|
328
|
+
}, []);
|
|
329
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
330
|
+
"div",
|
|
331
|
+
{
|
|
332
|
+
className: "react-sway-container scroller-content",
|
|
333
|
+
ref: containerRef,
|
|
334
|
+
style: {
|
|
335
|
+
transform: `translate3d(0, ${visualPosition}px, 0)`,
|
|
336
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
337
|
+
position: "absolute",
|
|
338
|
+
width: "100%",
|
|
339
|
+
willChange: "transform",
|
|
340
|
+
WebkitTransform: "translateZ(0)",
|
|
341
|
+
touchAction: "none",
|
|
342
|
+
userSelect: "none",
|
|
343
|
+
WebkitUserSelect: "none",
|
|
344
|
+
msUserSelect: "none",
|
|
345
|
+
MozUserSelect: "none",
|
|
346
|
+
overscrollBehavior: "contain",
|
|
347
|
+
// Ensure it's on top and can receive events
|
|
348
|
+
pointerEvents: "auto",
|
|
349
|
+
zIndex: 1
|
|
350
|
+
},
|
|
351
|
+
tabIndex: 0,
|
|
352
|
+
children: [
|
|
353
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "content-group original", children }),
|
|
354
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children }),
|
|
355
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children })
|
|
356
|
+
]
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
var ReactSway_default = ReactSway;
|
|
361
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
362
|
+
0 && (module.exports = {
|
|
363
|
+
ReactSway
|
|
364
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ReactSwayProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
declare function ReactSway({ children }: ReactSwayProps): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
export { ReactSway };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ReactSwayProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
declare function ReactSway({ children }: ReactSwayProps): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
export { ReactSway };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// src/ReactSway.tsx
|
|
2
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
var SCROLL_SPEED = 0.5;
|
|
5
|
+
var INACTIVITY_DELAY = 2e3;
|
|
6
|
+
var FRICTION = 0.95;
|
|
7
|
+
var MAX_DELTA_TIME = 3;
|
|
8
|
+
function ReactSway({ children }) {
|
|
9
|
+
const [position, setPosition] = useState(0);
|
|
10
|
+
const [, setVelocity] = useState(0);
|
|
11
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
12
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
13
|
+
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
|
14
|
+
const [isTabActive, setIsTabActive] = useState(true);
|
|
15
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
16
|
+
const [loopPoint, setLoopPoint] = useState(0);
|
|
17
|
+
const [, setContainerHeight] = useState(0);
|
|
18
|
+
const containerRef = useRef(null);
|
|
19
|
+
const animationFrameRef = useRef(null);
|
|
20
|
+
const inactivityTimerRef = useRef(null);
|
|
21
|
+
const lastTouchYRef = useRef(0);
|
|
22
|
+
const lastMouseYRef = useRef(0);
|
|
23
|
+
const lastFrameTimeRef = useRef(0);
|
|
24
|
+
let visualPosition = position % (loopPoint || 1);
|
|
25
|
+
if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const calculateDimensions = () => {
|
|
28
|
+
if (containerRef.current) {
|
|
29
|
+
containerRef.current.offsetHeight;
|
|
30
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
31
|
+
const calculatedLoopPoint = currentContentHeight / 3;
|
|
32
|
+
console.log("Calculating dimensions:", { currentContentHeight, calculatedLoopPoint });
|
|
33
|
+
if (currentContentHeight > 0) {
|
|
34
|
+
setContentHeight(currentContentHeight);
|
|
35
|
+
setLoopPoint(calculatedLoopPoint);
|
|
36
|
+
}
|
|
37
|
+
setContainerHeight(window.innerHeight);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const rafId = requestAnimationFrame(() => {
|
|
41
|
+
calculateDimensions();
|
|
42
|
+
});
|
|
43
|
+
return () => {
|
|
44
|
+
cancelAnimationFrame(rafId);
|
|
45
|
+
};
|
|
46
|
+
}, [children]);
|
|
47
|
+
const pauseAutoScroll = useCallback(() => {
|
|
48
|
+
setAutoScrollEnabled(false);
|
|
49
|
+
if (inactivityTimerRef.current) {
|
|
50
|
+
clearTimeout(inactivityTimerRef.current);
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
const scheduleAutoScrollResume = useCallback(() => {
|
|
54
|
+
if (inactivityTimerRef.current) {
|
|
55
|
+
clearTimeout(inactivityTimerRef.current);
|
|
56
|
+
}
|
|
57
|
+
inactivityTimerRef.current = setTimeout(() => {
|
|
58
|
+
setAutoScrollEnabled(true);
|
|
59
|
+
}, INACTIVITY_DELAY);
|
|
60
|
+
}, []);
|
|
61
|
+
const handleMouseDown = useCallback((e) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
setIsDragging(true);
|
|
64
|
+
lastMouseYRef.current = e.clientY;
|
|
65
|
+
setVelocity(0);
|
|
66
|
+
pauseAutoScroll();
|
|
67
|
+
}, [pauseAutoScroll]);
|
|
68
|
+
const handleMouseMove = useCallback((e) => {
|
|
69
|
+
if (!isDragging) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const deltaY = e.clientY - lastMouseYRef.current;
|
|
72
|
+
setPosition((prev) => prev + deltaY);
|
|
73
|
+
setVelocity(deltaY);
|
|
74
|
+
lastMouseYRef.current = e.clientY;
|
|
75
|
+
}, [isDragging]);
|
|
76
|
+
const handleMouseUp = useCallback((e) => {
|
|
77
|
+
if (!isDragging) return;
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
setIsDragging(false);
|
|
80
|
+
scheduleAutoScrollResume();
|
|
81
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
82
|
+
const handleTouchStart = useCallback((e) => {
|
|
83
|
+
if (e.touches.length === 1) {
|
|
84
|
+
setIsDragging(true);
|
|
85
|
+
lastTouchYRef.current = e.touches[0].clientY;
|
|
86
|
+
setVelocity(0);
|
|
87
|
+
pauseAutoScroll();
|
|
88
|
+
}
|
|
89
|
+
}, [pauseAutoScroll]);
|
|
90
|
+
const handleTouchMove = useCallback((e) => {
|
|
91
|
+
if (!isDragging || e.touches.length !== 1) return;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
const touch = e.touches[0];
|
|
94
|
+
const deltaY = touch.clientY - lastTouchYRef.current;
|
|
95
|
+
setPosition((prev) => prev + deltaY);
|
|
96
|
+
setVelocity(deltaY);
|
|
97
|
+
lastTouchYRef.current = touch.clientY;
|
|
98
|
+
}, [isDragging]);
|
|
99
|
+
const handleTouchEnd = useCallback((_e) => {
|
|
100
|
+
if (!isDragging) return;
|
|
101
|
+
setIsDragging(false);
|
|
102
|
+
scheduleAutoScrollResume();
|
|
103
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
104
|
+
const handleWheel = useCallback((e) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
setVelocity((prev) => prev - e.deltaY * 0.3);
|
|
107
|
+
pauseAutoScroll();
|
|
108
|
+
scheduleAutoScrollResume();
|
|
109
|
+
}, [pauseAutoScroll, scheduleAutoScrollResume]);
|
|
110
|
+
const togglePause = useCallback(() => {
|
|
111
|
+
setIsPaused((prev) => {
|
|
112
|
+
const newPausedState = !prev;
|
|
113
|
+
if (newPausedState) {
|
|
114
|
+
pauseAutoScroll();
|
|
115
|
+
} else {
|
|
116
|
+
setAutoScrollEnabled(true);
|
|
117
|
+
}
|
|
118
|
+
return newPausedState;
|
|
119
|
+
});
|
|
120
|
+
}, [pauseAutoScroll]);
|
|
121
|
+
const handleKeyDown = useCallback((e) => {
|
|
122
|
+
switch (e.key) {
|
|
123
|
+
case " ":
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
togglePause();
|
|
126
|
+
break;
|
|
127
|
+
case "ArrowUp":
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
setVelocity((prev) => prev + 15);
|
|
130
|
+
pauseAutoScroll();
|
|
131
|
+
scheduleAutoScrollResume();
|
|
132
|
+
break;
|
|
133
|
+
case "ArrowDown":
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setVelocity((prev) => prev - 15);
|
|
136
|
+
pauseAutoScroll();
|
|
137
|
+
scheduleAutoScrollResume();
|
|
138
|
+
break;
|
|
139
|
+
case "Home":
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
setPosition(0);
|
|
142
|
+
setVelocity(0);
|
|
143
|
+
pauseAutoScroll();
|
|
144
|
+
scheduleAutoScrollResume();
|
|
145
|
+
break;
|
|
146
|
+
case "End":
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
if (loopPoint > 0) {
|
|
149
|
+
setPosition(-loopPoint);
|
|
150
|
+
}
|
|
151
|
+
setVelocity(0);
|
|
152
|
+
pauseAutoScroll();
|
|
153
|
+
scheduleAutoScrollResume();
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]);
|
|
159
|
+
const handleResize = useCallback(() => {
|
|
160
|
+
setContainerHeight(window.innerHeight);
|
|
161
|
+
if (containerRef.current) {
|
|
162
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
163
|
+
setContentHeight(currentContentHeight);
|
|
164
|
+
setLoopPoint(currentContentHeight / 3);
|
|
165
|
+
}
|
|
166
|
+
}, []);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const currentContainer = containerRef.current;
|
|
169
|
+
if (!currentContainer) return;
|
|
170
|
+
const boundHandlers = {
|
|
171
|
+
mouseDown: handleMouseDown,
|
|
172
|
+
mouseMove: handleMouseMove,
|
|
173
|
+
mouseUp: handleMouseUp,
|
|
174
|
+
touchStart: handleTouchStart,
|
|
175
|
+
touchMove: handleTouchMove,
|
|
176
|
+
touchEnd: handleTouchEnd,
|
|
177
|
+
wheel: handleWheel
|
|
178
|
+
};
|
|
179
|
+
currentContainer.addEventListener("mousedown", boundHandlers.mouseDown);
|
|
180
|
+
window.addEventListener("mousemove", boundHandlers.mouseMove);
|
|
181
|
+
window.addEventListener("mouseup", boundHandlers.mouseUp);
|
|
182
|
+
currentContainer.addEventListener("touchstart", boundHandlers.touchStart, { passive: true });
|
|
183
|
+
window.addEventListener("touchmove", boundHandlers.touchMove, { passive: false });
|
|
184
|
+
window.addEventListener("touchend", boundHandlers.touchEnd, { passive: true });
|
|
185
|
+
currentContainer.addEventListener("wheel", boundHandlers.wheel, { passive: false });
|
|
186
|
+
return () => {
|
|
187
|
+
currentContainer.removeEventListener("mousedown", boundHandlers.mouseDown);
|
|
188
|
+
window.removeEventListener("mousemove", boundHandlers.mouseMove);
|
|
189
|
+
window.removeEventListener("mouseup", boundHandlers.mouseUp);
|
|
190
|
+
currentContainer.removeEventListener("touchstart", boundHandlers.touchStart);
|
|
191
|
+
window.removeEventListener("touchmove", boundHandlers.touchMove);
|
|
192
|
+
window.removeEventListener("touchend", boundHandlers.touchEnd);
|
|
193
|
+
currentContainer.removeEventListener("wheel", boundHandlers.wheel);
|
|
194
|
+
};
|
|
195
|
+
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
198
|
+
window.addEventListener("resize", handleResize);
|
|
199
|
+
return () => {
|
|
200
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
201
|
+
window.removeEventListener("resize", handleResize);
|
|
202
|
+
};
|
|
203
|
+
}, [handleKeyDown, handleResize]);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const handleVisibilityChange = () => {
|
|
206
|
+
setIsTabActive(!document.hidden);
|
|
207
|
+
if (!document.hidden) {
|
|
208
|
+
lastFrameTimeRef.current = performance.now();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
212
|
+
return () => {
|
|
213
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
214
|
+
};
|
|
215
|
+
}, []);
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!isTabActive || isPaused) {
|
|
218
|
+
if (animationFrameRef.current) {
|
|
219
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
220
|
+
animationFrameRef.current = null;
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const animate = (currentTime) => {
|
|
225
|
+
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1;
|
|
226
|
+
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
227
|
+
lastFrameTimeRef.current = currentTime;
|
|
228
|
+
setPosition((prevPosition) => {
|
|
229
|
+
let newPosition = prevPosition;
|
|
230
|
+
if (autoScrollEnabled && !isDragging && !isPaused) {
|
|
231
|
+
newPosition -= SCROLL_SPEED * deltaTime;
|
|
232
|
+
}
|
|
233
|
+
return newPosition;
|
|
234
|
+
});
|
|
235
|
+
setVelocity((prevVelocity) => {
|
|
236
|
+
let newVelocity = prevVelocity;
|
|
237
|
+
if (Math.abs(newVelocity) > 0.1) {
|
|
238
|
+
if (!isDragging) {
|
|
239
|
+
setPosition((prev) => prev + newVelocity * deltaTime);
|
|
240
|
+
}
|
|
241
|
+
newVelocity *= Math.pow(FRICTION, deltaTime);
|
|
242
|
+
} else {
|
|
243
|
+
newVelocity = 0;
|
|
244
|
+
}
|
|
245
|
+
return newVelocity;
|
|
246
|
+
});
|
|
247
|
+
setPosition((prevPosition) => {
|
|
248
|
+
let newPosition = prevPosition;
|
|
249
|
+
if (loopPoint > 0) {
|
|
250
|
+
while (newPosition > 0) {
|
|
251
|
+
newPosition -= loopPoint;
|
|
252
|
+
}
|
|
253
|
+
while (newPosition < -loopPoint * 2) {
|
|
254
|
+
newPosition += loopPoint;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return newPosition;
|
|
258
|
+
});
|
|
259
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
260
|
+
};
|
|
261
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
262
|
+
return () => {
|
|
263
|
+
if (animationFrameRef.current) {
|
|
264
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]);
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (!containerRef.current) return;
|
|
270
|
+
const observer = new IntersectionObserver(
|
|
271
|
+
(entries) => {
|
|
272
|
+
entries.forEach((entry) => {
|
|
273
|
+
if (entry.isIntersecting) {
|
|
274
|
+
entry.target.classList.add("visible");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
root: null,
|
|
280
|
+
rootMargin: "100px",
|
|
281
|
+
threshold: 0.01
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
const items = containerRef.current.querySelectorAll(".content-item");
|
|
285
|
+
items.forEach((item) => observer.observe(item));
|
|
286
|
+
return () => {
|
|
287
|
+
items.forEach((item) => observer.unobserve(item));
|
|
288
|
+
observer.disconnect();
|
|
289
|
+
};
|
|
290
|
+
}, [children, contentHeight]);
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
const originalBodyStyle = {
|
|
293
|
+
touchAction: document.body.style.touchAction,
|
|
294
|
+
overflow: document.body.style.overflow
|
|
295
|
+
};
|
|
296
|
+
document.body.style.touchAction = "none";
|
|
297
|
+
document.body.style.overflow = "hidden";
|
|
298
|
+
return () => {
|
|
299
|
+
document.body.style.touchAction = originalBodyStyle.touchAction;
|
|
300
|
+
document.body.style.overflow = originalBodyStyle.overflow;
|
|
301
|
+
};
|
|
302
|
+
}, []);
|
|
303
|
+
return /* @__PURE__ */ jsxs(
|
|
304
|
+
"div",
|
|
305
|
+
{
|
|
306
|
+
className: "react-sway-container scroller-content",
|
|
307
|
+
ref: containerRef,
|
|
308
|
+
style: {
|
|
309
|
+
transform: `translate3d(0, ${visualPosition}px, 0)`,
|
|
310
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
311
|
+
position: "absolute",
|
|
312
|
+
width: "100%",
|
|
313
|
+
willChange: "transform",
|
|
314
|
+
WebkitTransform: "translateZ(0)",
|
|
315
|
+
touchAction: "none",
|
|
316
|
+
userSelect: "none",
|
|
317
|
+
WebkitUserSelect: "none",
|
|
318
|
+
msUserSelect: "none",
|
|
319
|
+
MozUserSelect: "none",
|
|
320
|
+
overscrollBehavior: "contain",
|
|
321
|
+
// Ensure it's on top and can receive events
|
|
322
|
+
pointerEvents: "auto",
|
|
323
|
+
zIndex: 1
|
|
324
|
+
},
|
|
325
|
+
tabIndex: 0,
|
|
326
|
+
children: [
|
|
327
|
+
/* @__PURE__ */ jsx("div", { className: "content-group original", children }),
|
|
328
|
+
/* @__PURE__ */ jsx("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children }),
|
|
329
|
+
/* @__PURE__ */ jsx("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children })
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
var ReactSway_default = ReactSway;
|
|
335
|
+
export {
|
|
336
|
+
ReactSway_default as ReactSway
|
|
337
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": {
|
|
3
|
+
"name": "Mehdy Lafitte",
|
|
4
|
+
"url": "https://github.com/lafittemehdy"
|
|
5
|
+
},
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/lafittemehdy/sway/issues"
|
|
8
|
+
},
|
|
9
|
+
"description": "A React component for smooth infinite scrolling, designed for creating engaging, continuous content streams with minimal configuration.",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/react": "^18.3.3",
|
|
12
|
+
"@types/react-dom": "^18.3.0",
|
|
13
|
+
"eslint": "^8.57.0",
|
|
14
|
+
"eslint-plugin-react": "^7.35.0",
|
|
15
|
+
"eslint-plugin-react-hooks": "^4.6.2",
|
|
16
|
+
"react": "^18.3.1",
|
|
17
|
+
"react-dom": "^18.3.1",
|
|
18
|
+
"tsup": "^8.2.3",
|
|
19
|
+
"typescript": "^5.5.4"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"homepage": "https://github.com/lafittemehdy/sway#readme",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"content-stream",
|
|
28
|
+
"continuous-scroll",
|
|
29
|
+
"frontend",
|
|
30
|
+
"infinite-scroll",
|
|
31
|
+
"javascript",
|
|
32
|
+
"react",
|
|
33
|
+
"react-component",
|
|
34
|
+
"scroller",
|
|
35
|
+
"smooth-scroll",
|
|
36
|
+
"typescript",
|
|
37
|
+
"ui-component"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"main": "dist/index.cjs",
|
|
41
|
+
"module": "dist/index.js",
|
|
42
|
+
"name": "react-sway",
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=16.8.0",
|
|
45
|
+
"react-dom": ">=16.8.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/lafittemehdy/sway.git"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --external react",
|
|
56
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch",
|
|
57
|
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
58
|
+
"prepublishOnly": "npm run build"
|
|
59
|
+
},
|
|
60
|
+
"type": "module",
|
|
61
|
+
"types": "dist/index.d.ts",
|
|
62
|
+
"version": "0.1.0"
|
|
63
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// Constants (hardcoded as per current plan)
|
|
4
|
+
const SCROLL_SPEED = 0.5; // pixels per frame at 60fps
|
|
5
|
+
const INACTIVITY_DELAY = 2000; // ms
|
|
6
|
+
const FRICTION = 0.95;
|
|
7
|
+
const MAX_DELTA_TIME = 3; // Cap deltaTime to prevent physics breaking
|
|
8
|
+
|
|
9
|
+
interface ReactSwayProps {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ReactSway({ children }: ReactSwayProps) {
|
|
14
|
+
const [position, setPosition] = useState(0);
|
|
15
|
+
const [, setVelocity] = useState(0); // velocity is set but never used directly for rendering, only for physics calcs
|
|
16
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
17
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
18
|
+
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
|
19
|
+
const [isTabActive, setIsTabActive] = useState(true);
|
|
20
|
+
|
|
21
|
+
// Content dimensions
|
|
22
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
23
|
+
const [loopPoint, setLoopPoint] = useState(0);
|
|
24
|
+
const [, setContainerHeight] = useState(0); // containerHeight is set but never used
|
|
25
|
+
|
|
26
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
28
|
+
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
const lastTouchYRef = useRef(0);
|
|
30
|
+
const lastMouseYRef = useRef(0);
|
|
31
|
+
const lastFrameTimeRef = useRef(0);
|
|
32
|
+
|
|
33
|
+
// Visual position calculation
|
|
34
|
+
let visualPosition = position % (loopPoint || 1);
|
|
35
|
+
if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const calculateDimensions = () => {
|
|
39
|
+
if (containerRef.current) {
|
|
40
|
+
// Force a reflow to ensure accurate measurements
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
42
|
+
containerRef.current.offsetHeight;
|
|
43
|
+
|
|
44
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
45
|
+
const calculatedLoopPoint = currentContentHeight / 3;
|
|
46
|
+
|
|
47
|
+
console.log('Calculating dimensions:', { currentContentHeight, calculatedLoopPoint });
|
|
48
|
+
|
|
49
|
+
if (currentContentHeight > 0) {
|
|
50
|
+
setContentHeight(currentContentHeight);
|
|
51
|
+
setLoopPoint(calculatedLoopPoint);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setContainerHeight(window.innerHeight);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Use RAF to ensure layout is complete
|
|
59
|
+
const rafId = requestAnimationFrame(() => {
|
|
60
|
+
calculateDimensions();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
cancelAnimationFrame(rafId);
|
|
65
|
+
};
|
|
66
|
+
}, [children]);
|
|
67
|
+
|
|
68
|
+
const pauseAutoScroll = useCallback(() => {
|
|
69
|
+
setAutoScrollEnabled(false);
|
|
70
|
+
if (inactivityTimerRef.current) {
|
|
71
|
+
clearTimeout(inactivityTimerRef.current);
|
|
72
|
+
}
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const scheduleAutoScrollResume = useCallback(() => {
|
|
76
|
+
if (inactivityTimerRef.current) {
|
|
77
|
+
clearTimeout(inactivityTimerRef.current);
|
|
78
|
+
}
|
|
79
|
+
inactivityTimerRef.current = setTimeout(() => {
|
|
80
|
+
setAutoScrollEnabled(true);
|
|
81
|
+
}, INACTIVITY_DELAY);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setIsDragging(true);
|
|
87
|
+
lastMouseYRef.current = e.clientY;
|
|
88
|
+
setVelocity(0);
|
|
89
|
+
pauseAutoScroll();
|
|
90
|
+
}, [pauseAutoScroll]);
|
|
91
|
+
|
|
92
|
+
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
|
|
93
|
+
if (!isDragging) return;
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
const deltaY = e.clientY - lastMouseYRef.current;
|
|
96
|
+
setPosition(prev => prev + deltaY);
|
|
97
|
+
setVelocity(deltaY);
|
|
98
|
+
lastMouseYRef.current = e.clientY;
|
|
99
|
+
}, [isDragging]);
|
|
100
|
+
|
|
101
|
+
const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
|
|
102
|
+
if (!isDragging) return;
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
setIsDragging(false);
|
|
105
|
+
scheduleAutoScrollResume();
|
|
106
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
107
|
+
|
|
108
|
+
const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
|
|
109
|
+
if (e.touches.length === 1) {
|
|
110
|
+
setIsDragging(true);
|
|
111
|
+
lastTouchYRef.current = e.touches[0].clientY;
|
|
112
|
+
setVelocity(0);
|
|
113
|
+
pauseAutoScroll();
|
|
114
|
+
}
|
|
115
|
+
}, [pauseAutoScroll]);
|
|
116
|
+
|
|
117
|
+
const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
|
|
118
|
+
if (!isDragging || e.touches.length !== 1) return;
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
const touch = e.touches[0];
|
|
121
|
+
const deltaY = touch.clientY - lastTouchYRef.current;
|
|
122
|
+
setPosition(prev => prev + deltaY);
|
|
123
|
+
setVelocity(deltaY);
|
|
124
|
+
lastTouchYRef.current = touch.clientY;
|
|
125
|
+
}, [isDragging]);
|
|
126
|
+
|
|
127
|
+
const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => { // e is not used
|
|
128
|
+
if (!isDragging) return;
|
|
129
|
+
setIsDragging(false);
|
|
130
|
+
scheduleAutoScrollResume();
|
|
131
|
+
}, [isDragging, scheduleAutoScrollResume]);
|
|
132
|
+
|
|
133
|
+
const handleWheel = useCallback((e: globalThis.WheelEvent) => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setVelocity(prev => prev - e.deltaY * 0.3);
|
|
136
|
+
pauseAutoScroll();
|
|
137
|
+
scheduleAutoScrollResume();
|
|
138
|
+
}, [pauseAutoScroll, scheduleAutoScrollResume]);
|
|
139
|
+
|
|
140
|
+
const togglePause = useCallback(() => {
|
|
141
|
+
setIsPaused(prev => {
|
|
142
|
+
const newPausedState = !prev;
|
|
143
|
+
if (newPausedState) {
|
|
144
|
+
pauseAutoScroll();
|
|
145
|
+
} else {
|
|
146
|
+
setAutoScrollEnabled(true);
|
|
147
|
+
}
|
|
148
|
+
return newPausedState;
|
|
149
|
+
});
|
|
150
|
+
}, [pauseAutoScroll]);
|
|
151
|
+
|
|
152
|
+
const handleKeyDown = useCallback((e: globalThis.KeyboardEvent) => {
|
|
153
|
+
switch (e.key) {
|
|
154
|
+
case ' ':
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
togglePause();
|
|
157
|
+
break;
|
|
158
|
+
case 'ArrowUp':
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
setVelocity(prev => prev + 15);
|
|
161
|
+
pauseAutoScroll();
|
|
162
|
+
scheduleAutoScrollResume();
|
|
163
|
+
break;
|
|
164
|
+
case 'ArrowDown':
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
setVelocity(prev => prev - 15);
|
|
167
|
+
pauseAutoScroll();
|
|
168
|
+
scheduleAutoScrollResume();
|
|
169
|
+
break;
|
|
170
|
+
case 'Home':
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
setPosition(0);
|
|
173
|
+
setVelocity(0);
|
|
174
|
+
pauseAutoScroll();
|
|
175
|
+
scheduleAutoScrollResume();
|
|
176
|
+
break;
|
|
177
|
+
case 'End':
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (loopPoint > 0) {
|
|
180
|
+
setPosition(-loopPoint);
|
|
181
|
+
}
|
|
182
|
+
setVelocity(0);
|
|
183
|
+
pauseAutoScroll();
|
|
184
|
+
scheduleAutoScrollResume();
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]);
|
|
190
|
+
|
|
191
|
+
const handleResize = useCallback(() => {
|
|
192
|
+
setContainerHeight(window.innerHeight);
|
|
193
|
+
if (containerRef.current) {
|
|
194
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
195
|
+
setContentHeight(currentContentHeight);
|
|
196
|
+
setLoopPoint(currentContentHeight / 3);
|
|
197
|
+
}
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const currentContainer = containerRef.current;
|
|
202
|
+
if (!currentContainer) return;
|
|
203
|
+
|
|
204
|
+
// Create bound event handlers that maintain proper context
|
|
205
|
+
const boundHandlers = {
|
|
206
|
+
mouseDown: handleMouseDown,
|
|
207
|
+
mouseMove: handleMouseMove,
|
|
208
|
+
mouseUp: handleMouseUp,
|
|
209
|
+
touchStart: handleTouchStart,
|
|
210
|
+
touchMove: handleTouchMove,
|
|
211
|
+
touchEnd: handleTouchEnd,
|
|
212
|
+
wheel: handleWheel
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Add event listeners
|
|
216
|
+
currentContainer.addEventListener('mousedown', boundHandlers.mouseDown);
|
|
217
|
+
window.addEventListener('mousemove', boundHandlers.mouseMove);
|
|
218
|
+
window.addEventListener('mouseup', boundHandlers.mouseUp);
|
|
219
|
+
currentContainer.addEventListener('touchstart', boundHandlers.touchStart, { passive: true });
|
|
220
|
+
window.addEventListener('touchmove', boundHandlers.touchMove, { passive: false });
|
|
221
|
+
window.addEventListener('touchend', boundHandlers.touchEnd, { passive: true });
|
|
222
|
+
currentContainer.addEventListener('wheel', boundHandlers.wheel, { passive: false });
|
|
223
|
+
|
|
224
|
+
return () => {
|
|
225
|
+
currentContainer.removeEventListener('mousedown', boundHandlers.mouseDown);
|
|
226
|
+
window.removeEventListener('mousemove', boundHandlers.mouseMove);
|
|
227
|
+
window.removeEventListener('mouseup', boundHandlers.mouseUp);
|
|
228
|
+
currentContainer.removeEventListener('touchstart', boundHandlers.touchStart);
|
|
229
|
+
window.removeEventListener('touchmove', boundHandlers.touchMove);
|
|
230
|
+
window.removeEventListener('touchend', boundHandlers.touchEnd);
|
|
231
|
+
currentContainer.removeEventListener('wheel', boundHandlers.wheel);
|
|
232
|
+
};
|
|
233
|
+
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
237
|
+
window.addEventListener('resize', handleResize);
|
|
238
|
+
|
|
239
|
+
return () => {
|
|
240
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
241
|
+
window.removeEventListener('resize', handleResize);
|
|
242
|
+
};
|
|
243
|
+
}, [handleKeyDown, handleResize]);
|
|
244
|
+
|
|
245
|
+
// Tab Visibility Handling
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const handleVisibilityChange = () => {
|
|
248
|
+
setIsTabActive(!document.hidden);
|
|
249
|
+
if (!document.hidden) {
|
|
250
|
+
lastFrameTimeRef.current = performance.now();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
255
|
+
return () => {
|
|
256
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
257
|
+
};
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
// Animation Loop
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!isTabActive || isPaused) {
|
|
263
|
+
if (animationFrameRef.current) {
|
|
264
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
265
|
+
animationFrameRef.current = null;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const animate = (currentTime: number) => {
|
|
271
|
+
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1;
|
|
272
|
+
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
273
|
+
lastFrameTimeRef.current = currentTime;
|
|
274
|
+
|
|
275
|
+
setPosition(prevPosition => {
|
|
276
|
+
let newPosition = prevPosition;
|
|
277
|
+
|
|
278
|
+
// Auto-scroll when enabled
|
|
279
|
+
if (autoScrollEnabled && !isDragging && !isPaused) {
|
|
280
|
+
newPosition -= SCROLL_SPEED * deltaTime;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return newPosition;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
setVelocity(prevVelocity => {
|
|
287
|
+
let newVelocity = prevVelocity;
|
|
288
|
+
|
|
289
|
+
if (Math.abs(newVelocity) > 0.1) {
|
|
290
|
+
if (!isDragging) {
|
|
291
|
+
setPosition(prev => prev + newVelocity * deltaTime);
|
|
292
|
+
}
|
|
293
|
+
newVelocity *= Math.pow(FRICTION, deltaTime);
|
|
294
|
+
} else {
|
|
295
|
+
newVelocity = 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return newVelocity;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Handle position wrapping
|
|
302
|
+
setPosition(prevPosition => {
|
|
303
|
+
let newPosition = prevPosition;
|
|
304
|
+
|
|
305
|
+
if (loopPoint > 0) {
|
|
306
|
+
while (newPosition > 0) {
|
|
307
|
+
newPosition -= loopPoint;
|
|
308
|
+
}
|
|
309
|
+
while (newPosition < -loopPoint * 2) {
|
|
310
|
+
newPosition += loopPoint;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return newPosition;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
321
|
+
|
|
322
|
+
return () => {
|
|
323
|
+
if (animationFrameRef.current) {
|
|
324
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]);
|
|
328
|
+
|
|
329
|
+
// Intersection Observer for lazy loading
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!containerRef.current) return;
|
|
332
|
+
|
|
333
|
+
const observer = new IntersectionObserver(
|
|
334
|
+
(entries) => {
|
|
335
|
+
entries.forEach((entry) => {
|
|
336
|
+
if (entry.isIntersecting) {
|
|
337
|
+
entry.target.classList.add('visible');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
root: null,
|
|
343
|
+
rootMargin: '100px',
|
|
344
|
+
threshold: 0.01,
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const items = containerRef.current.querySelectorAll('.content-item');
|
|
349
|
+
items.forEach((item) => observer.observe(item));
|
|
350
|
+
|
|
351
|
+
return () => {
|
|
352
|
+
items.forEach((item) => observer.unobserve(item));
|
|
353
|
+
observer.disconnect();
|
|
354
|
+
};
|
|
355
|
+
}, [children, contentHeight]);
|
|
356
|
+
|
|
357
|
+
// Apply styles to override conflicting CSS
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
const originalBodyStyle = {
|
|
360
|
+
touchAction: document.body.style.touchAction,
|
|
361
|
+
overflow: document.body.style.overflow
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Override body styles that might conflict
|
|
365
|
+
document.body.style.touchAction = 'none';
|
|
366
|
+
document.body.style.overflow = 'hidden';
|
|
367
|
+
|
|
368
|
+
return () => {
|
|
369
|
+
// Restore original styles
|
|
370
|
+
document.body.style.touchAction = originalBodyStyle.touchAction;
|
|
371
|
+
document.body.style.overflow = originalBodyStyle.overflow;
|
|
372
|
+
};
|
|
373
|
+
}, []);
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div
|
|
377
|
+
className="react-sway-container scroller-content"
|
|
378
|
+
ref={containerRef}
|
|
379
|
+
style={{
|
|
380
|
+
transform: `translate3d(0, ${visualPosition}px, 0)`,
|
|
381
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
382
|
+
position: 'absolute',
|
|
383
|
+
width: '100%',
|
|
384
|
+
willChange: 'transform',
|
|
385
|
+
WebkitTransform: 'translateZ(0)',
|
|
386
|
+
touchAction: 'none',
|
|
387
|
+
userSelect: 'none',
|
|
388
|
+
WebkitUserSelect: 'none',
|
|
389
|
+
msUserSelect: 'none',
|
|
390
|
+
MozUserSelect: 'none',
|
|
391
|
+
overscrollBehavior: 'contain',
|
|
392
|
+
// Ensure it's on top and can receive events
|
|
393
|
+
pointerEvents: 'auto',
|
|
394
|
+
zIndex: 1
|
|
395
|
+
}}
|
|
396
|
+
tabIndex={0}
|
|
397
|
+
>
|
|
398
|
+
<div className="content-group original">
|
|
399
|
+
{children}
|
|
400
|
+
</div>
|
|
401
|
+
<aside className="content-group duplicate" aria-hidden="true" data-duplicate="true" role="presentation">
|
|
402
|
+
{children}
|
|
403
|
+
</aside>
|
|
404
|
+
<aside className="content-group duplicate" aria-hidden="true" data-duplicate="true" role="presentation">
|
|
405
|
+
{children}
|
|
406
|
+
</aside>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export default ReactSway;
|
package/src/index.ts
ADDED