fix(grid): unify data grid scrollbars

This commit is contained in:
t8y2 2026-06-15 15:53:51 +08:00
parent 60e49128cb
commit 8ee5dd9d35
1 changed files with 134 additions and 28 deletions

View File

@ -1397,16 +1397,25 @@ const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const gridScrollbarGutter = ref(0);
const gridHorizontalScrollbarTrackRef = ref<HTMLDivElement>();
const gridVerticalScrollbarTrackRef = ref<HTMLDivElement>();
const hasGridHorizontalOverflow = ref(false);
const hasGridVerticalOverflow = ref(false);
const gridHorizontalScrollbarThumbLeftPercent = ref(0);
const gridHorizontalScrollbarThumbWidthPercent = ref(100);
const gridVerticalScrollbarThumbTopPercent = ref(0);
const gridVerticalScrollbarThumbHeightPercent = ref(100);
const gridHorizontalScrollbarDragging = ref(false);
const gridVerticalScrollbarDragging = ref(false);
let gridHorizontalScrollbarFrame = 0;
let gridHorizontalScrollbarResizeObserver: ResizeObserver | null = null;
let gridHorizontalScrollbarDragState: {
trackRect: DOMRect;
thumbOffsetPx: number;
} | null = null;
let gridVerticalScrollbarDragState: {
trackRect: DOMRect;
thumbOffsetPx: number;
} | null = null;
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
@ -1565,6 +1574,7 @@ function updateGridHorizontalViewport(element: HTMLElement) {
gridHorizontalScrollLeft.value = element.scrollLeft;
gridViewportWidth.value = element.clientWidth;
updateGridHorizontalScrollbar(element);
updateGridVerticalScrollbar(element);
}
function gridScrollerElement(): HTMLElement | null {
@ -1589,11 +1599,30 @@ function updateGridHorizontalScrollbar(element: HTMLElement | null = gridScrolle
gridHorizontalScrollbarThumbLeftPercent.value = maxScrollLeft > 0 ? (element.scrollLeft / maxScrollLeft) * thumbTravel : 0;
}
function updateGridVerticalScrollbar(element: HTMLElement | null = gridScrollerElement()) {
if (!element) {
hasGridVerticalOverflow.value = false;
gridVerticalScrollbarThumbTopPercent.value = 0;
gridVerticalScrollbarThumbHeightPercent.value = 100;
return;
}
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
hasGridVerticalOverflow.value = maxScrollTop > 1;
const rawThumbHeight = element.scrollHeight > 0 ? (element.clientHeight / element.scrollHeight) * 100 : 100;
const thumbHeight = Math.min(100, Math.max(6, rawThumbHeight));
const thumbTravel = Math.max(0, 100 - thumbHeight);
gridVerticalScrollbarThumbHeightPercent.value = thumbHeight;
gridVerticalScrollbarThumbTopPercent.value = maxScrollTop > 0 ? (element.scrollTop / maxScrollTop) * thumbTravel : 0;
}
function scheduleGridHorizontalScrollbarUpdate() {
if (gridHorizontalScrollbarFrame) return;
const update = () => {
gridHorizontalScrollbarFrame = 0;
updateGridHorizontalScrollbar();
updateGridVerticalScrollbar();
};
if (typeof requestAnimationFrame === "function") {
gridHorizontalScrollbarFrame = requestAnimationFrame(update);
@ -1651,6 +1680,16 @@ function stopGridHorizontalScrollbarDrag() {
document.body.style.userSelect = "";
}
function stopGridVerticalScrollbarDrag() {
if (!gridVerticalScrollbarDragState) return;
gridVerticalScrollbarDragState = null;
gridVerticalScrollbarDragging.value = false;
window.removeEventListener("pointermove", onGridVerticalScrollbarPointerMove, true);
window.removeEventListener("pointerup", stopGridVerticalScrollbarDrag, true);
window.removeEventListener("pointercancel", stopGridVerticalScrollbarDrag, true);
document.body.style.userSelect = "";
}
function startGridHorizontalScrollbarDrag(event: PointerEvent) {
const scroller = gridScrollerElement();
const track = gridHorizontalScrollbarTrackRef.value;
@ -1675,11 +1714,62 @@ function startGridHorizontalScrollbarDrag(event: PointerEvent) {
applyGridHorizontalScrollbarDrag(event.clientX);
}
function applyGridVerticalScrollbarDrag(clientY: number) {
const scroller = gridScrollerElement();
const dragState = gridVerticalScrollbarDragState;
if (!scroller || !dragState) return;
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (maxScrollTop <= 1) return;
const thumbHeightPx = dragState.trackRect.height * (gridVerticalScrollbarThumbHeightPercent.value / 100);
const maxThumbTopPx = Math.max(1, dragState.trackRect.height - thumbHeightPx);
const thumbTopPx = Math.min(maxThumbTopPx, Math.max(0, clientY - dragState.trackRect.top - dragState.thumbOffsetPx));
scroller.scrollTop = (thumbTopPx / maxThumbTopPx) * maxScrollTop;
updateGridVerticalScrollbar(scroller);
if (useCanvasGridRows.value) syncCanvasViewport();
}
function onGridVerticalScrollbarPointerMove(event: PointerEvent) {
if (!gridVerticalScrollbarDragState) return;
event.preventDefault();
applyGridVerticalScrollbarDrag(event.clientY);
}
function startGridVerticalScrollbarDrag(event: PointerEvent) {
const scroller = gridScrollerElement();
const track = gridVerticalScrollbarTrackRef.value;
if (!scroller || !track || !hasGridVerticalOverflow.value) return;
const trackRect = track.getBoundingClientRect();
const thumbTopPx = trackRect.height * (gridVerticalScrollbarThumbTopPercent.value / 100);
const thumbHeightPx = trackRect.height * (gridVerticalScrollbarThumbHeightPercent.value / 100);
const pointerY = event.clientY - trackRect.top;
const pointerInsideThumb = pointerY >= thumbTopPx && pointerY <= thumbTopPx + thumbHeightPx;
gridVerticalScrollbarDragState = {
trackRect,
thumbOffsetPx: pointerInsideThumb ? pointerY - thumbTopPx : thumbHeightPx / 2,
};
gridVerticalScrollbarDragging.value = true;
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onGridVerticalScrollbarPointerMove, true);
window.addEventListener("pointerup", stopGridVerticalScrollbarDrag, true);
window.addEventListener("pointercancel", stopGridVerticalScrollbarDrag, true);
event.preventDefault();
applyGridVerticalScrollbarDrag(event.clientY);
}
const gridHorizontalScrollbarThumbStyle = computed<CSSProperties>(() => ({
width: `${gridHorizontalScrollbarThumbWidthPercent.value}%`,
left: `${gridHorizontalScrollbarThumbLeftPercent.value}%`,
}));
const gridVerticalScrollbarThumbStyle = computed<CSSProperties>(() => ({
height: `${gridVerticalScrollbarThumbHeightPercent.value}%`,
top: `${gridVerticalScrollbarThumbTopPercent.value}%`,
}));
function updateGridScrollbarGutter(element: HTMLElement) {
gridScrollbarGutter.value = scrollbarGutterWidth(element);
}
@ -3585,6 +3675,7 @@ function onCanvasScroll(event: Event) {
if (gridHorizontalScrollLeft.value !== scrollLeft || gridViewportWidth.value !== viewportWidth) {
updateGridHorizontalViewport(scroller);
}
updateGridVerticalScrollbar(scroller);
const gutter = scrollbarGutterWidth(scroller);
if (gridScrollbarGutter.value !== gutter) gridScrollbarGutter.value = gutter;
if (headerRef.value && headerRef.value.scrollLeft !== scrollLeft) headerRef.value.scrollLeft = scrollLeft;
@ -3936,6 +4027,7 @@ onUnmounted(() => {
pauseCanvasGridWork();
gridHorizontalScrollbarResizeObserver?.disconnect();
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
if (gridHorizontalScrollbarFrame && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(gridHorizontalScrollbarFrame);
}
@ -6908,6 +7000,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div v-if="hasGridHorizontalOverflow" ref="gridHorizontalScrollbarTrackRef" class="data-grid-horizontal-scrollbar" :class="{ 'data-grid-horizontal-scrollbar--dragging': gridHorizontalScrollbarDragging }" @pointerdown="startGridHorizontalScrollbarDrag">
<div class="data-grid-horizontal-scrollbar__thumb" :style="gridHorizontalScrollbarThumbStyle" />
</div>
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" :class="{ 'data-grid-vertical-scrollbar--dragging': gridVerticalScrollbarDragging }" @pointerdown="startGridVerticalScrollbarDrag">
<div class="data-grid-vertical-scrollbar__thumb" :style="gridVerticalScrollbarThumbStyle" />
</div>
<div v-if="loading" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
<div class="flex items-center gap-2 px-3 py-1.5 rounded-md bg-background border shadow-sm text-xs text-muted-foreground">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
@ -7705,6 +7800,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-number-deleted-bg: rgb(255 244 244);
--data-grid-row-number-active-bg: rgb(232 232 232);
--data-grid-row-number-selected-bg: rgb(226 226 226);
--data-grid-scrollbar-thumb: color-mix(in oklch, var(--foreground) 30%, transparent);
--data-grid-scrollbar-thumb-hover: color-mix(in oklch, var(--foreground) 48%, transparent);
--data-grid-scrollbar-track: transparent;
}
:global(.dark) [data-grid-root] {
@ -7761,34 +7859,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
scrollbar-gutter: stable;
will-change: scroll-position;
contain: layout style paint;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, var(--foreground) 30%, transparent) transparent;
scrollbar-width: none;
}
.data-grid-scroller::-webkit-scrollbar {
width: 10px;
height: 0;
}
.data-grid-scroller::-webkit-scrollbar:horizontal {
height: 0;
}
.data-grid-scroller::-webkit-scrollbar-track {
background: transparent;
}
.data-grid-scroller::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
background-clip: content-box;
}
.data-grid-scroller::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--foreground) 48%, transparent);
background-clip: content-box;
display: none;
}
.data-grid-scroller :deep(.vue-recycle-scroller__item-wrapper) {
@ -7821,7 +7896,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
top: 4px;
height: 2px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 7%, transparent);
background: var(--data-grid-scrollbar-track);
}
.data-grid-horizontal-scrollbar__thumb {
@ -7830,7 +7905,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
height: 4px;
min-width: 24px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 24%, transparent);
background: var(--data-grid-scrollbar-thumb);
transition:
height 120ms ease,
background-color 120ms ease,
@ -7841,7 +7916,38 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
.data-grid-horizontal-scrollbar--dragging .data-grid-horizontal-scrollbar__thumb {
top: 2px;
height: 6px;
background: color-mix(in oklch, var(--foreground) 42%, transparent);
background: var(--data-grid-scrollbar-thumb-hover);
}
.data-grid-vertical-scrollbar {
position: absolute;
top: 10px;
right: 2px;
bottom: 14px;
z-index: 30;
width: 10px;
cursor: pointer;
touch-action: none;
}
.data-grid-vertical-scrollbar__thumb {
position: absolute;
left: 3px;
width: 4px;
min-height: 24px;
border-radius: 999px;
background: var(--data-grid-scrollbar-thumb);
transition:
background-color 120ms ease,
left 120ms ease,
width 120ms ease;
}
.data-grid-vertical-scrollbar:hover .data-grid-vertical-scrollbar__thumb,
.data-grid-vertical-scrollbar--dragging .data-grid-vertical-scrollbar__thumb {
left: 2px;
width: 6px;
background: var(--data-grid-scrollbar-thumb-hover);
}
.canvas-grid-surface {