From 81f963fe60196b4ed457069f4037b1d013edf7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 24 Sep 2026 12:16:20 +0800 Subject: [PATCH] fix(grid): size columns by sampled values and distribute spare width --- apps/desktop/src/components/grid/DataGrid.vue | 3 +- .../__tests__/useDataGridColumnResize.spec.ts | 55 ++++++++++++++++++- .../composables/useDataGridColumnResize.ts | 52 ++++++++++++++---- 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index dddf6b4fff..09f03b6a85 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2282,6 +2282,7 @@ function measureColumnHeaderText(text: string): number | undefined { } let columnFormatterForWidth: ((columnIndex: number) => ColumnFormatterConfig | undefined) | undefined; +const gridViewportWidth = ref(0); function columnWidthDisplayValue(value: CellValue, columnIndex: number): CellValue { const formatter = columnFormatterForWidth?.(columnIndex); @@ -2301,6 +2302,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, autoFitAllColumns, rende measureHeaderText: measureColumnHeaderText, headerMeasurementKey: columnHeaderMeasurementKey, rowNumberWidth, + viewportWidth: gridViewportWidth, displayValue: columnWidthDisplayValue, }); const gridStyle = computed(() => ({ @@ -2312,7 +2314,6 @@ const gridStyle = computed(() => ({ "--dbx-table-font-size": `${tableFontSize.value}px`, })); const gridHorizontalScrollLeft = ref(0); -const gridViewportWidth = ref(0); let gridScrollLeftBeforeTranspose = 0; let gridScrollTopBeforeKeyboardTranspose: number | null = null; let restoreGridScrollTopAfterTranspose = false; diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts index 39ec10a19c..7244ddfd77 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts @@ -17,6 +17,7 @@ function createResizeState(options: { compactColumnHeaderActions?: boolean; indexIndicatorColumnIndexes?: number[] | ReturnType>; headerTextWidth?: number; + viewportWidth?: number | ReturnType>; displayValue?: (value: string | number | boolean | null, columnIndex: number) => string | number | boolean | null; }) { const compact = ref(options.compactColumnHeaderActions ?? true); @@ -25,6 +26,7 @@ function createResizeState(options: { const density = ref(options.density ?? "standard"); const rows = isRef(options.rows) ? options.rows : ref(options.rows); const indexIndicatorColumnIndexes = isRef(options.indexIndicatorColumnIndexes) ? options.indexIndicatorColumnIndexes : ref(options.indexIndicatorColumnIndexes ?? []); + const viewportWidth = isRef(options.viewportWidth) ? options.viewportWidth : ref(options.viewportWidth ?? 0); const state = useDataGridColumnResize({ columns: computed(() => options.columns), sourceRows: computed(() => rows.value), @@ -36,6 +38,7 @@ function createResizeState(options: { columnStructureSignature: computed(() => createDataGridColumnStructureSignature(options.columns, options.columnTypes)), measureHeaderText: () => headerTextWidth.value, headerMeasurementKey, + viewportWidth, displayValue: options.displayValue, }); return { @@ -87,6 +90,52 @@ describe("useDataGridColumnResize", () => { expect(after.length).toBe(before.length); }); + it("distributes spare viewport width across non-user-sized columns", () => { + const state = createResizeState({ + columns: ["id", "name"], + rows: [[1, "a"]], + viewportWidth: 400, + }); + state.initColumnWidths(); + const intrinsic = state.columnWidths.value.slice(); + expect(state.renderedColumnWidths.value.reduce((sum, width) => sum + width, 0) + DATA_GRID_ROW_NUM_WIDTH).toBe(400); + expect(state.renderedColumnWidths.value[0]).toBeGreaterThan(intrinsic[0]); + expect(state.renderedColumnWidths.value[1]).toBeGreaterThan(intrinsic[1]); + + state.onResizeStart(0, new MouseEvent("mousedown", { clientX: 100, cancelable: true })); + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 100 })); + expect(state.renderedColumnWidths.value[0]).toBeGreaterThan(intrinsic[0]); + expect(state.renderedColumnWidths.value[1]).toBeGreaterThan(intrinsic[1]); + }); + + it("tracks the cursor while dragging under spare width and stores the final width without stale rendering", async () => { + const state = createResizeState({ + columns: ["id", "name"], + rows: [[1, "a"]], + viewportWidth: 1000, + }); + state.initColumnWidths(); + const intrinsic = state.columnWidths.value.slice(); + const startWidth = state.renderedColumnWidths.value[0] ?? 0; + // The grabbed column initially renders wider than its stored intrinsic width. + expect(startWidth).toBeGreaterThan(intrinsic[0] ?? 0); + + state.onResizeStart(0, new MouseEvent("mousedown", { clientX: 100, cancelable: true })); + // Grabbing the handle must not shift the rendered edge. + expect(state.renderedColumnWidths.value[0]).toBe(startWidth); + + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 150 })); + await new Promise((resolve) => requestAnimationFrame(resolve)); + // Mid-drag the rendered width is exactly startWidth + delta: the resizing + // column is not re-stretched with the remaining surplus. + expect(state.renderedColumnWidths.value[0]).toBe(startWidth + 50); + + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 150 })); + // After mouseup the rendered width equals the stored user width (no stale render). + expect(state.columnWidths.value[0]).toBe(startWidth + 50); + expect(state.renderedColumnWidths.value[0]).toBe(state.columnWidths.value[0]); + }); + it("keeps auto fitted widths when the grid re-initialises for the same columns", () => { const longText = "y".repeat(220); const state = createResizeState({ @@ -112,13 +161,17 @@ describe("useDataGridColumnResize", () => { expect(state.renderedColumnWidths.value).toEqual([]); }); - it("keeps compact query result columns at content width instead of filling the viewport", () => { + it("keeps compact query result columns at content width when no viewport width is reported", () => { + // viewportWidth unset (0) means no spare width is distributed, so compact + // columns keep their content width; with a real viewport they fill it — see + // "distributes spare viewport width across non-user-sized columns" above. const state = createResizeState({ columns: ["id", "user_id"], rows: [ [1, 10], [2, 20], ], + viewportWidth: 0, }); state.initColumnWidths(); diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts index d8ff7f714a..fbd24d0dc8 100644 --- a/apps/desktop/src/composables/useDataGridColumnResize.ts +++ b/apps/desktop/src/composables/useDataGridColumnResize.ts @@ -1,4 +1,4 @@ -import { ref, computed, watch, type ComputedRef, type Ref } from "vue"; +import { ref, shallowRef, computed, watch, type ComputedRef, type Ref } from "vue"; import { calculateDataGridColumnWidth, DATA_GRID_AUTO_FIT_VALUE_TEXT_LIMIT, DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH, COLUMN_WIDTH_DENSITY_PRESETS, sampleDataGridColumnValues } from "@/lib/dataGrid/dataGridColumnWidth"; import { createDataGridColumnMeasurementSignature, loadDataGridColumnWidthState, removeDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState"; import type { ColumnWidthDensity } from "@/stores/settingsStore"; @@ -42,6 +42,7 @@ export interface UseDataGridColumnResizeOptions { measureHeaderText?: (text: string) => number | undefined; headerMeasurementKey?: Ref; rowNumberWidth?: Ref | ComputedRef; + viewportWidth?: Ref | ComputedRef; displayValue?: (value: CellValue, columnIndex: number) => CellValue; } @@ -51,7 +52,9 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) const columnWidths = ref([]); let isResizing = false; let previousColumnIndexes: number[] = []; - let userSizedColumnIndexes = new Set(); + // Replaced wholesale instead of mutated so reactive consumers (renderedColumnWidths) + // re-run when a column becomes user-sized and stops receiving spare width. + const userSizedColumnIndexes = shallowRef>(new Set()); function columnWidthStateIdentity() { return { @@ -62,7 +65,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) } function persistColumnWidths() { - saveDataGridColumnWidthState(columnWidthStateIdentity(), previousColumnIndexes, columnWidths.value, userSizedColumnIndexes); + saveDataGridColumnWidthState(columnWidthStateIdentity(), previousColumnIndexes, columnWidths.value, userSizedColumnIndexes.value); } function sampleColumnValues(visibleColIdx: number): CellValue[] { @@ -96,7 +99,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) const next = columnWidths.value.slice(); for (let colIdx = 0; colIdx < columns.value.length; colIdx++) { const actualColIdx = columnIndexes.value[colIdx]; - if (actualColIdx === undefined || userSizedColumnIndexes.has(actualColIdx)) continue; + if (actualColIdx === undefined || userSizedColumnIndexes.value.has(actualColIdx)) continue; const needed = neededWidths[colIdx] ?? DATA_GRID_COL_MIN_WIDTH; if (needed > (next[colIdx] ?? 0)) { next[colIdx] = needed; @@ -110,11 +113,14 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) function markColumnUserSized(visibleColIdx: number) { const actualColIdx = columnIndexes.value[visibleColIdx]; - if (actualColIdx !== undefined) userSizedColumnIndexes.add(actualColIdx); + if (actualColIdx === undefined || userSizedColumnIndexes.value.has(actualColIdx)) return; + const next = new Set(userSizedColumnIndexes.value); + next.add(actualColIdx); + userSizedColumnIndexes.value = next; } function initColumnWidths(force = false) { - if (force) userSizedColumnIndexes.clear(); + if (force) userSizedColumnIndexes.value = new Set(); const previousWidthsByColumnIndex = new Map(); previousColumnIndexes.forEach((columnIndex, visibleIndex) => { const width = columnWidths.value[visibleIndex]; @@ -122,7 +128,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) }); const nextColumnIndexes = [...columnIndexes.value]; const cachedState = !force && previousColumnIndexes.length === 0 ? loadDataGridColumnWidthState(columnWidthStateIdentity(), nextColumnIndexes) : undefined; - if (cachedState) userSizedColumnIndexes = new Set(cachedState.userSizedColumnIndexes); + if (cachedState) userSizedColumnIndexes.value = new Set(cachedState.userSizedColumnIndexes); const currentNeededWidths = neededColumnWidths.value ?? []; const columnCount = columns.value?.length ?? 0; if (force || columnWidths.value.length !== columnCount || previousColumnIndexes.join("\0") !== nextColumnIndexes.join("\0")) { @@ -144,7 +150,15 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) event.preventDefault(); isResizing = true; const startX = event.clientX; - const startWidth = columnWidths.value[colIdx] ?? DATA_GRID_COL_MIN_WIDTH; + // The drag base is the width the user grabbed, which may include spare width + // distributed at render time. Freezing that snapshot as the stored width and + // marking the column user-sized right away keeps the column out of the + // flexible set for the whole drag, so every frame renders startWidth + delta + // as-is instead of re-stretching it with the remaining surplus, and the + // stored (persisted) width matches what the cursor moved. + const startWidth = renderedColumnWidths.value[colIdx] ?? DATA_GRID_COL_MIN_WIDTH; + markColumnUserSized(colIdx); + columnWidths.value[colIdx] = startWidth; let pendingClientX = startX; let resizeFrame = 0; @@ -232,10 +246,26 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) persistColumnWidths(); } - const renderedColumnWidths = computed(() => columnWidths.value.slice()); - const resolvedRowNumberWidth = computed(() => options.rowNumberWidth?.value ?? DATA_GRID_ROW_NUM_WIDTH); + const renderedColumnWidths = computed(() => { + const widths = columnWidths.value.slice(); + const viewportWidth = options.viewportWidth?.value ?? 0; + const surplus = viewportWidth - resolvedRowNumberWidth.value - widths.reduce((sum, width) => sum + width, 0); + if (surplus <= 0) return widths; + const flexibleIndexes = widths.flatMap((_, visibleIndex) => { + const actualIndex = columnIndexes.value[visibleIndex]; + return actualIndex !== undefined && !userSizedColumnIndexes.value.has(actualIndex) ? [visibleIndex] : []; + }); + if (flexibleIndexes.length === 0) return widths; + const base = Math.floor(surplus / flexibleIndexes.length); + let remainder = surplus - base * flexibleIndexes.length; + flexibleIndexes.forEach((index) => { + widths[index] += base + (remainder-- > 0 ? 1 : 0); + }); + return widths; + }); + const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + resolvedRowNumberWidth.value); const columnVars = computed(() => { @@ -259,7 +289,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) watch([() => options.cacheKey?.value, options.columnStructureSignature], () => { columnWidths.value = []; previousColumnIndexes = []; - userSizedColumnIndexes.clear(); + userSizedColumnIndexes.value = new Set(); initColumnWidths(); }); watch([density, compactColumnHeaderActions, () => options.headerMeasurementKey?.value], () => {