diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 101e8de3c..095e5f0a8 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -110,9 +110,11 @@ import { shouldAutoTransposeSingleRow, transposeRecordIndexesForMode, transposeRecordWidthsForDensity, + transposeEndAlignmentSpacerWidth, transposeFieldWidth, transposeScrollLeftForRecord, visibleTransposeRecordWindow, + type TransposeScrollAlignment, } from "@/lib/dataGrid/dataGridTranspose"; import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus"; import { displayCellValue, firstLineCellDisplayValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH, type CellValue } from "@/lib/dataGrid/cellValue"; @@ -454,7 +456,7 @@ watch( if (!shouldAutoTransposeSingleRow({ enabled: !!props.autoTransposeSingleRow, preserveTranspose: preserveTransposeOnNextResult.value, rowCount: result.rows.length, columnCount: result.columns.length })) return; nextTick(() => { if (props.result !== result || !props.autoTransposeSingleRow || result.rows.length !== 1 || result.columns.length <= 1) return; - applyTransposeState({ showTranspose: true, transposeRowIndex: 0 }); + applyTransposeState({ showTranspose: true, transposeRowIndex: 0 }, "start"); }); }, { immediate: true }, @@ -1728,6 +1730,11 @@ let gridVerticalScrollbarThumbHeightPercent = 100; let gridScrollbarsRuntime: DataGridScrollbarsRuntime; let dataGridTopbarResizeObserver: ResizeObserver | null = null; let cellEditResizeObserver: ResizeObserver | null = null; +// vue-virtual-scroller's @resize only fires in pageMode (it observes window), +// so in container-scroll mode (transpose uses RecycleScroller without pageMode) +// container size changes from window resize / sidebar drag never reach +// updateTransposeViewport. Observe the scroller element directly instead. +let transposeViewportResizeObserver: ResizeObserver | null = null; let resetCellEditTextareaScrollOnResize = false; let gridHorizontalScrollbarDragState: { scroller: HTMLElement; @@ -5637,6 +5644,7 @@ function pauseCanvasGridWork() { disconnectCellEditResizeObserver(); dataGridTopbarResizeObserver?.disconnect(); dataGridTopbarResizeObserver = null; + disconnectTransposeViewportObserver(); canvasPixelRatioMediaQueryCleanup?.(); canvasPixelRatioMediaQueryCleanup = null; canvasPixelRatioMediaQuery = null; @@ -5652,6 +5660,7 @@ function resumeCanvasGridWork() { nextTick(() => { attachCanvasResizeObserver(); observeDataGridTopbarWidth(); + observeTransposeViewport(); refreshGridScrollerMetrics(); observeGridHorizontalScrollbarScroller(); }); @@ -5704,6 +5713,7 @@ onUnmounted(() => { gridScrollbarsRuntime.dispose(); dataGridTopbarResizeObserver?.disconnect(); disconnectCellEditResizeObserver(); + disconnectTransposeViewportObserver(); stopGridHorizontalScrollbarDrag(); stopGridVerticalScrollbarDrag(); if (columnLayoutRefreshFrame) cancelAnimationFrame(columnLayoutRefreshFrame); @@ -6613,7 +6623,7 @@ function toggleKeyboardTranspose(): boolean { if (next.showTranspose) { closeCellDetails(); nextTick(updateTransposeViewport); - if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, "start"); } else if (!restoreGridScrollTopAfterTranspose) { scrollGridRowIntoView(requestedRowIndex); } @@ -7131,6 +7141,14 @@ watch(columnWidthDensity, () => { }); const transposePinnedWidthOverride = ref(null); const transposePinnedWidth = computed(() => transposePinnedWidthOverride.value ?? transposeFieldWidth(visibleColumns.value, { density: columnWidthDensity.value })); +const transposeEndSpacerWidth = computed(() => { + if (!multiRowTranspose.value || displayRowCount.value <= 0) return 0; + return transposeEndAlignmentSpacerWidth({ + viewportWidth: transposeViewportWidth.value, + pinnedWidth: transposePinnedWidth.value, + lastRecordWidth: getTransposeRecordWidth(displayRowCount.value - 1), + }); +}); const transposeRecordWindow = computed(() => visibleTransposeRecordWindow({ @@ -7156,7 +7174,7 @@ const activeTransposeRecordIndexes = computed(() => }), ); const transposeBeforeSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.beforeWidth : 0)); -const transposeAfterSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.afterWidth : 0)); +const transposeAfterSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.afterWidth + transposeEndSpacerWidth.value : 0)); const transposeRows = computed(() => { return buildVisibleTransposeRows({ columns: visibleColumns.value, @@ -7171,7 +7189,7 @@ const transposeRows = computed(() => { const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0); const transposeTotalWidth = computed(() => { const recordIndexes = multiRowTranspose.value ? Array.from({ length: displayRowCount.value }, (_, i) => i) : activeTransposeRecordIndexes.value; - return transposePinnedWidth.value + recordIndexes.reduce((sum, i) => sum + getTransposeRecordWidth(i), 0); + return transposePinnedWidth.value + recordIndexes.reduce((sum, i) => sum + getTransposeRecordWidth(i), 0) + (multiRowTranspose.value ? transposeEndSpacerWidth.value : 0); }); function transposeScrollElement(): HTMLElement | undefined { @@ -7194,6 +7212,24 @@ function updateTransposeViewport() { transposeViewportWidth.value = el.clientWidth; } +function disconnectTransposeViewportObserver() { + transposeViewportResizeObserver?.disconnect(); + transposeViewportResizeObserver = null; +} + +// Attach a ResizeObserver on the transpose scroller so clientWidth is re-measured +// when the container resizes (window resize, sidebar drag). RecycleScroller's own +// @resize event does not fire outside pageMode, so this is the only reliable hook. +function observeTransposeViewport() { + disconnectTransposeViewportObserver(); + if (typeof ResizeObserver === "undefined") return; + const el = transposeScrollElement(); + if (!el) return; + updateTransposeViewport(); + transposeViewportResizeObserver = new ResizeObserver(updateTransposeViewport); + transposeViewportResizeObserver.observe(el); +} + function onTransposeScroll() { updateTransposeViewport(); const el = transposeScrollElement(); @@ -7201,20 +7237,38 @@ function onTransposeScroll() { markGridScrolling(); } -function scrollTransposeRecordIntoView(rowIndex: number) { +function scrollTransposeRecordIntoView(rowIndex: number, alignment: TransposeScrollAlignment = "nearest") { nextTick(() => { const el = transposeScrollElement(); if (!el) return; - el.scrollLeft = transposeScrollLeftForRecord({ - recordIndex: rowIndex, - totalRecords: displayRowCount.value, - viewportWidth: el.clientWidth, - pinnedWidth: transposePinnedWidth.value, - recordWidth: estimatedTransposeRecordWidth(), - recordOffsets: transposeRecordOffsets.value, - currentScrollLeft: el.scrollLeft, - }); updateTransposeViewport(); + // The measured viewport determines the end spacer. Wait for that width to + // render before assigning scrollLeft, otherwise the browser clamps against + // the pre-spacer scrollWidth and the final record cannot align at the start. + nextTick(() => { + const measuredEl = transposeScrollElement(); + if (!measuredEl || displayRowCount.value <= 0) return; + if (alignment === "start" && !multiRowTranspose.value) { + measuredEl.scrollLeft = 0; + updateTransposeViewport(); + return; + } + const activeRecordIndex = Math.max(0, Math.min(displayRowCount.value - 1, rowIndex)); + const multiRow = multiRowTranspose.value; + const recordOffsets = multiRow ? transposeRecordOffsets.value : [0, getTransposeRecordWidth(activeRecordIndex)]; + measuredEl.scrollLeft = transposeScrollLeftForRecord({ + recordIndex: multiRow ? activeRecordIndex : 0, + totalRecords: multiRow ? displayRowCount.value : 1, + viewportWidth: measuredEl.clientWidth, + pinnedWidth: transposePinnedWidth.value, + recordWidth: multiRow ? estimatedTransposeRecordWidth() : getTransposeRecordWidth(activeRecordIndex), + recordOffsets, + currentScrollLeft: measuredEl.scrollLeft, + alignment, + endSpacerWidth: multiRow ? transposeEndSpacerWidth.value : 0, + }); + updateTransposeViewport(); + }); }); } @@ -7224,7 +7278,7 @@ function setMultiRowTranspose(value: boolean) { if (!showTranspose.value) return; nextTick(updateTransposeViewport); if (value && transposeRowIndex.value !== null) { - scrollTransposeRecordIntoView(transposeRowIndex.value); + scrollTransposeRecordIntoView(transposeRowIndex.value, "start"); } else { nextTick(() => { const el = transposeScrollElement(); @@ -7239,12 +7293,12 @@ function toggleMultiRowTranspose() { setMultiRowTranspose(!multiRowTranspose.value); } -function applyTransposeState(next: { showTranspose: boolean; transposeRowIndex: number | null }) { +function applyTransposeState(next: { showTranspose: boolean; transposeRowIndex: number | null }, alignment: TransposeScrollAlignment = "nearest") { showTranspose.value = next.showTranspose; transposeRowIndex.value = next.transposeRowIndex; if (next.showTranspose) { nextTick(updateTransposeViewport); - if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, alignment); } } @@ -7338,18 +7392,19 @@ function openContextTranspose() { if (next.showTranspose) { closeCellDetails(); nextTick(updateTransposeViewport); - if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, "start"); } } function toggleTranspose(rowIndex: number) { + const wasTransposeOpen = showTranspose.value; const next = nextTransposeState(showTranspose.value, transposeRowIndex.value, rowIndex); transposeRowIndex.value = next.transposeRowIndex; showTranspose.value = next.showTranspose; if (next.showTranspose) { closeCellDetails(); nextTick(updateTransposeViewport); - if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, wasTransposeOpen ? "nearest" : "start"); } else { scrollGridRowIntoView(rowIndex); } @@ -7408,10 +7463,16 @@ function transposeNav(delta: number) { watch(isTransposeMode, (active) => { if (active) { gridScrollLeftBeforeTranspose = gridScrollerElement()?.scrollLeft ?? gridHorizontalScrollLeft.value; - nextTick(updateTransposeViewport); + nextTick(() => { + updateTransposeViewport(); + observeTransposeViewport(); + }); return; } + // The transpose scroller is v-if-removed by isTransposeMode; drop the observer + // before the element unmounts so it never observes a detached node. + disconnectTransposeViewportObserver(); const scrollTopBeforeTranspose = restoreGridScrollTopAfterTranspose ? (gridScrollTopBeforeKeyboardTranspose ?? undefined) : undefined; restoreGridScrollTopAfterTranspose = false; gridScrollTopBeforeKeyboardTranspose = null; @@ -9074,6 +9135,7 @@ const gridContextMenuItems = computed(() => { :buffer="400" key-field="id" @scroll="onTransposeScroll" + @resize="updateTransposeViewport" >