vue-stack-tabs 1.4.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/README.ru.md +1 -0
  4. package/dist/StackTabs.vue.d.ts +5 -2
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  7. package/dist/hooks/stackTabsContext.d.ts +0 -1
  8. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  9. package/dist/hooks/tabPanel/refresh.d.ts +4 -2
  10. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  11. package/dist/hooks/tabPanel/state.d.ts +0 -1
  12. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  13. package/dist/hooks/useIframeManager.d.ts +1 -1
  14. package/dist/hooks/useScrollShadow.d.ts +11 -2
  15. package/dist/hooks/useTabActions.d.ts +1 -0
  16. package/dist/hooks/useTabEventBus.d.ts +5 -0
  17. package/dist/hooks/useTabPanel.d.ts +14 -2
  18. package/dist/model/TabModel.d.ts +17 -5
  19. package/dist/nuxt/module.mjs +1 -1
  20. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  21. package/dist/style.css +1 -1
  22. package/dist/utils/scrollUtils.d.ts +0 -2
  23. package/dist/utils/tabInfoEncoder.d.ts +8 -0
  24. package/dist/utils/urlParser.d.ts +20 -10
  25. package/dist/vue-stack-tabs.es.js +1 -1
  26. package/package.json +4 -7
  27. package/src/lib/StackTabs.vue +40 -3
  28. package/src/lib/assets/image/close_all.svg +1 -9
  29. package/src/lib/assets/image/close_lefts.svg +1 -1
  30. package/src/lib/assets/image/close_other.svg +1 -1
  31. package/src/lib/assets/image/close_rights.svg +1 -1
  32. package/src/lib/assets/image/reload.svg +1 -3
  33. package/src/lib/assets/image/reload_all.svg +1 -6
  34. package/src/lib/assets/style/common/mixins.scss +58 -19
  35. package/src/lib/assets/style/common/var.scss +100 -60
  36. package/src/lib/assets/style/icon.scss +31 -98
  37. package/src/lib/assets/style/index.scss +2 -2
  38. package/src/lib/assets/style/loading.scss +45 -0
  39. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
  40. package/src/lib/assets/style/transition.scss +12 -17
  41. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  42. package/src/lib/components/ContextMenu/index.vue +22 -19
  43. package/src/lib/components/PageLoading.vue +12 -11
  44. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  45. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  46. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  47. package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
  48. package/src/lib/components/TabHeader/index.vue +67 -12
  49. package/src/lib/hooks/stackTabsContext.ts +0 -1
  50. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  51. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
  52. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  53. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  54. package/src/lib/hooks/tabPanel/session.ts +11 -4
  55. package/src/lib/hooks/tabPanel/state.ts +0 -2
  56. package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
  57. package/src/lib/hooks/useContextMenu.ts +1 -1
  58. package/src/lib/hooks/useIframeManager.ts +21 -18
  59. package/src/lib/hooks/useScrollShadow.ts +74 -19
  60. package/src/lib/hooks/useTabActions.ts +18 -8
  61. package/src/lib/hooks/useTabEventBus.ts +3 -0
  62. package/src/lib/hooks/useTabPanel.tsx +247 -58
  63. package/src/lib/hooks/useTabRouter.ts +18 -11
  64. package/src/lib/model/TabModel.ts +19 -5
  65. package/src/lib/nuxt/module.ts +3 -1
  66. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  67. package/src/lib/utils/scrollUtils.ts +0 -18
  68. package/src/lib/utils/tabInfoEncoder.ts +121 -18
  69. package/src/lib/utils/urlParser.ts +75 -48
  70. package/dist/vue-stack-tabs.css +0 -2
  71. package/src/lib/assets/style/overlayer.scss +0 -46
@@ -7,58 +7,58 @@
7
7
  <template>
8
8
  <div
9
9
  ref="menuElementRef"
10
- class="stack-tab__contextmenu"
10
+ class="stack-tab__context-menu"
11
11
  role="menu"
12
12
  tabindex="-1"
13
13
  :style="{
14
14
  left: `${menuPosition.left}px`,
15
15
  top: `${menuPosition.top}px`,
16
- 'z-index': menuZIndex()
16
+ 'z-index': menuZIndexValue
17
17
  }"
18
18
  @keydown="handleMenuKeydown"
19
19
  >
20
20
  <context-menu-item
