Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,20 @@ <h2 id="settings-title" data-i18n="settings.allTitle">Settings</h2>
</div>
</fieldset>

<fieldset class="settings-group">
<legend data-i18n="settings.wheelNavigation">Turn pages with mouse wheel</legend>
<div class="segmented-control">
<label class="setting-option">
<input type="radio" name="wheel-navigation" value="off" />
<span data-i18n="settings.off">Off</span>
</label>
<label class="setting-option">
<input type="radio" name="wheel-navigation" value="on" />
<span data-i18n="settings.on">On</span>
</label>
</div>
</fieldset>

<fieldset class="settings-group">
<legend data-i18n="settings.fullscreenMode">Full-screen mode</legend>
<div class="segmented-control">
Expand Down
27 changes: 27 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
bindTouchTap,
swipeTurnDirection,
} from './header-visibility';
import { bindWheelNavigation } from './wheel-navigation';
import { VisibilityMotion } from './motion';
import {
ReaderPager,
Expand Down Expand Up @@ -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<HTMLButtonElement>('back-to-text');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -318,6 +321,9 @@ const russian: Record<TranslationKey, string> = {
'settings.fullscreenNone': 'Ничего',
'settings.show': 'Показывать',
'settings.hide': 'Скрывать',
'settings.wheelNavigation': 'Листать страницы колесом мыши',
'settings.off': 'Выкл.',
'settings.on': 'Вкл.',
'settings.allTitle': 'Настройки',
'settings.returnReading': 'Вернуться к чтению',
'settings.sections': 'Разделы настроек',
Expand Down
4 changes: 4 additions & 0 deletions src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
2 changes: 2 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ReaderSettings {
textAlignment: TextAlignment;
pageMode: PageMode;
pageButtons: PageButtonsMode;
wheelNavigation: boolean;
fullscreenStatus: FullscreenStatusMode;
footnoteMode: FootnoteMode;
theme: Theme;
Expand All @@ -30,6 +31,7 @@ export const DEFAULT_SETTINGS: ReaderSettings = {
textAlignment: 'start',
pageMode: 'auto',
pageButtons: 'auto',
wheelNavigation: false,
fullscreenStatus: 'progress',
footnoteMode: 'appendix',
theme: 'auto',
Expand Down
80 changes: 80 additions & 0 deletions src/wheel-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
67 changes: 67 additions & 0 deletions src/wheel-navigation.ts
Original file line number Diff line number Diff line change
@@ -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);
}