@aiquants/virtualscroll 1.23.0 → 1.25.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/CHANGELOG.md +44 -1
- package/README.md +16 -5
- package/dist/VirtualScroll.d.cts +25 -0
- package/dist/VirtualScroll.d.ts +25 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1372 -1341
- package/package.json +1 -1
- package/src/VirtualScroll.tsx +121 -9
package/package.json
CHANGED
package/src/VirtualScroll.tsx
CHANGED
|
@@ -40,6 +40,12 @@ export type VirtualScrollHandle = ScrollPaneHandle & {
|
|
|
40
40
|
focusItemAtIndex: (index: number, options?: { ensureVisible?: boolean }) => void
|
|
41
41
|
/** Gets the current scroll range information / 現在のスクロール範囲情報を取得 */
|
|
42
42
|
getRange: () => VirtualScrollRange
|
|
43
|
+
/**
|
|
44
|
+
* Captures the current top-row anchor ({index, offsetPx}) for exact restore via initialScrollAnchor.
|
|
45
|
+
* initialScrollAnchor での厳密復元用に、現在の先頭可視行アンカー ({index, offsetPx}) を取得。
|
|
46
|
+
* itemCount 0 のときは null。offsetPx は先頭可視行の上端がビューポート上端より上に隠れている px (論理座標)。
|
|
47
|
+
*/
|
|
48
|
+
getScrollAnchor: () => { index: number; offsetPx: number } | null
|
|
43
49
|
/**
|
|
44
50
|
* Manually updates the size of a specific item. Contract: after calling this,
|
|
45
51
|
* `getItemHeight(index)` must return the same `size`; `getItemHeight` is the source of truth,
|
|
@@ -132,6 +138,19 @@ export type VirtualScrollProps<T> = {
|
|
|
132
138
|
children: (item: T, index: number) => ReactNode
|
|
133
139
|
initialScrollIndex?: number
|
|
134
140
|
initialScrollOffset?: number
|
|
141
|
+
/**
|
|
142
|
+
* Anchor-based initial position: starts with row `index` scrolled `offsetPx` px past the viewport top.
|
|
143
|
+
* アンカー基準の初期位置。行 index の上端がビューポート上端より offsetPx px 分だけ上に隠れた位置で開始する。
|
|
144
|
+
*
|
|
145
|
+
* 可変高さ一覧の「位置キープ」はこれを使うこと。initialScrollOffset の生 px 復元は、実測高さが
|
|
146
|
+
* 具現化されていない再マウント空間では px→行変換が推定値で行われ、別の行に着地する
|
|
147
|
+
* (実測: 深い復元で 21 行漂着)。アンカーは行の同一性で位置決めするため空間差に不変。
|
|
148
|
+
* 保存側は VirtualScrollHandle.getScrollAnchor() で取得した値をそのまま渡す。
|
|
149
|
+
* 優先順位: initialScrollAnchor > initialScrollIndex > initialScrollOffset。
|
|
150
|
+
* マウント時に itemCount > 0 なら初回コミットから厳密。itemCount 0 でマウントした場合は
|
|
151
|
+
* 最初のデータ到着 (0 → N) 時に scrollToIndex 経由で遅延適用される (近似 → 自己修復)。
|
|
152
|
+
*/
|
|
153
|
+
initialScrollAnchor?: { index: number; offsetPx?: number }
|
|
135
154
|
callbackThrottleMs?: number
|
|
136
155
|
contentInsets?: ScrollPaneProps["contentInsets"]
|
|
137
156
|
onItemFocus?: (index: number) => void
|
|
@@ -180,7 +199,12 @@ const normalizeInsets = (insets?: ScrollPaneContentInsets): Required<ScrollPaneC
|
|
|
180
199
|
|
|
181
200
|
const toLogicalPositionWithInset = (pane: number, top: number) => (pane <= top ? 0 : pane - top)
|
|
182
201
|
|
|
183
|
-
|
|
202
|
+
// 論理 0 は「本当の先頭」= ペイン 0 (上インセットが見えている状態) へ写像する。
|
|
203
|
+
// ❗ `logical <= 0 ? top` (インセットを隠して先頭行を上端密着にする端点) へ戻さないこと:
|
|
204
|
+
// マウント既定 (initialScroll 未指定 = ペイン 0) や ScrollPane のクランプ休止点 (ペイン 0) と
|
|
205
|
+
// 端点が食い違い、「保存した論理 0 を復元すると数 px スクロールされて見える」実害バグになる
|
|
206
|
+
// (toLogical はペイン [0..top] を論理 0 へ潰す不可逆写像のため、逆写像の端点選択がここで確定する)
|
|
207
|
+
const toPanePositionWithInset = (logical: number, top: number) => (logical <= 0 ? 0 : logical + top)
|
|
184
208
|
|
|
185
209
|
/**
|
|
186
210
|
* Maximum run length of consecutive zero-height rows scanned linearly before attempting an
|
|
@@ -572,15 +596,27 @@ const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObj
|
|
|
572
596
|
// last: 最終実行時刻, id: RAF ID, arg: 待機中の引数
|
|
573
597
|
const state = useRef({ last: 0, id: null as number | null, arg: null as Payload | null }).current
|
|
574
598
|
|
|
575
|
-
// クリーンアップ: アンマウント時にRAF
|
|
599
|
+
// クリーンアップ: アンマウント時に RAF をキャンセルし、待機中のペイロードは**破棄せず同期配信**する。
|
|
600
|
+
// ❗ 破棄すると、フリング中のタブ切替などで確定済みの待機通知まで消費側へ届かず、
|
|
601
|
+
// 「スクロールのたびに書き戻す」型の位置保存が取りこぼす。なおこの flush が救えるのは
|
|
602
|
+
// 「キューに載った最後のペイロード」まで: 最後の render-loop tick 以後の移動 (最大 1 フレーム分、
|
|
603
|
+
// 慣性で概ね 100px 未満) は残余として許容する (読中でないフリング途中の位置のため実害は軽微)
|
|
576
604
|
useEffect(
|
|
577
605
|
() => () => {
|
|
578
606
|
if (state.id !== null) {
|
|
579
607
|
cancelAnimationFrame(state.id)
|
|
580
608
|
state.id = null
|
|
581
609
|
}
|
|
610
|
+
if (state.arg !== null && callbackRef.current) {
|
|
611
|
+
try {
|
|
612
|
+
invoke(callbackRef.current, state.arg)
|
|
613
|
+
} catch (e) {
|
|
614
|
+
console.error("[useThrottledInvoker] Error flushing pending callback on unmount", e)
|
|
615
|
+
}
|
|
616
|
+
state.arg = null
|
|
617
|
+
}
|
|
582
618
|
},
|
|
583
|
-
[state],
|
|
619
|
+
[state, callbackRef, invoke],
|
|
584
620
|
)
|
|
585
621
|
|
|
586
622
|
return useCallback(
|
|
@@ -686,6 +722,7 @@ const VirtualScrollInner = <T,>(
|
|
|
686
722
|
background,
|
|
687
723
|
initialScrollIndex,
|
|
688
724
|
initialScrollOffset,
|
|
725
|
+
initialScrollAnchor,
|
|
689
726
|
callbackThrottleMs = 5,
|
|
690
727
|
contentInsets,
|
|
691
728
|
onItemFocus,
|
|
@@ -709,18 +746,32 @@ const VirtualScrollInner = <T,>(
|
|
|
709
746
|
scrollPosition: 0,
|
|
710
747
|
totalHeight: 0,
|
|
711
748
|
})
|
|
749
|
+
// アンカー付きマウントは保留アンカーを最初から種まきする: マウント後の実測反映 (contentSize
|
|
750
|
+
// コミット) のたびにドリフト補正 effect が同じ行へ再ピン留めし、自己修復する。ユーザーの
|
|
751
|
+
// 手動スクロールで解除される (handleScroll 参照)。offset は scrollToIndex と同じ「減算」規約の
|
|
752
|
+
// ため符号を反転して持つ (offsetPx = 行上端がビューポート上端より上に隠れる px、正の値)
|
|
712
753
|
const pendingVisibleStartIndexRef = useRef<{
|
|
713
754
|
index: number
|
|
714
755
|
align?: "top" | "bottom" | "center"
|
|
715
756
|
offset?: number
|
|
716
|
-
} | null>(null)
|
|
757
|
+
} | null>(initialScrollAnchor && itemCount > 0 ? { index: minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1), align: "top", offset: -Math.max(0, initialScrollAnchor.offsetPx ?? 0) } : null)
|
|
717
758
|
// 「アンマウント済みか」を追跡する (初期 false)。「マウント済みか」の正ガードだと、初回レンダー中に
|
|
718
759
|
// 予約された高さ照合マイクロタスクが passive effect (マウントフラグ設定) より先に実行されて破棄される。
|
|
719
760
|
const isUnmountedRef = useRef(false)
|
|
720
761
|
|
|
762
|
+
// 初回実行スキップ用 (マウント時に走る effect 本体が、直前で種まきした初期アンカーを消さないため)
|
|
763
|
+
const isFirstGetItemHeightRunRef = useRef(true)
|
|
721
764
|
useEffect(() => {
|
|
722
765
|
// 目的: アイテムの高さ取得ロジックがリセットされた場合に、古いインデックスへのアンカーを解除する。
|
|
723
766
|
// 依存関係: resetOnGetItemHeightChange, getItemHeight
|
|
767
|
+
// ❗ 初回 (マウント) は解除しない: このフラグの意味は「getItemHeight の**変更**時に解除」で
|
|
768
|
+
// あり、マウント時の実行で解除すると initialScrollAnchor が種まきした保留アンカー
|
|
769
|
+
// (実測反映ごとの自己修復再ピン留め) が resetOnGetItemHeightChange: true の消費者で
|
|
770
|
+
// 無言に無効化されてしまう
|
|
771
|
+
if (isFirstGetItemHeightRunRef.current) {
|
|
772
|
+
isFirstGetItemHeightRunRef.current = false
|
|
773
|
+
return
|
|
774
|
+
}
|
|
724
775
|
if (resetOnGetItemHeightChange) {
|
|
725
776
|
// When configured to reset on logic change, we drop the scroll anchor
|
|
726
777
|
// to avoid sticking to an index that may no longer be relevant contextually.
|
|
@@ -739,7 +790,9 @@ const VirtualScrollInner = <T,>(
|
|
|
739
790
|
// (sampleRangeChanged) が破壊的な tree.reset を発火し、実測済みの全行高さが破棄されてしまう。
|
|
740
791
|
const [initialSampleRange] = useState(() => {
|
|
741
792
|
const SAMPLE_HALF_WINDOW = 50
|
|
742
|
-
|
|
793
|
+
// アンカー復元はアンカー行を、index 復元はその行を窓の中心にする (どちらも「最初に見る領域」)
|
|
794
|
+
const anchorSource = initialScrollAnchor?.index ?? initialScrollIndex
|
|
795
|
+
const anchor = typeof anchorSource === "number" && Number.isFinite(anchorSource) ? Math.max(0, Math.trunc(anchorSource)) : 0
|
|
743
796
|
return { from: Math.max(0, anchor - SAMPLE_HALF_WINDOW), to: anchor + SAMPLE_HALF_WINDOW }
|
|
744
797
|
})
|
|
745
798
|
|
|
@@ -760,7 +813,20 @@ const VirtualScrollInner = <T,>(
|
|
|
760
813
|
const [initialValues] = useState(() => {
|
|
761
814
|
let position = 0
|
|
762
815
|
let total = 0
|
|
763
|
-
if (
|
|
816
|
+
if (initialScrollAnchor && itemCount > 0) {
|
|
817
|
+
// アンカー復元: アンカー行の周辺を具現化し、その行の上端 + offsetPx を初期位置にする。
|
|
818
|
+
// 初回描画のレンジ計算も同じツリー状態から導かれるため、推定誤差の量に関係なく
|
|
819
|
+
// 「アンカー行がちょうど offsetPx 分だけ上に隠れた」画で最初のフレームから出る
|
|
820
|
+
const safeIndex = minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1)
|
|
821
|
+
const anchorOffsetPx = Math.max(0, initialScrollAnchor.offsetPx ?? 0)
|
|
822
|
+
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
823
|
+
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
824
|
+
const options = safeIndex > 0 || anchorOffsetPx > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
|
|
825
|
+
const { cumulative, total: materializedTotal, currentValue } = fenwickTree.prefixSum(safeIndex, options)
|
|
826
|
+
const logicalOffset = Math.max(cumulative - currentValue + anchorOffsetPx, 0)
|
|
827
|
+
position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
|
|
828
|
+
total = materializedTotal ?? fenwickTree.getTotal()
|
|
829
|
+
} else if (typeof initialScrollIndex === "number") {
|
|
764
830
|
const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
|
|
765
831
|
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
766
832
|
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
@@ -1051,7 +1117,10 @@ const VirtualScrollInner = <T,>(
|
|
|
1051
1117
|
didApplyInitialOffsetRef.current = true
|
|
1052
1118
|
// 目的: 初期オフセットを一度だけ適用し (同期処理)、ペインと論理位置を同期させる。
|
|
1053
1119
|
// 依存関係: initialScrollOffset (初回のみ実行されるガード節あり)
|
|
1054
|
-
|
|
1120
|
+
// ❗ initialScrollOffset の適用はそれが「最優先の初期位置指定」である場合のみ。
|
|
1121
|
+
// アンカー/インデックス指定と併用されたとき、この passive effect (描画後) が offset で
|
|
1122
|
+
// 上書きすると「初回描画は index 位置 → 1 コミット後に offset 位置へ跳ぶ」踏み潰しになる
|
|
1123
|
+
if (initialScrollAnchor == null && typeof initialScrollIndex !== "number" && typeof initialScrollOffset === "number") {
|
|
1055
1124
|
const paneOffset = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
|
|
1056
1125
|
const needsPaneSync = Math.abs(paneOffset - latestScrollPositionRef.current) > 0.5
|
|
1057
1126
|
updateScrollPositionImmediate(paneOffset, { immediate: true })
|
|
@@ -1061,7 +1130,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1061
1130
|
} else {
|
|
1062
1131
|
updateScrollPositionImmediate(latestScrollPositionRef.current, { immediate: true })
|
|
1063
1132
|
}
|
|
1064
|
-
}, [initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
|
|
1133
|
+
}, [initialScrollAnchor, initialScrollIndex, initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
|
|
1065
1134
|
|
|
1066
1135
|
useEffect(() => {
|
|
1067
1136
|
// itemCount triggers recalculation of contentSize
|
|
@@ -1305,6 +1374,21 @@ const VirtualScrollInner = <T,>(
|
|
|
1305
1374
|
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
|
|
1306
1375
|
)
|
|
1307
1376
|
|
|
1377
|
+
// アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
|
|
1378
|
+
// 初期値経路 (useState) はマウント時データが前提のため、データ後着の消費者では
|
|
1379
|
+
// アンカーが無言に落ちる。最初の 0 → N 遷移で scrollToIndex 経由で適用する
|
|
1380
|
+
// (アンカー近傍の具現化 + 保留アンカー設定を再利用。初回コミット厳密性はデータが
|
|
1381
|
+
// ある状態でのマウントのみで保証され、遅延適用は近似 → ドリフト補正で自己修復)
|
|
1382
|
+
const deferredInitialAnchorRef = useRef(initialScrollAnchor && itemCount === 0 ? initialScrollAnchor : null)
|
|
1383
|
+
useEffect(() => {
|
|
1384
|
+
const deferred = deferredInitialAnchorRef.current
|
|
1385
|
+
if (!deferred || itemCount === 0) {
|
|
1386
|
+
return
|
|
1387
|
+
}
|
|
1388
|
+
deferredInitialAnchorRef.current = null
|
|
1389
|
+
scrollToIndex(minmax(Math.trunc(deferred.index), 0, itemCount - 1), { align: "top", offset: -Math.max(0, deferred.offsetPx ?? 0) })
|
|
1390
|
+
}, [itemCount, scrollToIndex])
|
|
1391
|
+
|
|
1308
1392
|
/**
|
|
1309
1393
|
* Scrolls to a raw offset while resolving to an index.
|
|
1310
1394
|
*
|
|
@@ -1329,6 +1413,33 @@ const VirtualScrollInner = <T,>(
|
|
|
1329
1413
|
[fenwickTree, itemCount, scrollToIndex],
|
|
1330
1414
|
)
|
|
1331
1415
|
|
|
1416
|
+
/**
|
|
1417
|
+
* Captures the current top-row anchor for exact position restore across remounts.
|
|
1418
|
+
* 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。
|
|
1419
|
+
*
|
|
1420
|
+
* offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
|
|
1421
|
+
* 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
|
|
1422
|
+
* ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
|
|
1423
|
+
* 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
|
|
1424
|
+
*/
|
|
1425
|
+
const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
|
|
1426
|
+
if (itemCount === 0) {
|
|
1427
|
+
return null
|
|
1428
|
+
}
|
|
1429
|
+
const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
|
|
1430
|
+
const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(logical, { materializeOption: { materialize: false } })
|
|
1431
|
+
if (index === -1) {
|
|
1432
|
+
// 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
|
|
1433
|
+
return { index: itemCount - 1, offsetPx: 0 }
|
|
1434
|
+
}
|
|
1435
|
+
if (cumulative === logical) {
|
|
1436
|
+
// 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
|
|
1437
|
+
return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
|
|
1438
|
+
}
|
|
1439
|
+
const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
|
|
1440
|
+
return { index, offsetPx: Math.max(0, logical - itemTop) }
|
|
1441
|
+
}, [fenwickTree, itemCount, resolvedInsets.top])
|
|
1442
|
+
|
|
1332
1443
|
/**
|
|
1333
1444
|
* Imperative scroll entry supporting updater functions.
|
|
1334
1445
|
*
|
|
@@ -1879,9 +1990,10 @@ const VirtualScrollInner = <T,>(
|
|
|
1879
1990
|
getFenwickSize: () => fenwickTree.getSize(),
|
|
1880
1991
|
focusItemAtIndex,
|
|
1881
1992
|
getRange: () => currentRangeRef.current,
|
|
1993
|
+
getScrollAnchor,
|
|
1882
1994
|
updateItemSize,
|
|
1883
1995
|
}),
|
|
1884
|
-
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, updateItemSize],
|
|
1996
|
+
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
|
|
1885
1997
|
)
|
|
1886
1998
|
|
|
1887
1999
|
const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
|