diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9e9fb11c55..03d006150b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1910,15 +1910,15 @@ const { invertColumnVisibility, showColumn, persistColumnOrder, - moveDisplayableColumn, - resetColumnOrder, + moveDisplayableColumn: moveDisplayableColumnInLayout, + resetColumnOrder: resetColumnOrderInLayout, toggleAllNullColumns, resetColumnVisibility, onTableDataGridColumnOrderChanged, frozenColumnCount, freezeToColumn, - freezeSelectedColumns, - unfreezeAllColumns, + freezeSelectedColumns: freezeSelectedColumnsInLayout, + unfreezeAllColumns: unfreezeAllColumnsInLayout, } = useDataGridColumnLayoutState({ columns: computed(() => props.result.columns), sourceColumns: computed(() => props.sourceColumns), @@ -2136,13 +2136,36 @@ let gridScrollLeftBeforeTranspose = 0; let gridScrollTopBeforeKeyboardTranspose: number | null = null; let restoreGridScrollTopAfterTranspose = false; -function persistDraggedColumnOrder(indexes: number[]) { +function applyColumnOrderChange(change: () => void) { const previousVisibleColumnIndexes = [...visibleColumnIndexes.value]; - persistColumnOrder(indexes); - selection.remapColumnSelection( - previousVisibleColumnIndexes, - indexes.filter((index) => !hiddenColumnIndexes.value.has(index)), - ); + change(); + const nextVisibleColumnIndexes = [...visibleColumnIndexes.value]; + if (previousVisibleColumnIndexes.length === nextVisibleColumnIndexes.length && previousVisibleColumnIndexes.every((index, position) => index === nextVisibleColumnIndexes[position])) return; + selection.reconcileSelectionAfterColumnReorder(previousVisibleColumnIndexes, nextVisibleColumnIndexes); +} + +function onSynchronizedTableDataGridColumnOrderChanged(event: Event) { + applyColumnOrderChange(() => onTableDataGridColumnOrderChanged(event)); +} + +function persistDraggedColumnOrder(indexes: number[]) { + applyColumnOrderChange(() => persistColumnOrder(indexes)); +} + +function moveDisplayableColumn(fromDisplayableIndex: number, toDisplayableIndex: number) { + applyColumnOrderChange(() => moveDisplayableColumnInLayout(fromDisplayableIndex, toDisplayableIndex)); +} + +function resetColumnOrder() { + applyColumnOrderChange(resetColumnOrderInLayout); +} + +function freezeSelectedColumns(selectedVisibleColumnIndexes: number[]) { + applyColumnOrderChange(() => freezeSelectedColumnsInLayout(selectedVisibleColumnIndexes)); +} + +function unfreezeAllColumns() { + applyColumnOrderChange(unfreezeAllColumnsInLayout); } const { @@ -6304,7 +6327,7 @@ onMounted(() => { window.addEventListener("resize", refreshDataGridViewportMetrics); window.visualViewport?.addEventListener("resize", refreshDataGridViewportMetrics); window.addEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); - window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); + window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); window.addEventListener("blur", clearInternalClipboardCopy); document.addEventListener("visibilitychange", clearInternalClipboardCopy); }); @@ -6326,7 +6349,7 @@ onUnmounted(() => { window.removeEventListener("resize", refreshDataGridViewportMetrics); window.visualViewport?.removeEventListener("resize", refreshDataGridViewportMetrics); window.removeEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); - window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); + window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); window.removeEventListener("blur", clearInternalClipboardCopy); document.removeEventListener("visibilitychange", clearInternalClipboardCopy); }); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridColumnOrderSelection.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridColumnOrderSelection.spec.ts new file mode 100644 index 0000000000..d73b8ead5e --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridColumnOrderSelection.spec.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +describe("DataGrid synchronized column order selection", () => { + it("reconciles the active selection when another grid changes the table order", () => { + expect(dataGridSource).toContain("applyColumnOrderChange(() => onTableDataGridColumnOrderChanged(event));"); + expect(dataGridSource).toContain("window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);"); + expect(dataGridSource).toContain("window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts index 93892647ca..80949f4317 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts @@ -45,7 +45,7 @@ describe("useDataGridSelection", () => { selection.selectColumn(0); selection.selectColumn(2, rowEvent({ meta: true })); - selection.remapColumnSelection([0, 1, 2], [1, 2, 0]); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); expect(selection.selectedColumnIndexes.value).toEqual(new Set([1, 2])); @@ -53,6 +53,92 @@ describe("useDataGridSelection", () => { expect(selection.selectedColumnIndexes.value).toEqual(new Set([0, 1, 2])); }); + it("moves a single-column rectangular cell selection with its column after reordering", () => { + const selection = createSelection(); + + selection.selectSingleCell(1, 0); + selection.extendCellSelectionTo(2, 0); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); + + expect(selection.selectedRange.value).toEqual({ startRow: 1, endRow: 2, startCol: 2, endCol: 2 }); + expect(selection.cellIsSelected(1, 2)).toBe(true); + expect(selection.cellIsSelected(2, 0)).toBe(false); + }); + + it("moves a discrete single-column cell selection with its column after reordering", () => { + const selection = createSelection(); + + selection.selectedCellKeys.value = new Set(["0:0", "2:0"]); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); + + expect(selection.selectedCellKeys.value).toEqual(new Set(["0:2", "2:2"])); + expect(selection.cellIsSelected(0, 2)).toBe(true); + expect(selection.cellIsSelected(2, 0)).toBe(false); + }); + + it("keeps a multi-column rectangular cell selection when its columns remain contiguous", () => { + const selection = createSelection(); + + selection.selectSingleCell(1, 0); + selection.extendCellSelectionTo(2, 1); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [2, 0, 1]); + + expect(selection.selectedRange.value).toEqual({ startRow: 1, endRow: 2, startCol: 1, endCol: 2 }); + expect(selection.cellIsSelected(1, 1)).toBe(true); + expect(selection.cellIsSelected(2, 2)).toBe(true); + expect(selection.cellIsSelected(1, 0)).toBe(false); + }); + + it.each([ + { edge: "left", nextColumnIndexes: [1, 0, 2] }, + { edge: "right", nextColumnIndexes: [0, 2, 1] }, + ])("keeps the whole rectangular selection when an inner column moves to its $edge edge", ({ nextColumnIndexes }) => { + const selection = createSelection(); + + selection.selectSingleCell(1, 0); + selection.extendCellSelectionTo(2, 2); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], nextColumnIndexes); + + expect(selection.selectedRange.value).toEqual({ startRow: 1, endRow: 2, startCol: 0, endCol: 2 }); + expect(selection.cellIsSelected(1, nextColumnIndexes.indexOf(1))).toBe(true); + expect(selection.selectedCellCount.value).toBe(6); + }); + + it("clears a multi-column rectangular cell selection when its columns split apart", () => { + const selection = createSelection(); + + selection.selectSingleCell(1, 0); + selection.extendCellSelectionTo(2, 1); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); + + expect(selection.selectedRange.value).toBeNull(); + expect(selection.selectedCellKeys.value).toEqual(new Set()); + expect(selection.hasCellSelection.value).toBe(false); + }); + + it("keeps a discrete multi-column cell selection when its columns become contiguous", () => { + const selection = createSelection(); + + selection.selectedCellKeys.value = new Set(["0:0", "0:2", "2:0", "2:2"]); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); + + expect(selection.selectedCellKeys.value).toEqual(new Set(["0:2", "0:1", "2:2", "2:1"])); + expect(selection.cellIsSelected(0, 1)).toBe(true); + expect(selection.cellIsSelected(2, 2)).toBe(true); + expect(selection.cellIsSelected(0, 0)).toBe(false); + }); + + it("clears a discrete multi-column cell selection when its columns split apart", () => { + const selection = createSelection(); + + selection.selectedCellKeys.value = new Set(["0:0", "0:1", "2:0", "2:1"]); + selection.reconcileSelectionAfterColumnReorder([0, 1, 2], [1, 2, 0]); + + expect(selection.selectedRange.value).toBeNull(); + expect(selection.selectedCellKeys.value).toEqual(new Set()); + expect(selection.hasCellSelection.value).toBe(false); + }); + it("invalidates synthetic context state for ordinary, Ctrl, and Cmd cell selection", () => { const originalDocument = globalThis.document; const fakeDocument = { diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 7a9802403a..bd32ec9306 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -247,13 +247,63 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { lastClickedColumnIndex.value = colIndex; } - function remapColumnSelection(previousColumnIndexes: readonly number[], nextColumnIndexes: readonly number[]) { - const selectedColumns = new Set([...selectedColumnIndexes.value].map((index) => previousColumnIndexes[index]).filter((index): index is number => index !== undefined)); - selectedColumnIndexes.value = new Set(nextColumnIndexes.flatMap((columnIndex, index) => (selectedColumns.has(columnIndex) ? [index] : []))); - + function reconcileSelectionAfterColumnReorder(previousColumnIndexes: readonly number[], nextColumnIndexes: readonly number[]) { const lastClickedColumn = lastClickedColumnIndex.value === null ? undefined : previousColumnIndexes[lastClickedColumnIndex.value]; const nextLastClickedColumnIndex = lastClickedColumn === undefined ? -1 : nextColumnIndexes.indexOf(lastClickedColumn); lastClickedColumnIndex.value = nextLastClickedColumnIndex >= 0 ? nextLastClickedColumnIndex : null; + + if (hasColumnSelection.value) { + const selectedColumns = new Set([...selectedColumnIndexes.value].map((index) => previousColumnIndexes[index]).filter((index): index is number => index !== undefined)); + selectedColumnIndexes.value = new Set(nextColumnIndexes.flatMap((columnIndex, index) => (selectedColumns.has(columnIndex) ? [index] : []))); + return; + } + + const selectedCellColumnIndexes = cellSelectionColumnIndexes(); + const remappedCellColumnIndexes = selectedCellColumnIndexes.map((index) => { + const selectedColumn = previousColumnIndexes[index]; + return selectedColumn === undefined ? -1 : nextColumnIndexes.indexOf(selectedColumn); + }); + const sortedRemappedCellColumnIndexes = [...remappedCellColumnIndexes].sort((a, b) => a - b); + const selectionRemainsContiguous = sortedRemappedCellColumnIndexes.length > 0 && sortedRemappedCellColumnIndexes.every((index, position) => index >= 0 && index === sortedRemappedCellColumnIndexes[0]! + position); + if (selectionRemainsContiguous) { + const remappedColumnIndexByPreviousIndex = new Map(selectedCellColumnIndexes.map((index, position) => [index, remappedCellColumnIndexes[position]!])); + if (selectedCellKeys.value.size > 0) { + selectedCellKeys.value = new Set( + [...selectedCellKeys.value].map((key) => { + const position = parseCellKey(key)!; + return cellKey(position.rowIndex, remappedColumnIndexByPreviousIndex.get(position.colIndex)!); + }), + ); + if (selectionAnchor.value) selectionAnchor.value = { ...selectionAnchor.value, colIndex: remappedColumnIndexByPreviousIndex.get(selectionAnchor.value.colIndex)! }; + if (selectionFocus.value) selectionFocus.value = { ...selectionFocus.value, colIndex: remappedColumnIndexByPreviousIndex.get(selectionFocus.value.colIndex)! }; + } else if (selectionAnchor.value && selectionFocus.value) { + const selectionStartsLeft = selectionAnchor.value.colIndex <= selectionFocus.value.colIndex; + const startCol = sortedRemappedCellColumnIndexes[0]!; + const endCol = sortedRemappedCellColumnIndexes[sortedRemappedCellColumnIndexes.length - 1]!; + selectionAnchor.value = { ...selectionAnchor.value, colIndex: selectionStartsLeft ? startCol : endCol }; + selectionFocus.value = { ...selectionFocus.value, colIndex: selectionStartsLeft ? endCol : startCol }; + } + return; + } + + clearCellSelection(); + lastClickedColumnIndex.value = null; + } + + function cellSelectionColumnIndexes(): number[] { + if (selectedCellKeys.value.size > 0) { + const selectedColumnIndexes = new Set(); + for (const key of selectedCellKeys.value) { + const position = parseCellKey(key); + if (!position) return []; + selectedColumnIndexes.add(position.colIndex); + } + return [...selectedColumnIndexes]; + } + + const range = selectedRange.value; + if (!range) return []; + return Array.from({ length: range.endCol - range.startCol + 1 }, (_, index) => range.startCol + index); } function selectAllCells() { @@ -588,7 +638,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { selectRow, selectColumn, selectColumns, - remapColumnSelection, + reconcileSelectionAfterColumnReorder, selectAllCells, extendCellSelectionTo, finishCellSelection,