mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
253 lines
9.7 KiB
TypeScript
253 lines
9.7 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { getOSPlatform } from '@/utils/misc';
|
|
|
|
const TRIGGER_THRESHOLD_STAGE1 = 120;
|
|
const TRIGGER_THRESHOLD_STAGE2 = 400;
|
|
const MAX_LOADING_POSITION = 80;
|
|
const PARALLAX_EFFECT = 0.3;
|
|
|
|
// Platform-specific damping parameters for pull resistance
|
|
const getPlatformDamping = () => {
|
|
const platform = getOSPlatform();
|
|
|
|
if (platform === 'ios') {
|
|
// iOS - tighter resistance (lower k = more damping)
|
|
return { MAX: 120, k: 0.35 };
|
|
} else if (platform === 'android') {
|
|
// Android - looser resistance (higher k = less damping)
|
|
return { MAX: 140, k: 0.5 };
|
|
}
|
|
return { MAX: 128, k: 0.4 };
|
|
};
|
|
|
|
function createApprFunction(MAX: number, k: number) {
|
|
return (x: number) => MAX * (1 - Math.exp((-k * x) / MAX));
|
|
}
|
|
|
|
export const usePullToRefresh = (
|
|
ref: React.RefObject<HTMLDivElement | null>,
|
|
onTriggerStage1: () => Promise<void> | void,
|
|
onTriggerStage2?: () => Promise<void> | void,
|
|
) => {
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
const damping = getPlatformDamping();
|
|
const appr = createApprFunction(damping.MAX, damping.k);
|
|
let isLoading = false;
|
|
|
|
// Disable native bounce on the scroll container so the JS-based
|
|
// pull-to-refresh resistance is visible (especially on iOS WKWebView).
|
|
el.style.overscrollBehavior = 'none';
|
|
|
|
el.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
|
|
function handleTouchStart(startEvent: TouchEvent) {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
if (el.scrollTop > 0) return;
|
|
|
|
const initialX = startEvent.touches[0]!.clientX;
|
|
const initialY = startEvent.touches[0]!.clientY;
|
|
|
|
el.addEventListener('touchmove', handleTouchMove, { passive: true });
|
|
el.addEventListener('touchend', handleTouchEnd);
|
|
|
|
function handleTouchMove(moveEvent: TouchEvent) {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
if (isLoading) return;
|
|
|
|
const currentX = moveEvent.touches[0]!.clientX;
|
|
const currentY = moveEvent.touches[0]!.clientY;
|
|
const dx = currentX - initialX;
|
|
const dy = currentY - initialY;
|
|
if (dy < 0 || Math.abs(dx) * 2 > Math.abs(dy)) return;
|
|
|
|
const transformValue = appr(dy);
|
|
|
|
const parentEl = el.parentNode as HTMLDivElement;
|
|
if (dy > 10) {
|
|
const opacity = Math.min(1, 0.3 + (dy / TRIGGER_THRESHOLD_STAGE2) * 0.7);
|
|
showLoadingSpinner(parentEl, transformValue, opacity);
|
|
} else {
|
|
hideLoadingSpinner(parentEl);
|
|
}
|
|
|
|
// Update loading spinner position and opacity with parallax if it exists
|
|
updateLoadingSpinnerPosition(parentEl, transformValue, dy);
|
|
|
|
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
|
|
if (wrapper) {
|
|
wrapper.style.transform = `translate3d(0, ${transformValue}px, 0)`;
|
|
}
|
|
}
|
|
|
|
function showLoadingSpinner(el: HTMLDivElement, transform: number, opacity: number = 1) {
|
|
const existing = el.querySelector('.pull-refresh-loading');
|
|
if (existing) return;
|
|
|
|
const headerbar = document.querySelector('.titlebar');
|
|
const loadingSpinner = document.createElement('div');
|
|
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
|
|
const parallaxOffset = transform * PARALLAX_EFFECT;
|
|
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
|
|
loadingSpinner.style.transition = 'opacity 0.15s ease-out';
|
|
loadingSpinner.style.opacity = opacity.toString();
|
|
loadingSpinner.className = 'pull-refresh-loading';
|
|
loadingSpinner.innerHTML = `<span class="loading loading-infinity loading-lg"></span>`;
|
|
el.appendChild(loadingSpinner);
|
|
}
|
|
|
|
function updateLoadingSpinnerPosition(el: HTMLDivElement, transform: number, dy: number) {
|
|
const loadingSpinner = el.querySelector('.pull-refresh-loading') as HTMLElement;
|
|
if (!loadingSpinner) return;
|
|
|
|
const headerbar = document.querySelector('.titlebar');
|
|
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
|
|
const parallaxOffset = transform * PARALLAX_EFFECT;
|
|
const opacity = Math.min(1, 0.2 + (dy / TRIGGER_THRESHOLD_STAGE2) * 0.8);
|
|
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
|
|
loadingSpinner.style.opacity = opacity.toString();
|
|
}
|
|
|
|
function hideLoadingSpinner(el: HTMLDivElement) {
|
|
const loadingSpinner = el.querySelector('.pull-refresh-loading');
|
|
if (loadingSpinner) {
|
|
loadingSpinner.remove();
|
|
}
|
|
}
|
|
|
|
async function handleTouchEnd(endEvent: TouchEvent) {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
|
|
const parentEl = el.parentNode as HTMLDivElement;
|
|
|
|
const y = endEvent.changedTouches[0]!.clientY;
|
|
const dy = y - initialY;
|
|
|
|
el.removeEventListener('touchmove', handleTouchMove);
|
|
el.removeEventListener('touchend', handleTouchEnd);
|
|
|
|
const isStage2 = onTriggerStage2 && dy > TRIGGER_THRESHOLD_STAGE2;
|
|
const isStage1 = dy > TRIGGER_THRESHOLD_STAGE1;
|
|
|
|
if (isStage2 || isStage1) {
|
|
isLoading = true;
|
|
|
|
// Calculate current transform value with damping
|
|
const transformValue = appr(dy);
|
|
const targetPosition = Math.min(transformValue, MAX_LOADING_POSITION);
|
|
|
|
if (wrapper) {
|
|
wrapper.style.transition = 'transform 0.2s ease-out';
|
|
wrapper.style.transform = `translateY(${targetPosition}px)`;
|
|
}
|
|
|
|
const loadingSpinner = parentEl.querySelector('.pull-refresh-loading') as HTMLElement;
|
|
if (loadingSpinner) {
|
|
const headerbar = document.querySelector('.titlebar');
|
|
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
|
|
const parallaxOffset = targetPosition * PARALLAX_EFFECT;
|
|
loadingSpinner.style.transition = 'top 0.2s ease-out';
|
|
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
|
|
loadingSpinner.style.opacity = '1';
|
|
|
|
// Remove transition after snap animation completes for smooth touch tracking
|
|
setTimeout(() => {
|
|
if (loadingSpinner) {
|
|
loadingSpinner.style.transition = 'opacity 0.15s ease-out';
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
// Add touch listeners during loading to detect pull up and update parallax
|
|
let loadingTouchStartY = 0;
|
|
const handleLoadingTouchStart = (e: TouchEvent) => {
|
|
loadingTouchStartY = e.touches[0]!.clientY;
|
|
};
|
|
const handleLoadingTouchMove = (e: TouchEvent) => {
|
|
const currentY = e.touches[0]!.clientY;
|
|
const pullDelta = currentY - loadingTouchStartY;
|
|
|
|
// Update both wrapper and spinner position to maintain parallax consistency
|
|
const newTransform = targetPosition + pullDelta;
|
|
if (newTransform > 0) {
|
|
if (wrapper) {
|
|
wrapper.style.transform = `translateY(${newTransform}px)`;
|
|
}
|
|
|
|
// Update spinner position with same parallax calculation
|
|
const loadingSpinner = parentEl.querySelector('.pull-refresh-loading') as HTMLElement;
|
|
if (loadingSpinner) {
|
|
const headerbar = document.querySelector('.titlebar');
|
|
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
|
|
const parallaxOffset = newTransform * PARALLAX_EFFECT;
|
|
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
|
|
loadingSpinner.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
// User pulled up significantly, reset
|
|
if (pullDelta < -30) {
|
|
if (wrapper) {
|
|
wrapper.style.transition = 'transform 0.3s ease-out';
|
|
wrapper.style.transform = 'translateY(0)';
|
|
}
|
|
hideLoadingSpinner(parentEl);
|
|
el.removeEventListener('touchstart', handleLoadingTouchStart);
|
|
el.removeEventListener('touchmove', handleLoadingTouchMove);
|
|
}
|
|
};
|
|
|
|
el.addEventListener('touchstart', handleLoadingTouchStart, { passive: true });
|
|
el.addEventListener('touchmove', handleLoadingTouchMove, { passive: true });
|
|
|
|
try {
|
|
const triggerFn = isStage2 ? onTriggerStage2 : onTriggerStage1;
|
|
await Promise.resolve(triggerFn());
|
|
} catch (error) {
|
|
console.error('Pull to refresh error:', error);
|
|
} finally {
|
|
isLoading = false;
|
|
hideLoadingSpinner(parentEl);
|
|
if (wrapper) {
|
|
wrapper.style.transition = 'transform 0.3s ease-out';
|
|
wrapper.style.transform = 'translateY(0)';
|
|
setTimeout(() => {
|
|
if (wrapper) wrapper.style.transition = '';
|
|
}, 300);
|
|
}
|
|
el.removeEventListener('touchstart', handleLoadingTouchStart);
|
|
el.removeEventListener('touchmove', handleLoadingTouchMove);
|
|
}
|
|
} else {
|
|
hideLoadingSpinner(parentEl);
|
|
if (wrapper) {
|
|
wrapper.style.transition = 'transform 0.2s';
|
|
wrapper.style.transform = 'translateY(0)';
|
|
}
|
|
|
|
el.addEventListener('transitionend', onTransitionEnd);
|
|
}
|
|
}
|
|
|
|
function onTransitionEnd() {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
el.style.transition = '';
|
|
el.removeEventListener('transitionend', onTransitionEnd);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
el.removeEventListener('touchstart', handleTouchStart);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ref.current]);
|
|
};
|