vue-stack-tabs 2.5.1 → 2.6.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/dist/StackTabs.vue.d.ts +9 -1
- package/dist/hooks/stackTabsContext.d.ts +8 -1
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +3 -0
- package/dist/hooks/tabPanel/scroll.d.ts +16 -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/vue-stack-tabs.es.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +17 -1
- package/src/lib/hooks/stackTabsContext.ts +11 -2
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +124 -16
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/useScrollShadow.ts +69 -25
- package/src/lib/hooks/useTabPanel.tsx +14 -3
- package/src/lib/index.ts +6 -0
- package/src/lib/model/ScrollAdapter.ts +187 -0
package/src/lib/StackTabs.vue
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
toRef,
|
|
23
23
|
watch
|
|
24
24
|
} from 'vue'
|
|
25
|
-
import type { TransitionProps } from 'vue'
|
|
25
|
+
import type { Component, TransitionProps } from 'vue'
|
|
26
26
|
import { getMaxZIndex, invalidateZIndexCache } from './utils/scrollUtils'
|
|
27
27
|
import { isAllowedTabUrl } from './utils/urlParser'
|
|
28
28
|
import { applyStackTabsLocale } from './i18n/stackTabsLocale'
|
|
@@ -70,6 +70,14 @@ const props = withDefaults(
|
|
|
70
70
|
i18n?: string
|
|
71
71
|
space?: number
|
|
72
72
|
globalScroll?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* 可选的自定义滚动组件,用于承载每个页签内容区的滚动。
|
|
75
|
+
*
|
|
76
|
+
* 组件需要渲染默认插槽、根元素填满父容器,并通过 `expose` 暴露
|
|
77
|
+
* `scrollElement`(或实现 `StackTabsScrollAdapter` 的可选方法)。
|
|
78
|
+
* 传入后 wrapper 恒为 `overflow: hidden`,`globalScroll` 仅对默认路径生效。
|
|
79
|
+
*/
|
|
80
|
+
scrollContainer?: Component
|
|
73
81
|
iframePath: string
|
|
74
82
|
sessionPrefix?: string
|
|
75
83
|
iframeAllowedOrigins?: string[]
|
|
@@ -118,6 +126,7 @@ const runtimeContext =
|
|
|
118
126
|
iframePath: props.iframePath,
|
|
119
127
|
maxTabCount: props.max,
|
|
120
128
|
useGlobalScroll: props.globalScroll,
|
|
129
|
+
scrollContainer: props.scrollContainer,
|
|
121
130
|
sessionPrefix: props.sessionPrefix
|
|
122
131
|
})
|
|
123
132
|
const stackTabsApp = getCurrentInstance()?.appContext.app
|
|
@@ -148,6 +157,7 @@ const panelApi = isRuntimeContextOwner
|
|
|
148
157
|
initialize: () => undefined,
|
|
149
158
|
setMaxSize: () => undefined,
|
|
150
159
|
setGlobalScroll: () => undefined,
|
|
160
|
+
setScrollContainer: () => undefined,
|
|
151
161
|
setSessionPrefix: () => undefined
|
|
152
162
|
}
|
|
153
163
|
const {
|
|
@@ -157,6 +167,7 @@ const {
|
|
|
157
167
|
initialize,
|
|
158
168
|
setMaxSize,
|
|
159
169
|
setGlobalScroll,
|
|
170
|
+
setScrollContainer,
|
|
160
171
|
setSessionPrefix
|
|
161
172
|
} = panelApi
|
|
162
173
|
const tabActions = isRuntimeContextOwner
|
|
@@ -170,6 +181,7 @@ const { setIFramePath, openTab, openInNewWindow } = tabActions
|
|
|
170
181
|
if (isRuntimeContextOwner) {
|
|
171
182
|
setIFramePath(props.iframePath)
|
|
172
183
|
setGlobalScroll(props.globalScroll)
|
|
184
|
+
setScrollContainer(props.scrollContainer)
|
|
173
185
|
setMaxSize(props.max)
|
|
174
186
|
applyStackTabsLocale(changeLocale, props.i18n)
|
|
175
187
|
|
|
@@ -183,6 +195,10 @@ if (isRuntimeContextOwner) {
|
|
|
183
195
|
() => props.globalScroll,
|
|
184
196
|
(value) => setGlobalScroll(value)
|
|
185
197
|
)
|
|
198
|
+
watch(
|
|
199
|
+
() => props.scrollContainer,
|
|
200
|
+
(value) => setScrollContainer(value)
|
|
201
|
+
)
|
|
186
202
|
watch(
|
|
187
203
|
() => props.sessionPrefix,
|
|
188
204
|
(value) => setSessionPrefix(value)
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* 容器的兼容调用(例如旧的单元测试),绝不能承载某个 Vue App 的状态。
|
|
7
7
|
* 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
|
|
8
8
|
*/
|
|
9
|
-
import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
|
|
10
|
-
import { hasInjectionContext, inject, ref } from 'vue'
|
|
9
|
+
import type { App, Component, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
|
|
10
|
+
import { hasInjectionContext, inject, ref, shallowRef } from 'vue'
|
|
11
11
|
import type { Emitter } from 'mitt'
|
|
12
12
|
import mitt from 'mitt'
|
|
13
13
|
import type { ITabItem } from '../model/TabModel'
|
|
@@ -33,6 +33,12 @@ export interface StackTabsRuntimeContext {
|
|
|
33
33
|
iframeRefreshKeys: Ref<Record<string, number>>
|
|
34
34
|
maxTabCount: Ref<number>
|
|
35
35
|
useGlobalScroll: Ref<boolean>
|
|
36
|
+
/**
|
|
37
|
+
* 宿主提供的自定义滚动组件(可选)。
|
|
38
|
+
* 传入后每个缓存页的内容区由该组件承载滚动,库通过其 expose 的适配器
|
|
39
|
+
* 读写滚动位置与计算阴影;未传入时保持内建 `overflow: auto` 行为。
|
|
40
|
+
*/
|
|
41
|
+
scrollContainer: Ref<Component | null>
|
|
36
42
|
sessionPrefix: Ref<string>
|
|
37
43
|
iframePath: Ref<string>
|
|
38
44
|
eventBus: Emitter<TabEventPayloadMap>
|
|
@@ -42,6 +48,7 @@ export interface CreateStackTabsRuntimeContextOptions {
|
|
|
42
48
|
iframePath?: string
|
|
43
49
|
maxTabCount?: number
|
|
44
50
|
useGlobalScroll?: boolean
|
|
51
|
+
scrollContainer?: Component
|
|
45
52
|
sessionPrefix?: string
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -71,6 +78,8 @@ export const createStackTabsRuntimeContext = (
|
|
|
71
78
|
...state,
|
|
72
79
|
maxTabCount: ref(options.maxTabCount ?? state.maxTabCount.value),
|
|
73
80
|
useGlobalScroll: ref(options.useGlobalScroll ?? state.useGlobalScroll.value),
|
|
81
|
+
// 组件定义不应被深度响应式代理,使用 shallowRef。
|
|
82
|
+
scrollContainer: shallowRef<Component | null>(options.scrollContainer ?? null),
|
|
74
83
|
sessionPrefix: ref(options.sessionPrefix ?? state.sessionPrefix.value),
|
|
75
84
|
iframePath: ref(options.iframePath ?? ''),
|
|
76
85
|
eventBus: mitt<TabEventPayloadMap>()
|
|
@@ -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,7 +40,12 @@ 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
|
|
48
|
+
recordMainScroller: (pageId: string) => void
|
|
38
49
|
restoreScroller: (pageId: string) => void
|
|
39
50
|
removeScroller: (pageId: string) => void
|
|
40
51
|
}
|
|
@@ -79,7 +90,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
79
90
|
evictMarkedCaches,
|
|
80
91
|
clearTabEvictionMarks,
|
|
81
92
|
addPageScroller,
|
|
93
|
+
setPageScrollAdapter,
|
|
82
94
|
saveScroller,
|
|
95
|
+
recordMainScroller,
|
|
83
96
|
restoreScroller,
|
|
84
97
|
removeScroller
|
|
85
98
|
} = options
|
|
@@ -154,6 +167,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
154
167
|
const lastCloned = shallowRef<VNode | null>(null)
|
|
155
168
|
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
156
169
|
const pageRoot = ref<HTMLElement | null>(null)
|
|
170
|
+
/** 自定义滚动组件的实例(expose proxy),用于解析滚动适配器。 */
|
|
171
|
+
const scrollContainerRef = ref<unknown>(null)
|
|
172
|
+
const customScrollAdapter = shallowRef<StackTabsScrollAdapter | null>(null)
|
|
157
173
|
/**
|
|
158
174
|
* 实际渲染的刷新版本。
|
|
159
175
|
*
|
|
@@ -163,6 +179,68 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
163
179
|
*/
|
|
164
180
|
const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
|
|
165
181
|
|
|
182
|
+
const hasCustomScrollContainer = (): boolean =>
|
|
183
|
+
Boolean(runtimeContext.scrollContainer.value)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 当前页面的主滚动适配器。
|
|
187
|
+
*
|
|
188
|
+
* 未传 `scrollContainer` 时把 wrapper 本身当作适配器,默认路径行为保持不变;
|
|
189
|
+
* 传入时读取自定义组件 expose 的能力,解析不到则按「无滚动容器」处理。
|
|
190
|
+
*/
|
|
191
|
+
const getScrollAdapter = (): StackTabsScrollAdapter | null => {
|
|
192
|
+
if (!hasCustomScrollContainer()) {
|
|
193
|
+
return pageRoot.value ? { scrollElement: pageRoot.value } : null
|
|
194
|
+
}
|
|
195
|
+
return customScrollAdapter.value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 重新解析自定义滚动组件的适配器(运行时切换容器后也会调用)。 */
|
|
199
|
+
const syncScrollAdapter = (): void => {
|
|
200
|
+
customScrollAdapter.value = hasCustomScrollContainer()
|
|
201
|
+
? resolveScrollAdapter(scrollContainerRef.value)
|
|
202
|
+
: null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 阴影订阅:优先用适配器的 onScroll,缺失时监听原生滚动元素。 */
|
|
206
|
+
const subscribeAdapterScroll = (listener: () => void): (() => void) => {
|
|
207
|
+
const adapter = getScrollAdapter()
|
|
208
|
+
// keep-alive 停用会先把 DOM 移入隐藏容器、让 scrollTop 归零,
|
|
209
|
+
// 因此滚动过程中就要记录位置,不能等到 onDeactivated 再读 DOM。
|
|
210
|
+
const handleScroll = () => {
|
|
211
|
+
recordMainScroller(cacheName)
|
|
212
|
+
listener()
|
|
213
|
+
}
|
|
214
|
+
if (adapter?.onScroll) return adapter.onScroll(handleScroll)
|
|
215
|
+
const element = adapter?.scrollElement
|
|
216
|
+
if (!element) return () => undefined
|
|
217
|
+
element.addEventListener('scroll', handleScroll, { passive: true })
|
|
218
|
+
return () => element.removeEventListener('scroll', handleScroll)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听绑定真实滚动元素。
|
|
222
|
+
// 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
|
|
223
|
+
const scrollShadow = useScrollShadow({
|
|
224
|
+
target: () => getScrollAdapter()?.scrollElement ?? null,
|
|
225
|
+
classTarget: () => pageRoot.value?.parentElement ?? null,
|
|
226
|
+
getMetrics: () => readAdapterScrollMetrics(getScrollAdapter()),
|
|
227
|
+
onScroll: subscribeAdapterScroll
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 同步主滚动容器的注册方式:
|
|
232
|
+
* - 默认路径:保持 `#W-<cacheName>` 选择器,行为与旧版本一致;
|
|
233
|
+
* - 自定义路径:改用适配器读写位置,跳过 wrapper 的注册。
|
|
234
|
+
*/
|
|
235
|
+
const syncScrollRegistration = (): void => {
|
|
236
|
+
if (hasCustomScrollContainer()) {
|
|
237
|
+
setPageScrollAdapter(cacheName, () => customScrollAdapter.value)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
241
|
+
setPageScrollAdapter(cacheName, null)
|
|
242
|
+
}
|
|
243
|
+
|
|
166
244
|
let refreshDisposed = false
|
|
167
245
|
let isFlushingRefresh = false
|
|
168
246
|
let queuedRefreshVersion: number | null = null
|
|
@@ -309,11 +387,22 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
309
387
|
|
|
310
388
|
onMounted(() => {
|
|
311
389
|
context.emit('onLoaded')
|
|
312
|
-
|
|
390
|
+
syncScrollRegistration()
|
|
391
|
+
syncScrollAdapter()
|
|
313
392
|
// 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
|
|
314
393
|
// 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
|
|
315
|
-
scrollShadow.sync()
|
|
394
|
+
scrollShadow.sync(true)
|
|
316
395
|
checkAndConsumeBackParams()
|
|
396
|
+
|
|
397
|
+
// 自定义滚动组件可能异步加载(异步组件 / Suspense),首帧尚未 expose。
|
|
398
|
+
// 下一 tick 再解析一次;首次解析成功时补一次位置恢复。
|
|
399
|
+
void nextTick(() => {
|
|
400
|
+
if (refreshDisposed) return
|
|
401
|
+
const wasResolved = Boolean(customScrollAdapter.value)
|
|
402
|
+
syncScrollAdapter()
|
|
403
|
+
if (!wasResolved && customScrollAdapter.value) restoreScroller(cacheName)
|
|
404
|
+
scrollShadow.sync(true)
|
|
405
|
+
})
|
|
317
406
|
})
|
|
318
407
|
onDeactivated(() => {
|
|
319
408
|
scrollShadow.clear()
|
|
@@ -321,8 +410,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
321
410
|
})
|
|
322
411
|
onActivated(() => {
|
|
323
412
|
context.emit('onLoaded')
|
|
413
|
+
syncScrollAdapter()
|
|
324
414
|
restoreScroller(cacheName)
|
|
325
|
-
scrollShadow.sync()
|
|
415
|
+
scrollShadow.sync(true)
|
|
326
416
|
evictMarkedCaches()
|
|
327
417
|
clearTabEvictionMarks()
|
|
328
418
|
checkAndConsumeBackParams()
|
|
@@ -336,12 +426,21 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
336
426
|
})
|
|
337
427
|
onUnmounted(() => removeScroller(cacheName))
|
|
338
428
|
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
429
|
+
// 运行时切换滚动组件:只重新解析适配器并重新同步阴影,不重建缓存页。
|
|
430
|
+
watch(
|
|
431
|
+
() => runtimeContext.scrollContainer.value,
|
|
432
|
+
() => {
|
|
433
|
+
// 切换前保存旧容器的主滚动位置,切换后由新容器 / 默认 wrapper 恢复。
|
|
434
|
+
saveScroller(cacheName)
|
|
435
|
+
void nextTick(() => {
|
|
436
|
+
if (refreshDisposed) return
|
|
437
|
+
syncScrollRegistration()
|
|
438
|
+
syncScrollAdapter()
|
|
439
|
+
restoreScroller(cacheName)
|
|
440
|
+
scrollShadow.sync(true)
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
)
|
|
345
444
|
|
|
346
445
|
return () => {
|
|
347
446
|
const vnode = props.vnode
|
|
@@ -356,24 +455,33 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
356
455
|
lastBackParams.value = backParams
|
|
357
456
|
}
|
|
358
457
|
|
|
458
|
+
const scrollContainer = runtimeContext.scrollContainer.value
|
|
459
|
+
const pageContent = lastCloned.value ? (
|
|
460
|
+
<PageShell
|
|
461
|
+
key={`${cacheName}-${renderedRefreshVersion.value}`}
|
|
462
|
+
vnode={lastCloned.value}
|
|
463
|
+
/>
|
|
464
|
+
) : null
|
|
465
|
+
|
|
359
466
|
return (
|
|
360
467
|
<div
|
|
361
468
|
ref={pageRoot}
|
|
362
469
|
class="stack-tab__cache-page-wrapper"
|
|
363
470
|
id={`W-${cacheName}`}
|
|
364
471
|
style={[
|
|
472
|
+
// 自定义滚动容器优先:wrapper 不再承担滚动,避免出现两层滚动条。
|
|
365
473
|
// 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
|
|
366
474
|
// 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
|
|
367
|
-
runtimeContext.useGlobalScroll.value
|
|
475
|
+
!scrollContainer && runtimeContext.useGlobalScroll.value
|
|
476
|
+
? 'overflow:auto'
|
|
477
|
+
: 'overflow:hidden',
|
|
368
478
|
'height: 100%'
|
|
369
479
|
]}
|
|
370
480
|
>
|
|
371
|
-
{
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
/>
|
|
376
|
-
) : null}
|
|
481
|
+
{scrollContainer
|
|
482
|
+
? h(scrollContainer, { ref: scrollContainerRef }, { default: () => pageContent })
|
|
483
|
+
: pageContent}
|
|
484
|
+
{/* PageLoading 必须在滚动容器外层:它是覆盖整个内容区的遮罩。 */}
|
|
377
485
|
<PageLoading tabId={tabInfo.id!} />
|
|
378
486
|
</div>
|
|
379
487
|
)
|
|
@@ -6,14 +6,45 @@
|
|
|
6
6
|
* 避免每次 deactivate 都全量 querySelectorAll。
|
|
7
7
|
*/
|
|
8
8
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
9
|
+
import type { ScrollPosition } from './state'
|
|
10
|
+
import {
|
|
11
|
+
readAdapterScrollPosition,
|
|
12
|
+
writeAdapterScrollPosition,
|
|
13
|
+
type StackTabsScrollAdapter
|
|
14
|
+
} from '../../model/ScrollAdapter'
|
|
9
15
|
|
|
10
16
|
export interface TabPanelScrollApi {
|
|
11
17
|
restoreScroller: (pageCacheId: string) => void
|
|
12
18
|
saveScroller: (pageCacheId: string) => void
|
|
19
|
+
/**
|
|
20
|
+
* 记录主滚动容器的实时位置(由页面滚动事件调用)。
|
|
21
|
+
*
|
|
22
|
+
* keep-alive 停用时会先把页面 DOM 移入隐藏容器,原生 scrollTop 随之归零;
|
|
23
|
+
* 因此不能只在 onDeactivated 时读取 DOM,必须在滚动过程中持续记录。
|
|
24
|
+
*/
|
|
25
|
+
recordMainScroller: (pageCacheId: string) => void
|
|
13
26
|
removeScroller: (pageCacheId: string) => void
|
|
14
27
|
addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
|
|
28
|
+
/**
|
|
29
|
+
* 注册页面的自定义滚动适配器。
|
|
30
|
+
*
|
|
31
|
+
* 传 null 表示该页面回退到默认 wrapper(`#W-<cacheName>`)路径。
|
|
32
|
+
* getter 而不是适配器实例:运行时切换 `scrollContainer` 时,
|
|
33
|
+
* 位置保存/恢复总是读取最新的适配器。
|
|
34
|
+
*/
|
|
35
|
+
setPageScrollAdapter: (
|
|
36
|
+
pageCacheId: string,
|
|
37
|
+
getter: (() => StackTabsScrollAdapter | null) | null
|
|
38
|
+
) => void
|
|
15
39
|
}
|
|
16
40
|
|
|
41
|
+
/** 自定义滚动容器对应的主滚动位置 key;无自定义容器时仍使用 `#W-<cacheName>`。 */
|
|
42
|
+
const MAIN_SCROLLER_KEY = '__stack-tab-main-scroller__'
|
|
43
|
+
|
|
44
|
+
/** 位置是否为原点(或尚未记录)。 */
|
|
45
|
+
const isZeroPosition = (position: ScrollPosition | undefined): boolean =>
|
|
46
|
+
!position || (position.top === 0 && position.left === 0)
|
|
47
|
+
|
|
17
48
|
const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
18
49
|
selector.startsWith('#')
|
|
19
50
|
? document.getElementById(selector.slice(1))
|
|
@@ -88,12 +119,36 @@ const resolveAutoScrollElement = (pageCacheId: string, key: string): HTMLElement
|
|
|
88
119
|
|
|
89
120
|
export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
|
|
90
121
|
const { scrollPositionsByPageId } = context
|
|
122
|
+
/** 已注册自定义滚动容器的页面;值为读取最新适配器的 getter。 */
|
|
123
|
+
const adapterGetters = new Map<string, () => StackTabsScrollAdapter | null>()
|
|
124
|
+
|
|
125
|
+
const getPageAdapter = (pageCacheId: string): StackTabsScrollAdapter | null =>
|
|
126
|
+
adapterGetters.get(pageCacheId)?.() ?? null
|
|
127
|
+
|
|
128
|
+
const recordMainScroller = (pageCacheId: string): void => {
|
|
129
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
130
|
+
if (!positions) return
|
|
131
|
+
|
|
132
|
+
if (adapterGetters.has(pageCacheId)) {
|
|
133
|
+
const position = readAdapterScrollPosition(getPageAdapter(pageCacheId))
|
|
134
|
+
if (position) positions.set(MAIN_SCROLLER_KEY, position)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const wrapper =
|
|
139
|
+
typeof document !== 'undefined' ? document.getElementById(`W-${pageCacheId}`) : null
|
|
140
|
+
if (!wrapper) return
|
|
141
|
+
positions.set(`#W-${pageCacheId}`, { top: wrapper.scrollTop, left: wrapper.scrollLeft })
|
|
142
|
+
}
|
|
91
143
|
|
|
92
144
|
const restoreScroller = (pageCacheId: string): void => {
|
|
93
145
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
94
146
|
if (!positions) return
|
|
95
147
|
|
|
148
|
+
// 先恢复页面内其它滚动元素,最后恢复主滚动容器:
|
|
149
|
+
// 运行时切换滚动组件时,普通 key 可能仍指向旧 wrapper,最后写入主位置可避免被覆盖。
|
|
96
150
|
for (const [selector, position] of positions) {
|
|
151
|
+
if (selector === MAIN_SCROLLER_KEY) continue
|
|
97
152
|
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
98
153
|
? resolveAutoScrollElement(pageCacheId, selector)
|
|
99
154
|
: resolveScrollElement(selector)
|
|
@@ -102,12 +157,35 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
102
157
|
element.scrollLeft = position.left
|
|
103
158
|
}
|
|
104
159
|
}
|
|
160
|
+
|
|
161
|
+
const mainPosition = positions.get(MAIN_SCROLLER_KEY)
|
|
162
|
+
if (!mainPosition) return
|
|
163
|
+
const adapter = getPageAdapter(pageCacheId)
|
|
164
|
+
if (adapter && writeAdapterScrollPosition(adapter, mainPosition)) return
|
|
165
|
+
// 自定义组件被移除(切换回默认路径)或适配器暂时不可用时回退 wrapper。
|
|
166
|
+
const wrapper = document.getElementById(`W-${pageCacheId}`)
|
|
167
|
+
if (wrapper) {
|
|
168
|
+
wrapper.scrollTop = mainPosition.top
|
|
169
|
+
wrapper.scrollLeft = mainPosition.left
|
|
170
|
+
}
|
|
105
171
|
}
|
|
106
172
|
|
|
107
173
|
const saveScroller = (pageCacheId: string): void => {
|
|
108
174
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
109
175
|
if (!positions) return
|
|
110
176
|
|
|
177
|
+
// 自定义滚动容器存在时,主滚动位置由适配器读写;wrapper 不再承载滚动,
|
|
178
|
+
// 必须跳过 `#W-...` 的 DOM 读取,否则会把 0 写入位置缓存。
|
|
179
|
+
if (adapterGetters.has(pageCacheId)) {
|
|
180
|
+
positions.delete(`#W-${pageCacheId}`)
|
|
181
|
+
const position = readAdapterScrollPosition(getPageAdapter(pageCacheId))
|
|
182
|
+
const previous = positions.get(MAIN_SCROLLER_KEY)
|
|
183
|
+
// 停用时滚动元素可能已被移入隐藏容器而读到 0;保留滚动过程中的记录值。
|
|
184
|
+
if (position && (!isZeroPosition(position) || isZeroPosition(previous))) {
|
|
185
|
+
positions.set(MAIN_SCROLLER_KEY, position)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
111
189
|
// 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置。
|
|
112
190
|
for (const key of positions.keys()) {
|
|
113
191
|
if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
|
|
@@ -122,13 +200,17 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
122
200
|
})
|
|
123
201
|
|
|
124
202
|
for (const selector of positions.keys()) {
|
|
125
|
-
// auto key 已在上一步按 DOM
|
|
126
|
-
if (selector.startsWith(AUTO_SCROLLER_PREFIX)) continue
|
|
203
|
+
// auto key 已在上一步按 DOM 顺序写入,主滚动位置由适配器维护,均无需再次解析。
|
|
204
|
+
if (selector.startsWith(AUTO_SCROLLER_PREFIX) || selector === MAIN_SCROLLER_KEY) continue
|
|
127
205
|
const element = resolveScrollElement(selector)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
206
|
+
const next = { top: element?.scrollTop ?? 0, left: element?.scrollLeft ?? 0 }
|
|
207
|
+
const previous = positions.get(selector)
|
|
208
|
+
// 元素不可用,或停用后读到 0 而此前记录过非零位置时,保留记录值。
|
|
209
|
+
if (!element || (isZeroPosition(next) && !isZeroPosition(previous))) {
|
|
210
|
+
if (previous) positions.set(selector, previous)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
positions.set(selector, next)
|
|
132
214
|
}
|
|
133
215
|
}
|
|
134
216
|
|
|
@@ -138,6 +220,7 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
138
220
|
positions.clear()
|
|
139
221
|
scrollPositionsByPageId.delete(pageCacheId)
|
|
140
222
|
}
|
|
223
|
+
adapterGetters.delete(pageCacheId)
|
|
141
224
|
// 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用。
|
|
142
225
|
const cache = autoScrollCaches.get(pageCacheId)
|
|
143
226
|
if (cache) {
|
|
@@ -156,10 +239,30 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
156
239
|
}
|
|
157
240
|
}
|
|
158
241
|
|
|
242
|
+
const setPageScrollAdapter = (
|
|
243
|
+
pageCacheId: string,
|
|
244
|
+
getter: (() => StackTabsScrollAdapter | null) | null
|
|
245
|
+
): void => {
|
|
246
|
+
if (!getter) {
|
|
247
|
+
adapterGetters.delete(pageCacheId)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
adapterGetters.set(pageCacheId, getter)
|
|
251
|
+
if (!scrollPositionsByPageId.has(pageCacheId)) {
|
|
252
|
+
scrollPositionsByPageId.set(pageCacheId, new Map())
|
|
253
|
+
}
|
|
254
|
+
const positions = scrollPositionsByPageId.get(pageCacheId)!
|
|
255
|
+
if (!positions.has(MAIN_SCROLLER_KEY)) {
|
|
256
|
+
positions.set(MAIN_SCROLLER_KEY, { top: 0, left: 0 })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
159
260
|
return {
|
|
160
261
|
restoreScroller,
|
|
161
262
|
saveScroller,
|
|
263
|
+
recordMainScroller,
|
|
162
264
|
removeScroller,
|
|
163
|
-
addPageScroller
|
|
265
|
+
addPageScroller,
|
|
266
|
+
setPageScrollAdapter
|
|
164
267
|
}
|
|
165
268
|
}
|
|
@@ -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()
|