From 53b7be3c6fe30797aa6802fe10925d80d56d8ebf Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 22 Aug 2026 10:08:46 +0000 Subject: [PATCH] fix(grid): handle horizontal wheel input Closes #6793 --- apps/desktop/src/components/grid/DataGrid.vue | 61 ++++---- .../__tests__/DataGridWheelBindings.spec.ts | 36 +++++ .../__tests__/dataGrid/dataGridWheel.spec.ts | 146 ++++++++++++++++++ .../desktop/src/lib/dataGrid/dataGridWheel.ts | 75 +++++++++ 4 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridWheelBindings.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridWheel.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridWheel.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 07e757f6a0..9c5e505545 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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); } diff --git a/apps/desktop/src/components/grid/__tests__/DataGridWheelBindings.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridWheelBindings.spec.ts new file mode 100644 index 0000000000..f905cd0671 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridWheelBindings.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridWheel.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridWheel.spec.ts new file mode 100644 index 0000000000..9b1d8027af --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridWheel.spec.ts @@ -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[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, + }); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridWheel.ts b/apps/desktop/src/lib/dataGrid/dataGridWheel.ts new file mode 100644 index 0000000000..8e6edaf686 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridWheel.ts @@ -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, + }; +}