21
- icon="stack-tab__icon-reload svg-mask"
21
+ icon="stack-tab__icon stack-tab__icon--reload stack-tab__icon--mask"
22
22
  :title="t('VueStackTab.reload')"
23
23
  :disabled="!tabItem.refreshable"
24
24
  @click="handleMenuItemClick(() => refreshTab(tabItem.id))"
25
25
  />
26
26
  <context-menu-item
27
- icon="stack-tab__icon-reload-all svg-mask"
27
+ icon="stack-tab__icon stack-tab__icon--reload-all stack-tab__icon--mask"
28
28
  :title="t('VueStackTab.reloadAll')"
29
29
  @click="handleMenuItemClick(refreshAllTabs)"
30
30
  />
31
31
  <context-menu-item
32
- icon="stack-tab__icon-close svg-mask"
32
+ icon="stack-tab__icon stack-tab__icon--close stack-tab__icon--mask"
33
33
  :title="t('VueStackTab.close')"
34
34
  :disabled="!tabItem.closable"
35
35
  @click="handleMenuItemClick(() => closeTab(tabItem.id))"
36
36
  />
37
37
  <context-menu-item
38
- icon="stack-tab__icon-close-lefts svg-mask"
38
+ icon="stack-tab__icon stack-tab__icon--close-lefts stack-tab__icon--mask"
39
39
  :title="t('VueStackTab.closeLeft')"
40
40
  :disabled="index <= 0"
41
41
  @click="handleMenuItemClick(() => removeLeftTabs(tabItem.id))"
42
42
  />
43
43
  <context-menu-item
44
- icon="stack-tab__icon-close-rights svg-mask"
44
+ icon="stack-tab__icon stack-tab__icon--close-rights stack-tab__icon--mask"
45
45
  :title="t('VueStackTab.closeRight')"
46
46
  :disabled="index >= max - 1"
47
47
  @click="handleMenuItemClick(() => removeRightTabs(tabItem.id))"
48
48
  />
49
49
  <context-menu-item
50
- icon="stack-tab__icon-close-others svg-mask"
50
+ icon="stack-tab__icon stack-tab__icon--close-others stack-tab__icon--mask"
51
51
  :title="t('VueStackTab.closeOthers')"
52
52
  @click="handleMenuItemClick(() => removeOtherTabs(tabItem.id))"
53
53
  />
54
54
  <context-menu-item
55
- icon="stack-tab__icon-close-all svg-mask"
55
+ icon="stack-tab__icon stack-tab__icon--close-all stack-tab__icon--mask"
56
56
  :title="t('VueStackTab.closeAll')"
57
57
  @click="handleMenuItemClick(closeAllTabs)"
58
58
  />
59
59
  <context-menu-item
60
60
  v-if="tabItem.iframe && tabItem.url"
61
- icon="stack-tab__icon-open svg-mask"
61
+ icon="stack-tab__icon stack-tab__icon--open stack-tab__icon--mask"
62
62
  :title="t('VueStackTab.openInNewWindow')"
63
63
  @click="handleMenuItemClick(() => openInNewWindow(tabItem.id))"
64
64
  />
@@ -115,13 +115,8 @@ const menuPosition = reactive({ left: props.left, top: props.top })
115
115
  const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
116
116
  const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
117
117
 
118
- /**
119
- * 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
120
- */
121
- const menuZIndex = () => {
122
- invalidateZIndexCache('.stack-tab,.stack-tab *')
123
- return getMaxZIndex('.stack-tab,.stack-tab *')
124
- }
118
+ /** 菜单挂载时计算一次 z-index;菜单打开期间层叠关系不会变化。 */
119
+ const menuZIndexValue = ref(0)
125
120
 
