From 9ecb9b24d2d10e207f2473b50224ab08131481b2 Mon Sep 17 00:00:00 2001 From: Mohammed Efaz <44260523+WhiteHades@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:59:44 +0600 Subject: [PATCH] feat: make reading ruler selection and step navigation coherent (#3722) * refactor(reader): extract reading ruler math helpers * fix(reader): keep text selectable inside reading ruler * feat(reader): route taps to ruler step navigation * feat(reader): route keyboard navigation to ruler steps * fix(reader): animate ruler mask and frame together * fix(reader): preserve drag anchor for ruler handles * fix(reader): fall back to page turns at ruler edges --- .../components/ReadingRuler.test.tsx | 185 ++++++++++++++++ .../components/useBookShortcuts.test.tsx | 173 +++++++++++++++ .../src/__tests__/utils/readingRuler.test.ts | 66 ++++++ .../app/reader/components/ReadingRuler.tsx | 198 ++++++++++++------ .../src/app/reader/hooks/useBookShortcuts.ts | 19 ++ .../src/app/reader/hooks/usePagination.ts | 61 ++++-- .../src/app/reader/utils/readingRuler.ts | 66 ++++++ 7 files changed, 678 insertions(+), 90 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx create mode 100644 apps/readest-app/src/__tests__/utils/readingRuler.test.ts create mode 100644 apps/readest-app/src/app/reader/utils/readingRuler.ts diff --git a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx new file mode 100644 index 00000000..ea24fe55 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx @@ -0,0 +1,185 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ReadingRuler from '@/app/reader/components/ReadingRuler'; +import { ViewSettings } from '@/types/book'; +import { eventDispatcher } from '@/utils/event'; + +const saveViewSettings = vi.fn(); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {} }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getProgress: () => null, + }), +})); + +vi.mock('@/helpers/settings', () => ({ + saveViewSettings: (...args: unknown[]) => saveViewSettings(...args), +})); + +global.ResizeObserver = class ResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +}; + +HTMLElement.prototype.setPointerCapture = vi.fn(); +HTMLElement.prototype.releasePointerCapture = vi.fn(); + +describe('ReadingRuler', () => { + const viewSettings = { + defaultFontSize: 16, + lineHeight: 1.5, + } as ViewSettings; + + beforeEach(() => { + vi.clearAllMocks(); + + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => 1000, + }); + + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 800, + }); + + HTMLElement.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 1000, + width: 800, + height: 1000, + toJSON: () => ({}), + })); + }); + + afterEach(() => { + cleanup(); + }); + + it('keeps the ruler body pass-through and exposes dedicated drag handles', () => { + const { container } = render( + , + ); + + const ruler = container.querySelector('.ruler'); + const dragHandles = container.querySelectorAll('.cursor-row-resize'); + + expect(ruler?.className).toContain('pointer-events-none'); + expect(dragHandles).toHaveLength(2); + dragHandles.forEach((handle) => { + expect(handle.className).toContain('pointer-events-auto'); + }); + }); + + it('moves and persists the ruler position when a ruler-step event is dispatched', async () => { + const { container } = render( + , + ); + + eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey: 'book-1', + direction: 'forward', + }); + + await waitFor(() => { + const ruler = container.querySelector('.ruler') as HTMLDivElement; + const overlays = container.querySelectorAll('.bg-base-100'); + + expect(ruler.style.top).toBe('37.8%'); + expect(ruler.style.transition).toContain('top 0.6s'); + expect(overlays[0]?.getAttribute('style')).toContain('transition: height 0.6s'); + expect(overlays[1]?.getAttribute('style')).toContain('transition: height 0.6s'); + expect(saveViewSettings).toHaveBeenCalledWith( + {}, + 'book-1', + 'readingRulerPosition', + 37.8, + false, + false, + ); + }); + }); + + it('keeps the drag handle anchored instead of snapping the ruler center to the pointer', () => { + const { container } = render( + , + ); + + const ruler = container.querySelector('.ruler') as HTMLDivElement; + const topHandle = container.querySelector('.cursor-row-resize') as HTMLDivElement; + + expect(ruler.style.top).toBe('33%'); + + fireEvent.pointerDown(topHandle, { pointerId: 1, clientY: 306 }); + fireEvent.pointerMove(topHandle, { pointerId: 1, clientY: 316 }); + + expect(ruler.style.top).toBe('34%'); + }); + + it('does not consume ruler movement when already at the boundary', () => { + render( + , + ); + + const consumed = eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey: 'book-1', + direction: 'forward', + }); + + expect(consumed).toBe(false); + expect(saveViewSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx new file mode 100644 index 00000000..d8ba59e3 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx @@ -0,0 +1,173 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useBookShortcuts from '@/app/reader/hooks/useBookShortcuts'; +import { eventDispatcher } from '@/utils/event'; + +const shortcutState = { + actions: null as Record< + string, + ((event?: KeyboardEvent | MessageEvent) => void) | undefined + > | null, +}; + +const mockView = { + book: { dir: 'ltr' }, + prev: vi.fn(), + next: vi.fn(), + pan: vi.fn(), + renderer: { + scrolled: false, + setAttribute: vi.fn(), + }, + history: { + back: vi.fn(), + forward: vi.fn(), + }, +}; + +const currentViewSettings = { + defaultFontSize: 16, + lineHeight: 1.5, + readingRulerEnabled: true, + paragraphMode: { enabled: false }, +}; + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getView: () => mockView, + getViewState: () => ({ ttsEnabled: false }), + getViewSettings: () => currentViewSettings, + setViewSettings: vi.fn(), + }), +})); + +vi.mock('@/store/sidebarStore', () => ({ + useSidebarStore: () => ({ + toggleSideBar: vi.fn(), + setSideBarBookKey: vi.fn(), + }), +})); + +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: () => ({ + setSettingsDialogOpen: vi.fn(), + }), +})); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ + getBookData: vi.fn(), + }), +})); + +vi.mock('@/store/notebookStore', () => ({ + useNotebookStore: () => ({ + toggleNotebook: vi.fn(), + }), +})); + +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ + safeAreaInsets: null, + }), +})); + +vi.mock('@/components/command-palette', () => ({ + useCommandPalette: () => ({ + open: vi.fn(), + }), +})); + +vi.mock('@/hooks/useShortcuts', () => ({ + default: (actions: typeof shortcutState.actions) => { + shortcutState.actions = actions; + }, +})); + +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: () => false, +})); + +vi.mock('@/utils/window', () => ({ + tauriHandleClose: vi.fn(), + tauriHandleToggleFullScreen: vi.fn(), + tauriQuitApp: vi.fn(), +})); + +vi.mock('@/utils/style', () => ({ + getStyles: vi.fn(), +})); + +vi.mock('@/services/constants', () => ({ + MAX_ZOOM_LEVEL: 200, + MIN_ZOOM_LEVEL: 50, + ZOOM_STEP: 10, +})); + +vi.mock('@/app/reader/hooks/useBooksManager', () => ({ + default: () => ({ + getNextBookKey: () => 'book-1', + }), +})); + +const Harness = () => { + useBookShortcuts({ sideBarBookKey: 'book-1', bookKeys: ['book-1'] }); + return null; +}; + +describe('useBookShortcuts', () => { + beforeEach(() => { + vi.clearAllMocks(); + shortcutState.actions = null; + currentViewSettings.readingRulerEnabled = true; + mockView.book.dir = 'ltr'; + }); + + afterEach(() => { + cleanup(); + }); + + it('routes page-turn shortcuts to reading ruler movement when enabled', () => { + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(true); + + render(); + shortcutState.actions?.['onGoNext']?.(); + + expect(dispatchSpy).toHaveBeenCalledWith('reading-ruler-move', { + bookKey: 'book-1', + direction: 'forward', + }); + expect(mockView.next).not.toHaveBeenCalled(); + }); + + it('uses reading order when directional shortcuts are handled in rtl books', () => { + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(true); + mockView.book.dir = 'rtl'; + + render(); + shortcutState.actions?.['onGoRight']?.(); + + expect(dispatchSpy).toHaveBeenCalledWith('reading-ruler-move', { + bookKey: 'book-1', + direction: 'backward', + }); + }); + + it('falls back to normal page navigation when the ruler is disabled', () => { + currentViewSettings.readingRulerEnabled = false; + + render(); + shortcutState.actions?.['onGoNext']?.(); + + expect(mockView.next).toHaveBeenCalledWith(72); + }); + + it('falls back to normal page navigation when the ruler cannot move further', () => { + vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(false); + + render(); + shortcutState.actions?.['onGoNext']?.(); + + expect(mockView.next).toHaveBeenCalledWith(72); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/readingRuler.test.ts b/apps/readest-app/src/__tests__/utils/readingRuler.test.ts new file mode 100644 index 00000000..23b58670 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/readingRuler.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateReadingRulerSize, + clampReadingRulerPosition, + FIXED_LAYOUT_READING_RULER_LINE_HEIGHT, + getReadingRulerMoveDirection, + stepReadingRulerPosition, +} from '@/app/reader/utils/readingRuler'; + +describe('readingRuler utils', () => { + it('calculates fixed-layout ruler size from the configured line count', () => { + expect( + calculateReadingRulerSize( + 3, + { + defaultFontSize: 16, + lineHeight: 1.5, + }, + 'PDF', + ), + ).toBe(3 * FIXED_LAYOUT_READING_RULER_LINE_HEIGHT); + }); + + it('calculates reflowable ruler size from font size and line height', () => { + expect( + calculateReadingRulerSize( + 2, + { + defaultFontSize: 18, + lineHeight: 1.4, + }, + 'EPUB', + ), + ).toBe(50); + }); + + it('clamps the ruler center so the full ruler stays inside the viewport', () => { + expect(clampReadingRulerPosition(5, 1000, 200)).toBe(10); + expect(clampReadingRulerPosition(95, 1000, 200)).toBe(90); + expect(clampReadingRulerPosition(33, 1000, 200)).toBe(33); + }); + + it('centers the ruler when it is larger than the viewport', () => { + expect(clampReadingRulerPosition(10, 100, 120)).toBe(50); + expect(clampReadingRulerPosition(90, 100, 120)).toBe(50); + }); + + it('moves by exactly one ruler window per navigation step', () => { + expect(stepReadingRulerPosition(33, 1000, 200, 'forward')).toBe(53); + expect(stepReadingRulerPosition(33, 1000, 200, 'backward')).toBe(13); + }); + + it('clamps stepped movement at the edges', () => { + expect(stepReadingRulerPosition(85, 1000, 200, 'forward')).toBe(90); + expect(stepReadingRulerPosition(15, 1000, 200, 'backward')).toBe(10); + }); + + it('maps tap and key sides to logical ruler movement direction', () => { + expect(getReadingRulerMoveDirection('right', 'ltr')).toBe('forward'); + expect(getReadingRulerMoveDirection('left', 'ltr')).toBe('backward'); + expect(getReadingRulerMoveDirection('right', 'rtl')).toBe('backward'); + expect(getReadingRulerMoveDirection('left', 'rtl')).toBe('forward'); + expect(getReadingRulerMoveDirection('down', 'rtl')).toBe('forward'); + expect(getReadingRulerMoveDirection('up', 'ltr')).toBe('backward'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx index b9fac5af..ef0e552b 100644 --- a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx +++ b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx @@ -1,12 +1,18 @@ import clsx from 'clsx'; import React, { useCallback, useRef, useState, useEffect } from 'react'; import { Insets } from '@/types/misc'; -import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book'; +import { BookFormat, 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'; +import { eventDispatcher } from '@/utils/event'; +import { + calculateReadingRulerSize, + clampReadingRulerPosition, + stepReadingRulerPosition, +} from '../utils/readingRuler'; interface ReadingRulerProps { bookKey: string; @@ -21,21 +27,6 @@ interface ReadingRulerProps { gridInsets: Insets; } -const FIXED_LAYOUT_LINE_HEIGHT = 28; - -const calculateRulerSize = ( - 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 = ({ bookKey, isVertical, @@ -61,22 +52,16 @@ const ReadingRuler: React.FC = ({ const [shouldAnimate, setShouldAnimate] = useState(false); const isDragging = useRef(false); + const dragPointerOffsetRef = useRef(0); const lastPageRef = useRef(null); const animationTimeoutRef = useRef | null>(null); const currentPositionRef = useRef(position); - const rulerSize = calculateRulerSize(lines, viewSettings, bookFormat); + const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat); const baseColor = READING_RULER_COLORS[color] || READING_RULER_COLORS['yellow']; const clampPosition = useCallback( - (pos: number, dimension: number) => { - if (dimension <= 0) return Math.max(0, Math.min(100, pos)); - const halfPct = (rulerSize / 2 / dimension) * 100; - if (halfPct >= 50) return 50; - const min = halfPct; - const max = 100 - halfPct; - return Math.max(min, Math.min(max, pos)); - }, + (pos: number, dimension: number) => clampReadingRulerPosition(pos, dimension, rulerSize), [rulerSize], ); @@ -88,6 +73,28 @@ const ReadingRuler: React.FC = ({ [envConfig, bookKey], ); + const setRulerPosition = useCallback( + (nextPosition: number, animate = false) => { + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + animationTimeoutRef.current = null; + } + + setShouldAnimate(animate); + setCurrentPosition(nextPosition); + currentPositionRef.current = nextPosition; + throttledSave(nextPosition); + + if (animate) { + animationTimeoutRef.current = setTimeout(() => { + setShouldAnimate(false); + animationTimeoutRef.current = null; + }, 650); + } + }, + [throttledSave], + ); + // Track container size for overlay calculations useEffect(() => { if (!containerRef.current) return; @@ -203,17 +210,7 @@ const ReadingRuler: React.FC = ({ containerDimension, ); - // 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); + setRulerPosition(targetPosition, true); }; const currentPage = progress.pageinfo.current; @@ -240,17 +237,31 @@ const ReadingRuler: React.FC = ({ viewSettings.marginLeftPx, viewSettings.marginRightPx, rulerSize, - throttledSave, + setRulerPosition, ]); - const handlePointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - isDragging.current = true; - // Disable animation during manual drag for immediate feedback - setShouldAnimate(false); - e.currentTarget.setPointerCapture(e.pointerId); - }, []); + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDragging.current = true; + + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + const dimension = isVertical ? rect.width : rect.height; + const pointerPosition = isVertical ? e.clientX - rect.left : e.clientY - rect.top; + const rulerCenter = (currentPositionRef.current / 100) * dimension; + dragPointerOffsetRef.current = pointerPosition - rulerCenter; + } else { + dragPointerOffsetRef.current = 0; + } + + // Disable animation during manual drag for immediate feedback + setShouldAnimate(false); + e.currentTarget.setPointerCapture(e.pointerId); + }, + [isVertical], + ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { @@ -262,10 +273,10 @@ const ReadingRuler: React.FC = ({ let newPosition: number; if (isVertical) { - const relativeX = e.clientX - rect.left; + const relativeX = e.clientX - rect.left - dragPointerOffsetRef.current; newPosition = clampPosition((relativeX / rect.width) * 100, rect.width); } else { - const relativeY = e.clientY - rect.top; + const relativeY = e.clientY - rect.top - dragPointerOffsetRef.current; newPosition = clampPosition((relativeY / rect.height) * 100, rect.height); } setCurrentPosition(newPosition); @@ -278,10 +289,11 @@ const ReadingRuler: React.FC = ({ (e: React.PointerEvent) => { if (!isDragging.current) return; isDragging.current = false; + dragPointerOffsetRef.current = 0; e.currentTarget.releasePointerCapture(e.pointerId); - throttledSave(currentPosition); + throttledSave(currentPositionRef.current); }, - [currentPosition, throttledSave], + [throttledSave], ); useEffect(() => { @@ -289,11 +301,45 @@ const ReadingRuler: React.FC = ({ if (!dimension || isDragging.current) return; const clamped = clampPosition(currentPositionRef.current, dimension); if (clamped !== currentPositionRef.current) { - setCurrentPosition(clamped); - currentPositionRef.current = clamped; - throttledSave(clamped); + setRulerPosition(clamped); } - }, [containerSize.width, containerSize.height, isVertical, clampPosition, throttledSave]); + }, [containerSize.width, containerSize.height, isVertical, clampPosition, setRulerPosition]); + + useEffect(() => { + const handleMove = (event: CustomEvent) => { + const detail = (event.detail ?? {}) as { + bookKey?: string; + direction?: 'backward' | 'forward'; + }; + + if (detail.bookKey !== bookKey || !detail.direction || isDragging.current) return false; + + const dimension = isVertical + ? (containerRef.current?.clientWidth ?? containerSize.width) + : (containerRef.current?.clientHeight ?? containerSize.height); + + if (!dimension) return false; + + const nextPosition = stepReadingRulerPosition( + currentPositionRef.current, + dimension, + rulerSize, + detail.direction, + ); + + if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) { + return false; + } + + setRulerPosition(nextPosition, true); + return true; + }; + + eventDispatcher.onSync('reading-ruler-move', handleMove); + return () => { + eventDispatcher.offSync('reading-ruler-move', handleMove); + }; + }, [bookKey, containerSize.height, containerSize.width, isVertical, rulerSize, setRulerPosition]); const fadeOpacity = Math.min(0.9, opacity); @@ -320,9 +366,16 @@ const ReadingRuler: React.FC = ({ }; // Animation transition for smooth auto-positioning - const getTransitionStyle = (property: 'left' | 'top') => + const getTransitionStyle = (property: 'left' | 'top' | 'width' | 'height') => shouldAnimate ? `${property} 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)` : 'none'; + const dragHandleProps = { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerUp, + }; + if (isVertical) { // Vertical ruler (for vertical writing mode - moves left/right) return ( @@ -339,6 +392,7 @@ const ReadingRuler: React.FC = ({ style={{ width: `${rulerStartPx}px`, opacity: fadeOpacity, + transition: getTransitionStyle('width'), }} /> @@ -348,13 +402,14 @@ const ReadingRuler: React.FC = ({ style={{ width: `${containerSize.width - rulerEndPx}px`, opacity: fadeOpacity, + transition: getTransitionStyle('width'), }} /> {/* Vertical ruler */}
= ({ } : backdropFilterStyle), }} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} > - {/* extended touch area */} -
+ {/* Keep the ruler body pass-through so text inside stays selectable. */} +
+
); @@ -395,6 +453,7 @@ const ReadingRuler: React.FC = ({ style={{ height: `${rulerStartPx}px`, opacity: fadeOpacity, + transition: getTransitionStyle('height'), }} /> @@ -404,13 +463,14 @@ const ReadingRuler: React.FC = ({ style={{ height: `${containerSize.height - rulerEndPx}px`, opacity: fadeOpacity, + transition: getTransitionStyle('height'), }} /> {/* Horizontal ruler */}
= ({ } : backdropFilterStyle), }} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} > - {/* Extended touch area */} -
+
+
); diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index c8d74ee4..cb0c37e5 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -14,6 +14,7 @@ import { viewPagination } from './usePagination'; import { getStyles } from '@/utils/style'; import useShortcuts from '@/hooks/useShortcuts'; import useBooksManager from './useBooksManager'; +import { getReadingRulerMoveDirection } from '../utils/readingRuler'; interface UseBookShortcutsProps { sideBarBookKey: string | null; @@ -34,6 +35,18 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const lineHeight = viewSettings?.lineHeight ?? 1.6; const distance = fontSize * lineHeight * 3; + const moveReadingRuler = (side: 'left' | 'right' | 'up' | 'down') => { + if (!sideBarBookKey) return false; + + const viewSettings = getViewSettings(sideBarBookKey); + if (!viewSettings?.readingRulerEnabled) return false; + + return eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey: sideBarBookKey, + direction: getReadingRulerMoveDirection(side, getView(sideBarBookKey)?.book.dir), + }); + }; + const toggleScrollMode = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); if (viewSettings && sideBarBookKey) { @@ -55,6 +68,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('paragraph-prev', { bookKey: sideBarBookKey }); return; } + if (moveReadingRuler('left')) return; viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'pan', distance); }; @@ -65,6 +79,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('paragraph-next', { bookKey: sideBarBookKey }); return; } + if (moveReadingRuler('right')) return; viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'pan', distance); }; @@ -76,6 +91,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('paragraph-prev', { bookKey: sideBarBookKey }); return; } + if (moveReadingRuler('up')) return; if (view?.renderer.scrolled && event instanceof MessageEvent) return; viewPagination(view, viewSettings, 'up', 'pan', distance); }; @@ -88,6 +104,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('paragraph-next', { bookKey: sideBarBookKey }); return; } + if (moveReadingRuler('down')) return; if (view?.renderer.scrolled && event instanceof MessageEvent) return; viewPagination(view, viewSettings, 'down', 'pan', distance); }; @@ -113,10 +130,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = }; const goPrev = () => { + if (moveReadingRuler('up')) return; getView(sideBarBookKey)?.prev(distance); }; const goNext = () => { + if (moveReadingRuler('down')) return; getView(sideBarBookKey)?.next(distance); }; diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index 47da3c4d..b4436f4c 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -8,6 +8,7 @@ import { useDeviceControlStore } from '@/store/deviceStore'; import { eventDispatcher } from '@/utils/event'; import { isTauriAppPlatform } from '@/services/environment'; import { tauriGetWindowLogicalPosition } from '@/utils/window'; +import { getReadingRulerMoveDirection } from '../utils/readingRuler'; export type ScrollSource = 'touch' | 'mouse'; @@ -116,6 +117,13 @@ export const usePagination = ( const bookData = getBookData(bookKey); if (!viewState?.inited || !bookData) return; + const dispatchReadingRulerMove = (side: PaginationSide) => { + return eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey, + direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir), + }); + }; + if (msg instanceof MessageEvent) { if (msg.data && msg.data.bookKey === bookKey) { const viewSettings = getViewSettings(bookKey)!; @@ -151,29 +159,32 @@ export const usePagination = ( ) { // toggle visibility of the header bar and the footer bar setHoveredBookKey(hoveredBookKey ? null : bookKey); - } else { - if (hoveredBookKey) { - setHoveredBookKey(null); - return; - } - if (!viewSettings.disableClick! && screenX >= viewCenterX) { - if (viewSettings.fullscreenClickArea) { - viewPagination(viewRef.current, viewSettings, 'down'); - } else if (viewSettings.swapClickArea) { - viewPagination(viewRef.current, viewSettings, 'left'); - } else { - viewPagination(viewRef.current, viewSettings, 'right'); - } - } else if (!viewSettings.disableClick! && screenX < viewCenterX) { - if (viewSettings.fullscreenClickArea) { - viewPagination(viewRef.current, viewSettings, 'down'); - } else if (viewSettings.swapClickArea) { - viewPagination(viewRef.current, viewSettings, 'right'); - } else { - viewPagination(viewRef.current, viewSettings, 'left'); - } - } + return; } + + if (hoveredBookKey) { + setHoveredBookKey(null); + return; + } + + const side: PaginationSide = + screenX >= viewCenterX + ? viewSettings.fullscreenClickArea + ? 'down' + : viewSettings.swapClickArea + ? 'left' + : 'right' + : viewSettings.fullscreenClickArea + ? 'down' + : viewSettings.swapClickArea + ? 'right' + : 'left'; + + if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove(side)) { + return; + } + + viewPagination(viewRef.current, viewSettings, side); } } } else if ( @@ -206,8 +217,14 @@ export const usePagination = ( const { keyName } = msg.detail; setHoveredBookKey(''); if (keyName === 'VolumeUp') { + if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('up')) { + return; + } viewPagination(viewRef.current, viewSettings, 'up'); } else if (keyName === 'VolumeDown') { + if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('down')) { + return; + } viewPagination(viewRef.current, viewSettings, 'down'); } } else if ( diff --git a/apps/readest-app/src/app/reader/utils/readingRuler.ts b/apps/readest-app/src/app/reader/utils/readingRuler.ts new file mode 100644 index 00000000..aa82e233 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/readingRuler.ts @@ -0,0 +1,66 @@ +import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book'; + +export const FIXED_LAYOUT_READING_RULER_LINE_HEIGHT = 28; + +type ReadingRulerSettings = Pick; + +export const calculateReadingRulerSize = ( + lines: number, + viewSettings: ReadingRulerSettings, + bookFormat: BookFormat, +): number => { + if (FIXED_LAYOUT_FORMATS.has(bookFormat)) { + return lines * FIXED_LAYOUT_READING_RULER_LINE_HEIGHT; + } + + const fontSize = viewSettings.defaultFontSize || 16; + const lineHeight = viewSettings.lineHeight || 1.5; + return Math.round(lines * fontSize * lineHeight); +}; + +export const clampReadingRulerPosition = ( + position: number, + dimension: number, + rulerSize: number, +): number => { + if (dimension <= 0) return Math.max(0, Math.min(100, position)); + + const halfPct = (rulerSize / 2 / dimension) * 100; + if (halfPct >= 50) return 50; + + return Math.max(halfPct, Math.min(100 - halfPct, position)); +}; + +export const stepReadingRulerPosition = ( + currentPosition: number, + dimension: number, + rulerSize: number, + direction: 'backward' | 'forward', +): number => { + if (dimension <= 0) { + return clampReadingRulerPosition(currentPosition, dimension, rulerSize); + } + + const currentCenter = (currentPosition / 100) * dimension; + const offset = direction === 'forward' ? rulerSize : -rulerSize; + + return clampReadingRulerPosition( + ((currentCenter + offset) / dimension) * 100, + dimension, + rulerSize, + ); +}; + +export const getReadingRulerMoveDirection = ( + side: 'left' | 'right' | 'up' | 'down', + bookDir?: string, +): 'backward' | 'forward' => { + const normalizedSide = + bookDir === 'rtl' && (side === 'left' || side === 'right') + ? side === 'left' + ? 'right' + : 'left' + : side; + + return normalizedSide === 'left' || normalizedSide === 'up' ? 'backward' : 'forward'; +};