fix(realtime-table): плавная прокрутка колесом с накоплением цели вместо мгновенного прыжка
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
36d8192221
commit
dfccd209a7
@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
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"]';
|
const NATIVE_WHEEL_SELECTOR = 'textarea, select, input, [role="listbox"]';
|
||||||
@ -8,12 +8,25 @@ export const useWheelScrollStep = (containerRef) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return undefined;
|
if (!el) return undefined;
|
||||||
|
let pendingTarget = null;
|
||||||
|
let lastWheelAt = 0;
|
||||||
const onWheel = (event) => {
|
const onWheel = (event) => {
|
||||||
if (event.target instanceof Element && event.target.closest(NATIVE_WHEEL_SELECTOR)) return;
|
if (event.target instanceof Element && event.target.closest(NATIVE_WHEEL_SELECTOR)) return;
|
||||||
const delta = getWheelScrollDelta(event);
|
const delta = getWheelScrollDelta(event);
|
||||||
if (delta === null || !canScrollBy(el, delta)) return;
|
if (delta === null || !canScrollBy(el, delta)) return;
|
||||||
event.preventDefault();
|
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 не работает.
|
// passive: false — иначе preventDefault не работает.
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|||||||
@ -20,3 +20,11 @@ export const getWheelScrollDelta = ({ deltaX, deltaY, deltaMode, ctrlKey, shiftK
|
|||||||
// Можно ли прокрутить элемент на delta по вертикали (иначе отдаём событие браузеру — прокрутка страницы).
|
// Можно ли прокрутить элемент на delta по вертикали (иначе отдаём событие браузеру — прокрутка страницы).
|
||||||
export const canScrollBy = ({ scrollTop, scrollHeight, clientHeight }, delta) =>
|
export const canScrollBy = ({ scrollTop, scrollHeight, clientHeight }, delta) =>
|
||||||
delta < 0 ? scrollTop > 0 : scrollTop + clientHeight < scrollHeight;
|
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));
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 });
|
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);
|
||||||
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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user