diff --git a/README.md b/README.md
index 49d063e..525b793 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ The bundled edition of *Anna Karenina* opens automatically as the demo book. Use
| `Home` / `End` | First / last page |
| `+` / `-` | Increase / decrease font size |
| Swipe left / right | Next / previous page |
+| Mouse wheel down / up | Next / previous page (off by default; enable **Turn pages with mouse wheel** in settings) |
The floating header remains available after page turns. Page buttons can be shown, hidden, or left in **Auto** mode, which hides them on smaller touch-oriented layouts.
## Format support and safety
diff --git a/index.html b/index.html
index 9ce2f42..bff8627 100644
--- a/index.html
+++ b/index.html
@@ -425,6 +425,20 @@
Full-screen mode
diff --git a/src/app.ts b/src/app.ts
index b8018dc..10f93d4 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -12,6 +12,7 @@ import {
bindTouchTap,
swipeTurnDirection,
} from './header-visibility';
+import { bindWheelNavigation } from './wheel-navigation';
import { VisibilityMotion } from './motion';
import {
ReaderPager,
@@ -261,6 +262,7 @@ export class ChitalkaApp {
private readonly alignmentInputs = requiredInputs('text-alignment');
private readonly pageModeInputs = requiredInputs('page-mode');
private readonly pageButtonInputs = requiredInputs('page-buttons');
+ private readonly wheelNavigationInputs = requiredInputs('wheel-navigation');
private readonly fullscreenStatusInputs = requiredInputs('fullscreen-status');
private readonly footnoteModeInputs = requiredInputs('footnote-mode');
private readonly backButton = requiredElement('back-to-text');
@@ -403,6 +405,7 @@ export class ChitalkaApp {
textAlignment: normalizeTextAlignment(value.textAlignment),
pageMode: pageModes.includes(value.pageMode) ? value.pageMode : DEFAULT_SETTINGS.pageMode,
pageButtons: normalizePageButtonsMode(value.pageButtons),
+ wheelNavigation: value.wheelNavigation === true,
fullscreenStatus: normalizeFullscreenStatusMode(value.fullscreenStatus),
footnoteMode: footnoteModes.includes(value.footnoteMode)
? value.footnoteMode
@@ -454,6 +457,11 @@ export class ChitalkaApp {
if (input.checked) this.setPageButtons(input.value as PageButtonsMode);
});
}
+ for (const input of this.wheelNavigationInputs) {
+ input.addEventListener('change', () => {
+ if (input.checked) this.setWheelNavigation(input.value === 'on');
+ });
+ }
for (const input of this.fullscreenStatusInputs) {
input.addEventListener('change', () => {
if (input.checked) this.setFullscreenStatus(input.value as FullscreenStatusMode);
@@ -510,6 +518,15 @@ export class ChitalkaApp {
});
document.addEventListener('selectionchange', () => this.scheduleSelectionMenu());
document.addEventListener('keydown', (event) => this.handleKeydown(event));
+ bindWheelNavigation(this.reader, {
+ enabled: () => this.settings.wheelNavigation,
+ ignore: (target) => target instanceof Element
+ && (target.closest('input, textarea, select') !== null || this.quoteMenuElement.contains(target)),
+ turn: (direction) => {
+ if (direction > 0) this.navigate(() => this.pager.next());
+ else this.navigateBackward();
+ },
+ });
bindMouseReadingClick(this.viewport, () => this.headerVisibility.toggle());
bindTouchTap(this.viewport, () => this.headerVisibility.toggle());
bindTouchSwipe(this.viewport, {
@@ -1082,6 +1099,13 @@ export class ChitalkaApp {
this.updateSettingsControls();
}
+ private setWheelNavigation(enabled: boolean): void {
+ if (enabled === this.settings.wheelNavigation) return;
+ this.settings.wheelNavigation = enabled;
+ this.saveSettings('wheelNavigation');
+ this.updateSettingsControls();
+ }
+
private setFullscreenStatus(mode: FullscreenStatusMode): void {
if (mode === this.settings.fullscreenStatus) return;
this.settings.fullscreenStatus = mode;
@@ -1126,6 +1150,9 @@ export class ChitalkaApp {
for (const input of this.pageButtonInputs) {
input.checked = input.value === this.settings.pageButtons;
}
+ for (const input of this.wheelNavigationInputs) {
+ input.checked = (input.value === 'on') === this.settings.wheelNavigation;
+ }
for (const input of this.fullscreenStatusInputs) {
input.checked = input.value === this.settings.fullscreenStatus;
}
diff --git a/src/i18n.ts b/src/i18n.ts
index cf63804..7caeef7 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -84,6 +84,9 @@ const english = {
'settings.fullscreenNone': 'Nothing',
'settings.show': 'Show',
'settings.hide': 'Hide',
+ 'settings.wheelNavigation': 'Turn pages with mouse wheel',
+ 'settings.off': 'Off',
+ 'settings.on': 'On',
'settings.allTitle': 'Settings',
'settings.returnReading': 'Return to reading',
'settings.sections': 'Settings sections',
@@ -318,6 +321,9 @@ const russian: Record = {
'settings.fullscreenNone': 'Ничего',
'settings.show': 'Показывать',
'settings.hide': 'Скрывать',
+ 'settings.wheelNavigation': 'Листать страницы колесом мыши',
+ 'settings.off': 'Выкл.',
+ 'settings.on': 'Вкл.',
'settings.allTitle': 'Настройки',
'settings.returnReading': 'Вернуться к чтению',
'settings.sections': 'Разделы настроек',
diff --git a/src/settings.test.ts b/src/settings.test.ts
index 4458d51..9bb3e71 100644
--- a/src/settings.test.ts
+++ b/src/settings.test.ts
@@ -17,6 +17,10 @@ describe('reader settings', () => {
expect(DEFAULT_SETTINGS.pageButtons).toBe('auto');
});
+ it('keeps mouse-wheel page turning off by default', () => {
+ expect(DEFAULT_SETTINGS.wheelNavigation).toBe(false);
+ });
+
it('shows reading percentage in full-screen mode by default', () => {
expect(DEFAULT_SETTINGS.fullscreenStatus).toBe('progress');
});
diff --git a/src/settings.ts b/src/settings.ts
index c5304de..f717a2f 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -17,6 +17,7 @@ export interface ReaderSettings {
textAlignment: TextAlignment;
pageMode: PageMode;
pageButtons: PageButtonsMode;
+ wheelNavigation: boolean;
fullscreenStatus: FullscreenStatusMode;
footnoteMode: FootnoteMode;
theme: Theme;
@@ -30,6 +31,7 @@ export const DEFAULT_SETTINGS: ReaderSettings = {
textAlignment: 'start',
pageMode: 'auto',
pageButtons: 'auto',
+ wheelNavigation: false,
fullscreenStatus: 'progress',
footnoteMode: 'appendix',
theme: 'auto',
diff --git a/src/wheel-navigation.test.ts b/src/wheel-navigation.test.ts
new file mode 100644
index 0000000..4640837
--- /dev/null
+++ b/src/wheel-navigation.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest';
+import { bindWheelNavigation, WHEEL_GESTURE_IDLE_MS, WheelGesture } from './wheel-navigation';
+
+const sample = (deltaY: number, timeStamp: number, deltaX = 0, deltaMode = 0) => ({
+ deltaX,
+ deltaY,
+ deltaMode,
+ timeStamp,
+});
+
+describe('wheel gesture', () => {
+ it('turns forward on wheel down and backward on wheel up', () => {
+ expect(new WheelGesture().push(sample(100, 0))).toBe(1);
+ expect(new WheelGesture().push(sample(-100, 0))).toBe(-1);
+ });
+
+ it('uses horizontal scroll when it dominates', () => {
+ expect(new WheelGesture().push(sample(5, 0, 60))).toBe(1);
+ expect(new WheelGesture().push(sample(5, 0, -60))).toBe(-1);
+ });
+
+ it('turns only one page per continuous gesture', () => {
+ const gesture = new WheelGesture();
+ const turns = Array.from({ length: 40 }, (_, index) => gesture.push(sample(30, index * 16)));
+ expect(turns.filter((turn) => turn !== 0)).toEqual([1]);
+ });
+
+ it('starts a new gesture after the wheel goes idle', () => {
+ const gesture = new WheelGesture();
+ expect(gesture.push(sample(100, 0))).toBe(1);
+ expect(gesture.push(sample(100, WHEEL_GESTURE_IDLE_MS - 1))).toBe(0);
+ expect(gesture.push(sample(-100, WHEEL_GESTURE_IDLE_MS * 3))).toBe(-1);
+ });
+
+ it('accumulates small trackpad deltas before turning', () => {
+ const gesture = new WheelGesture();
+ expect(gesture.push(sample(4, 0))).toBe(0);
+ expect(gesture.push(sample(4, 16))).toBe(0);
+ expect(gesture.push(sample(4, 32))).toBe(1);
+ });
+
+ it('treats line-mode deltas as full wheel notches', () => {
+ expect(new WheelGesture().push(sample(1, 0, 0, 1))).toBe(1);
+ });
+});
+
+describe('wheel navigation binding', () => {
+ const wheel = (init: WheelEventInit) => new WheelEvent('wheel', { cancelable: true, ...init });
+
+ it('does nothing while disabled', () => {
+ const target = document.createElement('div');
+ const turn = vi.fn();
+ bindWheelNavigation(target, { enabled: () => false, ignore: () => false, turn });
+ const event = wheel({ deltaY: 100 });
+ target.dispatchEvent(event);
+ expect(turn).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('turns pages and prevents scrolling while enabled', () => {
+ const target = document.createElement('div');
+ const turn = vi.fn();
+ bindWheelNavigation(target, { enabled: () => true, ignore: () => false, turn });
+ const event = wheel({ deltaY: 100 });
+ target.dispatchEvent(event);
+ expect(turn).toHaveBeenCalledWith(1);
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('leaves zoom gestures and ignored targets alone', () => {
+ const target = document.createElement('div');
+ const turn = vi.fn();
+ bindWheelNavigation(target, { enabled: () => true, ignore: (node) => node === target, turn });
+ target.dispatchEvent(wheel({ deltaY: 100 }));
+ const other = document.createElement('div');
+ bindWheelNavigation(other, { enabled: () => true, ignore: () => false, turn });
+ other.dispatchEvent(wheel({ deltaY: 100, ctrlKey: true }));
+ expect(turn).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/wheel-navigation.ts b/src/wheel-navigation.ts
new file mode 100644
index 0000000..c20b761
--- /dev/null
+++ b/src/wheel-navigation.ts
@@ -0,0 +1,67 @@
+const WHEEL_LINE_HEIGHT = 16;
+const WHEEL_TURN_THRESHOLD = 12;
+export const WHEEL_GESTURE_IDLE_MS = 220;
+
+export interface WheelSample {
+ deltaX: number;
+ deltaY: number;
+ deltaMode: number;
+ timeStamp: number;
+}
+
+function pixelDelta(delta: number, deltaMode: number, pageSize: number): number {
+ if (deltaMode === 1) return delta * WHEEL_LINE_HEIGHT;
+ if (deltaMode === 2) return delta * pageSize;
+ return delta;
+}
+
+/**
+ * Turns a stream of wheel events into at most one page turn per gesture.
+ * A gesture lasts until no wheel event arrives for WHEEL_GESTURE_IDLE_MS,
+ * so trackpad inertia and free-spinning wheels flip a single page.
+ */
+export class WheelGesture {
+ private accumulated = 0;
+ private lastTime = Number.NEGATIVE_INFINITY;
+ private turned = false;
+
+ constructor(private readonly pageSize = 800) {}
+
+ /** Returns 1 for the next page, -1 for the previous page, 0 for no turn. */
+ push(sample: WheelSample): -1 | 0 | 1 {
+ if (sample.timeStamp - this.lastTime > WHEEL_GESTURE_IDLE_MS) {
+ this.accumulated = 0;
+ this.turned = false;
+ }
+ this.lastTime = sample.timeStamp;
+ if (this.turned) return 0;
+
+ const dominant = Math.abs(sample.deltaX) > Math.abs(sample.deltaY) ? sample.deltaX : sample.deltaY;
+ const delta = pixelDelta(dominant, sample.deltaMode, this.pageSize);
+ if (delta === 0) return 0;
+ if (Math.sign(delta) !== Math.sign(this.accumulated)) this.accumulated = 0;
+ this.accumulated += delta;
+ if (Math.abs(this.accumulated) < WHEEL_TURN_THRESHOLD) return 0;
+
+ this.turned = true;
+ return this.accumulated > 0 ? 1 : -1;
+ }
+}
+
+export interface WheelNavigationOptions {
+ enabled(): boolean;
+ ignore(target: EventTarget | null): boolean;
+ turn(direction: -1 | 1): void;
+}
+
+export function bindWheelNavigation(target: HTMLElement, options: WheelNavigationOptions): () => void {
+ const gesture = new WheelGesture();
+ const handleWheel = (event: WheelEvent): void => {
+ if (!options.enabled() || event.ctrlKey || event.metaKey || options.ignore(event.target)) return;
+ event.preventDefault();
+ const direction = gesture.push(event);
+ if (direction !== 0) options.turn(direction);
+ };
+ target.addEventListener('wheel', handleWheel, { passive: false });
+ return () => target.removeEventListener('wheel', handleWheel);
+}