fix(sidebar): improve scrollbar hover display

This commit is contained in:
zipg 2026-06-26 01:52:03 +08:00 committed by GitHub
parent 6646b432d3
commit 4d000c88fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 190 additions and 14 deletions

View File

@ -30,6 +30,7 @@ const searchInputRef = ref<HTMLInputElement>();
const pointerInsideTree = ref(false);
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
const sidebarScrollbarTrackRef = ref<HTMLElement | null>(null);
type SearchScope = "connection" | "database" | "schema" | "table" | "view";
const selectedSearchScopes = ref<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(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 <TreeItem>, 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<FlatTreeNode | null>(() => {
if (!useVirtualTree.value || isFiltering.value) return null;
const nodes = flatNodes.value;
@ -263,10 +309,88 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
// 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<CSSProperties>(() => {
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 });
<div v-if="stickyNode" class="sticky-database-header pointer-events-auto absolute inset-x-0 top-0 z-[5] border-b border-border/60" :style="stickyHeaderStyle">
<TreeItem :node="stickyNode.node" :depth="stickyNode.depth" :drag-disabled="true" @search-toggle="onSearchToggle" />
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
<div v-else-if="flatNodes.length > 0" ref="plainTreeScrollerRef" class="sidebar-tree min-h-0 flex-1 overflow-y-auto" :class="sidebarTreeOverflowClass" @click="clearSidebarSelection">
<TreeItem
v-for="item in flatNodes"
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
/>
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
<TreeItem
v-for="item in flatNodes"
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
/>
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("sidebar.noConnections") }}
@ -740,6 +875,10 @@ defineExpose({ focusSearch, createNewGroup });
contain: content;
}
.connection-tree-scroller::-webkit-scrollbar:vertical {
width: 0;
}
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
min-width: 100%;
contain: style;
@ -748,4 +887,41 @@ defineExpose({ focusSearch, createNewGroup });
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.vue-recycle-scroller__item-view) {
width: max-content;
}
.sidebar-tree-scrollbar {
position: absolute;
top: 4px;
right: 0;
bottom: 4px;
z-index: 10;
width: 12px;
cursor: default;
opacity: 0;
transition: opacity 120ms ease;
}
.sidebar-tree-scrollbar:hover,
.sidebar-tree-scrollbar--dragging {
opacity: 1;
}
.sidebar-tree-scrollbar__thumb {
position: absolute;
right: 2px;
width: 6px;
min-height: 24px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
transition:
background-color 120ms ease,
width 120ms ease,
right 120ms ease;
}
.sidebar-tree-scrollbar:hover .sidebar-tree-scrollbar__thumb,
.sidebar-tree-scrollbar--dragging .sidebar-tree-scrollbar__thumb {
right: 1px;
width: 8px;
background: color-mix(in oklch, var(--foreground) 48%, transparent);
}
</style>