vue-stack-tabs 1.4.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +5 -2
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +17 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +8 -0
- package/dist/utils/urlParser.d.ts +20 -10
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +40 -3
- package/src/lib/assets/image/close_all.svg +1 -9
- package/src/lib/assets/image/close_lefts.svg +1 -1
- package/src/lib/assets/image/close_other.svg +1 -1
- package/src/lib/assets/image/close_rights.svg +1 -1
- package/src/lib/assets/image/reload.svg +1 -3
- package/src/lib/assets/image/reload_all.svg +1 -6
- package/src/lib/assets/style/common/mixins.scss +58 -19
- package/src/lib/assets/style/common/var.scss +100 -60
- package/src/lib/assets/style/icon.scss +31 -98
- package/src/lib/assets/style/index.scss +2 -2
- package/src/lib/assets/style/loading.scss +45 -0
- package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
- package/src/lib/assets/style/transition.scss +12 -17
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
- package/src/lib/components/ContextMenu/index.vue +22 -19
- package/src/lib/components/PageLoading.vue +12 -11
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
- package/src/lib/components/TabHeader/index.vue +67 -12
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +21 -18
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +247 -58
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +19 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +121 -18
- package/src/lib/utils/urlParser.ts +75 -48
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -23,13 +23,10 @@
|
|
|
23
23
|
<tab-header-item
|
|
24
24
|
v-for="(item, index) in tabs"
|
|
25
25
|
:key="item.id"
|
|
26
|
-
:item="item
|
|
26
|
+
:item="item"
|
|
27
27
|
:drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
|
|
28
|
-
@contextmenu="
|
|
29
|
-
|
|
30
|
-
handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
|
|
31
|
-
"
|
|
32
|
-
@click.middle.prevent="handleCloseTab(item as ITabItem)"
|
|
28
|
+
@contextmenu="(e: MouseEvent) => handleTabContextMenu(e, item, index, tabs.length)"
|
|
29
|
+
@click.middle.prevent="handleCloseTab(item)"
|
|
33
30
|
@close="handleCloseTab"
|
|
34
31
|
@active="handleActivateTab"
|
|
35
32
|
@dragstart="handleTabDragStart"
|
|
@@ -42,7 +39,11 @@
|
|
|
42
39
|
</tab-header-scroll>
|
|
43
40
|
<slot name="rightButton" />
|
|
44
41
|
<tab-header-button
|
|
45
|
-
:icon-class="
|
|
42
|
+
:icon-class="
|
|
43
|
+
maximum
|
|
44
|
+
? 'stack-tab__icon stack-tab__icon--restore'
|
|
45
|
+
: 'stack-tab__icon stack-tab__icon--fullscreen'
|
|
46
|
+
"
|
|
46
47
|
:title="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
47
48
|
:aria-label="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
48
49
|
@click="maximum = !maximum"
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
依赖:useTabActions(tabs/closeTab/activeTab)、useContextMenu
|
|
72
73
|
-->
|
|
73
74
|
<script lang="ts" setup>
|
|
74
|
-
import { inject, computed, ref, nextTick } from 'vue'
|
|
75
|
+
import { inject, computed, ref, nextTick, watch, onUnmounted } from 'vue'
|
|
75
76
|
import type { TransitionProps } from 'vue'
|
|
76
77
|
import { TabScrollMode } from '../../model/TabModel'
|
|
77
78
|
import type { IContextMenu, ITabItem } from '../../model/TabModel'
|
|
@@ -83,6 +84,7 @@ import TabHeaderScroll from './TabHeaderScroll.vue'
|
|
|
83
84
|
import TabHeaderButton from './TabHeaderButton.vue'
|
|
84
85
|
import { useI18n } from 'vue-i18n-lite'
|
|
85
86
|
import { maximumKey } from '../../hooks/stackTabsContext'
|
|
87
|
+
import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
|
|
86
88
|
const { t } = useI18n()
|
|
87
89
|
/** 是否最大化,由上层 provide */
|
|
88
90
|
const maximum = inject(maximumKey, ref(false))
|
|
@@ -117,6 +119,47 @@ const props = withDefaults(
|
|
|
117
119
|
}
|
|
118
120
|
)
|
|
119
121
|
const { closeTab, activeTab, moveTab, tabs } = useTabActions()
|
|
122
|
+
|
|
123
|
+
/** 当前激活标签 id */
|
|
124
|
+
const activeTabId = computed<string>(() => tabs.value.find((tab) => tab.active)?.id ?? '')
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 将指定标签滚动到可视区域;未指定 id 时使用当前激活标签。
|
|
128
|
+
* 仅在标签确实不可见时才滚动,避免干扰用户的手动滚动。
|
|
129
|
+
*/
|
|
130
|
+
const ensureTabInView = (tabId?: string) => {
|
|
131
|
+
const scrollContainer = scrollContainerRef.value
|
|
132
|
+
if (!scrollContainer) return
|
|
133
|
+
|
|
134
|
+
const target = headerRef.value?.querySelector<HTMLElement>(
|
|
135
|
+
tabId
|
|
136
|
+
? `.stack-tab__nav [role="tab"][data-tab-id="${CSS.escape(tabId)}"]`
|
|
137
|
+
: '.stack-tab__nav .stack-tab__item--active'
|
|
138
|
+
)
|
|
139
|
+
const tabElement = target?.closest<HTMLElement>('.stack-tab__item')
|
|
140
|
+
if (tabElement && !scrollContainer.isInView(tabElement)) {
|
|
141
|
+
scrollContainer.scrollIntoView(tabElement)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 激活标签变化后确保其位于可视区域:
|
|
147
|
+
* 覆盖新开标签、通过 openTab 唤醒已有标签、关闭标签后顺延激活等场景。
|
|
148
|
+
* 仅响应用户主动滚动时不会触发(滚动不改变 active),因此不会把视图强行拉回。
|
|
149
|
+
*/
|
|
150
|
+
watch(activeTabId, () => {
|
|
151
|
+
nextTick(() => ensureTabInView())
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** 标签已是 active 但被滚开时,显式打开/激活请求仍需把标签栏滚动到它。 */
|
|
155
|
+
const emitter = useTabEmitter()
|
|
156
|
+
const handleTabScrollTo = ({ id }: { id: string }) => {
|
|
157
|
+
nextTick(() => ensureTabInView(id))
|
|
158
|
+
}
|
|
159
|
+
emitter.on(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
|
|
160
|
+
onUnmounted(() => {
|
|
161
|
+
emitter.off(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
|
|
162
|
+
})
|
|
120
163
|
const draggingTabId = ref<string | null>(null)
|
|
121
164
|
type DropPosition = 'before' | 'after'
|
|
122
165
|
const dropIndicator = ref<{ tabId: string; position: DropPosition } | null>(null)
|
|
@@ -149,6 +192,7 @@ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max:
|
|
|
149
192
|
|
|
150
193
|
/** 原生拖放只在释放鼠标时更新顺序,避免拖动经过标签时反复变更响应式列表。 */
|
|
151
194
|
const handleTabDragStart = (item: ITabItem) => {
|
|
195
|
+
if (item.pinned) return
|
|
152
196
|
draggingTabId.value = item.id
|
|
153
197
|
}
|
|
154
198
|
|
|
@@ -161,9 +205,11 @@ const handleTabDragOver = (item: ITabItem, event: DragEvent) => {
|
|
|
161
205
|
if (!tabElement) return
|
|
162
206
|
|
|
163
207
|
const { left, width } = tabElement.getBoundingClientRect()
|
|
208
|
+
const position = event.clientX < left + width / 2 ? 'before' : 'after'
|
|
164
209
|
dropIndicator.value = {
|
|
165
210
|
tabId: item.id,
|
|
166
|
-
|
|
211
|
+
// pinned 标签固定在左侧,拖到它前半区时改为插到它后面。
|
|
212
|
+
position: item.pinned && position === 'before' ? 'after' : position
|
|
167
213
|
}
|
|
168
214
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
169
215
|
}
|
|
@@ -181,6 +227,13 @@ const handleTabDrop = (target: ITabItem) => {
|
|
|
181
227
|
let destinationIndex = targetIndex
|
|
182
228
|
if (indicator.position === 'before' && sourceIndex < targetIndex) destinationIndex--
|
|
183
229
|
if (indicator.position === 'after' && sourceIndex > targetIndex) destinationIndex++
|
|
230
|
+
|
|
231
|
+
// home 等 pinned 标签固定在左侧,任何标签都不能插到它前面(store 层还会再兜底)。
|
|
232
|
+
let firstUnpinnedIndex = 0
|
|
233
|
+
tabs.value.forEach((tab, index) => {
|
|
234
|
+
if (tab.pinned) firstUnpinnedIndex = index + 1
|
|
235
|
+
})
|
|
236
|
+
destinationIndex = Math.max(destinationIndex, firstUnpinnedIndex)
|
|
184
237
|
moveTab(sourceId, destinationIndex)
|
|
185
238
|
}
|
|
186
239
|
|
|
@@ -205,7 +258,7 @@ const handleTabListDragOver = (event: DragEvent) => {
|
|
|
205
258
|
const handleTabListDrop = (event: DragEvent) => {
|
|
206
259
|
if (isTabItemDragEvent(event)) return
|
|
207
260
|
const lastTab = tabs.value[tabs.value.length - 1]
|
|
208
|
-
if (lastTab) handleTabDrop(lastTab
|
|
261
|
+
if (lastTab) handleTabDrop(lastTab)
|
|
209
262
|
}
|
|
210
263
|
|
|
211
264
|
/** 标签列表 transition 的 props,支持 string 或对象 */
|
|
@@ -234,7 +287,7 @@ const focusTabById = (tabId: string) => {
|
|
|
234
287
|
const activateTabByIndex = (index: number) => {
|
|
235
288
|
const item = tabs.value[index]
|
|
236
289
|
if (!item) return
|
|
237
|
-
handleActivateTab(item
|
|
290
|
+
handleActivateTab(item, undefined, true)
|
|
238
291
|
focusTabById(String(item.id))
|
|
239
292
|
}
|
|
240
293
|
|
|
@@ -303,7 +356,9 @@ const handleActivateTab = (
|
|
|
303
356
|
Promise.resolve(activeTab(item.id, isRoute))
|
|
304
357
|
.then(() => emit('active', item.id))
|
|
305
358
|
.catch((error: unknown) => {
|
|
306
|
-
|
|
359
|
+
if (!import.meta.env.PROD) {
|
|
360
|
+
console.warn('[vue-stack-tabs] Failed to activate tab:', error)
|
|
361
|
+
}
|
|
307
362
|
})
|
|
308
363
|
}
|
|
309
364
|
</script>
|
|
@@ -23,7 +23,6 @@ export const STACK_TABS_DUPLICATE_INSTANCE_MESSAGE =
|
|
|
23
23
|
|
|
24
24
|
export interface StackTabsRuntimeContext {
|
|
25
25
|
tabs: Ref<ITabItem[]>
|
|
26
|
-
defaultTabs: Ref<ITabItem[]>
|
|
27
26
|
caches: ShallowRef<string[]>
|
|
28
27
|
components: Map<string, DefineComponent>
|
|
29
28
|
cacheIdsToEvict: Set<string>
|
|
@@ -9,8 +9,11 @@ import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
|
9
9
|
|
|
10
10
|
export interface TabPanelEvictionApi {
|
|
11
11
|
markCacheForEviction: (cacheName: string) => void
|
|
12
|
-
markTabPagesForEviction: (tab: {
|
|
13
|
-
|
|
12
|
+
markTabPagesForEviction: (tab: {
|
|
13
|
+
id: string
|
|
14
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
15
|
+
}) => void
|
|
16
|
+
markTabPagesForEvictionOnly: (tab: { pages: { readonlyList(): readonly ITabPage[] } }) => void
|
|
14
17
|
addCache: (cacheName: string) => void
|
|
15
18
|
removeCache: (cacheName: string) => void
|
|
16
19
|
evictPageCache: (cacheName: string) => void
|
|
@@ -39,13 +42,18 @@ export const createTabPanelEviction = (context: StackTabsRuntimeContext): TabPan
|
|
|
39
42
|
cacheIdsToEvict.add(cacheName)
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
const markTabPagesForEviction = (tab: {
|
|
43
|
-
|
|
45
|
+
const markTabPagesForEviction = (tab: {
|
|
46
|
+
id: string
|
|
47
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
48
|
+
}): void => {
|
|
49
|
+
for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
|
|
44
50
|
tabIdsToEvict.add(tab.id)
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
const markTabPagesForEvictionOnly = (tab: {
|
|
48
|
-
|
|
53
|
+
const markTabPagesForEvictionOnly = (tab: {
|
|
54
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
55
|
+
}): void => {
|
|
56
|
+
for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
const addCache = (cacheName: string): void => {
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
import {
|
|
8
8
|
cloneVNode,
|
|
9
9
|
defineComponent,
|
|
10
|
+
nextTick,
|
|
10
11
|
onActivated,
|
|
12
|
+
onBeforeUnmount,
|
|
11
13
|
onDeactivated,
|
|
12
14
|
onMounted,
|
|
13
15
|
onUnmounted,
|
|
14
16
|
ref,
|
|
15
|
-
shallowRef
|
|
17
|
+
shallowRef,
|
|
18
|
+
watch
|
|
16
19
|
} from 'vue'
|
|
17
20
|
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
18
21
|
import type { ITabBase } from '../../model/TabModel'
|
|
@@ -34,6 +37,36 @@ interface PageComponentFactoryOptions {
|
|
|
34
37
|
removeScroller: (pageId: string) => void
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
const parseTimeList = (value: string): number[] =>
|
|
41
|
+
value.split(',').map((item) => Number.parseFloat(item.trim()) || 0)
|
|
42
|
+
|
|
43
|
+
/** 读取元素当前 transition 的总时长(秒转毫秒),用于等待离场/入场结束。 */
|
|
44
|
+
const getTransitionDurationMs = (element: HTMLElement): number => {
|
|
45
|
+
if (typeof window === 'undefined') return 0
|
|
46
|
+
const style = window.getComputedStyle(element)
|
|
47
|
+
const durations = parseTimeList(style.transitionDuration || '0s')
|
|
48
|
+
const delays = parseTimeList(style.transitionDelay || '0s')
|
|
49
|
+
if (durations.length === 0) return 0
|
|
50
|
+
return (
|
|
51
|
+
Math.max(
|
|
52
|
+
...durations.map((duration, index) => duration + (delays[index % delays.length] ?? 0))
|
|
53
|
+
) * 1000
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 等待下一帧,让刚添加的 transition 起始样式先生效。 */
|
|
58
|
+
const nextFrame = (): Promise<void> =>
|
|
59
|
+
new Promise((resolve) => {
|
|
60
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve())
|
|
61
|
+
else setTimeout(resolve, 16)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** 用户开启「减少动态效果」时直接切换页面,不执行转场。 */
|
|
65
|
+
const prefersReducedMotion = (): boolean =>
|
|
66
|
+
typeof window !== 'undefined' &&
|
|
67
|
+
typeof window.matchMedia === 'function' &&
|
|
68
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
69
|
+
|
|
37
70
|
export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
|
|
38
71
|
const {
|
|
39
72
|
runtimeContext,
|
|
@@ -57,13 +90,51 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
57
90
|
return existing
|
|
58
91
|
}
|
|
59
92
|
|
|
60
|
-
|
|
93
|
+
/**
|
|
94
|
+
* 页面壳组件:承接刷新时的 key 变化,让页面重建发生在缓存 wrapper 内部,
|
|
95
|
+
* 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
|
|
96
|
+
* 不额外渲染元素,页面可以是单根,也可以是多根节点(Fragment)。
|
|
97
|
+
*/
|
|
98
|
+
const PageShell = defineComponent({
|
|
99
|
+
name: `${cacheName}-shell`,
|
|
100
|
+
props: {
|
|
101
|
+
vnode: {
|
|
102
|
+
type: Object as PropType<VNode | null>,
|
|
103
|
+
required: false,
|
|
104
|
+
default: null
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
setup(props) {
|
|
108
|
+
return () => props.vnode
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const cacheComponent = defineComponent<{
|
|
113
|
+
vnode?: VNode
|
|
114
|
+
refreshVersion?: number
|
|
115
|
+
transitionName?: string
|
|
116
|
+
}>({
|
|
61
117
|
name: cacheName,
|
|
62
118
|
props: {
|
|
63
119
|
vnode: {
|
|
64
120
|
type: Object as PropType<VNode>,
|
|
65
121
|
required: false,
|
|
66
122
|
default: undefined
|
|
123
|
+
},
|
|
124
|
+
/**
|
|
125
|
+
* 页面级刷新版本。外层 keep-alive 的 key 保持稳定(避免旧实例滞留缓存),
|
|
126
|
+
* 刷新时只让内部页面 vnode 的 key 变化,从而重建页面组件而保留包装层与滚动位置。
|
|
127
|
+
*/
|
|
128
|
+
refreshVersion: {
|
|
129
|
+
type: Number,
|
|
130
|
+
required: false,
|
|
131
|
+
default: 0
|
|
132
|
+
},
|
|
133
|
+
/** 页面转场名,与 StackKeepAlive 外层 Transition 保持一致 */
|
|
134
|
+
transitionName: {
|
|
135
|
+
type: String,
|
|
136
|
+
required: false,
|
|
137
|
+
default: ''
|
|
67
138
|
}
|
|
68
139
|
},
|
|
69
140
|
emits: ['onLoaded'],
|
|
@@ -73,6 +144,154 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
73
144
|
const lastCloned = shallowRef<VNode | null>(null)
|
|
74
145
|
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
75
146
|
const pageRoot = ref<HTMLElement | null>(null)
|
|
147
|
+
/**
|
|
148
|
+
* 实际渲染的刷新版本。
|
|
149
|
+
*
|
|
150
|
+
* 刷新时先把 wrapper(真实元素,兼容多根节点页面)作为动画主体执行离场,
|
|
151
|
+
* 离场结束再提升该值重建页面,最后执行入场;因此它会短暂落后于
|
|
152
|
+
* props.refreshVersion。外层 keep-alive key 保持不变,避免实例累积。
|
|
153
|
+
*/
|
|
154
|
+
const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
|
|
155
|
+
|
|
156
|
+
let refreshDisposed = false
|
|
157
|
+
let isFlushingRefresh = false
|
|
158
|
+
let queuedRefreshVersion: number | null = null
|
|
159
|
+
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
160
|
+
let cancelRefreshWait: (() => void) | null = null
|
|
161
|
+
let appliedTransitionName = ''
|
|
162
|
+
|
|
163
|
+
const clearRefreshTimer = () => {
|
|
164
|
+
if (refreshTimer === null) return
|
|
165
|
+
clearTimeout(refreshTimer)
|
|
166
|
+
refreshTimer = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const removeRefreshClasses = () => {
|
|
170
|
+
const element = pageRoot.value
|
|
171
|
+
const name = appliedTransitionName
|
|
172
|
+
if (!element || !name) return
|
|
173
|
+
element.classList.remove(
|
|
174
|
+
`${name}-leave-active`,
|
|
175
|
+
`${name}-leave-to`,
|
|
176
|
+
`${name}-enter-active`,
|
|
177
|
+
`${name}-enter-from`
|
|
178
|
+
)
|
|
179
|
+
appliedTransitionName = ''
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 等待一段 transition 结束;transitionend 不可用时用时长兜底。
|
|
184
|
+
* 元素可能同时有多个过渡属性(opacity / transform),任意一个结束即可。
|
|
185
|
+
*/
|
|
186
|
+
const waitForRefreshEnd = (duration: number): Promise<void> =>
|
|
187
|
+
new Promise((resolve) => {
|
|
188
|
+
const element = pageRoot.value
|
|
189
|
+
if (!element || duration <= 0) {
|
|
190
|
+
resolve()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
let settled = false
|
|
194
|
+
const finish = () => {
|
|
195
|
+
if (settled) return
|
|
196
|
+
settled = true
|
|
197
|
+
cancelRefreshWait = null
|
|
198
|
+
clearRefreshTimer()
|
|
199
|
+
element.removeEventListener('transitionend', handleEnd)
|
|
200
|
+
resolve()
|
|
201
|
+
}
|
|
202
|
+
const handleEnd = (event: TransitionEvent) => {
|
|
203
|
+
if (event.target === element) finish()
|
|
204
|
+
}
|
|
205
|
+
cancelRefreshWait = finish
|
|
206
|
+
element.addEventListener('transitionend', handleEnd)
|
|
207
|
+
refreshTimer = setTimeout(finish, duration + 80)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 执行一次完整的刷新转场:旧内容离场 → 提升版本重建页面 → 新内容入场。
|
|
212
|
+
*
|
|
213
|
+
* 动画类名沿用 Vue Transition 的约定(`${name}-leave-active` 等),
|
|
214
|
+
* 因此自定义页面过渡 CSS 仍然有效;动画主体是缓存 wrapper 本身,
|
|
215
|
+
* 页面是单根还是多根(Fragment)都不影响。
|
|
216
|
+
*/
|
|
217
|
+
const runRefreshAnimation = async (targetVersion: number): Promise<void> => {
|
|
218
|
+
const element = pageRoot.value
|
|
219
|
+
const name = props.transitionName
|
|
220
|
+
if (!element || !name || prefersReducedMotion()) {
|
|
221
|
+
renderedRefreshVersion.value = targetVersion
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
appliedTransitionName = name
|
|
226
|
+
element.classList.add(`${name}-leave-active`)
|
|
227
|
+
const leaveDuration = getTransitionDurationMs(element)
|
|
228
|
+
if (leaveDuration <= 0) {
|
|
229
|
+
removeRefreshClasses()
|
|
230
|
+
renderedRefreshVersion.value = targetVersion
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 先让 leave-active 生效,再添加 leave-to,触发离场过渡。
|
|
235
|
+
await nextFrame()
|
|
236
|
+
if (refreshDisposed) {
|
|
237
|
+
removeRefreshClasses()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
element.classList.add(`${name}-leave-to`)
|
|
241
|
+
await waitForRefreshEnd(leaveDuration)
|
|
242
|
+
removeRefreshClasses()
|
|
243
|
+
if (refreshDisposed) return
|
|
244
|
+
|
|
245
|
+
renderedRefreshVersion.value = targetVersion
|
|
246
|
+
await nextTick()
|
|
247
|
+
if (refreshDisposed) return
|
|
248
|
+
|
|
249
|
+
appliedTransitionName = name
|
|
250
|
+
// 先把元素落到入场起点(不加 transition,避免从离场终点反向缓动),
|
|
251
|
+
// 再启用 enter-active,下一帧移除 enter-from,形成标准的入场过渡。
|
|
252
|
+
element.classList.add(`${name}-enter-from`)
|
|
253
|
+
void element.offsetWidth
|
|
254
|
+
element.classList.add(`${name}-enter-active`)
|
|
255
|
+
const enterDuration = getTransitionDurationMs(element)
|
|
256
|
+
if (enterDuration <= 0) {
|
|
257
|
+
removeRefreshClasses()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await nextFrame()
|
|
262
|
+
if (refreshDisposed) {
|
|
263
|
+
removeRefreshClasses()
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
element.classList.remove(`${name}-enter-from`)
|
|
267
|
+
await waitForRefreshEnd(enterDuration)
|
|
268
|
+
removeRefreshClasses()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const flushRefreshQueue = async (): Promise<void> => {
|
|
272
|
+
if (isFlushingRefresh) return
|
|
273
|
+
isFlushingRefresh = true
|
|
274
|
+
try {
|
|
275
|
+
while (!refreshDisposed && queuedRefreshVersion !== null) {
|
|
276
|
+
const targetVersion = queuedRefreshVersion
|
|
277
|
+
queuedRefreshVersion = null
|
|
278
|
+
if (targetVersion === renderedRefreshVersion.value) continue
|
|
279
|
+
await runRefreshAnimation(targetVersion)
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
isFlushingRefresh = false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
watch(
|
|
287
|
+
() => props.refreshVersion ?? 0,
|
|
288
|
+
(nextVersion) => {
|
|
289
|
+
if (nextVersion === renderedRefreshVersion.value && queuedRefreshVersion === null)
|
|
290
|
+
return
|
|
291
|
+
queuedRefreshVersion = nextVersion
|
|
292
|
+
void flushRefreshQueue()
|
|
293
|
+
}
|
|
294
|
+
)
|
|
76
295
|
|
|
77
296
|
const checkAndConsumeBackParams = () => {
|
|
78
297
|
localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
|
|
@@ -81,20 +300,38 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
81
300
|
onMounted(() => {
|
|
82
301
|
context.emit('onLoaded')
|
|
83
302
|
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
303
|
+
// 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
|
|
304
|
+
// 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
|
|
305
|
+
scrollShadow.sync()
|
|
84
306
|
checkAndConsumeBackParams()
|
|
85
307
|
})
|
|
86
|
-
onDeactivated(() =>
|
|
308
|
+
onDeactivated(() => {
|
|
309
|
+
scrollShadow.clear()
|
|
310
|
+
saveScroller(cacheName)
|
|
311
|
+
})
|
|
87
312
|
onActivated(() => {
|
|
88
313
|
context.emit('onLoaded')
|
|
89
314
|
restoreScroller(cacheName)
|
|
315
|
+
scrollShadow.sync()
|
|
90
316
|
evictMarkedCaches()
|
|
91
317
|
clearTabEvictionMarks()
|
|
92
318
|
checkAndConsumeBackParams()
|
|
93
319
|
})
|
|
320
|
+
onBeforeUnmount(() => {
|
|
321
|
+
refreshDisposed = true
|
|
322
|
+
queuedRefreshVersion = null
|
|
323
|
+
cancelRefreshWait?.()
|
|
324
|
+
clearRefreshTimer()
|
|
325
|
+
removeRefreshClasses()
|
|
326
|
+
})
|
|
94
327
|
onUnmounted(() => removeScroller(cacheName))
|
|
95
328
|
|
|
96
|
-
//
|
|
97
|
-
|
|
329
|
+
// 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听仍绑定 wrapper。
|
|
330
|
+
// 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
|
|
331
|
+
const scrollShadow = useScrollShadow({
|
|
332
|
+
target: pageRoot,
|
|
333
|
+
classTarget: () => pageRoot.value?.parentElement ?? null
|
|
334
|
+
})
|
|
98
335
|
|
|
99
336
|
return () => {
|
|
100
337
|
const vnode = props.vnode
|
|
@@ -110,17 +347,25 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
110
347
|
lastVnode.value = vnode
|
|
111
348
|
lastBackParams.value = backParams
|
|
112
349
|
}
|
|
350
|
+
|
|
113
351
|
return (
|
|
114
352
|
<div
|
|
115
353
|
ref={pageRoot}
|
|
116
|
-
class="
|
|
354
|
+
class="stack-tab__cache-page-wrapper"
|
|
117
355
|
id={`W-${cacheName}`}
|
|
118
356
|
style={[
|
|
357
|
+
// 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
|
|
358
|
+
// 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
|
|
119
359
|
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
120
360
|
'height: 100%'
|
|
121
361
|
]}
|
|
122
362
|
>
|
|
123
|
-
{lastCloned.value
|
|
363
|
+
{lastCloned.value ? (
|
|
364
|
+
<PageShell
|
|
365
|
+
key={`${cacheName}-${renderedRefreshVersion.value}`}
|
|
366
|
+
vnode={lastCloned.value}
|
|
367
|
+
/>
|
|
368
|
+
) : null}
|
|
124
369
|
<PageLoading tabId={tabInfo.id!} />
|
|
125
370
|
</div>
|
|
126
371
|
)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Ref } from 'vue'
|
|
7
7
|
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
8
|
-
import type { PagePatch } from './tabStore'
|
|
8
|
+
import type { PagePatch, PageUpdate } from './tabStore'
|
|
9
9
|
|
|
10
10
|
export interface TabPanelRefreshApi {
|
|
11
11
|
refreshTab: (id: string) => void
|
|
@@ -15,8 +15,10 @@ export interface TabPanelRefreshApi {
|
|
|
15
15
|
export interface CreateTabPanelRefreshOptions {
|
|
16
16
|
getTabs: () => readonly ITabItem[]
|
|
17
17
|
getTopPage: (tabId: string) => ITabPage | null
|
|
18
|
-
/** 修改页面字段(刷新版本 / 页面 id
|
|
18
|
+
/** 修改页面字段(刷新版本 / 页面 id) */
|
|
19
19
|
updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
|
|
20
|
+
/** 批量修改页面字段,refreshAllTabs 最后统一触发一次 tabs 更新 */
|
|
21
|
+
updatePages: (updates: readonly PageUpdate[]) => void
|
|
20
22
|
iframeRefreshKeys: Ref<Record<string, number>>
|
|
21
23
|
createPageId: () => string
|
|
22
24
|
addCache: (cacheName: string) => void
|
|
@@ -35,6 +37,7 @@ export const createTabPanelRefresh = (
|
|
|
35
37
|
getTabs,
|
|
36
38
|
getTopPage,
|
|
37
39
|
updatePage,
|
|
40
|
+
updatePages,
|
|
38
41
|
iframeRefreshKeys,
|
|
39
42
|
createPageId,
|
|
40
43
|
addCache,
|
|
@@ -54,21 +57,34 @@ export const createTabPanelRefresh = (
|
|
|
54
57
|
refreshIframeByPostMessage(tab.id)
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
const refreshPage = (
|
|
60
|
+
const refreshPage = (
|
|
61
|
+
tab: ITabItem,
|
|
62
|
+
cacheNamesToEvict?: string[],
|
|
63
|
+
cacheNamesToAdd?: string[],
|
|
64
|
+
pageUpdates?: PageUpdate[]
|
|
65
|
+
) => {
|
|
58
66
|
const currentPage = getTopPage(tab.id)
|
|
59
67
|
if (!currentPage) return
|
|
60
68
|
|
|
61
69
|
if (tab.active) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
70
|
+
const patch = { refreshVersion: (currentPage.refreshVersion ?? 0) + 1 }
|
|
71
|
+
if (pageUpdates) {
|
|
72
|
+
pageUpdates.push({ tabId: tab.id, pageId: currentPage.id, patch })
|
|
73
|
+
} else {
|
|
74
|
+
// 只有 refreshVersion 变化:页面对象是响应式的,不需要重建 tabs 数组。
|
|
75
|
+
updatePage(tab.id, currentPage.id, patch)
|
|
76
|
+
}
|
|
66
77
|
return
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
const oldCacheName = currentPage.id
|
|
70
81
|
const nextPageId = createPageId()
|
|
71
|
-
|
|
82
|
+
const patch = { id: nextPageId, refreshVersion: 0 }
|
|
83
|
+
if (pageUpdates) {
|
|
84
|
+
pageUpdates.push({ tabId: tab.id, pageId: oldCacheName, patch })
|
|
85
|
+
} else {
|
|
86
|
+
updatePage(tab.id, oldCacheName, patch)
|
|
87
|
+
}
|
|
72
88
|
if (cacheNamesToEvict && cacheNamesToAdd) {
|
|
73
89
|
cacheNamesToEvict.push(oldCacheName)
|
|
74
90
|
cacheNamesToAdd.push(nextPageId)
|
|
@@ -91,6 +107,7 @@ export const createTabPanelRefresh = (
|
|
|
91
107
|
const refreshAllTabs = () => {
|
|
92
108
|
const cacheNamesToEvict: string[] = []
|
|
93
109
|
const cacheNamesToAdd: string[] = []
|
|
110
|
+
const pageUpdates: PageUpdate[] = []
|
|
94
111
|
const iframeKeysUpdate = { ...iframeRefreshKeys.value }
|
|
95
112
|
let iframeKeysChanged = false
|
|
96
113
|
|
|
@@ -105,10 +122,11 @@ export const createTabPanelRefresh = (
|
|
|
105
122
|
}
|
|
106
123
|
continue
|
|
107
124
|
}
|
|
108
|
-
refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd)
|
|
125
|
+
refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd, pageUpdates)
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
if (iframeKeysChanged) iframeRefreshKeys.value = iframeKeysUpdate
|
|
129
|
+
updatePages(pageUpdates)
|
|
112
130
|
replacePageCaches(cacheNamesToEvict, cacheNamesToAdd)
|
|
113
131
|
}
|
|
114
132
|
|
|
@@ -12,8 +12,6 @@ export interface TabPanelScrollApi {
|
|
|
12
12
|
saveScroller: (pageCacheId: string) => void
|
|
13
13
|
removeScroller: (pageCacheId: string) => void
|
|
14
14
|
addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
|
|
15
|
-
/** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
|
|
16
|
-
invalidateAutoScrollCache: (pageCacheId: string) => void
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
@@ -124,9 +122,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
124
122
|
})
|
|
125
123
|
|
|
126
124
|
for (const selector of positions.keys()) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
// auto key 已在上一步按 DOM 顺序写入,无需再次解析。
|
|
126
|
+
if (selector.startsWith(AUTO_SCROLLER_PREFIX)) continue
|
|
127
|
+
const element = resolveScrollElement(selector)
|
|
130
128
|
positions.set(selector, {
|
|
131
129
|
top: element?.scrollTop ?? 0,
|
|
132
130
|
left: element?.scrollLeft ?? 0
|
|
@@ -158,19 +156,10 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
158
156
|
}
|
|
159
157
|
}
|
|
160
158
|
|
|
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
|
-
|
|
169
159
|
return {
|
|
170
160
|
restoreScroller,
|
|
171
161
|
saveScroller,
|
|
172
162
|
removeScroller,
|
|
173
|
-
addPageScroller
|
|
174
|
-
invalidateAutoScrollCache
|
|
163
|
+
addPageScroller
|
|
175
164
|
}
|
|
176
165
|
}
|