Improve reader horizontal mode scroll Y to X re-mapping

When scrolling really fast, the delta value could increase, which leads to incorrect trackpad detection, because the delta value is not consistent over consecutive scroll events
This commit is contained in:
schroda
2025-11-12 03:03:40 +01:00
parent cdab741ce5
commit 88f1ef7384
3 changed files with 180 additions and 34 deletions

View File

@@ -8,6 +8,7 @@
import { MutableRefObject, useEffect } from 'react';
import { ReadingDirection, ReadingMode } from '@/features/reader/Reader.types.ts';
import { PointerDeviceUtil } from '@/lib/PointerDeviceUtil.ts';
export const useReaderHorizontalModeInvertXYScrolling = (
readingMode: ReadingMode,
@@ -16,60 +17,45 @@ export const useReaderHorizontalModeInvertXYScrolling = (
) => {
// invert x and y scrolling for the continuous horizontal reading mode
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (readingMode !== ReadingMode.CONTINUOUS_HORIZONTAL) {
return () => {};
}
if (!scrollElementRef.current) {
if (!scrollElement) {
return () => {};
}
let previousDeltaX: number | undefined;
let previousDeltaY: number | undefined;
// Trackpads can be identified by checking the delta values.
// For a trackpad these values are not consistent since it basically behaves like scrolling via touch.
// While for a mouse the values should always have the same value because each wheel turn scrolls the same exact amount.
let isTrackpad: boolean | undefined;
const handleScroll = (e: WheelEvent) => {
// Trackpads can scroll horizontally without the need of having "shift" pressed.
// This is a problem because the hook will consider this to be a vertical scroll event and will invert it, which
// breaks scrolling horizontally on trackpads.
if (isTrackpad === true) {
const isHorizontalScroll = Math.abs(e.deltaX) > Math.abs(e.deltaY);
if (isHorizontalScroll) {
return;
}
// Trackpads can scroll horizontally without having shift pressed down.
// This makes mapping vertical scrolling to horizontal scrolling unnecessary, and additionally, it feels weird and unexpected.
// Especially since pages can cause vertical overflow which requires scrolling up and down.
const isTrackPadLike = PointerDeviceUtil.isTrackPadLike();
if (isTrackPadLike) {
return;
}
e.preventDefault();
const isConsistentDeltaX = !Math.abs(Math.abs(previousDeltaX ?? e.deltaX) - Math.abs(e.deltaX));
const isConsistentDeltaY = !Math.abs(Math.abs(previousDeltaY ?? e.deltaY) - Math.abs(e.deltaY));
previousDeltaX = e.deltaX;
previousDeltaY = e.deltaY;
if (!isTrackpad) {
isTrackpad = !isConsistentDeltaX || !isConsistentDeltaY;
}
const preventInversion = isTrackpad === undefined;
if (preventInversion) {
return;
}
if (e.shiftKey) {
scrollElementRef.current?.scrollBy({
scrollElement.scrollBy({
top: e.deltaY,
});
return;
}
scrollElementRef.current?.scrollBy({
scrollElement.scrollBy({
left: readingDirection === ReadingDirection.LTR ? e.deltaY : e.deltaY * -1,
});
};
scrollElementRef.current.addEventListener('wheel', handleScroll);
return () => scrollElementRef.current?.removeEventListener('wheel', handleScroll);
scrollElement.addEventListener('wheel', handleScroll);
return () => scrollElement.removeEventListener('wheel', handleScroll);
}, [readingMode, readingDirection]);
};

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { d } from 'koration';
import { coerceIn } from '@/lib/HelperFunctions.ts';
type Direction = 'X' | 'Y';
interface DeltaData {
simultaneousDeltaOccurrences: number[];
deltas: Record<Direction, { timestamp: number; delta: number }[]>;
}
class PointerDeviceUtilClass {
private readonly TRACK_PAD_DELTA_THRESHOLD = 30;
private readonly MAX_RECORDS = 150;
private data: DeltaData = {
simultaneousDeltaOccurrences: [],
deltas: {
X: [],
Y: [],
},
};
private detectedTrackPadLike: boolean | undefined = undefined;
constructor() {
window.addEventListener('wheel', this.record.bind(this));
setInterval(() => {
this.determineIsTrackPadLike();
}, d(5).seconds.inWholeMilliseconds);
}
private record(event: WheelEvent): void {
const now = Date.now();
const isSimultaneous = event.deltaX !== 0 && event.deltaY !== 0;
if (isSimultaneous) {
this.data.simultaneousDeltaOccurrences.push(now);
}
this.data.deltas.X.push({ timestamp: now, delta: Math.abs(event.deltaX) });
this.data.deltas.Y.push({ timestamp: now, delta: Math.abs(event.deltaY) });
this.data.simultaneousDeltaOccurrences = this.cleanupSeries(this.data.simultaneousDeltaOccurrences);
this.data.deltas.X = this.cleanupSeries(this.data.deltas.X);
this.data.deltas.Y = this.cleanupSeries(this.data.deltas.Y);
const triggerInitialDetermination = this.data.deltas.X.length > 5 && this.detectedTrackPadLike === undefined;
if (triggerInitialDetermination) {
this.determineIsTrackPadLike();
}
}
private cleanupSeries<T>(series: T[]): T[] {
if (series.length <= this.MAX_RECORDS * 1.25) {
return series;
}
const recordsToRemove = Math.abs(this.MAX_RECORDS - series.length);
return series.slice(recordsToRemove);
}
private getOldestRecordTimestamp(): number | undefined {
return this.data.deltas.X[0]?.timestamp;
}
private getLatestRecordTimestamp(): number | undefined {
return this.data.deltas.X[this.data.deltas.X.length - 1]?.timestamp;
}
private getIndexOfLastValidRecord<T>(
series: T[],
timespan: number,
now: number,
getTimestamp: (entry: T) => number,
): number {
const firstInvalidIndex = series.findLastIndex((entry) => now - getTimestamp(entry) > timespan);
return coerceIn(firstInvalidIndex + 1, 0, series.length - 1);
}
public isTrackPadLike(): boolean {
return !!this.detectedTrackPadLike;
}
private determineIsTrackPadLike(): void {
const finalTimespan = d(15).seconds.inWholeMilliseconds;
const now = Date.now();
const oldestRecordTimestamp = this.getOldestRecordTimestamp();
const latestRecordTimestamp = this.getLatestRecordTimestamp();
const hasRecordedData = oldestRecordTimestamp !== undefined;
if (!hasRecordedData) {
return;
}
// The timespan needs to be based on the latest recorded data, not the current time.
const finalLatestRecordTimestamp = latestRecordTimestamp ?? now;
const hasSimultaneousDeltaOccurrences = this.hasSimultaneousDeltaOccurrences(
finalTimespan,
finalLatestRecordTimestamp,
);
const isTrackPadLikeX = this.isTrackPadLikeForDirection('X', finalTimespan, finalLatestRecordTimestamp);
const isTrackPadLikeY = this.isTrackPadLikeForDirection('Y', finalTimespan, finalLatestRecordTimestamp);
this.detectedTrackPadLike = hasSimultaneousDeltaOccurrences || isTrackPadLikeX || isTrackPadLikeY;
}
private hasSimultaneousDeltaOccurrences(timespan: number, now: number): boolean {
const threshold = d(timespan).milliseconds.asWholeSeconds.div(2).inWholeSeconds;
const indexOfLastValidOccurrence = this.getIndexOfLastValidRecord(
this.data.simultaneousDeltaOccurrences,
timespan,
now,
(timestamp) => timestamp,
);
return this.data.simultaneousDeltaOccurrences.slice(indexOfLastValidOccurrence).length >= threshold;
}
private isTrackPadLikeForDirection(direction: 'X' | 'Y', timespan: number, now: number): boolean {
const indexOfLastValidRecord = this.getIndexOfLastValidRecord(
this.data.deltas[direction],
timespan,
now,
(entry) => entry.timestamp,
);
const filteredDeltaRecords = this.data.deltas[direction]
.slice(indexOfLastValidRecord)
.filter((entry) => !!entry.delta);
if (!filteredDeltaRecords.length) {
return false;
}
const deltas = filteredDeltaRecords.sort((a, b) => a.delta - b.delta);
const isEven = deltas.length % 2 === 0;
const medianIndex = Math.floor(deltas.length / 2);
const oddMedian = deltas[medianIndex].delta;
const evenMedian = (deltas[medianIndex].delta + deltas[medianIndex + 1].delta) / 2;
const median = isEven ? evenMedian : oddMedian;
return median < this.TRACK_PAD_DELTA_THRESHOLD;
}
}
export const PointerDeviceUtil = new PointerDeviceUtilClass();