fix(grid): improve column drag scrolling and selection

This commit is contained in:
hanxuanyu authored and GitHub committed 2026-08-13 12:41:57 +08:00
1 parent 7eac5dd610
commit e4b9e55883
9 files changed
+539 -16

No files matched your search

+16 -1
View File
@@ -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,
});
@@ -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')"
>
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
<span v-if="!compact">{{ t("grid.columnVisibility") }}</span>
<span v-if="!compact && (grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
@@ -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('<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />');
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<HTMLButtonElement>("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,
@@ -1622,6 +1622,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</template>
</template>
<template #result-toolbar-actions="{ compact }">
<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />
<QueryResultToolbarActions
:active-view="activeOutputView"
:can-show-explain="canShowExplainOutput"
@@ -3,6 +3,7 @@
import { effectScope, nextTick, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
describe("useDataGridColumnLayout", () => {
@@ -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<HTMLElement>("[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<number, FrameRequestCallback>();
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<number, FrameRequestCallback>();
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();
@@ -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 = {
@@ -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<number>;
bufferPx?: number;
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
getScrollElement?: () => HTMLElement | null;
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
hiddenColumnIndexes?: MaybeRefOrGetter<ReadonlySet<number>>;
getIsResizing?: () => boolean;
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
onCanvasMouseLeave?: () => void;
onCanvasDrawSchedule?: () => void;
onHorizontalScroll?: (element: HTMLElement) => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter<number>;
@@ -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<HTMLElement>(`[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,
});
}
@@ -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,
@@ -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;
}