Add grid cell selection and details

This commit is contained in:
SuLe 2026-05-01 09:40:18 +08:00
parent 4c4f5de6d8
commit 3e7abf8212
5 changed files with 462 additions and 11 deletions

View File

@ -7,7 +7,7 @@ const globalDdlOpen = ref(false);
import { computed, nextTick, onUnmounted, watch } from "vue";
import { useElementSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2, WrapText } from "lucide-vue-next";
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2, WrapText, Info } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@ -23,6 +23,17 @@ import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database";
import { save as savePath } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import * as api from "@/lib/tauri";
import {
extractSelection,
formatSelectionAsCsv,
formatSelectionAsJson,
formatSelectionAsSqlInList,
formatSelectionAsTsv,
isCellInSelection,
normalizeSelectionRange,
type CellPosition,
type CellSelectionRange,
} from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
@ -102,7 +113,12 @@ function typeColorClass(t: string): string {
if (["bytea", "blob", "binary", "varbinary", "image"].includes(s)) return "text-red-400";
return "text-muted-foreground";
}
const contextCell = ref<{ rowId: number; col: number } | null>(null);
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const selectionAnchor = ref<CellPosition | null>(null);
const selectionFocus = ref<CellPosition | null>(null);
const isSelectingCells = ref(false);
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const showCellDetail = ref(false);
const sortCol = ref<string | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
@ -257,6 +273,46 @@ const displayItems = computed<RowItem[]>(() => {
const hasVisibleRows = computed(() => displayItems.value.length > 0);
const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
const selectedRange = computed<CellSelectionRange | null>(() => {
if (!selectionAnchor.value || !selectionFocus.value) return null;
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
});
const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data));
const selectedCells = computed(() => extractSelection(props.result.columns, visibleSelectionRows.value, selectedRange.value));
const selectedCellCount = computed(() => selectedCells.value.columns.length * selectedCells.value.rows.length);
const hasCellSelection = computed(() => selectedCellCount.value > 0);
const selectionSummary = computed(() => t("grid.selectedCells", { count: selectedCellCount.value }));
const activeCellDetail = computed(() => {
const cell = detailCell.value;
if (!cell) return null;
const item = displayItems.value[cell.rowIndex];
const column = props.result.columns[cell.col];
if (!item || !column) return null;
const value = item.data[cell.col] ?? null;
const rawValue = formatCell(value);
const valueText = value === null ? "" : String(value);
const trimmed = valueText.trim();
const maybeJson = typeof value === "string" && (trimmed.startsWith("{") || trimmed.startsWith("["));
let formattedJson = "";
if (maybeJson) {
try {
formattedJson = JSON.stringify(JSON.parse(value), null, 2);
} catch {
formattedJson = "";
}
}
return {
rowNumber: cell.rowIndex + 1,
colIndex: cell.col,
column,
type: columnTypeMap.value.get(column) || "",
comment: columnCommentMap.value.get(column) || "",
value,
rawValue,
length: value === null ? 0 : String(value).length,
formattedJson,
};
});
function toggleSort(colName: string) {
if (isResizing) return;
@ -574,16 +630,111 @@ function discardChanges() {
editingCell.value = null;
}
// --- Cell selection and detail ---
function clearCellSelection() {
selectionAnchor.value = null;
selectionFocus.value = null;
isSelectingCells.value = false;
}
function selectSingleCell(rowIndex: number, colIndex: number) {
const cell = { rowIndex, colIndex };
selectionAnchor.value = cell;
selectionFocus.value = cell;
}
function finishCellSelection() {
isSelectingCells.value = false;
document.removeEventListener("mouseup", finishCellSelection);
}
function beginCellSelection(rowIndex: number, colIndex: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
event.preventDefault();
selectSingleCell(rowIndex, colIndex);
isSelectingCells.value = true;
document.addEventListener("mouseup", finishCellSelection);
}
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
selectionFocus.value = { rowIndex, colIndex };
}
function cellIsSelected(rowIndex: number, colIndex: number): boolean {
return isCellInSelection(rowIndex, colIndex, selectedRange.value);
}
function showCellDetails(rowIndex: number, colIndex: number) {
detailCell.value = { rowIndex, col: colIndex };
showCellDetail.value = true;
}
function copyText(text: string) {
navigator.clipboard.writeText(text);
toast(t('grid.copied'));
}
function copySelectionTsv() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsTsv(selectedCells.value));
}
function copySelectionCsv() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsCsv(selectedCells.value));
}
function copySelectionJson() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsJson(selectedCells.value));
}
function copySelectionSqlInList() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsSqlInList(selectedCells.value));
}
function copyDetailValue() {
if (!activeCellDetail.value) return;
copyText(activeCellDetail.value.rawValue);
}
function copyDetailColumnName() {
if (!activeCellDetail.value) return;
copyText(activeCellDetail.value.column);
}
function copyDetailSqlCondition() {
const detail = activeCellDetail.value;
if (!detail) return;
const column = quoteIdent(detail.column);
const condition = detail.value === null
? `${column} IS NULL`
: `${column} = ${escapeVal(detail.value)}`;
copyText(condition);
}
watch(() => props.result, () => {
clearCellSelection();
showCellDetail.value = false;
detailCell.value = null;
});
// --- Copy/Export ---
function onCellContext(rowId: number, colIdx: number) {
contextCell.value = { rowId, col: colIdx };
function onCellContext(rowId: number, rowIndex: number, colIdx: number) {
contextCell.value = { rowId, rowIndex, col: colIdx };
if (!cellIsSelected(rowIndex, colIdx)) {
selectSingleCell(rowIndex, colIdx);
}
}
function copyCell() {
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
const val = item?.data[contextCell.value.col] ?? null;
navigator.clipboard.writeText(formatCell(val));
copyText(formatCell(val));
}
function copyRow() {
@ -592,7 +743,7 @@ function copyRow() {
if (!item) return;
const obj: Record<string, unknown> = {};
props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; });
navigator.clipboard.writeText(JSON.stringify(obj, null, 2));
copyText(JSON.stringify(obj, null, 2));
}
function copyAll() {
@ -600,7 +751,7 @@ function copyAll() {
const body = sortedRows.value
.map(({ row, sourceIndex }) => rowDataWithChanges(row, sourceIndex).map((c) => formatCell(c)).join("\t"))
.join("\n");
navigator.clipboard.writeText(`${header}\n${body}`);
copyText(`${header}\n${body}`);
}
async function exportCsv() {
@ -721,7 +872,10 @@ function onDdlResizeEnd() {
window.removeEventListener("mouseup", onDdlResizeEnd);
}
onUnmounted(onDdlResizeEnd);
onUnmounted(() => {
onDdlResizeEnd();
finishCellSelection();
});
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;
@ -879,17 +1033,20 @@ function escapeAndHighlightKeywords(s: string): string {
<div
v-for="(cell, colIdx) in item.data"
:key="colIdx"
class="shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative"
class="group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none"
:style="{ width: `var(--col-w-${colIdx})` }"
:class="{
'text-muted-foreground italic': isNull(cell),
'bg-yellow-500/10': item.isDirtyCol[colIdx],
'cell-selected': cellIsSelected(index, colIdx),
'tabular-nums': typeof cell === 'number',
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
'line-through': item.isDeleted,
}"
@mousedown="beginCellSelection(index, colIdx, $event)"
@mouseenter="extendCellSelection(index, colIdx)"
@dblclick="editable && !item.isDeleted && startEdit(item.id, colIdx)"
@contextmenu="onCellContext(item.id, colIdx)"
@contextmenu="onCellContext(item.id, index, colIdx)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === colIdx">
<input
@ -902,6 +1059,14 @@ function escapeAndHighlightKeywords(s: string): string {
</template>
<template v-else>
{{ formatCell(cell) }}
<button
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showCellDetails(index, colIdx)"
>
<Info class="h-3 w-3" />
</button>
</template>
</div>
</div>
@ -942,15 +1107,87 @@ function escapeAndHighlightKeywords(s: string): string {
v-html="highlightSql(ddlContent)"
></pre>
</div>
<!-- Cell Detail Drawer -->
<div
v-if="showCellDetail && activeCellDetail"
class="relative w-80 shrink-0 border-l flex flex-col bg-background min-w-0"
>
<div class="flex items-center gap-2 px-3 py-1.5 border-b shrink-0 bg-muted/20">
<Info class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t('grid.cellDetails') }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showCellDetail = false">
<X class="w-3 h-3" />
</Button>
</div>
<div class="flex-1 min-h-0 overflow-auto p-3 text-xs space-y-3">
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.columnName') }}</div>
<div class="font-medium break-all">{{ activeCellDetail.column }}</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.rowNumber') }}</div>
<div>{{ activeCellDetail.rowNumber }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.columnType') }}</div>
<div :class="activeCellDetail.type ? typeColorClass(activeCellDetail.type) : 'text-muted-foreground'">
{{ activeCellDetail.type || '-' }}
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.nullValue') }}</div>
<div>{{ activeCellDetail.value === null ? 'true' : 'false' }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.valueLength') }}</div>
<div>{{ activeCellDetail.length }}</div>
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.columnComment') }}</div>
<div class="whitespace-pre-wrap break-words">{{ activeCellDetail.comment || t('grid.noComment') }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.cellValue') }}</div>
<pre class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words">{{ activeCellDetail.rawValue }}</pre>
</div>
<div v-if="activeCellDetail.formattedJson" class="space-y-1">
<div class="text-muted-foreground">{{ t('grid.formattedJson') }}</div>
<pre class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words">{{ activeCellDetail.formattedJson }}</pre>
</div>
</div>
<div class="border-t p-2 grid grid-cols-1 gap-1">
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailValue">
<Copy class="w-3 h-3 mr-2" /> {{ t('grid.copyValue') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailColumnName">
<Copy class="w-3 h-3 mr-2" /> {{ t('grid.copyColumnName') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailSqlCondition">
<Code2 class="w-3 h-3 mr-2" /> {{ t('grid.copySqlCondition') }}
</Button>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuContent class="w-60">
<ContextMenuItem @click="copyCell">{{ t('grid.copyCell') }}</ContextMenuItem>
<ContextMenuItem @click="copyRow">{{ t('grid.copyRow') }}</ContextMenuItem>
<ContextMenuItem @click="copyAll">{{ t('grid.copyAll') }}</ContextMenuItem>
<ContextMenuSeparator />
<template v-if="hasCellSelection">
<ContextMenuItem @click="copySelectionTsv">{{ t('grid.copySelectionTsv') }}</ContextMenuItem>
<ContextMenuItem @click="copySelectionCsv">{{ t('grid.copySelectionCsv') }}</ContextMenuItem>
<ContextMenuItem @click="copySelectionJson">{{ t('grid.copySelectionJson') }}</ContextMenuItem>
<ContextMenuItem @click="copySelectionSqlInList">{{ t('grid.copySelectionSql') }}</ContextMenuItem>
<ContextMenuItem @click="clearCellSelection">{{ t('grid.clearSelection') }}</ContextMenuItem>
<ContextMenuSeparator />
</template>
<template v-if="editable">
<ContextMenuItem class="text-destructive" @click="deleteSelectedRow">
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.deleteRow') }}
@ -978,6 +1215,7 @@ function escapeAndHighlightKeywords(s: string): string {
<span v-if="hasData">{{ t('grid.rows', { count: result.rows.length }) }}</span>
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
<span>{{ result.execution_time_ms }}ms</span>
<span v-if="hasCellSelection" class="text-foreground">{{ selectionSummary }}</span>
<template v-if="editable && tableMeta">
<span v-if="hasPendingChanges" class="ml-2 text-foreground">
@ -1065,6 +1303,11 @@ function escapeAndHighlightKeywords(s: string): string {
transition: none;
}
.cell-selected {
background-color: color-mix(in oklab, var(--primary) 18%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 55%, transparent);
}
.ddl-code :deep(.ddl-kw) {
color: oklch(0.6 0.15 250);
font-weight: 600;

View File

@ -86,6 +86,11 @@ export default {
copyCell: "Copy Cell",
copyRow: "Copy Row (JSON)",
copyAll: "Copy All (TSV)",
copySelectionTsv: "Copy Selection (TSV)",
copySelectionCsv: "Copy Selection (CSV)",
copySelectionJson: "Copy Selection (JSON)",
copySelectionSql: "Copy Selection as SQL IN List",
clearSelection: "Clear Selection",
exportCsv: "Export CSV",
exportJson: "Export JSON",
exportMarkdown: "Export Markdown",
@ -105,6 +110,20 @@ export default {
statusEdited: "Edited",
statusDeleted: "Deleted",
pendingChanges: "{count} pending",
selectedCells: "{count} selected",
cellDetails: "Cell Details",
cellValue: "Value",
columnName: "Column",
columnType: "Type",
columnComment: "Comment",
rowNumber: "Row",
valueLength: "Length",
nullValue: "NULL",
noComment: "No comment",
formattedJson: "Formatted JSON",
copyValue: "Copy Value",
copyColumnName: "Copy Column Name",
copySqlCondition: "Copy SQL Condition",
rowsPerPageShort: " rows",
},
welcome: {

View File

@ -88,6 +88,11 @@ export default {
copyCell: "复制单元格",
copyRow: "复制行 (JSON)",
copyAll: "复制全部 (TSV)",
copySelectionTsv: "复制选区 (TSV)",
copySelectionCsv: "复制选区 (CSV)",
copySelectionJson: "复制选区 (JSON)",
copySelectionSql: "复制选区为 SQL IN 列表",
clearSelection: "清除选区",
exportCsv: "导出 CSV",
exportJson: "导出 JSON",
exportMarkdown: "导出 Markdown",
@ -107,6 +112,20 @@ export default {
statusEdited: "已改",
statusDeleted: "删除",
pendingChanges: "{count} 项待保存",
selectedCells: "已选 {count} 个单元格",
cellDetails: "单元格详情",
cellValue: "值",
columnName: "列名",
columnType: "类型",
columnComment: "注释",
rowNumber: "行号",
valueLength: "长度",
nullValue: "NULL",
noComment: "暂无注释",
formattedJson: "格式化 JSON",
copyValue: "复制值",
copyColumnName: "复制列名",
copySqlCondition: "复制 SQL 条件",
rowsPerPageShort: " 行/页",
},
welcome: {

102
src/lib/gridSelection.ts Normal file
View File

@ -0,0 +1,102 @@
export type GridCellValue = string | number | boolean | null;
export interface CellPosition {
rowIndex: number;
colIndex: number;
}
export interface CellSelectionRange {
startRow: number;
endRow: number;
startCol: number;
endCol: number;
}
export interface SelectionData {
columns: string[];
rows: GridCellValue[][];
}
export function normalizeSelectionRange(anchor: CellPosition, focus: CellPosition): CellSelectionRange {
return {
startRow: Math.min(anchor.rowIndex, focus.rowIndex),
endRow: Math.max(anchor.rowIndex, focus.rowIndex),
startCol: Math.min(anchor.colIndex, focus.colIndex),
endCol: Math.max(anchor.colIndex, focus.colIndex),
};
}
export function isCellInSelection(rowIndex: number, colIndex: number, range: CellSelectionRange | null): boolean {
if (!range) return false;
return (
rowIndex >= range.startRow
&& rowIndex <= range.endRow
&& colIndex >= range.startCol
&& colIndex <= range.endCol
);
}
export function extractSelection(
columns: readonly string[],
rows: readonly GridCellValue[][],
range: CellSelectionRange | null,
): SelectionData {
if (!range) return { columns: [], rows: [] };
const selectedColumns = columns.slice(range.startCol, range.endCol + 1);
const selectedRows = rows
.slice(range.startRow, range.endRow + 1)
.map((row) => row.slice(range.startCol, range.endCol + 1));
return { columns: selectedColumns, rows: selectedRows };
}
function displayValue(value: GridCellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
function csvValue(value: GridCellValue | string): string {
const text = typeof value === "string" ? value : displayValue(value);
return `"${text.replace(/"/g, '""')}"`;
}
function sqlValue(value: GridCellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return `'${String(value).replace(/'/g, "''")}'`;
}
export function formatSelectionAsTsv(selection: SelectionData): string {
const header = selection.columns.join("\t");
const body = selection.rows
.map((row) => row.map(displayValue).join("\t"))
.join("\n");
return [header, body].filter(Boolean).join("\n");
}
export function formatSelectionAsCsv(selection: SelectionData): string {
const header = selection.columns.map(csvValue).join(",");
const body = selection.rows
.map((row) => row.map(csvValue).join(","))
.join("\n");
return [header, body].filter(Boolean).join("\n");
}
export function formatSelectionAsJson(selection: SelectionData): string {
const objects = selection.rows.map((row) => {
const item: Record<string, GridCellValue> = {};
selection.columns.forEach((column, index) => {
item[column] = row[index] ?? null;
});
return item;
});
return JSON.stringify(objects, null, 2);
}
export function formatSelectionAsSqlInList(selection: SelectionData): string {
const values = selection.rows.flat().map(sqlValue);
return `(${values.join(", ")})`;
}

View File

@ -0,0 +1,68 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
extractSelection,
formatSelectionAsCsv,
formatSelectionAsJson,
formatSelectionAsSqlInList,
formatSelectionAsTsv,
isCellInSelection,
normalizeSelectionRange,
} from "../src/lib/gridSelection.ts";
test("normalizes a dragged cell range in either direction", () => {
const range = normalizeSelectionRange(
{ rowIndex: 4, colIndex: 3 },
{ rowIndex: 1, colIndex: 0 },
);
assert.deepEqual(range, {
startRow: 1,
endRow: 4,
startCol: 0,
endCol: 3,
});
assert.equal(isCellInSelection(2, 1, range), true);
assert.equal(isCellInSelection(5, 1, range), false);
});
test("extracts selection rows and columns from a rectangular range", () => {
const selection = extractSelection(
["id", "name", "active"],
[
[1, "Ada", true],
[2, "Linus", false],
[3, null, true],
],
{ startRow: 0, endRow: 1, startCol: 1, endCol: 2 },
);
assert.deepEqual(selection.columns, ["name", "active"]);
assert.deepEqual(selection.rows, [
["Ada", true],
["Linus", false],
]);
});
test("formats selected cells as TSV, CSV, JSON, and SQL values", () => {
const selection = {
columns: ["name", "note"],
rows: [
["Ada", "math"],
["Bob", "quote \"here\""],
["O'Hara", null],
],
};
assert.equal(formatSelectionAsTsv(selection), "name\tnote\nAda\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),
);
assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)");
});