fix(desktop): stabilize tab overflow scrolling
This commit is contained in:
1 parent
426b7911bc
commit
9278630fd2
4 files changed
+279
-199
No files matched your search
@@ -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')"
|
||||
<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 }"
|
||||
>
|
||||
{{ (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="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>
|
||||
Reference in new issue
Block a user