feat: improve data grid selection and copy

This commit is contained in:
t8y2
2026-05-26 18:26:31 +08:00
parent f9b58993b4
commit 0e67f19117
12 changed files with 392 additions and 83 deletions
+136 -44
View File
@@ -1925,9 +1925,14 @@ const {
hasCellSelection,
clearCellSelection,
selectSingleCell,
selectRow,
selectColumn,
selectAllCells,
extendCellSelectionTo,
finishCellSelection,
extendCellSelection,
cellIsSelected,
columnIsSelected,
selectedRangeStart,
selectedRowIds,
hasRowSelection,
@@ -1951,6 +1956,12 @@ const multiRowCount = computed(() => {
});
const isMultiRow = computed(() => multiRowCount.value > 1);
const copyRowJsonLabel = computed(() => {
if (isTransposeMode.value) {
return isMultiRow.value ? t("grid.copyColumnsJson", { count: multiRowCount.value }) : t("grid.copyColumnJson");
}
return isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow");
});
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
@@ -2002,6 +2013,9 @@ function isRowActive(index: number): boolean {
if (item && isRowSelected(item.id)) return true;
const range = selectedRange.value;
if (!range) return false;
const coversAllVisibleRows = range.startRow === 0 && range.endRow >= displayItems.value.length - 1;
const coversAllVisibleColumns = range.startCol === 0 && range.endCol >= visibleColumnCount.value - 1;
if (coversAllVisibleRows && !coversAllVisibleColumns) return false;
return index >= range.startRow && index <= range.endRow;
}
@@ -2495,6 +2509,7 @@ const {
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectedRowsTsv,
exportCsv,
exportJson,
exportMarkdown,
@@ -2530,6 +2545,37 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number,
showCellDetails(rowIndex, actualColIdx);
}
function transposeCellIsSelected(rowIndex: number, actualColIdx: number) {
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
return visibleColIdx >= 0 && cellIsSelected(rowIndex, visibleColIdx);
}
function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) {
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
}
function selectTransposeCell(rowIndex: number, actualColIdx: number, event: MouseEvent) {
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
if (visibleColIdx < 0) return;
contextHeaderColumn.value = null;
clearRowSelection();
if (event.shiftKey || event.metaKey || event.ctrlKey) {
extendCellSelectionTo(rowIndex, visibleColIdx);
} else {
selectSingleCell(rowIndex, visibleColIdx);
}
transposeRowIndex.value = rowIndex;
showCellDetails(rowIndex, actualColIdx);
gridRef.value?.focus({ preventScroll: true });
}
function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: MouseEvent) {
selectTransposeCell(rowIndex, actualColIdx, event);
const item = displayItems.value[rowIndex];
contextCell.value = item ? { rowId: item.id, rowIndex, col: actualColIdx } : null;
void prefetchCopyStatements();
}
watch([selectedRange, showCellDetail, isEditingDetail], () => {
const selectedCell = currentSelectedCellPosition();
const target = linkedCellDetailTarget({
@@ -2609,6 +2655,23 @@ function scrollCellIntoView(rowIndex: number, colIndex: number) {
});
}
function scrollGridRowIntoView(rowIndex: number) {
const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
nextTick(() => {
const scroller = scrollerRef.value;
if (scroller && !(scroller instanceof HTMLElement)) {
scroller.scrollToItem?.(target);
scroller.scrollToPosition?.(target * 26);
} else if (scroller instanceof HTMLElement) {
scroller.scrollTop = target * 26;
}
requestAnimationFrame(() => {
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${target}"]`);
rowEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
});
}
function currentTransposeRequestedRowIndex(): number {
const position = currentSelectedCellPosition();
if (position) return position.rowIndex;
@@ -2618,10 +2681,11 @@ function currentTransposeRequestedRowIndex(): number {
function toggleKeyboardTranspose(): boolean {
if (displayItems.value.length === 0) return false;
const requestedRowIndex = currentTransposeRequestedRowIndex();
const next = nextKeyboardTransposeState({
showTranspose: showTranspose.value,
transposeRowIndex: transposeRowIndex.value,
requestedRowIndex: currentTransposeRequestedRowIndex(),
requestedRowIndex,
rowIds: displayItems.value.map((item) => item.id),
selectedRowIds: selectedRowIds.value,
selectedRange: selectedRange.value,
@@ -2632,6 +2696,8 @@ function toggleKeyboardTranspose(): boolean {
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
} else {
scrollGridRowIntoView(requestedRowIndex);
}
return true;
}
@@ -2746,9 +2812,23 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (clipboardShortcut(event, "c")) {
if (!hasCellSelection.value) return;
if (!hasCellSelection.value && !hasRowSelection.value) return;
event.preventDefault();
copySelectionTsv();
if (isTransposeMode.value && hasRowSelection.value) {
copyRow();
return;
}
if (hasCellSelection.value) {
copySelectionTsv();
} else {
copySelectedRowsTsv();
}
return;
}
if (clipboardShortcut(event, "a")) {
if (!hasData.value) return;
event.preventDefault();
selectAllCells();
return;
}
if (clipboardShortcut(event, "x")) {
@@ -2905,9 +2985,17 @@ function onTransposeRecordResizeStart(event: MouseEvent) {
document.addEventListener("mouseup", onUp);
}
function closeTranspose() {
function currentTransposeViewportRowIndex(): number {
if (displayItems.value.length === 0) return 0;
const rowIndex = transposeRowIndex.value ?? transposeRecordWindow.value.start;
return Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
}
function closeTranspose(scrollToCurrentRecord = true) {
const rowIndex = currentTransposeViewportRowIndex();
showTranspose.value = false;
transposeRowIndex.value = null;
if (scrollToCurrentRecord) scrollGridRowIntoView(rowIndex);
}
function openContextTranspose() {
@@ -2941,12 +3029,27 @@ function toggleTranspose(rowIndex: number) {
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
} else {
scrollGridRowIntoView(rowIndex);
}
}
function selectTransposeRecord(rowIndex: number) {
function selectTransposeRecord(rowIndex: number, event?: MouseEvent) {
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return;
transposeRowIndex.value = rowIndex;
contextHeaderColumn.value = null;
const item = displayItems.value[rowIndex];
if (item) {
if (event) {
handleRowClick(rowIndex, item.id, event);
} else {
selectedRowIds.value = new Set([item.id]);
selection.lastClickedRowIndex.value = rowIndex;
selectRow(rowIndex);
}
contextCell.value = { rowId: item.id, rowIndex, col: -1 };
void prefetchCopyStatements();
}
gridRef.value?.focus({ preventScroll: true });
}
@@ -2984,7 +3087,7 @@ watch(
nextTransposeStateForRecordCount(showTranspose.value, transposeRowIndex.value, displayItems.value.length),
);
} else {
closeTranspose();
closeTranspose(false);
}
exitTransaction();
},
@@ -3329,37 +3432,8 @@ onUnmounted(() => {
clearInterval(_loadingTimer);
});
const SQL_KEYWORDS =
/\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi;
const highlightedDdlContent = computed(() => highlight(ddlContent.value));
function highlightSql(sql: string): string {
const tokens: string[] = [];
let rest = sql;
const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g;
let match: RegExpExecArray | null;
let last = 0;
while ((match = re.exec(rest)) !== null) {
if (match.index > last) tokens.push(escapeAndHighlightKeywords(rest.slice(last, match.index)));
const q = match[1];
const escaped = q.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const cls = q.startsWith('"') ? "ddl-ident" : "ddl-str";
tokens.push(`<span class="${cls}">${escaped}</span>`);
last = re.lastIndex;
}
if (last < rest.length) tokens.push(escapeAndHighlightKeywords(rest.slice(last)));
return tokens.join("");
}
function escapeAndHighlightKeywords(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(SQL_KEYWORDS, '<span class="ddl-kw">$1</span>');
}
defineExpose({
useTransaction,
transactionActive,
@@ -3920,7 +3994,8 @@ defineExpose({
: 'bg-[rgb(239_239_239)] dark:bg-muted'
"
:style="{ width: `${transposeRecordWidth}px` }"
@click="selectTransposeRecord(recordIndex)"
@click="selectTransposeRecord(recordIndex, $event)"
@contextmenu="selectTransposeRecord(recordIndex, $event)"
>
{{ recordIndex + 1 }}
<div
@@ -3950,6 +4025,12 @@ defineExpose({
class="relative shrink-0 border-r border-border/70 px-2 py-1.5 font-mono truncate"
:class="{
'text-muted-foreground italic': cell.isNull,
'cell-selected':
transposeCellIsSelected(cell.recordIndex, cell.valueIndex) &&
!displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty':
transposeCellIsSelected(cell.recordIndex, cell.valueIndex) &&
displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-primary/10': cell.recordIndex === transposeRowIndex,
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cursor-text hover:bg-accent/50': canEditCellItem(
@@ -3959,7 +4040,10 @@ defineExpose({
}"
:style="{ width: `${transposeRecordWidth}px` }"
:title="cell.display"
@click="selectTransposeRecord(cell.recordIndex)"
@click="selectTransposeCell(cell.recordIndex, cell.valueIndex, $event)"
@mouseenter="onTransposeCellMouseenter(cell.recordIndex, cell.valueIndex)"
@mouseleave="onCellMouseleave(cell.recordIndex, cell.valueIndex)"
@contextmenu="onTransposeCellContext(cell.recordIndex, cell.valueIndex, $event)"
@dblclick.stop="
canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) &&
startEdit(displayItems[cell.recordIndex].id, cell.valueIndex)
@@ -3992,6 +4076,15 @@ defineExpose({
</template>
<template v-else>
{{ cell.display }}
<button
v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)"
class="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showCellDetails(cell.recordIndex, cell.valueIndex)"
>
<Info class="h-3 w-3" />
</button>
</template>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
@@ -4007,8 +4100,9 @@ defineExpose({
>
<div class="flex text-xs font-semibold text-foreground" :style="{ width: 'var(--header-total-w)' }">
<div
class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none"
class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none cursor-pointer hover:bg-accent/60"
:style="{ width: 'var(--row-num-w)' }"
@click="selectAllCells"
>
#
</div>
@@ -4018,18 +4112,16 @@ defineExpose({
class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-accent/60 select-none relative overflow-hidden"
:class="{
'bg-primary/15 ring-1 ring-inset ring-primary/40':
highlightedColumnIndex === actualColumnIndex(colIdx),
highlightedColumnIndex === actualColumnIndex(colIdx) || columnIsSelected(colIdx),
}"
:style="{ width: `var(--col-w-${colIdx})` }"
:data-grid-column-index="actualColumnIndex(colIdx)"
@click="selectColumn(colIdx, $event)"
@contextmenu="onHeaderContext(col)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
<span
class="min-w-0 truncate cursor-pointer leading-4"
@click="toggleSort(col, actualColumnIndex(colIdx))"
>
<span class="min-w-0 truncate leading-4">
{{ col }}
</span>
<span
@@ -5158,7 +5250,7 @@ defineExpose({
<ContextMenuSubContent class="w-max max-w-[min(80vw,18rem)]">
<ContextMenuItem v-if="contextColumn" @click="copyCell">{{ t("grid.copyCell") }}</ContextMenuItem>
<ContextMenuItem @click="copyRow">
{{ isMultiRow ? t("grid.copyRows", { count: multiRowCount }) : t("grid.copyRow") }}
{{ copyRowJsonLabel }}
</ContextMenuItem>
<ContextMenuItem :disabled="!canCopyPreparedInsert(false)" @click="copyRowAsInsert">
{{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }}
@@ -320,6 +320,26 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await copyText(formatSelectionAsSqlInList(selectedCells.value));
}
async function copySelectedRowsTsv() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)).map((item) => item.data);
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }));
}
function rowToJsonObject(item: RowItem): Record<string, unknown> {
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
return obj;
}
async function copyRowsAsJson(items: RowItem[]) {
if (items.length === 0) return;
const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject);
await copyText(JSON.stringify(value, null, 2));
}
// --- Cell/row copy ---
async function copyCell() {
if (!contextCell.value || contextCell.value.col < 0) return;
@@ -331,37 +351,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyRow() {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
const objects = items.map((item) => {
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
return obj;
});
await copyText(JSON.stringify(objects, null, 2));
await copyRowsAsJson(items);
return;
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
const items = displayItems.value.slice(range.startRow, range.endRow + 1);
const objects = items.map((item) => {
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
return obj;
});
await copyText(JSON.stringify(objects, null, 2));
await copyRowsAsJson(items);
return;
}
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
await copyText(JSON.stringify(obj, null, 2));
await copyRowsAsJson([item]);
}
function insertEligibleRows(): RowItem[] {
@@ -545,6 +547,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectedRowsTsv,
exportCsv,
exportJson,
exportMarkdown,
@@ -1,8 +1,11 @@
import { ref, computed, type ComputedRef, type Ref } from "vue";
import {
allCellsSelectionRange,
columnSelectionRange,
extractSelection,
isCellInSelection,
normalizeSelectionRange,
rowSelectionRange,
type CellPosition,
type CellSelectionRange,
type SelectionData,
@@ -39,6 +42,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
const selectedRowIds = ref<Set<number>>(new Set());
const lastClickedRowIndex = ref<number | null>(null);
const lastClickedColumnIndex = ref<number | null>(null);
const hasRowSelection = computed(() => selectedRowIds.value.size > 0);
const selectedRowCount = computed(() => selectedRowIds.value.size);
@@ -74,9 +78,57 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
}
function selectRow(rowIndex: number) {
if (columns.value.length === 0) return;
selectionAnchor.value = { rowIndex, colIndex: 0 };
selectionFocus.value = { rowIndex, colIndex: columns.value.length - 1 };
const range = rowSelectionRange(rowIndex, columns.value.length);
if (!range) return;
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
}
function selectRows(startRow: number, endRow: number) {
const range = rowSelectionRange(startRow, columns.value.length, endRow);
if (!range) return;
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
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;
clearRowSelection();
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
focusGridWithoutScrolling();
}
function selectColumn(colIndex: number, event?: MouseEvent) {
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));
} else {
selectColumns(colIndex, colIndex);
}
lastClickedColumnIndex.value = colIndex;
}
function selectAllCells() {
const range = allCellsSelectionRange(displayItems.value.length, columns.value.length);
if (!range) return;
clearRowSelection();
lastClickedColumnIndex.value = null;
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
focusGridWithoutScrolling();
}
function extendCellSelectionTo(rowIndex: number, colIndex: number) {
if (!selectionAnchor.value) {
selectSingleCell(rowIndex, colIndex);
return;
}
selectionFocus.value = { rowIndex, colIndex };
}
function handleRowClick(rowIndex: number, rowId: number, event: MouseEvent) {
@@ -103,8 +155,10 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
if (item) next.add(item.id);
}
selectedRowIds.value = next;
selectRows(start, end);
} else {
selectedRowIds.value = new Set([rowId]);
selectRow(rowIndex);
lastClickedRowIndex.value = rowIndex;
}
}
@@ -125,6 +179,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
focusGridWithoutScrolling();
selectSingleCell(rowIndex, colIndex);
isSelectingCells.value = true;
lastClickedColumnIndex.value = colIndex;
if (showTranspose.value) transposeRowIndex.value = rowIndex;
document.addEventListener("mouseup", finishCellSelection);
}
@@ -134,7 +189,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectionFocus.value = { rowIndex, colIndex };
}
function handleDataCellMousedown(rowIndex: number, colIndex: number, rowId: number, event: MouseEvent) {
function handleDataCellMousedown(rowIndex: number, colIndex: number, _rowId: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
@@ -144,7 +199,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
if (isMeta || isShift) {
event.preventDefault();
focusGridWithoutScrolling();
handleRowClick(rowIndex, rowId, event);
clearRowSelection();
extendCellSelectionTo(rowIndex, colIndex);
return;
}
@@ -160,6 +216,17 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
return isCellInSelection(rowIndex, colIndex, selectedRange.value);
}
function columnIsSelected(colIndex: number): boolean {
const range = selectedRange.value;
if (!range) return false;
return (
range.startCol <= colIndex &&
range.endCol >= colIndex &&
range.startRow === 0 &&
range.endRow >= displayItems.value.length - 1
);
}
function selectedRangeStart(): CellPosition | null {
const range = selectedRange.value;
if (!range) return null;
@@ -177,10 +244,15 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
clearCellSelection,
selectSingleCell,
selectRow,
selectColumn,
selectColumns,
selectAllCells,
extendCellSelectionTo,
finishCellSelection,
beginCellSelection,
extendCellSelection,
cellIsSelected,
columnIsSelected,
selectedRangeStart,
selectedRowIds,
lastClickedRowIndex,
@@ -1,4 +1,4 @@
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { useTheme } from "@/composables/useTheme";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlighter";
+2
View File
@@ -320,6 +320,7 @@ export default {
copy: "Copy",
copyCell: "Copy Cell",
copyRow: "Copy Row (JSON)",
copyColumnJson: "Copy Column (JSON)",
copyRowInsert: "Copy as INSERT",
copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys",
copyRowUpdate: "Copy as UPDATE",
@@ -436,6 +437,7 @@ export default {
cloneRows: "Clone {count} as New Rows",
restoreRows: "Restore {count} Rows",
copyRows: "Copy {count} Rows (JSON)",
copyColumnsJson: "Copy {count} Columns (JSON)",
copyRowsInsert: "Copy {count} Rows as INSERT",
copyRowsInsertWithoutPrimaryKeys: "Copy {count} Rows as INSERT without Primary Keys",
copyRowsUpdate: "Copy {count} Rows as UPDATE",
+2
View File
@@ -311,6 +311,7 @@ export default {
copy: "Copiar",
copyCell: "Copiar celda",
copyRow: "Copiar fila (JSON)",
copyColumnJson: "Copiar columna (JSON)",
copyRowInsert: "Copiar como INSERT",
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias",
copyRowUpdate: "Copiar como UPDATE",
@@ -400,6 +401,7 @@ export default {
cloneRows: "Clonar {count} como filas nuevas",
restoreRows: "Restaurar {count} filas",
copyRows: "Copiar {count} filas (JSON)",
copyColumnsJson: "Copiar {count} columnas (JSON)",
copyRowsInsert: "Copiar {count} filas como INSERT",
copyRowsInsertWithoutPrimaryKeys: "Copiar {count} filas como INSERT sin claves primarias",
copyRowsUpdate: "Copiar {count} filas como UPDATE",
+2
View File
@@ -318,6 +318,7 @@ export default {
copy: "复制",
copyCell: "复制单元格",
copyRow: "复制行 (JSON)",
copyColumnJson: "复制列 (JSON)",
copyRowInsert: "复制为 INSERT 语句",
copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)",
copyRowUpdate: "复制为 UPDATE 语句",
@@ -433,6 +434,7 @@ export default {
cloneRows: "克隆 {count} 行为新行",
restoreRows: "恢复 {count} 行",
copyRows: "复制 {count} 行 (JSON)",
copyColumnsJson: "复制 {count} 列 (JSON)",
copyRowsInsert: "复制 {count} 行为 INSERT",
copyRowsInsertWithoutPrimaryKeys: "复制 {count} 行为 INSERT(不含主键)",
copyRowsUpdate: "复制 {count} 行为 UPDATE",
+33
View File
@@ -26,6 +26,39 @@ export function normalizeSelectionRange(anchor: CellPosition, focus: CellPositio
};
}
export function rowSelectionRange(
rowIndex: number,
columnCount: number,
endRowIndex = rowIndex,
): CellSelectionRange | null {
if (rowIndex < 0 || endRowIndex < 0 || columnCount <= 0) return null;
return {
startRow: Math.min(rowIndex, endRowIndex),
endRow: Math.max(rowIndex, endRowIndex),
startCol: 0,
endCol: columnCount - 1,
};
}
export function columnSelectionRange(
rowCount: number,
colIndex: number,
endColIndex = colIndex,
): CellSelectionRange | null {
if (rowCount <= 0 || colIndex < 0 || endColIndex < 0) return null;
return {
startRow: 0,
endRow: rowCount - 1,
startCol: Math.min(colIndex, endColIndex),
endCol: Math.max(colIndex, endColIndex),
};
}
export function allCellsSelectionRange(rowCount: number, columnCount: number): CellSelectionRange | null {
if (rowCount <= 0 || columnCount <= 0) return null;
return { startRow: 0, endRow: rowCount - 1, startCol: 0, endCol: columnCount - 1 };
}
export function isCellInSelection(rowIndex: number, colIndex: number, range: CellSelectionRange | null): boolean {
if (!range) return false;
return (
@@ -1392,6 +1392,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![renamed, email],
indexes: vec![old_index, email_index],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1418,6 +1420,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![created_at],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1439,6 +1443,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![created_at],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1467,6 +1473,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id, name],
indexes: vec![idx],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1501,6 +1509,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.statements, Vec::<String>::new());
@@ -1559,6 +1569,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id, email, name],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1583,6 +1595,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![email],
indexes: vec![index("idx_users_email", &["email"])],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1610,6 +1624,8 @@ mod tests {
table_name: "events".to_string(),
columns: vec![name, created_at],
indexes: vec![index("idx_events_name", &["name"])],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1648,6 +1664,8 @@ mod tests {
table_name: "events".to_string(),
columns: vec![source, status],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1687,6 +1705,8 @@ mod tests {
table_name: "USERS".to_string(),
columns: vec![name],
indexes: vec![index("IDX_USERS_DISPLAY_NAME", &["DISPLAY_NAME"])],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1725,6 +1745,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1753,6 +1775,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1797,6 +1821,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![old_pk, new_pk],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1828,6 +1854,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
@@ -1856,6 +1884,8 @@ mod tests {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.statements, Vec::<String>::new());
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../../apps/desktop/src/components/grid/DataGrid.vue", import.meta.url), "utf8");
const selectionSource = readFileSync(
new URL("../../apps/desktop/src/composables/useDataGridSelection.ts", import.meta.url),
"utf8",
);
test("data grid wires whole table, row, and column selection gestures", () => {
assert.match(source, /@click="selectAllCells"/);
assert.match(source, /@click="selectColumn\(colIdx, \$event\)"/);
assert.match(source, /columnIsSelected\(colIdx\)/);
assert.match(selectionSource, /function selectAllCells\(\)/);
assert.match(selectionSource, /function selectColumn\(colIndex: number, event\?: MouseEvent\)/);
assert.match(selectionSource, /lastClickedColumnIndex/);
});
test("data grid intercepts copy and select-all shortcuts for grid selections", () => {
assert.match(source, /clipboardShortcut\(event, "a"\)/);
assert.match(source, /selectAllCells\(\)/);
assert.match(source, /isTransposeMode\.value && hasRowSelection\.value/);
assert.match(source, /copyRow\(\);/);
assert.match(source, /if \(hasCellSelection\.value\) \{\s+copySelectionTsv\(\);/);
assert.match(source, /copySelectedRowsTsv\(\)/);
});
test("transpose cells reuse grid cell selection and details", () => {
assert.match(source, /function selectTransposeCell\(rowIndex: number, actualColIdx: number, event: MouseEvent\)/);
assert.match(source, /transposeCellIsSelected\(cell\.recordIndex, cell\.valueIndex\)/);
assert.match(source, /@click="selectTransposeCell\(cell\.recordIndex, cell\.valueIndex, \$event\)"/);
assert.match(source, /@contextmenu="onTransposeCellContext\(cell\.recordIndex, cell\.valueIndex, \$event\)"/);
assert.match(source, /showCellDetails\(cell\.recordIndex, cell\.valueIndex\)/);
});
test("transpose record headers copy selected records as rows", () => {
assert.match(source, /function selectTransposeRecord\(rowIndex: number, event\?: MouseEvent\)/);
assert.match(source, /handleRowClick\(rowIndex, item\.id, event\)/);
assert.match(source, /@click="selectTransposeRecord\(recordIndex, \$event\)"/);
assert.match(source, /@contextmenu="selectTransposeRecord\(recordIndex, \$event\)"/);
assert.match(source, /copyRowJsonLabel/);
assert.match(source, /if \(isTransposeMode\.value\)/);
assert.match(source, /grid\.copyColumnJson/);
assert.match(source, /grid\.copyColumnsJson/);
});
@@ -27,3 +27,12 @@ test("transpose mode follows appended rows and survives rollback refresh", () =>
);
assert.match(source, /nextTransposeStateForRecordCount/);
});
test("closing transpose scrolls the normal grid to the active record", () => {
assert.match(source, /function currentTransposeViewportRowIndex\(\)/);
assert.match(source, /transposeRowIndex\.value \?\? transposeRecordWindow\.value\.start/);
assert.match(source, /function scrollGridRowIntoView\(rowIndex: number\)/);
assert.match(source, /scrollToItem\?\.\(target\)/);
assert.match(source, /function closeTranspose\(scrollToCurrentRecord = true\)/);
assert.match(source, /closeTranspose\(false\)/);
});
+30 -12
View File
@@ -1,6 +1,8 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
allCellsSelectionRange,
columnSelectionRange,
extractSelection,
formatSelectionAsCsv,
formatSelectionAsJson,
@@ -8,13 +10,11 @@ import {
formatSelectionAsTsv,
isCellInSelection,
normalizeSelectionRange,
rowSelectionRange,
} from "../../apps/desktop/src/lib/gridSelection.ts";
test("normalizes a dragged cell range in either direction", () => {
const range = normalizeSelectionRange(
{ rowIndex: 4, colIndex: 3 },
{ rowIndex: 1, colIndex: 0 },
);
const range = normalizeSelectionRange({ rowIndex: 4, colIndex: 3 }, { rowIndex: 1, colIndex: 0 });
assert.deepEqual(range, {
startRow: 1,
@@ -44,25 +44,43 @@ test("extracts selection rows and columns from a rectangular range", () => {
]);
});
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 });
assert.deepEqual(columnSelectionRange(5, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 1 });
assert.deepEqual(columnSelectionRange(5, 3, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 3 });
assert.deepEqual(allCellsSelectionRange(3, 2), { startRow: 0, endRow: 2, startCol: 0, endCol: 1 });
assert.equal(rowSelectionRange(0, 0), null);
assert.equal(columnSelectionRange(0, 0), null);
assert.equal(allCellsSelectionRange(0, 2), null);
});
test("formats selected cells as TSV, CSV, JSON, and SQL values", () => {
const selection = {
columns: ["name", "note"],
rows: [
["Ada", "math"],
["Bob", "quote \"here\""],
["Bob", 'quote "here"'],
["O'Hara", null],
],
};
assert.equal(formatSelectionAsTsv(selection), "Ada\tmath\nBob\tquote \"here\"\nO'Hara\tNULL");
assert.equal(formatSelectionAsCsv(selection), "\"name\",\"note\"\n\"Ada\",\"math\"\n\"Bob\",\"quote \"\"here\"\"\"\n\"O'Hara\",\"NULL\"");
assert.equal(formatSelectionAsTsv(selection), 'Ada\tmath\nBob\tquote "here"\nO\'Hara\tNULL');
assert.equal(
formatSelectionAsCsv(selection),
'"name","note"\n"Ada","math"\n"Bob","quote ""here"""\n"O\'Hara","NULL"',
);
assert.equal(
formatSelectionAsJson(selection),
JSON.stringify([
{ name: "Ada", note: "math" },
{ name: "Bob", note: "quote \"here\"" },
{ name: "O'Hara", note: null },
], null, 2),
JSON.stringify(
[
{ name: "Ada", note: "math" },
{ name: "Bob", note: 'quote "here"' },
{ name: "O'Hara", note: null },
],
null,
2,
),
);
assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)");
});