fix(desktop): stabilize tab overflow scrolling
This commit is contained in:
parent
426b7911bc
commit
9278630fd2
|
|
@ -6,8 +6,6 @@ import {
|
|||
X,
|
||||
Pin,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
Code2,
|
||||
TableProperties,
|
||||
|
|
@ -24,12 +22,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
|||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import { useTabDrag } from "@/composables/useTabDrag";
|
||||
import {
|
||||
connectionColor,
|
||||
shouldShowTabOverflowControls,
|
||||
tabDisplayTitle,
|
||||
tabTooltipLines,
|
||||
} from "@/lib/tabPresentation";
|
||||
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
|
||||
import { hexToRgba } from "@/lib/color";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
|
|
@ -127,8 +120,15 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
|||
}
|
||||
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } =
|
||||
useTabScroll(tabsContainerRef);
|
||||
const {
|
||||
hasTabOverflow,
|
||||
scrollThumbLeftPercent,
|
||||
scrollThumbWidthPercent,
|
||||
isScrollbarDragging,
|
||||
updateScrollButtons,
|
||||
onTabsWheel,
|
||||
startScrollbarDrag,
|
||||
} = useTabScroll(tabsContainerRef);
|
||||
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
|
||||
|
||||
watch(
|
||||
|
|
@ -204,14 +204,7 @@ function tabIconClass(tab: QueryTab) {
|
|||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
const showTabOverflowControls = computed(() =>
|
||||
shouldShowTabOverflowControls(
|
||||
queryStore.tabs.length,
|
||||
hasTabOverflow.value,
|
||||
canScrollLeft.value,
|
||||
canScrollRight.value,
|
||||
),
|
||||
);
|
||||
const showTabOverflowControls = computed(() => queryStore.tabs.length > 0 && hasTabOverflow.value);
|
||||
|
||||
const openTabMenuItems = computed(() =>
|
||||
queryStore.tabs.map((tab) => ({
|
||||
|
|
@ -261,6 +254,11 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
|
|||
WebkitOverflowScrolling: "touch",
|
||||
}));
|
||||
|
||||
const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||||
insetInlineStart: `${scrollThumbLeftPercent.value}%`,
|
||||
width: `${scrollThumbWidthPercent.value}%`,
|
||||
}));
|
||||
|
||||
const tabTailDragRegionClass = computed(() =>
|
||||
showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
|
||||
);
|
||||
|
|
@ -296,159 +294,150 @@ function activateTab(tabId: string) {
|
|||
: 'h-10 items-center bg-background px-2'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-if="showTabOverflowControls"
|
||||
type="button"
|
||||
class="z-30 shrink-0 inline-flex items-center justify-center"
|
||||
:class="[tabOverflowControlClass, canScrollLeft ? '' : 'opacity-40']"
|
||||
:aria-label="t('tabs.scrollLeft')"
|
||||
:title="t('tabs.scrollLeft')"
|
||||
:disabled="!canScrollLeft"
|
||||
@click="scrollTabs('left')"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4 stroke-[2.5]" />
|
||||
</button>
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="flex-1 flex items-center overflow-x-auto min-w-0"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
|
||||
:style="tabsContainerStyle"
|
||||
@scroll="updateScrollButtons"
|
||||
@wheel="onTabsWheel"
|
||||
>
|
||||
<CustomContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
:items="getTabMenuItems(tab)"
|
||||
v-slot="{ onContextMenu }"
|
||||
>
|
||||
<div :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-full border-r border-border/80 dark:border-border/45',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'bg-background text-foreground font-medium'
|
||||
: 'text-foreground/70 hover:text-foreground/90',
|
||||
]
|
||||
: [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-7 rounded-md border',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'text-foreground font-medium'
|
||||
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
|
||||
]
|
||||
"
|
||||
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
|
||||
@click="handleTabClick(tab)"
|
||||
@dblclick.stop="startRenameTab(tab)"
|
||||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||||
@mouseenter="tabDrag.updateTarget($event, tab.id)"
|
||||
@mousemove="tabDrag.updateTarget($event, tab.id)"
|
||||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
v-if="editingTabId === tab.id"
|
||||
v-model="editingTitle"
|
||||
:data-tab-title-input="tab.id"
|
||||
:aria-label="t('contextMenu.renameTab')"
|
||||
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@keydown.enter.prevent="commitRenameTab(tab)"
|
||||
@keydown.escape.prevent="cancelRenameTab"
|
||||
@blur="commitRenameTab(tab)"
|
||||
/>
|
||||
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
|
||||
<!-- Driver Store Tab -->
|
||||
<div class="relative h-full min-w-0 flex-1">
|
||||
<div
|
||||
v-if="showDriverStore"
|
||||
data-driver-store-tab
|
||||
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
|
||||
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
|
||||
"
|
||||
:style="
|
||||
settingsStore.editorSettings.appLayout === 'classic' ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}
|
||||
"
|
||||
@click="emit('toggle-driver-store')"
|
||||
v-if="showTabOverflowControls"
|
||||
class="app-tab-scrollbar"
|
||||
:class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }"
|
||||
@pointerdown="startScrollbarDrag"
|
||||
>
|
||||
<span class="shrink-0 text-amber-600 dark:text-amber-400">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
|
||||
<span
|
||||
v-if="(agentDriverUpdateCount ?? 0) > 0"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
|
||||
:aria-label="t('toolbar.updatableDriverCount')"
|
||||
>
|
||||
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
|
||||
</span>
|
||||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
<div class="app-tab-scrollbar__thumb" :style="tabScrollbarThumbStyle" />
|
||||
</div>
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="app-tab-scroll flex min-w-0 flex-1 items-center overflow-x-auto"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : 'h-10 gap-1.5 py-1.5'"
|
||||
:style="tabsContainerStyle"
|
||||
@scroll="updateScrollButtons"
|
||||
@wheel="onTabsWheel"
|
||||
>
|
||||
<CustomContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
:items="getTabMenuItems(tab)"
|
||||
v-slot="{ onContextMenu }"
|
||||
>
|
||||
<div
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-full border-r border-border/80 font-medium dark:border-border/45',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-foreground/70 hover:text-foreground/90',
|
||||
]
|
||||
: [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-7 rounded-md border',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'text-foreground font-medium'
|
||||
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
|
||||
]
|
||||
"
|
||||
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
|
||||
@click="handleTabClick(tab)"
|
||||
@dblclick.stop="startRenameTab(tab)"
|
||||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||||
@mouseenter="tabDrag.updateTarget($event, tab.id)"
|
||||
@mousemove="tabDrag.updateTarget($event, tab.id)"
|
||||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
v-if="editingTabId === tab.id"
|
||||
v-model="editingTitle"
|
||||
:data-tab-title-input="tab.id"
|
||||
:aria-label="t('contextMenu.renameTab')"
|
||||
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@keydown.enter.prevent="commitRenameTab(tab)"
|
||||
@keydown.escape.prevent="cancelRenameTab"
|
||||
@blur="commitRenameTab(tab)"
|
||||
/>
|
||||
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
|
||||
<!-- Driver Store Tab -->
|
||||
<div
|
||||
v-if="showDriverStore"
|
||||
data-driver-store-tab
|
||||
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
|
||||
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
|
||||
"
|
||||
:style="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? { boxShadow: '0 1px 0 0 var(--color-background)' }
|
||||
: {}
|
||||
"
|
||||
@click="emit('toggle-driver-store')"
|
||||
>
|
||||
<span class="shrink-0 text-amber-600 dark:text-amber-400">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
|
||||
<span
|
||||
v-if="(agentDriverUpdateCount ?? 0) > 0"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
|
||||
:aria-label="t('toolbar.updatableDriverCount')"
|
||||
>
|
||||
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
|
||||
</span>
|
||||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||||
</div>
|
||||
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||||
</div>
|
||||
<div v-if="showTabOverflowControls" class="relative z-30 flex shrink-0 items-center">
|
||||
<button
|
||||
v-if="showTabOverflowControls"
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center"
|
||||
:class="[tabOverflowControlClass, canScrollRight ? '' : 'opacity-40']"
|
||||
:aria-label="t('tabs.scrollRight')"
|
||||
:title="t('tabs.scrollRight')"
|
||||
:disabled="!canScrollRight"
|
||||
@click="scrollTabs('right')"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4 stroke-[2.5]" />
|
||||
</button>
|
||||
<LightDropdown
|
||||
:model-value="queryStore.activeTabId || ''"
|
||||
:items="openTabMenuItems"
|
||||
|
|
@ -471,3 +460,47 @@ function activateTab(tabId: string) {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-tab-scrollbar {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
height: 6px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.app-tab-scrollbar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.app-tab-scrollbar__thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
min-width: 20px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||||
transition:
|
||||
height 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.app-tab-scrollbar:hover .app-tab-scrollbar__thumb,
|
||||
.app-tab-scrollbar--dragging .app-tab-scrollbar__thumb {
|
||||
height: 5px;
|
||||
background: color-mix(in oklch, var(--foreground) 52%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,23 +2,31 @@ import { onBeforeUnmount, ref, watch, type Ref } from "vue";
|
|||
|
||||
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
|
||||
const hasTabOverflow = ref(false);
|
||||
const canScrollLeft = ref(false);
|
||||
const canScrollRight = ref(false);
|
||||
const scrollThumbLeftPercent = ref(0);
|
||||
const scrollThumbWidthPercent = ref(100);
|
||||
const isScrollbarDragging = ref(false);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let updateFrame = 0;
|
||||
let dragState: {
|
||||
trackRect: DOMRect;
|
||||
thumbOffsetPx: number;
|
||||
} | null = null;
|
||||
|
||||
function updateScrollButtons() {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) {
|
||||
hasTabOverflow.value = false;
|
||||
canScrollLeft.value = false;
|
||||
canScrollRight.value = false;
|
||||
scrollThumbLeftPercent.value = 0;
|
||||
scrollThumbWidthPercent.value = 100;
|
||||
return;
|
||||
}
|
||||
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
hasTabOverflow.value = maxScrollLeft > 1;
|
||||
canScrollLeft.value = el.scrollLeft > 1;
|
||||
canScrollRight.value = el.scrollLeft < maxScrollLeft - 1;
|
||||
const rawThumbWidth = el.scrollWidth > 0 ? (el.clientWidth / el.scrollWidth) * 100 : 100;
|
||||
const thumbWidth = Math.min(100, Math.max(8, rawThumbWidth));
|
||||
const thumbTravel = Math.max(0, 100 - thumbWidth);
|
||||
scrollThumbWidthPercent.value = thumbWidth;
|
||||
scrollThumbLeftPercent.value = maxScrollLeft > 0 ? (el.scrollLeft / maxScrollLeft) * thumbTravel : 0;
|
||||
}
|
||||
|
||||
function scheduleScrollButtonUpdate() {
|
||||
|
|
@ -34,15 +42,6 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
|
|||
}
|
||||
}
|
||||
|
||||
function scrollTabs(direction: "left" | "right") {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) return;
|
||||
const scrollAmount = Math.max(160, el.clientWidth * 0.65);
|
||||
el.scrollBy({ left: direction === "left" ? -scrollAmount : scrollAmount, behavior: "smooth" });
|
||||
scheduleScrollButtonUpdate();
|
||||
window.setTimeout(updateScrollButtons, 260);
|
||||
}
|
||||
|
||||
function onTabsWheel(event: WheelEvent) {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el) return;
|
||||
|
|
@ -60,6 +59,63 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
|
|||
}
|
||||
}
|
||||
|
||||
function applyScrollbarDrag(clientX: number) {
|
||||
const el = tabsContainerRef.value;
|
||||
if (!el || !dragState) return;
|
||||
|
||||
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
if (maxScrollLeft <= 1) return;
|
||||
|
||||
const thumbWidthPx = dragState.trackRect.width * (scrollThumbWidthPercent.value / 100);
|
||||
const maxThumbLeftPx = Math.max(1, dragState.trackRect.width - thumbWidthPx);
|
||||
const thumbLeftPx = Math.min(
|
||||
maxThumbLeftPx,
|
||||
Math.max(0, clientX - dragState.trackRect.left - dragState.thumbOffsetPx),
|
||||
);
|
||||
el.scrollLeft = (thumbLeftPx / maxThumbLeftPx) * maxScrollLeft;
|
||||
updateScrollButtons();
|
||||
}
|
||||
|
||||
function onScrollbarPointerMove(event: PointerEvent) {
|
||||
if (!dragState) return;
|
||||
event.preventDefault();
|
||||
applyScrollbarDrag(event.clientX);
|
||||
}
|
||||
|
||||
function stopScrollbarDrag() {
|
||||
if (!dragState) return;
|
||||
dragState = null;
|
||||
isScrollbarDragging.value = false;
|
||||
window.removeEventListener("pointermove", onScrollbarPointerMove, true);
|
||||
window.removeEventListener("pointerup", stopScrollbarDrag, true);
|
||||
window.removeEventListener("pointercancel", stopScrollbarDrag, true);
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
function startScrollbarDrag(event: PointerEvent) {
|
||||
const el = tabsContainerRef.value;
|
||||
const track = event.currentTarget as HTMLElement | null;
|
||||
if (!el || !track || !hasTabOverflow.value) return;
|
||||
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const thumbLeftPx = trackRect.width * (scrollThumbLeftPercent.value / 100);
|
||||
const thumbWidthPx = trackRect.width * (scrollThumbWidthPercent.value / 100);
|
||||
const pointerX = event.clientX - trackRect.left;
|
||||
const pointerInsideThumb = pointerX >= thumbLeftPx && pointerX <= thumbLeftPx + thumbWidthPx;
|
||||
|
||||
dragState = {
|
||||
trackRect,
|
||||
thumbOffsetPx: pointerInsideThumb ? pointerX - thumbLeftPx : thumbWidthPx / 2,
|
||||
};
|
||||
isScrollbarDragging.value = true;
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("pointermove", onScrollbarPointerMove, true);
|
||||
window.addEventListener("pointerup", stopScrollbarDrag, true);
|
||||
window.addEventListener("pointercancel", stopScrollbarDrag, true);
|
||||
event.preventDefault();
|
||||
applyScrollbarDrag(event.clientX);
|
||||
}
|
||||
|
||||
watch(
|
||||
tabsContainerRef,
|
||||
(el) => {
|
||||
|
|
@ -76,10 +132,19 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
|
|||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
stopScrollbarDrag();
|
||||
if (updateFrame && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(updateFrame);
|
||||
}
|
||||
});
|
||||
|
||||
return { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel };
|
||||
return {
|
||||
hasTabOverflow,
|
||||
scrollThumbLeftPercent,
|
||||
scrollThumbWidthPercent,
|
||||
isScrollbarDragging,
|
||||
updateScrollButtons,
|
||||
onTabsWheel,
|
||||
startScrollbarDrag,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,15 +97,6 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
|
|||
return lines;
|
||||
}
|
||||
|
||||
export function shouldShowTabOverflowControls(
|
||||
tabCount: number,
|
||||
hasTabOverflow: boolean,
|
||||
canScrollLeft: boolean,
|
||||
canScrollRight: boolean,
|
||||
): boolean {
|
||||
return tabCount > 0 && (hasTabOverflow || canScrollLeft || canScrollRight);
|
||||
}
|
||||
|
||||
export function tabularResultItems(
|
||||
results: QueryResult[] | undefined,
|
||||
): { result: QueryResult; index: number; n: number }[] {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { test } from "vitest";
|
|||
import { createPinia, setActivePinia } from "pinia";
|
||||
import {
|
||||
executionSummaryItems,
|
||||
shouldShowTabOverflowControls,
|
||||
tabDisplayTitle,
|
||||
tabularResultItems,
|
||||
} from "../../apps/desktop/src/lib/tabPresentation.ts";
|
||||
|
|
@ -62,14 +61,6 @@ function result(columns: string[]): QueryResult {
|
|||
};
|
||||
}
|
||||
|
||||
test("tab overflow controls only show when there are hidden tabs to reach", () => {
|
||||
assert.equal(shouldShowTabOverflowControls(0, true, true, true), false);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, false, false), false);
|
||||
assert.equal(shouldShowTabOverflowControls(3, true, false, false), true);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, true, false), true);
|
||||
assert.equal(shouldShowTabOverflowControls(3, false, false, true), true);
|
||||
});
|
||||
|
||||
test("query tab display title uses custom title when present", () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
|
|
|||
Loading…
Reference in New Issue