diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 72a928a742..306d2b1456 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2934,13 +2934,13 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (clipboardShortcut(event, "x")) { - if (!props.editable || !hasCellSelection.value) return; + if (!props.editable || !selectedRange.value) return; event.preventDefault(); cutSelection(); return; } if (clipboardShortcut(event, "v")) { - if (!props.editable || !hasCellSelection.value) return; + if (!props.editable || !selectedRange.value) return; event.preventDefault(); await pasteClipboardIntoSelection(); } @@ -3155,6 +3155,23 @@ function selectTransposeRecord(rowIndex: number, event?: MouseEvent) { gridRef.value?.focus({ preventScroll: true }); } +function transposeRecordIsSelected(rowIndex: number): boolean { + const item = displayItems.value[rowIndex]; + return !!item && isRowSelected(item.id); +} + +function transposeRecordUsesSelectionVisual(rowIndex: number): boolean { + return hasRowSelection.value && transposeRecordIsSelected(rowIndex); +} + +function transposeRecordUsesActiveHighlight(rowIndex: number): boolean { + return transposeRowIndex.value === rowIndex; +} + +function transposeRecordUsesFramedHeader(rowIndex: number): boolean { + return hasRowSelection.value && transposeRecordIsSelected(rowIndex) && !hasCellSelection.value; +} + function moveTransposeRecordSelection(delta: number): boolean { if (!isTransposeMode.value || displayItems.value.length === 0) return false; const current = transposeRowIndex.value ?? 0; @@ -4329,11 +4346,16 @@ const gridContextMenuItems = computed(() => { v-for="recordIndex in visibleTransposeRecordIndexes" :key="`transpose-head-${recordIndex}`" class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums relative" - :class=" - recordIndex === transposeRowIndex - ? 'bg-primary/15 text-primary font-semibold' - : 'bg-[rgb(239_239_239)] dark:bg-muted' - " + :class="{ + 'transpose-record-header-selected text-primary font-semibold': + transposeRecordUsesFramedHeader(recordIndex), + 'transpose-record-header-active text-primary': + transposeRecordUsesActiveHighlight(recordIndex) && + !transposeRecordUsesFramedHeader(recordIndex), + 'bg-[rgb(239_239_239)] dark:bg-muted': + !transposeRecordUsesActiveHighlight(recordIndex) && + !transposeRecordUsesFramedHeader(recordIndex), + }" :style="{ width: `${transposeRecordWidth}px` }" @click="selectTransposeRecord(recordIndex, $event)" @contextmenu="selectTransposeRecord(recordIndex, $event)" @@ -4372,12 +4394,26 @@ const gridContextMenuItems = computed(() => { 'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex], - 'bg-primary/10': cell.recordIndex === transposeRowIndex, + 'row-cell-selected': + transposeRecordUsesSelectionVisual(cell.recordIndex) && + !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && + !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex], + 'row-cell-selected-dirty': + transposeRecordUsesSelectionVisual(cell.recordIndex) && + !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && + displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex], + 'bg-primary/15': + transposeRecordUsesActiveHighlight(cell.recordIndex) && + !transposeRecordUsesSelectionVisual(cell.recordIndex) && + !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && + !transposeCellIsSelected(cell.recordIndex, cell.valueIndex), 'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex], - 'cursor-text hover:bg-accent/50': canEditCellItem( - displayItems[cell.recordIndex], - cell.valueIndex, - ), + 'cursor-text': canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex), + 'hover:bg-accent/50': + canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && + !transposeRecordUsesSelectionVisual(cell.recordIndex) && + !transposeRecordUsesActiveHighlight(cell.recordIndex) && + !transposeCellIsSelected(cell.recordIndex, cell.valueIndex), }" :style="{ width: `${transposeRecordWidth}px` }" :title="cell.display" @@ -5751,6 +5787,15 @@ const gridContextMenuItems = computed(() => { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent); } +.transpose-record-header-selected { + background-color: color-mix(in oklab, var(--primary) 25%, var(--background)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent); +} + +.transpose-record-header-active { + background-color: color-mix(in oklab, var(--primary) 25%, var(--background)); +} + .cell-selected-dirty { background-color: color-mix(in oklab, oklch(0.8 0.15 85) 30%, color-mix(in oklab, var(--primary) 18%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent); diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 79b5f1ed3f..7325d7c40c 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -1,10 +1,11 @@ import { ref, computed, type ComputedRef, type Ref } from "vue"; import { allCellsSelectionRange, - columnSelectionRange, + extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, + normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, @@ -41,10 +42,12 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const isSelectingCells = ref(false); const selectedRowIds = ref>(new Set()); + const selectedColumnIndexes = ref>(new Set()); const lastClickedRowIndex = ref(null); const lastClickedColumnIndex = ref(null); const hasRowSelection = computed(() => selectedRowIds.value.size > 0); const selectedRowCount = computed(() => selectedRowIds.value.size); + const hasColumnSelection = computed(() => selectedColumnIndexes.value.size > 0); const selectedRange = computed(() => { if (!selectionAnchor.value || !selectionFocus.value) return null; @@ -53,9 +56,12 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data)); - const selectedCells = computed(() => - extractSelection(columns.value, visibleSelectionRows.value, selectedRange.value), - ); + const selectedCells = computed(() => { + if (hasColumnSelection.value) { + return extractColumnsSelection(columns.value, visibleSelectionRows.value, selectedColumnIndexes.value); + } + return extractSelection(columns.value, visibleSelectionRows.value, selectedRange.value); + }); const selectedCellCount = computed(() => selectedCells.value.columns.length * selectedCells.value.rows.length); const hasCellSelection = computed(() => selectedCellCount.value > 0); @@ -63,6 +69,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { function clearCellSelection() { selectionAnchor.value = null; selectionFocus.value = null; + selectedColumnIndexes.value = new Set(); isSelectingCells.value = false; } @@ -91,12 +98,17 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; } - function selectColumns(startCol: number, endCol: number) { - const range = columnSelectionRange(displayItems.value.length, startCol, endCol); - if (!range) return; + function selectColumns(startCol: number, endCol: number, options?: { merge?: boolean }) { + const normalizedColumns = normalizeSelectedColumnIndexes( + Array.from({ length: Math.abs(endCol - startCol) + 1 }, (_, index) => Math.min(startCol, endCol) + index), + ); + if (normalizedColumns.length === 0 || displayItems.value.length <= 0) return; clearRowSelection(); - selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; - selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; + selectionAnchor.value = null; + selectionFocus.value = null; + const next = options?.merge ? new Set(selectedColumnIndexes.value) : new Set(); + normalizedColumns.forEach((index) => next.add(index)); + selectedColumnIndexes.value = next; focusGridWithoutScrolling(); } @@ -104,9 +116,19 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const isShift = Boolean(event?.shiftKey); const isMeta = Boolean(event?.metaKey || event?.ctrlKey); if (isShift && lastClickedColumnIndex.value !== null) { - selectColumns(lastClickedColumnIndex.value, colIndex); - } else if (isMeta && selectedRange.value) { - selectColumns(Math.min(selectedRange.value.startCol, colIndex), Math.max(selectedRange.value.endCol, colIndex)); + selectColumns(lastClickedColumnIndex.value, colIndex, { merge: hasColumnSelection.value }); + } else if (isMeta) { + clearRowSelection(); + selectionAnchor.value = null; + selectionFocus.value = null; + const next = new Set(selectedColumnIndexes.value); + if (next.has(colIndex)) { + next.delete(colIndex); + } else { + next.add(colIndex); + } + selectedColumnIndexes.value = next; + focusGridWithoutScrolling(); } else { selectColumns(colIndex, colIndex); } @@ -117,6 +139,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const range = allCellsSelectionRange(displayItems.value.length, columns.value.length); if (!range) return; clearRowSelection(); + selectedColumnIndexes.value = new Set(); lastClickedColumnIndex.value = null; selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; @@ -177,6 +200,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { if (editingCell.value) return; event.preventDefault(); focusGridWithoutScrolling(); + clearCellSelection(); selectSingleCell(rowIndex, colIndex); isSelectingCells.value = true; lastClickedColumnIndex.value = colIndex; @@ -200,6 +224,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { event.preventDefault(); focusGridWithoutScrolling(); clearRowSelection(); + if (hasColumnSelection.value) clearCellSelection(); extendCellSelectionTo(rowIndex, colIndex); return; } @@ -213,10 +238,13 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { } function cellIsSelected(rowIndex: number, colIndex: number): boolean { + if (hasColumnSelection.value) + return rowIndex >= 0 && rowIndex < displayItems.value.length && selectedColumnIndexes.value.has(colIndex); return isCellInSelection(rowIndex, colIndex, selectedRange.value); } function columnIsSelected(colIndex: number): boolean { + if (hasColumnSelection.value) return selectedColumnIndexes.value.has(colIndex); const range = selectedRange.value; if (!range) return false; return ( @@ -255,9 +283,11 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { columnIsSelected, selectedRangeStart, selectedRowIds, + selectedColumnIndexes, lastClickedRowIndex, hasRowSelection, selectedRowCount, + hasColumnSelection, clearRowSelection, handleRowClick, handleDataCellMousedown, diff --git a/apps/desktop/src/lib/gridSelection.ts b/apps/desktop/src/lib/gridSelection.ts index ed722b6bcd..3275be7033 100644 --- a/apps/desktop/src/lib/gridSelection.ts +++ b/apps/desktop/src/lib/gridSelection.ts @@ -17,6 +17,10 @@ export interface SelectionData { rows: GridCellValue[][]; } +export function normalizeSelectedColumnIndexes(columnIndexes: Iterable): number[] { + return [...new Set(columnIndexes)].filter((index) => index >= 0).sort((a, b) => a - b); +} + export function normalizeSelectionRange(anchor: CellPosition, focus: CellPosition): CellSelectionRange { return { startRow: Math.min(anchor.rowIndex, focus.rowIndex), @@ -81,6 +85,22 @@ export function extractSelection( return { columns: selectedColumns, rows: selectedRows }; } +export function extractColumnsSelection( + columns: readonly string[], + rows: readonly GridCellValue[][], + selectedColumnIndexes: Iterable, +): SelectionData { + const normalizedIndexes = normalizeSelectedColumnIndexes(selectedColumnIndexes).filter( + (index) => index < columns.length, + ); + if (normalizedIndexes.length === 0) return { columns: [], rows: [] }; + + return { + columns: normalizedIndexes.map((index) => columns[index]), + rows: rows.map((row) => normalizedIndexes.map((index) => row[index] ?? null)), + }; +} + function displayValue(value: GridCellValue): string { if (value === null) return "NULL"; if (typeof value === "boolean") return value ? "true" : "false"; diff --git a/packages/app-tests/dataGridSelectionShortcuts.test.ts b/packages/app-tests/dataGridSelectionShortcuts.test.ts index 757d813a83..81fc098c71 100644 --- a/packages/app-tests/dataGridSelectionShortcuts.test.ts +++ b/packages/app-tests/dataGridSelectionShortcuts.test.ts @@ -15,6 +15,11 @@ test("data grid wires whole table, row, and column selection gestures", () => { assert.match(selectionSource, /function selectAllCells\(\)/); assert.match(selectionSource, /function selectColumn\(colIndex: number, event\?: MouseEvent\)/); assert.match(selectionSource, /lastClickedColumnIndex/); + assert.match(selectionSource, /selectedColumnIndexes/); + assert.match(selectionSource, /event\?\.metaKey \|\| event\?\.ctrlKey/); + assert.match(selectionSource, /next\.has\(colIndex\)/); + assert.match(selectionSource, /clearCellSelection\(\);\s+selectSingleCell\(rowIndex, colIndex\);/); + assert.match(selectionSource, /if \(hasColumnSelection\.value\) clearCellSelection\(\);/); }); test("data grid intercepts copy and select-all shortcuts for grid selections", () => { @@ -45,6 +50,18 @@ test("transpose cells reuse grid cell selection and details", () => { test("transpose record headers copy selected records as rows", () => { assert.match(source, /function selectTransposeRecord\(rowIndex: number, event\?: MouseEvent\)/); + assert.match(source, /function transposeRecordUsesSelectionVisual\(rowIndex: number\): boolean/); + assert.match(source, /function transposeRecordUsesActiveHighlight\(rowIndex: number\): boolean/); + assert.match(source, /function transposeRecordUsesFramedHeader\(rowIndex: number\): boolean/); + assert.match(source, /transposeRecordUsesSelectionVisual\(/); + assert.match(source, /transposeRecordUsesActiveHighlight\(/); + assert.match(source, /transposeRecordUsesFramedHeader\(/); + assert.match(source, /'transpose-record-header-selected text-primary font-semibold':/); + assert.match(source, /'transpose-record-header-active text-primary':/); + assert.match(source, /\.transpose-record-header-selected \{/); + assert.match(source, /\.transpose-record-header-active \{/); + assert.match(source, /'row-cell-selected':/); + assert.match(source, /'row-cell-selected-dirty':/); assert.match(source, /handleRowClick\(rowIndex, item\.id, event\)/); assert.match(source, /@click="selectTransposeRecord\(recordIndex, \$event\)"/); assert.match(source, /@contextmenu="selectTransposeRecord\(recordIndex, \$event\)"/); diff --git a/packages/app-tests/gridSelection.test.ts b/packages/app-tests/gridSelection.test.ts index 4b5c43edcf..cb98ccb2db 100644 --- a/packages/app-tests/gridSelection.test.ts +++ b/packages/app-tests/gridSelection.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { allCellsSelectionRange, columnSelectionRange, + extractColumnsSelection, extractSelection, formatSelectionAsCsv, formatSelectionAsJson, @@ -44,6 +45,23 @@ test("extracts selection rows and columns from a rectangular range", () => { ]); }); +test("extracts non-contiguous columns in display order", () => { + const selection = extractColumnsSelection( + ["id", "name", "active", "email"], + [ + [1, "Ada", true, "ada@example.com"], + [2, "Linus", false, "linus@example.com"], + ], + [3, 1, 3], + ); + + assert.deepEqual(selection.columns, ["name", "email"]); + assert.deepEqual(selection.rows, [ + ["Ada", "ada@example.com"], + ["Linus", "linus@example.com"], + ]); +}); + test("builds whole row, column, and table selection ranges", () => { assert.deepEqual(rowSelectionRange(2, 4), { startRow: 2, endRow: 2, startCol: 0, endCol: 3 }); assert.deepEqual(rowSelectionRange(4, 4, 2), { startRow: 2, endRow: 4, startCol: 0, endCol: 3 });