vue-stack-tabs 1.3.0 → 1.4.1
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 +119 -52
- package/README.md +104 -41
- package/README.ru.md +125 -54
- package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
- package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
- package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
- package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
- package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +20 -0
- package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
- package/dist/hooks/useScrollShadow.d.ts +20 -0
- package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
- package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
- package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
- package/dist/iframe-bridge.mjs +1 -1
- package/dist/index.d.ts +22 -6
- package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
- package/dist/nuxt/module.mjs +10 -6
- package/dist/nuxt/runtime/plugin.mjs +1600 -4
- package/dist/style.css +1 -1
- package/dist/utils/iframeBridge.d.ts +7 -1
- package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
- package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
- package/dist/vue-stack-tabs.css +1 -1
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +83 -59
- package/src/lib/StackTabs.vue +16 -3
- package/src/lib/assets/style/common/var.scss +2 -0
- package/src/lib/assets/style/stackTab.scss +43 -4
- package/src/lib/components/ContextMenu/index.vue +12 -3
- package/src/lib/components/PageLoading.vue +10 -2
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
- package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
- package/src/lib/components/TabHeader/TabHeaderItem.vue +30 -2
- package/src/lib/components/TabHeader/index.vue +76 -4
- package/src/lib/hooks/stackTabsContext.ts +23 -18
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
- package/src/lib/hooks/tabPanel/refresh.ts +3 -0
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/tabPanel/session.ts +29 -3
- package/src/lib/hooks/useContextMenu.ts +57 -11
- package/src/lib/hooks/useIframeManager.ts +18 -2
- package/src/lib/hooks/useScrollShadow.ts +124 -0
- package/src/lib/hooks/useTabActions.ts +20 -8
- package/src/lib/hooks/useTabEventBus.ts +5 -3
- package/src/lib/hooks/useTabPanel.tsx +132 -180
- package/src/lib/hooks/useTabRouter.ts +11 -2
- package/src/lib/i18n/index.ts +3 -3
- package/src/lib/model/TabModel.ts +40 -1
- package/src/lib/nuxt/module.ts +10 -3
- package/src/lib/nuxt/runtime/plugin.ts +13 -2
- package/src/lib/utils/iframeBridge.ts +9 -1
- package/src/lib/utils/scrollUtils.ts +35 -2
- package/src/lib/utils/tabInfoEncoder.ts +21 -8
- package/src/lib/utils/urlParser.ts +3 -1
- package/src/lib/versionLogger.ts +6 -2
- package/dist/favicon.svg +0 -10
- package/dist/iframe-bridge.html +0 -77
- package/dist/iframe-child.html +0 -88
- package/dist/nuxt/favicon.svg +0 -10
- package/dist/nuxt/iframe-bridge.html +0 -77
- package/dist/nuxt/iframe-child.html +0 -88
- package/dist/nuxt/plugin.mjs +0 -9
- package/dist/src/lib/iframe-bridge.d.ts +0 -8
- package/dist/src/lib/index.d.ts +0 -22
- package/dist/src/lib/nuxt/module.d.ts +0 -6
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
- package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
- package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
- /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
- /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* VueStackTabs 全局运行时上下文管理。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 负责创建、注册、解析 StackTabsRuntimeContext。
|
|
5
|
+
* 业务运行时通过 Vue provide/inject 隔离;模块级回退仅保留给没有 App
|
|
6
|
+
* 容器的兼容调用(例如旧的单元测试),绝不能承载某个 Vue App 的状态。
|
|
6
7
|
* 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
|
|
7
8
|
*/
|
|
8
9
|
import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
|
|
@@ -77,7 +78,10 @@ export const createStackTabsRuntimeContext = (
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* 获取非 App 容器下注册的兼容上下文。
|
|
83
|
+
* @deprecated App 内的 composable 必须依赖 provide/inject,不应使用这个回退。
|
|
84
|
+
*/
|
|
81
85
|
export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
|
|
82
86
|
activeRuntimeContext
|
|
83
87
|
|
|
@@ -86,22 +90,21 @@ export const registerStackTabsRuntimeContext = (
|
|
|
86
90
|
context: StackTabsRuntimeContext,
|
|
87
91
|
options: RegisterStackTabsRuntimeContextOptions = {}
|
|
88
92
|
): boolean => {
|
|
89
|
-
if (!activeRuntimeContext) {
|
|
90
|
-
activeRuntimeContext = context
|
|
91
|
-
}
|
|
92
|
-
|
|
93
93
|
if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
|
|
94
94
|
runtimeContextOwnersByApp.add(options.app)
|
|
95
95
|
return true
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
if (
|
|
99
|
-
!
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
if (!options.app) {
|
|
99
|
+
if (!activeRuntimeContext) {
|
|
100
|
+
activeRuntimeContext = context
|
|
101
|
+
isFallbackRuntimeContextOwnerRegistered = true
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
if (activeRuntimeContext === context && !isFallbackRuntimeContextOwnerRegistered) {
|
|
105
|
+
isFallbackRuntimeContextOwnerRegistered = true
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
const isProduction = options.isProduction ?? import.meta.env.PROD
|
|
@@ -118,13 +121,15 @@ export const unregisterStackTabsRuntimeContext = (
|
|
|
118
121
|
context: StackTabsRuntimeContext,
|
|
119
122
|
options: UnregisterStackTabsRuntimeContextOptions = {}
|
|
120
123
|
): void => {
|
|
121
|
-
if (options.app)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
if (options.app) {
|
|
125
|
+
runtimeContextOwnersByApp.delete(options.app)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (activeRuntimeContext === context) isFallbackRuntimeContextOwnerRegistered = false
|
|
124
129
|
if (activeRuntimeContext === context) activeRuntimeContext = null
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
/** 解析当前可用的运行时上下文,优先使用 inject
|
|
132
|
+
/** 解析当前可用的运行时上下文,优先使用 inject,兼容回退仅限非 App 容器。 */
|
|
128
133
|
export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
|
|
129
134
|
const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
|
|
130
135
|
const context = injected ?? activeRuntimeContext
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 页面缓存组件工厂。
|
|
3
|
+
*
|
|
4
|
+
* 将 keep-alive 包装、VNode 注入和页面激活生命周期从 tab 状态编排中隔离,
|
|
5
|
+
* 使 useTabPanel 只负责领域状态与路由协调。
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
cloneVNode,
|
|
9
|
+
defineComponent,
|
|
10
|
+
onActivated,
|
|
11
|
+
onDeactivated,
|
|
12
|
+
onMounted,
|
|
13
|
+
onUnmounted,
|
|
14
|
+
ref,
|
|
15
|
+
shallowRef
|
|
16
|
+
} from 'vue'
|
|
17
|
+
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
18
|
+
import type { ITabBase, ITabItem } from '../../model/TabModel'
|
|
19
|
+
import PageLoading from '../../components/PageLoading.vue'
|
|
20
|
+
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
21
|
+
import { useScrollShadow } from '../useScrollShadow'
|
|
22
|
+
|
|
23
|
+
interface PageComponentFactoryOptions {
|
|
24
|
+
runtimeContext: StackTabsRuntimeContext
|
|
25
|
+
getTab: (id: string) => ITabItem | null
|
|
26
|
+
addCache: (cacheName: string) => void
|
|
27
|
+
evictMarkedCaches: () => void
|
|
28
|
+
addPageScroller: (pageId: string, ...selectors: string[]) => void
|
|
29
|
+
saveScroller: (pageId: string) => void
|
|
30
|
+
restoreScroller: (pageId: string) => void
|
|
31
|
+
removeScroller: (pageId: string) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
|
|
35
|
+
const {
|
|
36
|
+
runtimeContext,
|
|
37
|
+
getTab,
|
|
38
|
+
addCache,
|
|
39
|
+
evictMarkedCaches,
|
|
40
|
+
addPageScroller,
|
|
41
|
+
saveScroller,
|
|
42
|
+
restoreScroller,
|
|
43
|
+
removeScroller
|
|
44
|
+
} = options
|
|
45
|
+
const { components, tabIdsToEvict } = runtimeContext
|
|
46
|
+
|
|
47
|
+
const resolvePageComponent = (ctx: { cacheName: string; tabInfo: ITabBase }): DefineComponent => {
|
|
48
|
+
const { cacheName, tabInfo } = ctx
|
|
49
|
+
const existing = components.get(cacheName)
|
|
50
|
+
if (existing) {
|
|
51
|
+
addCache(cacheName)
|
|
52
|
+
return existing
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cacheComponent = defineComponent<{ vnode?: VNode }>({
|
|
56
|
+
name: cacheName,
|
|
57
|
+
props: {
|
|
58
|
+
vnode: {
|
|
59
|
+
type: Object as PropType<VNode>,
|
|
60
|
+
required: false,
|
|
61
|
+
default: undefined
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
emits: ['onLoaded'],
|
|
65
|
+
setup(props, context) {
|
|
66
|
+
const localBackParams = ref<Record<string, unknown> | null>(null)
|
|
67
|
+
const lastVnode = shallowRef<VNode | null | undefined>(null)
|
|
68
|
+
const lastCloned = shallowRef<VNode | null>(null)
|
|
69
|
+
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
70
|
+
const pageRoot = ref<HTMLElement | null>(null)
|
|
71
|
+
|
|
72
|
+
const checkAndConsumeBackParams = () => {
|
|
73
|
+
try {
|
|
74
|
+
const tab = getTab(tabInfo.id!)
|
|
75
|
+
const top = tab?.pages.peek()
|
|
76
|
+
if (top && top.id === cacheName && top._backParams) {
|
|
77
|
+
localBackParams.value = { ...top._backParams }
|
|
78
|
+
delete top._backParams
|
|
79
|
+
} else {
|
|
80
|
+
localBackParams.value = null
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
localBackParams.value = null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onMounted(() => {
|
|
88
|
+
context.emit('onLoaded')
|
|
89
|
+
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
90
|
+
checkAndConsumeBackParams()
|
|
91
|
+
})
|
|
92
|
+
onDeactivated(() => saveScroller(cacheName))
|
|
93
|
+
onActivated(() => {
|
|
94
|
+
context.emit('onLoaded')
|
|
95
|
+
restoreScroller(cacheName)
|
|
96
|
+
evictMarkedCaches()
|
|
97
|
+
tabIdsToEvict.clear()
|
|
98
|
+
checkAndConsumeBackParams()
|
|
99
|
+
})
|
|
100
|
+
onUnmounted(() => removeScroller(cacheName))
|
|
101
|
+
|
|
102
|
+
// 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
|
|
103
|
+
useScrollShadow({ target: pageRoot })
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
const vnode = props.vnode
|
|
107
|
+
const backParams = localBackParams.value
|
|
108
|
+
if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
|
|
109
|
+
lastCloned.value = vnode
|
|
110
|
+
? cloneVNode(vnode, {
|
|
111
|
+
tId: tabInfo.id,
|
|
112
|
+
pId: cacheName,
|
|
113
|
+
...(backParams ? { _back: backParams } : {})
|
|
114
|
+
})
|
|
115
|
+
: null
|
|
116
|
+
lastVnode.value = vnode
|
|
117
|
+
lastBackParams.value = backParams
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={pageRoot}
|
|
122
|
+
class="cache-page-wrapper"
|
|
123
|
+
id={`W-${cacheName}`}
|
|
124
|
+
style={[
|
|
125
|
+
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
126
|
+
'height: 100%'
|
|
127
|
+
]}
|
|
128
|
+
>
|
|
129
|
+
{lastCloned.value}
|
|
130
|
+
<PageLoading tabId={tabInfo.id!} />
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}) as DefineComponent
|
|
136
|
+
components.set(cacheName, cacheComponent)
|
|
137
|
+
addCache(cacheName)
|
|
138
|
+
return cacheComponent
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { resolvePageComponent }
|
|
142
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 活动普通页面保持 cache id 稳定,只递增页面级刷新版本;非活动页面可直接替换缓存。
|
|
5
5
|
*/
|
|
6
6
|
import type { Ref } from 'vue'
|
|
7
|
+
import { triggerRef } from 'vue'
|
|
7
8
|
import type { ITabItem } from '../../model/TabModel'
|
|
8
9
|
|
|
9
10
|
export interface TabPanelRefreshApi {
|
|
@@ -56,6 +57,8 @@ export const createTabPanelRefresh = (
|
|
|
56
57
|
|
|
57
58
|
if (tab.active) {
|
|
58
59
|
currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
|
|
60
|
+
// 通知 activePageRefreshVersion 这类依赖 tabs.value 的计算属性重新计算
|
|
61
|
+
triggerRef(tabs)
|
|
59
62
|
return
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* tabPanel/scroll - 滚动位置保存与恢复
|
|
3
3
|
*
|
|
4
4
|
* 职责:基于当前 StackTabsRuntimeContext 在切出/切入页面时保存和恢复滚动位置。
|
|
5
|
+
* 优化:用 MutationObserver 缓存 [data-stack-tab-scroll] 元素列表,
|
|
6
|
+
* 避免每次 deactivate 都全量 querySelectorAll。
|
|
5
7
|
*/
|
|
6
8
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
7
9
|
|
|
@@ -10,6 +12,8 @@ export interface TabPanelScrollApi {
|
|
|
10
12
|
saveScroller: (pageCacheId: string) => void
|
|
11
13
|
removeScroller: (pageCacheId: string) => void
|
|
12
14
|
addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
|
|
15
|
+
/** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
|
|
16
|
+
invalidateAutoScrollCache: (pageCacheId: string) => void
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
@@ -17,6 +21,73 @@ const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
|
17
21
|
? document.getElementById(selector.slice(1))
|
|
18
22
|
: (document.querySelector(selector) as HTMLElement | null)
|
|
19
23
|
|
|
24
|
+
const AUTO_SCROLLER_PREFIX = '__stack-tab-auto-scroll__:'
|
|
25
|
+
|
|
26
|
+
interface AutoScrollCache {
|
|
27
|
+
elements: HTMLElement[]
|
|
28
|
+
observer: MutationObserver | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 在 pageRoot 上挂一个 MutationObserver,自动维护 [data-stack-tab-scroll] 元素列表;
|
|
33
|
+
* 列表变化时无需上层主动失效,下次读到的就是最新值。
|
|
34
|
+
*/
|
|
35
|
+
const createAutoScrollCache = (pageRoot: HTMLElement): AutoScrollCache => {
|
|
36
|
+
const collect = (): HTMLElement[] =>
|
|
37
|
+
Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
|
|
38
|
+
|
|
39
|
+
const cache: AutoScrollCache = {
|
|
40
|
+
elements: collect(),
|
|
41
|
+
observer: null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
45
|
+
cache.observer = new MutationObserver(() => {
|
|
46
|
+
cache.elements = collect()
|
|
47
|
+
})
|
|
48
|
+
// 仅监听子树增删与属性变化,不监听字符数据,减少开销。
|
|
49
|
+
cache.observer.observe(pageRoot, {
|
|
50
|
+
childList: true,
|
|
51
|
+
subtree: true,
|
|
52
|
+
attributes: true,
|
|
53
|
+
attributeFilter: ['data-stack-tab-scroll']
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cache
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const autoScrollCaches = new Map<string, AutoScrollCache>()
|
|
61
|
+
|
|
62
|
+
const getAutoScrollCache = (pageCacheId: string): AutoScrollCache | null => {
|
|
63
|
+
const pageRoot = document.getElementById(`W-${pageCacheId}`)
|
|
64
|
+
if (!pageRoot) {
|
|
65
|
+
autoScrollCaches.delete(pageCacheId)
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cache = autoScrollCaches.get(pageCacheId)
|
|
70
|
+
if (!cache) {
|
|
71
|
+
cache = createAutoScrollCache(pageRoot)
|
|
72
|
+
autoScrollCaches.set(pageCacheId, cache)
|
|
73
|
+
} else {
|
|
74
|
+
// 防御:检查 pageRoot 是否已被替换(极端情况:同一 pageCacheId 重新挂载)
|
|
75
|
+
if (cache.elements.length === 0 && cache.observer) {
|
|
76
|
+
cache.elements = Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return cache
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getAutoScrollerKey = (index: number): string => `${AUTO_SCROLLER_PREFIX}${index}`
|
|
83
|
+
|
|
84
|
+
const resolveAutoScrollElement = (pageCacheId: string, key: string): HTMLElement | null => {
|
|
85
|
+
const index = Number(key.slice(AUTO_SCROLLER_PREFIX.length))
|
|
86
|
+
if (!Number.isInteger(index)) return null
|
|
87
|
+
const cache = getAutoScrollCache(pageCacheId)
|
|
88
|
+
return cache?.elements[index] ?? null
|
|
89
|
+
}
|
|
90
|
+
|
|
20
91
|
export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
|
|
21
92
|
const { scrollPositionsByPageId } = context
|
|
22
93
|
|
|
@@ -25,7 +96,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
25
96
|
if (!positions) return
|
|
26
97
|
|
|
27
98
|
for (const [selector, position] of positions) {
|
|
28
|
-
const element =
|
|
99
|
+
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
100
|
+
? resolveAutoScrollElement(pageCacheId, selector)
|
|
101
|
+
: resolveScrollElement(selector)
|
|
29
102
|
if (element) {
|
|
30
103
|
element.scrollTop = position.top
|
|
31
104
|
element.scrollLeft = position.left
|
|
@@ -37,8 +110,23 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
37
110
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
38
111
|
if (!positions) return
|
|
39
112
|
|
|
113
|
+
// 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置。
|
|
114
|
+
for (const key of positions.keys()) {
|
|
115
|
+
if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
|
|
116
|
+
}
|
|
117
|
+
const cache = getAutoScrollCache(pageCacheId)
|
|
118
|
+
const elements = cache?.elements ?? []
|
|
119
|
+
elements.forEach((element, index) => {
|
|
120
|
+
positions.set(getAutoScrollerKey(index), {
|
|
121
|
+
top: element.scrollTop,
|
|
122
|
+
left: element.scrollLeft
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
40
126
|
for (const selector of positions.keys()) {
|
|
41
|
-
const element =
|
|
127
|
+
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
128
|
+
? resolveAutoScrollElement(pageCacheId, selector)
|
|
129
|
+
: resolveScrollElement(selector)
|
|
42
130
|
positions.set(selector, {
|
|
43
131
|
top: element?.scrollTop ?? 0,
|
|
44
132
|
left: element?.scrollLeft ?? 0
|
|
@@ -48,10 +136,16 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
48
136
|
|
|
49
137
|
const removeScroller = (pageCacheId: string): void => {
|
|
50
138
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
139
|
+
if (positions) {
|
|
140
|
+
positions.clear()
|
|
141
|
+
scrollPositionsByPageId.delete(pageCacheId)
|
|
142
|
+
}
|
|
143
|
+
// 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用。
|
|
144
|
+
const cache = autoScrollCaches.get(pageCacheId)
|
|
145
|
+
if (cache) {
|
|
146
|
+
cache.observer?.disconnect()
|
|
147
|
+
autoScrollCaches.delete(pageCacheId)
|
|
148
|
+
}
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
const addPageScroller = (pageCacheId: string, ...selectorIds: string[]): void => {
|
|
@@ -64,10 +158,19 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
64
158
|
}
|
|
65
159
|
}
|
|
66
160
|
|
|
161
|
+
const invalidateAutoScrollCache = (pageCacheId: string): void => {
|
|
162
|
+
const cache = autoScrollCaches.get(pageCacheId)
|
|
163
|
+
if (!cache) return
|
|
164
|
+
cache.elements = []
|
|
165
|
+
cache.observer?.disconnect()
|
|
166
|
+
autoScrollCaches.delete(pageCacheId)
|
|
167
|
+
}
|
|
168
|
+
|
|
67
169
|
return {
|
|
68
170
|
restoreScroller,
|
|
69
171
|
saveScroller,
|
|
70
172
|
removeScroller,
|
|
71
|
-
addPageScroller
|
|
173
|
+
addPageScroller,
|
|
174
|
+
invalidateAutoScrollCache
|
|
72
175
|
}
|
|
73
176
|
}
|
|
@@ -33,12 +33,38 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
33
33
|
let writeVersion = 0
|
|
34
34
|
let writeScheduled = false
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
|
|
38
|
+
* 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
|
|
39
|
+
*/
|
|
40
|
+
const safeSetItem = (key: string, value: string): void => {
|
|
41
|
+
if (typeof window === 'undefined') return
|
|
42
|
+
try {
|
|
43
|
+
window.sessionStorage.setItem(key, value)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!import.meta.env.PROD) {
|
|
46
|
+
console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const safeRemoveItem = (key: string): void => {
|
|
52
|
+
if (typeof window === 'undefined') return
|
|
53
|
+
try {
|
|
54
|
+
window.sessionStorage.removeItem(key)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!import.meta.env.PROD) {
|
|
57
|
+
console.warn('[vue-stack-tabs] Failed to clear sessionStorage:', error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
const flushPendingWrite = () => {
|
|
37
63
|
writeScheduled = false
|
|
38
64
|
const write = pendingWrite
|
|
39
65
|
pendingWrite = null
|
|
40
66
|
if (!write || write.version !== writeVersion) return
|
|
41
|
-
|
|
67
|
+
safeSetItem(getSessionKey(), JSON.stringify(write.tab))
|
|
42
68
|
}
|
|
43
69
|
|
|
44
70
|
const invalidatePendingWrite = () => {
|
|
@@ -56,7 +82,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
56
82
|
|
|
57
83
|
const clearSession = (): void => {
|
|
58
84
|
invalidatePendingWrite()
|
|
59
|
-
|
|
85
|
+
safeRemoveItem(getSessionKey())
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
const restoreActiveTabSession = (storedJson: string | null): void => {
|
|
@@ -65,7 +91,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
65
91
|
return
|
|
66
92
|
}
|
|
67
93
|
invalidatePendingWrite()
|
|
68
|
-
|
|
94
|
+
safeSetItem(getSessionKey(), storedJson)
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
|
|
@@ -19,29 +19,75 @@ export default () => {
|
|
|
19
19
|
/** 菜单是否显示 */
|
|
20
20
|
const shown = ref<boolean>(false)
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** 关闭菜单并清理全局事件。 */
|
|
23
|
+
const hideContextMenu = () => {
|
|
24
|
+
shown.value = false
|
|
25
|
+
document.removeEventListener('click', handleClickOutside)
|
|
26
|
+
document.removeEventListener('focusin', handleDocumentFocusIn, true)
|
|
27
|
+
window.removeEventListener('blur', handleWindowBlur)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 记录最近一次 showContextMenu 触发的时间戳。contextmenu 派发后部分浏览器
|
|
32
|
+
* 会再派发一次 click 事件;如果 click 时间与 contextmenu 太近,则忽略本次 click,
|
|
33
|
+
* 避免菜单刚显示就被自身点击触发 handleClickOutside 关闭。
|
|
34
|
+
* 同时也是上一轮 await nextTick() 双跳方案之外的兜底:nextTick 在极端时序下
|
|
35
|
+
* 仍可能晚于同一帧的 click 派发。
|
|
36
|
+
*/
|
|
37
|
+
let lastContextMenuAt = 0
|
|
38
|
+
/** 触发菜单后 BUBBLE_GUARD_MS 毫秒内的 click 视为「同一手势」,不关闭菜单 */
|
|
39
|
+
const BUBBLE_GUARD_MS = 250
|
|
40
|
+
|
|
41
|
+
/** 点击菜单外部时关闭菜单。 */
|
|
23
42
|
const handleClickOutside = (ev: MouseEvent) => {
|
|
24
43
|
const target = ev.target as Element
|
|
25
44
|
if (target.closest?.('.stack-tab__contextmenu')) return
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
// 同一手势的 click 仍在守卫窗口内,跳过关闭判断。
|
|
46
|
+
if (Date.now() - lastContextMenuAt < BUBBLE_GUARD_MS) return
|
|
47
|
+
hideContextMenu()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* iframe 内的点击不会冒泡到父页面,但 iframe 自身会在父文档中获得焦点。
|
|
52
|
+
* 捕获 focusin 仅处理该元素,避免 window.blur 干扰菜单内部的焦点管理。
|
|
53
|
+
*/
|
|
54
|
+
const handleDocumentFocusIn = (event: FocusEvent) => {
|
|
55
|
+
if (event.target instanceof HTMLIFrameElement) hideContextMenu()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 部分浏览器不会把 iframe 内部点击的 focusin 派发到父文档;此时父窗口
|
|
60
|
+
* 会失焦。等待焦点完成切换后,只在 activeElement 确实为 iframe 时收起。
|
|
61
|
+
*/
|
|
62
|
+
const handleWindowBlur = () => {
|
|
63
|
+
window.requestAnimationFrame(() => {
|
|
64
|
+
if (document.activeElement instanceof HTMLIFrameElement) hideContextMenu()
|
|
65
|
+
})
|
|
28
66
|
}
|
|
29
67
|
|
|
30
68
|
onUnmounted(() => {
|
|
31
|
-
|
|
69
|
+
hideContextMenu()
|
|
32
70
|
})
|
|
33
71
|
|
|
34
|
-
/** 显示右键菜单;nextTick
|
|
72
|
+
/** 显示右键菜单;nextTick 后设置位置与数据,再下一拍挂载外部点击监听,避免与关闭逻辑冲突 */
|
|
35
73
|
const showContextMenu = async (e: MouseEvent, item: ITabItem, index: number, max: number) => {
|
|
74
|
+
// 先更新守卫时间戳,确保紧随其后的 click 事件被 handleClickOutside 忽略。
|
|
75
|
+
lastContextMenuAt = Date.now()
|
|
36
76
|
shown.value = false
|
|
37
77
|
document.removeEventListener('click', handleClickOutside)
|
|
78
|
+
document.removeEventListener('focusin', handleDocumentFocusIn, true)
|
|
79
|
+
window.removeEventListener('blur', handleWindowBlur)
|
|
38
80
|
|
|
39
|
-
await nextTick(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
await nextTick()
|
|
82
|
+
const { clientY: top, clientX: left } = e
|
|
83
|
+
shown.value = true
|
|
84
|
+
Object.assign(contextMenuData, { item, index, top, left, max })
|
|
85
|
+
|
|
86
|
+
// 等下一拍再挂载 click 监听,避免本次触发 showContextMenu 的同一 click 事件被立即关闭。
|
|
87
|
+
await nextTick()
|
|
88
|
+
document.addEventListener('click', handleClickOutside)
|
|
89
|
+
document.addEventListener('focusin', handleDocumentFocusIn, true)
|
|
90
|
+
window.addEventListener('blur', handleWindowBlur)
|
|
45
91
|
}
|
|
46
92
|
|
|
47
93
|
return {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 统一管理所有 iframe 标签的 src 计算、加载状态追踪、超时检测、
|
|
5
5
|
* 刷新控制、DOM ref 收集,以及 postMessage 通信。
|
|
6
6
|
*/
|
|
7
|
-
import { computed, onBeforeUnmount, reactive, watch, type Ref } from 'vue'
|
|
7
|
+
import { computed, nextTick, onBeforeUnmount, reactive, watch, type Ref } from 'vue'
|
|
8
8
|
import type { ITabData, ITabItem } from '../model/TabModel'
|
|
9
9
|
import { toSafeTabUrl } from '../utils/urlParser'
|
|
10
10
|
import { getPostMessageTargetOrigin, isStackTabsOpenTabMessage } from '../utils/stackTabsMessage'
|
|
@@ -34,6 +34,9 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
34
34
|
const iframeEverActivated = reactive<Record<string, boolean>>({})
|
|
35
35
|
|
|
36
36
|
const appendIframeRefreshKey = (src: string, refreshKey: number): string => {
|
|
37
|
+
// 仅当 refreshKey > 0 时附加查询参数:
|
|
38
|
+
// - 初次激活(key=0)保留干净的 URL,便于用户阅读和书签
|
|
39
|
+
// - 后续 reload / 重试时附加,用于强制浏览器破坏 iframe 缓存
|
|
37
40
|
if (refreshKey <= 0 || src === 'about:blank') return src
|
|
38
41
|
|
|
39
42
|
const hashIndex = src.indexOf('#')
|
|
@@ -113,7 +116,11 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
113
116
|
...iframeRefreshKeys.value,
|
|
114
117
|
[frameId]: (iframeRefreshKeys.value[frameId] ?? 0) + 1
|
|
115
118
|
}
|
|
116
|
-
|
|
119
|
+
// 等下一拍再设置 loading 状态,让 refreshKey watcher 先把新 src 反映到 DOM 上,
|
|
120
|
+
// 避免 loading 遮罩先于 src 变化显示,造成视觉抖动。
|
|
121
|
+
nextTick(() => {
|
|
122
|
+
setIframeLoading(frameId, { force: true })
|
|
123
|
+
})
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
const shouldShowIframeLoading = (frameId: string) =>
|
|
@@ -148,6 +155,15 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
148
155
|
delete iframeElRefs[id]
|
|
149
156
|
}
|
|
150
157
|
}
|
|
158
|
+
// 同步清理已不再属于任何 iframe tab 的 refreshKey,避免长会话下 Map 持续膨胀。
|
|
159
|
+
if (activeIds.size < Object.keys(iframeRefreshKeys.value).length) {
|
|
160
|
+
const next: Record<string, number> = {}
|
|
161
|
+
for (const id of activeIds) {
|
|
162
|
+
const key = iframeRefreshKeys.value[id]
|
|
163
|
+
if (typeof key === 'number') next[id] = key
|
|
164
|
+
}
|
|
165
|
+
iframeRefreshKeys.value = next
|
|
166
|
+
}
|
|
151
167
|
},
|
|
152
168
|
{ immediate: true }
|
|
153
169
|
)
|