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.
@@ -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
- addPageScroller(cacheName, `#W-${cacheName}`)
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
- // 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听仍绑定 wrapper。
340
- // 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
341
- const scrollShadow = useScrollShadow({
342
- target: pageRoot,
343
- classTarget: () => pageRoot.value?.parentElement ?? null
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 ? 'overflow:auto' : 'overflow:hidden',
475
+ !scrollContainer && runtimeContext.useGlobalScroll.value
476
+ ? 'overflow:auto'
477
+ : 'overflow:hidden',
368
478
  'height: 100%'
369
479
  ]}
370
480
  >
371
- {lastCloned.value ? (
372
- <PageShell
373
- key={`${cacheName}-${renderedRefreshVersion.value}`}
374
- vnode={lastCloned.value}
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
- positions.set(selector, {
129
- top: element?.scrollTop ?? 0,
130
- left: element?.scrollLeft ?? 0
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
- /** 重新解析滚动/阴影目标并同步一次(用于 keep-alive 激活、DOM 重新挂载后) */
50
- sync: () => void
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
- if (!attached) return
82
- const target = attached
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
- const { scrollTop, scrollHeight, clientHeight } = target
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
- const attach = (): void => {
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
- if (!el) return
117
- if (el === attached) {
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
- if (attached) {
122
- attached.removeEventListener('scroll', schedule)
123
- resizeObserver?.disconnect()
124
- resizeObserver = null
125
- clearClasses(classElement)
126
- classElement = null
127
- }
162
+ releaseScrollSource()
163
+ clearClasses(classElement)
164
+ classElement = null
165
+
128
166
  attached = el
129
- el.addEventListener('scroll', schedule, { passive: true })
130
- if (watchResize && typeof ResizeObserver !== 'undefined') {
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
- if (!el) {
153
- clear()
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
- if (attached) attached.removeEventListener('scroll', schedule)
210
+ releaseScrollSource()
165
211
  attached = null
166
- resizeObserver?.disconnect()
167
- resizeObserver = null
168
212
  clearClasses(classElement)
169
213
  classElement = null
170
214
  cancelScheduled()