UNPKG

react-sway

Version:

A React component for smooth infinite scrolling, designed for creating engaging, continuous content streams with minimal configuration.

354 lines (351 loc) 13.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ReactSway: () => ReactSway_default }); module.exports = __toCommonJS(index_exports); // src/ReactSway.tsx var import_react = require("react"); var import_jsx_runtime = require("react/jsx-runtime"); var SCROLL_SPEED = 0.5; var INACTIVITY_DELAY = 2e3; var FRICTION = 0.95; var MAX_DELTA_TIME = 3; function ReactSway({ children }) { const [position, setPosition] = (0, import_react.useState)(0); const [, setVelocity] = (0, import_react.useState)(0); const [isDragging, setIsDragging] = (0, import_react.useState)(false); const [isPaused, setIsPaused] = (0, import_react.useState)(false); const [autoScrollEnabled, setAutoScrollEnabled] = (0, import_react.useState)(true); const [isTabActive, setIsTabActive] = (0, import_react.useState)(true); const [contentHeight, setContentHeight] = (0, import_react.useState)(0); const [loopPoint, setLoopPoint] = (0, import_react.useState)(0); const [, setContainerHeight] = (0, import_react.useState)(0); const containerRef = (0, import_react.useRef)(null); const animationFrameRef = (0, import_react.useRef)(null); const inactivityTimerRef = (0, import_react.useRef)(null); const lastTouchYRef = (0, import_react.useRef)(0); const lastMouseYRef = (0, import_react.useRef)(0); const lastFrameTimeRef = (0, import_react.useRef)(0); let visualPosition = position % (loopPoint || 1); if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint; (0, import_react.useEffect)(() => { const calculateDimensions = () => { if (containerRef.current) { containerRef.current.offsetHeight; const currentContentHeight = containerRef.current.scrollHeight; const calculatedLoopPoint = currentContentHeight / 3; if (currentContentHeight > 0) { setContentHeight(currentContentHeight); setLoopPoint(calculatedLoopPoint); } setContainerHeight(window.innerHeight); } }; const rafId = requestAnimationFrame(() => { calculateDimensions(); }); return () => { cancelAnimationFrame(rafId); }; }, [children]); const pauseAutoScroll = (0, import_react.useCallback)(() => { setAutoScrollEnabled(false); if (inactivityTimerRef.current) { clearTimeout(inactivityTimerRef.current); } }, []); const scheduleAutoScrollResume = (0, import_react.useCallback)(() => { if (inactivityTimerRef.current) { clearTimeout(inactivityTimerRef.current); } inactivityTimerRef.current = setTimeout(() => { setAutoScrollEnabled(true); }, INACTIVITY_DELAY); }, []); const handleMouseDown = (0, import_react.useCallback)((e) => { e.preventDefault(); setIsDragging(true); lastMouseYRef.current = e.clientY; setVelocity(0); pauseAutoScroll(); }, [pauseAutoScroll]); const handleMouseMove = (0, import_react.useCallback)((e) => { if (!isDragging) return; e.preventDefault(); const deltaY = e.clientY - lastMouseYRef.current; setPosition((prev) => prev + deltaY); setVelocity(deltaY); lastMouseYRef.current = e.clientY; }, [isDragging]); const handleMouseUp = (0, import_react.useCallback)((e) => { if (!isDragging) return; e.preventDefault(); setIsDragging(false); scheduleAutoScrollResume(); }, [isDragging, scheduleAutoScrollResume]); const handleTouchStart = (0, import_react.useCallback)((e) => { if (e.touches.length === 1) { setIsDragging(true); lastTouchYRef.current = e.touches[0].clientY; setVelocity(0); pauseAutoScroll(); } }, [pauseAutoScroll]); const handleTouchMove = (0, import_react.useCallback)((e) => { if (!isDragging || e.touches.length !== 1) return; e.preventDefault(); const touch = e.touches[0]; const deltaY = touch.clientY - lastTouchYRef.current; setPosition((prev) => prev + deltaY); setVelocity(deltaY); lastTouchYRef.current = touch.clientY; }, [isDragging]); const handleTouchEnd = (0, import_react.useCallback)((_e) => { if (!isDragging) return; setIsDragging(false); scheduleAutoScrollResume(); }, [isDragging, scheduleAutoScrollResume]); const handleWheel = (0, import_react.useCallback)((e) => { e.preventDefault(); setVelocity((prev) => prev - e.deltaY * 0.3); pauseAutoScroll(); scheduleAutoScrollResume(); }, [pauseAutoScroll, scheduleAutoScrollResume]); const togglePause = (0, import_react.useCallback)(() => { setIsPaused((prev) => { const newPausedState = !prev; if (newPausedState) { pauseAutoScroll(); } else { setAutoScrollEnabled(true); } return newPausedState; }); }, [pauseAutoScroll]); const handleKeyDown = (0, import_react.useCallback)((e) => { switch (e.key) { case " ": e.preventDefault(); togglePause(); break; case "ArrowUp": e.preventDefault(); setVelocity((prev) => prev + 15); pauseAutoScroll(); scheduleAutoScrollResume(); break; case "ArrowDown": e.preventDefault(); setVelocity((prev) => prev - 15); pauseAutoScroll(); scheduleAutoScrollResume(); break; case "Home": e.preventDefault(); setPosition(0); setVelocity(0); pauseAutoScroll(); scheduleAutoScrollResume(); break; case "End": e.preventDefault(); if (loopPoint > 0) { setPosition(-loopPoint); } setVelocity(0); pauseAutoScroll(); scheduleAutoScrollResume(); break; default: break; } }, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]); const handleResize = (0, import_react.useCallback)(() => { setContainerHeight(window.innerHeight); if (containerRef.current) { const currentContentHeight = containerRef.current.scrollHeight; setContentHeight(currentContentHeight); setLoopPoint(currentContentHeight / 3); } }, []); (0, import_react.useEffect)(() => { const currentContainer = containerRef.current; if (!currentContainer) return; const boundHandlers = { mouseDown: handleMouseDown, mouseMove: handleMouseMove, mouseUp: handleMouseUp, touchStart: handleTouchStart, touchMove: handleTouchMove, touchEnd: handleTouchEnd, wheel: handleWheel }; currentContainer.addEventListener("mousedown", boundHandlers.mouseDown); window.addEventListener("mousemove", boundHandlers.mouseMove); window.addEventListener("mouseup", boundHandlers.mouseUp); currentContainer.addEventListener("touchstart", boundHandlers.touchStart, { passive: true }); window.addEventListener("touchmove", boundHandlers.touchMove, { passive: false }); window.addEventListener("touchend", boundHandlers.touchEnd, { passive: true }); currentContainer.addEventListener("wheel", boundHandlers.wheel, { passive: false }); return () => { currentContainer.removeEventListener("mousedown", boundHandlers.mouseDown); window.removeEventListener("mousemove", boundHandlers.mouseMove); window.removeEventListener("mouseup", boundHandlers.mouseUp); currentContainer.removeEventListener("touchstart", boundHandlers.touchStart); window.removeEventListener("touchmove", boundHandlers.touchMove); window.removeEventListener("touchend", boundHandlers.touchEnd); currentContainer.removeEventListener("wheel", boundHandlers.wheel); }; }, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]); (0, import_react.useEffect)(() => { document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", handleResize); return () => { document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", handleResize); }; }, [handleKeyDown, handleResize]); (0, import_react.useEffect)(() => { const handleVisibilityChange = () => { setIsTabActive(!document.hidden); if (!document.hidden) { lastFrameTimeRef.current = performance.now(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); (0, import_react.useEffect)(() => { if (!isTabActive || isPaused) { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } return; } const animate = (currentTime) => { let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1; deltaTime = Math.min(deltaTime, MAX_DELTA_TIME); lastFrameTimeRef.current = currentTime; setPosition((prevPosition) => { let newPosition = prevPosition; if (autoScrollEnabled && !isDragging && !isPaused) { newPosition -= SCROLL_SPEED * deltaTime; } return newPosition; }); setVelocity((prevVelocity) => { let newVelocity = prevVelocity; if (Math.abs(newVelocity) > 0.1) { if (!isDragging) { setPosition((prev) => prev + newVelocity * deltaTime); } newVelocity *= Math.pow(FRICTION, deltaTime); } else { newVelocity = 0; } return newVelocity; }); setPosition((prevPosition) => { let newPosition = prevPosition; if (loopPoint > 0) { while (newPosition > 0) { newPosition -= loopPoint; } while (newPosition < -loopPoint * 2) { newPosition += loopPoint; } } return newPosition; }); animationFrameRef.current = requestAnimationFrame(animate); }; animationFrameRef.current = requestAnimationFrame(animate); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]); (0, import_react.useEffect)(() => { if (!containerRef.current) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add("visible"); } }); }, { root: null, rootMargin: "100px", threshold: 0.01 } ); const items = containerRef.current.querySelectorAll(".content-item"); items.forEach((item) => observer.observe(item)); return () => { items.forEach((item) => observer.unobserve(item)); observer.disconnect(); }; }, [children, contentHeight]); return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( "div", { className: "react-sway-container scroller-content", ref: containerRef, style: { transform: `translate3d(0, ${visualPosition}px, 0)`, cursor: isDragging ? "grabbing" : "grab", position: "absolute", width: "100%", willChange: "transform", WebkitTransform: "translateZ(0)", touchAction: "none", userSelect: "none", WebkitUserSelect: "none", msUserSelect: "none", MozUserSelect: "none", overscrollBehavior: "contain", overflow: "hidden", // Prevent component's content from overflowing and causing page scroll // Ensure it's on top and can receive events pointerEvents: "auto", zIndex: 1 }, tabIndex: 0, children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "content-group original", children }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children }) ] } ); } var ReactSway_default = ReactSway; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ReactSway });