From 347b4bea22a7c4523b6de2a72310086a0924ab63 Mon Sep 17 00:00:00 2001 From: Dmitriy Purgin Date: Sun, 18 Oct 2026 10:48:01 +0200 Subject: [PATCH] feat: add optional mouse-wheel page turning --- README.md | 1 + index.html | 14 +++++++ src/app.ts | 27 ++++++++++++ src/i18n.ts | 6 +++ src/settings.test.ts | 4 ++ src/settings.ts | 2 + src/wheel-navigation.test.ts | 80 ++++++++++++++++++++++++++++++++++++ src/wheel-navigation.ts | 67 ++++++++++++++++++++++++++++++ 8 files changed, 201 insertions(+) create mode 100644 src/wheel-navigation.test.ts create mode 100644 src/wheel-navigation.ts 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 @@

Settings

+
+ Turn pages with mouse wheel +
+ + +
+
+
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); +}