forked from akai/readest
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user