From 88f1ef7384ba30f9b3493a2a7e1b0dc7429f3347 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:03:40 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 4 +- ...seReaderHorizontalModeInvertXYScrolling.ts | 50 ++---- src/lib/PointerDeviceUtil.ts | 160 ++++++++++++++++++ 3 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 src/lib/PointerDeviceUtil.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fff920ce..43d0095d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - (**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) -- (**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 deletion of chapters while reading - (**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 - (**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 Feel free to translate the project on [Weblate](https://hosted.weblate.org/projects/suwayomi/suwayomi-webui/) diff --git a/src/features/reader/viewer/hooks/useReaderHorizontalModeInvertXYScrolling.ts b/src/features/reader/viewer/hooks/useReaderHorizontalModeInvertXYScrolling.ts index 1bbb8120..47dc16fd 100644 --- a/src/features/reader/viewer/hooks/useReaderHorizontalModeInvertXYScrolling.ts +++ b/src/features/reader/viewer/hooks/useReaderHorizontalModeInvertXYScrolling.ts @@ -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]); }; diff --git a/src/lib/PointerDeviceUtil.ts b/src/lib/PointerDeviceUtil.ts new file mode 100644 index 00000000..3cfa2f56 --- /dev/null +++ b/src/lib/PointerDeviceUtil.ts @@ -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; +} + +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(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( + 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();