126
121
  const focusActiveTabFallback = () => {
127
122
  const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
@@ -204,12 +199,20 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
204
199
  const getCustomMenuKey = (item: IContextMenu): string =>
205
200
  item.key ?? `${item.title}-${item.icon ?? ''}`
206
201
 
207
- /** 挂载后修正菜单位置,避免超出右边界 */
202
+ /** 挂载后计算 z-index 并修正右/下边界,避免菜单溢出视口 */
208
203
  onMounted(() => {
209
- const menuWidth = menuElementRef.value?.clientWidth ?? 0
204
+ invalidateZIndexCache('.stack-tab,.stack-tab *')
205
+ menuZIndexValue.value = getMaxZIndex('.stack-tab,.stack-tab *')
206
+
207
+ const menuElement = menuElementRef.value
208
+ const menuWidth = menuElement?.clientWidth ?? 0
209
+ const menuHeight = menuElement?.clientHeight ?? 0
210
210
  if (menuPosition.left + menuWidth > window.innerWidth) {
211
211
  menuPosition.left = window.innerWidth - menuWidth - 5
212
212
  }
213
+ if (menuPosition.top + menuHeight > window.innerHeight) {
214
+ menuPosition.top = Math.max(0, window.innerHeight - menuHeight - 5)
215
+ }
213
216
  focusMenuItem(0)
214
217
  })
215
218
  </script>
@@ -7,28 +7,23 @@
7
7
  <template>
8
8
  <div
9
9
  v-if="isLoading"
10
- class="stack-tab-loading-mask"
10
+ class="stack-tab-loading__mask"
11
11
  role="status"
12
12
  aria-live="polite"
13
13
  :aria-label="t('VueStackTab.loading')"
14
- :style="{ zIndex: loadingZIndex() }"
14
+ :style="{ zIndex: loadingZIndex }"
15
15
  >
16
- <div class="stack-tab-loading--spin turn" aria-hidden="true" />
16
+ <div class="stack-tab-loading__spin" aria-hidden="true" />
17
17
  </div>
18
18
  </template>
19
19
  <script setup lang="ts">
20
- import { ref, onUnmounted } from 'vue'
20
+ import { ref, onUnmounted, watch } from 'vue'
21
21
  import { useI18n } from 'vue-i18n-lite'
22
22
  import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
23
23
  import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
24
24
 
25
- /**
26
- * loading 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
27
- */
28
- const loadingZIndex = () => {
29
- invalidateZIndexCache('.cache-page-wrapper *')
30
- return getMaxZIndex('.cache-page-wrapper *')
31
- }
25
+ /** 遮罩显示时的层叠值;只在显示时计算一次,避免每次重渲染都全量扫描 DOM。 */
26
+ const loadingZIndex = ref(0)
32
27
  const props = defineProps<{
33
28
  /** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
34
29
  tabId: string
@@ -37,6 +32,12 @@ const { t } = useI18n()
37
32
  const emitter = useTabEmitter()
38
33
  /** 是否显示 loading 遮罩 */
39
34
  const isLoading = ref<boolean>(false)
35
+ watch(isLoading, (loading) => {
36
+ if (!loading) return
37
+ // 遮罩显隐会改变 .stack-tab__cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
38
+ invalidateZIndexCache('.stack-tab__cache-page-wrapper *')
39
+ loadingZIndex.value = getMaxZIndex('.stack-tab__cache-page-wrapper *')
40
+ })
40
41
  /** 处理 PAGE_LOADING 事件,仅当 tId 匹配时更新 */
41
42
  const handleLoadingEvent = (payload: { tId: string; value: boolean }) => {
42
43
  if (payload.tId === props.tabId) {
@@ -25,8 +25,6 @@ interface StackCacheRendererProps {
25
25
  interface StackCacheRendererSlotProps {
26
26
  wrappedComponent: DefineComponent
27
27
  activeCacheKey: string
28
- refreshKey: number
29
- activePageRefreshVersion: number
30
28
  component?: VNode | null
31
29
  }
32
30
 
@@ -36,7 +34,7 @@ defineSlots<{
36
34
  default(props: StackCacheRendererSlotProps): unknown
37
35
  }>()
38
36
 
39
- const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
37
+ const { refreshKey, activeCacheKey, addPage } = useTabPanel()
40
38
 
41
39
  const wrappedComponent = shallowRef<DefineComponent>(getEmptyPlaceholderComponent())
42
40
 
@@ -57,8 +55,6 @@ watch(
57
55
  <slot
58
56
  :wrapped-component="wrappedComponent"
59
57
  :active-cache-key="activeCacheKey"
60
- :refresh-key="refreshKey"
61
- :active-page-refresh-version="activePageRefreshVersion"
62
58
  :component="component"
63
59
  />
64
60
  </template>
@@ -27,7 +27,7 @@ const emit = defineEmits<{
27
27
  loaded: []
28
28
  }>()
29
29
 
30
- const { caches } = useTabPanel()
30
+ const { caches, getPageRefreshVersion } = useTabPanel()
31
31
 
32
32
  /** 页面组件加载完成时向父组件转发 */
33
33
  const emitLoaded = () => {
@@ -38,7 +38,7 @@ const emitLoaded = () => {
38
38
  <template>
39
39
  <router-view v-slot="{ Component, route }">
40
40
  <StackCacheRenderer
41
- v-slot="{ wrappedComponent, activeCacheKey, activePageRefreshVersion, component }"
41
+ v-slot="{ wrappedComponent, activeCacheKey, component }"
42
42
  :route="route"
43
43
  :component="Component"
44
44
  >
@@ -46,8 +46,10 @@ const emitLoaded = () => {
46
46
  <keep-alive :include="caches">
47
47
  <component
48
48
  :is="wrappedComponent"
49
- :key="`${activeCacheKey}-${activePageRefreshVersion}`"
49
+ :key="activeCacheKey"
50
50
  :vnode="component"
51
+ :refresh-version="getPageRefreshVersion(activeCacheKey)"
52
+ :transition-name="transitionName"
51
53
  @on-loaded="emitLoaded"
52
54
  />
53
55
  </keep-alive>
@@ -4,13 +4,14 @@
4
4
  role="presentation"
5
5
  class="stack-tab__item"
6
6
  :class="{
7
- 'is-active': item.active,
8
- 'is-icon': item.closable,
9
- 'is-dragging': dragging,
10
- 'is-drop-before': dropPosition === 'before',
11
- 'is-drop-after': dropPosition === 'after'
7
+ 'stack-tab__item--active': item.active,
8
+ 'stack-tab__item--closable': item.closable,
9
+ 'stack-tab__item--pinned': item.pinned,
10
+ 'stack-tab__item--dragging': dragging,
11
+ 'stack-tab__item--drop-before': dropPosition === 'before',
12
+ 'stack-tab__item--drop-after': dropPosition === 'after'
12
13
  }"
13
- :draggable="true"
14
+ :draggable="!item.pinned"
14
15
  @click.self="handleActivate(true)"
15
16
  @contextmenu="$emit('contextmenu', $event)"
16
17
  @dragstart="handleDragStart"
@@ -40,7 +41,7 @@
40
41
  <button
41
42
  v-if="item.closable"
42
43
  type="button"
43
- class="stack-tab__icon-close-fill stack-tab__item-button"
44
+ class="stack-tab__icon stack-tab__icon--close-fill stack-tab__item-button"
44
45
  :title="t('VueStackTab.close')"
45
46
  :aria-label="`${t('VueStackTab.close')} ${title}`"
46
47
  :tabindex="item.active ? 0 : -1"
@@ -99,6 +100,11 @@ const handleClose = () => {
99
100
  emit('close', props.item)
100
101
  }
101
102
  const handleDragStart = (event: DragEvent) => {
103
+ // pinned 标签(home)固定在左侧,不参与拖拽。
104
+ if (props.item.pinned) {
105
+ event.preventDefault()
106
+ return
107
+ }
102
108
  dragging.value = true
103
109
  event.dataTransfer?.setData('text/plain', props.item.id)
104
110
  if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
@@ -2,11 +2,15 @@
2
2
  TabHeaderScroll - 可横向滚动的标签栏容器
3
3
 
4
4
  职责:左右箭头滚动、滚轮滚动、自定义滚动条拖拽、确保激活标签在可视区域内
5
+
6
+ 滚动条是自绘的:原生滚动条隐藏,滚动位置通过 scrollLeft 驱动。
7
+ 滚动过程中滑块位置直接写 DOM(不经过响应式),避免每帧触发组件重渲染、
8
+ 进而重新执行 slot 并 patch 整个标签列表。
5
9
  -->
6
10
  <template>
7
11
  <tab-header-button
8
12
  v-if="isScrollButtonVisible"
9
- icon-class="stack-tab__icon-left-arrow"
13
+ icon-class="stack-tab__icon stack-tab__icon--left-arrow"
10
14
  :disabled="isDisabledLeftButton"
11
15
  :title="t('VueStackTab.scrollLeft')"
12
16
  :aria-label="t('VueStackTab.scrollLeft')"
@@ -29,23 +33,23 @@
29
33
  v-show="hasScroller"
30
34
  ref="bar"
31
35
  class="stack-tab__scrollbar"
32
- :class="{ 'is-dragging': scrollDragging }"
36
+ :class="{ 'stack-tab__scrollbar--dragging': scrollDragging }"
33
37
  >
34
38
  <div
35
39
  ref="thumb"
36
40
  class="stack-tab__scrollbar-thumb"
37
- :class="{ 'is-dragging': scrollDragging }"
38
- :style="{
39
- width: `${thumbWidth}px`,
40
- transform: `translateX(${thumbLeft}px)`
41
- }"
42
- @mousedown.prevent="handleScrollbarDragStart"
41
+ :class="{ 'stack-tab__scrollbar-thumb--dragging': scrollDragging }"
42
+ @pointerdown.prevent="handleScrollbarDragStart"
43
+ @pointermove="handleScrollbarDragMove"
44
+ @pointerup="handleScrollbarDragEnd"
45
+ @pointercancel="handleScrollbarDragEnd"
46
+ @lostpointercapture="handleScrollbarDragEnd"
43
47
  />
44
48
  </div>
45
49
  </div>
46
50
  <tab-header-button
47
51
  v-if="isScrollButtonVisible"
48
- icon-class="stack-tab__icon-right-arrow"
52
+ icon-class="stack-tab__icon stack-tab__icon--right-arrow"
49
53
  :disabled="isDisabledRightButton"
50
54
  :title="t('VueStackTab.scrollRight')"
51
55
  :aria-label="t('VueStackTab.scrollRight')"
@@ -55,8 +59,8 @@
55
59
 
56
60
  <script lang="ts" setup>
57
61
  import { useI18n } from 'vue-i18n-lite'
58
- import { computed, ref, reactive, onMounted, watch, onUnmounted } from 'vue'
59
- import type { ScrollData, DragData } from '../../model/TabModel'
62
+ import { ref, onMounted, watch, onUnmounted } from 'vue'
63
+ import type { ScrollData } from '../../model/TabModel'
60
64
  import {
61
65
  scrollTo as scrollToUtil,
62
66
  scrollIntoView as scrollIntoViewUtil
@@ -79,6 +83,8 @@ const isDisabledLeftButton = ref<boolean>(false)
79
83
  const isDisabledRightButton = ref<boolean>(false)
80
84
  /** 是否显示左右滚动按钮:必须启用按钮模式且内容横向溢出 */
81
85
  const isScrollButtonVisible = ref<boolean>(false)
86
+ /** 内容是否超出容器宽度,需要显示滚动条 */
87
+ const hasScroller = ref<boolean>(false)
82
88
  /** 外层滚动区域,用于 ResizeObserver */
83
89
  const headerScroll = ref<HTMLElement>()
84
90
  const props = withDefaults(
@@ -96,72 +102,90 @@ const props = withDefaults(
96
102
  isScrollButton: true
97
103
  }
98
104
  )
99
- /** 滚动容器尺寸与滚动位置,用于计算滑块和按钮状态 */
100
- const scrollData = reactive<ScrollData>({
105
+
106
+ /**
107
+ * 滚动容器的尺寸与位置。
108
+ * 刻意保持非响应式:滚动时每帧更新它,若走 reactive 会触发组件重渲染。
109
+ */
110
+ const scrollData: ScrollData = {
101
111
  clientWidth: 0,
102
112
  scrollWidth: 0,
103
113
  scrollLeft: 0
104
- })
105
- /** 拖拽滚动条时的起始状态 */
106
- const dragData = reactive<DragData>({
107
- thumbLeft: 0,
108
- startScrollLeft: 0,
109
- startThumbLeft: 0,
110
- startPageX: 0
111
- })
112
-
113
- /** 滚动数据变化时更新箭头与滚动条的启用状态 */
114
- watch(scrollData, (data) => {
115
- updateScrollButtonState(data)
116
- })
117
-
118
- watch(
119
- () => props.isScrollButton,
120
- () => updateScrollButtonState(scrollData)
121
- )
122
-
123
- /** 根据滚动位置更新左右箭头禁用状态及滚动按钮可见性 */
124
- const updateScrollButtonState = (data: ScrollData) => {
125
- const hasHorizontalOverflow = data.scrollWidth > data.clientWidth
126
-
127
- isDisabledLeftButton.value = data.scrollLeft <= 10
128
- isDisabledRightButton.value = data.clientWidth + data.scrollLeft - data.scrollWidth >= -10
129
- isScrollButtonVisible.value = props.isScrollButton && hasHorizontalOverflow
130
114
  }
131
115
 
132
- /** 内容是否超出容器宽度,需要显示滚动条 */
133
- const hasScroller = computed<boolean>(() => {
134
- return scrollData.scrollWidth > scrollData.clientWidth
135
- })
116
+ /** 拖拽起始状态(非响应式,避免 pointermove 触发渲染) */
117
+ const dragData = {
118
+ startPageX: 0,
119
+ startThumbLeft: 0
120
+ }
121
+ /** 拖拽指针位置与 rAF 句柄,用于合并高频 pointermove */
122
+ let dragRafId = 0
123
+ let pendingPageX = 0
124
+ let capturedPointerId: number | null = null
136
125
 
137
126
  /** 滚动条滑块宽度(按内容比例计算) */
138
- const thumbWidth = computed<number>(() => {
139
- if (!hasScroller.value) return 0
140
-
127
+ const getThumbWidth = (): number => {
141
128
  const { clientWidth, scrollWidth } = scrollData
129
+ if (scrollWidth <= clientWidth || scrollWidth === 0) return 0
130
+
142
131
  return (clientWidth / scrollWidth) * clientWidth
143
- })
132
+ }
144
133
 
145
134
  /** 滚动条滑块 left 偏移(与 scrollLeft 对应) */
146
- const thumbLeft = computed(() => {
147
- if (!hasScroller.value) return 0
135
+ const getThumbLeft = (scrollLeft: number): number => {
136
+ const { clientWidth, scrollWidth } = scrollData
137
+ const maxScrollLeft = scrollWidth - clientWidth
138
+ if (maxScrollLeft <= 0) return 0
148
139
 
149
- const { clientWidth, scrollWidth, scrollLeft } = scrollData
140
+ return (clientWidth - getThumbWidth()) * (scrollLeft / maxScrollLeft)
141
+ }
150
142
 
151
- return (clientWidth - thumbWidth.value) * (scrollLeft / (scrollWidth - clientWidth))
152
- })
143
+ /**
144
+ * 直接写滑块样式,绕过模板绑定。
145
+ * 这是滚动路径上唯一的 DOM 写入,不触发组件重渲染。
146
+ */
147
+ const updateThumb = (scrollLeft: number) => {
148
+ const thumbEl = thumb.value
149
+ if (!thumbEl || !hasScroller.value) return
150
+
151
+ thumbEl.style.width = `${getThumbWidth()}px`
152
+ thumbEl.style.transform = `translateX(${getThumbLeft(scrollLeft)}px)`
153
+ }
154
+
155
+ /** 根据滚动位置更新左右箭头禁用状态 */
156
+ const updateScrollButtonState = () => {
157
+ isDisabledLeftButton.value = scrollData.scrollLeft <= 10
158
+ isDisabledRightButton.value =
159
+ scrollData.clientWidth + scrollData.scrollLeft - scrollData.scrollWidth >= -10
160
+ }
161
+
162
+ /** 更新溢出判断与滚动按钮可见性 */
163
+ const updateScrollVisibility = () => {
164
+ hasScroller.value = scrollData.scrollWidth > scrollData.clientWidth
165
+ isScrollButtonVisible.value = props.isScrollButton && hasScroller.value
166
+ }
153
167
 
154
- /** 更新滚动条数据 */
168
+ /**
169
+ * 同步容器尺寸与滚动位置。
170
+ * scroll 与 ResizeObserver 共用:尺寸只在滚动时顺带读取,不写入响应式状态,
171
+ * 因此不会触发组件重渲染。
172
+ */
155
173
  const updateScrollState = () => {
156
- if (!container.value) return
157
- const { clientWidth, scrollWidth, scrollLeft } = container.value
158
- Object.assign(scrollData, { clientWidth, scrollWidth, scrollLeft })
174
+ const el = container.value
175
+ if (!el) return
176
+
177
+ scrollData.clientWidth = el.clientWidth
178
+ scrollData.scrollWidth = el.scrollWidth
179
+ scrollData.scrollLeft = el.scrollLeft
180
+ updateScrollVisibility()
181
+ updateScrollButtonState()
182
+ updateThumb(scrollData.scrollLeft)
159
183
  }
160
184
 
161
185
  /** 确保激活的标签在可视区域内 */
162
186
  const ensureActiveTabInView = () => {
163
187
  const activeTabEl = container.value?.querySelector(
164
- '.stack-tab__item.is-active'
188
+ '.stack-tab__item--active'
165
189
  ) as HTMLElement | null
166
190
  if (activeTabEl && !isInView(activeTabEl)) {
167
191
  scrollIntoView(activeTabEl)
@@ -170,6 +194,8 @@ const ensureActiveTabInView = () => {
170
194
 
171
195
  let scrollContainerResizeObserver: ResizeObserver | null = null
172
196
  let tabListResizeObserver: ResizeObserver | null = null
197
+ /** 上一次观察到的标签数量,用于区分“标签增删”和普通的尺寸抖动 */
198
+ let lastTabCount = 0
173
199
  /** 滚动到指定 left 位置 */
174
200
  const scrollTo = (left: number, smooth = true) => {
175
201
  scrollToUtil({ wrap: container.value, left, top: 0, smooth })
@@ -181,69 +207,119 @@ const handleScrollButtonClick = (delta: number) => {
181
207
 
182
208
  const handleWheelScroll = (e: WheelEvent) => {
183
209
  if (!props.isScrollWheel || !container.value) return
210
+ // 触控板横向手势交给原生滚动,避免与下面的定距滚动叠加
211
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
212
+ if (!e.deltaY) return
184
213
 
185
214
  const isScrollUp = e.deltaY < 0
186
215
  scrollTo(container.value.scrollLeft + (isScrollUp ? -props.space : props.space))
187
216
  }
188
217
 
189
- const handleScrollbarDragStart = (e: MouseEvent) => {
190
- Object.assign(dragData, {
191
- startPageX: e.pageX,
192
- startScrollLeft: container.value?.scrollLeft,
193
- startThumbLeft: thumbLeft.value,
194
- thumbLeft: thumbLeft.value
218
+ /** 应用一次拖拽位移(在 rAF 中执行,合并高频 pointermove) */
219
+ const applyDrag = () => {
220
+ dragRafId = 0
221
+ if (!scrollDragging.value || !container.value) return
222
+
223
+ const { clientWidth, scrollWidth } = scrollData
224
+ const maxScrollLeft = scrollWidth - clientWidth
225
+ const maxThumbLeft = clientWidth - getThumbWidth()
226
+ if (maxScrollLeft <= 0 || maxThumbLeft <= 0) return
227
+
228
+ const thumbLeft = Math.min(
229
+ Math.max(dragData.startThumbLeft + pendingPageX - dragData.startPageX, 0),
230
+ maxThumbLeft
231
+ )
232
+
233
+ // 同步滚动与滑块位置,拖拽跟手,不依赖异步 scroll 事件
234
+ scrollToUtil({
235
+ wrap: container.value,
236
+ left: (thumbLeft * maxScrollLeft) / maxThumbLeft,
237
+ top: 0,
238
+ smooth: false
195
239
  })
240
+ if (thumb.value) {
241
+ thumb.value.style.transform = `translateX(${thumbLeft}px)`
242
+ }
243
+ }
244
+
245
+ const handleScrollbarDragStart = (e: PointerEvent) => {
246
+ const thumbEl = thumb.value
247
+ const isPrimaryButton = e.pointerType !== 'mouse' || e.button === 0
248
+ if (!thumbEl || !isPrimaryButton) return
249
+
250
+ const scrollLeft = container.value?.scrollLeft ?? 0
251
+ dragData.startPageX = e.pageX
252
+ dragData.startThumbLeft = getThumbLeft(scrollLeft)
253
+ pendingPageX = e.pageX
196
254
  scrollDragging.value = true
197
- document.addEventListener('mousemove', handleScrollbarDragMove)
198
- document.addEventListener('mouseup', handleScrollbarDragEnd)
255
+ capturedPointerId = e.pointerId
256
+ thumbEl.setPointerCapture?.(e.pointerId)
199
257
  }
200
258
 
201
- const handleScrollbarDragMove = (e: MouseEvent) => {
202
- const { clientWidth, scrollWidth } = scrollData
203
- let thumbLeft = dragData.startThumbLeft + e.pageX - dragData.startPageX
204
- const maxThumbLeft = clientWidth - thumbWidth.value
259
+ const handleScrollbarDragMove = (e: PointerEvent) => {
260
+ if (!scrollDragging.value) return
205
261
 
206
- if (thumbLeft < 0) {
207
- thumbLeft = 0
208
- } else if (thumbLeft > maxThumbLeft) {
209
- thumbLeft = maxThumbLeft
210
- }
211
- // 更新滑块定位
212
- dragData.thumbLeft = thumbLeft
213
- // 滚动
214
- scrollTo((thumbLeft * (scrollWidth - clientWidth)) / (clientWidth - thumbWidth.value), false)
215
- e.preventDefault()
262
+ pendingPageX = e.pageX
263
+ if (dragRafId) return
264
+ dragRafId = requestAnimationFrame(applyDrag)
216
265
  }
217
266
 
218
267
  const handleScrollbarDragEnd = () => {
268
+ if (dragRafId) {
269
+ cancelAnimationFrame(dragRafId)
270
+ dragRafId = 0
271
+ }
219
272
  scrollDragging.value = false
220
- document.removeEventListener('mousemove', handleScrollbarDragMove)
221
- document.removeEventListener('mouseup', handleScrollbarDragEnd)
273
+
274
+ const pointerId = capturedPointerId
275
+ capturedPointerId = null
276
+ const thumbEl = thumb.value
277
+ if (thumbEl && pointerId !== null && thumbEl.hasPointerCapture?.(pointerId)) {
278
+ thumbEl.releasePointerCapture(pointerId)
279
+ }
222
280
  }
223
281
 
224
282
  onMounted(() => {
225
- if (typeof ResizeObserver === 'undefined') return
226
-
227
- scrollContainerResizeObserver = new ResizeObserver(() => {
228
- updateScrollState()
229
- ensureActiveTabInView()
230
- })
231
- tabListResizeObserver = new ResizeObserver(() => {
232
- if (!hasScroller.value) ensureActiveTabInView()
233
- updateScrollState()
234
- })
283
+ if (typeof ResizeObserver !== 'undefined') {
284
+ scrollContainerResizeObserver = new ResizeObserver(() => {
285
+ updateScrollState()
286
+ ensureActiveTabInView()
287
+ })
288
+ tabListResizeObserver = new ResizeObserver(() => {
289
+ // 只有标签增删(数量变化)才需要纠正滚动位置。
290
+ // hover 展开关闭按钮、标题动态变化等也会改变列表宽度并触发本回调,
291
+ // 若此时无条件 ensureActiveTabInView,会把用户主动滚开的标签栏强行拉回 active 标签。
292
+ const tabCount = container.value?.querySelectorAll('.stack-tab__item').length ?? 0
293
+ const tabCountChanged = tabCount !== lastTabCount
294
+ lastTabCount = tabCount
295
+
296
+ if (hasScroller.value && tabCountChanged) ensureActiveTabInView()
297
+ updateScrollState()
298
+ })
299
+
300
+ if (headerScroll.value) scrollContainerResizeObserver.observe(headerScroll.value)
301
+ const tabNav = container.value?.querySelector('.stack-tab__nav')
302
+ lastTabCount = container.value?.querySelectorAll('.stack-tab__item').length ?? 0
303
+ if (tabNav) tabListResizeObserver.observe(tabNav)
304
+ }
235
305
 
236
- if (headerScroll.value) scrollContainerResizeObserver.observe(headerScroll.value)
237
- const tabNav = container.value?.querySelector('.stack-tab__nav')
238
- if (tabNav) tabListResizeObserver.observe(tabNav)
306
+ updateScrollState()
239
307
  })
240
308
  onUnmounted(() => {
309
+ handleScrollbarDragEnd()
241
310
  scrollContainerResizeObserver?.disconnect()
242
311
  tabListResizeObserver?.disconnect()
243
312
  scrollContainerResizeObserver = null
244
313
  tabListResizeObserver = null
245
314
  })
246
315
 
316
+ watch(
317
+ () => props.isScrollButton,
318
+ () => {
319
+ isScrollButtonVisible.value = props.isScrollButton && hasScroller.value
320
+ }
321
+ )
322
+
247
323
  const scrollIntoView = (el: HTMLElement) => {
248
324
  scrollIntoViewUtil({ el, wrap: container.value, block: 'end', inline: 'center' })
249
325
  }