From 4d000c88fa7f9ace06efd047b94f868cc2e13c9e Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 26 Jun 2026 01:52:03 +0800 Subject: [PATCH] fix(sidebar): improve scrollbar hover display --- .../src/components/sidebar/ConnectionTree.vue | 204 ++++++++++++++++-- 1 file changed, 190 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 2e78c94d6..352cf7f88 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -30,6 +30,7 @@ const searchInputRef = ref(); const pointerInsideTree = ref(false); const treeScrollerRef = ref | null>(null); const plainTreeScrollerRef = ref(null); +const sidebarScrollbarTrackRef = ref(null); type SearchScope = "connection" | "database" | "schema" | "table" | "view"; const selectedSearchScopes = ref([]); const searchCollapsedIds = ref>(new Set()); @@ -206,10 +207,34 @@ const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryS // component, tracking scroll offset to find the topmost visible database-level // ancestor. The overlay reuses , so collapse/expand comes for free. const stickyScrollTop = ref(0); +const sidebarScrollMetrics = ref({ scrollTop: 0, clientHeight: 0, scrollHeight: 0 }); +const isDraggingSidebarScrollbar = ref(false); +let sidebarScrollbarResizeObserver: ResizeObserver | null = null; +let sidebarScrollbarAnimationFrame = 0; +let sidebarScrollbarDragOffset = 0; + +function updateSidebarScrollMetrics() { + const scroller = currentTreeScroller(); + if (!scroller) { + sidebarScrollMetrics.value = { scrollTop: 0, clientHeight: 0, scrollHeight: 0 }; + return; + } + + if (useVirtualTree.value) stickyScrollTop.value = scroller.scrollTop; + sidebarScrollMetrics.value = { + scrollTop: scroller.scrollTop, + clientHeight: scroller.clientHeight, + scrollHeight: scroller.scrollHeight, + }; +} + +function scheduleSidebarScrollMetricsUpdate() { + window.cancelAnimationFrame(sidebarScrollbarAnimationFrame); + sidebarScrollbarAnimationFrame = window.requestAnimationFrame(updateSidebarScrollMetrics); +} function onTreeScroll() { - const scroller = (treeScrollerRef.value?.$el as HTMLElement | undefined) ?? null; - if (scroller) stickyScrollTop.value = scroller.scrollTop; + scheduleSidebarScrollMetricsUpdate(); } // RecycleScroller only emits scrollStart/scrollEnd, not continuous scroll, so @@ -225,6 +250,27 @@ watch( { flush: "post" }, ); +watch( + [treeScrollerRef, plainTreeScrollerRef, useVirtualTree], + (_value, _oldValue, onCleanup) => { + sidebarScrollbarResizeObserver?.disconnect(); + sidebarScrollbarResizeObserver = null; + + const scroller = currentTreeScroller(); + if (!scroller) return; + + sidebarScrollbarResizeObserver = new ResizeObserver(scheduleSidebarScrollMetricsUpdate); + sidebarScrollbarResizeObserver.observe(scroller); + scheduleSidebarScrollMetricsUpdate(); + + onCleanup(() => { + sidebarScrollbarResizeObserver?.disconnect(); + sidebarScrollbarResizeObserver = null; + }); + }, + { flush: "post" }, +); + const stickyNode = computed(() => { if (!useVirtualTree.value || isFiltering.value) return null; const nodes = flatNodes.value; @@ -263,10 +309,88 @@ const stickyHeaderStyle = computed(() => { // stale scrollTop doesn't keep the overlay mounted after a structural change. watch(flatNodes, () => { stickyScrollTop.value = 0; + void nextTick(scheduleSidebarScrollMetricsUpdate); }); const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden")); +const hasSidebarVerticalOverflow = computed(() => sidebarScrollMetrics.value.scrollHeight > sidebarScrollMetrics.value.clientHeight + 1); + +function sidebarScrollbarGeometry() { + const { scrollTop, clientHeight, scrollHeight } = sidebarScrollMetrics.value; + const trackHeight = sidebarScrollbarTrackRef.value?.clientHeight ?? Math.max(0, clientHeight - 8); + if (trackHeight <= 0 || scrollHeight <= clientHeight) { + return { thumbTop: 0, thumbHeight: 0, maxThumbTop: 0, maxScrollTop: 0 }; + } + + const thumbHeight = Math.max(24, Math.min(trackHeight, (clientHeight / scrollHeight) * trackHeight)); + const maxThumbTop = Math.max(0, trackHeight - thumbHeight); + const maxScrollTop = Math.max(1, scrollHeight - clientHeight); + const thumbTop = Math.min(maxThumbTop, Math.max(0, (scrollTop / maxScrollTop) * maxThumbTop)); + return { thumbTop, thumbHeight, maxThumbTop, maxScrollTop }; +} + +const sidebarScrollbarThumbStyle = computed(() => { + const { thumbTop, thumbHeight } = sidebarScrollbarGeometry(); + return { + height: `${thumbHeight}px`, + transform: `translateY(${thumbTop}px)`, + }; +}); + +function setSidebarScrollFromPointer(clientY: number, offset: number) { + const scroller = currentTreeScroller(); + const track = sidebarScrollbarTrackRef.value; + if (!scroller || !track) return; + + const rect = track.getBoundingClientRect(); + const { maxThumbTop, maxScrollTop } = sidebarScrollbarGeometry(); + if (maxThumbTop <= 0) return; + + const thumbTop = Math.min(maxThumbTop, Math.max(0, clientY - rect.top - offset)); + scroller.scrollTop = (thumbTop / maxThumbTop) * maxScrollTop; + updateSidebarScrollMetrics(); +} + +function stopSidebarScrollbarDrag() { + isDraggingSidebarScrollbar.value = false; + window.removeEventListener("pointermove", onSidebarScrollbarPointerMove); + window.removeEventListener("pointerup", stopSidebarScrollbarDrag); + window.removeEventListener("pointercancel", stopSidebarScrollbarDrag); +} + +function onSidebarScrollbarPointerMove(event: PointerEvent) { + event.preventDefault(); + setSidebarScrollFromPointer(event.clientY, sidebarScrollbarDragOffset); +} + +function onSidebarScrollbarTrackPointerDown(event: PointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + const { thumbHeight } = sidebarScrollbarGeometry(); + sidebarScrollbarDragOffset = thumbHeight / 2; + setSidebarScrollFromPointer(event.clientY, sidebarScrollbarDragOffset); + isDraggingSidebarScrollbar.value = true; + window.addEventListener("pointermove", onSidebarScrollbarPointerMove); + window.addEventListener("pointerup", stopSidebarScrollbarDrag); + window.addEventListener("pointercancel", stopSidebarScrollbarDrag); +} + +function onSidebarScrollbarThumbPointerDown(event: PointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + const track = sidebarScrollbarTrackRef.value; + if (!track) return; + + const rect = track.getBoundingClientRect(); + const { thumbTop } = sidebarScrollbarGeometry(); + sidebarScrollbarDragOffset = event.clientY - rect.top - thumbTop; + isDraggingSidebarScrollbar.value = true; + window.addEventListener("pointermove", onSidebarScrollbarPointerMove); + window.addEventListener("pointerup", stopSidebarScrollbarDrag); + window.addEventListener("pointercancel", stopSidebarScrollbarDrag); +} + provide(sidebarTreeContextKey, { getVisibleNodes: () => visibleNodes.value, getVisibleNodeIndex: (id: string) => visibleNodeIndexById.value.get(id) ?? -1, @@ -637,6 +761,9 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener("keydown", onWindowKeydown); + stopSidebarScrollbarDrag(); + sidebarScrollbarResizeObserver?.disconnect(); + window.cancelAnimationFrame(sidebarScrollbarAnimationFrame); }); defineExpose({ focusSearch, createNewGroup }); @@ -710,19 +837,27 @@ defineExpose({ focusSearch, createNewGroup });
+ -