From e4b9e558835a855cf55a9d9ba9063f3fcf1c9ebc Mon Sep 17 00:00:00 2001
From: hanxuanyu <2252193204@qq.com>
Date: Thu, 13 Aug 2026 12:41:57 +0800
Subject: [PATCH] fix(grid): improve column drag scrolling and selection
---
apps/desktop/src/components/grid/DataGrid.vue | 17 +-
.../grid/DataGridColumnLayoutPopover.vue | 8 +-
.../dataGridColumnLayoutPopover.spec.ts | 21 ++
.../src/components/layout/ContentArea.vue | 1 +
.../__tests__/useDataGridColumnLayout.spec.ts | 288 +++++++++++++++++-
.../__tests__/useDataGridSelection.spec.ts | 13 +
.../composables/useDataGridColumnLayout.ts | 126 +++++++-
.../src/composables/useDataGridSelection.ts | 10 +
.../dataGridColumnHeaderInteraction.ts | 71 +++++
9 files changed, 539 insertions(+), 16 deletions(-)
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index 4c2e71f0a3..83412d4d2e 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -2123,6 +2123,16 @@ const gridViewportWidth = ref(0);
let gridScrollLeftBeforeTranspose = 0;
let gridScrollTopBeforeKeyboardTranspose: number | null = null;
let restoreGridScrollTopAfterTranspose = false;
+
+function persistDraggedColumnOrder(indexes: number[]) {
+ const previousVisibleColumnIndexes = [...visibleColumnIndexes.value];
+ persistColumnOrder(indexes);
+ selection.remapColumnSelection(
+ previousVisibleColumnIndexes,
+ indexes.filter((index) => !hiddenColumnIndexes.value.has(index)),
+ );
+}
+
const {
renderedColumnOffsets,
horizontalColumnWindow,
@@ -2147,14 +2157,19 @@ const {
viewportWidth: gridViewportWidth,
rowNumberWidth,
headerRef,
+ getScrollElement: gridScrollerElement,
orderedColumnIndexes: orderedDisplayableColumnIndexes,
hiddenColumnIndexes,
getIsResizing,
onResizeStart,
onCanvasMouseLeave,
onCanvasDrawSchedule: scheduleCanvasDraw,
+ onHorizontalScroll: (scroller) => {
+ updateGridHorizontalViewport(scroller);
+ if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
+ },
onRefreshMetrics: scheduleColumnLayoutRefresh,
- onPersistColumnOrder: persistColumnOrder,
+ onPersistColumnOrder: persistDraggedColumnOrder,
frozenColumnCount,
});
diff --git a/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue b/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue
index b5bd76a90a..8eed331fb1 100644
--- a/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue
+++ b/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue
@@ -20,9 +20,11 @@ const props = withDefaults(
defineProps<{
grid?: DataGridColumnLayoutHandle;
triggerClass?: string;
+ compact?: boolean;
}>(),
{
triggerClass: "",
+ compact: false,
},
);
@@ -299,14 +301,14 @@ onBeforeUnmount(resetColumnDragState);
variant="ghost"
size="sm"
class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent"
- :class="[triggerClass, { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
+ :class="[triggerClass, compact ? 'w-6 gap-0 px-0' : '', { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
:disabled="!grid"
:title="t('grid.columnVisibility')"
:aria-label="t('grid.columnVisibility')"
>
- {{ t("grid.columnVisibility") }}
- {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }}
+ {{ t("grid.columnVisibility") }}
+ {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }}
diff --git a/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
index d69aedb417..a9e1b17529 100644
--- a/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
+++ b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
+import { readFileSync } from "node:fs";
+import path from "node:path";
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -39,6 +41,8 @@ vi.mock("@/components/ui/button", async () => {
import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
+const contentAreaSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/layout/ContentArea.vue"), "utf8");
+
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
@@ -137,6 +141,23 @@ afterEach(() => {
});
describe("data grid column layout popover", () => {
+ it("is available from the query result toolbar and keeps a labelled compact trigger", async () => {
+ expect(contentAreaSource).toContain('');
+
+ const gridState = createGrid(4);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const app = createApp(DataGridColumnLayoutPopover, { grid: gridState.grid, compact: true });
+ app.use(i18n);
+ app.mount(host);
+ mountedApps.push({ app, host });
+ await nextTick();
+
+ const trigger = [...host.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === i18n.global.t("grid.columnVisibility"));
+ expect(trigger?.className).toContain("w-6");
+ expect(trigger?.textContent?.trim()).toBe("");
+ });
+
it("renders only the visible field window plus a bounded buffer", () => {
const window = dataGridColumnLayoutVirtualWindow({
itemCount: 500,
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue
index 4c38b5e503..fddbdfa31f 100644
--- a/apps/desktop/src/components/layout/ContentArea.vue
+++ b/apps/desktop/src/components/layout/ContentArea.vue
@@ -1622,6 +1622,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
+
{
@@ -15,7 +16,11 @@ describe("useDataGridColumnLayout", () => {
removeItem: (key: string) => values.delete(key),
});
});
- afterEach(() => vi.unstubAllGlobals());
+ afterEach(() => {
+ window.dispatchEvent(new Event("blur"));
+ document.querySelectorAll("[data-column-header-drag-preview]").forEach((element) => element.remove());
+ vi.unstubAllGlobals();
+ });
it("builds cumulative offsets", () => {
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
});
@@ -35,6 +40,47 @@ describe("useDataGridColumnLayout", () => {
});
});
+ it("accelerates column drag scrolling toward the viewport edges", () => {
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 100, viewportLeft: 0, viewportRight: 400 })).toBe(0);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 32, viewportLeft: 0, viewportRight: 400 })).toBeLessThan(0);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: -10, viewportLeft: 0, viewportRight: 400 })).toBe(-24);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 368, viewportLeft: 0, viewportRight: 400 })).toBeGreaterThan(0);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 410, viewportLeft: 0, viewportRight: 400 })).toBe(24);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 410, viewportLeft: 0, viewportRight: 400, maxStep: 0 })).toBe(0);
+ expect(columnHeaderDragAutoScrollDelta({ clientX: 85, viewportLeft: 50, viewportRight: 100 })).toBeGreaterThan(0);
+ });
+
+ it("calculates drag targets after excluding the source column", () => {
+ const state = {
+ columnWidths: [100, 100, 100, 100],
+ columnOffsets: [0, 100, 200, 300, 400],
+ };
+
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 150 })).toBe(0);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 151 })).toBe(1);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 251 })).toBe(2);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 3, direction: -1, pointerContentX: 250 })).toBe(3);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 3, direction: -1, pointerContentX: 249 })).toBe(2);
+ });
+
+ it("uses the shifted column midpoint when reversing direction", () => {
+ const state = {
+ sourceVisibleIndex: 0,
+ columnWidths: [100, 100, 100, 100],
+ columnOffsets: [0, 100, 200, 300, 400],
+ };
+
+ expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 151 })).toBe(2);
+ expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 150 })).toBe(2);
+ expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 149 })).toBe(1);
+ expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 1, direction: -1, pointerContentX: 51 })).toBe(1);
+ expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 1, direction: -1, pointerContentX: 49 })).toBe(0);
+
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 249 })).toBe(1);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 250 })).toBe(1);
+ expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 251 })).toBe(2);
+ });
+
it("owns visibility, null-column toggles, and persisted ordering", () => {
const scope = effectScope();
const state = scope.run(() =>
@@ -253,7 +299,7 @@ describe("useDataGridColumnLayout", () => {
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
- return 1;
+ return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
@@ -297,6 +343,244 @@ describe("useDataGridColumnLayout", () => {
expect(persist).toHaveBeenCalledTimes(1);
});
+ it("delays rightward sibling previews until the dragged column reaches the target slot", () => {
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 0;
+ }),
+ );
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+
+ const header = document.createElement("div");
+ for (let index = 0; index < 3; index++) {
+ const column = document.createElement("div");
+ column.dataset.visibleColIndex = String(index);
+ column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
+ header.append(column);
+ }
+
+ const scope = effectScope();
+ const layout = scope.run(() =>
+ useDataGridColumnLayout({
+ columnNames: ref(["a", "b", "c"]),
+ visibleColumnIndexes: ref([0, 1, 2]),
+ renderedColumnWidths: ref([100, 100, 100]),
+ scrollLeft: ref(0),
+ viewportWidth: ref(300),
+ rowNumberWidth: 0,
+ headerRef: ref(header),
+ }),
+ )!;
+
+ layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([70, 0, 0]);
+
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 150, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([100, 0, 0]);
+
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 151, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([101, -100, 0]);
+
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 150, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([100, -100, 0]);
+
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 51, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([1, -100, 0]);
+
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 49, clientY: 10 }));
+ expect(layout.columnHeaderPreviewOffsets.value).toEqual([-1, 0, 0]);
+ scope.stop();
+ });
+
+ it("keeps a detached drag preview after the source header leaves the virtual window", () => {
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 0;
+ }),
+ );
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+
+ const header = document.createElement("div");
+ const source = document.createElement("div");
+ source.dataset.visibleColIndex = "0";
+ source.textContent = "source_column";
+ source.getBoundingClientRect = () => ({ left: 40, width: 120, right: 160, top: 20, bottom: 50, height: 30, x: 40, y: 20, toJSON: () => ({}) });
+ header.append(source);
+
+ const scope = effectScope();
+ const layout = scope.run(() =>
+ useDataGridColumnLayout({
+ columnNames: ref(["source_column", "target_column"]),
+ visibleColumnIndexes: ref([0, 1]),
+ renderedColumnWidths: ref([120, 120]),
+ scrollLeft: ref(0),
+ viewportWidth: ref(240),
+ rowNumberWidth: 40,
+ headerRef: ref(header),
+ }),
+ )!;
+
+ layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 80, clientY: 30 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 30 }));
+ const preview = document.body.querySelector("[data-column-header-drag-preview]");
+ expect(preview?.textContent).toBe("source_column");
+ expect(preview?.style.width).toBe("120px");
+
+ source.remove();
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 360, clientY: 30 }));
+ expect(document.body.querySelector("[data-column-header-drag-preview]")).toBe(preview);
+ expect(preview?.style.transform).toBe("translateX(280px)");
+
+ window.dispatchEvent(new PointerEvent("pointerup", { clientX: 360, clientY: 30 }));
+ expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
+
+ header.append(source);
+ layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 80, clientY: 30 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 30 }));
+ expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
+ window.dispatchEvent(new Event("blur"));
+ expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
+ scope.stop();
+ });
+
+ it("auto-scrolls while dragging a column to the first and last positions", () => {
+ const frames = new Map();
+ let nextFrame = 1;
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ const frame = nextFrame++;
+ frames.set(frame, callback);
+ return frame;
+ }),
+ );
+ vi.stubGlobal(
+ "cancelAnimationFrame",
+ vi.fn((frame: number) => frames.delete(frame)),
+ );
+
+ const scroller = document.createElement("div");
+ Object.defineProperties(scroller, {
+ clientWidth: { configurable: true, value: 220 },
+ scrollWidth: { configurable: true, value: 500 },
+ });
+ scroller.getBoundingClientRect = () => ({ left: 0, width: 220, right: 220, top: 20, bottom: 200, height: 180, x: 0, y: 20, toJSON: () => ({}) });
+
+ const header = document.createElement("div");
+ for (let index = 0; index < 5; index++) {
+ const column = document.createElement("div");
+ column.dataset.visibleColIndex = String(index);
+ column.getBoundingClientRect = () => ({ left: index * 100 - scroller.scrollLeft, width: 100, right: (index + 1) * 100 - scroller.scrollLeft, top: 0, bottom: 20, height: 20, x: index * 100 - scroller.scrollLeft, y: 0, toJSON: () => ({}) });
+ header.append(column);
+ }
+ const persist = vi.fn();
+ const syncScroll = vi.fn();
+ const scope = effectScope();
+ const layout = scope.run(() =>
+ useDataGridColumnLayout({
+ columnNames: ref(["a", "b", "c", "d", "e"]),
+ visibleColumnIndexes: ref([0, 1, 2, 3, 4]),
+ renderedColumnWidths: ref([100, 100, 100, 100, 100]),
+ scrollLeft: ref(0),
+ viewportWidth: ref(220),
+ rowNumberWidth: 0,
+ headerRef: ref(header),
+ getScrollElement: () => scroller,
+ onHorizontalScroll: syncScroll,
+ onPersistColumnOrder: persist,
+ }),
+ )!;
+
+ layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 218, clientY: 10 }));
+ for (let iteration = 0; iteration < 30 && frames.size > 0; iteration++) {
+ const [frame, callback] = frames.entries().next().value!;
+ frames.delete(frame);
+ callback(iteration * 16);
+ }
+
+ expect(scroller.scrollLeft).toBe(280);
+ expect(syncScroll).toHaveBeenCalled();
+ window.dispatchEvent(new PointerEvent("pointerup", { clientX: 218, clientY: 10 }));
+ expect(persist).toHaveBeenCalledWith([1, 2, 3, 4, 0]);
+ expect(frames.size).toBe(0);
+
+ scroller.scrollLeft = 280;
+ layout.startColumnHeaderDrag(4, new PointerEvent("pointerdown", { button: 0, clientX: 170, clientY: 10 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 2, clientY: 10 }));
+ for (let iteration = 0; iteration < 30 && frames.size > 0; iteration++) {
+ const [frame, callback] = frames.entries().next().value!;
+ frames.delete(frame);
+ callback(iteration * 16);
+ }
+
+ expect(scroller.scrollLeft).toBe(0);
+ window.dispatchEvent(new PointerEvent("pointerup", { clientX: 2, clientY: 10 }));
+ expect(persist).toHaveBeenLastCalledWith([4, 0, 1, 2, 3]);
+ expect(frames.size).toBe(0);
+ scope.stop();
+ });
+
+ it("uses the edge after frozen columns as the left auto-scroll trigger", () => {
+ const frames = new Map();
+ let nextFrame = 1;
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ const frame = nextFrame++;
+ frames.set(frame, callback);
+ return frame;
+ }),
+ );
+ vi.stubGlobal(
+ "cancelAnimationFrame",
+ vi.fn((frame: number) => frames.delete(frame)),
+ );
+
+ const scroller = document.createElement("div");
+ scroller.scrollLeft = 200;
+ Object.defineProperties(scroller, {
+ clientWidth: { configurable: true, value: 400 },
+ scrollWidth: { configurable: true, value: 800 },
+ });
+ scroller.getBoundingClientRect = () => ({ left: 0, width: 400, right: 400, top: 20, bottom: 200, height: 180, x: 0, y: 20, toJSON: () => ({}) });
+
+ const header = document.createElement("div");
+ const column = document.createElement("div");
+ column.dataset.visibleColIndex = "2";
+ column.getBoundingClientRect = () => ({ left: 240, width: 100, right: 340, top: 0, bottom: 20, height: 20, x: 240, y: 0, toJSON: () => ({}) });
+ header.append(column);
+
+ const scope = effectScope();
+ const layout = scope.run(() =>
+ useDataGridColumnLayout({
+ columnNames: ref(["a", "b", "c", "d", "e", "f", "g"]),
+ visibleColumnIndexes: ref([0, 1, 2, 3, 4, 5, 6]),
+ renderedColumnWidths: ref([100, 100, 100, 100, 100, 100, 100]),
+ scrollLeft: ref(200),
+ viewportWidth: ref(400),
+ rowNumberWidth: 40,
+ frozenColumnCount: ref(2),
+ headerRef: ref(header),
+ getScrollElement: () => scroller,
+ }),
+ )!;
+
+ layout.startColumnHeaderDrag(2, new PointerEvent("pointerdown", { button: 0, clientX: 260, clientY: 10 }));
+ window.dispatchEvent(new PointerEvent("pointermove", { clientX: 235, clientY: 10 }));
+ const [frame, callback] = frames.entries().next().value!;
+ frames.delete(frame);
+ callback(0);
+
+ expect(scroller.scrollLeft).toBeLessThan(200);
+ scope.stop();
+ });
+
describe("frozen columns", () => {
it("starts with frozenColumnCount of 0", () => {
const scope = effectScope();
diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts
index b559d3d2d8..93892647ca 100644
--- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts
@@ -40,6 +40,19 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent
}
describe("useDataGridSelection", () => {
+ it("keeps selected columns and the range anchor attached to their columns after reordering", () => {
+ const selection = createSelection();
+
+ selection.selectColumn(0);
+ selection.selectColumn(2, rowEvent({ meta: true }));
+ selection.remapColumnSelection([0, 1, 2], [1, 2, 0]);
+
+ expect(selection.selectedColumnIndexes.value).toEqual(new Set([1, 2]));
+
+ selection.selectColumn(0, rowEvent({ shift: true }));
+ expect(selection.selectedColumnIndexes.value).toEqual(new Set([0, 1, 2]));
+ });
+
it("invalidates synthetic context state for ordinary, Ctrl, and Cmd cell selection", () => {
const originalDocument = globalThis.document;
const fakeDocument = {
diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts
index 7aa7e7df08..363a350fc0 100644
--- a/apps/desktop/src/composables/useDataGridColumnLayout.ts
+++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts
@@ -1,6 +1,6 @@
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
-import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
+import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
loadDataGridColumnLayout,
loadDataGridColumnFrozenState,
@@ -42,7 +42,13 @@ type ColumnHeaderDragState = {
startX: number;
startY: number;
currentX: number;
+ startScrollLeft: number;
+ currentScrollLeft: number;
+ dragCenterClientOffsetX: number;
+ lastClientX: number;
+ direction: -1 | 0 | 1;
columnRects: { visibleIndex: number; left: number; width: number }[];
+ previewElement: HTMLElement | null;
dragging: boolean;
};
@@ -408,12 +414,14 @@ export function useDataGridColumnLayout(options: {
rowNumberWidth: MaybeRefOrGetter;
bufferPx?: number;
headerRef?: MaybeRefOrGetter;
+ getScrollElement?: () => HTMLElement | null;
orderedColumnIndexes?: MaybeRefOrGetter;
hiddenColumnIndexes?: MaybeRefOrGetter>;
getIsResizing?: () => boolean;
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
onCanvasMouseLeave?: () => void;
onCanvasDrawSchedule?: () => void;
+ onHorizontalScroll?: (element: HTMLElement) => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter;
@@ -561,13 +569,75 @@ export function useDataGridColumnLayout(options: {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
}
- function columnHeaderDropTargetVisibleIndex(clientX: number): number {
+ function columnHeaderPointerContentX(clientX: number, scroller?: HTMLElement | null): number {
+ if (!scroller) return clientX;
+ const viewport = scroller.getBoundingClientRect();
+ const frozen = frozenColumnCount.value;
+ const pointerViewportX = clientX - viewport.left;
+ const frozenRight = toValue(options.rowNumberWidth) + (renderedColumnOffsets.value[frozen] ?? 0);
+ return frozen > 0 && pointerViewportX < frozenRight ? pointerViewportX - toValue(options.rowNumberWidth) : scroller.scrollLeft + pointerViewportX - toValue(options.rowNumberWidth);
+ }
+
+ function columnHeaderDropTargetVisibleIndex(clientX: number, scroller = options.getScrollElement?.()): number {
const state = columnHeaderDragState.value;
- if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
- for (const rect of state.columnRects) {
- if (clientX < rect.left + rect.width / 2) return rect.visibleIndex;
+ if (!state) return 0;
+ const visibleColumnCount = toValue(options.visibleColumnIndexes).length;
+ const movement = clientX - state.lastClientX;
+ if (Math.abs(movement) >= 0.5) state.direction = movement < 0 ? -1 : 1;
+ state.lastClientX = clientX;
+ const dragCenterClientX = clientX + state.dragCenterClientOffsetX;
+ const dragCenterContentX = columnHeaderPointerContentX(dragCenterClientX, scroller);
+ if (scroller) {
+ const viewport = scroller.getBoundingClientRect();
+ const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
+ if (state.direction < 0 && scroller.scrollLeft <= 0 && clientX <= viewport.left + 64) return 0;
+ if (state.direction > 0 && scroller.scrollLeft >= maxScrollLeft - 0.5 && clientX >= viewport.right - 64) return Math.max(0, visibleColumnCount - 1);
+
+ const widths = toValue(options.renderedColumnWidths);
+ return columnHeaderDropTargetIndex({ pointerContentX: dragCenterContentX, sourceVisibleIndex: state.sourceVisibleIndex, currentTargetIndex: state.targetVisibleIndex, direction: state.direction, columnWidths: widths, columnOffsets: renderedColumnOffsets.value });
}
- return toValue(options.visibleColumnIndexes).length;
+ if (state.columnRects.length === 0) return state.sourceVisibleIndex;
+ const widths = state.columnRects.map((rect) => rect.width);
+ const offsets = state.columnRects.map((rect) => rect.left);
+ return Math.min(Math.max(0, visibleColumnCount - 1), columnHeaderDropTargetIndex({ pointerContentX: dragCenterContentX, sourceVisibleIndex: state.sourceVisibleIndex, currentTargetIndex: state.targetVisibleIndex, direction: state.direction, columnWidths: widths, columnOffsets: offsets }));
+ }
+
+ function createColumnHeaderDragPreview(state: ColumnHeaderDragState) {
+ if (state.previewElement) return;
+ const header = toValue(options.headerRef);
+ const source = header?.querySelector(`[data-visible-col-index="${state.sourceVisibleIndex}"]`);
+ if (!source) return;
+ const rect = source.getBoundingClientRect();
+ const preview = source.cloneNode(true) as HTMLElement;
+ preview.dataset.columnHeaderDragPreview = "";
+ preview.removeAttribute("data-visible-col-index");
+ preview.setAttribute("aria-hidden", "true");
+ preview.inert = true;
+ Object.assign(preview.style, {
+ position: "fixed",
+ left: `${rect.left}px`,
+ top: `${rect.top}px`,
+ width: `${rect.width}px`,
+ height: `${rect.height}px`,
+ margin: "0",
+ transform: "translateX(0)",
+ transition: "none",
+ zIndex: "100",
+ pointerEvents: "none",
+ });
+ preview.classList.add("shadow-lg", "ring-1", "ring-primary/40");
+ document.body.append(preview);
+ state.previewElement = preview;
+ }
+
+ function updateColumnHeaderDragPreview(state: ColumnHeaderDragState) {
+ if (!state.previewElement) return;
+ state.previewElement.style.transform = `translateX(${state.currentX - state.startX}px)`;
+ }
+
+ function removeColumnHeaderDragPreview(state: ColumnHeaderDragState) {
+ state.previewElement?.remove();
+ state.previewElement = null;
}
function applyColumnHeaderDragPreview() {
@@ -575,8 +645,28 @@ export function useDataGridColumnLayout(options: {
const state = columnHeaderDragState.value;
if (!state?.dragging) return;
state.currentX = columnHeaderPendingClientX;
- state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
+ updateColumnHeaderDragPreview(state);
+ const scroller = options.getScrollElement?.();
+ if (scroller) state.currentScrollLeft = scroller.scrollLeft;
+ state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX, scroller);
+ let keepScrolling = false;
+ if (scroller) {
+ const viewport = scroller.getBoundingClientRect();
+ const frozenWidth = renderedColumnOffsets.value[frozenColumnCount.value] ?? 0;
+ const scrollViewportLeft = Math.min(viewport.right, viewport.left + toValue(options.rowNumberWidth) + frozenWidth);
+ const scrollDelta = columnHeaderDragAutoScrollDelta({ clientX: columnHeaderPendingClientX, viewportLeft: scrollViewportLeft, viewportRight: viewport.right });
+ const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroller.scrollLeft + scrollDelta));
+ if (Math.abs(nextScrollLeft - scroller.scrollLeft) >= 0.5) {
+ scroller.scrollLeft = nextScrollLeft;
+ state.currentScrollLeft = scroller.scrollLeft;
+ options.onHorizontalScroll?.(scroller);
+ state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX, scroller);
+ keepScrolling = true;
+ }
+ }
options.onCanvasDrawSchedule?.();
+ if (keepScrolling) columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
}
function scheduleColumnHeaderDragPreview(clientX: number) {
@@ -613,7 +703,9 @@ export function useDataGridColumnLayout(options: {
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
+ window.removeEventListener("blur", onColumnHeaderPointerCancel, true);
cancelColumnHeaderDragPreview();
+ removeColumnHeaderDragPreview(state);
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (hadCanvasPreview) options.onCanvasDrawSchedule?.();
@@ -637,6 +729,7 @@ export function useDataGridColumnLayout(options: {
state.dragging = true;
document.body.style.userSelect = "none";
options.onCanvasMouseLeave?.();
+ createColumnHeaderDragPreview(state);
}
if (!state.dragging) return;
event.preventDefault();
@@ -655,19 +748,31 @@ export function useDataGridColumnLayout(options: {
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
+ const scroller = options.getScrollElement?.();
+ const scrollLeft = scroller?.scrollLeft ?? 0;
+ const columnRects = columnHeaderLayoutRects();
+ const sourceRect = columnRects.find((rect) => rect.visibleIndex === visibleColIdx);
+ const dragCenterClientOffsetX = sourceRect ? sourceRect.left + sourceRect.width / 2 - event.clientX : 0;
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
- columnRects: columnHeaderLayoutRects(),
+ startScrollLeft: scrollLeft,
+ currentScrollLeft: scrollLeft,
+ dragCenterClientOffsetX,
+ lastClientX: event.clientX,
+ direction: 0,
+ columnRects,
+ previewElement: null,
dragging: false,
};
columnHeaderPendingClientX = event.clientX;
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
+ window.addEventListener("blur", onColumnHeaderPointerCancel, true);
}
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
@@ -681,19 +786,20 @@ export function useDataGridColumnLayout(options: {
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 };
+ return { "opacity-0 pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state) return 0;
+ const scrollCompensation = state.sourceVisibleIndex < frozenColumnCount.value ? 0 : state.currentScrollLeft - state.startScrollLeft;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
visibleColIdx,
sourceVisibleIndex: state.sourceVisibleIndex,
targetVisibleIndex: state.targetVisibleIndex,
startX: state.startX,
- currentX: state.currentX,
+ currentX: state.currentX + scrollCompensation,
sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0,
});
}
diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts
index 06e033e025..7a9802403a 100644
--- a/apps/desktop/src/composables/useDataGridSelection.ts
+++ b/apps/desktop/src/composables/useDataGridSelection.ts
@@ -247,6 +247,15 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
lastClickedColumnIndex.value = colIndex;
}
+ function remapColumnSelection(previousColumnIndexes: readonly number[], nextColumnIndexes: readonly number[]) {
+ const selectedColumns = new Set([...selectedColumnIndexes.value].map((index) => previousColumnIndexes[index]).filter((index): index is number => index !== undefined));
+ selectedColumnIndexes.value = new Set(nextColumnIndexes.flatMap((columnIndex, index) => (selectedColumns.has(columnIndex) ? [index] : [])));
+
+ const lastClickedColumn = lastClickedColumnIndex.value === null ? undefined : previousColumnIndexes[lastClickedColumnIndex.value];
+ const nextLastClickedColumnIndex = lastClickedColumn === undefined ? -1 : nextColumnIndexes.indexOf(lastClickedColumn);
+ lastClickedColumnIndex.value = nextLastClickedColumnIndex >= 0 ? nextLastClickedColumnIndex : null;
+ }
+
function selectAllCells() {
const range = allCellsSelectionRange(displayItems.value.length, columns.value.length);
if (!range) return;
@@ -579,6 +588,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectRow,
selectColumn,
selectColumns,
+ remapColumnSelection,
selectAllCells,
extendCellSelectionTo,
finishCellSelection,
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnHeaderInteraction.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnHeaderInteraction.ts
index ae052345af..dd5a777220 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnHeaderInteraction.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnHeaderInteraction.ts
@@ -26,6 +26,77 @@ export function columnHeaderClickShouldBeSuppressed(state: ColumnHeaderClickGuar
return state.suppressNextClick || state.now < state.guardUntil;
}
+export interface ColumnHeaderDragAutoScrollState {
+ clientX: number;
+ viewportLeft: number;
+ viewportRight: number;
+ threshold?: number;
+ maxStep?: number;
+}
+
+export function columnHeaderDragAutoScrollDelta(state: ColumnHeaderDragAutoScrollState): number {
+ const threshold = Math.max(1, state.threshold ?? 64);
+ const maxStep = Math.max(0, state.maxStep ?? 24);
+ if (maxStep === 0) return 0;
+ const leftDistance = state.clientX - state.viewportLeft;
+ const rightDistance = state.viewportRight - state.clientX;
+ const leftActive = leftDistance < threshold;
+ const rightActive = rightDistance < threshold;
+ const direction = leftActive && (!rightActive || leftDistance <= rightDistance) ? -1 : rightActive ? 1 : 0;
+ const edgeDistance = direction < 0 ? leftDistance : direction > 0 ? rightDistance : null;
+ if (edgeDistance === null) return 0;
+
+ const intensity = Math.min(1, Math.max(0, (threshold - edgeDistance) / threshold));
+ return direction * Math.max(1, Math.round(maxStep * intensity * intensity));
+}
+
+export interface ColumnHeaderDropTargetState {
+ pointerContentX: number;
+ sourceVisibleIndex: number;
+ currentTargetIndex: number;
+ direction: -1 | 0 | 1;
+ columnWidths: readonly number[];
+ columnOffsets: readonly number[];
+}
+
+export function columnHeaderDropTargetIndex(state: ColumnHeaderDropTargetState): number {
+ const otherColumnIndexes = state.columnWidths.map((_, visibleIndex) => visibleIndex).filter((visibleIndex) => visibleIndex !== state.sourceVisibleIndex);
+ const maxTargetIndex = otherColumnIndexes.length;
+ let targetIndex = Math.max(0, Math.min(state.currentTargetIndex, maxTargetIndex));
+ const sourceWidth = state.columnWidths[state.sourceVisibleIndex] ?? 0;
+
+ function previewOffset(columnIndex: number): number {
+ return columnHeaderPreviewOffsetForColumn({
+ columnDragActive: true,
+ visibleColIdx: columnIndex,
+ sourceVisibleIndex: state.sourceVisibleIndex,
+ targetVisibleIndex: targetIndex,
+ startX: 0,
+ currentX: 0,
+ sourceWidth,
+ });
+ }
+
+ if (state.direction > 0) {
+ while (targetIndex < maxTargetIndex) {
+ const columnIndex = otherColumnIndexes[targetIndex];
+ const width = state.columnWidths[columnIndex] ?? 0;
+ const threshold = (state.columnOffsets[columnIndex] ?? 0) + width / 2 + previewOffset(columnIndex);
+ if (state.pointerContentX <= threshold) break;
+ targetIndex++;
+ }
+ } else if (state.direction < 0) {
+ while (targetIndex > 0) {
+ const columnIndex = otherColumnIndexes[targetIndex - 1];
+ const width = state.columnWidths[columnIndex] ?? 0;
+ const threshold = (state.columnOffsets[columnIndex] ?? 0) + width / 2 + previewOffset(columnIndex);
+ if (state.pointerContentX >= threshold) break;
+ targetIndex--;
+ }
+ }
+ return targetIndex;
+}
+
export interface ColumnHeaderPreviewState {
columnDragActive: boolean;
}