Skip to content

Commit 0e42f6d

Browse files
ohahclaude
andcommitted
feat: use native browser scroll for wheel with scroll overlay
Replace custom wheel event handling with a transparent scroll overlay div that leverages the browser's native scroll behavior. This provides free momentum scrolling, smooth scrolling, and trackpad gesture support. - Add scroll overlay div (overflow: auto, touch-action: none) on top of canvas - Remove wheel event handler from EventManager (browser handles it natively) - Keep manual touch scroll with custom momentum (for selection drag compat) - Bidirectional sync between overlay scrollTop/Left and scrollTopRef/LeftRef - Safari compat via injected ::-webkit-scrollbar { display: none } style Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7317ba2 commit 0e42f6d

6 files changed

Lines changed: 313 additions & 55 deletions

File tree

packages/react-wasm-table/src/adapter/__tests__/event-manager.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -996,19 +996,14 @@ describe("EventManager", () => {
996996
expect(coords.viewportX).toBe(56);
997997
});
998998

999-
it("passes native WheelEvent to onScroll", () => {
999+
it("wheel scroll is handled natively by overlay (no onScroll from wheel)", () => {
1000+
// Wheel events no longer trigger onScroll — the scroll overlay div
1001+
// handles them natively via overflow: auto.
10001002
const onScroll = mock((_dy: number, _dx: number, _native: WheelEvent | null) => {});
10011003
em.attach(canvas, { onScroll });
10021004

10031005
canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 100, deltaX: 0, bubbles: true }));
1004-
expect(onScroll).toHaveBeenCalledTimes(1);
1005-
const [dy, _dx, native] = onScroll.mock.calls[0] as unknown as [
1006-
number,
1007-
number,
1008-
WheelEvent | null,
1009-
];
1010-
expect(dy).toBe(100);
1011-
expect(native).toBeInstanceOf(WheelEvent);
1006+
expect(onScroll).toHaveBeenCalledTimes(0);
10121007
});
10131008

10141009
it("passes EventCoords with scrollLeft correction", () => {

packages/react-wasm-table/src/adapter/event-manager.ts

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const DOUBLE_TAP_INTERVAL = 300; // ms between taps for double-tap
1010
const DOUBLE_TAP_DISTANCE = 20; // max px between taps for double-tap
1111
const LONG_PRESS_DURATION = 500; // ms hold → selection drag mode
1212
const 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
1315
const RESIZE_HANDLE_ZONE = 5; // px from header right edge for resize handle
1416
const 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. */
8486
export 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
}

packages/react-wasm-table/src/react/Grid.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
import { useRef, useMemo, useEffect, useCallback } from "react";
2+
3+
// Inject global CSS to hide scrollbar in scroll overlay (Safari doesn't support scrollbar-width: none)
4+
const SCROLL_OVERLAY_STYLE_ID = "__rwt-scroll-overlay-style";
5+
function ensureScrollOverlayStyle() {
6+
if (typeof document === "undefined") return;
7+
if (document.getElementById(SCROLL_OVERLAY_STYLE_ID)) return;
8+
const style = document.createElement("style");
9+
style.id = SCROLL_OVERLAY_STYLE_ID;
10+
style.textContent = `[data-scroll-overlay]::-webkit-scrollbar { display: none; }`;
11+
document.head.appendChild(style);
12+
}
213
import type { GridProps, Theme } from "../types";
314
import { DEFAULT_THEME } from "../types";
415
import { resolveColumns } from "../resolve-columns";
@@ -144,7 +155,10 @@ export function Grid({
144155
_parsedBorderStyles,
145156
_onVisibleRangeChange,
146157
}: GridProps) {
158+
ensureScrollOverlayStyle();
159+
147160
const canvasRef = useRef<HTMLCanvasElement>(null);
161+
const scrollOverlayRef = useRef<HTMLDivElement>(null);
148162
const editorRef = useRef<HTMLDivElement>(null);
149163
const vScrollbarRef = useRef<HTMLDivElement>(null);
150164
const hScrollbarRef = useRef<HTMLDivElement>(null);
@@ -269,6 +283,7 @@ export function Grid({
269283
width,
270284
columnRegistry,
271285
invalidate,
286+
scrollOverlayRef,
272287
});
273288

274289
// Hook composition
@@ -485,6 +500,7 @@ export function Grid({
485500
engine,
486501
memoryBridgeRef,
487502
canvasRef,
503+
scrollOverlayRef,
488504
columnRegistry,
489505
data,
490506
stringTableRef,
@@ -527,6 +543,7 @@ export function Grid({
527543

528544
useEventAttachment({
529545
canvasRef,
546+
scrollOverlayRef,
530547
eventManagerRef,
531548
editorManagerRef,
532549
table: tableProp,
@@ -715,8 +732,27 @@ export function Grid({
715732
<canvas
716733
ref={canvasRef}
717734
data-grid-canvas
718-
style={{ display: "block", touchAction: "none", width, height }}
735+
style={{ display: "block", width, height, pointerEvents: "none" }}
719736
/>
737+
{/* Scroll overlay: native browser scroll for wheel (momentum/smooth),
738+
touch-action: none so touch is handled manually by EventManager */}
739+
<div
740+
ref={scrollOverlayRef}
741+
data-scroll-overlay
742+
style={{
743+
position: "absolute",
744+
top: 0,
745+
left: 0,
746+
width,
747+
height,
748+
overflow: "auto",
749+
touchAction: "none",
750+
scrollbarWidth: "none",
751+
zIndex: 1,
752+
}}
753+
>
754+
<div data-scroll-spacer style={{ pointerEvents: "none" }} />
755+
</div>
720756
<div
721757
ref={editorRef}
722758
style={{
@@ -817,7 +853,10 @@ export function Grid({
817853
si._domHandlers?.onKeyDown?.(e);
818854
}}
819855
onWheel={(e) => {
820-
canvasRef.current?.dispatchEvent(new WheelEvent("wheel", e.nativeEvent));
856+
scrollOverlayRef.current?.scrollBy({
857+
top: e.nativeEvent.deltaY,
858+
left: e.nativeEvent.deltaX,
859+
});
821860
}}
822861
style={selectStyle}
823862
/>

0 commit comments

Comments
 (0)