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.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +5 -2
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +17 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +8 -0
- package/dist/utils/urlParser.d.ts +20 -10
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +40 -3
- package/src/lib/assets/image/close_all.svg +1 -9
- package/src/lib/assets/image/close_lefts.svg +1 -1
- package/src/lib/assets/image/close_other.svg +1 -1
- package/src/lib/assets/image/close_rights.svg +1 -1
- package/src/lib/assets/image/reload.svg +1 -3
- package/src/lib/assets/image/reload_all.svg +1 -6
- package/src/lib/assets/style/common/mixins.scss +58 -19
- package/src/lib/assets/style/common/var.scss +100 -60
- package/src/lib/assets/style/icon.scss +31 -98
- package/src/lib/assets/style/index.scss +2 -2
- package/src/lib/assets/style/loading.scss +45 -0
- package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
- package/src/lib/assets/style/transition.scss +12 -17
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
- package/src/lib/components/ContextMenu/index.vue +22 -19
- package/src/lib/components/PageLoading.vue +12 -11
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
- package/src/lib/components/TabHeader/index.vue +67 -12
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +21 -18
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +247 -58
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +19 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +121 -18
- package/src/lib/utils/urlParser.ts +75 -48
- package/dist/vue-stack-tabs.css +0 -2
- 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-
|
|
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':
|
|
16
|
+
'z-index': menuZIndexValue
|
|
17
17
|
}"
|
|
18
18
|
@keydown="handleMenuKeydown"
|
|
19
19
|
>
|
|
20
20
|
<context-menu-item
|
|
21
|
-
icon="stack-tab__icon-reload
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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="
|
|
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
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
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="
|
|
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="{ '
|
|
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="{ '
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@
|
|
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 {
|
|
59
|
-
import type { ScrollData
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
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
|
|
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
|
|
147
|
-
|
|
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
|
-
|
|
140
|
+
return (clientWidth - getThumbWidth()) * (scrollLeft / maxScrollLeft)
|
|
141
|
+
}
|
|
150
142
|
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
255
|
+
capturedPointerId = e.pointerId
|
|
256
|
+
thumbEl.setPointerCapture?.(e.pointerId)
|
|
199
257
|
}
|
|
200
258
|
|
|
201
|
-
const handleScrollbarDragMove = (e:
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
}
|