@aiquants/virtualscroll 1.24.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 +1353 -1329
- package/package.json +1 -1
- package/src/VirtualScroll.tsx +101 -6
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
|
|
@@ -703,6 +722,7 @@ const VirtualScrollInner = <T,>(
|
|
|
703
722
|
background,
|
|
704
723
|
initialScrollIndex,
|
|
705
724
|
initialScrollOffset,
|
|
725
|
+
initialScrollAnchor,
|
|
706
726
|
callbackThrottleMs = 5,
|
|
707
727
|
contentInsets,
|
|
708
728
|
onItemFocus,
|
|
@@ -726,18 +746,32 @@ const VirtualScrollInner = <T,>(
|
|
|
726
746
|
scrollPosition: 0,
|
|
727
747
|
totalHeight: 0,
|
|
728
748
|
})
|
|
749
|
+
// アンカー付きマウントは保留アンカーを最初から種まきする: マウント後の実測反映 (contentSize
|
|
750
|
+
// コミット) のたびにドリフト補正 effect が同じ行へ再ピン留めし、自己修復する。ユーザーの
|
|
751
|
+
// 手動スクロールで解除される (handleScroll 参照)。offset は scrollToIndex と同じ「減算」規約の
|
|
752
|
+
// ため符号を反転して持つ (offsetPx = 行上端がビューポート上端より上に隠れる px、正の値)
|
|
729
753
|
const pendingVisibleStartIndexRef = useRef<{
|
|
730
754
|
index: number
|
|
731
755
|
align?: "top" | "bottom" | "center"
|
|
732
756
|
offset?: number
|
|
733
|
-
} | 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)
|
|
734
758
|
// 「アンマウント済みか」を追跡する (初期 false)。「マウント済みか」の正ガードだと、初回レンダー中に
|
|
735
759
|
// 予約された高さ照合マイクロタスクが passive effect (マウントフラグ設定) より先に実行されて破棄される。
|
|
736
760
|
const isUnmountedRef = useRef(false)
|
|
737
761
|
|
|
762
|
+
// 初回実行スキップ用 (マウント時に走る effect 本体が、直前で種まきした初期アンカーを消さないため)
|
|
763
|
+
const isFirstGetItemHeightRunRef = useRef(true)
|
|
738
764
|
useEffect(() => {
|
|
739
765
|
// 目的: アイテムの高さ取得ロジックがリセットされた場合に、古いインデックスへのアンカーを解除する。
|
|
740
766
|
// 依存関係: resetOnGetItemHeightChange, getItemHeight
|
|
767
|
+
// ❗ 初回 (マウント) は解除しない: このフラグの意味は「getItemHeight の**変更**時に解除」で
|
|
768
|
+
// あり、マウント時の実行で解除すると initialScrollAnchor が種まきした保留アンカー
|
|
769
|
+
// (実測反映ごとの自己修復再ピン留め) が resetOnGetItemHeightChange: true の消費者で
|
|
770
|
+
// 無言に無効化されてしまう
|
|
771
|
+
if (isFirstGetItemHeightRunRef.current) {
|
|
772
|
+
isFirstGetItemHeightRunRef.current = false
|
|
773
|
+
return
|
|
774
|
+
}
|
|
741
775
|
if (resetOnGetItemHeightChange) {
|
|
742
776
|
// When configured to reset on logic change, we drop the scroll anchor
|
|
743
777
|
// to avoid sticking to an index that may no longer be relevant contextually.
|
|
@@ -756,7 +790,9 @@ const VirtualScrollInner = <T,>(
|
|
|
756
790
|
// (sampleRangeChanged) が破壊的な tree.reset を発火し、実測済みの全行高さが破棄されてしまう。
|
|
757
791
|
const [initialSampleRange] = useState(() => {
|
|
758
792
|
const SAMPLE_HALF_WINDOW = 50
|
|
759
|
-
|
|
793
|
+
// アンカー復元はアンカー行を、index 復元はその行を窓の中心にする (どちらも「最初に見る領域」)
|
|
794
|
+
const anchorSource = initialScrollAnchor?.index ?? initialScrollIndex
|
|
795
|
+
const anchor = typeof anchorSource === "number" && Number.isFinite(anchorSource) ? Math.max(0, Math.trunc(anchorSource)) : 0
|
|
760
796
|
return { from: Math.max(0, anchor - SAMPLE_HALF_WINDOW), to: anchor + SAMPLE_HALF_WINDOW }
|
|
761
797
|
})
|
|
762
798
|
|
|
@@ -777,7 +813,20 @@ const VirtualScrollInner = <T,>(
|
|
|
777
813
|
const [initialValues] = useState(() => {
|
|
778
814
|
let position = 0
|
|
779
815
|
let total = 0
|
|
780
|
-
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") {
|
|
781
830
|
const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
|
|
782
831
|
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
783
832
|
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
@@ -1068,7 +1117,10 @@ const VirtualScrollInner = <T,>(
|
|
|
1068
1117
|
didApplyInitialOffsetRef.current = true
|
|
1069
1118
|
// 目的: 初期オフセットを一度だけ適用し (同期処理)、ペインと論理位置を同期させる。
|
|
1070
1119
|
// 依存関係: initialScrollOffset (初回のみ実行されるガード節あり)
|
|
1071
|
-
|
|
1120
|
+
// ❗ initialScrollOffset の適用はそれが「最優先の初期位置指定」である場合のみ。
|
|
1121
|
+
// アンカー/インデックス指定と併用されたとき、この passive effect (描画後) が offset で
|
|
1122
|
+
// 上書きすると「初回描画は index 位置 → 1 コミット後に offset 位置へ跳ぶ」踏み潰しになる
|
|
1123
|
+
if (initialScrollAnchor == null && typeof initialScrollIndex !== "number" && typeof initialScrollOffset === "number") {
|
|
1072
1124
|
const paneOffset = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
|
|
1073
1125
|
const needsPaneSync = Math.abs(paneOffset - latestScrollPositionRef.current) > 0.5
|
|
1074
1126
|
updateScrollPositionImmediate(paneOffset, { immediate: true })
|
|
@@ -1078,7 +1130,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1078
1130
|
} else {
|
|
1079
1131
|
updateScrollPositionImmediate(latestScrollPositionRef.current, { immediate: true })
|
|
1080
1132
|
}
|
|
1081
|
-
}, [initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
|
|
1133
|
+
}, [initialScrollAnchor, initialScrollIndex, initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
|
|
1082
1134
|
|
|
1083
1135
|
useEffect(() => {
|
|
1084
1136
|
// itemCount triggers recalculation of contentSize
|
|
@@ -1322,6 +1374,21 @@ const VirtualScrollInner = <T,>(
|
|
|
1322
1374
|
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
|
|
1323
1375
|
)
|
|
1324
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
|
+
|
|
1325
1392
|
/**
|
|
1326
1393
|
* Scrolls to a raw offset while resolving to an index.
|
|
1327
1394
|
*
|
|
@@ -1346,6 +1413,33 @@ const VirtualScrollInner = <T,>(
|
|
|
1346
1413
|
[fenwickTree, itemCount, scrollToIndex],
|
|
1347
1414
|
)
|
|
1348
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
|
+
|
|
1349
1443
|
/**
|
|
1350
1444
|
* Imperative scroll entry supporting updater functions.
|
|
1351
1445
|
*
|
|
@@ -1896,9 +1990,10 @@ const VirtualScrollInner = <T,>(
|
|
|
1896
1990
|
getFenwickSize: () => fenwickTree.getSize(),
|
|
1897
1991
|
focusItemAtIndex,
|
|
1898
1992
|
getRange: () => currentRangeRef.current,
|
|
1993
|
+
getScrollAnchor,
|
|
1899
1994
|
updateItemSize,
|
|
1900
1995
|
}),
|
|
1901
|
-
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, updateItemSize],
|
|
1996
|
+
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
|
|
1902
1997
|
)
|
|
1903
1998
|
|
|
1904
1999
|
const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
|