fix(sidebar): add draggable horizontal scrollbar

This commit is contained in:
zipg 2026-07-25 19:40:49 +08:00 committed by GitHub
parent cff987f748
commit 30641f3c89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 351 additions and 32 deletions

View File

@ -41,8 +41,9 @@ import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNodeComment } from "@/lib/sidebar/sidebarTreeItemLayout";
import { sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
const { t } = useI18n();
const store = useConnectionStore();
@ -58,6 +59,7 @@ const pointerInsideTree = ref(false);
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
const sidebarScrollbarTrackRef = ref<HTMLElement | null>(null);
const sidebarHorizontalScrollbarTrackRef = ref<HTMLElement | null>(null);
const sidebarContextMenuRef = ref<{ close: () => void } | null>(null);
const sidebarContextMenuItems = ref<ContextMenuItem[]>([]);
const sidebarContextMenuTarget = ref<SidebarActionTarget | null>(null);
@ -377,7 +379,10 @@ const flatNodes = computed<FlatTreeNode[]>(() =>
const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
let sidebarCommentMeasureFrame = 0;
const sidebarTreeContentWidth = ref(0);
let sidebarTreeContentMeasureFrame = 0;
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
const sidebarStorageDisplayTypes = new Set<TreeNodeType>(["database", "table", "materialized_view"]);
function sidebarCommentLabel(node: TreeNode): string {
const label = sidebarTableNameDisplayTypes.has(node.type) ? sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes) : node.label;
@ -415,11 +420,55 @@ function scheduleSidebarCommentLabelMeasure() {
sidebarCommentMeasureFrame = window.requestAnimationFrame(measureSidebarCommentLabelWidths);
}
function sidebarNodeHasTrailingMetadata(node: TreeNode): boolean {
const mode = settingsStore.editorSettings.sidebarObjectInfoMode;
if (mode.startsWith("comment-") && sidebarTreeNodeComment(node)) return true;
return mode === "size" && sidebarStorageDisplayTypes.has(node.type) && !!formatSidebarObjectStorage(node.sizeBytes);
}
const sidebarTreeNaturalWidthItems = computed(() =>
flatNodes.value.map(({ depth, node }) => ({
depth,
label: sidebarCommentLabel(node),
usesNaturalWidth: usesFullWidthTreeLabel(node.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, sidebarNodeHasTrailingMetadata(node)),
trailingWidth: node.pinned || store.isTreeNodePinned(node) ? 20 : 0,
})),
);
function measureSidebarTreeContentWidth() {
sidebarTreeContentMeasureFrame = 0;
if (!settingsStore.editorSettings.sidebarAllowHorizontalScroll || typeof document === "undefined" || !rootRef.value) {
sidebarTreeContentWidth.value = 0;
return;
}
const context = document.createElement("canvas").getContext("2d");
if (!context) return;
const style = window.getComputedStyle(rootRef.value);
context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
sidebarTreeContentWidth.value = sidebarTreeNaturalContentWidth(sidebarTreeNaturalWidthItems.value, (text) => context.measureText(text).width);
void nextTick(scheduleSidebarScrollMetricsUpdate);
}
function scheduleSidebarTreeContentWidthMeasure() {
if (typeof window === "undefined") {
measureSidebarTreeContentWidth();
return;
}
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
sidebarTreeContentMeasureFrame = window.requestAnimationFrame(measureSidebarTreeContentWidth);
}
watch([flatNodes, () => settingsStore.editorSettings.sidebarObjectInfoMode, () => settingsStore.editorSettings.sidebarHiddenTablePrefixes, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarCommentLabelMeasure, {
flush: "post",
immediate: true,
});
watch([sidebarTreeNaturalWidthItems, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarTreeContentWidthMeasure, {
flush: "post",
immediate: true,
});
const visibleSidebarTableStorageScopes = computed(() => {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return [];
return sidebarTableStorageScopes(flatNodes.value.map(({ node }) => node)).filter((scope) => supportsSidebarTableStorage(store.getConfig(scope.connectionId)));
@ -454,26 +503,31 @@ 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 sidebarScrollMetrics = ref({ scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 });
const isScrollingSidebar = ref(false);
const isDraggingSidebarScrollbar = ref(false);
const isDraggingSidebarHorizontalScrollbar = ref(false);
let sidebarScrollbarResizeObserver: ResizeObserver | null = null;
let sidebarScrollbarAnimationFrame = 0;
let sidebarScrollbarDragOffset = 0;
let sidebarHorizontalScrollbarDragOffset = 0;
let sidebarScrollingTimer = 0;
function updateSidebarScrollMetrics() {
const scroller = currentTreeScroller();
if (!scroller) {
sidebarScrollMetrics.value = { scrollTop: 0, clientHeight: 0, scrollHeight: 0 };
sidebarScrollMetrics.value = { scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 };
return;
}
if (useVirtualTree.value) stickyScrollTop.value = scroller.scrollTop;
sidebarScrollMetrics.value = {
scrollTop: scroller.scrollTop,
scrollLeft: scroller.scrollLeft,
clientHeight: scroller.clientHeight,
clientWidth: scroller.clientWidth,
scrollHeight: scroller.scrollHeight,
scrollWidth: Math.max(scroller.scrollWidth, sidebarTreeContentWidth.value),
};
}
@ -515,6 +569,8 @@ watch(
sidebarScrollbarResizeObserver = new ResizeObserver(scheduleSidebarScrollMetricsUpdate);
sidebarScrollbarResizeObserver.observe(scroller);
const content = scroller.querySelector<HTMLElement>(".connection-tree-content");
if (content) sidebarScrollbarResizeObserver.observe(content);
scheduleSidebarScrollMetricsUpdate();
onCleanup(() => {
@ -566,21 +622,32 @@ watch(flatNodes, () => {
});
const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden"));
const sidebarTreeScrollerStyle = computed<CSSProperties>(() => ({ "--sidebar-tree-content-width": `${sidebarTreeContentWidth.value}px` }) as CSSProperties);
const hasSidebarVerticalOverflow = computed(() => sidebarScrollMetrics.value.scrollHeight > sidebarScrollMetrics.value.clientHeight + 1);
const hasSidebarHorizontalOverflow = computed(() => settingsStore.editorSettings.sidebarAllowHorizontalScroll && sidebarScrollMetrics.value.scrollWidth > sidebarScrollMetrics.value.clientWidth + 1);
watch(
() => settingsStore.editorSettings.sidebarAllowHorizontalScroll,
(enabled) =>
void nextTick(() => {
const scroller = currentTreeScroller();
if (!enabled && scroller) scroller.scrollLeft = 0;
scheduleSidebarScrollMetricsUpdate();
}),
{ flush: "post" },
);
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 { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({
scrollOffset: scrollTop,
viewportSize: clientHeight,
contentSize: scrollHeight,
trackSize: trackHeight,
});
return { thumbTop: thumbOffset, thumbHeight: thumbSize, maxThumbTop: maxThumbOffset, maxScrollTop: maxScrollOffset };
}
const sidebarScrollbarThumbStyle = computed<CSSProperties>(() => {
@ -644,6 +711,79 @@ function onSidebarScrollbarThumbPointerDown(event: PointerEvent) {
window.addEventListener("pointercancel", stopSidebarScrollbarDrag);
}
function sidebarHorizontalScrollbarGeometry() {
const { scrollLeft, clientWidth, scrollWidth } = sidebarScrollMetrics.value;
const trackWidth = sidebarHorizontalScrollbarTrackRef.value?.clientWidth ?? clientWidth;
const { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({
scrollOffset: scrollLeft,
viewportSize: clientWidth,
contentSize: scrollWidth,
trackSize: trackWidth,
});
return { thumbLeft: thumbOffset, thumbWidth: thumbSize, maxThumbLeft: maxThumbOffset, maxScrollLeft: maxScrollOffset };
}
const sidebarHorizontalScrollbarThumbStyle = computed<CSSProperties>(() => {
const { thumbLeft, thumbWidth } = sidebarHorizontalScrollbarGeometry();
return {
width: `${thumbWidth}px`,
transform: `translateX(${thumbLeft}px)`,
};
});
function setSidebarHorizontalScrollFromPointer(clientX: number, offset: number) {
const scroller = currentTreeScroller();
const track = sidebarHorizontalScrollbarTrackRef.value;
if (!scroller || !track) return;
const rect = track.getBoundingClientRect();
const { maxThumbLeft, maxScrollLeft } = sidebarHorizontalScrollbarGeometry();
if (maxThumbLeft <= 0) return;
const thumbLeft = Math.min(maxThumbLeft, Math.max(0, clientX - rect.left - offset));
scroller.scrollLeft = (thumbLeft / maxThumbLeft) * maxScrollLeft;
updateSidebarScrollMetrics();
}
function stopSidebarHorizontalScrollbarDrag() {
isDraggingSidebarHorizontalScrollbar.value = false;
window.removeEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
window.removeEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
window.removeEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
}
function onSidebarHorizontalScrollbarPointerMove(event: PointerEvent) {
event.preventDefault();
setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset);
}
function onSidebarHorizontalScrollbarTrackPointerDown(event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
const { thumbWidth } = sidebarHorizontalScrollbarGeometry();
sidebarHorizontalScrollbarDragOffset = thumbWidth / 2;
setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset);
isDraggingSidebarHorizontalScrollbar.value = true;
window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
}
function onSidebarHorizontalScrollbarThumbPointerDown(event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
const track = sidebarHorizontalScrollbarTrackRef.value;
if (!track) return;
const rect = track.getBoundingClientRect();
const { thumbLeft } = sidebarHorizontalScrollbarGeometry();
sidebarHorizontalScrollbarDragOffset = event.clientX - rect.left - thumbLeft;
isDraggingSidebarHorizontalScrollbar.value = true;
window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
}
const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
@ -1440,10 +1580,12 @@ onUnmounted(() => {
tableSearchFocusRestoreTokens.clear();
latestTableSearchInteractionParentId = null;
stopSidebarScrollbarDrag();
stopSidebarHorizontalScrollbarDrag();
sidebarScrollbarResizeObserver?.disconnect();
window.cancelAnimationFrame(sidebarScrollbarAnimationFrame);
window.clearTimeout(sidebarScrollingTimer);
if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame);
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
});
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
@ -1541,11 +1683,12 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</div>
</div>
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
<div v-if="flatNodes.length > 0 && useVirtualTree" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<div v-if="flatNodes.length > 0 && useVirtualTree" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
<RecycleScroller
ref="treeScrollerRef"
class="sidebar-tree connection-tree-scroller h-full overflow-y-auto"
:class="sidebarTreeOverflowClass"
:style="sidebarTreeScrollerStyle"
@click="clearSidebarSelection"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
@ -1554,6 +1697,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:skip-hover="true"
key-field="id"
type-field="poolType"
list-class="connection-tree-content"
flow-mode
>
<template #default="{ item }">
@ -1573,29 +1717,61 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<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" :comment-label-width="sidebarCommentLabelWidths.get(stickyNode.node.id)" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" />
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div
v-if="hasSidebarVerticalOverflow"
ref="sidebarScrollbarTrackRef"
class="sidebar-tree-scrollbar"
:class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar, 'sidebar-tree-scrollbar--with-horizontal': hasSidebarHorizontalOverflow }"
@pointerdown="onSidebarScrollbarTrackPointerDown"
>
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
<div
v-if="hasSidebarHorizontalOverflow"
ref="sidebarHorizontalScrollbarTrackRef"
class="sidebar-tree-horizontal-scrollbar"
:class="{ 'sidebar-tree-horizontal-scrollbar--with-vertical': hasSidebarVerticalOverflow, 'sidebar-tree-horizontal-scrollbar--dragging': isDraggingSidebarHorizontalScrollbar }"
@pointerdown="onSidebarHorizontalScrollbarTrackPointerDown"
>
<div class="sidebar-tree-horizontal-scrollbar__thumb" :style="sidebarHorizontalScrollbarThumbStyle" @pointerdown.stop="onSidebarHorizontalScrollbarThumbPointerDown" />
</div>
</div>
<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="isRootListPartial || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
/>
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" :style="sidebarTreeScrollerStyle" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
<div class="connection-tree-content">
<TreeItem
v-for="item in flatNodes"
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
/>
</div>
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div
v-if="hasSidebarVerticalOverflow"
ref="sidebarScrollbarTrackRef"
class="sidebar-tree-scrollbar"
:class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar, 'sidebar-tree-scrollbar--with-horizontal': hasSidebarHorizontalOverflow }"
@pointerdown="onSidebarScrollbarTrackPointerDown"
>
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
<div
v-if="hasSidebarHorizontalOverflow"
ref="sidebarHorizontalScrollbarTrackRef"
class="sidebar-tree-horizontal-scrollbar"
:class="{ 'sidebar-tree-horizontal-scrollbar--with-vertical': hasSidebarVerticalOverflow, 'sidebar-tree-horizontal-scrollbar--dragging': isDraggingSidebarHorizontalScrollbar }"
@pointerdown="onSidebarHorizontalScrollbarTrackPointerDown"
>
<div class="sidebar-tree-horizontal-scrollbar__thumb" :style="sidebarHorizontalScrollbarThumbStyle" @pointerdown.stop="onSidebarHorizontalScrollbarThumbPointerDown" />
</div>
</div>
</CustomContextMenu>
<SidebarDdlViewDialog
@ -1743,6 +1919,15 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
width: max-content;
}
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.connection-tree-content),
.connection-tree-scroller.sidebar-tree-horizontal-scroll > .connection-tree-content {
min-width: max(100%, var(--sidebar-tree-content-width));
}
.connection-tree-scroll-shell--horizontal-overflow .connection-tree-scroller {
height: calc(100% - 10px);
}
.sidebar-tree-scrollbar {
position: absolute;
top: 0;
@ -1755,6 +1940,10 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
transition: opacity 120ms ease;
}
.sidebar-tree-scrollbar--with-horizontal {
bottom: 10px;
}
.sidebar-tree-scrollbar--scrolling,
.sidebar-tree-scrollbar:hover,
.sidebar-tree-scrollbar--dragging {
@ -1780,4 +1969,40 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
width: 8px;
background: color-mix(in oklch, var(--foreground) 48%, transparent);
}
.sidebar-tree-horizontal-scrollbar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
height: 10px;
cursor: default;
background: color-mix(in oklch, var(--muted) 45%, transparent);
}
.sidebar-tree-horizontal-scrollbar--with-vertical {
right: 10px;
}
.sidebar-tree-horizontal-scrollbar__thumb {
position: absolute;
bottom: 2px;
left: 0;
height: 6px;
min-width: 24px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 34%, transparent);
transition:
background-color 120ms ease,
height 120ms ease,
bottom 120ms ease;
}
.sidebar-tree-horizontal-scrollbar:hover .sidebar-tree-horizontal-scrollbar__thumb,
.sidebar-tree-horizontal-scrollbar--dragging .sidebar-tree-horizontal-scrollbar__thumb {
bottom: 1px;
height: 8px;
background: color-mix(in oklch, var(--foreground) 50%, transparent);
}
</style>

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { sidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
describe("sidebarScrollbarGeometry", () => {
it("returns no thumb when content fits the viewport", () => {
expect(sidebarScrollbarGeometry({ scrollOffset: 0, viewportSize: 300, contentSize: 300, trackSize: 300 })).toEqual({
thumbOffset: 0,
thumbSize: 0,
maxThumbOffset: 0,
maxScrollOffset: 0,
});
});
it("maps horizontal content scrolling onto the draggable track", () => {
expect(sidebarScrollbarGeometry({ scrollOffset: 150, viewportSize: 300, contentSize: 600, trackSize: 300 })).toEqual({
thumbOffset: 75,
thumbSize: 150,
maxThumbOffset: 150,
maxScrollOffset: 300,
});
});
it("keeps the thumb usable for very wide content and clamps its position", () => {
expect(sidebarScrollbarGeometry({ scrollOffset: 10_000, viewportSize: 200, contentSize: 10_000, trackSize: 200 })).toEqual({
thumbOffset: 176,
thumbSize: 24,
maxThumbOffset: 176,
maxScrollOffset: 9_800,
});
});
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { alignedSidebarCommentLabelWidths, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { alignedSidebarCommentLabelWidths, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
describe("sidebar tree item layout", () => {
it("keeps a table row constrained when it displays a comment", () => {
@ -31,4 +31,21 @@ describe("sidebar tree item layout", () => {
expect(trailingCommentAvailableWidth(100, 100)).toBe(0);
expect(trailingCommentAvailableWidth(99, 100)).toBe(0);
});
it("keeps the natural width of the widest node in the complete virtual tree", () => {
const width = sidebarTreeNaturalContentWidth(
[
{ depth: 1, label: "visible", usesNaturalWidth: true },
{ depth: 4, label: "widest-node-outside-the-mounted-window", usesNaturalWidth: true, trailingWidth: 20 },
{ depth: 8, label: "constrained metadata row", usesNaturalWidth: false },
],
(text) => text.length * 7,
);
expect(width).toBe(4 * 16 + 8 + 54 + "widest-node-outside-the-mounted-window".length * 7 + 20);
});
it("returns zero when no tree row uses natural width", () => {
expect(sidebarTreeNaturalContentWidth([{ depth: 2, label: "commented", usesNaturalWidth: false }], (text) => text.length * 7)).toBe(0);
});
});

View File

@ -0,0 +1,26 @@
export interface SidebarScrollbarGeometryOptions {
scrollOffset: number;
viewportSize: number;
contentSize: number;
trackSize: number;
minThumbSize?: number;
}
export interface SidebarScrollbarGeometry {
thumbOffset: number;
thumbSize: number;
maxThumbOffset: number;
maxScrollOffset: number;
}
export function sidebarScrollbarGeometry({ scrollOffset, viewportSize, contentSize, trackSize, minThumbSize = 24 }: SidebarScrollbarGeometryOptions): SidebarScrollbarGeometry {
if (trackSize <= 0 || contentSize <= viewportSize) {
return { thumbOffset: 0, thumbSize: 0, maxThumbOffset: 0, maxScrollOffset: 0 };
}
const thumbSize = Math.max(minThumbSize, Math.min(trackSize, (viewportSize / contentSize) * trackSize));
const maxThumbOffset = Math.max(0, trackSize - thumbSize);
const maxScrollOffset = Math.max(1, contentSize - viewportSize);
const thumbOffset = Math.min(maxThumbOffset, Math.max(0, (scrollOffset / maxScrollOffset) * maxThumbOffset));
return { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset };
}

View File

@ -71,6 +71,26 @@ export interface SidebarCommentAlignmentItem {
labelWidth: number;
}
export interface SidebarTreeNaturalWidthItem {
depth: number;
label: string;
usesNaturalWidth: boolean;
trailingWidth?: number;
}
// Right padding, expander/icon widths and the two flex gaps before the label.
const sidebarTreeRowChromeWidth = 54;
export function sidebarTreeNaturalContentWidth(items: readonly SidebarTreeNaturalWidthItem[], measureText: (text: string) => number): number {
let width = 0;
for (const item of items) {
if (!item.usesNaturalWidth) continue;
const paddingLeft = item.depth * 16 + 8;
width = Math.max(width, Math.ceil(paddingLeft + sidebarTreeRowChromeWidth + measureText(item.label) + (item.trailingWidth ?? 0)));
}
return width;
}
export function alignedSidebarCommentLabelWidths(items: readonly SidebarCommentAlignmentItem[]): Map<string, number> {
const ancestorIds: string[] = [];
const parentIdByCommentId = new Map<string, string>();