From b509520382ebf38354f99b4f31da0a776db6ed91 Mon Sep 17 00:00:00 2001 From: gggaiitx <62124152+gggaiitx@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:38:54 +0800 Subject: [PATCH] fix(grid): prevent horizontal scrollbar row overlap --- apps/desktop/src/components/grid/DataGrid.vue | 37 +++++++++++++++++-- .../dataGrid/dataGridInfiniteScroll.spec.ts | 32 ++++++++++++++++ .../lib/dataGrid/dataGridInfiniteScroll.ts | 9 +++++ 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridInfiniteScroll.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index bc995fdf5..f6b5eaa99 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -119,7 +119,7 @@ import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/d import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; -import { dataGridScrollPosition, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; +import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; @@ -2790,8 +2790,22 @@ function updateGridVerticalScrollbar(element: HTMLElement | null = gridScrollerE function setGridHorizontalOverflow(overflow: boolean) { if (hasGridHorizontalOverflow.value === overflow) return; + const scroller = gridScrollerElement(); + const preserveBottom = overflow && !!scroller && isDataGridAtScrollBottom(scroller); hasGridHorizontalOverflow.value = overflow; - if (overflow) nextTick(applyGridHorizontalScrollbarThumbStyle); + if (!overflow) return; + nextTick(() => { + applyGridHorizontalScrollbarThumbStyle(); + if (!preserveBottom || gridScrollerElement() !== scroller) return; + // The custom horizontal scrollbar changes the scrollable geometry after render; + // restore bottom anchoring through the normal handlers so every grid mode stays synchronized. + scroller.scrollTop = dataGridBottomScrollTop(scroller); + if (useCanvasGridRows.value) { + onCanvasScroll({ target: scroller } as unknown as Event); + } else { + onScrollerScroll({ target: scroller } as unknown as Event); + } + }); } function setGridVerticalOverflow(overflow: boolean) { @@ -9752,7 +9766,14 @@ const gridContextMenuItems = computed(() => { -
+
(() => { v-else-if="hasVisibleRows" ref="scrollerRef" class="data-grid-scroller dbx-data-grid-font-family flex-1 overflow-x-auto overscroll-none" - :class="{ 'is-scrolling': isScrolling }" + :class="{ 'is-scrolling': isScrolling, 'has-horizontal-scrollbar': hasGridHorizontalOverflow }" :items="displayItems" :item-size="26" :buffer="600" @@ -11257,6 +11278,14 @@ const gridContextMenuItems = computed(() => { display: none; } +.canvas-grid-scroller.has-horizontal-scrollbar { + margin-bottom: 10px; +} + +.data-grid-scroller.has-horizontal-scrollbar:not(.canvas-grid-scroller) { + padding-bottom: 10px; +} + .data-grid-scroller :deep(.vue-recycle-scroller__item-wrapper) { min-width: var(--total-w); overflow: visible; diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridInfiniteScroll.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridInfiniteScroll.spec.ts new file mode 100644 index 000000000..3cf968c28 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridInfiniteScroll.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { dataGridBottomScrollTop, isDataGridAtScrollBottom } from "@/lib/dataGrid/dataGridInfiniteScroll"; + +describe("data grid bottom anchoring", () => { + it("keeps DOM rows anchored when scrollbar padding increases the scroll height", () => { + const before = { scrollTop: 740, scrollHeight: 1000, clientHeight: 260 }; + const after = { scrollHeight: 1010, clientHeight: 260 }; + + expect(isDataGridAtScrollBottom(before)).toBe(true); + expect(dataGridBottomScrollTop(after)).toBe(750); + }); + + it("keeps canvas rows anchored when scrollbar margin reduces the viewport", () => { + const before = { scrollTop: 740, scrollHeight: 1000, clientHeight: 260 }; + const after = { scrollHeight: 1000, clientHeight: 250 }; + + expect(isDataGridAtScrollBottom(before)).toBe(true); + expect(dataGridBottomScrollTop(after)).toBe(750); + }); + + it("keeps the quick-entry draft row visible after the horizontal scrollbar appears", () => { + const before = { scrollTop: 766, scrollHeight: 1026, clientHeight: 260 }; + const after = { scrollHeight: 1036, clientHeight: 260 }; + + expect(isDataGridAtScrollBottom(before)).toBe(true); + expect(dataGridBottomScrollTop(after)).toBe(776); + }); + + it("does not anchor a user who is away from the bottom", () => { + expect(isDataGridAtScrollBottom({ scrollTop: 700, scrollHeight: 1000, clientHeight: 260 })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts b/apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts index 465cf9e18..9b9bcce71 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts @@ -25,3 +25,12 @@ export function shouldCheckInfiniteScrollAfterScroll(previous: DataGridScrollPos export function isDataGridNearScrollBottom(metrics: DataGridScrollMetrics, threshold = 100): boolean { return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight < threshold; } + +export function isDataGridAtScrollBottom(metrics: DataGridScrollMetrics, tolerance = 1): boolean { + const maxScrollTop = Math.max(0, metrics.scrollHeight - metrics.clientHeight); + return maxScrollTop - metrics.scrollTop <= tolerance; +} + +export function dataGridBottomScrollTop(metrics: Pick): number { + return Math.max(0, metrics.scrollHeight - metrics.clientHeight); +}