vue-stack-tabs 2.5.0 → 2.6.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/dist/StackTabs.vue.d.ts +9 -1
- package/dist/hooks/stackTabsContext.d.ts +8 -1
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +2 -0
- package/dist/hooks/tabPanel/scroll.d.ts +9 -0
- package/dist/hooks/useScrollShadow.d.ts +20 -2
- package/dist/hooks/useTabPanel.d.ts +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/model/ScrollAdapter.d.ts +60 -0
- package/dist/style.css +1 -1
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +17 -1
- package/src/lib/assets/style/common/theme.scss +13 -10
- package/src/lib/hooks/stackTabsContext.ts +11 -2
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +116 -16
- package/src/lib/hooks/tabPanel/scroll.ts +69 -3
- package/src/lib/hooks/useScrollShadow.ts +69 -25
- package/src/lib/hooks/useTabPanel.tsx +6 -2
- package/src/lib/index.ts +6 -0
- package/src/lib/model/ScrollAdapter.ts +187 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {
|
|
8
8
|
cloneVNode,
|
|
9
9
|
defineComponent,
|
|
10
|
+
h,
|
|
10
11
|
nextTick,
|
|
11
12
|
onActivated,
|
|
12
13
|
onBeforeUnmount,
|
|
@@ -20,6 +21,11 @@ import {
|
|
|
20
21
|
} from 'vue'
|
|
21
22
|
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
22
23
|
import type { ITabBase } from '../../model/TabModel'
|
|
24
|
+
import {
|
|
25
|
+
readAdapterScrollMetrics,
|
|
26
|
+
resolveScrollAdapter,
|
|
27
|
+
type StackTabsScrollAdapter
|
|
28
|
+
} from '../../model/ScrollAdapter'
|
|
23
29
|
import PageLoading from '../../components/PageLoading.vue'
|
|
24
30
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
25
31
|
import { tabPageContextKey } from '../tabPageContext'
|
|
@@ -34,6 +40,10 @@ interface PageComponentFactoryOptions {
|
|
|
34
40
|
evictMarkedCaches: () => void
|
|
35
41
|
clearTabEvictionMarks: () => void
|
|
36
42
|
addPageScroller: (pageId: string, ...selectors: string[]) => void
|
|
43
|
+
setPageScrollAdapter: (
|
|
44
|
+
pageId: string,
|
|
45
|
+
getter: (() => StackTabsScrollAdapter | null) | null
|
|
46
|
+
) => void
|
|
37
47
|
saveScroller: (pageId: string) => void
|
|
38
48
|
restoreScroller: (pageId: string) => void
|
|
39
49
|
removeScroller: (pageId: string) => void
|
|
@@ -79,6 +89,7 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
79
89
|
evictMarkedCaches,
|
|
80
90
|
clearTabEvictionMarks,
|
|
81
91
|
addPageScroller,
|
|
92
|
+
setPageScrollAdapter,
|
|
82
93
|
saveScroller,
|
|
83
94
|
restoreScroller,
|
|
84
95
|
removeScroller
|
|
@@ -154,6 +165,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
154
165
|
const lastCloned = shallowRef<VNode | null>(null)
|
|
155
166
|
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
156
167
|
const pageRoot = ref<HTMLElement | null>(null)
|
|
168
|
+
/** 自定义滚动组件的实例(expose proxy),用于解析滚动适配器。 */
|
|
169
|
+
const scrollContainerRef = ref<unknown>(null)
|
|
170
|
+
const customScrollAdapter = shallowRef<StackTabsScrollAdapter | null>(null)
|
|
157
171
|
/**
|
|
158
172
|
* 实际渲染的刷新版本。
|
|
159
173
|
*
|
|
@@ -163,6 +177,62 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
163
177
|
*/
|
|
164
178
|
const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
|
|
165
179
|
|
|
180
|
+
const hasCustomScrollContainer = (): boolean =>
|
|
181
|
+
Boolean(runtimeContext.scrollContainer.value)
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 当前页面的主滚动适配器。
|
|
185
|
+
*
|
|
186
|
+
* 未传 `scrollContainer` 时把 wrapper 本身当作适配器,默认路径行为保持不变;
|
|
187
|
+
* 传入时读取自定义组件 expose 的能力,解析不到则按「无滚动容器」处理。
|
|
188
|
+
*/
|
|
189
|
+
const getScrollAdapter = (): StackTabsScrollAdapter | null => {
|
|
190
|
+
if (!hasCustomScrollContainer()) {
|
|
191
|
+
return pageRoot.value ? { scrollElement: pageRoot.value } : null
|
|
192
|
+
}
|
|
193
|
+
return customScrollAdapter.value
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 重新解析自定义滚动组件的适配器(运行时切换容器后也会调用)。 */
|
|
197
|
+
const syncScrollAdapter = (): void => {
|
|
198
|
+
customScrollAdapter.value = hasCustomScrollContainer()
|
|
199
|
+
? resolveScrollAdapter(scrollContainerRef.value)
|
|
200
|
+
: null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 阴影订阅:优先用适配器的 onScroll,缺失时监听原生滚动元素。 */
|
|
204
|
+
const subscribeAdapterScroll = (listener: () => void): (() => void) => {
|
|
205
|
+
const adapter = getScrollAdapter()
|
|
206
|
+
if (adapter?.onScroll) return adapter.onScroll(listener)
|
|
207
|
+
const element = adapter?.scrollElement
|
|
208
|
+
if (!element) return () => undefined
|
|
209
|
+
element.addEventListener('scroll', listener, { passive: true })
|
|
210
|
+
return () => element.removeEventListener('scroll', listener)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听绑定真实滚动元素。
|
|
214
|
+
// 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
|
|
215
|
+
const scrollShadow = useScrollShadow({
|
|
216
|
+
target: () => getScrollAdapter()?.scrollElement ?? null,
|
|
217
|
+
classTarget: () => pageRoot.value?.parentElement ?? null,
|
|
218
|
+
getMetrics: () => readAdapterScrollMetrics(getScrollAdapter()),
|
|
219
|
+
onScroll: subscribeAdapterScroll
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 同步主滚动容器的注册方式:
|
|
224
|
+
* - 默认路径:保持 `#W-<cacheName>` 选择器,行为与旧版本一致;
|
|
225
|
+
* - 自定义路径:改用适配器读写位置,跳过 wrapper 的注册。
|
|
226
|
+
*/
|
|
227
|
+
const syncScrollRegistration = (): void => {
|
|
228
|
+
if (hasCustomScrollContainer()) {
|
|
229
|
+
setPageScrollAdapter(cacheName, () => customScrollAdapter.value)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
233
|
+
setPageScrollAdapter(cacheName, null)
|
|
234
|
+
}
|
|
235
|
+
|
|
166
236
|
let refreshDisposed = false
|
|
167
237
|
let isFlushingRefresh = false
|
|
168
238
|
let queuedRefreshVersion: number | null = null
|
|
@@ -309,11 +379,22 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
309
379
|
|
|
310
380
|
onMounted(() => {
|
|
311
381
|
context.emit('onLoaded')
|
|
312
|
-
|
|
382
|
+
syncScrollRegistration()
|
|
383
|
+
syncScrollAdapter()
|
|
313
384
|
// 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
|
|
314
385
|
// 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
|
|
315
|
-
scrollShadow.sync()
|
|
386
|
+
scrollShadow.sync(true)
|
|
316
387
|
checkAndConsumeBackParams()
|
|
388
|
+
|
|
389
|
+
// 自定义滚动组件可能异步加载(异步组件 / Suspense),首帧尚未 expose。
|
|
390
|
+
// 下一 tick 再解析一次;首次解析成功时补一次位置恢复。
|
|
391
|
+
void nextTick(() => {
|
|
392
|
+
if (refreshDisposed) return
|
|
393
|
+
const wasResolved = Boolean(customScrollAdapter.value)
|
|
394
|
+
syncScrollAdapter()
|
|
395
|
+
if (!wasResolved && customScrollAdapter.value) restoreScroller(cacheName)
|
|
396
|
+
scrollShadow.sync(true)
|
|
397
|
+
})
|
|
317
398
|
})
|
|
318
399
|
onDeactivated(() => {
|
|
319
400
|
scrollShadow.clear()
|
|
@@ -321,8 +402,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
321
402
|
})
|
|
322
403
|
onActivated(() => {
|
|
323
404
|
context.emit('onLoaded')
|
|
405
|
+
syncScrollAdapter()
|
|
324
406
|
restoreScroller(cacheName)
|
|
325
|
-
scrollShadow.sync()
|
|
407
|
+
scrollShadow.sync(true)
|
|
326
408
|
evictMarkedCaches()
|
|
327
409
|
clearTabEvictionMarks()
|
|
328
410
|
checkAndConsumeBackParams()
|
|
@@ -336,12 +418,21 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
336
418
|
})
|
|
337
419
|
onUnmounted(() => removeScroller(cacheName))
|
|
338
420
|
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
421
|
+
// 运行时切换滚动组件:只重新解析适配器并重新同步阴影,不重建缓存页。
|
|
422
|
+
watch(
|
|
423
|
+
() => runtimeContext.scrollContainer.value,
|
|
424
|
+
() => {
|
|
425
|
+
// 切换前保存旧容器的主滚动位置,切换后由新容器 / 默认 wrapper 恢复。
|
|
426
|
+
saveScroller(cacheName)
|
|
427
|
+
void nextTick(() => {
|
|
428
|
+
if (refreshDisposed) return
|
|
429
|
+
syncScrollRegistration()
|
|
430
|
+
syncScrollAdapter()
|
|
431
|
+
restoreScroller(cacheName)
|
|
432
|
+
scrollShadow.sync(true)
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
)
|
|
345
436
|
|
|
346
437
|
return () => {
|
|
347
438
|
const vnode = props.vnode
|
|
@@ -356,24 +447,33 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
356
447
|
lastBackParams.value = backParams
|
|
357
448
|
}
|
|
358
449
|
|
|
450
|
+
const scrollContainer = runtimeContext.scrollContainer.value
|
|
451
|
+
const pageContent = lastCloned.value ? (
|
|
452
|
+
<PageShell
|
|
453
|
+
key={`${cacheName}-${renderedRefreshVersion.value}`}
|
|
454
|
+
vnode={lastCloned.value}
|
|
455
|
+
/>
|
|
456
|
+
) : null
|
|
457
|
+
|
|
359
458
|
return (
|
|
360
459
|
<div
|
|
361
460
|
ref={pageRoot}
|
|
362
461
|
class="stack-tab__cache-page-wrapper"
|
|
363
462
|
id={`W-${cacheName}`}
|
|
364
463
|
style={[
|
|
464
|
+
// 自定义滚动容器优先:wrapper 不再承担滚动,避免出现两层滚动条。
|
|
365
465
|
// 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
|
|
366
466
|
// 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
|
|
367
|
-
runtimeContext.useGlobalScroll.value
|
|
467
|
+
!scrollContainer && runtimeContext.useGlobalScroll.value
|
|
468
|
+
? 'overflow:auto'
|
|
469
|
+
: 'overflow:hidden',
|
|
368
470
|
'height: 100%'
|
|
369
471
|
]}
|
|
370
472
|
>
|
|
371
|
-
{
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
/>
|
|
376
|
-
) : null}
|
|
473
|
+
{scrollContainer
|
|
474
|
+
? h(scrollContainer, { ref: scrollContainerRef }, { default: () => pageContent })
|
|
475
|
+
: pageContent}
|
|
476
|
+
{/* PageLoading 必须在滚动容器外层:它是覆盖整个内容区的遮罩。 */}
|
|
377
477
|
<PageLoading tabId={tabInfo.id!} />
|
|
378
478
|
</div>
|
|
379
479
|
)
|
|
@@ -6,14 +6,33 @@
|
|
|
6
6
|
* 避免每次 deactivate 都全量 querySelectorAll。
|
|
7
7
|
*/
|
|
8
8
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
9
|
+
import {
|
|
10
|
+
readAdapterScrollPosition,
|
|
11
|
+
writeAdapterScrollPosition,
|
|
12
|
+
type StackTabsScrollAdapter
|
|
13
|
+
} from '../../model/ScrollAdapter'
|
|
9
14
|
|
|
10
15
|
export interface TabPanelScrollApi {
|
|
11
16
|
restoreScroller: (pageCacheId: string) => void
|
|
12
17
|
saveScroller: (pageCacheId: string) => void
|
|
13
18
|
removeScroller: (pageCacheId: string) => void
|
|
14
19
|
addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
|
|
20
|
+
/**
|
|
21
|
+
* 注册页面的自定义滚动适配器。
|
|
22
|
+
*
|
|
23
|
+
* 传 null 表示该页面回退到默认 wrapper(`#W-<cacheName>`)路径。
|
|
24
|
+
* getter 而不是适配器实例:运行时切换 `scrollContainer` 时,
|
|
25
|
+
* 位置保存/恢复总是读取最新的适配器。
|
|
26
|
+
*/
|
|
27
|
+
setPageScrollAdapter: (
|
|
28
|
+
pageCacheId: string,
|
|
29
|
+
getter: (() => StackTabsScrollAdapter | null) | null
|
|
30
|
+
) => void
|
|
15
31
|
}
|
|
16
32
|
|
|
33
|
+
/** 自定义滚动容器对应的主滚动位置 key;无自定义容器时仍使用 `#W-<cacheName>`。 */
|
|
34
|
+
const MAIN_SCROLLER_KEY = '__stack-tab-main-scroller__'
|
|
35
|
+
|
|
17
36
|
const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
18
37
|
selector.startsWith('#')
|
|
19
38
|
? document.getElementById(selector.slice(1))
|
|
@@ -88,12 +107,20 @@ const resolveAutoScrollElement = (pageCacheId: string, key: string): HTMLElement
|
|
|
88
107
|
|
|
89
108
|
export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
|
|
90
109
|
const { scrollPositionsByPageId } = context
|
|
110
|
+
/** 已注册自定义滚动容器的页面;值为读取最新适配器的 getter。 */
|
|
111
|
+
const adapterGetters = new Map<string, () => StackTabsScrollAdapter | null>()
|
|
112
|
+
|
|
113
|
+
const getPageAdapter = (pageCacheId: string): StackTabsScrollAdapter | null =>
|
|
114
|
+
adapterGetters.get(pageCacheId)?.() ?? null
|
|
91
115
|
|
|
92
116
|
const restoreScroller = (pageCacheId: string): void => {
|
|
93
117
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
94
118
|
if (!positions) return
|
|
95
119
|
|
|
120
|
+
// 先恢复页面内其它滚动元素,最后恢复主滚动容器:
|
|
121
|
+
// 运行时切换滚动组件时,普通 key 可能仍指向旧 wrapper,最后写入主位置可避免被覆盖。
|
|
96
122
|
for (const [selector, position] of positions) {
|
|
123
|
+
if (selector === MAIN_SCROLLER_KEY) continue
|
|
97
124
|
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
98
125
|
? resolveAutoScrollElement(pageCacheId, selector)
|
|
99
126
|
: resolveScrollElement(selector)
|
|
@@ -102,12 +129,31 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
102
129
|
element.scrollLeft = position.left
|
|
103
130
|
}
|
|
104
131
|
}
|
|
132
|
+
|
|
133
|
+
const mainPosition = positions.get(MAIN_SCROLLER_KEY)
|
|
134
|
+
if (!mainPosition) return
|
|
135
|
+
const adapter = getPageAdapter(pageCacheId)
|
|
136
|
+
if (adapter && writeAdapterScrollPosition(adapter, mainPosition)) return
|
|
137
|
+
// 自定义组件被移除(切换回默认路径)或适配器暂时不可用时回退 wrapper。
|
|
138
|
+
const wrapper = document.getElementById(`W-${pageCacheId}`)
|
|
139
|
+
if (wrapper) {
|
|
140
|
+
wrapper.scrollTop = mainPosition.top
|
|
141
|
+
wrapper.scrollLeft = mainPosition.left
|
|
142
|
+
}
|
|
105
143
|
}
|
|
106
144
|
|
|
107
145
|
const saveScroller = (pageCacheId: string): void => {
|
|
108
146
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
109
147
|
if (!positions) return
|
|
110
148
|
|
|
149
|
+
// 自定义滚动容器存在时,主滚动位置由适配器读写;wrapper 不再承载滚动,
|
|
150
|
+
// 必须跳过 `#W-...` 的 DOM 读取,否则会把 0 写入位置缓存。
|
|
151
|
+
if (adapterGetters.has(pageCacheId)) {
|
|
152
|
+
positions.delete(`#W-${pageCacheId}`)
|
|
153
|
+
const position = readAdapterScrollPosition(getPageAdapter(pageCacheId))
|
|
154
|
+
if (position) positions.set(MAIN_SCROLLER_KEY, position)
|
|
155
|
+
}
|
|
156
|
+
|
|
111
157
|
// 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置。
|
|
112
158
|
for (const key of positions.keys()) {
|
|
113
159
|
if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
|
|
@@ -122,8 +168,8 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
122
168
|
})
|
|
123
169
|
|
|
124
170
|
for (const selector of positions.keys()) {
|
|
125
|
-
// auto key 已在上一步按 DOM
|
|
126
|
-
if (selector.startsWith(AUTO_SCROLLER_PREFIX)) continue
|
|
171
|
+
// auto key 已在上一步按 DOM 顺序写入,主滚动位置由适配器维护,均无需再次解析。
|
|
172
|
+
if (selector.startsWith(AUTO_SCROLLER_PREFIX) || selector === MAIN_SCROLLER_KEY) continue
|
|
127
173
|
const element = resolveScrollElement(selector)
|
|
128
174
|
positions.set(selector, {
|
|
129
175
|
top: element?.scrollTop ?? 0,
|
|
@@ -138,6 +184,7 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
138
184
|
positions.clear()
|
|
139
185
|
scrollPositionsByPageId.delete(pageCacheId)
|
|
140
186
|
}
|
|
187
|
+
adapterGetters.delete(pageCacheId)
|
|
141
188
|
// 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用。
|
|
142
189
|
const cache = autoScrollCaches.get(pageCacheId)
|
|
143
190
|
if (cache) {
|
|
@@ -156,10 +203,29 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
156
203
|
}
|
|
157
204
|
}
|
|
158
205
|
|
|
206
|
+
const setPageScrollAdapter = (
|
|
207
|
+
pageCacheId: string,
|
|
208
|
+
getter: (() => StackTabsScrollAdapter | null) | null
|
|
209
|
+
): void => {
|
|
210
|
+
if (!getter) {
|
|
211
|
+
adapterGetters.delete(pageCacheId)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
adapterGetters.set(pageCacheId, getter)
|
|
215
|
+
if (!scrollPositionsByPageId.has(pageCacheId)) {
|
|
216
|
+
scrollPositionsByPageId.set(pageCacheId, new Map())
|
|
217
|
+
}
|
|
218
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)!
|
|
219
|
+
if (!positions.has(MAIN_SCROLLER_KEY)) {
|
|
220
|
+
positions.set(MAIN_SCROLLER_KEY, { top: 0, left: 0 })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
159
224
|
return {
|
|
160
225
|
restoreScroller,
|
|
161
226
|
saveScroller,
|
|
162
227
|
removeScroller,
|
|
163
|
-
addPageScroller
|
|
228
|
+
addPageScroller,
|
|
229
|
+
setPageScrollAdapter
|
|
164
230
|
}
|
|
165
231
|
}
|
|
@@ -43,11 +43,25 @@ export interface UseScrollShadowOptions {
|
|
|
43
43
|
* 距离边界 ≤ 阈值视为已贴边(不显示阴影);距离边界 > 阈值视为可继续滚动(显示阴影)。
|
|
44
44
|
*/
|
|
45
45
|
edgeThreshold?: number
|
|
46
|
+
/**
|
|
47
|
+
* 自定义尺寸读取(可选)。提供时优先于 `target` 的原生
|
|
48
|
+
* `scrollTop / scrollHeight / clientHeight`,用于命令式滚动组件。
|
|
49
|
+
*/
|
|
50
|
+
getMetrics?: () => { scrollTop: number; scrollHeight: number; clientHeight: number } | null
|
|
51
|
+
/**
|
|
52
|
+
* 自定义滚动订阅(可选)。提供时优先于在 `target` 上监听 scroll 事件,
|
|
53
|
+
* 返回取消订阅函数。`target` 可以为 null(例如纯 transform 驱动的滚动组件)。
|
|
54
|
+
*/
|
|
55
|
+
onScroll?: (listener: () => void) => () => void
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export interface UseScrollShadowReturn {
|
|
49
|
-
/**
|
|
50
|
-
|
|
59
|
+
/**
|
|
60
|
+
* 重新解析滚动/阴影目标并同步一次(用于 keep-alive 激活、DOM 重新挂载后)。
|
|
61
|
+
* `force` 为 true 时即使 target 未变化也重建订阅,用于适配器内部状态
|
|
62
|
+
* (自定义滚动元素的 expose)在两次 sync 之间发生变化的场景。
|
|
63
|
+
*/
|
|
64
|
+
sync: (force?: boolean) => void
|
|
51
65
|
/** 移除阴影类并取消待执行的 rAF(用于 keep-alive 失活) */
|
|
52
66
|
clear: () => void
|
|
53
67
|
}
|
|
@@ -65,6 +79,8 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
65
79
|
const edgeThreshold = Math.max(0, options.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD)
|
|
66
80
|
let rafId: number | null = null
|
|
67
81
|
let attached: HTMLElement | null = null
|
|
82
|
+
let attachedOnScroll: ((listener: () => void) => () => void) | null = null
|
|
83
|
+
let scrollCleanup: (() => void) | null = null
|
|
68
84
|
let classElement: HTMLElement | null = null
|
|
69
85
|
let resizeObserver: ResizeObserver | null = null
|
|
70
86
|
|
|
@@ -78,8 +94,8 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
78
94
|
options.classTarget ? resolveTarget(options.classTarget) : attached
|
|
79
95
|
|
|
80
96
|
const recompute = (): void => {
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
// 命令式滚动组件可能没有原生滚动元素,但通过 getMetrics 提供尺寸。
|
|
98
|
+
if (!attached && !options.getMetrics) return
|
|
83
99
|
const nextClassTarget = resolveClassTarget()
|
|
84
100
|
if (classElement && classElement !== nextClassTarget) {
|
|
85
101
|
clearClasses(classElement)
|
|
@@ -88,7 +104,19 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
88
104
|
classElement = nextClassTarget
|
|
89
105
|
if (!classElement) return
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
// 自定义尺寸优先;返回 null 时回退到原生滚动元素。
|
|
108
|
+
const metrics =
|
|
109
|
+
options.getMetrics?.() ??
|
|
110
|
+
(attached
|
|
111
|
+
? {
|
|
112
|
+
scrollTop: attached.scrollTop,
|
|
113
|
+
scrollHeight: attached.scrollHeight,
|
|
114
|
+
clientHeight: attached.clientHeight
|
|
115
|
+
}
|
|
116
|
+
: null)
|
|
117
|
+
if (!metrics) return
|
|
118
|
+
|
|
119
|
+
const { scrollTop, scrollHeight, clientHeight } = metrics
|
|
92
120
|
const distanceToTop = scrollTop
|
|
93
121
|
const distanceToBottom = scrollHeight - clientHeight - scrollTop
|
|
94
122
|
// 顶部阴影:表示上方还有内容,离开顶部(距离顶部 > 阈值)时显示。
|
|
@@ -111,23 +139,40 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
111
139
|
})
|
|
112
140
|
}
|
|
113
141
|
|
|
114
|
-
|
|
142
|
+
/** 释放滚动事件订阅 / ResizeObserver,不清理阴影类。 */
|
|
143
|
+
const releaseScrollSource = (): void => {
|
|
144
|
+
if (attached) attached.removeEventListener('scroll', schedule)
|
|
145
|
+
if (scrollCleanup) {
|
|
146
|
+
scrollCleanup()
|
|
147
|
+
scrollCleanup = null
|
|
148
|
+
}
|
|
149
|
+
attachedOnScroll = null
|
|
150
|
+
resizeObserver?.disconnect()
|
|
151
|
+
resizeObserver = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const attach = (force = false): void => {
|
|
115
155
|
const el = resolveTarget(options.target)
|
|
116
|
-
|
|
117
|
-
if (el
|
|
156
|
+
const onScroll = options.onScroll ?? null
|
|
157
|
+
if (!el && !onScroll && !options.getMetrics) return
|
|
158
|
+
if (!force && el === attached && onScroll === attachedOnScroll) {
|
|
118
159
|
schedule()
|
|
119
160
|
return
|
|
120
161
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
clearClasses(classElement)
|
|
126
|
-
classElement = null
|
|
127
|
-
}
|
|
162
|
+
releaseScrollSource()
|
|
163
|
+
clearClasses(classElement)
|
|
164
|
+
classElement = null
|
|
165
|
+
|
|
128
166
|
attached = el
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
if (onScroll) {
|
|
168
|
+
// 自定义滚动订阅优先于原生 scroll 事件;没有原生滚动元素时也能工作。
|
|
169
|
+
attachedOnScroll = onScroll
|
|
170
|
+
const cleanup = onScroll(schedule)
|
|
171
|
+
scrollCleanup = typeof cleanup === 'function' ? cleanup : null
|
|
172
|
+
} else if (el) {
|
|
173
|
+
el.addEventListener('scroll', schedule, { passive: true })
|
|
174
|
+
}
|
|
175
|
+
if (watchResize && el && typeof ResizeObserver !== 'undefined') {
|
|
131
176
|
resizeObserver = new ResizeObserver(() => schedule())
|
|
132
177
|
resizeObserver.observe(el)
|
|
133
178
|
}
|
|
@@ -147,24 +192,23 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
147
192
|
}
|
|
148
193
|
|
|
149
194
|
/** 重新解析目标:keep-alive 激活或 DOM 重新挂载后由调用方触发 */
|
|
150
|
-
const sync = (): void => {
|
|
195
|
+
const sync = (force = false): void => {
|
|
151
196
|
const el = resolveTarget(options.target)
|
|
152
|
-
|
|
153
|
-
|
|
197
|
+
const onScroll = options.onScroll ?? null
|
|
198
|
+
if (!el && !onScroll && !options.getMetrics) {
|
|
199
|
+
detach()
|
|
154
200
|
return
|
|
155
201
|
}
|
|
156
|
-
if (el !== attached) {
|
|
157
|
-
attach()
|
|
202
|
+
if (force || el !== attached || onScroll !== attachedOnScroll) {
|
|
203
|
+
attach(force)
|
|
158
204
|
return
|
|
159
205
|
}
|
|
160
206
|
schedule()
|
|
161
207
|
}
|
|
162
208
|
|
|
163
209
|
const detach = (): void => {
|
|
164
|
-
|
|
210
|
+
releaseScrollSource()
|
|
165
211
|
attached = null
|
|
166
|
-
resizeObserver?.disconnect()
|
|
167
|
-
resizeObserver = null
|
|
168
212
|
clearClasses(classElement)
|
|
169
213
|
classElement = null
|
|
170
214
|
cancelScheduled()
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
|
|
15
15
|
import { computed, ref } from 'vue'
|
|
16
|
-
import type { DefineComponent, VNode } from 'vue'
|
|
16
|
+
import type { Component, DefineComponent, VNode } from 'vue'
|
|
17
17
|
import { isNavigationFailure, useRouter } from 'vue-router'
|
|
18
18
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
19
19
|
import { encodeTabInfo, createId, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
|
|
@@ -108,7 +108,7 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
|
|
|
108
108
|
restoreCaches,
|
|
109
109
|
reset: resetStore
|
|
110
110
|
} = store
|
|
111
|
-
const { restoreScroller, saveScroller, removeScroller, addPageScroller } =
|
|
111
|
+
const { restoreScroller, saveScroller, removeScroller, addPageScroller, setPageScrollAdapter } =
|
|
112
112
|
createTabPanelScroll(runtimeContext)
|
|
113
113
|
const {
|
|
114
114
|
saveActiveTabToSession,
|
|
@@ -246,6 +246,7 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
|
|
|
246
246
|
evictMarkedCaches,
|
|
247
247
|
clearTabEvictionMarks,
|
|
248
248
|
addPageScroller,
|
|
249
|
+
setPageScrollAdapter,
|
|
249
250
|
saveScroller,
|
|
250
251
|
restoreScroller,
|
|
251
252
|
removeScroller
|
|
@@ -813,6 +814,9 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
|
|
|
813
814
|
setGlobalScroll: (value: boolean) => {
|
|
814
815
|
runtimeContext.useGlobalScroll.value = value
|
|
815
816
|
},
|
|
817
|
+
setScrollContainer: (value: Component | null | undefined) => {
|
|
818
|
+
runtimeContext.scrollContainer.value = value ?? null
|
|
819
|
+
},
|
|
816
820
|
clearSession,
|
|
817
821
|
setSessionPrefix: (value: string) => {
|
|
818
822
|
runtimeContext.sessionPrefix.value = value
|
package/src/lib/index.ts
CHANGED
|
@@ -37,6 +37,12 @@ export {
|
|
|
37
37
|
}
|
|
38
38
|
export { postOpenTab, onRefreshRequest, MSG_REFRESH, MSG_OPEN_TAB } from './utils/iframeBridge'
|
|
39
39
|
export type { IframeOpenTabPayload } from './utils/iframeBridge'
|
|
40
|
+
export type {
|
|
41
|
+
StackTabsScrollAdapter,
|
|
42
|
+
StackTabsScrollElementInstance,
|
|
43
|
+
StackTabsScrollMetrics,
|
|
44
|
+
StackTabsScrollPosition
|
|
45
|
+
} from './model/ScrollAdapter'
|
|
40
46
|
export default {
|
|
41
47
|
install(Vue: App, options?: VueStackTabsPluginOptions): void {
|
|
42
48
|
logVersion(import.meta.env.PACKAGE_VERSION)
|