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
This commit is contained in:
Mohammed Efaz
2026-04-01 22:59:44 +06:00
committed by GitHub
parent f0ab05bbde
commit 9ecb9b24d2
7 changed files with 678 additions and 90 deletions
@@ -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(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
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(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
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(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
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(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={97.6}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(consumed).toBe(false);
expect(saveViewSettings).not.toHaveBeenCalled();
});
});
@@ -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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
shortcutState.actions?.['onGoNext']?.();
expect(mockView.next).toHaveBeenCalledWith(72);
});
});
@@ -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');
});
});
@@ -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<ReadingRulerProps> = ({
bookKey,
isVertical,
@@ -61,22 +52,16 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
const [shouldAnimate, setShouldAnimate] = useState(false);
const isDragging = useRef(false);
const dragPointerOffsetRef = useRef(0);
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 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<ReadingRulerProps> = ({
[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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
(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<ReadingRulerProps> = ({
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<ReadingRulerProps> = ({
};
// 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<ReadingRulerProps> = ({
style={{
width: `${rulerStartPx}px`,
opacity: fadeOpacity,
transition: getTransitionStyle('width'),
}}
/>
@@ -348,13 +402,14 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
style={{
width: `${containerSize.width - rulerEndPx}px`,
opacity: fadeOpacity,
transition: getTransitionStyle('width'),
}}
/>
{/* Vertical ruler */}
<div
className={clsx(
'ruler pointer-events-auto absolute bottom-0 top-0 my-2 cursor-col-resize touch-none rounded-2xl',
'ruler pointer-events-none absolute bottom-0 top-0 my-2 rounded-2xl',
color === 'transparent' ? 'border-base-content/55 border' : '',
)}
style={{
@@ -368,13 +423,16 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
}
: backdropFilterStyle),
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* extended touch area */}
<div className='absolute inset-y-0 -left-2 -right-2 touch-none' />
{/* Keep the ruler body pass-through so text inside stays selectable. */}
<div
className='pointer-events-auto absolute inset-y-0 -left-2 w-4 cursor-col-resize touch-none'
{...dragHandleProps}
/>
<div
className='pointer-events-auto absolute inset-y-0 -right-2 w-4 cursor-col-resize touch-none'
{...dragHandleProps}
/>
</div>
</div>
);
@@ -395,6 +453,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
style={{
height: `${rulerStartPx}px`,
opacity: fadeOpacity,
transition: getTransitionStyle('height'),
}}
/>
@@ -404,13 +463,14 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
style={{
height: `${containerSize.height - rulerEndPx}px`,
opacity: fadeOpacity,
transition: getTransitionStyle('height'),
}}
/>
{/* Horizontal ruler */}
<div
className={clsx(
'ruler pointer-events-auto absolute left-0 right-0 mx-2 cursor-row-resize touch-none rounded-2xl',
'ruler pointer-events-none absolute left-0 right-0 mx-2 rounded-2xl',
color === 'transparent' ? 'border-base-content/55 border' : '',
)}
style={{
@@ -424,13 +484,15 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
}
: backdropFilterStyle),
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Extended touch area */}
<div className='absolute inset-x-0 -bottom-2 -top-2 touch-none' />
<div
className='pointer-events-auto absolute inset-x-0 -top-2 h-4 cursor-row-resize touch-none'
{...dragHandleProps}
/>
<div
className='pointer-events-auto absolute inset-x-0 -bottom-2 h-4 cursor-row-resize touch-none'
{...dragHandleProps}
/>
</div>
</div>
);
@@ -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);
};
@@ -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 (
@@ -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<ViewSettings, 'defaultFontSize' | 'lineHeight'>;
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';
};