From dfccd209a72f023ba7e1dc0ef0236de387018438 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 18:37:02 +0400 Subject: [PATCH] =?UTF-8?q?fix(realtime-table):=20=D0=BF=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BB=D0=B5=D1=81=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=81=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=86=D0=B5=D0=BB=D0=B8=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D0=BC=D0=B3=D0=BD=D0=BE=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D1=8B=D0=B6=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/hooks/useWheelScrollStep.js | 17 +++++++++++-- .../RealtimeTable/utils/wheelScroll.js | 8 +++++++ .../RealtimeTable/utils/wheelScroll.test.js | 24 ++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js b/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js index 39e411c..b8944e8 100644 --- a/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js +++ b/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { canScrollBy, getWheelScrollDelta } from '../utils/wheelScroll'; +import { canScrollBy, getWheelScrollDelta, getWheelScrollTarget } from '../utils/wheelScroll'; // Внутри полей ввода и выпадающих списков колесо работает как обычно. const NATIVE_WHEEL_SELECTOR = 'textarea, select, input, [role="listbox"]'; @@ -8,12 +8,25 @@ export const useWheelScrollStep = (containerRef) => { useEffect(() => { const el = containerRef.current; if (!el) return undefined; + let pendingTarget = null; + let lastWheelAt = 0; const onWheel = (event) => { if (event.target instanceof Element && event.target.closest(NATIVE_WHEEL_SELECTOR)) return; const delta = getWheelScrollDelta(event); if (delta === null || !canScrollBy(el, delta)) return; event.preventDefault(); - el.scrollTop += delta; + const now = performance.now(); + const target = getWheelScrollTarget({ + scrollTop: el.scrollTop, + maxScrollTop: el.scrollHeight - el.clientHeight, + delta, + pendingTarget, + msSinceLastWheel: now - lastWheelAt, + }); + pendingTarget = target; + lastWheelAt = now; + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + el.scrollTo({ top: target, behavior: reduceMotion ? 'auto' : 'smooth' }); }; // passive: false — иначе preventDefault не работает. el.addEventListener('wheel', onWheel, { passive: false }); diff --git a/web/src/components/RealtimeTable/utils/wheelScroll.js b/web/src/components/RealtimeTable/utils/wheelScroll.js index e59e37f..e47755e 100644 --- a/web/src/components/RealtimeTable/utils/wheelScroll.js +++ b/web/src/components/RealtimeTable/utils/wheelScroll.js @@ -20,3 +20,11 @@ export const getWheelScrollDelta = ({ deltaX, deltaY, deltaMode, ctrlKey, shiftK // Можно ли прокрутить элемент на delta по вертикали (иначе отдаём событие браузеру — прокрутка страницы). export const canScrollBy = ({ scrollTop, scrollHeight, clientHeight }, delta) => delta < 0 ? scrollTop > 0 : scrollTop + clientHeight < scrollHeight; + +// Пока идёт плавная прокрутка предыдущего щелчка, следующий щелчок добавляется к его цели. +export const WHEEL_TARGET_RESET_MS = 300; + +export const getWheelScrollTarget = ({ scrollTop, maxScrollTop, delta, pendingTarget, msSinceLastWheel }) => { + const base = pendingTarget !== null && msSinceLastWheel < WHEEL_TARGET_RESET_MS ? pendingTarget : scrollTop; + return Math.min(maxScrollTop, Math.max(0, base + delta)); +}; diff --git a/web/src/components/RealtimeTable/utils/wheelScroll.test.js b/web/src/components/RealtimeTable/utils/wheelScroll.test.js index e4bf0ab..351271a 100644 --- a/web/src/components/RealtimeTable/utils/wheelScroll.test.js +++ b/web/src/components/RealtimeTable/utils/wheelScroll.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { canScrollBy, getWheelScrollDelta } from './wheelScroll.js'; +import { canScrollBy, getWheelScrollDelta, getWheelScrollTarget } from './wheelScroll.js'; const wheel = (overrides) => ({ deltaX: 0, deltaY: 0, deltaMode: 0, ctrlKey: false, shiftKey: false, ...overrides }); @@ -42,3 +42,25 @@ test('canScrollBy: у верха вверх и у низа вниз — нель assert.equal(canScrollBy(middle, 200), true); assert.equal(canScrollBy(middle, -200), true); }); + +const target = (overrides) => ({ scrollTop: 0, maxScrollTop: 1000, delta: 200, pendingTarget: null, msSinceLastWheel: 0, ...overrides }); + +test('getWheelScrollTarget: нет прошлой цели — от scrollTop', () => { + assert.equal(getWheelScrollTarget(target({ scrollTop: 250 })), 450); +}); + +test('getWheelScrollTarget: прошлая цель не дошла, щелчок быстрый — копим от цели', () => { + assert.equal(getWheelScrollTarget(target({ scrollTop: 250, pendingTarget: 400, msSinceLastWheel: 100 })), 600); +}); + +test('getWheelScrollTarget: прошло больше WHEEL_TARGET_RESET_MS — от scrollTop', () => { + assert.equal(getWheelScrollTarget(target({ scrollTop: 250, pendingTarget: 400, msSinceLastWheel: 500 })), 450); +}); + +test('getWheelScrollTarget: ограничение сверху', () => { + assert.equal(getWheelScrollTarget(target({ scrollTop: 900 })), 1000); +}); + +test('getWheelScrollTarget: ограничение снизу', () => { + assert.equal(getWheelScrollTarget(target({ scrollTop: 50, delta: -200 })), 0); +});