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

@@ -31,13 +31,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Library**) Fix showing incorrect info text for an empty category - (**Library**) Fix showing incorrect info text for an empty category
- (**Browse**) Prevent infinite loading in case the whole source catalogue has been added to the library and the "hide entries in library" setting is enabled - (**Browse**) Prevent infinite loading in case the whole source catalogue has been added to the library and the "hide entries in library" setting is enabled
- (**Browse**) Fix using incorrect native language name for some sources (e.g., different chinese languages (zh-hans, zh-hant, ...) were all showing the same native name) - (**Browse**) Fix using incorrect native language name for some sources (e.g., different chinese languages (zh-hans, zh-hant, ...) were all showing the same native name)
- (**Reader**) Fix mouse drag scrolling intertia effect being locked at 60hz - (**Reader**) Fix mouse drag scrolling inertia effect being locked at 60hz
- (**Reader**) Fix mouse cursor drift during drag scroll - (**Reader**) Fix mouse cursor drift during drag scroll
- (**Reader**) Fix deletion of chapters while reading - (**Reader**) Fix deletion of chapters while reading
- (**Reader**) Fix potential page loss during window resize while using continuous horizontal reading mode - (**Reader**) Fix potential page loss during window resize while using continuous horizontal reading mode
- (**Extension**) Fix handling obsolete extensions as updatable in case they are marked as having an available update - (**Extension**) Fix handling obsolete extensions as updatable in case they are marked as having an available update
- (**Theme**) Fix loading of fonts defined in themes - (**Theme**) Fix loading of fonts defined in themes
- (**Backup**) Add option to exclude specific data during backup creation - (**Reader**) Fix broken scrolling in continuous horizontal reading mode
### Translations ### Translations
Feel free to translate the project on [Weblate](https://hosted.weblate.org/projects/suwayomi/suwayomi-webui/) Feel free to translate the project on [Weblate](https://hosted.weblate.org/projects/suwayomi/suwayomi-webui/)

View File

@@ -8,6 +8,7 @@
import { MutableRefObject, useEffect } from 'react'; import { MutableRefObject, useEffect } from 'react';
import { ReadingDirection, ReadingMode } from '@/features/reader/Reader.types.ts'; import { ReadingDirection, ReadingMode } from '@/features/reader/Reader.types.ts';
import { PointerDeviceUtil } from '@/lib/PointerDeviceUtil.ts';
export const useReaderHorizontalModeInvertXYScrolling = ( export const useReaderHorizontalModeInvertXYScrolling = (
readingMode: ReadingMode, readingMode: ReadingMode,
@@ -16,60 +17,45 @@ export const useReaderHorizontalModeInvertXYScrolling = (
) => { ) => {
// invert x and y scrolling for the continuous horizontal reading mode // invert x and y scrolling for the continuous horizontal reading mode
useEffect(() => { useEffect(() => {
const scrollElement = scrollElementRef.current;
if (readingMode !== ReadingMode.CONTINUOUS_HORIZONTAL) { if (readingMode !== ReadingMode.CONTINUOUS_HORIZONTAL) {
return () => {}; return () => {};
} }
if (!scrollElementRef.current) { if (!scrollElement) {
return () => {}; 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) => { const handleScroll = (e: WheelEvent) => {
// Trackpads can scroll horizontally without the need of having "shift" pressed. const isHorizontalScroll = Math.abs(e.deltaX) > Math.abs(e.deltaY);
// This is a problem because the hook will consider this to be a vertical scroll event and will invert it, which if (isHorizontalScroll) {
// breaks scrolling horizontally on trackpads. return;
if (isTrackpad === true) { }
// 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; return;
} }
e.preventDefault(); 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) { if (e.shiftKey) {
scrollElementRef.current?.scrollBy({ scrollElement.scrollBy({
top: e.deltaY, top: e.deltaY,
}); });
return; return;
} }
scrollElementRef.current?.scrollBy({ scrollElement.scrollBy({
left: readingDirection === ReadingDirection.LTR ? e.deltaY : e.deltaY * -1, left: readingDirection === ReadingDirection.LTR ? e.deltaY : e.deltaY * -1,
}); });
}; };
scrollElementRef.current.addEventListener('wheel', handleScroll); scrollElement.addEventListener('wheel', handleScroll);
return () => scrollElementRef.current?.removeEventListener('wheel', handleScroll); return () => scrollElement.removeEventListener('wheel', handleScroll);
}, [readingMode, readingDirection]); }, [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();