From ef83a6c361d7c2bd83ec8faa09d95d0089638497 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 18:26:04 +0400 Subject: [PATCH 1/3] =?UTF-8?q?test(realtime-table):=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B5=D1=81=D0=BE=D0=BC=20=E2=80=94=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=8B=D0=B5=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чистые функции getWheelScrollDelta и canScrollBy для шага прокрутки колесом мыши в таблице форм (AURORA-1944), с тестами node:test. Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/utils/wheelScroll.js | 22 ++++++++++ .../RealtimeTable/utils/wheelScroll.test.js | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 web/src/components/RealtimeTable/utils/wheelScroll.js create mode 100644 web/src/components/RealtimeTable/utils/wheelScroll.test.js diff --git a/web/src/components/RealtimeTable/utils/wheelScroll.js b/web/src/components/RealtimeTable/utils/wheelScroll.js new file mode 100644 index 0000000..e59e37f --- /dev/null +++ b/web/src/components/RealtimeTable/utils/wheelScroll.js @@ -0,0 +1,22 @@ +// Шаг прокрутки колесом мыши в таблице форм (AURORA-1944). +// Браузер прокручивает ~100 px за щелчок, строки 34–48 px: за щелчок уезжает 2–3 строки. +export const WHEEL_SCROLL_MULTIPLIER = 2; + +const DOM_DELTA_PIXEL = 0; +const DOM_DELTA_LINE = 1; +// Щелчок колеса мыши в Chrome/Edge — от ~50 px; тачпад шлёт мелкие шаги, их не трогаем. +const MIN_MOUSE_WHEEL_DELTA_PX = 50; +// Firefox шлёт строки (deltaMode = 1, обычно 3 строки за щелчок). +const LINE_HEIGHT_PX = 33; + +// На сколько px прокрутить по вертикали вместо браузера, или null — отдать событие браузеру. +export const getWheelScrollDelta = ({ deltaX, deltaY, deltaMode, ctrlKey, shiftKey }) => { + if (ctrlKey || shiftKey || deltaY === 0 || Math.abs(deltaX) > Math.abs(deltaY)) return null; + if (deltaMode === DOM_DELTA_LINE) return deltaY * LINE_HEIGHT_PX * WHEEL_SCROLL_MULTIPLIER; + if (deltaMode !== DOM_DELTA_PIXEL || Math.abs(deltaY) < MIN_MOUSE_WHEEL_DELTA_PX) return null; + return deltaY * WHEEL_SCROLL_MULTIPLIER; +}; + +// Можно ли прокрутить элемент на delta по вертикали (иначе отдаём событие браузеру — прокрутка страницы). +export const canScrollBy = ({ scrollTop, scrollHeight, clientHeight }, delta) => + delta < 0 ? scrollTop > 0 : scrollTop + clientHeight < scrollHeight; diff --git a/web/src/components/RealtimeTable/utils/wheelScroll.test.js b/web/src/components/RealtimeTable/utils/wheelScroll.test.js new file mode 100644 index 0000000..e4bf0ab --- /dev/null +++ b/web/src/components/RealtimeTable/utils/wheelScroll.test.js @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canScrollBy, getWheelScrollDelta } from './wheelScroll.js'; + +const wheel = (overrides) => ({ deltaX: 0, deltaY: 0, deltaMode: 0, ctrlKey: false, shiftKey: false, ...overrides }); + +test('щелчок колеса мыши удваивает шаг', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaY: 100 })), 200); + assert.equal(getWheelScrollDelta(wheel({ deltaY: -100 })), -200); + assert.equal(getWheelScrollDelta(wheel({ deltaY: 120 })), 240); +}); + +test('тачпад (мелкие px-шаги) не трогаем', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaY: 10 })), null); + assert.equal(getWheelScrollDelta(wheel({ deltaY: 49 })), null); +}); + +test('Firefox (строки) считает в строках ×2', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaMode: 1, deltaY: 3 })), 198); +}); + +test('прокрутка страницами (deltaMode 2) не трогаем', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaMode: 2, deltaY: 1 })), null); +}); + +test('Ctrl и Shift не трогаем', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaY: 100, ctrlKey: true })), null); + assert.equal(getWheelScrollDelta(wheel({ deltaY: 100, shiftKey: true })), null); +}); + +test('горизонтальная прокрутка и нулевая дельта не трогаем', () => { + assert.equal(getWheelScrollDelta(wheel({ deltaX: 100, deltaY: 20 })), null); + assert.equal(getWheelScrollDelta(wheel({ deltaY: 0 })), null); +}); + +test('canScrollBy: у верха вверх и у низа вниз — нельзя, в середине — можно', () => { + const top = { scrollTop: 0, scrollHeight: 1000, clientHeight: 400 }; + const bottom = { scrollTop: 600, scrollHeight: 1000, clientHeight: 400 }; + const middle = { scrollTop: 300, scrollHeight: 1000, clientHeight: 400 }; + assert.equal(canScrollBy(top, -200), false); + assert.equal(canScrollBy(bottom, 200), false); + assert.equal(canScrollBy(middle, 200), true); + assert.equal(canScrollBy(middle, -200), true); +}); -- 2.47.2 From 36d819222105a579d61437d0ec1db892da9e9b56 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 18:26:04 +0400 Subject: [PATCH 2/3] =?UTF-8?q?perf(realtime-table):=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B5=D1=81=D0=BE=D0=BC=20=D0=BC=D1=8B=D1=88?= =?UTF-8?q?=D0=B8=20=C3=972=20(AURORA-1944)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хук useWheelScrollStep перехватывает щелчок колеса мыши над таблицей и прокручивает вдвое больше (~100 px браузера -> 200 px). Тачпад, Ctrl и Shift + колесо, горизонтальная прокрутка и поля ввода не меняются. Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/RealtimeTable.jsx | 2 ++ .../RealtimeTable/hooks/useWheelScrollStep.js | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 web/src/components/RealtimeTable/hooks/useWheelScrollStep.js diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 539e4b3..dc373f3 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -20,6 +20,7 @@ import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePap import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; +import { useWheelScrollStep } from './hooks/useWheelScrollStep'; import { useTableScale } from './hooks/useTableScale'; import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; @@ -158,6 +159,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { } = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`); const { headerPortalRef, containerRef } = useHeaderPortal(); + useWheelScrollStep(containerRef); const rowVirtualizerRef = useRef(null); const columnVirtualizerRef = useRef(null); diff --git a/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js b/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js new file mode 100644 index 0000000..39e411c --- /dev/null +++ b/web/src/components/RealtimeTable/hooks/useWheelScrollStep.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { canScrollBy, getWheelScrollDelta } from '../utils/wheelScroll'; + +// Внутри полей ввода и выпадающих списков колесо работает как обычно. +const NATIVE_WHEEL_SELECTOR = 'textarea, select, input, [role="listbox"]'; + +export const useWheelScrollStep = (containerRef) => { + useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + 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; + }; + // passive: false — иначе preventDefault не работает. + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, [containerRef]); +}; -- 2.47.2 From dfccd209a72f023ba7e1dc0ef0236de387018438 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 18:37:02 +0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(realtime-table):=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BB=D0=B5=D1=81=D0=BE=D0=BC?= =?UTF-8?q?=20=D1=81=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BF=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D1=86=D0=B5=D0=BB=D0=B8=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=BC=D0=B3=D0=BD=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D1=8B=D0=B6=D0=BA?= =?UTF-8?q?=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); +}); -- 2.47.2