fix(sidebar): add draggable horizontal scrollbar
This commit is contained in:
parent
cff987f748
commit
30641f3c89
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
Loading…
Reference in New Issue