diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index 0419e8f8..29d3a868 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -186,10 +186,11 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => { contentInsets={contentInsets} gridInsets={gridInsets} /> - {viewSettings.readingRulerEnabled && !viewState?.loading && ( + {viewSettings.readingRulerEnabled && viewState?.inited && ( = ({ bookKey, isVertical, + rtl, lines, position, opacity, @@ -45,19 +49,35 @@ const ReadingRuler: React.FC = ({ gridInsets, }) => { const { envConfig } = useEnv(); + const { getProgress } = useReaderStore(); + const progress = getProgress(bookKey); const containerRef = useRef(null); const [currentPosition, setCurrentPosition] = useState(position); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + // State for visibility animation (fade in) + const [isVisible, setIsVisible] = useState(false); + + // State for smooth auto-position animation + const [shouldAnimate, setShouldAnimate] = useState(false); + const isDragging = useRef(false); + const lastPageRef = useRef(null); + const animationTimeoutRef = useRef | null>(null); + const currentPositionRef = useRef(position); const rulerSize = calculateRulerSize(lines, viewSettings, bookFormat); const baseColor = READING_RULER_COLORS[color] || READING_RULER_COLORS['yellow']; - useEffect(() => { - setCurrentPosition(position); - }, [position]); + // eslint-disable-next-line react-hooks/exhaustive-deps + const throttledSave = useCallback( + throttle((pos: number) => { + saveViewSettings(envConfig, bookKey, 'readingRulerPosition', pos, false, false); + }, 10000), + [envConfig, bookKey], + ); + // Track container size for overlay calculations useEffect(() => { if (!containerRef.current) return; @@ -78,10 +98,145 @@ const ReadingRuler: React.FC = ({ return () => resizeObserver.disconnect(); }, []); + // Fade in on mount (delayed to prevent flash before content loads) + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 30); + return () => clearTimeout(timer); + }, []); + + // Auto-move ruler to first visible text on page change + useEffect(() => { + if (!progress?.pageinfo) return; + + /** + * Get the position of the first visible text element. + * For horizontal mode: returns top offset (same for both LTR and RTL) + * For vertical-rl mode (Japanese/Chinese): returns distance from right edge + * For vertical-lr mode (Mongolian): returns distance from left edge + */ + const getFirstVisibleTextPosition = (range: Range | null): number | null => { + if (!range) return null; + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return null; + + try { + const rects = range.getClientRects(); + if (rects.length === 0) return null; + + if (isVertical) { + // Vertical writing mode: text flows top-to-bottom + // For vertical-rl (rtl=true): columns flow right-to-left, first column is on right + // For vertical-lr (rtl=false): columns flow left-to-right, first column is on left + const viewportMidY = containerRect.top + containerRect.height / 2; + for (let i = 0; i < rects.length; i++) { + const rect = rects.item(i); + if (!rect || rect.height <= 0 || rect.width <= 0) continue; + // Check if this rect is in the upper half of the viewport (first visible line) + if (rect.top + rect.height / 2 < viewportMidY) { + if (rtl) { + // vertical-rl: return distance from right edge + return containerRect.right - rect.right; + } else { + // vertical-lr: return distance from left edge + return rect.left - containerRect.left; + } + } + } + const firstRect = rects.item(0); + if (firstRect && firstRect.width > 0) { + if (rtl) { + return containerRect.right - firstRect.right; + } else { + return firstRect.left - containerRect.left; + } + } + } else { + // Horizontal writing mode: find first line's top position + const viewportMidX = containerRect.left + containerRect.width / 2; + for (let i = 0; i < rects.length; i++) { + const rect = rects.item(i); + if (!rect || rect.height <= 0 || rect.width <= 0) continue; + if (rect.left + rect.width / 2 < viewportMidX) { + return rect.top - containerRect.top; + } + } + const firstRect = rects.item(0); + if (firstRect && firstRect.height > 0) { + return firstRect.top - containerRect.top; + } + } + } catch { + /* ignore errors from invalid ranges */ + } + return null; + }; + + const performAutoMove = (range: Range | null) => { + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return; + + const containerDimension = isVertical ? containerRect.width : containerRect.height; + if (containerDimension <= 0) return; + + const textPosition = getFirstVisibleTextPosition(range); + // For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr + const defaultOffset = isVertical + ? rtl + ? (viewSettings.marginRightPx ?? 44) + : (viewSettings.marginLeftPx ?? 44) + : (viewSettings.marginTopPx ?? 44); + + const offset = textPosition ?? defaultOffset; + const targetPosition = Math.max( + 5, + Math.min(95, ((offset + rulerSize / 2) / containerDimension) * 100), + ); + + // Clear any existing animation timeout + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + + // Enable animation, update position, then disable animation after transition + setShouldAnimate(true); + setCurrentPosition(targetPosition); + currentPositionRef.current = targetPosition; + throttledSave(targetPosition); + animationTimeoutRef.current = setTimeout(() => setShouldAnimate(false), 650); + }; + + const currentPage = progress.pageinfo.current; + const range = progress.range; + + // Only auto-move if page actually changed (not on initial load) + if (lastPageRef.current !== null && lastPageRef.current !== currentPage) { + requestAnimationFrame(() => performAutoMove(range)); + } + lastPageRef.current = currentPage; + + return () => { + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + progress?.pageinfo?.current, + isVertical, + rtl, + viewSettings.marginTopPx, + viewSettings.marginLeftPx, + viewSettings.marginRightPx, + rulerSize, + throttledSave, + ]); + const handlePointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); isDragging.current = true; + // Disable animation during manual drag for immediate feedback + setShouldAnimate(false); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, []); @@ -102,6 +257,7 @@ const ReadingRuler: React.FC = ({ newPosition = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); } setCurrentPosition(newPosition); + currentPositionRef.current = newPosition; }, [isVertical], ); @@ -111,9 +267,9 @@ const ReadingRuler: React.FC = ({ if (!isDragging.current) return; isDragging.current = false; (e.target as HTMLElement).releasePointerCapture(e.pointerId); - saveViewSettings(envConfig, bookKey, 'readingRulerPosition', currentPosition, false, false); + throttledSave(currentPosition); }, - [envConfig, bookKey, currentPosition], + [currentPosition, throttledSave], ); const fadeOpacity = Math.min(0.9, opacity); @@ -145,12 +301,19 @@ const ReadingRuler: React.FC = ({ WebkitBackdropFilter: cssFilter, }; + // Animation transition for smooth auto-positioning + const getTransitionStyle = (property: 'left' | 'top') => + shouldAnimate ? `${property} 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)` : 'none'; + if (isVertical) { // Vertical ruler (for vertical writing mode - moves left/right) return (
{/* Left overlay */} @@ -181,6 +344,7 @@ const ReadingRuler: React.FC = ({ left: `${currentPosition}%`, width: `${rulerSize}px`, transform: 'translateX(-50%)', + transition: getTransitionStyle('left'), ...(color === 'transparent' ? { backgroundColor: baseColor, @@ -203,7 +367,10 @@ const ReadingRuler: React.FC = ({ return (
{/* Top overlay */} @@ -234,6 +401,7 @@ const ReadingRuler: React.FC = ({ top: `${currentPosition}%`, height: `${rulerSize}px`, transform: 'translateY(-50%)', + transition: getTransitionStyle('top'), ...(color === 'transparent' ? { backgroundColor: baseColor,