Add grid cell selection and details
This commit is contained in:
parent
4c4f5de6d8
commit
3e7abf8212
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(", ")})`;
|
||||
}
|
||||
|
|
@ -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)");
|
||||
});
|
||||
Loading…
Reference in New Issue