fix(grid): smooth canvas column interactions

This commit is contained in:
t8y2 committed 2026-07-08 09:45:28 +08:00
1 parent 0747134dc2
commit d2874bf1bb
6 files changed
+424 -127

No files matched your search

+160 -19
View File
@@ -132,6 +132,7 @@ import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexe
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, loadDataGridColumnOrder, removeDataGridColumnOrder, saveDataGridColumnOrder } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { parseClipboardTable, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
@@ -3019,7 +3020,78 @@ type ColumnHeaderDragState = {
dragging: boolean;
};
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
const columnHeaderResizeActive = ref(false);
let columnHeaderDragClickGuardUntil = 0;
let columnHeaderSuppressNextClick = false;
let columnHeaderSuppressClickTimer = 0;
let columnHeaderDragFrame = 0;
let columnHeaderPendingClientX = 0;
let columnHeaderResizeListenersCleanup: (() => void) | null = null;
const columnHeaderTooltipsDisabled = computed(() =>
columnHeaderTooltipDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
}),
);
function columnHeaderPointerInteractionActive(): boolean {
return columnHeaderCanvasPointerDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
});
}
function finishColumnHeaderResizeInteraction() {
clearColumnHeaderResizeListeners();
requestAnimationFrame(() => {
columnHeaderResizeActive.value = false;
});
}
function clearColumnHeaderResizeListeners() {
if (!columnHeaderResizeListenersCleanup) return;
columnHeaderResizeListenersCleanup();
columnHeaderResizeListenersCleanup = null;
}
function clearColumnHeaderClickGuard() {
columnHeaderSuppressNextClick = false;
columnHeaderDragClickGuardUntil = 0;
if (columnHeaderSuppressClickTimer) {
window.clearTimeout(columnHeaderSuppressClickTimer);
columnHeaderSuppressClickTimer = 0;
}
}
function armColumnHeaderClickGuard() {
clearColumnHeaderClickGuard();
columnHeaderSuppressNextClick = true;
columnHeaderDragClickGuardUntil = Date.now() + 800;
columnHeaderSuppressClickTimer = window.setTimeout(() => {
clearColumnHeaderClickGuard();
}, 800);
}
function startColumnHeaderResize(visibleColIdx: number, event: MouseEvent) {
// Browser hover state can stick to multiple header cells during pointer drags,
// so keep header tooltips closed until the resize gesture has fully settled.
clearColumnHeaderResizeListeners();
columnHeaderResizeActive.value = true;
armColumnHeaderClickGuard();
onCanvasMouseLeave();
const finishResize = () => {
armColumnHeaderClickGuard();
finishColumnHeaderResizeInteraction();
};
columnHeaderResizeListenersCleanup = () => {
window.removeEventListener("mouseup", finishResize, true);
window.removeEventListener("blur", finishResize, true);
};
window.addEventListener("mouseup", finishResize, true);
window.addEventListener("blur", finishResize, true);
onResizeStart(visibleColIdx, event);
}
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
@@ -3036,6 +3108,32 @@ function columnHeaderDropTargetVisibleIndex(clientX: number): number {
return visibleColumnIndexes.value.length;
}
function applyColumnHeaderDragPreview() {
columnHeaderDragFrame = 0;
const state = columnHeaderDragState.value;
if (!state?.dragging) return;
state.currentX = columnHeaderPendingClientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
if (useCanvasGridRows.value) scheduleCanvasDraw();
}
function scheduleColumnHeaderDragPreview(clientX: number) {
columnHeaderPendingClientX = clientX;
if (columnHeaderDragFrame) return;
columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
}
function flushColumnHeaderDragPreview() {
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
applyColumnHeaderDragPreview();
}
function cancelColumnHeaderDragPreview() {
if (!columnHeaderDragFrame) return;
cancelAnimationFrame(columnHeaderDragFrame);
columnHeaderDragFrame = 0;
}
function columnHeaderLayoutRects(): { visibleIndex: number; left: number; width: number }[] {
const header = headerRef.value;
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
@@ -3053,12 +3151,15 @@ function columnHeaderLayoutRects(): { visibleIndex: number; left: number; width:
function stopColumnHeaderDrag(commit: boolean) {
const state = columnHeaderDragState.value;
if (!state) return;
const hadCanvasPreview = useCanvasGridRows.value && state.dragging;
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
cancelColumnHeaderDragPreview();
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (state.dragging) columnHeaderDragClickGuardUntil = Date.now() + 250;
if (hadCanvasPreview) scheduleCanvasDraw();
if (state.dragging) armColumnHeaderClickGuard();
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
const next = moveVisibleColumnIndex({
orderedIndexes: orderedDisplayableColumnIndexes.value,
@@ -3077,14 +3178,16 @@ function onColumnHeaderPointerMove(event: PointerEvent) {
if (!state.dragging && moved) {
state.dragging = true;
document.body.style.userSelect = "none";
onCanvasMouseLeave();
}
if (!state.dragging) return;
event.preventDefault();
state.currentX = event.clientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(event.clientX);
scheduleColumnHeaderDragPreview(event.clientX);
}
function onColumnHeaderPointerUp() {
function onColumnHeaderPointerUp(event: PointerEvent) {
columnHeaderPendingClientX = event.clientX;
flushColumnHeaderDragPreview();
stopColumnHeaderDrag(true);
}
@@ -3103,17 +3206,35 @@ function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
columnRects: columnHeaderLayoutRects(),
dragging: false,
};
columnHeaderPendingClientX = event.clientX;
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;
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
if (
!columnHeaderClickShouldBeSuppressed({
now: Date.now(),
guardUntil: columnHeaderDragClickGuardUntil,
suppressNextClick: columnHeaderSuppressNextClick,
})
) {
return false;
}
clearColumnHeaderClickGuard();
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return true;
}
function onHeaderClickCapture(event: MouseEvent) {
suppressHeaderClickIfNeeded(event);
}
function onHeaderClick(visibleColIdx: number, event: MouseEvent) {
if (suppressHeaderClickIfNeeded(event)) return;
selectColumn(visibleColIdx, event);
}
@@ -3126,14 +3247,16 @@ function columnHeaderDragClass(visibleColIdx: number) {
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;
if (!state) return 0;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
visibleColIdx,
sourceVisibleIndex: state.sourceVisibleIndex,
targetVisibleIndex: state.targetVisibleIndex,
startX: state.startX,
currentX: state.currentX,
sourceWidth: renderedColumnWidths.value[state.sourceVisibleIndex] ?? 0,
});
}
function columnHeaderStyle(visibleColIdx: number) {
@@ -3147,6 +3270,12 @@ function columnHeaderStyle(visibleColIdx: number) {
};
}
const columnHeaderPreviewOffsets = computed(() => renderedColumnWidths.value.map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
const state = columnHeaderDragState.value;
return state?.dragging ? state.sourceVisibleIndex : null;
});
function columnContentOffsetLeft(visibleColIdx: number): number {
return DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
}
@@ -5462,6 +5591,11 @@ function onCanvasWheel(event: WheelEvent) {
}
function onCanvasMouseMove(event: MouseEvent) {
if (columnHeaderPointerInteractionActive()) {
if (canvasRef.value) canvasRef.value.style.cursor = "default";
onCanvasMouseLeave();
return;
}
const hit = canvasHitTest(event);
const hitItem = hit ? displayItemAt(hit.rowIndex) : undefined;
const next = hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null;
@@ -5698,6 +5832,8 @@ function drawCanvasGrid() {
rowAt: displayItemAt,
renderedColumnWidths: renderedColumnWidths.value,
renderedColumnOffsets: renderedColumnOffsets.value,
columnPreviewOffsets: columnHeaderPreviewOffsets.value,
columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value,
visibleColumnIndexes: visibleColumnIndexes.value,
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
hoverCell: canvasHoverCell.value,
@@ -7795,6 +7931,10 @@ onDeactivated(() => {
onUnmounted(() => {
cleanupFrames();
stopAutoRefreshTimer();
cancelColumnHeaderDragPreview();
clearColumnHeaderResizeListeners();
clearColumnHeaderClickGuard();
columnHeaderResizeActive.value = false;
onSearchSplitResizeEnd();
onDdlResizeEnd();
onDetailResizeEnd();
@@ -9016,7 +9156,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
#
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<LightTooltip v-for="col in renderedGridColumns" :key="`${col.name}-${col.actualColIdx}`" :text="col.name" side="bottom" :side-offset="4">
<LightTooltip v-for="col in renderedGridColumns" :key="`${col.name}-${col.actualColIdx}`" :text="col.name" side="bottom" :side-offset="4" :disabled="columnHeaderTooltipsDisabled">
<div
class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-gray-200 dark:hover:bg-gray-800 select-none relative overflow-hidden"
:class="{
@@ -9028,6 +9168,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:data-grid-column-index="col.actualColIdx"
:data-visible-col-index="col.visibleColIdx"
@pointerdown="startColumnHeaderDrag(col.visibleColIdx, $event)"
@click.capture="onHeaderClickCapture"
@click="onHeaderClick(col.visibleColIdx, $event)"
@contextmenu="onHeaderContext(col.name, col.actualColIdx)"
>
@@ -9352,7 +9493,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<Database class="h-3.5 w-3.5" />
</button>
</span>
<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 data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="startColumnHeaderResize(col.visibleColIdx, $event)" @click.stop.prevent @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">