feat(grid): add drag-and-drop column reordering with persistence

* feat: support data grid column reordering

* fix: improve column visibility footer layout
This commit is contained in:
林剑凛 authored and GitHub committed 2026-06-20 21:50:59 +08:00
1 parent 72f90a064f
commit 9d329920b9
14 files changed
+468 -12

No files matched your search

@@ -832,12 +832,15 @@ function resetTableSearchSplitWidth() {
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center gap-1">
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>
+192 -4
View File
@@ -123,6 +123,8 @@ import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, comb
import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit";
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/paginationPageSize";
import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGridColumnVisibility";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, loadDataGridColumnOrder, removeDataGridColumnOrder, saveDataGridColumnOrder } from "@/lib/dataGridColumnLayoutStorage";
import { parseClipboardTable } from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
@@ -1558,13 +1560,35 @@ const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const highlightedColumnIndex = ref<number | null>(null);
let highlightedColumnTimer = 0;
const columnOrderKeys = computed(() => uniqueDataGridColumnOrderKeys(props.result.columns, props.sourceColumns));
const columnLayoutScopeKey = computed(() =>
dataGridColumnLayoutScopeKey({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
context: props.context,
tableSchema: props.tableMeta?.schema,
tableName: props.tableMeta?.tableName,
sql: props.sql,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
}),
);
const persistedColumnOrderKeys = ref<string[]>([]);
const displayableColumnIndexes = computed(() =>
props.result.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => !isHiddenGridColumn(props.databaseType, column, props.tableMeta?.primaryKeys ?? []))
.map(({ index }) => index),
);
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(displayableColumnIndexes.value, hiddenColumnIndexes.value));
const orderedDisplayableColumnIndexes = computed(() =>
orderedColumnIndexes({
availableIndexes: displayableColumnIndexes.value,
columnKeys: columnOrderKeys.value,
orderedKeys: persistedColumnOrderKeys.value,
}),
);
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const visibleColumns = computed(() => visibleColumnIndexes.value.map((index) => props.result.columns[index]));
const visibleSourceColumns = computed(() => {
if (!props.sourceColumns || props.sourceColumns.length !== props.result.columns.length) return undefined;
@@ -1593,6 +1617,7 @@ const previewActions = computed(() => {
});
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(displayableColumnIndexes.value, orderedDisplayableColumnIndexes.value));
const allNullColumnIndexesForResult = computed(() => allNullColumnIndexes(props.result.rows, displayableColumnIndexes.value));
const allNullColumnCount = computed(() => allNullColumnIndexesForResult.value.length);
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (allNullColumnCount.value > 0 && displayableColumnCount.value > 1));
@@ -1616,6 +1641,24 @@ function showAllColumns() {
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value);
}
function loadColumnOrder() {
persistedColumnOrderKeys.value = loadDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value);
}
function persistColumnOrder(indexes: number[]) {
if (isDefaultColumnOrder(displayableColumnIndexes.value, indexes)) {
removeDataGridColumnOrder(columnLayoutScopeKey.value);
persistedColumnOrderKeys.value = [];
return;
}
const keys = columnOrderKeysForIndexes(indexes, columnOrderKeys.value);
persistedColumnOrderKeys.value = keys;
saveDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value, keys);
}
function resetColumnOrder() {
removeDataGridColumnOrder(columnLayoutScopeKey.value);
persistedColumnOrderKeys.value = [];
nextTick(refreshGridScrollerMetrics);
}
function showAllNullColumns() {
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
@@ -1644,6 +1687,7 @@ watch(allNullColumnIndexesForResult, () => {
autoHiddenNullColumnIndexes.value = new Set();
hideAllNullColumns();
});
watch(() => columnLayoutScopeKey.value, loadColumnOrder, { immediate: true });
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
@@ -2002,6 +2046,144 @@ function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
}
type ColumnHeaderDragState = {
sourceVisibleIndex: number;
targetVisibleIndex: number;
startX: number;
startY: number;
currentX: number;
columnRects: { visibleIndex: number; left: number; width: number }[];
dragging: boolean;
};
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
let columnHeaderDragClickGuardUntil = 0;
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
}
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
const state = columnHeaderDragState.value;
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
for (const rect of state.columnRects) {
const visibleIndex = rect.visibleIndex;
if (!Number.isFinite(visibleIndex)) continue;
if (clientX < rect.left + rect.width / 2) return visibleIndex;
}
return visibleColumnIndexes.value.length;
}
function columnHeaderLayoutRects(): { visibleIndex: number; left: number; width: number }[] {
const header = headerRef.value;
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
.map((element) => {
const rect = element.getBoundingClientRect();
return {
visibleIndex: Number(element.dataset.visibleColIndex),
left: rect.left,
width: rect.width,
};
})
.filter((rect) => Number.isFinite(rect.visibleIndex));
}
function stopColumnHeaderDrag(commit: boolean) {
const state = columnHeaderDragState.value;
if (!state) return;
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (state.dragging) columnHeaderDragClickGuardUntil = Date.now() + 250;
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
const next = moveVisibleColumnIndex({
orderedIndexes: orderedDisplayableColumnIndexes.value,
hiddenIndexes: hiddenColumnIndexes.value,
fromVisibleIndex: state.sourceVisibleIndex,
toVisibleIndex: state.targetVisibleIndex,
});
persistColumnOrder(next);
nextTick(refreshGridScrollerMetrics);
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5;
if (!state.dragging && moved) {
state.dragging = true;
document.body.style.userSelect = "none";
}
if (!state.dragging) return;
event.preventDefault();
state.currentX = event.clientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(event.clientX);
}
function onColumnHeaderPointerUp() {
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
stopColumnHeaderDrag(false);
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || getIsResizing() || columnHeaderInteractiveTarget(event.target)) return;
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
columnRects: columnHeaderLayoutRects(),
dragging: false,
};
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
}
function onHeaderClick(visibleColIdx: number, event: MouseEvent) {
if (Date.now() < columnHeaderDragClickGuardUntil) {
event.preventDefault();
event.stopPropagation();
return;
}
selectColumn(visibleColIdx, event);
}
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return {
"z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx,
};
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state?.dragging) return 0;
const sourceIndex = state.sourceVisibleIndex;
if (visibleColIdx === sourceIndex) return state.currentX - state.startX;
const targetIndex = state.targetVisibleIndex;
const sourceWidth = renderedColumnWidths.value[sourceIndex] ?? 0;
if (targetIndex < sourceIndex && visibleColIdx >= targetIndex && visibleColIdx < sourceIndex) return sourceWidth;
if (targetIndex > sourceIndex && visibleColIdx > sourceIndex && visibleColIdx <= targetIndex) return -sourceWidth;
return 0;
}
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
if (!offset) return style;
return {
...style,
transform: `translateX(${offset}px)`,
transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out",
};
}
function columnContentOffsetLeft(visibleColIdx: number): number {
return DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
}
@@ -4265,6 +4447,7 @@ onDeactivated(pauseCanvasGridWork);
onUnmounted(() => {
pauseCanvasGridWork();
gridHorizontalScrollbarResizeObserver?.disconnect();
stopColumnHeaderDrag(false);
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
if (gridHorizontalScrollbarFrame && typeof cancelAnimationFrame === "function") {
@@ -6016,6 +6199,8 @@ defineExpose({
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
hasCustomColumnOrder,
resetColumnOrder,
nullColumnsHidden,
allNullColumnCount,
canToggleAllNullColumns,
@@ -6808,10 +6993,13 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:class="{
'!bg-gray-300 dark:!bg-gray-900 outline outline-primary -outline-offset-1': highlightedColumnIndex === col.actualColIdx || columnIsSelected(col.visibleColIdx),
'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40': currentSearchMatch?.kind === 'column' && currentSearchMatch.col === col.actualColIdx,
...columnHeaderDragClass(col.visibleColIdx),
}"
:style="renderedColumnStyle(col.visibleColIdx)"
:style="columnHeaderStyle(col.visibleColIdx)"
:data-grid-column-index="col.actualColIdx"
@click="selectColumn(col.visibleColIdx, $event)"
:data-visible-col-index="col.visibleColIdx"
@pointerdown="startColumnHeaderDrag(col.visibleColIdx, $event)"
@click="onHeaderClick(col.visibleColIdx, $event)"
@contextmenu="onHeaderContext(col.name, col.actualColIdx)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
@@ -7097,7 +7285,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</PopoverContent>
</Popover>
</span>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onResizeStart(col.visibleColIdx, $event)" @dblclick.stop="autoFitColumn(col.visibleColIdx)" />
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onResizeStart(col.visibleColIdx, $event)" @dblclick.stop="autoFitColumn(col.visibleColIdx)" />
</div>
<template #content>
<div class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">
@@ -72,6 +72,8 @@ type DataGridHandle = {
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
hasCustomColumnOrder: boolean;
resetColumnOrder: () => void;
nullColumnsHidden: boolean;
allNullColumnCount: number;
canToggleAllNullColumns: boolean;
@@ -844,12 +846,15 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center gap-1">
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>