vue-stack-tabs 1.3.1 → 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 +103 -42
- 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 +64 -40
- package/src/lib/StackTabs.vue +16 -3
- package/src/lib/assets/style/common/var.scss +2 -0
- package/src/lib/assets/style/stackTab.scss +17 -3
- 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/index.vue +4 -3
- 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 +18 -8
- package/src/lib/hooks/useTabEventBus.ts +5 -3
- package/src/lib/hooks/useTabPanel.tsx +108 -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
|
@@ -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
|
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScrollShadow - 为可滚动容器动态切换上下阴影类。
|
|
3
|
+
*
|
|
4
|
+
* 职责:在 scroll 事件触发时根据 scrollTop / scrollHeight 边界,
|
|
5
|
+
* 给目标元素动态加上/移除 stack-tab__shadow-inset-top / -bottom 类,
|
|
6
|
+
* 距离边界 > 阈值时显示阴影(提示用户"还能往这个方向滚"),
|
|
7
|
+
* 已贴边(距离边界 ≤ 阈值)时隐藏阴影,避免到顶/到底后阴影残留误导。
|
|
8
|
+
*
|
|
9
|
+
* 设计要点:
|
|
10
|
+
* - 使用 passive scroll 监听,不阻塞滚动
|
|
11
|
+
* - 使用 ResizeObserver 监视子页面缩放/尺寸变化,重新计算阴影边界
|
|
12
|
+
* - 使用 rAF 节流,避免一帧内多次写 class
|
|
13
|
+
* - 阈值(默认 5px)内视为已贴边(≤ 阈值)→ 隐藏阴影;超过阈值视为可继续滚动 → 显示阴影
|
|
14
|
+
* - SSR 安全:在 document 不存在时直接返回
|
|
15
|
+
* - 自动清理:onScopeDispose 移除监听并取消 rAF/disconnect observer
|
|
16
|
+
*/
|
|
17
|
+
import { onScopeDispose, type Ref } from 'vue'
|
|
18
|
+
|
|
19
|
+
const SHADOW_TOP_CLASS = 'stack-tab__shadow-inset-top'
|
|
20
|
+
const SHADOW_BOTTOM_CLASS = 'stack-tab__shadow-inset-bottom'
|
|
21
|
+
const SHADOW_VERTICAL_CLASS = 'stack-tab__shadow-inset-vertical'
|
|
22
|
+
const DEFAULT_EDGE_THRESHOLD = 5
|
|
23
|
+
|
|
24
|
+
export interface UseScrollShadowOptions {
|
|
25
|
+
/** 容器元素的 ref,可以是 Ref<HTMLElement | null> 或 getter 函数 */
|
|
26
|
+
target: Ref<HTMLElement | null> | (() => HTMLElement | null)
|
|
27
|
+
/**
|
|
28
|
+
* 是否监听内容缩放/尺寸变化(默认 true)。
|
|
29
|
+
* 启用后会用 ResizeObserver 监视目标尺寸,容器或子树尺寸变化都会重新计算阴影边界。
|
|
30
|
+
*/
|
|
31
|
+
watchResize?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* 到顶/到底的判定阈值(px,默认 5)。
|
|
34
|
+
* 距离边界 ≤ 阈值视为已贴边(不显示阴影);距离边界 > 阈值视为可继续滚动(显示阴影)。
|
|
35
|
+
*/
|
|
36
|
+
edgeThreshold?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseScrollShadowReturn {
|
|
40
|
+
/** 手动同步一次(用于内容变化/resize 后) */
|
|
41
|
+
sync: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const resolveTarget = (
|
|
45
|
+
target: Ref<HTMLElement | null> | (() => HTMLElement | null)
|
|
46
|
+
): HTMLElement | null => (typeof target === 'function' ? target() : target.value)
|
|
47
|
+
|
|
48
|
+
export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShadowReturn => {
|
|
49
|
+
if (typeof document === 'undefined') {
|
|
50
|
+
return { sync: () => undefined }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const watchResize = options.watchResize !== false
|
|
54
|
+
const edgeThreshold = Math.max(0, options.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD)
|
|
55
|
+
let rafId: number | null = null
|
|
56
|
+
let attached: HTMLElement | null = null
|
|
57
|
+
let resizeObserver: ResizeObserver | null = null
|
|
58
|
+
|
|
59
|
+
const recompute = (): void => {
|
|
60
|
+
if (!attached) return
|
|
61
|
+
const target = attached
|
|
62
|
+
const { scrollTop, scrollHeight, clientHeight } = target
|
|
63
|
+
const distanceToTop = scrollTop
|
|
64
|
+
const distanceToBottom = scrollHeight - clientHeight - scrollTop
|
|
65
|
+
// 顶部阴影:表示上方还有内容,离开顶部(距离顶部 > 阈值)时显示。
|
|
66
|
+
// 底部阴影:表示下方还有内容,离开底部(距离底部 > 阈值)时显示。
|
|
67
|
+
const showTopShadow = distanceToTop > edgeThreshold
|
|
68
|
+
const showBottomShadow = distanceToBottom > edgeThreshold
|
|
69
|
+
// 注意:-top / -bottom 是两个独立 CSS 规则、作用在同一个 ::after 上,
|
|
70
|
+
// 同时加会互相覆盖(后定义的 -bottom 胜出)。因此这里互斥地只加一个类:
|
|
71
|
+
// 两侧都有内容用 -vertical,只有一侧用 -top / -bottom,贴边时都不加。
|
|
72
|
+
target.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
|
|
73
|
+
target.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
|
|
74
|
+
target.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const schedule = (): void => {
|
|
78
|
+
if (rafId !== null) return
|
|
79
|
+
rafId = requestAnimationFrame(() => {
|
|
80
|
+
rafId = null
|
|
81
|
+
recompute()
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const attach = (): void => {
|
|
86
|
+
const el = resolveTarget(options.target)
|
|
87
|
+
if (!el || el === attached) return
|
|
88
|
+
if (attached) {
|
|
89
|
+
attached.removeEventListener('scroll', schedule)
|
|
90
|
+
resizeObserver?.disconnect()
|
|
91
|
+
resizeObserver = null
|
|
92
|
+
}
|
|
93
|
+
attached = el
|
|
94
|
+
el.addEventListener('scroll', schedule, { passive: true })
|
|
95
|
+
if (watchResize && typeof ResizeObserver !== 'undefined') {
|
|
96
|
+
resizeObserver = new ResizeObserver(() => schedule())
|
|
97
|
+
resizeObserver.observe(el)
|
|
98
|
+
}
|
|
99
|
+
schedule()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const detach = (): void => {
|
|
103
|
+
if (attached) {
|
|
104
|
+
attached.removeEventListener('scroll', schedule)
|
|
105
|
+
attached.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
|
|
106
|
+
attached = null
|
|
107
|
+
}
|
|
108
|
+
resizeObserver?.disconnect()
|
|
109
|
+
resizeObserver = null
|
|
110
|
+
if (rafId !== null) {
|
|
111
|
+
cancelAnimationFrame(rafId)
|
|
112
|
+
rafId = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 初始挂载(target 可能是异步插入的 DOM,使用 microtask 等一帧)
|
|
117
|
+
queueMicrotask(attach)
|
|
118
|
+
|
|
119
|
+
onScopeDispose(detach)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
sync: () => schedule()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from '../utils/urlParser'
|
|
12
12
|
import type { ITabData, IframeRefreshMode } from '../model/TabModel'
|
|
13
13
|
import { runNavigationTransaction } from './tabPanel/navigationTransaction'
|
|
14
|
-
import { resolveStackTabsRuntimeContext } from './stackTabsContext'
|
|
14
|
+
import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* useTabActions - 标签操作对外 Hook
|
|
@@ -26,9 +26,9 @@ import { resolveStackTabsRuntimeContext } from './stackTabsContext'
|
|
|
26
26
|
* - activeTab(id, isRoute?) 激活标签
|
|
27
27
|
* - reset() 重置(关闭所有)
|
|
28
28
|
*/
|
|
29
|
-
export default function useTabActions() {
|
|
29
|
+
export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeContext) {
|
|
30
30
|
const router = useRouter()
|
|
31
|
-
const runtimeContext = resolveStackTabsRuntimeContext()
|
|
31
|
+
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
32
32
|
const {
|
|
33
33
|
active,
|
|
34
34
|
hasTab,
|
|
@@ -43,7 +43,7 @@ export default function useTabActions() {
|
|
|
43
43
|
removeAllTabs,
|
|
44
44
|
refreshTab,
|
|
45
45
|
refreshAllTabs
|
|
46
|
-
} = useTabPanel()
|
|
46
|
+
} = useTabPanel(runtimeContext)
|
|
47
47
|
|
|
48
48
|
/** 合并默认值,生成完整 tabInfo */
|
|
49
49
|
const prepareTabInfo = (tab: ITabData): ITabData => {
|
|
@@ -80,7 +80,7 @@ export default function useTabActions() {
|
|
|
80
80
|
let refreshKeySnapshot: number | undefined
|
|
81
81
|
|
|
82
82
|
if (tabInfo.id && renew && isExistingTab) {
|
|
83
|
-
const currentTab = getTab(
|
|
83
|
+
const currentTab = getTab(tabInfo.id!)
|
|
84
84
|
rollbackRenew = renewTab(tab)
|
|
85
85
|
if (currentTab?.active) {
|
|
86
86
|
refreshKeySnapshot = refreshKey.value
|
|
@@ -143,9 +143,19 @@ export default function useTabActions() {
|
|
|
143
143
|
runtimeContext.iframePath.value = path
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
/**
|
|
147
|
+
* 获取缓存页面容器。
|
|
148
|
+
* 不传参数时保留旧行为并返回第一个容器;多标签场景应传 tabId 或 pageId,
|
|
149
|
+
* 避免因 DOM 排序而取得错误页面。
|
|
150
|
+
*/
|
|
151
|
+
const getWrapper = (
|
|
152
|
+
target: { tabId?: string; pageId?: string } = {}
|
|
153
|
+
): HTMLElement | undefined => {
|
|
154
|
+
if (typeof document === 'undefined') return undefined
|
|
155
|
+
const pageId =
|
|
156
|
+
target.pageId ?? (target.tabId ? getTab(target.tabId)?.pages.peek()?.id : undefined)
|
|
157
|
+
if (pageId) return document.getElementById(`W-${pageId}`) ?? undefined
|
|
158
|
+
return document.querySelector<HTMLElement>('.cache-page-wrapper') ?? undefined
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
/** iframe 标签在新窗口打开(无法嵌入时降级) */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { InjectionKey } from 'vue'
|
|
8
8
|
import { hasInjectionContext, inject } from 'vue'
|
|
9
9
|
import type { Emitter, EventType } from 'mitt'
|
|
10
|
-
import { resolveStackTabsRuntimeContext } from './stackTabsContext'
|
|
10
|
+
import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
|
|
11
11
|
|
|
12
12
|
declare module '@vue/runtime-core' {
|
|
13
13
|
export interface ComponentCustomProperties {
|
|
@@ -35,7 +35,9 @@ export interface TabEventPayloadMap extends Record<EventType, unknown> {
|
|
|
35
35
|
export const tabEmitterKey: InjectionKey<Emitter<TabEventPayloadMap>> = Symbol('tabEmitter')
|
|
36
36
|
|
|
37
37
|
/** 获取标签事件总线,用于组件间通信 */
|
|
38
|
-
export const useTabEmitter = (
|
|
38
|
+
export const useTabEmitter = (
|
|
39
|
+
runtimeContext?: StackTabsRuntimeContext
|
|
40
|
+
): Emitter<TabEventPayloadMap> => {
|
|
39
41
|
const injected = hasInjectionContext() ? inject(tabEmitterKey, null) : null
|
|
40
|
-
return injected ?? resolveStackTabsRuntimeContext().eventBus
|
|
42
|
+
return injected ?? runtimeContext?.eventBus ?? resolveStackTabsRuntimeContext().eventBus
|
|
41
43
|
}
|