From aba9e87fc1ca53c602fc5c36b7ec0385d329f6f6 Mon Sep 17 00:00:00 2001 From: Mohammed Efaz Date: Sun, 25 Jan 2026 11:48:13 +0600 Subject: [PATCH] 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 --- .../src/app/reader/components/BooksGrid.tsx | 13 ++ .../app/reader/components/ReadingRuler.tsx | 117 ++++++++++++++++++ .../src/components/settings/LayoutPanel.tsx | 94 ++++++++++++++ .../src/components/settings/NumberInput.tsx | 4 +- apps/readest-app/src/services/constants.ts | 14 +++ apps/readest-app/src/types/book.ts | 6 + 6 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/ReadingRuler.tsx diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index 708dfa64..c548e2ed 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -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 = ({ bookKeys, onCloseBook }) => { contentInsets={contentInsets} gridInsets={gridInsets} /> + {viewSettings.readingRulerEnabled && !viewState?.loading && ( + + )} {showFooter && ( { + 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 = ({ + bookKey, + lines, + position, + opacity, + color, + bookFormat, + viewSettings, + gridInsets, +}) => { + const { envConfig } = useEnv(); + const containerRef = useRef(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 ( +
+
+ {/* extended touch area */} +
+
+
+ ); +}; + +export default ReadingRuler; diff --git a/apps/readest-app/src/components/settings/LayoutPanel.tsx b/apps/readest-app/src/components/settings/LayoutPanel.tsx index 0a9af2d5..ccfa1d72 100644 --- a/apps/readest-app/src/components/settings/LayoutPanel.tsx +++ b/apps/readest-app/src/components/settings/LayoutPanel.tsx @@ -73,6 +73,12 @@ const LayoutPanel: React.FC = ({ 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 = ({ bookKey, onRegisterRese showProgressInfo: setShowProgressInfo, tapToToggleFooter: setTapToToggleFooter, showMarginsOnScroll: setShowMarginsOnScroll, + readingRulerEnabled: setReadingRulerEnabled, + readingRulerLines: setReadingRulerLines, + readingRulerOpacity: setReadingRulerOpacity, }); }; @@ -382,6 +391,26 @@ const LayoutPanel: React.FC = ({ 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 = ({ bookKey, onRegisterRese
+
+

{_('Reading Ruler')}

+
+
+
+ {_('Enable Reading Ruler')} + setReadingRulerEnabled(!readingRulerEnabled)} + /> +
+ +
+ {_('Ruler Color')} +
+
+
+ +
+
+
+ {appService?.hasOrientationLock && (

{_('Screen')}

diff --git a/apps/readest-app/src/components/settings/NumberInput.tsx b/apps/readest-app/src/components/settings/NumberInput.tsx index 18c67984..74c5e741 100644 --- a/apps/readest-app/src/components/settings/NumberInput.tsx +++ b/apps/readest-app/src/components/settings/NumberInput.tsx @@ -95,7 +95,7 @@ const NumberInput: React.FC = ({ 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' : ''}`} > @@ -103,7 +103,7 @@ const NumberInput: React.FC = ({ 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' : ''}`} > diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 973fbd26..7a46cf47 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -99,6 +99,14 @@ export const HIGHLIGHT_COLOR_HEX: Record = { violet: '#a78bfa', // violet-400 }; +export const READING_RULER_COLORS: Record = { + 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 = { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 346f9c7f..6f2ecd44 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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 {