fix(sidebar): improve scrollbar hover display
This commit is contained in:
parent
6646b432d3
commit
4d000c88fa
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue