Reading Progress
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { storyApi, seriesApi } from '../../../lib/api';
|
||||
@@ -19,9 +19,85 @@ export default function StoryReadingPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [readingProgress, setReadingProgress] = useState(0);
|
||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const storyId = params.id as string;
|
||||
|
||||
// Convert scroll position to approximate character position in the content
|
||||
const getCharacterPositionFromScroll = useCallback((): number => {
|
||||
if (!contentRef.current || !story) return 0;
|
||||
|
||||
const content = contentRef.current;
|
||||
const scrolled = window.scrollY;
|
||||
const contentTop = content.offsetTop;
|
||||
const contentHeight = content.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// Calculate how far through the content we are (0-1)
|
||||
const scrollRatio = Math.min(1, Math.max(0,
|
||||
(scrolled - contentTop + windowHeight * 0.3) / contentHeight
|
||||
));
|
||||
|
||||
// Convert to character position in the plain text content
|
||||
const textLength = story.contentPlain?.length || story.contentHtml.length;
|
||||
return Math.floor(scrollRatio * textLength);
|
||||
}, [story]);
|
||||
|
||||
// Convert character position back to scroll position for auto-scroll
|
||||
const scrollToCharacterPosition = useCallback((position: number) => {
|
||||
if (!contentRef.current || !story || hasScrolledToPosition) return;
|
||||
|
||||
const textLength = story.contentPlain?.length || story.contentHtml.length;
|
||||
if (textLength === 0 || position === 0) return;
|
||||
|
||||
const ratio = position / textLength;
|
||||
const content = contentRef.current;
|
||||
const contentTop = content.offsetTop;
|
||||
const contentHeight = content.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// Calculate target scroll position
|
||||
const targetScroll = contentTop + (ratio * contentHeight) - (windowHeight * 0.3);
|
||||
|
||||
// Smooth scroll to position
|
||||
window.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
setHasScrolledToPosition(true);
|
||||
}, [story, hasScrolledToPosition]);
|
||||
|
||||
// Debounced function to save reading position
|
||||
const saveReadingPosition = useCallback(async (position: number) => {
|
||||
if (!story || position === story.readingPosition) {
|
||||
console.log('Skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Saving reading position:', position, 'for story:', story.id);
|
||||
try {
|
||||
const updatedStory = await storyApi.updateReadingProgress(story.id, position);
|
||||
console.log('Reading position saved successfully, updated story:', updatedStory.readingPosition);
|
||||
setStory(prev => prev ? { ...prev, readingPosition: position, lastReadAt: updatedStory.lastReadAt } : null);
|
||||
} catch (error) {
|
||||
console.error('Failed to save reading position:', error);
|
||||
}
|
||||
}, [story]);
|
||||
|
||||
// Debounced version of saveReadingPosition
|
||||
const debouncedSavePosition = useCallback((position: number) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveReadingPosition(position);
|
||||
}, 2000); // Save after 2 seconds of no scrolling
|
||||
}, [saveReadingPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStory = async () => {
|
||||
try {
|
||||
@@ -57,7 +133,27 @@ export default function StoryReadingPage() {
|
||||
}
|
||||
}, [storyId]);
|
||||
|
||||
// Track reading progress
|
||||
// Auto-scroll to saved reading position when story content is loaded
|
||||
useEffect(() => {
|
||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||
// Use a small delay to ensure content is rendered
|
||||
const timeout = setTimeout(() => {
|
||||
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
||||
if (story.readingPosition && story.readingPosition > 0) {
|
||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||
scrollToCharacterPosition(story.readingPosition);
|
||||
} else {
|
||||
// Even if there's no saved position, mark as ready for tracking
|
||||
console.log('No saved position, starting fresh tracking');
|
||||
setHasScrolledToPosition(true);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [story, sanitizedContent, scrollToCharacterPosition, hasScrolledToPosition]);
|
||||
|
||||
// Track reading progress and save position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const article = document.querySelector('[data-reading-content]') as HTMLElement;
|
||||
@@ -72,12 +168,27 @@ export default function StoryReadingPage() {
|
||||
));
|
||||
|
||||
setReadingProgress(progress);
|
||||
|
||||
// Save reading position (debounced)
|
||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||
const characterPosition = getCharacterPositionFromScroll();
|
||||
console.log('Scroll detected, character position:', characterPosition);
|
||||
debouncedSavePosition(characterPosition);
|
||||
} else {
|
||||
console.log('Scroll detected but not ready for tracking yet');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [story]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
// Clean up timeout on unmount
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
||||
|
||||
const handleRatingUpdate = async (newRating: number) => {
|
||||
if (!story) return;
|
||||
@@ -229,6 +340,7 @@ export default function StoryReadingPage() {
|
||||
|
||||
{/* Story Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reading-content"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user