feat: line highlight to guide reading (reading ruler) (#3063)

* feat: add increment decrement numbers for opacity

* feat: add reading ruler colours

* feat: add reading ruler config to book config

* feat: reading Ruler settings panel (in layout section)

* feat: draggable reading ruler

* feat: reading ruler in reader view
This commit is contained in:
Mohammed Efaz
2026-01-25 11:48:13 +06:00
committed by GitHub
parent 2c4df601d8
commit aba9e87fc1
6 changed files with 246 additions and 2 deletions
@@ -20,6 +20,7 @@ import Ribbon from './Ribbon';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import ReadingRuler from './ReadingRuler';
import DoubleBorder from './DoubleBorder';
interface BooksGridProps {
@@ -185,6 +186,18 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
{viewSettings.readingRulerEnabled && !viewState?.loading && (
<ReadingRuler
bookKey={bookKey}
lines={viewSettings.readingRulerLines}
position={viewSettings.readingRulerPosition}
opacity={viewSettings.readingRulerOpacity}
color={viewSettings.readingRulerColor}
bookFormat={book.format}
viewSettings={viewSettings}
gridInsets={gridInsets}
/>
)}
{showFooter && (
<ProgressInfoView
bookKey={bookKey}
@@ -0,0 +1,117 @@
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 { saveViewSettings } from '@/helpers/settings';
import { READING_RULER_COLORS } from '@/services/constants';
interface ReadingRulerProps {
bookKey: string;
lines: number;
position: number;
opacity: number;
color: keyof typeof READING_RULER_COLORS;
bookFormat: BookFormat;
viewSettings: ViewSettings;
gridInsets: Insets;
}
const FIXED_LAYOUT_LINE_HEIGHT = 28;
const calculateRulerHeight = (
lines: number,
viewSettings: ViewSettings,
bookFormat: BookFormat,
): number => {
if (FIXED_LAYOUT_FORMATS.has(bookFormat)) {
return lines * FIXED_LAYOUT_LINE_HEIGHT;
}
const fontSize = viewSettings.defaultFontSize || 16;
const lineHeight = viewSettings.lineHeight || 1.5;
return Math.round(lines * fontSize * lineHeight);
};
const ReadingRuler: React.FC<ReadingRulerProps> = ({
bookKey,
lines,
position,
opacity,
color,
bookFormat,
viewSettings,
gridInsets,
}) => {
const { envConfig } = useEnv();
const containerRef = useRef<HTMLDivElement>(null);
const [currentPosition, setCurrentPosition] = useState(position);
const isDragging = useRef(false);
const height = calculateRulerHeight(lines, viewSettings, bookFormat);
const baseColor = READING_RULER_COLORS[color] || READING_RULER_COLORS['yellow'];
useEffect(() => {
setCurrentPosition(position);
}, [position]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
isDragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !containerRef.current) return;
e.preventDefault();
e.stopPropagation();
const rect = containerRef.current.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const newPosition = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
setCurrentPosition(newPosition);
}, []);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
isDragging.current = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
saveViewSettings(envConfig, bookKey, 'readingRulerPosition', currentPosition, false, false);
},
[envConfig, bookKey, currentPosition],
);
const topOffset = gridInsets.top;
const bottomOffset = gridInsets.bottom;
return (
<div
ref={containerRef}
className='pointer-events-none absolute inset-0 z-[5]'
style={{
top: `${topOffset}px`,
bottom: `${bottomOffset}px`,
}}
>
<div
className='pointer-events-auto absolute left-0 right-0 cursor-row-resize transition-shadow active:shadow-lg'
style={{
top: `${currentPosition}%`,
height: `${height}px`,
backgroundColor: baseColor,
opacity: opacity,
transform: 'translateY(-50%)',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* extended touch area */}
<div className='absolute inset-x-0 -bottom-2 -top-2' />
</div>
</div>
);
};
export default ReadingRuler;
@@ -73,6 +73,12 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
const [tapToToggleFooter, setTapToToggleFooter] = useState(viewSettings.tapToToggleFooter);
const [progressStyle, setProgressStyle] = useState(viewSettings.progressStyle);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation);
const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled);
const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines);
const [readingRulerOpacity, setReadingRulerOpacity] = useState(viewSettings.readingRulerOpacity);
const [readingRulerColor, setReadingRulerColor] = useState(viewSettings.readingRulerColor);
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
@@ -107,6 +113,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
showProgressInfo: setShowProgressInfo,
tapToToggleFooter: setTapToToggleFooter,
showMarginsOnScroll: setShowMarginsOnScroll,
readingRulerEnabled: setReadingRulerEnabled,
readingRulerLines: setReadingRulerLines,
readingRulerOpacity: setReadingRulerOpacity,
});
};
@@ -382,6 +391,26 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenOrientation]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'readingRulerEnabled', readingRulerEnabled, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readingRulerEnabled]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'readingRulerLines', readingRulerLines, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readingRulerLines]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'readingRulerOpacity', readingRulerOpacity, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readingRulerOpacity]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'readingRulerColor', readingRulerColor, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readingRulerColor]);
const langCode = getBookLangCode(bookData?.bookDoc?.metadata?.language);
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
const isVertical = viewSettings.vertical || writingMode.includes('vertical');
@@ -728,6 +757,71 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Reading Ruler')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Enable Reading Ruler')}</span>
<input
type='checkbox'
className='toggle'
checked={readingRulerEnabled}
onChange={() => setReadingRulerEnabled(!readingRulerEnabled)}
/>
</div>
<NumberInput
label={_('Lines to Highlight')}
value={readingRulerLines}
onChange={setReadingRulerLines}
disabled={!readingRulerEnabled}
min={1}
max={6}
step={1}
/>
<div className='config-item'>
<span className=''>{_('Ruler Color')}</span>
<div className='flex gap-2'>
<button
disabled={!readingRulerEnabled}
className={`btn btn-circle btn-sm bg-yellow-400 hover:bg-yellow-500 ${readingRulerColor === 'yellow' ? 'ring-base-content ring-2 ring-offset-1' : ''} ${!readingRulerEnabled ? 'btn-disabled opacity-50' : ''}`}
onClick={() => setReadingRulerColor('yellow')}
/>
<button
disabled={!readingRulerEnabled}
className={`btn btn-circle btn-sm bg-green-400 hover:bg-green-500 ${readingRulerColor === 'green' ? 'ring-base-content ring-2 ring-offset-1' : ''} ${!readingRulerEnabled ? 'btn-disabled opacity-50' : ''}`}
onClick={() => setReadingRulerColor('green')}
/>
<button
disabled={!readingRulerEnabled}
className={`btn btn-circle btn-sm bg-blue-400 hover:bg-blue-500 ${readingRulerColor === 'blue' ? 'ring-base-content ring-2 ring-offset-1' : ''} ${!readingRulerEnabled ? 'btn-disabled opacity-50' : ''}`}
onClick={() => setReadingRulerColor('blue')}
/>
<button
disabled={!readingRulerEnabled}
className={`btn btn-circle btn-sm bg-violet-400 hover:bg-violet-500 ${readingRulerColor === 'violet' ? 'ring-base-content ring-2 ring-offset-1' : ''} ${!readingRulerEnabled ? 'btn-disabled opacity-50' : ''}`}
onClick={() => setReadingRulerColor('violet')}
/>
<button
disabled={!readingRulerEnabled}
className={`btn btn-circle btn-sm bg-rose-400 hover:bg-rose-500 ${readingRulerColor === 'rose' ? 'ring-base-content ring-2 ring-offset-1' : ''} ${!readingRulerEnabled ? 'btn-disabled opacity-50' : ''}`}
onClick={() => setReadingRulerColor('rose')}
/>
</div>
</div>
<NumberInput
label={_('Opacity')}
value={readingRulerOpacity}
onChange={setReadingRulerOpacity}
disabled={!readingRulerEnabled}
min={0.1}
max={0.5}
step={0.1}
/>
</div>
</div>
</div>
{appService?.hasOrientationLock && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
@@ -95,7 +95,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
tabIndex={disabled ? -1 : 0}
aria-label={_('Decrease')}
onClick={decrement}
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${localValue <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiMinus className='h-4 w-4' />
</button>
@@ -103,7 +103,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
tabIndex={disabled ? -1 : 0}
aria-label={_('Increase')}
onClick={increment}
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${localValue >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiPlus className='h-4 w-4' />
</button>
@@ -99,6 +99,14 @@ export const HIGHLIGHT_COLOR_HEX: Record<HighlightColor, string> = {
violet: '#a78bfa', // violet-400
};
export const READING_RULER_COLORS: Record<string, string> = {
yellow: '#facc15',
green: '#4ade80',
blue: '#60a5fa',
violet: '#a78bfa',
rose: '#fb7185',
};
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '15%',
isSideBarPinned: true,
@@ -241,6 +249,12 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
showMarginsOnScroll: false,
progressStyle: 'fraction',
progressInfoMode: 'all',
readingRulerEnabled: false,
readingRulerLines: 2,
readingRulerPosition: 33,
readingRulerOpacity: 0.3,
readingRulerColor: 'yellow',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
+6
View File
@@ -200,6 +200,12 @@ export interface ViewConfig {
showMarginsOnScroll: boolean;
progressStyle: 'percentage' | 'fraction';
progressInfoMode: 'remaining' | 'progress' | 'all' | 'none';
readingRulerEnabled: boolean;
readingRulerLines: number;
readingRulerPosition: number;
readingRulerOpacity: number;
readingRulerColor: 'yellow' | 'green' | 'blue' | 'violet' | 'rose';
}
export interface TTSConfig {