vue-stack-tabs 1.3.1 → 1.4.2
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 +23 -0
- package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
- package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
- package/dist/hooks/tabPanel/tabStore.d.ts +69 -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 +16 -4
- 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 +136 -0
- package/src/lib/hooks/tabPanel/refresh.ts +20 -13
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/tabPanel/session.ts +29 -3
- package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
- 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 +183 -302
- package/src/lib/hooks/useTabRouter.ts +42 -38
- 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/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
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tabPanel/tabStore - 标签/页面栈的唯一状态入口
|
|
3
|
+
*
|
|
4
|
+
* 内部使用规范化的三张“表”保存状态:
|
|
5
|
+
* - tabOrder + tabsById:标签顺序与标签记录
|
|
6
|
+
* - pageIdsByTabId:标签到页面 id 的一对多关系
|
|
7
|
+
* - pagesById:页面记录
|
|
8
|
+
*
|
|
9
|
+
* 对外仍通过 tabs Ref 暴露兼容的 ITabItem[];每个 tab.pages 是 PageStackView,
|
|
10
|
+
* 读写会转发到 store,因此渲染层与公开类型不需要感知内部结构变化。
|
|
11
|
+
*
|
|
12
|
+
* 注意:本模块不依赖 vue-router,也不触发业务事件;路由事务与事件由 useTabPanel /
|
|
13
|
+
* useTabRouter 等上层编排。
|
|
14
|
+
*/
|
|
15
|
+
import { reactive, ref } from 'vue'
|
|
16
|
+
import type { DefineComponent } from 'vue'
|
|
17
|
+
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
18
|
+
import { Stack } from '../../model/TabModel'
|
|
19
|
+
import { clonePage } from '../../utils/urlParser'
|
|
20
|
+
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
21
|
+
import { createTabPanelEviction, type TabPanelEvictionApi } from './evict'
|
|
22
|
+
|
|
23
|
+
export interface RemoveTabResult {
|
|
24
|
+
status: 'not-found' | 'not-closable' | 'removed'
|
|
25
|
+
removed: ITabItem | null
|
|
26
|
+
/** 关闭后应激活的相邻标签;没有可激活标签时为空字符串 */
|
|
27
|
+
nextActiveId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** updatePage 允许修改的字段;页面身份与刷新版本必须通过 store 写入 */
|
|
31
|
+
export interface PagePatch {
|
|
32
|
+
id?: string
|
|
33
|
+
refreshVersion?: number
|
|
34
|
+
_backParams?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TabStoreApi extends TabPanelEvictionApi {
|
|
38
|
+
// ---- 查询 ----
|
|
39
|
+
/** 返回标签列表的只读视图(运行时为同一个响应式数组) */
|
|
40
|
+
getTabs: () => readonly ITabItem[]
|
|
41
|
+
getTab: (id: string) => ITabItem | null
|
|
42
|
+
getActiveTab: () => ITabItem | null
|
|
43
|
+
/** 查找栈顶页面 path 与给定 path 相同的标签 */
|
|
44
|
+
findTabByTopPagePath: (path: string) => ITabItem | null
|
|
45
|
+
hasTab: (id: string) => boolean
|
|
46
|
+
canAddTab: () => boolean
|
|
47
|
+
getPages: (tabId: string) => readonly ITabPage[]
|
|
48
|
+
getTopPage: (tabId: string) => ITabPage | null
|
|
49
|
+
/** 返回深拷贝的页面栈快照,可直接用于 rollback */
|
|
50
|
+
getPageSnapshot: (tabId: string) => ITabPage[]
|
|
51
|
+
|
|
52
|
+
// ---- 标签写入 ----
|
|
53
|
+
addTab: (tab: ITabItem) => void
|
|
54
|
+
addTabs: (tabs: readonly ITabItem[]) => void
|
|
55
|
+
/** 唯一 active 写入口 */
|
|
56
|
+
activateTab: (id: string) => ITabItem | null
|
|
57
|
+
moveTab: (id: string, targetIndex: number) => boolean
|
|
58
|
+
removeTab: (id: string) => RemoveTabResult
|
|
59
|
+
removeTabs: (shouldRemove: (tab: ITabItem, index: number) => boolean) => ITabItem[]
|
|
60
|
+
|
|
61
|
+
// ---- 页面栈写入 ----
|
|
62
|
+
appendPage: (tabId: string, page: ITabPage) => void
|
|
63
|
+
/** 从栈顶弹出若干页,返回被弹出的页面(由调用方决定驱逐/回滚) */
|
|
64
|
+
popPages: (tabId: string, steps: number) => ITabPage[]
|
|
65
|
+
/** 用给定页面替换整个栈(内部深拷贝页面) */
|
|
66
|
+
replacePages: (tabId: string, pages: readonly ITabPage[]) => void
|
|
67
|
+
clearPages: (tabId: string) => void
|
|
68
|
+
/** 修改页面字段(id / refreshVersion / _backParams),并触发 tabs 更新 */
|
|
69
|
+
updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
|
|
70
|
+
/** 取出并清除栈顶页面的 _backParams;pageId 不是当前栈顶时返回 null */
|
|
71
|
+
consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
|
|
72
|
+
|
|
73
|
+
// ---- 驱逐标记辅助 ----
|
|
74
|
+
isTabMarkedForEviction: (id: string) => boolean
|
|
75
|
+
isCacheMarkedForEviction: (id: string) => boolean
|
|
76
|
+
unmarkCacheForEviction: (id: string) => void
|
|
77
|
+
clearTabEvictionMarks: () => void
|
|
78
|
+
|
|
79
|
+
// ---- 组件注册表 ----
|
|
80
|
+
getComponent: (id: string) => DefineComponent | undefined
|
|
81
|
+
setComponent: (id: string, component: DefineComponent) => void
|
|
82
|
+
deleteComponent: (id: string) => void
|
|
83
|
+
snapshotComponents: () => Map<string, DefineComponent>
|
|
84
|
+
restoreComponents: (snapshot: Map<string, DefineComponent>) => void
|
|
85
|
+
|
|
86
|
+
// ---- 缓存快照 / 响应式 ----
|
|
87
|
+
snapshotCaches: () => string[]
|
|
88
|
+
restoreCaches: (cacheNames: readonly string[]) => void
|
|
89
|
+
/** 通知依赖 tabs 的计算属性重新求值(如 activePageRefreshVersion) */
|
|
90
|
+
touchTabs: () => void
|
|
91
|
+
|
|
92
|
+
// ---- 生命周期 ----
|
|
93
|
+
/** 清空 tabs / caches / components / 驱逐标记,供 destroy 使用 */
|
|
94
|
+
reset: () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 标签表中保存的纯净记录;active 由 activeTabId 派生 */
|
|
98
|
+
interface TabRecord {
|
|
99
|
+
id: string
|
|
100
|
+
title: string
|
|
101
|
+
closable: boolean
|
|
102
|
+
refreshable: boolean
|
|
103
|
+
iframe: boolean
|
|
104
|
+
iframeRefreshMode?: 'postMessage' | 'reload'
|
|
105
|
+
url?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface PageStackAccess {
|
|
109
|
+
getPages: (tabId: string) => readonly ITabPage[]
|
|
110
|
+
getTopPage: (tabId: string) => ITabPage | null
|
|
111
|
+
appendPage: (tabId: string, page: ITabPage) => void
|
|
112
|
+
popPages: (tabId: string, steps: number) => ITabPage[]
|
|
113
|
+
clearPages: (tabId: string) => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 兼容视图:继承 Stack 以保持 ITabItem.pages 的类型不变,
|
|
118
|
+
* 所有读写都转发给 store 的规范化状态。
|
|
119
|
+
*/
|
|
120
|
+
class PageStackView extends Stack<ITabPage> {
|
|
121
|
+
private readonly tabId: string
|
|
122
|
+
private readonly access: PageStackAccess
|
|
123
|
+
|
|
124
|
+
constructor(tabId: string, access: PageStackAccess) {
|
|
125
|
+
super()
|
|
126
|
+
this.tabId = tabId
|
|
127
|
+
this.access = access
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override push(page: ITabPage): void {
|
|
131
|
+
this.access.appendPage(this.tabId, page)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override pop(): ITabPage | undefined {
|
|
135
|
+
return this.access.popPages(this.tabId, 1)[0]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
override peek(): ITabPage | undefined {
|
|
139
|
+
return this.access.getTopPage(this.tabId) ?? undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
override at(index: number): ITabPage | undefined {
|
|
143
|
+
return this.access.getPages(this.tabId)[index]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
override isEmpty(): boolean {
|
|
147
|
+
return this.access.getPages(this.tabId).length === 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override size(): number {
|
|
151
|
+
return this.access.getPages(this.tabId).length
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override clear(): void {
|
|
155
|
+
this.access.clearPages(this.tabId)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
override list(): ITabPage[] {
|
|
159
|
+
return [...this.access.getPages(this.tabId)]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
override readonlyList(): readonly ITabPage[] {
|
|
163
|
+
return this.access.getPages(this.tabId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
override find(predicate: (element: ITabPage, index: number) => boolean): ITabPage | undefined {
|
|
167
|
+
return this.list().find(predicate)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override findIndex(predicate: (element: ITabPage, index: number) => boolean): number {
|
|
171
|
+
return this.list().findIndex(predicate)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override filter(predicate: (element: ITabPage, index: number) => boolean): ITabPage[] {
|
|
175
|
+
return this.list().filter(predicate)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
override some(predicate: (element: ITabPage, index: number) => boolean): boolean {
|
|
179
|
+
return this.list().some(predicate)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
override forEachTopDown(visit: (element: ITabPage, index: number) => void): void {
|
|
183
|
+
const pages = this.list()
|
|
184
|
+
for (let index = pages.length - 1; index >= 0; index--) {
|
|
185
|
+
visit(pages[index] as ITabPage, index)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
override toJSON(): ITabPage[] {
|
|
190
|
+
return this.list()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
override toString(): string {
|
|
194
|
+
return this.list().toString()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
199
|
+
const { tabs, caches, components, cacheIdsToEvict, tabIdsToEvict } = context
|
|
200
|
+
const eviction = createTabPanelEviction(context)
|
|
201
|
+
|
|
202
|
+
// ---- 规范化状态 ----
|
|
203
|
+
const tabOrder = ref<string[]>([])
|
|
204
|
+
const tabsById = reactive(new Map<string, TabRecord>())
|
|
205
|
+
const pageIdsByTabId = reactive(new Map<string, string[]>())
|
|
206
|
+
const pagesById = reactive(new Map<string, ITabPage>())
|
|
207
|
+
const activeTabId = ref('')
|
|
208
|
+
/** 稳定的兼容视图,保证 TabHeader 等使用方的对象身份不变 */
|
|
209
|
+
const tabViews = new Map<string, ITabItem>()
|
|
210
|
+
|
|
211
|
+
// ---- 查询 ----
|
|
212
|
+
const getTabs = (): readonly ITabItem[] => tabs.value
|
|
213
|
+
|
|
214
|
+
const getTab = (id: string): ITabItem | null => tabs.value.find((tab) => tab.id === id) ?? null
|
|
215
|
+
|
|
216
|
+
const getActiveTab = (): ITabItem | null => tabs.value.find((tab) => tab.active) ?? null
|
|
217
|
+
|
|
218
|
+
const getPageIds = (tabId: string): readonly string[] => pageIdsByTabId.get(tabId) ?? []
|
|
219
|
+
|
|
220
|
+
const getPages = (tabId: string): readonly ITabPage[] => {
|
|
221
|
+
const ids = pageIdsByTabId.get(tabId)
|
|
222
|
+
if (!ids || ids.length === 0) return []
|
|
223
|
+
const pages: ITabPage[] = []
|
|
224
|
+
for (const id of ids) {
|
|
225
|
+
const page = pagesById.get(id)
|
|
226
|
+
if (page) pages.push(page)
|
|
227
|
+
}
|
|
228
|
+
return pages
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const getTopPage = (tabId: string): ITabPage | null => {
|
|
232
|
+
const ids = pageIdsByTabId.get(tabId)
|
|
233
|
+
if (!ids || ids.length === 0) return null
|
|
234
|
+
return pagesById.get(ids[ids.length - 1] as string) ?? null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const findTabByTopPagePath = (path: string): ITabItem | null => {
|
|
238
|
+
for (const id of tabOrder.value) {
|
|
239
|
+
if (getTopPage(id)?.path === path) return getTab(id)
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hasTab = (id: string): boolean => tabsById.has(id)
|
|
245
|
+
|
|
246
|
+
const canAddTab = (): boolean =>
|
|
247
|
+
context.maxTabCount.value <= 0 ||
|
|
248
|
+
(context.maxTabCount.value > 0 && context.maxTabCount.value > tabOrder.value.length)
|
|
249
|
+
|
|
250
|
+
const getPageSnapshot = (tabId: string): ITabPage[] =>
|
|
251
|
+
getPages(tabId).map((page) => clonePage(page))
|
|
252
|
+
|
|
253
|
+
// ---- 标签写入 ----
|
|
254
|
+
const toRecord = (tab: ITabItem): TabRecord => ({
|
|
255
|
+
id: tab.id,
|
|
256
|
+
title: tab.title,
|
|
257
|
+
closable: tab.closable,
|
|
258
|
+
refreshable: tab.refreshable,
|
|
259
|
+
iframe: tab.iframe,
|
|
260
|
+
iframeRefreshMode: tab.iframeRefreshMode,
|
|
261
|
+
url: tab.url
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const registerTab = (tab: ITabItem): void => {
|
|
265
|
+
tabsById.set(tab.id, toRecord(tab))
|
|
266
|
+
|
|
267
|
+
const pages = tab.pages.list()
|
|
268
|
+
const pageIds = pages.map((page) => page.id)
|
|
269
|
+
for (const page of pages) pagesById.set(page.id, page)
|
|
270
|
+
pageIdsByTabId.set(tab.id, pageIds)
|
|
271
|
+
|
|
272
|
+
tabViews.delete(tab.id)
|
|
273
|
+
if (!tabOrder.value.includes(tab.id)) {
|
|
274
|
+
tabOrder.value = [...tabOrder.value, tab.id]
|
|
275
|
+
}
|
|
276
|
+
if (tab.active) activeTabId.value = tab.id
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const addTab = (tab: ITabItem): void => {
|
|
280
|
+
registerTab(tab)
|
|
281
|
+
syncTabsView()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const addTabs = (newTabs: readonly ITabItem[]): void => {
|
|
285
|
+
if (newTabs.length === 0) return
|
|
286
|
+
for (const tab of newTabs) registerTab(tab)
|
|
287
|
+
syncTabsView()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const activateTab = (id: string): ITabItem | null => {
|
|
291
|
+
activeTabId.value = tabsById.has(id) ? id : ''
|
|
292
|
+
syncTabsView()
|
|
293
|
+
return getTab(id)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const moveTab = (id: string, targetIndex: number): boolean => {
|
|
297
|
+
const order = tabOrder.value
|
|
298
|
+
const sourceIndex = order.indexOf(id)
|
|
299
|
+
if (
|
|
300
|
+
sourceIndex < 0 ||
|
|
301
|
+
targetIndex < 0 ||
|
|
302
|
+
targetIndex >= order.length ||
|
|
303
|
+
sourceIndex === targetIndex
|
|
304
|
+
) {
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const reordered = [...order]
|
|
309
|
+
const [tabId] = reordered.splice(sourceIndex, 1)
|
|
310
|
+
if (!tabId) return false
|
|
311
|
+
reordered.splice(targetIndex, 0, tabId)
|
|
312
|
+
tabOrder.value = reordered
|
|
313
|
+
syncTabsView()
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const removeTab = (id: string): RemoveTabResult => {
|
|
318
|
+
const order = tabOrder.value
|
|
319
|
+
const index = order.indexOf(id)
|
|
320
|
+
if (index < 0) return { status: 'not-found', removed: null, nextActiveId: '' }
|
|
321
|
+
|
|
322
|
+
const record = tabsById.get(id)
|
|
323
|
+
if (!record || !record.closable) {
|
|
324
|
+
return { status: 'not-closable', removed: null, nextActiveId: id }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const removed = materializeTab(id)
|
|
328
|
+
const wasActive = activeTabId.value === id
|
|
329
|
+
if (removed) eviction.markTabPagesForEviction(removed)
|
|
330
|
+
|
|
331
|
+
for (const pageId of getPageIds(id)) pagesById.delete(pageId)
|
|
332
|
+
pageIdsByTabId.delete(id)
|
|
333
|
+
tabsById.delete(id)
|
|
334
|
+
tabViews.delete(id)
|
|
335
|
+
|
|
336
|
+
let nextActiveId = ''
|
|
337
|
+
if (order.length > 1 && wasActive) {
|
|
338
|
+
nextActiveId = (index === 0 ? order[1] : order[index - 1]) ?? ''
|
|
339
|
+
}
|
|
340
|
+
if (wasActive) activeTabId.value = ''
|
|
341
|
+
|
|
342
|
+
tabOrder.value = order.filter((tabId) => tabId !== id)
|
|
343
|
+
syncTabsView()
|
|
344
|
+
|
|
345
|
+
// 非激活标签关闭后没有 onActivated 兜底,立即执行驱逐。
|
|
346
|
+
if (!wasActive) {
|
|
347
|
+
eviction.evictMarkedCaches()
|
|
348
|
+
tabIdsToEvict.clear()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { status: 'removed', removed, nextActiveId }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean): ITabItem[] => {
|
|
355
|
+
const removed: ITabItem[] = []
|
|
356
|
+
const remaining: string[] = []
|
|
357
|
+
|
|
358
|
+
tabOrder.value.forEach((id, index) => {
|
|
359
|
+
const record = tabsById.get(id)
|
|
360
|
+
const tab = materializeTab(id)
|
|
361
|
+
if (!record || !tab) return
|
|
362
|
+
if (!record.closable || !shouldRemove(tab, index)) {
|
|
363
|
+
remaining.push(id)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
eviction.markTabPagesForEviction(tab)
|
|
368
|
+
for (const pageId of getPageIds(id)) pagesById.delete(pageId)
|
|
369
|
+
pageIdsByTabId.delete(id)
|
|
370
|
+
tabsById.delete(id)
|
|
371
|
+
tabViews.delete(id)
|
|
372
|
+
if (activeTabId.value === id) activeTabId.value = ''
|
|
373
|
+
removed.push(tab)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
tabOrder.value = remaining
|
|
377
|
+
syncTabsView()
|
|
378
|
+
return removed
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- 页面栈写入 ----
|
|
382
|
+
const appendPage = (tabId: string, page: ITabPage): void => {
|
|
383
|
+
if (!tabsById.has(tabId)) return
|
|
384
|
+
const ids = pageIdsByTabId.get(tabId) ?? []
|
|
385
|
+
pageIdsByTabId.set(tabId, [...ids, page.id])
|
|
386
|
+
pagesById.set(page.id, page)
|
|
387
|
+
syncTabsView()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const popPages = (tabId: string, steps: number): ITabPage[] => {
|
|
391
|
+
if (!tabsById.has(tabId) || steps <= 0) return []
|
|
392
|
+
|
|
393
|
+
const ids = [...(pageIdsByTabId.get(tabId) ?? [])]
|
|
394
|
+
const popped: ITabPage[] = []
|
|
395
|
+
for (let index = 0; index < steps; index++) {
|
|
396
|
+
const pageId = ids.pop()
|
|
397
|
+
if (!pageId) break
|
|
398
|
+
const page = pagesById.get(pageId)
|
|
399
|
+
if (page) popped.push(page)
|
|
400
|
+
pagesById.delete(pageId)
|
|
401
|
+
}
|
|
402
|
+
pageIdsByTabId.set(tabId, ids)
|
|
403
|
+
syncTabsView()
|
|
404
|
+
return popped
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const replacePages = (tabId: string, pages: readonly ITabPage[]): void => {
|
|
408
|
+
if (!tabsById.has(tabId)) return
|
|
409
|
+
|
|
410
|
+
for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
|
|
411
|
+
const nextPageIds: string[] = []
|
|
412
|
+
for (const page of pages) {
|
|
413
|
+
const cloned = clonePage(page)
|
|
414
|
+
pagesById.set(cloned.id, cloned)
|
|
415
|
+
nextPageIds.push(cloned.id)
|
|
416
|
+
}
|
|
417
|
+
pageIdsByTabId.set(tabId, nextPageIds)
|
|
418
|
+
syncTabsView()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const clearPages = (tabId: string): void => {
|
|
422
|
+
if (!tabsById.has(tabId)) return
|
|
423
|
+
for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
|
|
424
|
+
pageIdsByTabId.set(tabId, [])
|
|
425
|
+
syncTabsView()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
|
|
429
|
+
const ids = pageIdsByTabId.get(tabId)
|
|
430
|
+
if (!ids || !ids.includes(pageId)) return false
|
|
431
|
+
|
|
432
|
+
const page = pagesById.get(pageId)
|
|
433
|
+
if (!page) return false
|
|
434
|
+
|
|
435
|
+
Object.assign(page, patch)
|
|
436
|
+
if (patch.id && patch.id !== pageId) {
|
|
437
|
+
pagesById.delete(pageId)
|
|
438
|
+
pagesById.set(patch.id, page)
|
|
439
|
+
pageIdsByTabId.set(
|
|
440
|
+
tabId,
|
|
441
|
+
ids.map((id) => (id === pageId ? (patch.id as string) : id))
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
syncTabsView()
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const consumeBackParams = (tabId: string, pageId: string): Record<string, unknown> | null => {
|
|
449
|
+
const top = getTopPage(tabId)
|
|
450
|
+
if (!top || top.id !== pageId || !top._backParams) return null
|
|
451
|
+
const backParams = { ...top._backParams }
|
|
452
|
+
delete top._backParams
|
|
453
|
+
syncTabsView()
|
|
454
|
+
return backParams
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- 缓存与组件注册表 ----
|
|
458
|
+
const snapshotCaches = (): string[] => [...caches.value]
|
|
459
|
+
|
|
460
|
+
const restoreCaches = (cacheNames: readonly string[]): void => {
|
|
461
|
+
caches.value = [...cacheNames]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const snapshotComponents = (): Map<string, DefineComponent> => new Map(components)
|
|
465
|
+
|
|
466
|
+
const restoreComponents = (snapshot: Map<string, DefineComponent>): void => {
|
|
467
|
+
components.clear()
|
|
468
|
+
for (const [id, component] of snapshot) components.set(id, component)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const reset = (): void => {
|
|
472
|
+
tabOrder.value = []
|
|
473
|
+
tabsById.clear()
|
|
474
|
+
pageIdsByTabId.clear()
|
|
475
|
+
pagesById.clear()
|
|
476
|
+
tabViews.clear()
|
|
477
|
+
activeTabId.value = ''
|
|
478
|
+
tabs.value = []
|
|
479
|
+
caches.value = []
|
|
480
|
+
components.clear()
|
|
481
|
+
cacheIdsToEvict.clear()
|
|
482
|
+
tabIdsToEvict.clear()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---- 兼容视图 ----
|
|
486
|
+
const pageAccess: PageStackAccess = {
|
|
487
|
+
getPages,
|
|
488
|
+
getTopPage,
|
|
489
|
+
appendPage,
|
|
490
|
+
popPages,
|
|
491
|
+
clearPages
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function materializeTab(id: string): ITabItem | null {
|
|
495
|
+
const record = tabsById.get(id)
|
|
496
|
+
if (!record) return null
|
|
497
|
+
|
|
498
|
+
let view = tabViews.get(id)
|
|
499
|
+
if (!view) {
|
|
500
|
+
view = {
|
|
501
|
+
id: record.id,
|
|
502
|
+
title: record.title,
|
|
503
|
+
closable: record.closable,
|
|
504
|
+
refreshable: record.refreshable,
|
|
505
|
+
iframe: record.iframe,
|
|
506
|
+
iframeRefreshMode: record.iframeRefreshMode,
|
|
507
|
+
url: record.url,
|
|
508
|
+
active: activeTabId.value === id,
|
|
509
|
+
pages: new PageStackView(id, pageAccess)
|
|
510
|
+
}
|
|
511
|
+
tabViews.set(id, view)
|
|
512
|
+
} else {
|
|
513
|
+
view.title = record.title
|
|
514
|
+
view.closable = record.closable
|
|
515
|
+
view.refreshable = record.refreshable
|
|
516
|
+
view.iframe = record.iframe
|
|
517
|
+
view.active = activeTabId.value === id
|
|
518
|
+
if (record.iframeRefreshMode === undefined) {
|
|
519
|
+
delete view.iframeRefreshMode
|
|
520
|
+
} else {
|
|
521
|
+
view.iframeRefreshMode = record.iframeRefreshMode
|
|
522
|
+
}
|
|
523
|
+
if (record.url === undefined) {
|
|
524
|
+
delete view.url
|
|
525
|
+
} else {
|
|
526
|
+
view.url = record.url
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return view
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function syncTabsView(): void {
|
|
533
|
+
const next: ITabItem[] = []
|
|
534
|
+
for (const id of tabOrder.value) {
|
|
535
|
+
const view = materializeTab(id)
|
|
536
|
+
if (view) next.push(view)
|
|
537
|
+
}
|
|
538
|
+
tabs.value = next
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
...eviction,
|
|
543
|
+
getTabs,
|
|
544
|
+
getTab,
|
|
545
|
+
getActiveTab,
|
|
546
|
+
findTabByTopPagePath,
|
|
547
|
+
hasTab,
|
|
548
|
+
canAddTab,
|
|
549
|
+
getPages,
|
|
550
|
+
getTopPage,
|
|
551
|
+
getPageSnapshot,
|
|
552
|
+
addTab,
|
|
553
|
+
addTabs,
|
|
554
|
+
activateTab,
|
|
555
|
+
moveTab,
|
|
556
|
+
removeTab,
|
|
557
|
+
removeTabs,
|
|
558
|
+
appendPage,
|
|
559
|
+
popPages,
|
|
560
|
+
replacePages,
|
|
561
|
+
clearPages,
|
|
562
|
+
updatePage,
|
|
563
|
+
consumeBackParams,
|
|
564
|
+
isTabMarkedForEviction: (id) => tabIdsToEvict.has(id),
|
|
565
|
+
isCacheMarkedForEviction: (id) => cacheIdsToEvict.has(id),
|
|
566
|
+
unmarkCacheForEviction: (id) => cacheIdsToEvict.delete(id),
|
|
567
|
+
clearTabEvictionMarks: () => tabIdsToEvict.clear(),
|
|
568
|
+
getComponent: (id) => components.get(id),
|
|
569
|
+
setComponent: (id, component) => components.set(id, component),
|
|
570
|
+
deleteComponent: (id) => components.delete(id),
|
|
571
|
+
snapshotComponents,
|
|
572
|
+
restoreComponents,
|
|
573
|
+
snapshotCaches,
|
|
574
|
+
restoreCaches,
|
|
575
|
+
touchTabs: syncTabsView,
|
|
576
|
+
reset
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const storesByContext = new WeakMap<StackTabsRuntimeContext, TabStoreApi>()
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 每个运行时上下文只创建一个 store。
|
|
584
|
+
*
|
|
585
|
+
* useTabPanel 会被 StackKeepAlive、StackCacheRenderer、页面内的 useTabRouter 多次调用;
|
|
586
|
+
* 复用同一实例可避免 eviction 的 Set/数组出现多份互相不同步的状态。
|
|
587
|
+
*/
|
|
588
|
+
export const getTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
589
|
+
const existing = storesByContext.get(context)
|
|
590
|
+
if (existing) return existing
|
|
591
|
+
|
|
592
|
+
const store = createTabStore(context)
|
|
593
|
+
storesByContext.set(context, store)
|
|
594
|
+
return store
|
|
595
|
+
}
|
|
@@ -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 {
|