vue-stack-tabs 0.1.51 → 1.0.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.md +717 -3
- package/dist/index.d.ts +3 -0
- package/dist/src/lib/StackTabs.vue.d.ts +33 -161
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
- package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
- package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
- package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
- package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
- package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
- package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
- package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
- package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
- package/dist/src/lib/i18n/index.d.ts +1 -1
- package/dist/src/lib/i18n/lang/en.d.ts +1 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
- package/dist/src/lib/index.d.ts +5 -4
- package/dist/src/lib/model/TabModel.d.ts +20 -3
- package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
- package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
- package/dist/src/lib/utils/urlParser.d.ts +19 -0
- package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
- package/dist/src/lib/versionLogger.d.ts +2 -0
- package/dist/style.css +1 -1
- package/dist/vue-stack-tabs.css +1 -0
- package/dist/vue-stack-tabs.es.js +1795 -1679
- package/dist/vue-stack-tabs.umd.js +1 -5
- package/package.json +76 -39
- package/src/lib/StackTabs.vue +222 -47
- package/src/lib/assets/image/open.svg +1 -0
- package/src/lib/assets/style/common/var.scss +1 -1
- package/src/lib/assets/style/icon.scss +6 -0
- package/src/lib/assets/style/stackTab.scss +44 -3
- package/src/lib/assets/style/transition.scss +63 -62
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
- package/src/lib/components/ContextMenu/index.vue +50 -38
- package/src/lib/components/PageLoading.vue +30 -10
- package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
- package/src/lib/components/TabHeader/index.vue +45 -40
- package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
- package/src/lib/hooks/tabPanel/evict.ts +57 -0
- package/src/lib/hooks/tabPanel/scroll.ts +52 -0
- package/src/lib/hooks/tabPanel/session.ts +33 -0
- package/src/lib/hooks/tabPanel/state.ts +52 -0
- package/src/lib/hooks/useContextMenu.ts +22 -20
- package/src/lib/hooks/useTabActions.ts +148 -0
- package/src/lib/hooks/useTabEventBus.ts +31 -0
- package/src/lib/hooks/useTabLoading.ts +12 -4
- package/src/lib/hooks/useTabPanel.spec.ts +34 -0
- package/src/lib/hooks/useTabPanel.tsx +546 -0
- package/src/lib/hooks/useTabRouter.ts +114 -103
- package/src/lib/i18n/index.ts +14 -18
- package/src/lib/i18n/lang/en.ts +2 -1
- package/src/lib/i18n/lang/zh-CN.ts +2 -1
- package/src/lib/iframe.vue +6 -0
- package/src/lib/index.ts +27 -8
- package/src/lib/model/TabModel.spec.ts +40 -0
- package/src/lib/model/TabModel.ts +22 -9
- package/src/lib/nuxt/module.d.ts +19 -0
- package/src/lib/nuxt/module.ts +34 -0
- package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
- package/src/lib/utils/iframeBridge.spec.ts +37 -0
- package/src/lib/utils/iframeBridge.ts +52 -0
- package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
- package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
- package/src/lib/utils/tabInfoEncoder.ts +65 -0
- package/src/lib/utils/urlParser.spec.ts +45 -0
- package/src/lib/utils/urlParser.ts +42 -0
- package/src/lib/{banner.ts → versionLogger.ts} +4 -5
- package/dist/src/lib/banner.d.ts +0 -2
- package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
- package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
- package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
- package/dist/src/lib/utils/UriHelper.d.ts +0 -4
- package/src/lib/hooks/useStackTab.ts +0 -119
- package/src/lib/hooks/useTabMitt.ts +0 -30
- package/src/lib/hooks/useTabpanel.tsx +0 -708
- package/src/lib/utils/TabIdHelper.ts +0 -70
- package/src/lib/utils/UriHelper.ts +0 -22
|
@@ -2,30 +2,30 @@
|
|
|
2
2
|
<div class="stack-tab__header">
|
|
3
3
|
<slot name="leftButton" />
|
|
4
4
|
<tab-header-scroll
|
|
5
|
-
ref="
|
|
5
|
+
ref="scrollContainerRef"
|
|
6
6
|
:space="space"
|
|
7
7
|
:is-scroll-wheel="isScrollWheel"
|
|
8
8
|
:is-scroll-button="isScrollButton"
|
|
9
9
|
>
|
|
10
10
|
<template #default>
|
|
11
11
|
<transition-group
|
|
12
|
-
key="
|
|
12
|
+
key="tab-list-transition"
|
|
13
13
|
tag="ul"
|
|
14
14
|
class="stack-tab__nav"
|
|
15
|
-
v-bind="
|
|
15
|
+
v-bind="tabTransitionProps"
|
|
16
16
|
appear
|
|
17
17
|
>
|
|
18
18
|
<tab-header-item
|
|
19
19
|
v-for="(item, index) in tabs"
|
|
20
20
|
:key="item.id"
|
|
21
21
|
:contextmenu="contextmenu"
|
|
22
|
-
:item="item"
|
|
22
|
+
:item="item as ITabItem"
|
|
23
23
|
@contextmenu.prevent="
|
|
24
|
-
(e: MouseEvent) => showContextMenu(e, item as ITabItem, index, tabs.length)
|
|
24
|
+
(e: MouseEvent) => showContextMenu(e, item as unknown as ITabItem, index, tabs.length)
|
|
25
25
|
"
|
|
26
|
-
@click.middle.prevent="
|
|
27
|
-
@close="
|
|
28
|
-
@active="
|
|
26
|
+
@click.middle.prevent="handleCloseTab(item as ITabItem)"
|
|
27
|
+
@close="handleCloseTab"
|
|
28
|
+
@active="handleActivateTab"
|
|
29
29
|
/>
|
|
30
30
|
</transition-group>
|
|
31
31
|
</template>
|
|
@@ -39,43 +39,52 @@
|
|
|
39
39
|
<transition name="stack-tab-zoom" appear>
|
|
40
40
|
<context-menu
|
|
41
41
|
v-if="contextMenuShown"
|
|
42
|
-
key="
|
|
42
|
+
key="context-menu-transition"
|
|
43
43
|
:left="contextMenuData.left"
|
|
44
44
|
:top="contextMenuData.top"
|
|
45
45
|
:tab-item="contextMenuData.item as ITabItem"
|
|
46
46
|
:max="contextMenuData.max"
|
|
47
47
|
:index="contextMenuData.index"
|
|
48
|
+
@close="handleCloseContextMenu"
|
|
48
49
|
/>
|
|
49
50
|
</transition>
|
|
50
51
|
</div>
|
|
51
52
|
</template>
|
|
52
53
|
|
|
54
|
+
<!--
|
|
55
|
+
TabHeader - 标签栏头部组件
|
|
56
|
+
|
|
57
|
+
职责:展示标签列表、左右滚动、最大化按钮、右键菜单
|
|
58
|
+
依赖:useTabActions(tabs/closeTab/activeTab)、useContextMenu
|
|
59
|
+
-->
|
|
53
60
|
<script lang="ts" setup>
|
|
54
|
-
// @ts-nocheck
|
|
55
61
|
import { inject, computed, ref, nextTick } from 'vue'
|
|
56
|
-
import type { TransitionProps
|
|
62
|
+
import type { TransitionProps } from 'vue'
|
|
57
63
|
import { TabScrollMode } from '../../model/TabModel'
|
|
58
64
|
import type { ITabItem } from '../../model/TabModel'
|
|
59
65
|
import ContextMenu from '../ContextMenu/index.vue'
|
|
60
66
|
import useContextMenu from '../../hooks/useContextMenu'
|
|
61
|
-
import
|
|
67
|
+
import useTabActions from '../../hooks/useTabActions'
|
|
62
68
|
import TabHeaderItem from './TabHeaderItem.vue'
|
|
63
69
|
import TabHeaderScroll from './TabHeaderScroll.vue'
|
|
64
70
|
import TabHeaderButton from './TabHeaderButton.vue'
|
|
65
71
|
import { useI18n } from 'vue-i18n-lite'
|
|
66
|
-
// @ts-ignore
|
|
67
72
|
const { t } = useI18n()
|
|
73
|
+
/** 是否最大化,由上层 provide */
|
|
68
74
|
const maximum = inject<boolean>('maximum')
|
|
69
75
|
const emit = defineEmits(['active'])
|
|
70
|
-
|
|
76
|
+
/** 滚动容器 ref,用于调用 scrollIntoView / isInView */
|
|
77
|
+
const scrollContainerRef = ref<InstanceType<typeof TabHeaderScroll>>()
|
|
71
78
|
const { shown: contextMenuShown, contextMenuData, showContextMenu } = useContextMenu()
|
|
79
|
+
/** 关闭右键菜单 */
|
|
80
|
+
const handleCloseContextMenu = () => {
|
|
81
|
+
contextMenuShown.value = false
|
|
82
|
+
}
|
|
72
83
|
|
|
73
84
|
const props = withDefaults(
|
|
74
85
|
defineProps<{
|
|
75
86
|
space: number
|
|
76
|
-
// tab的滚动方式
|
|
77
87
|
tabScrollMode?: TabScrollMode
|
|
78
|
-
// tab动画
|
|
79
88
|
tabTransition?: string | TransitionProps
|
|
80
89
|
contextmenu?: boolean | object
|
|
81
90
|
max?: number
|
|
@@ -91,46 +100,42 @@ const props = withDefaults(
|
|
|
91
100
|
max: 20
|
|
92
101
|
}
|
|
93
102
|
)
|
|
94
|
-
const {
|
|
103
|
+
const { closeTab, activeTab, tabs } = useTabActions()
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
})
|
|
105
|
-
// 判断是否有滚动按钮
|
|
105
|
+
/** 标签列表 transition 的 props,支持 string 或对象 */
|
|
106
|
+
const tabTransitionProps = computed<TransitionProps>(() =>
|
|
107
|
+
typeof props.tabTransition === 'string'
|
|
108
|
+
? ({ name: props.tabTransition } as TransitionProps)
|
|
109
|
+
: (props.tabTransition as TransitionProps)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
/** 是否显示左右滚动按钮 */
|
|
106
113
|
const isScrollButton = computed<boolean>(() => {
|
|
107
114
|
return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.BUTTON
|
|
108
115
|
})
|
|
109
|
-
|
|
116
|
+
/** 是否支持滚轮滚动 */
|
|
110
117
|
const isScrollWheel =
|
|
111
118
|
props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
|
|
112
119
|
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
// emit('close', item)
|
|
120
|
+
/** 关闭指定标签,并关闭右键菜单 */
|
|
121
|
+
const handleCloseTab = (item: ITabItem) => {
|
|
116
122
|
if (item.closable) {
|
|
117
|
-
|
|
118
|
-
// emit('active', activeId)
|
|
123
|
+
closeTab(item.id)
|
|
119
124
|
}
|
|
120
125
|
contextMenuShown.value = false
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
/** 激活指定标签,滚动到可视区域,并触发路由跳转 */
|
|
129
|
+
const handleActivateTab = (item: ITabItem, clickedTabElement: HTMLElement | undefined, isRoute: boolean) => {
|
|
130
|
+
if (scrollContainerRef.value && clickedTabElement) {
|
|
125
131
|
nextTick(() => {
|
|
126
|
-
if (!
|
|
127
|
-
|
|
132
|
+
if (!scrollContainerRef.value!.isInView(clickedTabElement)) {
|
|
133
|
+
scrollContainerRef.value!.scrollIntoView(clickedTabElement)
|
|
128
134
|
}
|
|
129
135
|
})
|
|
130
|
-
|
|
131
|
-
active(item.id, isRoute)
|
|
132
|
-
emit('active', item.id)
|
|
133
136
|
}
|
|
137
|
+
activeTab(item.id, isRoute)
|
|
138
|
+
emit('active', item.id)
|
|
134
139
|
}
|
|
135
140
|
</script>
|
|
136
141
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
markCacheForEviction,
|
|
4
|
+
markTabPagesForEviction,
|
|
5
|
+
markTabPagesForEvictionOnly,
|
|
6
|
+
addCache,
|
|
7
|
+
removeCache,
|
|
8
|
+
evictPageCache,
|
|
9
|
+
evictMarkedCaches
|
|
10
|
+
} from './evict'
|
|
11
|
+
import { caches, cacheIdsToEvict, tabIdsToEvict, components } from './state'
|
|
12
|
+
import { Stack } from '../../model/TabModel'
|
|
13
|
+
import type { ITabPage } from '../../model/TabModel'
|
|
14
|
+
|
|
15
|
+
function resetState() {
|
|
16
|
+
caches.value = []
|
|
17
|
+
cacheIdsToEvict.clear()
|
|
18
|
+
tabIdsToEvict.clear()
|
|
19
|
+
components.clear()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(resetState)
|
|
23
|
+
|
|
24
|
+
describe('evict', () => {
|
|
25
|
+
it('addCache 添加缓存 id', () => {
|
|
26
|
+
addCache('c1')
|
|
27
|
+
expect(caches.value).toContain('c1')
|
|
28
|
+
addCache('c1')
|
|
29
|
+
expect(caches.value.filter((x) => x === 'c1')).toHaveLength(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('removeCache 移除指定 id', () => {
|
|
33
|
+
addCache('c1')
|
|
34
|
+
removeCache('c1')
|
|
35
|
+
expect(caches.value).not.toContain('c1')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('markCacheForEviction 标记待驱逐', () => {
|
|
39
|
+
markCacheForEviction('c1')
|
|
40
|
+
expect(cacheIdsToEvict.has('c1')).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('markTabPagesForEviction 标记标签下所有页面并加入 tabIdsToEvict', () => {
|
|
44
|
+
const page: ITabPage = { id: 'p1', tabId: 't1', path: '/x' }
|
|
45
|
+
const stack = new Stack<ITabPage>()
|
|
46
|
+
stack.push(page)
|
|
47
|
+
const tab = { id: 't1', pages: { list: () => stack.list() } }
|
|
48
|
+
markTabPagesForEviction(tab)
|
|
49
|
+
expect(cacheIdsToEvict.has('p1')).toBe(true)
|
|
50
|
+
expect(tabIdsToEvict.has('t1')).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('markTabPagesForEvictionOnly 只标记页面不标记 tab', () => {
|
|
54
|
+
const page: ITabPage = { id: 'p1', tabId: 't1', path: '/x' }
|
|
55
|
+
const stack = new Stack<ITabPage>()
|
|
56
|
+
stack.push(page)
|
|
57
|
+
const tab = { pages: { list: () => stack.list() } }
|
|
58
|
+
markTabPagesForEvictionOnly(tab)
|
|
59
|
+
expect(cacheIdsToEvict.has('p1')).toBe(true)
|
|
60
|
+
expect(tabIdsToEvict.size).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('evictPageCache 立即驱逐单个缓存', () => {
|
|
64
|
+
addCache('c1')
|
|
65
|
+
components.set('c1', {} as never)
|
|
66
|
+
evictPageCache('c1')
|
|
67
|
+
expect(caches.value).not.toContain('c1')
|
|
68
|
+
expect(components.has('c1')).toBe(false)
|
|
69
|
+
expect(cacheIdsToEvict.has('c1')).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('evictMarkedCaches 执行所有待驱逐', () => {
|
|
73
|
+
addCache('c1')
|
|
74
|
+
addCache('c2')
|
|
75
|
+
components.set('c1', {} as never)
|
|
76
|
+
components.set('c2', {} as never)
|
|
77
|
+
markCacheForEviction('c1')
|
|
78
|
+
markCacheForEviction('c2')
|
|
79
|
+
evictMarkedCaches()
|
|
80
|
+
expect(caches.value).not.toContain('c1')
|
|
81
|
+
expect(caches.value).not.toContain('c2')
|
|
82
|
+
expect(components.size).toBe(0)
|
|
83
|
+
expect(cacheIdsToEvict.size).toBe(0)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tabPanel/evict - 缓存驱逐逻辑
|
|
3
|
+
*
|
|
4
|
+
* 职责:标记待驱逐缓存、执行驱逐、维护 caches/components
|
|
5
|
+
*/
|
|
6
|
+
import type { ITabPage } from '../../model/TabModel'
|
|
7
|
+
import {
|
|
8
|
+
caches,
|
|
9
|
+
components,
|
|
10
|
+
cacheIdsToEvict,
|
|
11
|
+
tabIdsToEvict
|
|
12
|
+
} from './state'
|
|
13
|
+
|
|
14
|
+
/** 标记缓存为待驱逐 */
|
|
15
|
+
export const markCacheForEviction = (cacheName: string) => {
|
|
16
|
+
cacheIdsToEvict.add(cacheName)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 标记标签下所有页面为可驱逐,并标记该标签(关闭场景) */
|
|
20
|
+
export const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }) => {
|
|
21
|
+
for (const page of tab.pages.list()) markCacheForEviction(page.id)
|
|
22
|
+
tabIdsToEvict.add(tab.id)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 仅标记标签下所有页面为可驱逐(续签场景) */
|
|
26
|
+
export const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }) => {
|
|
27
|
+
for (const page of tab.pages.list()) markCacheForEviction(page.id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 将缓存 id 加入 caches */
|
|
31
|
+
export const addCache = (cacheName: string) => {
|
|
32
|
+
if (!caches.value.includes(cacheName)) caches.value.push(cacheName)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 从 caches 中移除指定 id */
|
|
36
|
+
export const removeCache = (cacheName: string) => {
|
|
37
|
+
const i = caches.value.indexOf(cacheName)
|
|
38
|
+
if (i >= 0) caches.value.splice(i, 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 立即驱逐单个页面缓存 */
|
|
42
|
+
export const evictPageCache = (cacheName: string) => {
|
|
43
|
+
if (!cacheName) return
|
|
44
|
+
cacheIdsToEvict.delete(cacheName)
|
|
45
|
+
removeCache(cacheName)
|
|
46
|
+
components.delete(cacheName)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 执行所有待驱逐缓存的驱逐 */
|
|
50
|
+
export const evictMarkedCaches = () => {
|
|
51
|
+
if (cacheIdsToEvict.size > 0) {
|
|
52
|
+
for (const cacheName of [...cacheIdsToEvict]) {
|
|
53
|
+
evictPageCache(cacheName)
|
|
54
|
+
}
|
|
55
|
+
cacheIdsToEvict.clear()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tabPanel/scroll - 滚动位置保存与恢复
|
|
3
|
+
*
|
|
4
|
+
* 职责:在切出/切入页面时保存和恢复滚动位置
|
|
5
|
+
*/
|
|
6
|
+
import { scrollPositionsByPageId } from './state'
|
|
7
|
+
|
|
8
|
+
/** 恢复指定页面的滚动位置 */
|
|
9
|
+
export const restoreScroller = (pageCacheId: string) => {
|
|
10
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
11
|
+
if (!positions) return
|
|
12
|
+
for (const [sel, pos] of positions) {
|
|
13
|
+
const el = document.querySelector(sel) as HTMLElement | null
|
|
14
|
+
if (el) {
|
|
15
|
+
el.scrollTop = pos.top
|
|
16
|
+
el.scrollLeft = pos.left
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 保存指定页面的滚动位置 */
|
|
22
|
+
export const saveScroller = (pageCacheId: string) => {
|
|
23
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
24
|
+
if (!positions) return
|
|
25
|
+
for (const sel of positions.keys()) {
|
|
26
|
+
const el = document.querySelector(sel) as HTMLElement | null
|
|
27
|
+
positions.set(sel, {
|
|
28
|
+
top: el?.scrollTop ?? 0,
|
|
29
|
+
left: el?.scrollLeft ?? 0
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 移除指定页面的滚动记录 */
|
|
35
|
+
export const removeScroller = (pageCacheId: string) => {
|
|
36
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
37
|
+
if (positions) {
|
|
38
|
+
positions.clear()
|
|
39
|
+
scrollPositionsByPageId.delete(pageCacheId)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 为页面注册需要记录滚动位置的选择器 */
|
|
44
|
+
export const addPageScroller = (pageCacheId: string, ...selectorIds: string[]) => {
|
|
45
|
+
if (!scrollPositionsByPageId.has(pageCacheId)) {
|
|
46
|
+
scrollPositionsByPageId.set(pageCacheId, new Map<string, { top: number; left: number }>())
|
|
47
|
+
}
|
|
48
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)!
|
|
49
|
+
for (const sel of selectorIds) {
|
|
50
|
+
positions.set(sel, { top: 0, left: 0 })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tabPanel/session - Session 持久化
|
|
3
|
+
*
|
|
4
|
+
* 职责:将当前激活标签保存到 sessionStorage,刷新后恢复
|
|
5
|
+
*/
|
|
6
|
+
import type { ITabItem } from '../../model/TabModel'
|
|
7
|
+
import { Stack } from '../../model/TabModel'
|
|
8
|
+
import type { ITabPage } from '../../model/TabModel'
|
|
9
|
+
import { SESSION_TAB_NAME, sessionPrefix } from './state'
|
|
10
|
+
|
|
11
|
+
/** 获取 sessionStorage 的 key */
|
|
12
|
+
export const getSessionKey = () => sessionPrefix + SESSION_TAB_NAME
|
|
13
|
+
|
|
14
|
+
/** 将当前激活的标签保存到 sessionStorage */
|
|
15
|
+
export const saveActiveTabToSession = (tab: ITabItem) => {
|
|
16
|
+
if (!tab.id) return
|
|
17
|
+
window.sessionStorage.setItem(getSessionKey(), JSON.stringify(tab))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 清除 sessionStorage 中的激活标签 */
|
|
21
|
+
export const clearSession = () => {
|
|
22
|
+
window.sessionStorage.removeItem(getSessionKey())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 从 sessionStorage 解析恢复的标签(含 Stack 反序列化) */
|
|
26
|
+
export const restoreTabFromSession = (
|
|
27
|
+
storedJson: string | null
|
|
28
|
+
): ITabItem | null => {
|
|
29
|
+
if (storedJson == null) return null
|
|
30
|
+
return JSON.parse(storedJson, (k, v) =>
|
|
31
|
+
k === 'pages' ? new Stack<ITabPage>(v) : v
|
|
32
|
+
) as ITabItem
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tabPanel/state - 标签页核心共享状态
|
|
3
|
+
*
|
|
4
|
+
* 职责:集中管理 tabs、caches、components、驱逐集合、滚动位置等
|
|
5
|
+
* 被 useTabPanel、evict、scroll、session 导入使用
|
|
6
|
+
*/
|
|
7
|
+
import type { ITabItem } from '../../model/TabModel'
|
|
8
|
+
import { ref } from 'vue'
|
|
9
|
+
|
|
10
|
+
/** 当前打开的标签列表(含每个标签的页面栈) */
|
|
11
|
+
export const tabs = ref<ITabItem[]>([])
|
|
12
|
+
/** 初始化时传入的默认标签(用于对比 session 恢复) */
|
|
13
|
+
export const defaultTabs: ITabItem[] = []
|
|
14
|
+
/** keep-alive include 数组,与 Vue 的 keep-alive :include 绑定 */
|
|
15
|
+
export const caches = ref<string[]>([])
|
|
16
|
+
/** 页面缓存 ID -> 包装后的 DefineComponent */
|
|
17
|
+
export const components = new Map<string, import('vue').DefineComponent>()
|
|
18
|
+
/** 待驱逐的页面缓存 ID 集合 */
|
|
19
|
+
export const cacheIdsToEvict = new Set<string>()
|
|
20
|
+
/** 待驱逐的标签 ID 集合 */
|
|
21
|
+
export const tabIdsToEvict = new Set<string>()
|
|
22
|
+
/** 刷新计数器,变更时触发 component :key 变化 */
|
|
23
|
+
export const refreshKey = ref<number>(0)
|
|
24
|
+
/** 刷新时临时加入 keep-alive exclude 的缓存 ID */
|
|
25
|
+
export const excludedCacheIdsForRefresh = ref<string[]>([])
|
|
26
|
+
/** pageCacheId -> Map<selector, { top, left }> 滚动位置存储 */
|
|
27
|
+
export const scrollPositionsByPageId = new Map<string, Map<string, { top: number; left: number }>>()
|
|
28
|
+
/** 是否已完成初始化 */
|
|
29
|
+
export const isInitialized = ref<boolean>(false)
|
|
30
|
+
|
|
31
|
+
/** sessionStorage 中存储当前激活标签的 key 前缀 */
|
|
32
|
+
export const SESSION_TAB_NAME = 'stacktab-active-tab'
|
|
33
|
+
|
|
34
|
+
/** iframe 标签 tabId -> 刷新序号,变更时触发 iframe 重载 */
|
|
35
|
+
export const iframeRefreshKeys = ref<Record<string, number>>({})
|
|
36
|
+
|
|
37
|
+
/** 最大标签数量,0 表示不限制 */
|
|
38
|
+
export let maxTabCount = 0
|
|
39
|
+
/** 是否使用页面级滚动 */
|
|
40
|
+
export let useGlobalScroll = false
|
|
41
|
+
/** sessionStorage key 前缀 */
|
|
42
|
+
export let sessionPrefix = ''
|
|
43
|
+
|
|
44
|
+
export const setMaxTabCount = (n: number) => {
|
|
45
|
+
maxTabCount = n
|
|
46
|
+
}
|
|
47
|
+
export const setUseGlobalScroll = (v: boolean) => {
|
|
48
|
+
useGlobalScroll = v
|
|
49
|
+
}
|
|
50
|
+
export const setSessionPrefix = (p: string) => {
|
|
51
|
+
sessionPrefix = p
|
|
52
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { onUnmounted,
|
|
1
|
+
import { onUnmounted, ref, reactive, nextTick } from 'vue'
|
|
2
2
|
import type { ITabItem } from '../model/TabModel'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useContextMenu - 标签右键菜单 Hook
|
|
6
|
+
*
|
|
7
|
+
* 职责:管理右键菜单的显示/隐藏、定位,点击菜单外区域关闭
|
|
8
|
+
* 使用:TabHeader 中右键标签时调用 showContextMenu
|
|
9
|
+
*/
|
|
3
10
|
export default () => {
|
|
11
|
+
/** 菜单展示所需数据:当前标签、索引、坐标、最大数量 */
|
|
4
12
|
const contextMenuData = reactive({
|
|
5
13
|
item: {},
|
|
6
14
|
index: -1,
|
|
@@ -8,38 +16,32 @@ export default () => {
|
|
|
8
16
|
top: 0,
|
|
9
17
|
max: 0
|
|
10
18
|
})
|
|
19
|
+
/** 菜单是否显示 */
|
|
11
20
|
const shown = ref<boolean>(false)
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
/** 点击菜单外部时关闭菜单并移除监听 */
|
|
23
|
+
const handleClickOutside = (ev: MouseEvent) => {
|
|
24
|
+
const target = ev.target as Element
|
|
25
|
+
if (target.closest?.('.stack-tab__contextmenu')) return
|
|
26
|
+
shown.value = false
|
|
27
|
+
document.removeEventListener('click', handleClickOutside)
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
onUnmounted(() => {
|
|
17
|
-
document.removeEventListener('click',
|
|
31
|
+
document.removeEventListener('click', handleClickOutside)
|
|
18
32
|
})
|
|
19
33
|
|
|
34
|
+
/** 显示右键菜单;nextTick 后设置位置与数据,避免与关闭逻辑冲突 */
|
|
20
35
|
const showContextMenu = async (e: MouseEvent, item: ITabItem, index: number, max: number) => {
|
|
21
|
-
// 关闭已打开的菜单
|
|
22
36
|
shown.value = false
|
|
37
|
+
document.removeEventListener('click', handleClickOutside)
|
|
23
38
|
|
|
24
39
|
await nextTick(() => {
|
|
25
40
|
const { clientY: top, clientX: left } = e
|
|
26
41
|
shown.value = true
|
|
27
42
|
Object.assign(contextMenuData, { item, index, top, left, max })
|
|
28
|
-
|
|
29
|
-
console.log('showContextMenu in callback')
|
|
43
|
+
nextTick(() => document.addEventListener('click', handleClickOutside))
|
|
30
44
|
})
|
|
31
|
-
|
|
32
|
-
// .then(() => {
|
|
33
|
-
|
|
34
|
-
// })
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const clickOutSide = (ev: MouseEvent) => {
|
|
38
|
-
const classList = (ev.target as Element).classList
|
|
39
|
-
if (classList.contains('stack-tab__contextmenu')) {
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
shown.value = false
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
return {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useRouter } from 'vue-router'
|
|
2
|
+
import { throttle } from 'throttle-debounce'
|
|
3
|
+
import { defu } from 'defu'
|
|
4
|
+
import { ulid } from 'ulid'
|
|
5
|
+
|
|
6
|
+
import useTabPanel from './useTabPanel'
|
|
7
|
+
import { encodeTabInfo } from '../utils/tabInfoEncoder'
|
|
8
|
+
import { parseUrl, isCrossOriginUrl } from '../utils/urlParser'
|
|
9
|
+
import type { ITabData, IframeRefreshMode } from '../model/TabModel'
|
|
10
|
+
import { TabEventType, useTabEmitter } from './useTabEventBus'
|
|
11
|
+
|
|
12
|
+
/** iframe 占位路由的 path,由 StackTabs 通过 setIFramePath 注入 */
|
|
13
|
+
let iframePath: string
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* useTabActions - 标签操作对外 Hook
|
|
17
|
+
*
|
|
18
|
+
* 职责:提供打开、关闭、刷新标签等用户级 API,内部委托 useTabPanel 执行
|
|
19
|
+
* 使用范围:业务代码、ContextMenu、TabHeader 等
|
|
20
|
+
*
|
|
21
|
+
* 主要 API:
|
|
22
|
+
* - openTab(tab, renew?) 打开或续签标签
|
|
23
|
+
* - closeTab(id) / closeAllTabs() 关闭标签
|
|
24
|
+
* - refreshTab(id) / refreshAllTabs() 刷新
|
|
25
|
+
* - activeTab(id, isRoute?) 激活标签
|
|
26
|
+
* - reset() 重置(关闭所有)
|
|
27
|
+
*/
|
|
28
|
+
export default function useTabActions() {
|
|
29
|
+
const router = useRouter()
|
|
30
|
+
const emitter = useTabEmitter()
|
|
31
|
+
const {
|
|
32
|
+
active,
|
|
33
|
+
hasTab,
|
|
34
|
+
refreshKey,
|
|
35
|
+
reset,
|
|
36
|
+
canAddTab,
|
|
37
|
+
renewTab,
|
|
38
|
+
getTab,
|
|
39
|
+
tabs,
|
|
40
|
+
removeTab,
|
|
41
|
+
removeAllTabs,
|
|
42
|
+
refreshTab,
|
|
43
|
+
refreshAllTabs
|
|
44
|
+
} = useTabPanel()
|
|
45
|
+
|
|
46
|
+
/** 合并默认值,生成完整 tabInfo */
|
|
47
|
+
const prepareTabInfo = (tab: ITabData): ITabData => {
|
|
48
|
+
const defaults: Partial<ITabData> = {
|
|
49
|
+
refreshable: true,
|
|
50
|
+
closable: true,
|
|
51
|
+
iframe: false
|
|
52
|
+
}
|
|
53
|
+
if (tab.iframe && tab.iframeRefreshMode === undefined) {
|
|
54
|
+
defaults.iframeRefreshMode = isCrossOriginUrl(tab.path ?? '')
|
|
55
|
+
? ('reload' as IframeRefreshMode)
|
|
56
|
+
: ('postMessage' as IframeRefreshMode)
|
|
57
|
+
} else if (tab.iframe) {
|
|
58
|
+
defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
|
|
59
|
+
}
|
|
60
|
+
const info = defu(tab, defaults)
|
|
61
|
+
if (!info.id) info.id = ulid()
|
|
62
|
+
return info
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开;500ms 节流 */
|
|
66
|
+
const openTab = throttle(500, (tab: ITabData, renew = false) => {
|
|
67
|
+
return new Promise<string | undefined>((resolve, reject) => {
|
|
68
|
+
const tabInfo = prepareTabInfo(tab)
|
|
69
|
+
|
|
70
|
+
if (hasTab(tabInfo.id!) && !renew) {
|
|
71
|
+
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id! })
|
|
72
|
+
resolve(tabInfo.id)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (tabInfo.id && renew && hasTab(tabInfo.id)) {
|
|
77
|
+
renewTab(tab)
|
|
78
|
+
const currentTab = getTab(tab.id!)
|
|
79
|
+
if (currentTab?.active) refreshKey.value++
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!canAddTab()) {
|
|
83
|
+
reject(new Error('Max Size'))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
navigateToTab(tab, tabInfo)
|
|
88
|
+
resolve(tabInfo.id)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
|
|
93
|
+
const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
|
|
94
|
+
const __tab = encodeTabInfo(tabInfo)
|
|
95
|
+
let query = defu({ __tab }, tab.query) as Record<string, string>
|
|
96
|
+
let path: string
|
|
97
|
+
if (!tab.iframe) {
|
|
98
|
+
const url = parseUrl(tab.path)
|
|
99
|
+
path = url.path
|
|
100
|
+
query = defu(url.query, query)
|
|
101
|
+
} else {
|
|
102
|
+
query['__src'] = encodeURIComponent(tab.path as string)
|
|
103
|
+
path = iframePath
|
|
104
|
+
}
|
|
105
|
+
router.push({ path, query })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 设置 iframe 占位路由的 path,由 StackTabs 在挂载时调用 */
|
|
109
|
+
const setIFramePath = (path: string) => {
|
|
110
|
+
iframePath = path
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 获取当前页面的缓存容器 DOM(用于某些需要直接操作 DOM 的场景) */
|
|
114
|
+
const getWrapper = () => {
|
|
115
|
+
return document.getElementsByClassName('cache-page-wrapper')[0]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** iframe 标签在新窗口打开(无法嵌入时降级) */
|
|
119
|
+
const openInNewWindow = (id: string) => {
|
|
120
|
+
const tab = getTab(id)
|
|
121
|
+
if (tab?.iframe && tab.url) {
|
|
122
|
+
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
// 打开
|
|
128
|
+
openTab,
|
|
129
|
+
|
|
130
|
+
// 关闭
|
|
131
|
+
closeTab: removeTab,
|
|
132
|
+
closeAllTabs: removeAllTabs,
|
|
133
|
+
|
|
134
|
+
// 刷新
|
|
135
|
+
refreshTab,
|
|
136
|
+
refreshAllTabs,
|
|
137
|
+
|
|
138
|
+
// 激活与重置
|
|
139
|
+
activeTab: active,
|
|
140
|
+
reset,
|
|
141
|
+
|
|
142
|
+
// 数据与配置
|
|
143
|
+
tabs,
|
|
144
|
+
setIFramePath,
|
|
145
|
+
getWrapper,
|
|
146
|
+
openInNewWindow
|
|
147
|
+
}
|
|
148
|
+
}
|