@@ -10,6 +10,8 @@ const DOUBLE_TAP_INTERVAL = 300; // ms between taps for double-tap
1010const DOUBLE_TAP_DISTANCE = 20 ; // max px between taps for double-tap
1111const LONG_PRESS_DURATION = 500 ; // ms hold → selection drag mode
1212const MOUSE_DRAG_THRESHOLD = 5 ; // px movement before mousemove triggers drag-extend
13+ const MOMENTUM_FRICTION = 0.95 ; // velocity multiplier per frame (lower = more friction)
14+ const MOMENTUM_MIN_VELOCITY = 0.5 ; // px/frame — stop animation below this
1315const RESIZE_HANDLE_ZONE = 5 ; // px from header right edge for resize handle
1416const DRAG_HANDLE_ZONE = 20 ; // px zone for column DnD grip handle (left of resize zone)
1517
@@ -80,11 +82,9 @@ export interface GridEventHandlers {
8082 ) => boolean | void ;
8183}
8284
83- /** Options for deltaMode normalization . */
85+ /** @deprecated Wheel scroll is now handled natively by the scroll overlay . */
8486export interface ScrollNormalization {
85- /** Pixels per line for deltaMode=1 (DOM_DELTA_LINE). Typically rowHeight. */
8687 lineHeight : number ;
87- /** Pixels per page for deltaMode=2 (DOM_DELTA_PAGE). Typically viewport height. */
8888 pageHeight : number ;
8989}
9090
@@ -154,6 +154,12 @@ export class EventManager {
154154 private lastTapX = 0 ;
155155 private lastTapY = 0 ;
156156
157+ // Momentum / inertia state (touch only — wheel uses native scroll)
158+ private momentumRafId : number | null = null ;
159+ private velocityX = 0 ;
160+ private velocityY = 0 ;
161+ private lastMoveTime = 0 ;
162+
157163 // Mouse drag state
158164 private mouseDownPos : { x : number ; y : number } | null = null ;
159165 private mouseDragActive = false ;
@@ -177,6 +183,35 @@ export class EventManager {
177183 coords : EventCoords ;
178184 } | null = null ;
179185
186+ /** Cancel any running touch momentum animation. */
187+ private stopMomentum ( ) : void {
188+ if ( this . momentumRafId !== null ) {
189+ cancelAnimationFrame ( this . momentumRafId ) ;
190+ this . momentumRafId = null ;
191+ }
192+ this . velocityX = 0 ;
193+ this . velocityY = 0 ;
194+ }
195+
196+ /** Start momentum deceleration animation (shared by touch and wheel). */
197+ private startMomentum ( onScroll : GridEventHandlers [ "onScroll" ] ) : void {
198+ const speed = Math . sqrt ( this . velocityX ** 2 + this . velocityY ** 2 ) ;
199+ if ( speed < MOMENTUM_MIN_VELOCITY ) return ;
200+
201+ const tick = ( ) => {
202+ this . velocityX *= MOMENTUM_FRICTION ;
203+ this . velocityY *= MOMENTUM_FRICTION ;
204+ const curSpeed = Math . sqrt ( this . velocityX ** 2 + this . velocityY ** 2 ) ;
205+ if ( curSpeed < MOMENTUM_MIN_VELOCITY ) {
206+ this . stopMomentum ( ) ;
207+ return ;
208+ }
209+ onScroll ?.( this . velocityY , this . velocityX , null ) ;
210+ this . momentumRafId = requestAnimationFrame ( tick ) ;
211+ } ;
212+ this . momentumRafId = requestAnimationFrame ( tick ) ;
213+ }
214+
180215 /** Update the layouts used for hit-testing. */
181216 setLayouts ( headerLayouts : CellLayout [ ] , rowLayouts : CellLayout [ ] ) : void {
182217 this . headerLayouts = headerLayouts ;
@@ -265,11 +300,14 @@ export class EventManager {
265300 return findCell ( x , y , this . rowLayouts ) ?? findNearestCell ( x , y , this . rowLayouts ) ;
266301 }
267302
268- /** Attach event listeners to a canvas element. */
303+ /**
304+ * Attach event listeners to an element (typically the scroll overlay div).
305+ * Wheel scroll is handled natively by the overlay's `overflow: auto`.
306+ */
269307 attach (
270- canvas : HTMLCanvasElement ,
308+ canvas : HTMLElement ,
271309 handlers : GridEventHandlers ,
272- scrollNorm ?: ScrollNormalization ,
310+ _scrollNorm ?: ScrollNormalization ,
273311 ) : void {
274312 this . detach ( ) ;
275313 this . controller = new AbortController ( ) ;
@@ -629,29 +667,9 @@ export class EventManager {
629667 { signal } ,
630668 ) ;
631669
632- const lineH = scrollNorm ?. lineHeight ?? 36 ;
633- const pageH = scrollNorm ?. pageHeight ?? 400 ;
634-
635- canvas . addEventListener (
636- "wheel" ,
637- ( e : WheelEvent ) => {
638- e . preventDefault ( ) ;
639- let dy = e . deltaY ;
640- let dx = e . deltaX ;
641- // Normalize deltaMode to pixels
642- if ( e . deltaMode === 1 ) {
643- // DOM_DELTA_LINE (Firefox mouse wheel)
644- dy *= lineH ;
645- dx *= lineH ;
646- } else if ( e . deltaMode === 2 ) {
647- // DOM_DELTA_PAGE
648- dy *= pageH ;
649- dx *= pageH ;
650- }
651- handlers . onScroll ?.( dy , dx , e ) ;
652- } ,
653- { signal, passive : false } ,
654- ) ;
670+ // Wheel scroll is handled natively by the overlay's overflow: auto.
671+ // No wheel event listener needed — the browser provides momentum,
672+ // smooth scrolling, and trackpad gesture support automatically.
655673
656674 // ── Touch event listeners ──────────────────────────────────────────
657675
@@ -681,6 +699,9 @@ export class EventManager {
681699 // Fire user touch callback — preventDefault cancels all internal handling
682700 if ( handlers . onTouchStart ?.( e , coords , hitTest ) === false ) return ;
683701
702+ // Stop any running momentum animation when a new touch begins
703+ this . stopMomentum ( ) ;
704+
684705 this . touchState = {
685706 startX : touch . clientX ,
686707 startY : touch . clientY ,
@@ -778,6 +799,17 @@ export class EventManager {
778799 if ( ts . isScrolling ) {
779800 // Invert: finger moves down → content scrolls up (negative deltaY)
780801 handlers . onScroll ?.( - dy , - dx , null ) ;
802+
803+ // Track velocity for momentum
804+ const now = performance . now ( ) ;
805+ const dt = now - this . lastMoveTime ;
806+ if ( dt > 0 && dt < 100 ) {
807+ // Use exponential smoothing to avoid jitter
808+ const alpha = 0.8 ;
809+ this . velocityX = alpha * ( - dx / dt ) * 16 + ( 1 - alpha ) * this . velocityX ;
810+ this . velocityY = alpha * ( - dy / dt ) * 16 + ( 1 - alpha ) * this . velocityY ;
811+ }
812+ this . lastMoveTime = now ;
781813 }
782814 }
783815
@@ -814,6 +846,11 @@ export class EventManager {
814846 return ;
815847 }
816848
849+ // Start momentum animation if scrolling with enough velocity
850+ if ( ts . isScrolling && ! ts . isSelectionDrag ) {
851+ this . startMomentum ( handlers . onScroll ) ;
852+ }
853+
817854 // Tap detection
818855 const now = performance . now ( ) ;
819856 const elapsed = now - ts . startTime ;
@@ -878,6 +915,8 @@ export class EventManager {
878915 clearTimeout ( this . touchState . longPressTimer ) ;
879916 this . touchState . longPressTimer = null ;
880917 }
918+ // Note: do NOT stop touch momentum on detach — allow inertia to continue
919+ // across React effect re-runs (same reason as touchState preservation).
881920 this . controller ?. abort ( ) ;
882921 this . controller = null ;
883922 }
0 commit comments