fix(grid): smooth canvas column interactions
This commit is contained in:
1 parent
0747134dc2
commit
d2874bf1bb
6 files changed
+424
-127
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user