feat: auto position reader ruler to top of page and some fixes (#3075)

* fix(settings): ensure latest config is used when saving view settings

* fix: reader ruler appearing before other book reading contents

* feat(reader): dispatch reader-closing event during book close

* feat(reader) add auto reposition to top when changing pages and position persistence on book close

* fix(reader): add rtl prop to ReadingRuler component

* fix(reader): handle vertical ltr writing mode in ruler auto positioning

* chore: revert redundant changes to settings.ts

* refactor: remove redundant reader-closing

* refactor: use store progress for ruler positioning with throttled saving
This commit is contained in:
Mohammed Efaz
2026-01-26 08:38:06 +01:00
committed by GitHub
parent ffc51e75de
commit 19f9f5ea24
2 changed files with 177 additions and 8 deletions
@@ -186,10 +186,11 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
{viewSettings.readingRulerEnabled && !viewState?.loading && (
{viewSettings.readingRulerEnabled && viewState?.inited && (
<ReadingRuler
bookKey={bookKey}
isVertical={viewSettings.vertical}
rtl={viewSettings.rtl}
lines={viewSettings.readingRulerLines}
position={viewSettings.readingRulerPosition}
opacity={viewSettings.readingRulerOpacity}
@@ -3,12 +3,15 @@ import React, { useCallback, useRef, useState, useEffect } from 'react';
import { Insets } from '@/types/misc';
import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { saveViewSettings } from '@/helpers/settings';
import { READING_RULER_COLORS } from '@/services/constants';
import { throttle } from '@/utils/throttle';
interface ReadingRulerProps {
bookKey: string;
isVertical: boolean;
rtl: boolean;
lines: number;
position: number;
opacity: number;
@@ -36,6 +39,7 @@ const calculateRulerSize = (
const ReadingRuler: React.FC<ReadingRulerProps> = ({
bookKey,
isVertical,
rtl,
lines,
position,
opacity,
@@ -45,19 +49,35 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
gridInsets,
}) => {
const { envConfig } = useEnv();
const { getProgress } = useReaderStore();
const progress = getProgress(bookKey);
const containerRef = useRef<HTMLDivElement>(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<number | null>(null);
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
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 (
<div
ref={containerRef}
className='pointer-events-none absolute inset-0 z-[5]'
className={clsx(
'pointer-events-none absolute inset-0 z-[5] transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0',
)}
style={containerStyle}
>
{/* Left overlay */}
@@ -181,6 +344,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
left: `${currentPosition}%`,
width: `${rulerSize}px`,
transform: 'translateX(-50%)',
transition: getTransitionStyle('left'),
...(color === 'transparent'
? {
backgroundColor: baseColor,
@@ -203,7 +367,10 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
return (
<div
ref={containerRef}
className='pointer-events-none absolute inset-0 z-[5]'
className={clsx(
'pointer-events-none absolute inset-0 z-[5] transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0',
)}
style={containerStyle}
>
{/* Top overlay */}
@@ -234,6 +401,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
top: `${currentPosition}%`,
height: `${rulerSize}px`,
transform: 'translateY(-50%)',
transition: getTransitionStyle('top'),
...(color === 'transparent'
? {
backgroundColor: baseColor,