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:
@@ -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';
|
||||
};
|
||||
Reference in New Issue
Block a user