diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 820ed68c2..2e0477456 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5890,6 +5890,18 @@ function currentSelectedCellPosition() { } function scrollCellIntoView(rowIndex: number, colIndex: number) { + if (isTransposeMode.value) { + nextTick(() => { + const scroller = transposeScrollRef.value; + if (scroller && !(scroller instanceof HTMLElement)) { + (scroller as { scrollToItem?: (index: number) => void }).scrollToItem?.(colIndex); + } else if (scroller instanceof HTMLElement) { + scroller.scrollTop = colIndex * 30; + } + scrollTransposeRecordIntoView(rowIndex); + }); + return; + } nextTick(() => { scrollGridColumnIntoView(colIndex); if (useCanvasGridRows.value) { @@ -5961,9 +5973,22 @@ function scrollGridRowIntoView(rowIndex: number) { }); } -function currentTransposeRequestedRowIndex(): number { +function selectedTransposeRowIndex(): number | null { const position = currentSelectedCellPosition(); if (position) return position.rowIndex; + const lastSelectedRowIndex = selection.lastClickedRowIndex.value; + if (lastSelectedRowIndex !== null) { + const item = displayItemAt(lastSelectedRowIndex); + if (item && selectedRowIds.value.has(item.id)) return lastSelectedRowIndex; + } + const selectedRowIndex = displayRowRefs.value.findIndex((row) => selectedRowIds.value.has(row.id)); + if (selectedRowIndex >= 0) return selectedRowIndex; + return null; +} + +function currentTransposeRequestedRowIndex(): number { + const selectedRowIndex = selectedTransposeRowIndex(); + if (selectedRowIndex !== null) return selectedRowIndex; if (transposeRowIndex.value !== null) return transposeRowIndex.value; return 0; } @@ -6191,13 +6216,23 @@ async function onGridKeydown(event: KeyboardEvent) { event.preventDefault(); return; } - if (event.key === "ArrowLeft" && moveTransposeRecordSelection(-1)) { - event.preventDefault(); - return; - } - if (event.key === "ArrowRight" && moveTransposeRecordSelection(1)) { - event.preventDefault(); - return; + if (isTransposeMode.value) { + if (event.key === "ArrowUp" && moveSelectedCell(0, -1)) { + event.preventDefault(); + return; + } + if (event.key === "ArrowDown" && moveSelectedCell(0, 1)) { + event.preventDefault(); + return; + } + if (event.key === "ArrowLeft" && (moveSelectedCell(-1, 0) || moveTransposeRecordSelection(-1))) { + event.preventDefault(); + return; + } + if (event.key === "ArrowRight" && (moveSelectedCell(1, 0) || moveTransposeRecordSelection(1))) { + event.preventDefault(); + return; + } } if (event.key === "ArrowUp" && moveSelectedCell(-1, 0)) { event.preventDefault(); @@ -6374,6 +6409,13 @@ function copyColumnDetailFieldValue(field: DataGridCellDetail) { const transposeRecordWidths = ref([]); const transposeManualRecordWidthIndexes = ref(new Set()); +const transposeRecordOffsets = computed(() => { + const offsets = [0]; + for (let index = 0; index < displayRowCount.value; index += 1) { + offsets.push(offsets[index] + getTransposeRecordWidth(index)); + } + return offsets; +}); function calcTransposeRecordWidth(recordIndex: number): number { const item = displayItemAt(recordIndex); @@ -6424,6 +6466,7 @@ const transposeRecordWindow = computed(() => viewportWidth: transposeViewportWidth.value, pinnedWidth: transposePinnedWidth.value, recordWidth: estimatedTransposeRecordWidth(), + recordOffsets: transposeRecordOffsets.value, overscan: 2, }), ); @@ -6487,6 +6530,8 @@ function scrollTransposeRecordIntoView(rowIndex: number) { viewportWidth: el.clientWidth, pinnedWidth: transposePinnedWidth.value, recordWidth: estimatedTransposeRecordWidth(), + recordOffsets: transposeRecordOffsets.value, + currentScrollLeft: el.scrollLeft, }); updateTransposeViewport(); }); @@ -6594,10 +6639,12 @@ function openContextTranspose() { return; } if (!contextCell.value) return; + const selectedRowIndex = selectedTransposeRowIndex(); + const requestedRowIndex = selectedRowIds.value.size === 1 && selectedRowIndex !== null ? selectedRowIndex : contextCell.value.rowIndex; const next = nextContextTransposeState({ showTranspose: showTranspose.value, transposeRowIndex: transposeRowIndex.value, - requestedRowIndex: contextCell.value.rowIndex, + requestedRowIndex, rowIds: displayRowRefs.value.map((ref) => ref.id), selectedRowIds: selectedRowIds.value, selectedRange: selectedRange.value, diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridTranspose.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridTranspose.spec.ts index 1114e1172..1f1418919 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridTranspose.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridTranspose.spec.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; -import { averageTransposeRecordWidth, calculateTransposeRecordWidth, defaultTransposeRecordWidth, minTransposeFieldWidth, shouldAutoTransposeSingleRow, transposeFieldWidth, transposeRecordWidthsForDensity, visibleTransposeRecordWindow } from "@/lib/dataGrid/dataGridTranspose"; +import { + averageTransposeRecordWidth, + calculateTransposeRecordWidth, + defaultTransposeRecordWidth, + minTransposeFieldWidth, + shouldAutoTransposeSingleRow, + transposeAnchorRowIndex, + transposeFieldWidth, + transposeScrollLeftForRecord, + transposeRecordWidthsForDensity, + visibleTransposeRecordWindow, +} from "@/lib/dataGrid/dataGridTranspose"; describe("single-row automatic transpose", () => { it("only opens for enabled multi-column results that are not preserving a manual transpose", () => { @@ -12,6 +23,21 @@ describe("single-row automatic transpose", () => { }); }); +describe("transpose row anchor", () => { + it("keeps the requested row when multiple rows or cells are selected", () => { + const rowIds = [1, 2, 3, 4]; + + expect( + transposeAnchorRowIndex({ + requestedRowIndex: 3, + rowIds, + selectedRowIds: new Set([1, 4]), + selectedRange: { startRow: 0, endRow: 3, startCol: 0, endCol: 1 }, + }), + ).toBe(3); + }); +}); + describe("dataGridTranspose density widths", () => { it("uses the shared density preset for record and field widths", () => { const values = ["x".repeat(40)]; @@ -78,3 +104,46 @@ describe("dataGridTranspose density widths", () => { expect(averageTransposeRecordWidth([], "compact")).toBe(defaultTransposeRecordWidth("compact")); }); }); + +describe("transpose record scrolling", () => { + it("uses the scroll position after the sticky field for virtualization", () => { + expect( + visibleTransposeRecordWindow({ + totalRecords: 3, + scrollLeft: 500, + viewportWidth: 300, + pinnedWidth: 100, + recordWidth: 230, + recordOffsets: [0, 500, 596, 692], + overscan: 0, + }), + ).toEqual({ start: 1, end: 3, beforeWidth: 500, afterWidth: 0 }); + }); + + it("uses actual record widths and nearest scrolling", () => { + const recordOffsets = [0, 500, 596, 692]; + + expect( + transposeScrollLeftForRecord({ + recordIndex: 2, + totalRecords: 3, + viewportWidth: 300, + pinnedWidth: 100, + recordWidth: 230, + recordOffsets, + currentScrollLeft: 0, + }), + ).toBe(492); + expect( + transposeScrollLeftForRecord({ + recordIndex: 1, + totalRecords: 3, + viewportWidth: 300, + pinnedWidth: 100, + recordWidth: 230, + recordOffsets, + currentScrollLeft: 450, + }), + ).toBe(450); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts index 79a444280..c3946329b 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts @@ -50,6 +50,7 @@ export interface TransposeRecordWindowOptions { viewportWidth: number; pinnedWidth: number; recordWidth: number; + recordOffsets?: readonly number[]; overscan?: number; } @@ -159,6 +160,8 @@ export interface TransposeScrollLeftOptions { viewportWidth: number; pinnedWidth: number; recordWidth: number; + recordOffsets?: readonly number[]; + currentScrollLeft?: number; } export interface TransposeRecordIndexesForModeOptions { @@ -269,8 +272,25 @@ export function visibleTransposeRecordWindow(options: TransposeRecordWindowOptio } const overscan = options.overscan ?? 2; - const recordScrollLeft = Math.max(0, options.scrollLeft - options.pinnedWidth); + const recordScrollLeft = Math.max(0, options.scrollLeft); const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth); + if (options.recordOffsets?.length === options.totalRecords + 1) { + const offsets = options.recordOffsets; + const firstVisible = Math.max( + 0, + offsets.findIndex((_offset, index) => index < options.totalRecords && offsets[index + 1] > recordScrollLeft), + ); + const firstAfterViewport = offsets.findIndex((offset, index) => index > firstVisible && offset >= recordScrollLeft + recordViewportWidth); + const start = Math.max(0, firstVisible - overscan); + const end = Math.min(options.totalRecords, (firstAfterViewport < 0 ? options.totalRecords : firstAfterViewport) + overscan); + const totalWidth = offsets[options.totalRecords]; + return { + start, + end, + beforeWidth: offsets[start], + afterWidth: Math.max(0, totalWidth - offsets[end]), + }; + } const start = Math.max(0, Math.floor(recordScrollLeft / options.recordWidth) - overscan); const end = Math.min(options.totalRecords, Math.ceil((recordScrollLeft + recordViewportWidth) / options.recordWidth) + overscan + 1); @@ -290,18 +310,7 @@ export function transposeRecordIndexesForMode(options: TransposeRecordIndexesFor } export function transposeAnchorRowIndex(options: TransposeAnchorOptions): number { - const requestedRowId = options.rowIds[options.requestedRowIndex]; - if (requestedRowId !== undefined && options.selectedRowIds.size > 1 && options.selectedRowIds.has(requestedRowId)) { - const firstSelectedIndex = options.rowIds.findIndex((rowId) => options.selectedRowIds.has(rowId)); - if (firstSelectedIndex >= 0) return firstSelectedIndex; - } - - const range = options.selectedRange; - if (range && range.startRow !== range.endRow && options.requestedRowIndex >= range.startRow && options.requestedRowIndex <= range.endRow) { - return range.startRow; - } - - return options.requestedRowIndex; + return Math.max(0, Math.min(options.rowIds.length - 1, options.requestedRowIndex)); } export function transposeFieldWidth(columns: string[], options: TransposeFieldWidthOptions = {}): number { @@ -317,8 +326,15 @@ export function transposeFieldWidth(columns: string[], options: TransposeFieldWi export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions): number { if (options.recordWidth <= 0 || options.totalRecords <= 0) return 0; - const desired = Math.max(0, options.recordIndex) * options.recordWidth; - const totalWidth = options.pinnedWidth + options.totalRecords * options.recordWidth; + const recordIndex = Math.max(0, Math.min(options.totalRecords - 1, options.recordIndex)); + const offsets = options.recordOffsets?.length === options.totalRecords + 1 ? options.recordOffsets : undefined; + const recordStart = offsets ? offsets[recordIndex] : recordIndex * options.recordWidth; + const recordEnd = offsets ? offsets[recordIndex + 1] : recordStart + options.recordWidth; + const recordsWidth = offsets ? offsets[options.totalRecords] : options.totalRecords * options.recordWidth; + const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth); + const currentScrollLeft = Math.max(0, options.currentScrollLeft ?? recordStart); + const desired = recordStart < currentScrollLeft ? recordStart : recordEnd > currentScrollLeft + recordViewportWidth ? recordEnd - recordViewportWidth : currentScrollLeft; + const totalWidth = options.pinnedWidth + recordsWidth; const maxScrollLeft = Math.max(0, totalWidth - options.viewportWidth); - return Math.min(desired, maxScrollLeft); + return Math.max(0, Math.min(desired, maxScrollLeft)); } diff --git a/packages/app-tests/dataGridTranspose.test.ts b/packages/app-tests/dataGridTranspose.test.ts index 3e54f28d4..f3efaabd4 100644 --- a/packages/app-tests/dataGridTranspose.test.ts +++ b/packages/app-tests/dataGridTranspose.test.ts @@ -36,7 +36,7 @@ test("row number double click closes transpose for the same row", () => { }); }); -test("context menu transpose closes when invoked for the current anchor row", () => { +test("context menu transpose keeps the requested row when multiple rows are selected", () => { assert.deepEqual( nextContextTransposeState({ showTranspose: true, @@ -47,8 +47,8 @@ test("context menu transpose closes when invoked for the current anchor row", () selectedRange: null, }), { - showTranspose: false, - transposeRowIndex: null, + showTranspose: true, + transposeRowIndex: 3, }, ); }); @@ -256,15 +256,15 @@ test("calculates a horizontal record window with spacer widths", () => { overscan: 1, }), { - start: 1, - end: 7, - beforeWidth: 160, - afterWidth: 14880, + start: 3, + end: 9, + beforeWidth: 480, + afterWidth: 14560, }, ); }); -test("uses the first selected row as the transpose anchor when context row is inside row selection", () => { +test("keeps the requested row as the transpose anchor when context row is inside row selection", () => { assert.equal( transposeAnchorRowIndex({ requestedRowIndex: 3, @@ -272,11 +272,11 @@ test("uses the first selected row as the transpose anchor when context row is in selectedRowIds: new Set([12, 13, 14]), selectedRange: null, }), - 2, + 3, ); }); -test("uses the first selected cell range row as the transpose anchor when context row is inside range", () => { +test("keeps the requested row as the transpose anchor when context row is inside range", () => { assert.equal( transposeAnchorRowIndex({ requestedRowIndex: 5, @@ -284,7 +284,7 @@ test("uses the first selected cell range row as the transpose anchor when contex selectedRowIds: new Set(), selectedRange: { startRow: 2, endRow: 5, startCol: 0, endCol: 2 }, }), - 2, + 5, ); });