From 2443a550e3c6f612fa53c6da3f5e586d35b8d27e Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 22 Jul 2026 23:50:51 +0800 Subject: [PATCH] fix(data-grid): preserve scroll when editing cells --- apps/desktop/src/components/grid/DataGrid.vue | 3 ++ .../src/composables/useDataGridEditor.ts | 24 +++++++------ .../dataGrid/dataGridEditorFocus.spec.ts | 34 +++++++++++++++++++ .../src/lib/dataGrid/dataGridEditorFocus.ts | 19 +++++++++++ 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridEditorFocus.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridEditorFocus.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b1e5366ebb..90c5a3b0c3 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5793,6 +5793,9 @@ function prepareDataCellMouseDown(item: RowItem, actualColIdx: number) { pendingQuickEntryDraftCellFocus.value = { rowId: item.id, col: actualColIdx }; } else { pendingQuickEntryDraftCellFocus.value = null; + if (editing && (editing.rowId !== item.id || editing.col !== actualColIdx)) { + void commitEditFromCellBlur(); + } } } diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index c3ac3c42a1..f3801264d7 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -2,6 +2,7 @@ import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBefo import * as api from "@/lib/backend/api"; import type { CellValue } from "@/lib/dataGrid/cellValue"; import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; +import { focusDataGridEditorWithoutScrolling, preserveDataGridScrollPosition } from "@/lib/dataGrid/dataGridEditorFocus"; import { normalizeDataGridSaveError } from "@/lib/dataGrid/dataGridSql"; import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus"; import { supportsDataGridTransaction } from "@/lib/table/tableEditing"; @@ -271,9 +272,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { function focusEditInput(select = true) { const focusInput = () => { if (typeof document === "undefined") return; - const root = getScrollerElement()?.closest("[data-grid-root]"); + const scroller = getScrollerElement(); + const root = scroller?.closest("[data-grid-root]"); const input = (root ?? document).querySelector(".cell-edit-input") as HTMLInputElement | HTMLTextAreaElement | null; - input?.focus(); + if (input) focusDataGridEditorWithoutScrolling(input, scroller); if (select && input) { if (input instanceof HTMLTextAreaElement && input.dataset.expandedCellEditor === "true") { input.setSelectionRange?.(0, 0); @@ -399,14 +401,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function preserveScrollPosition() { - const el = getScrollerElement(); - if (!el) return () => {}; - const top = el.scrollTop; - const left = el.scrollLeft; - return () => { - el.scrollTop = top; - el.scrollLeft = left; - }; + return preserveDataGridScrollPosition(getScrollerElement()); } function readScrollPosition(): PendingChangesSnapshot["scroll"] | undefined { @@ -457,6 +452,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { restoreScroll(); nextTick(() => { restoreScroll(); + if (typeof requestAnimationFrame !== "function") { + isCancelling = false; + return; + } let attempts = 0; const restoreNextFrame = () => { restoreScroll(); @@ -731,7 +730,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { suppressNextBlurCommit = false; return; } - await commitEditAndMaybeAutoSave(options); + const restoreScroll = preserveScrollPosition(); + const pendingCommit = commitEditAndMaybeAutoSave(options); + restoreScrollAcrossFrames(restoreScroll); + await pendingCommit; } function applyCellValue(rowId: number, col: number, value: string | null, options: ApplyCellValueOptions = {}) { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditorFocus.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditorFocus.spec.ts new file mode 100644 index 0000000000..97ebf1ba21 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditorFocus.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { focusDataGridEditorWithoutScrolling, preserveDataGridScrollPosition } from "@/lib/dataGrid/dataGridEditorFocus"; + +describe("preserveDataGridScrollPosition", () => { + it("restores the viewport after an editor is removed", () => { + const scroller = { scrollLeft: 720, scrollTop: 180 }; + const restoreScroll = preserveDataGridScrollPosition(scroller); + + scroller.scrollLeft = 0; + scroller.scrollTop = 0; + restoreScroll(); + + expect(scroller).toEqual({ scrollLeft: 720, scrollTop: 180 }); + }); +}); + +describe("focusDataGridEditorWithoutScrolling", () => { + it("prevents focus from moving the data grid viewport", () => { + const scroller = { scrollLeft: 720, scrollTop: 180 }; + const input = { + focus: vi.fn((options?: FocusOptions) => { + expect(options).toEqual({ preventScroll: true }); + // Some WebViews still move a virtualized ancestor while mounting the editor. + scroller.scrollLeft = 0; + scroller.scrollTop = 0; + }), + }; + + focusDataGridEditorWithoutScrolling(input, scroller); + + expect(input.focus).toHaveBeenCalledOnce(); + expect(scroller).toEqual({ scrollLeft: 720, scrollTop: 180 }); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridEditorFocus.ts b/apps/desktop/src/lib/dataGrid/dataGridEditorFocus.ts new file mode 100644 index 0000000000..6ca1c5cc63 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridEditorFocus.ts @@ -0,0 +1,19 @@ +type DataGridEditorInput = Pick; +type DataGridEditorScroller = Pick; + +export function preserveDataGridScrollPosition(scroller?: DataGridEditorScroller | null) { + if (!scroller) return () => {}; + const scrollLeft = scroller.scrollLeft; + const scrollTop = scroller.scrollTop; + return () => { + if (scroller.scrollLeft !== scrollLeft) scroller.scrollLeft = scrollLeft; + if (scroller.scrollTop !== scrollTop) scroller.scrollTop = scrollTop; + }; +} + +export function focusDataGridEditorWithoutScrolling(input: DataGridEditorInput, scroller?: DataGridEditorScroller | null) { + const restoreScroll = preserveDataGridScrollPosition(scroller); + + input.focus({ preventScroll: true }); + restoreScroll(); +}