fix(grid): handle horizontal wheel input

Closes #6793
This commit is contained in:
t8y2 committed 2026-08-22 10:17:35 +00:00
1 parent de99931c04
commit 53b7be3c6f
4 files changed
+287 -31

No files matched your search

+30 -31
View File
@@ -195,6 +195,7 @@ import {
type ResultScopedRowCache,
} from "@/lib/dataGrid/dataGridLargeValues";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { resolveDataGridWheelScroll } from "@/lib/dataGrid/dataGridWheel";
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
@@ -6721,14 +6722,9 @@ function onCanvasScroll(event: Event) {
scheduleCanvasDraw();
}
function canvasWheelDeltaToPixels(delta: number, deltaMode: number, pageSize: number): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * CANVAS_DATA_GRID_ROW_HEIGHT;
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * pageSize;
return delta;
}
function shouldAccelerateCanvasWheel(event: WheelEvent): boolean {
if (event.ctrlKey || event.metaKey) return false;
if (event.deltaX !== 0) return true;
if (event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return true;
return event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX) && Math.abs(event.deltaY) >= CANVAS_TRACKPAD_DELTA_THRESHOLD;
}
@@ -6738,42 +6734,45 @@ function onCanvasWheel(event: WheelEvent) {
const scroller = canvasScrollerElement();
if (!scroller) return;
const verticalDelta = canvasWheelDeltaToPixels(event.deltaY, event.deltaMode, scroller.clientHeight);
const horizontalDelta = canvasWheelDeltaToPixels(event.deltaX, event.deltaMode, scroller.clientWidth);
const shiftedHorizontalDelta = event.shiftKey && Math.abs(verticalDelta) > Math.abs(horizontalDelta) ? verticalDelta : 0;
const nextTop = shiftedHorizontalDelta === 0 ? Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, scroller.scrollTop + verticalDelta * CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER)) : scroller.scrollTop;
const nextLeft = Math.max(0, Math.min(scroller.scrollWidth - scroller.clientWidth, scroller.scrollLeft + (horizontalDelta + shiftedHorizontalDelta) * CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER));
if (nextTop === scroller.scrollTop && nextLeft === scroller.scrollLeft) return;
const wheelScroll = resolveDataGridWheelScroll({
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
lineSize: CANVAS_DATA_GRID_ROW_HEIGHT,
metrics: scroller,
accelerationFactor: CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER,
});
if (!wheelScroll.moved) return;
event.preventDefault();
scroller.scrollTop = nextTop;
scroller.scrollLeft = nextLeft;
event.stopPropagation();
scroller.scrollTop = wheelScroll.nextScrollTop;
scroller.scrollLeft = wheelScroll.nextScrollLeft;
onCanvasScroll({ target: scroller } as unknown as Event);
}
function onDomGridWheel(event: WheelEvent) {
if (event.ctrlKey || event.metaKey) return;
const scroller = event.currentTarget instanceof HTMLElement ? event.currentTarget : gridScrollerElement();
if (!scroller) return;
const verticalDelta = canvasWheelDeltaToPixels(event.deltaY, event.deltaMode, scroller.clientHeight);
const horizontalDelta = canvasWheelDeltaToPixels(event.deltaX, event.deltaMode, scroller.clientWidth);
const shiftedHorizontalDelta = event.shiftKey && Math.abs(verticalDelta) > Math.abs(horizontalDelta) ? verticalDelta : 0;
const effectiveVerticalDelta = shiftedHorizontalDelta === 0 ? verticalDelta : 0;
const effectiveHorizontalDelta = horizontalDelta + shiftedHorizontalDelta;
if (effectiveVerticalDelta === 0 && effectiveHorizontalDelta === 0) return;
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const maxLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
const nextTop = Math.max(0, Math.min(maxTop, scroller.scrollTop + effectiveVerticalDelta));
const nextLeft = Math.max(0, Math.min(maxLeft, scroller.scrollLeft + effectiveHorizontalDelta));
// Let an outer scroll container handle wheel input once the grid reaches its boundary.
if (nextTop === scroller.scrollTop && nextLeft === scroller.scrollLeft) return;
const wheelScroll = resolveDataGridWheelScroll({
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
lineSize: CANVAS_DATA_GRID_ROW_HEIGHT,
metrics: scroller,
});
if (!wheelScroll.moved) return;
event.preventDefault();
event.stopPropagation();
scroller.scrollTop = nextTop;
scroller.scrollLeft = nextLeft;
scroller.scrollTop = wheelScroll.nextScrollTop;
scroller.scrollLeft = wheelScroll.nextScrollLeft;
onScrollerScroll({ target: scroller } as unknown as Event);
}
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
function sourceBetween(start: string, end: string): string {
const startIndex = dataGridSource.indexOf(start);
const endIndex = dataGridSource.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return dataGridSource.slice(startIndex, endIndex);
}
describe("data grid wheel bindings", () => {
it("routes Canvas and DOM wheel calculations through the shared helper", () => {
const canvasHandler = sourceBetween("function onCanvasWheel", "function onDomGridWheel");
const domHandler = sourceBetween("function onDomGridWheel", "function onCanvasMouseMove");
for (const handler of [canvasHandler, domHandler]) {
expect(handler).toContain("resolveDataGridWheelScroll({");
expect(handler).toContain("if (!wheelScroll.moved) return;");
expect(handler.indexOf("if (!wheelScroll.moved) return;")).toBeLessThan(handler.indexOf("event.preventDefault();"));
expect(handler).toContain("event.stopPropagation();");
}
});
it("accepts genuine pixel deltaX in the Canvas handler", () => {
const predicate = sourceBetween("function shouldAccelerateCanvasWheel", "function onCanvasWheel");
expect(predicate).toContain("if (event.deltaX !== 0) return true;");
});
it("keeps wheel handlers bound in both render modes", () => {
expect(dataGridSource).toContain('@wheel="onCanvasWheel"');
expect(dataGridSource.match(/@wheel="onDomGridWheel"/g)).toHaveLength(2);
});
});
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { shouldCheckInfiniteScrollAfterScroll } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { resolveDataGridWheelScroll, type DataGridWheelMetrics } from "@/lib/dataGrid/dataGridWheel";
const metrics: DataGridWheelMetrics = {
scrollTop: 100,
scrollLeft: 200,
scrollHeight: 800,
scrollWidth: 1000,
clientHeight: 300,
clientWidth: 400,
};
function resolveWheel(overrides: Partial<Parameters<typeof resolveDataGridWheelScroll>[0]> = {}) {
return resolveDataGridWheelScroll({
deltaX: 0,
deltaY: 0,
deltaMode: 0,
lineSize: 28,
metrics,
...overrides,
});
}
describe("data grid wheel scrolling", () => {
it.each([
{ deltaX: 120, nextScrollLeft: 320 },
{ deltaX: -120, nextScrollLeft: 80 },
])("keeps pixel deltaX at 1:1 with Canvas acceleration ($deltaX)", ({ deltaX, nextScrollLeft }) => {
expect(resolveWheel({ deltaX, accelerationFactor: 1.5 })).toEqual({
scrollDeltaX: deltaX,
scrollDeltaY: 0,
nextScrollTop: 100,
nextScrollLeft,
moved: true,
});
});
it("normalizes line deltas with the configured row height", () => {
expect(resolveWheel({ deltaX: 2, deltaY: -3, deltaMode: 1 })).toMatchObject({
scrollDeltaX: 56,
scrollDeltaY: -84,
nextScrollTop: 16,
nextScrollLeft: 256,
moved: true,
});
});
it("normalizes page deltas with the matching viewport dimensions", () => {
expect(resolveWheel({ deltaX: 1, deltaY: 1, deltaMode: 2 })).toMatchObject({
scrollDeltaX: 400,
scrollDeltaY: 300,
nextScrollTop: 400,
nextScrollLeft: 600,
moved: true,
});
});
it("turns Shift plus deltaY into horizontal-only movement", () => {
expect(resolveWheel({ deltaY: 40, shiftKey: true, accelerationFactor: 1.5 })).toMatchObject({
scrollDeltaX: 60,
scrollDeltaY: 0,
nextScrollTop: 100,
nextScrollLeft: 260,
moved: true,
});
});
it("keeps plain deltaY vertical", () => {
expect(resolveWheel({ deltaY: 40 })).toMatchObject({
scrollDeltaX: 0,
scrollDeltaY: 40,
nextScrollTop: 140,
nextScrollLeft: 200,
moved: true,
});
});
it("preserves both pixel axes without Canvas acceleration for diagonal input", () => {
expect(resolveWheel({ deltaX: 30, deltaY: 20, accelerationFactor: 1.5 })).toMatchObject({
scrollDeltaX: 30,
scrollDeltaY: 20,
nextScrollTop: 120,
nextScrollLeft: 230,
moved: true,
});
});
it.each([{ ctrlKey: true }, { metaKey: true }])("leaves modified wheel input untouched (%o)", (modifier) => {
expect(resolveWheel({ deltaX: 120, deltaY: 40, ...modifier })).toEqual({
scrollDeltaX: 0,
scrollDeltaY: 0,
nextScrollTop: 100,
nextScrollLeft: 200,
moved: false,
});
});
it("clamps movement to both scroll boundaries", () => {
expect(resolveWheel({ deltaX: 1000, deltaY: -1000 })).toMatchObject({
nextScrollTop: 0,
nextScrollLeft: 600,
moved: true,
});
});
it("hands horizontal input to an outer scroller at the relevant boundary", () => {
expect(
resolveWheel({
deltaX: 120,
metrics: { ...metrics, scrollLeft: 600 },
}),
).toMatchObject({
nextScrollTop: 100,
nextScrollLeft: 600,
moved: false,
});
});
it("does not trigger infinite pagination for horizontal-only movement", () => {
const wheelScroll = resolveWheel({ deltaX: 120 });
expect(shouldCheckInfiniteScrollAfterScroll({ top: metrics.scrollTop, left: metrics.scrollLeft }, { top: wheelScroll.nextScrollTop, left: wheelScroll.nextScrollLeft })).toBe(false);
});
it("does not consume input when the grid has no overflow", () => {
expect(
resolveWheel({
deltaX: 120,
deltaY: 40,
metrics: {
scrollTop: 0,
scrollLeft: 0,
scrollHeight: 300,
scrollWidth: 400,
clientHeight: 300,
clientWidth: 400,
},
}),
).toMatchObject({
nextScrollTop: 0,
nextScrollLeft: 0,
moved: false,
});
});
});
@@ -0,0 +1,75 @@
export interface DataGridWheelMetrics {
scrollTop: number;
scrollLeft: number;
scrollHeight: number;
scrollWidth: number;
clientHeight: number;
clientWidth: number;
}
export interface DataGridWheelInput {
deltaX: number;
deltaY: number;
deltaMode: number;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
lineSize: number;
metrics: DataGridWheelMetrics;
accelerationFactor?: number;
}
export interface DataGridWheelScroll {
scrollDeltaX: number;
scrollDeltaY: number;
nextScrollTop: number;
nextScrollLeft: number;
moved: boolean;
}
const DOM_DELTA_PIXEL = 0;
const DOM_DELTA_LINE = 1;
const DOM_DELTA_PAGE = 2;
function wheelDeltaToPixels(delta: number, deltaMode: number, lineSize: number, pageSize: number): number {
if (deltaMode === DOM_DELTA_LINE) return delta * lineSize;
if (deltaMode === DOM_DELTA_PAGE) return delta * pageSize;
return delta;
}
export function resolveDataGridWheelScroll(input: DataGridWheelInput): DataGridWheelScroll {
const { metrics } = input;
if (input.ctrlKey || input.metaKey) {
return {
scrollDeltaX: 0,
scrollDeltaY: 0,
nextScrollTop: metrics.scrollTop,
nextScrollLeft: metrics.scrollLeft,
moved: false,
};
}
const accelerationFactor = input.accelerationFactor ?? 1;
const normalizedDeltaX = wheelDeltaToPixels(input.deltaX, input.deltaMode, input.lineSize, metrics.clientWidth);
const normalizedDeltaY = wheelDeltaToPixels(input.deltaY, input.deltaMode, input.lineSize, metrics.clientHeight);
const shiftedDeltaY = input.shiftKey && Math.abs(normalizedDeltaY) > Math.abs(normalizedDeltaX) ? normalizedDeltaY : 0;
// Native pixel deltas are already expressed in CSS pixels. When a device reports deltaX,
// preserve both axes at 1:1 so diagonal trackpad input is not distorted by Canvas acceleration.
const hasNativePixelDeltaX = input.deltaMode === DOM_DELTA_PIXEL && input.deltaX !== 0;
const horizontalAcceleration = hasNativePixelDeltaX ? 1 : accelerationFactor;
const verticalAcceleration = hasNativePixelDeltaX ? 1 : accelerationFactor;
const scrollDeltaX = normalizedDeltaX * horizontalAcceleration + shiftedDeltaY * accelerationFactor;
const scrollDeltaY = shiftedDeltaY === 0 ? normalizedDeltaY * verticalAcceleration : 0;
const maxScrollTop = Math.max(0, metrics.scrollHeight - metrics.clientHeight);
const maxScrollLeft = Math.max(0, metrics.scrollWidth - metrics.clientWidth);
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, metrics.scrollTop + scrollDeltaY));
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, metrics.scrollLeft + scrollDeltaX));
return {
scrollDeltaX,
scrollDeltaY,
nextScrollTop,
nextScrollLeft,
moved: nextScrollTop !== metrics.scrollTop || nextScrollLeft !== metrics.scrollLeft,
};
}