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.
Files changed (96) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +104 -41
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +20 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  12. package/dist/hooks/useScrollShadow.d.ts +20 -0
  13. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  14. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  15. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
  16. package/dist/iframe-bridge.mjs +1 -1
  17. package/dist/index.d.ts +22 -6
  18. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  19. package/dist/nuxt/module.mjs +10 -6
  20. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  21. package/dist/style.css +1 -1
  22. package/dist/utils/iframeBridge.d.ts +7 -1
  23. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  24. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  25. package/dist/vue-stack-tabs.css +1 -1
  26. package/dist/vue-stack-tabs.es.js +1 -1
  27. package/package.json +83 -59
  28. package/src/lib/StackTabs.vue +16 -3
  29. package/src/lib/assets/style/common/var.scss +2 -0
  30. package/src/lib/assets/style/stackTab.scss +43 -4
  31. package/src/lib/components/ContextMenu/index.vue +12 -3
  32. package/src/lib/components/PageLoading.vue +10 -2
  33. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  34. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  35. package/src/lib/components/TabHeader/TabHeaderItem.vue +30 -2
  36. package/src/lib/components/TabHeader/index.vue +76 -4
  37. package/src/lib/hooks/stackTabsContext.ts +23 -18
  38. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
  39. package/src/lib/hooks/tabPanel/refresh.ts +3 -0
  40. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  41. package/src/lib/hooks/tabPanel/session.ts +29 -3
  42. package/src/lib/hooks/useContextMenu.ts +57 -11
  43. package/src/lib/hooks/useIframeManager.ts +18 -2
  44. package/src/lib/hooks/useScrollShadow.ts +124 -0
  45. package/src/lib/hooks/useTabActions.ts +20 -8
  46. package/src/lib/hooks/useTabEventBus.ts +5 -3
  47. package/src/lib/hooks/useTabPanel.tsx +132 -180
  48. package/src/lib/hooks/useTabRouter.ts +11 -2
  49. package/src/lib/i18n/index.ts +3 -3
  50. package/src/lib/model/TabModel.ts +40 -1
  51. package/src/lib/nuxt/module.ts +10 -3
  52. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  53. package/src/lib/utils/iframeBridge.ts +9 -1
  54. package/src/lib/utils/scrollUtils.ts +35 -2
  55. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  56. package/src/lib/utils/urlParser.ts +3 -1
  57. package/src/lib/versionLogger.ts +6 -2
  58. package/dist/favicon.svg +0 -10
  59. package/dist/iframe-bridge.html +0 -77
  60. package/dist/iframe-child.html +0 -88
  61. package/dist/nuxt/favicon.svg +0 -10
  62. package/dist/nuxt/iframe-bridge.html +0 -77
  63. package/dist/nuxt/iframe-child.html +0 -88
  64. package/dist/nuxt/plugin.mjs +0 -9
  65. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  66. package/dist/src/lib/index.d.ts +0 -22
  67. package/dist/src/lib/nuxt/module.d.ts +0 -6
  68. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  69. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  70. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  71. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  72. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  73. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  74. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  78. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  79. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  87. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  91. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  92. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  96. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * VueStackTabs 全局运行时上下文管理。
3
3
  *
4
- * 负责创建、注册、解析全局唯一的 StackTabsRuntimeContext,
5
- * 支持通过 Vue provide/inject 或模块级单例两种方式访问。
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
- !options.app &&
100
- activeRuntimeContext === context &&
101
- !isFallbackRuntimeContextOwnerRegistered
102
- ) {
103
- isFallbackRuntimeContextOwnerRegistered = true
104
- return true
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) runtimeContextOwnersByApp.delete(options.app)
122
- if (!options.app && activeRuntimeContext === context)
123
- isFallbackRuntimeContextOwnerRegistered = false
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 = resolveScrollElement(selector)
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 = resolveScrollElement(selector)
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 (!positions) return
52
-
53
- positions.clear()
54
- scrollPositionsByPageId.delete(pageCacheId)
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
- window.sessionStorage.setItem(getSessionKey(), JSON.stringify(write.tab))
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
- window.sessionStorage.removeItem(getSessionKey())
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
- window.sessionStorage.setItem(getSessionKey(), storedJson)
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
- shown.value = false
27
- document.removeEventListener('click', handleClickOutside)
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
- document.removeEventListener('click', handleClickOutside)
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
- const { clientY: top, clientX: left } = e
41
- shown.value = true
42
- Object.assign(contextMenuData, { item, index, top, left, max })
43
- nextTick(() => document.addEventListener('click', handleClickOutside))
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
- setIframeLoading(frameId, { force: true })
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
  )