@aiquants/virtualscroll 1.18.5 → 1.19.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/README.md +20 -13
- package/dist/ScrollPane.d.cts +2 -0
- package/dist/ScrollPane.d.ts +2 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +2 -0
- package/dist/VirtualScroll.d.ts +2 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1115 -1112
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +3 -0
- package/package.json +6 -4
- package/src/ScrollBar.spec.tsx +620 -0
- package/src/ScrollBar.tsx +1397 -0
- package/src/ScrollPane.spec.tsx +482 -0
- package/src/ScrollPane.tsx +913 -0
- package/src/TapScrollCircle.spec.tsx +275 -0
- package/src/TapScrollCircle.tsx +363 -0
- package/src/VirtualScroll.spec.ts +623 -0
- package/src/VirtualScroll.tsx +1891 -0
- package/src/cli.server.spec.ts +137 -0
- package/src/cli.server.ts +110 -0
- package/src/index.ts +24 -0
- package/src/logger.spec.ts +128 -0
- package/src/logger.ts +229 -0
- package/src/styles/components.entry.css +7 -0
- package/src/styles/standalone.entry.css +11 -0
- package/src/styles/virtualscroll.css +298 -0
- package/src/tapScrollCircleSampleVisual.tsx +74 -0
- package/src/useFenwickMapTree.huge.spec.ts +388 -0
- package/src/useFenwickMapTree.spec.ts +1518 -0
- package/src/useFenwickMapTree.ts +1368 -0
- package/src/useHeightCache.ts +32 -0
- package/src/useLruCache.spec.ts +382 -0
- package/src/useLruCache.ts +301 -0
- package/src/utils.spec.ts +39 -0
- package/src/utils.ts +16 -0
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { Logger } from "./logger.ts"
|
|
3
|
+
import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
|
|
4
|
+
import { useFenwickMapTree } from "./useFenwickMapTree.ts"
|
|
5
|
+
import { minmax } from "./utils.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Represents the current state of the rendered range and scroll metrics.
|
|
9
|
+
*
|
|
10
|
+
* 現在のレンダリング範囲とスクロールメトリクスの状態を表します。
|
|
11
|
+
*/
|
|
12
|
+
export type VirtualScrollRange = {
|
|
13
|
+
/** Start index for rendering (includes overscan) / 描画開始インデックス(オーバースキャン含む) */
|
|
14
|
+
renderingStartIndex: number
|
|
15
|
+
/** End index for rendering (includes overscan) / 描画終了インデックス(オーバースキャン含む) */
|
|
16
|
+
renderingEndIndex: number
|
|
17
|
+
/** Index of the first item strictly visible in the viewport / ビューポート内に見えている最初のアイテムのインデックス */
|
|
18
|
+
visibleStartIndex: number
|
|
19
|
+
/** Index of the last item strictly visible in the viewport / ビューポート内に見えている最後のアイテムのインデックス */
|
|
20
|
+
visibleEndIndex: number
|
|
21
|
+
/** Current logical scroll position / 現在の論理スクロール位置 */
|
|
22
|
+
scrollPosition: number
|
|
23
|
+
/** Total height of the scroll content / スクロールコンテンツの総高さ */
|
|
24
|
+
totalHeight: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Handle interface for controlling the VirtualScroll component externally.
|
|
29
|
+
*
|
|
30
|
+
* 外部から VirtualScroll コンポーネントを制御するためのハンドルインターフェース。
|
|
31
|
+
*/
|
|
32
|
+
export type VirtualScrollHandle = ScrollPaneHandle & {
|
|
33
|
+
/** Scrolls to a specific item index / 指定したアイテムインデックスへスクロール */
|
|
34
|
+
scrollToIndex: (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => void
|
|
35
|
+
/** Gets the total height managed by the Fenwick Tree / Fenwick Tree で管理されている総高さを取得 */
|
|
36
|
+
getFenwickTreeTotalHeight: () => number
|
|
37
|
+
/** Gets the number of items in the Fenwick Tree / Fenwick Tree 内のアイテム数を取得 */
|
|
38
|
+
getFenwickSize: () => number
|
|
39
|
+
/** Focuses an item at the specified index / 指定したインデックスのアイテムにフォーカス */
|
|
40
|
+
focusItemAtIndex: (index: number, options?: { ensureVisible?: boolean }) => void
|
|
41
|
+
/** Gets the current scroll range information / 現在のスクロール範囲情報を取得 */
|
|
42
|
+
getRange: () => VirtualScrollRange
|
|
43
|
+
/**
|
|
44
|
+
* Manually updates the size of a specific item. Contract: after calling this,
|
|
45
|
+
* `getItemHeight(index)` must return the same `size`; `getItemHeight` is the source of truth,
|
|
46
|
+
* so if it keeps returning the old value, rows inside the current rendering window (including
|
|
47
|
+
* overscan) are reverted to the `getItemHeight` value by height reconciliation on the next render.
|
|
48
|
+
*
|
|
49
|
+
* 特定のアイテムのサイズを手動で更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を
|
|
50
|
+
* 返すこと。`getItemHeight` が正であるため、旧値を返し続けると描画ウィンドウ (オーバースキャン
|
|
51
|
+
* 含む) 内の行は次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。
|
|
52
|
+
*/
|
|
53
|
+
updateItemSize: (index: number, size: number) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type VirtualScrollScrollBarOptions = {
|
|
57
|
+
width?: number
|
|
58
|
+
enableThumbDrag?: boolean
|
|
59
|
+
enableTrackClick?: boolean
|
|
60
|
+
enableArrowButtons?: boolean
|
|
61
|
+
enableScrollToTopBottomButtons?: boolean
|
|
62
|
+
renderThumbOverlay?: ScrollPaneProps["renderThumbOverlay"]
|
|
63
|
+
tapScrollCircleOptions?: ScrollPaneProps["tapScrollCircleOptions"]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type VirtualScrollBehaviorOptions = {
|
|
67
|
+
enablePointerDrag?: boolean
|
|
68
|
+
enableKeyboardNavigation?: boolean
|
|
69
|
+
wheelSpeedMultiplier?: number
|
|
70
|
+
inertiaOptions?: ScrollPaneProps["inertiaOptions"]
|
|
71
|
+
clipItemHeight?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* When `true`, the underlying Fenwick tree fully re-samples on every `getItemHeight` change
|
|
74
|
+
* instead of applying an incremental tail resize.
|
|
75
|
+
*
|
|
76
|
+
* Set this to `true` whenever an `itemCount` change may represent a middle insertion/deletion
|
|
77
|
+
* (not just a tail append/remove): an incremental resize assumes items are added/removed at the
|
|
78
|
+
* tail and keeps each existing index's measured height, so a middle insert would leave the
|
|
79
|
+
* `getItemHeight` index↔height mapping applied to the wrong rows. Alternatively, remount
|
|
80
|
+
* VirtualScroll with a new `key` when the list structure changes.
|
|
81
|
+
*
|
|
82
|
+
* `true` のとき、内部 Fenwick 木は `getItemHeight` 変更のたびに増分的な末尾リサイズではなく全面
|
|
83
|
+
* 再サンプリングを行う。`itemCount` の変更が末尾追記/削除ではなく中間挿入/削除を含みうる場合は
|
|
84
|
+
* `true` にすること。増分リサイズは要素が末尾で増減する前提で各既存インデックスの実測高さを保持する
|
|
85
|
+
* ため、中間挿入があると `getItemHeight` の index↔高さ 対応が誤った行に適用されてしまう。代替として、
|
|
86
|
+
* リスト構造が変わるときに新しい `key` で VirtualScroll を再マウントしてもよい。
|
|
87
|
+
*/
|
|
88
|
+
resetOnGetItemHeightChange?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Props for the VirtualScroll component.
|
|
93
|
+
*
|
|
94
|
+
* VirtualScroll コンポーネントの Props。
|
|
95
|
+
*/
|
|
96
|
+
export type VirtualScrollProps<T> = {
|
|
97
|
+
itemCount: number
|
|
98
|
+
getItem: (index: number) => T
|
|
99
|
+
getItemKey?: (index: number) => React.Key
|
|
100
|
+
/**
|
|
101
|
+
* Returns the height of the item at `index`. The index↔height mapping must be stable for a
|
|
102
|
+
* given list ordering: when `itemCount` grows/shrinks, VirtualScroll assumes items are
|
|
103
|
+
* appended to / removed from the tail and preserves the measured height of each existing index.
|
|
104
|
+
* If a list mutation inserts or removes items in the middle (shifting later indices), enable
|
|
105
|
+
* `behaviorOptions.resetOnGetItemHeightChange` or remount with a new `key`; otherwise cached
|
|
106
|
+
* heights stay attached to indices that now point at different items.
|
|
107
|
+
*
|
|
108
|
+
* `index` のアイテム高さを返す。index↔高さ の対応は同じ並び順の間は安定である必要がある。`itemCount`
|
|
109
|
+
* が増減するとき VirtualScroll は要素が末尾で追加/削除されると仮定し、既存インデックスの実測高さを
|
|
110
|
+
* 保持する。中間挿入/削除 (以降のインデックスがずれる) を伴うリスト変更では
|
|
111
|
+
* `behaviorOptions.resetOnGetItemHeightChange` を有効化するか新しい `key` で再マウントすること。
|
|
112
|
+
* さもないとキャッシュ済み高さが別のアイテムを指すインデックスに残る。
|
|
113
|
+
*/
|
|
114
|
+
getItemHeight: (index: number) => number
|
|
115
|
+
viewportSize: number
|
|
116
|
+
overscanCount?: number
|
|
117
|
+
className?: string
|
|
118
|
+
/** Test id emitted as data-testid on the scroll root (DOM hooks must use data-* attributes, never class selectors). / スクロールルートに data-testid として出力されるテスト ID (DOM フックはクラスセレクタでなく data-* 属性を使う)。 */
|
|
119
|
+
testId?: string
|
|
120
|
+
onScroll?: (scrollPosition: number, totalHeight: number) => void
|
|
121
|
+
onRangeChange?: (range: VirtualScrollRange) => void
|
|
122
|
+
background?: ReactNode
|
|
123
|
+
children: (item: T, index: number) => ReactNode
|
|
124
|
+
initialScrollIndex?: number
|
|
125
|
+
initialScrollOffset?: number
|
|
126
|
+
callbackThrottleMs?: number
|
|
127
|
+
contentInsets?: ScrollPaneProps["contentInsets"]
|
|
128
|
+
onItemFocus?: (index: number) => void
|
|
129
|
+
scrollBarOptions?: VirtualScrollScrollBarOptions
|
|
130
|
+
behaviorOptions?: VirtualScrollBehaviorOptions
|
|
131
|
+
/** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。 */
|
|
132
|
+
onWheelHorizontal?: (deltaX: number) => void
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalizes index values to valid bounds.
|
|
137
|
+
*
|
|
138
|
+
* インデックス値を安全な範囲に丸める。
|
|
139
|
+
*/
|
|
140
|
+
const sanitizeIndex = (value: number, size: number) => (size <= 0 ? 0 : minmax(value, 0, size - 1))
|
|
141
|
+
|
|
142
|
+
const normalizeInsets = (insets?: ScrollPaneContentInsets): Required<ScrollPaneContentInsets> => ({
|
|
143
|
+
top: Math.max(0, insets?.top ?? 0),
|
|
144
|
+
bottom: Math.max(0, insets?.bottom ?? 0),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const toLogicalPositionWithInset = (pane: number, top: number) => (pane <= top ? 0 : pane - top)
|
|
148
|
+
|
|
149
|
+
const toPanePositionWithInset = (logical: number, top: number) => (logical <= 0 ? top : logical + top)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Maximum run length of consecutive zero-height rows scanned linearly before attempting an
|
|
153
|
+
* O(log n) jump to the next non-zero row via the Fenwick tree.
|
|
154
|
+
*
|
|
155
|
+
* 高さ 0 行の連続をこの件数まで線形走査し、超えたら Fenwick 木で次の非 0 行へ O(log n) ジャンプする閾値。
|
|
156
|
+
*/
|
|
157
|
+
const ZERO_HEIGHT_RUN_LIMIT = 1000
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Safety cap for the number of item nodes materialized in a single render pass.
|
|
161
|
+
* Guards against pathological rendering ranges (e.g. a huge contiguous run of zero-height rows).
|
|
162
|
+
*
|
|
163
|
+
* 1 回のレンダーで具現化するアイテムノード数の安全上限。
|
|
164
|
+
* 病的な描画範囲 (巨大な高さ 0 行の連続など) からの防御。
|
|
165
|
+
*/
|
|
166
|
+
const MAX_RENDERED_ITEMS = 2000
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Distance (px) between the rendering window start and the current render anchor beyond which
|
|
170
|
+
* the anchor is re-based. Keeps row `top` values and the wrapper `translateY` small enough to
|
|
171
|
+
* stay within browser layout (LayoutUnit ~2^25 px) and compositor f32 precision limits.
|
|
172
|
+
*
|
|
173
|
+
* 描画ウィンドウ先頭と現在の描画アンカーの距離がこの値 (px) を超えたらアンカーを再基準化する。
|
|
174
|
+
* 行 top とラッパー translateY をブラウザのレイアウト座標上限 (LayoutUnit 約 2^25 px) と
|
|
175
|
+
* compositor の f32 精度内に収めるための量子化距離。
|
|
176
|
+
*/
|
|
177
|
+
const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Converts a numeric size into a non-negative bigint for large collection handling.
|
|
181
|
+
*
|
|
182
|
+
* 大規模コレクション向けに数値サイズを非負の bigint へ変換。
|
|
183
|
+
*/
|
|
184
|
+
const toSafeBigInt = (value: number): bigint => {
|
|
185
|
+
if (!Number.isFinite(value)) return 0n
|
|
186
|
+
const truncated = Math.trunc(value)
|
|
187
|
+
return truncated <= 0 ? 0n : BigInt(truncated)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Computes rendering ranges for collections exceeding Number.MAX_SAFE_INTEGER.
|
|
192
|
+
* Uses BigInt math to handle positions that cannot be represented by standard JavaScript numbers.
|
|
193
|
+
*
|
|
194
|
+
* Number.MAX_SAFE_INTEGER を超える巨大なコレクションに対して描画範囲を算出します。
|
|
195
|
+
* 標準的な JavaScript 数値では表現できない位置を扱うために、内部計算には BigInt を使用します。
|
|
196
|
+
*
|
|
197
|
+
* @param effectiveScrollPosition Clamped scroll position / 丸められた有効なスクロール位置
|
|
198
|
+
* @param viewportSize Height of the viewport / ビューポートの高さ
|
|
199
|
+
* @param overscanCount Number of items to render outside the viewport / ビューポート外に描画するアイテム数
|
|
200
|
+
* @param itemSize Total number of items / 総アイテム数
|
|
201
|
+
* @param getItemHeight Function to get height of an item / アイテムの高さを取得する関数
|
|
202
|
+
* @param fenwickTree Fenwick Tree instance / Fenwick Tree インスタンス
|
|
203
|
+
* @param totalHeight Total content height / コンテンツ総高さ
|
|
204
|
+
* @param hasFiniteTotal Whether the total height is finite / 総高さが有限かどうか
|
|
205
|
+
*/
|
|
206
|
+
const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number, hasFiniteTotal: boolean) => {
|
|
207
|
+
const sizeBig = toSafeBigInt(itemSize)
|
|
208
|
+
if (sizeBig === 0n) {
|
|
209
|
+
return { renderingStartIndex: 0, renderingEndIndex: 0, visibleStartIndex: 0, visibleEndIndex: 0 }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const clampBig = (value: bigint): bigint => {
|
|
213
|
+
if (value < 0n) {
|
|
214
|
+
return 0n
|
|
215
|
+
}
|
|
216
|
+
if (value >= sizeBig) {
|
|
217
|
+
return sizeBig - 1n
|
|
218
|
+
}
|
|
219
|
+
return value
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const materializeOption = { materializeOption: { materialize: false } }
|
|
223
|
+
const { index: rawStartIndex, cumulative } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, materializeOption)
|
|
224
|
+
|
|
225
|
+
let startBig: bigint
|
|
226
|
+
if (rawStartIndex === -1) {
|
|
227
|
+
startBig = sizeBig - 1n
|
|
228
|
+
} else {
|
|
229
|
+
if (cumulative === effectiveScrollPosition) {
|
|
230
|
+
startBig = toSafeBigInt(rawStartIndex + 1)
|
|
231
|
+
} else {
|
|
232
|
+
startBig = toSafeBigInt(rawStartIndex)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (startBig >= sizeBig) {
|
|
236
|
+
startBig = sizeBig - 1n
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (effectiveScrollPosition <= 0) {
|
|
241
|
+
startBig = 0n
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (hasFiniteTotal && effectiveScrollPosition >= totalHeight) {
|
|
245
|
+
startBig = sizeBig - 1n
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 高さ 0 (折りたたみ行) はスキップして走査を継続する (散発的な 0 行は正当な入力)。
|
|
249
|
+
// 走査を打ち切るのは負値・非有限の高さ (不正入力) のみ。0 行が大量に連続する場合は
|
|
250
|
+
// 連続数が閾値を超えた時点で Fenwick 木により次の非 0 行へ O(log n) でジャンプし、有界性を保つ。
|
|
251
|
+
const jumpPastZeroRun = (lastScanned: bigint): bigint | null => {
|
|
252
|
+
const lastNumber = Number(lastScanned)
|
|
253
|
+
if (!Number.isSafeInteger(lastNumber)) {
|
|
254
|
+
// number へ安全に変換できない領域では木のクエリが不正確になるため打ち切る
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
const { cumulative: runBottom } = fenwickTree.prefixSum(lastNumber, { materializeOption: { materialize: false } })
|
|
258
|
+
if (!Number.isFinite(runBottom)) {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom + 0.5, { materializeOption: { materialize: false } })
|
|
262
|
+
if (jumpIndex === -1) {
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
const jumpBig = toSafeBigInt(jumpIndex)
|
|
266
|
+
return jumpBig > lastScanned ? jumpBig : null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const accumulateForward = (initial: bigint) => {
|
|
270
|
+
let height = 0
|
|
271
|
+
let cursor = initial
|
|
272
|
+
let last = initial
|
|
273
|
+
let iterations = 0n
|
|
274
|
+
let zeroRun = 0
|
|
275
|
+
while (cursor < sizeBig && height < viewportSize) {
|
|
276
|
+
const cursorNumber = Number(cursor)
|
|
277
|
+
const currentHeight = getItemHeight(cursorNumber)
|
|
278
|
+
height += currentHeight
|
|
279
|
+
last = cursor
|
|
280
|
+
cursor += 1n
|
|
281
|
+
iterations += 1n
|
|
282
|
+
if (!Number.isFinite(currentHeight) || currentHeight < 0) {
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
if (currentHeight === 0) {
|
|
286
|
+
zeroRun += 1
|
|
287
|
+
if (zeroRun > ZERO_HEIGHT_RUN_LIMIT) {
|
|
288
|
+
const jumped = jumpPastZeroRun(last)
|
|
289
|
+
if (jumped !== null && jumped > cursor) {
|
|
290
|
+
// 0 行の連続を飛び越えて次の非 0 行から走査を続行する
|
|
291
|
+
cursor = jumped
|
|
292
|
+
zeroRun = 0
|
|
293
|
+
} else if (jumped !== null && jumped === cursor && getItemHeight(Number(cursor)) > 0) {
|
|
294
|
+
// 連続 0 行がちょうどここで終わる: 次の行は非 0 なので通常走査を続行する
|
|
295
|
+
zeroRun = 0
|
|
296
|
+
} else {
|
|
297
|
+
// ジャンプ先が無い/前進しない (木と getItemHeight の不一致など) は打ち切り、有界性を優先する
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
zeroRun = 0
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (iterations === 0n) {
|
|
306
|
+
last = initial
|
|
307
|
+
}
|
|
308
|
+
return { height, end: last }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let { height: forwardHeight, end: forwardEnd } = accumulateForward(startBig)
|
|
312
|
+
|
|
313
|
+
if (forwardHeight < viewportSize && startBig > 0n) {
|
|
314
|
+
let backwardStart = startBig
|
|
315
|
+
let backwardHeight = forwardHeight
|
|
316
|
+
let backwardZeroRun = 0
|
|
317
|
+
while (backwardStart > 0n && backwardHeight < viewportSize) {
|
|
318
|
+
backwardStart -= 1n
|
|
319
|
+
const indexNumber = Number(backwardStart)
|
|
320
|
+
const itemHeight = getItemHeight(indexNumber)
|
|
321
|
+
backwardHeight += itemHeight
|
|
322
|
+
if (!Number.isFinite(itemHeight) || itemHeight < 0) {
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
if (itemHeight === 0) {
|
|
326
|
+
backwardZeroRun += 1
|
|
327
|
+
if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
|
|
328
|
+
// 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
|
|
329
|
+
const indexNum = Number(backwardStart)
|
|
330
|
+
if (!Number.isSafeInteger(indexNum)) {
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
const { cumulative: runBottom } = fenwickTree.prefixSum(indexNum, { materializeOption: { materialize: false } })
|
|
334
|
+
if (!Number.isFinite(runBottom)) {
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, { materializeOption: { materialize: false } })
|
|
338
|
+
const jumpBig = jumpIndex === -1 ? -1n : toSafeBigInt(jumpIndex)
|
|
339
|
+
if (jumpBig >= 0n && jumpBig < backwardStart) {
|
|
340
|
+
// 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
|
|
341
|
+
backwardStart = jumpBig + 1n
|
|
342
|
+
backwardZeroRun = 0
|
|
343
|
+
} else if (jumpBig === backwardStart && backwardStart >= 1n && getItemHeight(Number(backwardStart - 1n)) > 0) {
|
|
344
|
+
// 連続 0 行がちょうどここで終わる: 次 (下方向) の行は非 0 なので通常走査を続行する
|
|
345
|
+
backwardZeroRun = 0
|
|
346
|
+
} else {
|
|
347
|
+
// ジャンプ先が無い/前進しない (木と getItemHeight の不一致など) は打ち切り、有界性を優先する
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
backwardZeroRun = 0
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
startBig = clampBig(backwardStart)
|
|
356
|
+
const forward = accumulateForward(startBig)
|
|
357
|
+
forwardHeight = forward.height
|
|
358
|
+
forwardEnd = forward.end
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const visibleStartBig = clampBig(startBig)
|
|
362
|
+
const visibleEndBig = clampBig(forwardEnd)
|
|
363
|
+
const renderingStartBig = clampBig(visibleStartBig - BigInt(Math.max(0, overscanCount)))
|
|
364
|
+
const renderingEndBig = clampBig(visibleEndBig + BigInt(Math.max(0, overscanCount)))
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
renderingStartIndex: sanitizeIndex(Number(renderingStartBig), itemSize),
|
|
368
|
+
renderingEndIndex: sanitizeIndex(Number(renderingEndBig), itemSize),
|
|
369
|
+
visibleStartIndex: sanitizeIndex(Number(visibleStartBig), itemSize),
|
|
370
|
+
visibleEndIndex: sanitizeIndex(Number(visibleEndBig), itemSize),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Retrieves a high-resolution timestamp when available.
|
|
376
|
+
*
|
|
377
|
+
* 高精度タイムスタンプを可能なら取得。
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* Calculates rendering boundaries from current scroll metrics.
|
|
381
|
+
*
|
|
382
|
+
* 現在のスクロール情報から描画範囲を算出。
|
|
383
|
+
*/
|
|
384
|
+
export const computeRenderingRanges = (scrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number) => {
|
|
385
|
+
if (itemSize === 0) {
|
|
386
|
+
return { renderingStartIndex: 0, renderingEndIndex: 0, visibleStartIndex: 0, visibleEndIndex: 0 }
|
|
387
|
+
}
|
|
388
|
+
const hasFiniteTotal = Number.isFinite(totalHeight)
|
|
389
|
+
const effectiveScrollPosition = hasFiniteTotal ? Math.min(Math.max(0, scrollPosition), totalHeight) : Math.max(0, scrollPosition)
|
|
390
|
+
if (itemSize >= Number.MAX_SAFE_INTEGER) {
|
|
391
|
+
return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree, totalHeight, hasFiniteTotal)
|
|
392
|
+
}
|
|
393
|
+
const { index: rawStartIndex, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, { materializeOption: { materialize: false } })
|
|
394
|
+
const startIndex =
|
|
395
|
+
rawStartIndex === -1
|
|
396
|
+
? (() => {
|
|
397
|
+
// Fenwick がインデックスを返さない場合は末尾に合わせて開始位置を再計算
|
|
398
|
+
if (viewportSize <= 0) {
|
|
399
|
+
return itemSize - 1
|
|
400
|
+
}
|
|
401
|
+
return (cumulative ?? 0) < effectiveScrollPosition + (currentValue ?? 0) ? itemSize - 1 : 0
|
|
402
|
+
})()
|
|
403
|
+
: rawStartIndex
|
|
404
|
+
let visibleStartIndex = sanitizeIndex(startIndex, itemSize)
|
|
405
|
+
|
|
406
|
+
let visibleHeight = 0
|
|
407
|
+
if (rawStartIndex !== -1 && cumulative === effectiveScrollPosition) {
|
|
408
|
+
visibleStartIndex = sanitizeIndex(rawStartIndex + 1, itemSize)
|
|
409
|
+
visibleHeight = 0
|
|
410
|
+
} else if (visibleStartIndex === rawStartIndex && cumulative !== undefined && currentValue !== undefined) {
|
|
411
|
+
const itemTop = cumulative - currentValue
|
|
412
|
+
visibleHeight = itemTop - effectiveScrollPosition
|
|
413
|
+
} else {
|
|
414
|
+
const { cumulative: startCumulative, currentValue: startHeight } = fenwickTree.prefixSum(visibleStartIndex, { materializeOption: { materialize: false } })
|
|
415
|
+
const itemTop = (startCumulative ?? 0) - (startHeight ?? 0)
|
|
416
|
+
visibleHeight = itemTop - effectiveScrollPosition
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const initialOffset = visibleHeight
|
|
420
|
+
|
|
421
|
+
// 高さ 0 (折りたたみ行・区切り行) はスキップして走査を継続する (散発的な 0 行は正当な入力)。
|
|
422
|
+
// 走査を打ち切るのは負値・非有限の高さ (不正入力) のみ。0 行が大量に連続する場合でも
|
|
423
|
+
// 走査が全件に膨れ上がらないよう、連続数が閾値を超えたら Fenwick 木の findIndexAtOrAfter で
|
|
424
|
+
// 次の非 0 行へ O(log n) でジャンプして続行する。ジャンプが前進しない場合 (木と
|
|
425
|
+
// getItemHeight の不一致など) は打ち切り、有界性を優先する。
|
|
426
|
+
const scanForward = (startIndex: number, initialHeight: number) => {
|
|
427
|
+
let forwardCursor = startIndex
|
|
428
|
+
let height = initialHeight
|
|
429
|
+
let zeroRun = 0
|
|
430
|
+
while (forwardCursor < itemSize && height < viewportSize) {
|
|
431
|
+
const currentHeight = getItemHeight(forwardCursor)
|
|
432
|
+
height += currentHeight
|
|
433
|
+
forwardCursor++
|
|
434
|
+
if (!Number.isFinite(currentHeight) || currentHeight < 0) {
|
|
435
|
+
break
|
|
436
|
+
}
|
|
437
|
+
if (currentHeight === 0) {
|
|
438
|
+
zeroRun++
|
|
439
|
+
if (zeroRun > ZERO_HEIGHT_RUN_LIMIT) {
|
|
440
|
+
const { cumulative: runBottom } = fenwickTree.prefixSum(forwardCursor - 1, { materializeOption: { materialize: false } })
|
|
441
|
+
const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom + 0.5, { materializeOption: { materialize: false } }) : { index: -1 }
|
|
442
|
+
if (jumpIndex > forwardCursor) {
|
|
443
|
+
// 0 行の連続を飛び越えて次の非 0 行から走査を続行する
|
|
444
|
+
forwardCursor = jumpIndex
|
|
445
|
+
zeroRun = 0
|
|
446
|
+
} else if (jumpIndex === forwardCursor && getItemHeight(forwardCursor) > 0) {
|
|
447
|
+
// 連続 0 行がちょうどここで終わる: 次の行は非 0 なので通常走査を続行する
|
|
448
|
+
zeroRun = 0
|
|
449
|
+
} else {
|
|
450
|
+
// ジャンプ先が無い/前進しない (木と getItemHeight の不一致など) は打ち切り、有界性を優先する
|
|
451
|
+
break
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
zeroRun = 0
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { cursor: forwardCursor, height }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const forward = scanForward(visibleStartIndex, visibleHeight)
|
|
462
|
+
let cursor = forward.cursor
|
|
463
|
+
visibleHeight = forward.height
|
|
464
|
+
|
|
465
|
+
if (visibleHeight < viewportSize && visibleStartIndex > 0) {
|
|
466
|
+
let backwardHeight = visibleHeight + Math.abs(Math.min(0, initialOffset))
|
|
467
|
+
let backwardCursor = visibleStartIndex - 1
|
|
468
|
+
let backwardZeroRun = 0
|
|
469
|
+
while (backwardCursor >= 0 && backwardHeight < viewportSize) {
|
|
470
|
+
const itemHeight = getItemHeight(backwardCursor)
|
|
471
|
+
backwardHeight += itemHeight
|
|
472
|
+
if (!Number.isFinite(itemHeight) || itemHeight < 0) {
|
|
473
|
+
backwardCursor--
|
|
474
|
+
break
|
|
475
|
+
}
|
|
476
|
+
if (itemHeight === 0) {
|
|
477
|
+
backwardZeroRun++
|
|
478
|
+
if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
|
|
479
|
+
// 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
|
|
480
|
+
const { cumulative: runBottom } = fenwickTree.prefixSum(backwardCursor, { materializeOption: { materialize: false } })
|
|
481
|
+
const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom - 0.5, { materializeOption: { materialize: false } }) : { index: -1 }
|
|
482
|
+
if (jumpIndex !== -1 && jumpIndex < backwardCursor) {
|
|
483
|
+
// 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
|
|
484
|
+
backwardCursor = jumpIndex
|
|
485
|
+
backwardZeroRun = 0
|
|
486
|
+
continue
|
|
487
|
+
}
|
|
488
|
+
if (jumpIndex === backwardCursor && backwardCursor >= 1 && getItemHeight(backwardCursor - 1) > 0) {
|
|
489
|
+
// 連続 0 行がちょうどここで終わる: 次 (下方向) の行は非 0 なので通常走査を続行する
|
|
490
|
+
backwardZeroRun = 0
|
|
491
|
+
} else {
|
|
492
|
+
// ジャンプ先が無い/前進しない (木と getItemHeight の不一致など) は打ち切り、有界性を優先する
|
|
493
|
+
backwardCursor--
|
|
494
|
+
break
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
backwardZeroRun = 0
|
|
499
|
+
}
|
|
500
|
+
backwardCursor--
|
|
501
|
+
}
|
|
502
|
+
visibleStartIndex = sanitizeIndex(backwardCursor + 1, itemSize)
|
|
503
|
+
const rescan = scanForward(visibleStartIndex, 0)
|
|
504
|
+
cursor = rescan.cursor
|
|
505
|
+
visibleHeight = rescan.height
|
|
506
|
+
}
|
|
507
|
+
const renderingStartIndex = sanitizeIndex(visibleStartIndex - overscanCount, itemSize)
|
|
508
|
+
const visibleEndIndex = sanitizeIndex(Math.max(cursor - 1, visibleStartIndex), itemSize)
|
|
509
|
+
const renderingEndIndex = sanitizeIndex(visibleEndIndex + overscanCount, itemSize)
|
|
510
|
+
|
|
511
|
+
return { renderingStartIndex, renderingEndIndex, visibleStartIndex, visibleEndIndex }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Renders a virtualized scroll pane referencing a Fenwick tree for offsets.
|
|
516
|
+
*
|
|
517
|
+
* Fenwick 木でオフセットを管理する仮想スクロールコンテナ。
|
|
518
|
+
*/
|
|
519
|
+
|
|
520
|
+
type OnScrollCallback = (scrollPosition: number, totalHeight: number) => void
|
|
521
|
+
type OnRangeChangeCallback = (range: VirtualScrollRange) => void
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A custom hook that creates a throttled invoker function using requestAnimationFrame.
|
|
525
|
+
* This ensures that the callback is executed at most once per animation frame, plus an optional time throttle.
|
|
526
|
+
* It also handles cleanup of pending animation frames on unmount.
|
|
527
|
+
*
|
|
528
|
+
* requestAnimationFrame を使用してスロットリングされた実行関数を作成するカスタムフック。
|
|
529
|
+
* コールバックがアニメーションフレームごとに最大1回(+オプションの時間スロットル)実行されることを保証します。
|
|
530
|
+
* また、アンマウント時に保留中のアニメーションフレームをクリーンアップします。
|
|
531
|
+
*
|
|
532
|
+
* @param callbackRef Mutable ref to the callback function / コールバック関数への Ref
|
|
533
|
+
* @param throttleMs Minimum time between invocations in ms / 実行間の最小時間(ミリ秒)
|
|
534
|
+
* @param invoke Wrapper function to execute the callback (e.g. for argument unwrapping) / コールバックを実行するラッパー関数
|
|
535
|
+
*/
|
|
536
|
+
const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObject<Callback | undefined>, throttleMs: number | undefined, invoke: (callback: Callback, payload: Payload) => void) => {
|
|
537
|
+
const throttle = Math.max(0, throttleMs ?? 0)
|
|
538
|
+
// last: 最終実行時刻, id: RAF ID, arg: 待機中の引数
|
|
539
|
+
const state = useRef({ last: 0, id: null as number | null, arg: null as Payload | null }).current
|
|
540
|
+
|
|
541
|
+
// クリーンアップ: アンマウント時にRAFをキャンセル
|
|
542
|
+
useEffect(
|
|
543
|
+
() => () => {
|
|
544
|
+
if (state.id !== null) {
|
|
545
|
+
cancelAnimationFrame(state.id)
|
|
546
|
+
state.id = null
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
[state],
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
return useCallback(
|
|
553
|
+
(payload: Payload) => {
|
|
554
|
+
state.arg = payload
|
|
555
|
+
if (state.id !== null) return
|
|
556
|
+
|
|
557
|
+
const loop = (now: number) => {
|
|
558
|
+
state.id = null
|
|
559
|
+
if (state.arg === null) return
|
|
560
|
+
|
|
561
|
+
const elapsed = now - state.last
|
|
562
|
+
if (throttle === 0 || state.last === 0 || elapsed >= throttle) {
|
|
563
|
+
if (callbackRef.current) {
|
|
564
|
+
try {
|
|
565
|
+
invoke(callbackRef.current, state.arg)
|
|
566
|
+
} catch (e) {
|
|
567
|
+
console.error("[useThrottledInvoker] Error invoking callback", e)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
state.arg = null
|
|
571
|
+
state.last = now
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (state.arg !== null) {
|
|
575
|
+
state.id = requestAnimationFrame(loop)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
state.id = requestAnimationFrame(loop)
|
|
579
|
+
},
|
|
580
|
+
[throttle, invoke, callbackRef, state],
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
type VirtualScrollItemProps<T> = {
|
|
585
|
+
index: number
|
|
586
|
+
top: number
|
|
587
|
+
height: number
|
|
588
|
+
item: T
|
|
589
|
+
children: (item: T, index: number) => ReactNode
|
|
590
|
+
clipItemHeight: boolean
|
|
591
|
+
enableKeyboardNavigation: boolean
|
|
592
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>, index: number) => void
|
|
593
|
+
onFocus: (index: number) => void
|
|
594
|
+
registerItemRef: (index: number, node: HTMLDivElement | null) => void
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Renders a single virtualized item.
|
|
599
|
+
* Wraps the user-provided item component with positioning styles and event handlers.
|
|
600
|
+
* Memoized to prevent unnecessary re-renders when props haven't changed.
|
|
601
|
+
*
|
|
602
|
+
* 単一の仮想アイテムをレンダリングするコンポーネント。
|
|
603
|
+
* ユーザーが提供したアイテムコンポーネントを、配置スタイルとイベントハンドラでラップします。
|
|
604
|
+
* Props が変更されていない場合の不要な再レンダリングを防ぐためにメモ化されています。
|
|
605
|
+
*/
|
|
606
|
+
const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children, clipItemHeight, enableKeyboardNavigation, onKeyDown, onFocus, registerItemRef }: VirtualScrollItemProps<T>) => {
|
|
607
|
+
const handleRef = useCallback((node: HTMLDivElement | null) => registerItemRef(index, node), [index, registerItemRef])
|
|
608
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => onKeyDown(e, index), [index, onKeyDown])
|
|
609
|
+
const handleFocus = useCallback(() => onFocus(index), [index, onFocus])
|
|
610
|
+
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
611
|
+
e.currentTarget.focus({ preventScroll: true })
|
|
612
|
+
}, [])
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
<div
|
|
616
|
+
ref={handleRef}
|
|
617
|
+
data-index={index}
|
|
618
|
+
data-virtualscroll-item="true"
|
|
619
|
+
className="aqvs-item-container"
|
|
620
|
+
style={{
|
|
621
|
+
top,
|
|
622
|
+
height,
|
|
623
|
+
overflow: clipItemHeight ? "hidden" : undefined,
|
|
624
|
+
}}
|
|
625
|
+
tabIndex={enableKeyboardNavigation ? -1 : undefined}
|
|
626
|
+
onPointerDown={enableKeyboardNavigation ? handlePointerDown : undefined}
|
|
627
|
+
onKeyDownCapture={enableKeyboardNavigation ? handleKeyDown : undefined}
|
|
628
|
+
onFocusCapture={enableKeyboardNavigation ? handleFocus : undefined}>
|
|
629
|
+
{children(item, index)}
|
|
630
|
+
</div>
|
|
631
|
+
)
|
|
632
|
+
}) as <T>(props: VirtualScrollItemProps<T>) => React.ReactElement
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Renders a virtualized scroll pane referencing a Fenwick tree for offsets.
|
|
636
|
+
*
|
|
637
|
+
* Fenwick 木でオフセットを管理する仮想スクロールコンテナ。
|
|
638
|
+
*/
|
|
639
|
+
const VirtualScrollInner = <T,>(
|
|
640
|
+
{
|
|
641
|
+
itemCount,
|
|
642
|
+
getItem,
|
|
643
|
+
getItemKey,
|
|
644
|
+
getItemHeight,
|
|
645
|
+
viewportSize,
|
|
646
|
+
overscanCount = 15,
|
|
647
|
+
className,
|
|
648
|
+
testId,
|
|
649
|
+
onScroll,
|
|
650
|
+
onRangeChange,
|
|
651
|
+
children,
|
|
652
|
+
background,
|
|
653
|
+
initialScrollIndex,
|
|
654
|
+
initialScrollOffset,
|
|
655
|
+
callbackThrottleMs = 5,
|
|
656
|
+
contentInsets,
|
|
657
|
+
onItemFocus,
|
|
658
|
+
scrollBarOptions,
|
|
659
|
+
behaviorOptions,
|
|
660
|
+
onWheelHorizontal,
|
|
661
|
+
}: VirtualScrollProps<T>,
|
|
662
|
+
ref: React.Ref<VirtualScrollHandle>,
|
|
663
|
+
) => {
|
|
664
|
+
const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
|
|
665
|
+
|
|
666
|
+
const { enablePointerDrag, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
|
|
667
|
+
|
|
668
|
+
const scrollPaneRef = useRef<VirtualScrollHandle>(null)
|
|
669
|
+
const currentRangeRef = useRef<VirtualScrollRange>({
|
|
670
|
+
renderingStartIndex: 0,
|
|
671
|
+
renderingEndIndex: 0,
|
|
672
|
+
visibleStartIndex: 0,
|
|
673
|
+
visibleEndIndex: 0,
|
|
674
|
+
scrollPosition: 0,
|
|
675
|
+
totalHeight: 0,
|
|
676
|
+
})
|
|
677
|
+
const pendingVisibleStartIndexRef = useRef<{
|
|
678
|
+
index: number
|
|
679
|
+
align?: "top" | "bottom" | "center"
|
|
680
|
+
offset?: number
|
|
681
|
+
} | null>(null)
|
|
682
|
+
// 「アンマウント済みか」を追跡する (初期 false)。「マウント済みか」の正ガードだと、初回レンダー中に
|
|
683
|
+
// 予約された高さ照合マイクロタスクが passive effect (マウントフラグ設定) より先に実行されて破棄される。
|
|
684
|
+
const isUnmountedRef = useRef(false)
|
|
685
|
+
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
// 目的: アイテムの高さ取得ロジックがリセットされた場合に、古いインデックスへのアンカーを解除する。
|
|
688
|
+
// 依存関係: resetOnGetItemHeightChange, getItemHeight
|
|
689
|
+
if (resetOnGetItemHeightChange) {
|
|
690
|
+
// When configured to reset on logic change, we drop the scroll anchor
|
|
691
|
+
// to avoid sticking to an index that may no longer be relevant contextually.
|
|
692
|
+
pendingVisibleStartIndexRef.current = null
|
|
693
|
+
}
|
|
694
|
+
}, [resetOnGetItemHeightChange, getItemHeight])
|
|
695
|
+
|
|
696
|
+
const resolvedInsets = useMemo(() => normalizeInsets(contentInsets), [contentInsets])
|
|
697
|
+
|
|
698
|
+
// sampleRange は「初期に具現化するウィンドウ」= 利用者が最初に見る領域 (initialScrollIndex 近傍) を指す。
|
|
699
|
+
// baseValue (未具現化アイテムの総高さ推定) の代表値は、木側がリスト全体にわたる分散 (ストライド)
|
|
700
|
+
// サンプリングで別途決めるため、この sampleRange は baseValue 推定には影響しない。先頭が非代表的な
|
|
701
|
+
// 高さ (ヒーロー行など) でも総高さ推定は多数派へ寄る。
|
|
702
|
+
// sampleRange はマウント時の initialScrollIndex から一度だけ確定する ("initial" prop の意味論)。
|
|
703
|
+
// マウント後の initialScrollIndex 変更を options に反映させると、useFenwickMapTree 側の値比較
|
|
704
|
+
// (sampleRangeChanged) が破壊的な tree.reset を発火し、実測済みの全行高さが破棄されてしまう。
|
|
705
|
+
const [initialSampleRange] = useState(() => {
|
|
706
|
+
const SAMPLE_HALF_WINDOW = 50
|
|
707
|
+
const anchor = typeof initialScrollIndex === "number" && Number.isFinite(initialScrollIndex) ? Math.max(0, Math.trunc(initialScrollIndex)) : 0
|
|
708
|
+
return { from: Math.max(0, anchor - SAMPLE_HALF_WINDOW), to: anchor + SAMPLE_HALF_WINDOW }
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
const fenwickTreeOptions = useMemo(
|
|
712
|
+
// 目的: Fenwick Tree のオプション設定
|
|
713
|
+
// 依存関係: resetOnGetItemHeightChange, initialSampleRange (マウント時固定)
|
|
714
|
+
// itemCount には依存させない (依存させると追記のたびに options 参照が変わり、計測済み高さが全消去される)。
|
|
715
|
+
// この useMemo により sampleRange を毎レンダー新規生成しない = 参照同一性由来の破壊的 reset を防ぐ。
|
|
716
|
+
() => ({
|
|
717
|
+
sampleRange: initialSampleRange,
|
|
718
|
+
resetOnValueFnChange: resetOnGetItemHeightChange,
|
|
719
|
+
materialize: true,
|
|
720
|
+
}),
|
|
721
|
+
[resetOnGetItemHeightChange, initialSampleRange],
|
|
722
|
+
)
|
|
723
|
+
const fenwickTree = useFenwickMapTree(itemCount, getItemHeight, fenwickTreeOptions)
|
|
724
|
+
|
|
725
|
+
const [initialValues] = useState(() => {
|
|
726
|
+
let position = resolvedInsets.top
|
|
727
|
+
let total = 0
|
|
728
|
+
if (typeof initialScrollIndex === "number") {
|
|
729
|
+
const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
|
|
730
|
+
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
731
|
+
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
732
|
+
const options = initialScrollIndex > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
|
|
733
|
+
const { cumulative, total: materializedTotal, currentValue } = fenwickTree.prefixSum(initialScrollIndex, options)
|
|
734
|
+
const logicalOffset = Math.max(cumulative - currentValue, 0)
|
|
735
|
+
position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
|
|
736
|
+
total = materializedTotal ?? fenwickTree.getTotal()
|
|
737
|
+
} else if (typeof initialScrollOffset === "number") {
|
|
738
|
+
position = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
|
|
739
|
+
total = fenwickTree.getTotal()
|
|
740
|
+
} else {
|
|
741
|
+
total = fenwickTree.getTotal()
|
|
742
|
+
}
|
|
743
|
+
return { position, total }
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
const [scrollPosition, setScrollPosition] = useState(initialValues.position)
|
|
747
|
+
const [contentSize, setContentSize] = useState<number>(initialValues.total)
|
|
748
|
+
|
|
749
|
+
const latestScrollPositionRef = useRef(initialValues.position)
|
|
750
|
+
const previousTopInsetRef = useRef(resolvedInsets.top)
|
|
751
|
+
const onScrollRef = useRef<OnScrollCallback | undefined>(onScroll ?? undefined)
|
|
752
|
+
const onRangeChangeRef = useRef<OnRangeChangeCallback | undefined>(onRangeChange ?? undefined)
|
|
753
|
+
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
754
|
+
const pendingFocusIndexRef = useRef<number | null>(null)
|
|
755
|
+
const lastFocusedIndexRef = useRef<number | null>(null)
|
|
756
|
+
|
|
757
|
+
const [scrollDirection, setScrollDirection] = useState<"up" | "down" | null>(null)
|
|
758
|
+
const [showScrollButtons, setShowScrollButtons] = useState(false)
|
|
759
|
+
const scrollButtonTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
760
|
+
const isProgrammaticScrollRef = useRef(false)
|
|
761
|
+
const isCompensatingRef = useRef(0)
|
|
762
|
+
// レイアウトシフト補正の scrollTo が ScrollPane の旧 contentSize でクランプされた場合に true。
|
|
763
|
+
// 新 contentSize がコミットされた後の同期 effect で補正を再発行する (二段補正の二段目) ためのフラグ。
|
|
764
|
+
const pendingCompensationRef = useRef(false)
|
|
765
|
+
const isResizingRef = useRef(false)
|
|
766
|
+
const prevItemCountRef = useRef(itemCount)
|
|
767
|
+
|
|
768
|
+
if (prevItemCountRef.current !== itemCount) {
|
|
769
|
+
prevItemCountRef.current = itemCount
|
|
770
|
+
isResizingRef.current = true
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
// 目的: 外部から渡されたコールバックの参照を最新状態に保つ。
|
|
775
|
+
// 依存関係: onRangeChange, onScroll
|
|
776
|
+
onScrollRef.current = onScroll ?? undefined
|
|
777
|
+
onRangeChangeRef.current = onRangeChange ?? undefined
|
|
778
|
+
}, [onRangeChange, onScroll])
|
|
779
|
+
|
|
780
|
+
const tryFocusElement = useCallback(
|
|
781
|
+
(element: HTMLElement | null) => {
|
|
782
|
+
if (!(enableKeyboardNavigation && element)) {
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
if (typeof element.focus === "function") {
|
|
786
|
+
try {
|
|
787
|
+
element.focus({ preventScroll: true } as FocusOptions)
|
|
788
|
+
} catch (_error) {
|
|
789
|
+
element.focus()
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
[enableKeyboardNavigation],
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
// スロットル制御された onScroll 通知関数
|
|
797
|
+
const invokeScroll = useCallback((callback: OnScrollCallback, { position, totalHeight }: { position: number; totalHeight: number }) => {
|
|
798
|
+
callback(position, totalHeight)
|
|
799
|
+
}, [])
|
|
800
|
+
const scheduleScrollEffect = useThrottledInvoker<OnScrollCallback, { position: number; totalHeight: number }>(onScrollRef, callbackThrottleMs, invokeScroll)
|
|
801
|
+
|
|
802
|
+
// スロットル制御された onRangeChange 通知関数
|
|
803
|
+
const invokeRangeChange = useCallback((callback: OnRangeChangeCallback, range: VirtualScrollRange) => {
|
|
804
|
+
callback(range)
|
|
805
|
+
}, [])
|
|
806
|
+
const scheduleRangeEffect = useThrottledInvoker<OnRangeChangeCallback, VirtualScrollRange>(onRangeChangeRef, callbackThrottleMs, invokeRangeChange)
|
|
807
|
+
|
|
808
|
+
// 自己再帰する RAF ループが常に最新の依存値を参照できるよう、揮発値を ref で保持する。
|
|
809
|
+
// これにより、走行中のループが古いクロージャ (旧 inset / 旧 scheduleScrollEffect) を掴み続けるのを防ぐ。
|
|
810
|
+
const resolvedInsetsTopRef = useRef(resolvedInsets.top)
|
|
811
|
+
resolvedInsetsTopRef.current = resolvedInsets.top
|
|
812
|
+
const scheduleScrollEffectRef = useRef(scheduleScrollEffect)
|
|
813
|
+
scheduleScrollEffectRef.current = scheduleScrollEffect
|
|
814
|
+
|
|
815
|
+
useEffect(() => {
|
|
816
|
+
// 目的: アンマウント状態を追跡し、アンマウント後のステート更新を防止する。
|
|
817
|
+
// 依存関係: なし (マウント/アンマウント時のみ)
|
|
818
|
+
// StrictMode の二重マウント (cleanup 後に再 effect) でも正しく復帰するよう、effect 本体で false へ戻す。
|
|
819
|
+
isUnmountedRef.current = false
|
|
820
|
+
return () => {
|
|
821
|
+
isUnmountedRef.current = true
|
|
822
|
+
// スクロールボタン非表示用タイマーがアンマウント後に発火するのを防ぐ。
|
|
823
|
+
if (scrollButtonTimerRef.current) {
|
|
824
|
+
clearTimeout(scrollButtonTimerRef.current)
|
|
825
|
+
scrollButtonTimerRef.current = null
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}, [])
|
|
829
|
+
|
|
830
|
+
useEffect(() => {
|
|
831
|
+
// 目的: キーボードナビゲーションが無効化された場合、フォーカス管理用の参照をクリアする。
|
|
832
|
+
// 依存関係: enableKeyboardNavigation
|
|
833
|
+
if (enableKeyboardNavigation) {
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
itemRefs.current.clear()
|
|
837
|
+
pendingFocusIndexRef.current = null
|
|
838
|
+
lastFocusedIndexRef.current = null
|
|
839
|
+
}, [enableKeyboardNavigation])
|
|
840
|
+
|
|
841
|
+
const registerItemRef = useCallback(
|
|
842
|
+
(index: number, node: HTMLDivElement | null) => {
|
|
843
|
+
if (!node) {
|
|
844
|
+
itemRefs.current.delete(index)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
if (!enableKeyboardNavigation) {
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
itemRefs.current.set(index, node)
|
|
851
|
+
if (pendingFocusIndexRef.current === index) {
|
|
852
|
+
pendingFocusIndexRef.current = null
|
|
853
|
+
lastFocusedIndexRef.current = index
|
|
854
|
+
tryFocusElement(node)
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
[enableKeyboardNavigation, tryFocusElement],
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Updates scroll position immediately for rendering.
|
|
862
|
+
*
|
|
863
|
+
* 即時にスクロール位置を更新してレンダリングへ反映。
|
|
864
|
+
*/
|
|
865
|
+
const POSITION_EPSILON = 0.01
|
|
866
|
+
|
|
867
|
+
const renderLoopRef = useRef<{
|
|
868
|
+
rafId: number | null
|
|
869
|
+
loopActive: boolean
|
|
870
|
+
idleFrames: number
|
|
871
|
+
lastRenderedPosition: number
|
|
872
|
+
}>({
|
|
873
|
+
rafId: null,
|
|
874
|
+
loopActive: false,
|
|
875
|
+
idleFrames: 0,
|
|
876
|
+
lastRenderedPosition: initialValues.position,
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
const stopRenderLoop = useCallback(() => {
|
|
880
|
+
// 目的: アニメーションループを停止し、保留中の RAF をキャンセルする。
|
|
881
|
+
const loop = renderLoopRef.current
|
|
882
|
+
if (loop.rafId !== null && typeof cancelAnimationFrame === "function") {
|
|
883
|
+
cancelAnimationFrame(loop.rafId)
|
|
884
|
+
}
|
|
885
|
+
loop.rafId = null
|
|
886
|
+
loop.loopActive = false
|
|
887
|
+
loop.idleFrames = 0
|
|
888
|
+
}, [])
|
|
889
|
+
|
|
890
|
+
useEffect(
|
|
891
|
+
() => () => {
|
|
892
|
+
// クリーンアップ: アンマウント時にレンダリングループを停止
|
|
893
|
+
stopRenderLoop()
|
|
894
|
+
},
|
|
895
|
+
[stopRenderLoop],
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
// 自身の最新クロージャを保持し、RAF 再アーム時に旧クロージャを掴まないようにする。
|
|
899
|
+
const runRenderLoopRef = useRef<(frameTime?: number) => void>(() => {})
|
|
900
|
+
|
|
901
|
+
const runRenderLoop: (frameTime?: number) => void = useCallback(() => {
|
|
902
|
+
const loop = renderLoopRef.current
|
|
903
|
+
loop.rafId = null
|
|
904
|
+
|
|
905
|
+
const currentPosition = latestScrollPositionRef.current
|
|
906
|
+
// 揮発値 (inset / 通知関数) は ref から読み、走行中でも常に最新を使う。
|
|
907
|
+
const logicalPosition = toLogicalPositionWithInset(currentPosition, resolvedInsetsTopRef.current)
|
|
908
|
+
const totalHeight = fenwickTree.getTotal()
|
|
909
|
+
|
|
910
|
+
setScrollPosition((prev) => {
|
|
911
|
+
if (Math.abs(prev - currentPosition) < POSITION_EPSILON) {
|
|
912
|
+
return prev
|
|
913
|
+
}
|
|
914
|
+
return currentPosition
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
const positionChanged = Math.abs(loop.lastRenderedPosition - currentPosition) >= POSITION_EPSILON
|
|
918
|
+
if (positionChanged) {
|
|
919
|
+
// 位置が実際に変化したフレームのみ onScroll を通知する。
|
|
920
|
+
// アイドル (静止) フレームで同一値を重複通知しないようにする。
|
|
921
|
+
scheduleScrollEffectRef.current({ position: logicalPosition, totalHeight })
|
|
922
|
+
loop.lastRenderedPosition = currentPosition
|
|
923
|
+
loop.idleFrames = 0
|
|
924
|
+
} else {
|
|
925
|
+
loop.idleFrames += 1
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (loop.idleFrames >= 2) {
|
|
929
|
+
stopRenderLoop()
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (typeof requestAnimationFrame === "function") {
|
|
934
|
+
loop.rafId = requestAnimationFrame((t) => runRenderLoopRef.current(t))
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
loop.loopActive = false
|
|
938
|
+
}, [fenwickTree, stopRenderLoop])
|
|
939
|
+
runRenderLoopRef.current = runRenderLoop
|
|
940
|
+
|
|
941
|
+
const ensureRenderLoop = useCallback(() => {
|
|
942
|
+
const loop = renderLoopRef.current
|
|
943
|
+
loop.idleFrames = 0
|
|
944
|
+
if (loop.loopActive) {
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
loop.loopActive = true
|
|
948
|
+
if (typeof requestAnimationFrame === "function") {
|
|
949
|
+
loop.rafId = requestAnimationFrame(runRenderLoop)
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
loop.loopActive = false
|
|
953
|
+
}, [runRenderLoop])
|
|
954
|
+
|
|
955
|
+
const updateScrollPositionImmediate = useCallback(
|
|
956
|
+
(nextPosition: number, options?: { immediate?: boolean }) => {
|
|
957
|
+
const immediate = options?.immediate ?? false
|
|
958
|
+
const logicalPosition = toLogicalPositionWithInset(nextPosition, resolvedInsets.top)
|
|
959
|
+
latestScrollPositionRef.current = nextPosition
|
|
960
|
+
if (immediate) {
|
|
961
|
+
renderLoopRef.current.lastRenderedPosition = nextPosition
|
|
962
|
+
renderLoopRef.current.idleFrames = 0
|
|
963
|
+
setScrollPosition(nextPosition)
|
|
964
|
+
scheduleScrollEffect({ position: logicalPosition, totalHeight: fenwickTree.getTotal() })
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
ensureRenderLoop()
|
|
968
|
+
},
|
|
969
|
+
[ensureRenderLoop, fenwickTree, resolvedInsets.top, scheduleScrollEffect],
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Issues a layout-shift compensation scroll to the pane and keeps the compensation
|
|
974
|
+
* bookkeeping consistent. Returns the clamped position the pane actually applied
|
|
975
|
+
* (single source of truth). When the pane position does not change, no `onScroll`
|
|
976
|
+
* fires, so the compensation counter increment is rolled back to prevent a leak.
|
|
977
|
+
* When the applied position diverges from the target (clamped by a stale content
|
|
978
|
+
* size inside the same synchronous batch), a re-issue is scheduled for after the
|
|
979
|
+
* new content size commits.
|
|
980
|
+
*
|
|
981
|
+
* レイアウトシフト補正のスクロールをペインへ発行し、補正カウンタの整合を保つ。戻り値は
|
|
982
|
+
* ペインが実際に適用したクランプ済み位置 (単一の真実)。ペイン位置が変化しない場合は
|
|
983
|
+
* onScroll が発火しないため、カウンタ増分を巻き戻してリークを防止。適用位置が目標と
|
|
984
|
+
* 乖離した場合 (同一同期バッチ内の旧 contentSize によるクランプ) は、新 contentSize
|
|
985
|
+
* コミット後の補正再発行を予約。
|
|
986
|
+
*/
|
|
987
|
+
const issueCompensationScroll = useCallback((targetPosition: number): number => {
|
|
988
|
+
const pane = scrollPaneRef.current
|
|
989
|
+
if (!pane) {
|
|
990
|
+
return targetPosition
|
|
991
|
+
}
|
|
992
|
+
// クランプで位置が変化しない場合 onScroll は発火しない。事前位置と比較して増分を戻すために保持する
|
|
993
|
+
const beforePosition = pane.getScrollPosition()
|
|
994
|
+
isCompensatingRef.current += 1
|
|
995
|
+
const appliedPosition = pane.scrollTo(targetPosition)
|
|
996
|
+
if (appliedPosition === beforePosition) {
|
|
997
|
+
// onScroll 不発: 補正カウンタの増分を巻き戻し、次の手動スクロールが補正扱いされるのを防ぐ
|
|
998
|
+
isCompensatingRef.current = Math.max(0, isCompensatingRef.current - 1)
|
|
999
|
+
}
|
|
1000
|
+
if (Math.abs(appliedPosition - targetPosition) > 0.5) {
|
|
1001
|
+
// 旧 contentSize でクランプされた: 新 contentSize コミット後の同期 effect で補正を再発行する
|
|
1002
|
+
pendingCompensationRef.current = true
|
|
1003
|
+
}
|
|
1004
|
+
return appliedPosition
|
|
1005
|
+
}, [])
|
|
1006
|
+
|
|
1007
|
+
const didApplyInitialOffsetRef = useRef(false)
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Flushes queued onScroll notifications.
|
|
1011
|
+
*
|
|
1012
|
+
* キューされた onScroll 通知を実行。
|
|
1013
|
+
*/
|
|
1014
|
+
useEffect(() => {
|
|
1015
|
+
if (didApplyInitialOffsetRef.current) return
|
|
1016
|
+
didApplyInitialOffsetRef.current = true
|
|
1017
|
+
// 目的: 初期オフセットを一度だけ適用し (同期処理)、ペインと論理位置を同期させる。
|
|
1018
|
+
// 依存関係: initialScrollOffset (初回のみ実行されるガード節あり)
|
|
1019
|
+
if (typeof initialScrollOffset === "number") {
|
|
1020
|
+
const paneOffset = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
|
|
1021
|
+
const needsPaneSync = Math.abs(paneOffset - latestScrollPositionRef.current) > 0.5
|
|
1022
|
+
updateScrollPositionImmediate(paneOffset, { immediate: true })
|
|
1023
|
+
if (needsPaneSync) {
|
|
1024
|
+
scrollPaneRef.current?.scrollTo(paneOffset)
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
updateScrollPositionImmediate(latestScrollPositionRef.current, { immediate: true })
|
|
1028
|
+
}
|
|
1029
|
+
}, [initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
|
|
1030
|
+
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
// itemCount triggers recalculation of contentSize
|
|
1033
|
+
void itemCount
|
|
1034
|
+
|
|
1035
|
+
// 目的: FenwickTree のサイズ変更を検出し、コンテンツサイズ State を同期させる。
|
|
1036
|
+
// 依存関係: fenwickTree, contentSize, itemCount
|
|
1037
|
+
|
|
1038
|
+
// Note: Logic for updating tree structure based on getItemHeight is now delegated to useFenwickMapTree.
|
|
1039
|
+
|
|
1040
|
+
const totalHeight = fenwickTree.getTotal()
|
|
1041
|
+
if (contentSize !== totalHeight) {
|
|
1042
|
+
setContentSize(totalHeight)
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// 二段補正の二段目: 補正 scrollTo が旧 contentSize でクランプされていた場合、この passive effect は
|
|
1047
|
+
// ScrollPane の layout effect (sizeRef の新 contentSize への更新) より後に走るため、ここでの
|
|
1048
|
+
// scrollTo は新 contentSize で正しくクランプされる。論理位置 (latestScrollPositionRef) に保持した
|
|
1049
|
+
// 補正目標へ再発行し、収束後はクランプ済み実位置を単一の真実として採用する。
|
|
1050
|
+
if (pendingCompensationRef.current) {
|
|
1051
|
+
pendingCompensationRef.current = false
|
|
1052
|
+
const pane = scrollPaneRef.current
|
|
1053
|
+
if (pane && Math.abs(pane.getScrollPosition() - latestScrollPositionRef.current) > 0.5) {
|
|
1054
|
+
const appliedPosition = issueCompensationScroll(latestScrollPositionRef.current)
|
|
1055
|
+
// 再発行後もなお目標に届かない場合 (目標が負値等) はクランプ済み実位置へ収束させ、
|
|
1056
|
+
// 再発行の連鎖を断つ
|
|
1057
|
+
pendingCompensationRef.current = false
|
|
1058
|
+
updateScrollPositionImmediate(appliedPosition, { immediate: true })
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}, [fenwickTree, contentSize, itemCount, issueCompensationScroll, updateScrollPositionImmediate])
|
|
1062
|
+
|
|
1063
|
+
useEffect(() => {
|
|
1064
|
+
// 目的: サイズ変更やスクロール等でターゲットインデックスの位置がずれた場合、スクロール位置を補正 (ドリフト補正) する。
|
|
1065
|
+
// 依存関係: contentSize, fenwickTree, itemCount, resolvedInsets.top, updateScrollPositionImmediate, viewportSize
|
|
1066
|
+
if (pendingVisibleStartIndexRef.current !== null) {
|
|
1067
|
+
const { index, align, offset } = pendingVisibleStartIndexRef.current
|
|
1068
|
+
const safeIndex = sanitizeIndex(index, itemCount)
|
|
1069
|
+
const { cumulative: itemBottom, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: false } })
|
|
1070
|
+
|
|
1071
|
+
if (itemBottom !== undefined && itemHeight !== undefined) {
|
|
1072
|
+
const itemTop = Math.max(itemBottom - itemHeight, 0)
|
|
1073
|
+
let targetLogicalPosition = itemTop
|
|
1074
|
+
|
|
1075
|
+
if (align === "bottom") {
|
|
1076
|
+
targetLogicalPosition = itemBottom - viewportSize
|
|
1077
|
+
} else if (align === "center") {
|
|
1078
|
+
targetLogicalPosition = itemTop + itemHeight / 2 - viewportSize / 2
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (offset) {
|
|
1082
|
+
targetLogicalPosition -= offset
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
targetLogicalPosition = Math.max(0, targetLogicalPosition)
|
|
1086
|
+
|
|
1087
|
+
const maxScrollPosition = Math.max(0, contentSize + resolvedInsets.top + resolvedInsets.bottom - viewportSize)
|
|
1088
|
+
const targetPanePosition = Math.min(toPanePositionWithInset(targetLogicalPosition, resolvedInsets.top), maxScrollPosition)
|
|
1089
|
+
|
|
1090
|
+
if (Math.abs(targetPanePosition - latestScrollPositionRef.current) > 1.0) {
|
|
1091
|
+
Logger.debug("[VirtualScroll] Drift correction", {
|
|
1092
|
+
from: latestScrollPositionRef.current,
|
|
1093
|
+
to: targetPanePosition,
|
|
1094
|
+
targetIndex: safeIndex,
|
|
1095
|
+
})
|
|
1096
|
+
// Guard against double increment if multiple effects fire before scroll handles it
|
|
1097
|
+
if (isCompensatingRef.current === 0) {
|
|
1098
|
+
isCompensatingRef.current += 1
|
|
1099
|
+
}
|
|
1100
|
+
scrollPaneRef.current?.scrollTo(targetPanePosition)
|
|
1101
|
+
updateScrollPositionImmediate(targetPanePosition, { immediate: true })
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
isResizingRef.current = false
|
|
1106
|
+
}, [contentSize, fenwickTree, itemCount, resolvedInsets.top, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
|
|
1107
|
+
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
// 目的: 上部インセットが動的に変更された場合、論理スクロール位置を維持したまま、ペインのスクロール位置を再計算してずらす。
|
|
1110
|
+
// 依存関係: resolvedInsets.top
|
|
1111
|
+
const previousTop = previousTopInsetRef.current
|
|
1112
|
+
if (previousTop === resolvedInsets.top) return
|
|
1113
|
+
|
|
1114
|
+
const logicalPosition = toLogicalPositionWithInset(latestScrollPositionRef.current, previousTop)
|
|
1115
|
+
const panePosition = toPanePositionWithInset(logicalPosition, resolvedInsets.top)
|
|
1116
|
+
previousTopInsetRef.current = resolvedInsets.top
|
|
1117
|
+
latestScrollPositionRef.current = panePosition
|
|
1118
|
+
scrollPaneRef.current?.scrollTo(panePosition)
|
|
1119
|
+
updateScrollPositionImmediate(panePosition, { immediate: true })
|
|
1120
|
+
}, [resolvedInsets.top, updateScrollPositionImmediate])
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Updates the size of a specific item. Contract: after this call, `getItemHeight(index)` must
|
|
1124
|
+
* return the same `size`; `getItemHeight` is the source of truth, so rows inside the current
|
|
1125
|
+
* rendering window (including overscan) are otherwise reverted to the `getItemHeight` value by
|
|
1126
|
+
* height reconciliation on the next render.
|
|
1127
|
+
*
|
|
1128
|
+
* 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
|
|
1129
|
+
* `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
|
|
1130
|
+
* 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。
|
|
1131
|
+
*/
|
|
1132
|
+
const updateItemSize = useCallback(
|
|
1133
|
+
(index: number, size: number) => {
|
|
1134
|
+
if (itemCount === 0) {
|
|
1135
|
+
return
|
|
1136
|
+
}
|
|
1137
|
+
const safeIndex = sanitizeIndex(index, itemCount)
|
|
1138
|
+
const oldSize = fenwickTree.get(safeIndex)
|
|
1139
|
+
const delta = size - oldSize
|
|
1140
|
+
|
|
1141
|
+
const total = fenwickTree.update(safeIndex, size)
|
|
1142
|
+
if (total !== undefined) {
|
|
1143
|
+
setContentSize(total)
|
|
1144
|
+
}
|
|
1145
|
+
Logger.debug("[VirtualScroll] Updated item size manually", { index: safeIndex, size, total })
|
|
1146
|
+
|
|
1147
|
+
// Layout Shift Compensation (レイアウトシフト補正)
|
|
1148
|
+
// Update scroll position if the size change happened above the current viewport
|
|
1149
|
+
// サイズ変更が現在のビューポートより上部で発生した場合、視覚的な位置ズレを防ぐためにスクロール位置を補正します。
|
|
1150
|
+
let activeVisibleStartIndex = pendingVisibleStartIndexRef.current ? pendingVisibleStartIndexRef.current.index : null
|
|
1151
|
+
|
|
1152
|
+
// If no pending target, calculate the current visible index based on scroll position
|
|
1153
|
+
// ターゲットインデックスが保留中でない場合、現在のスクロール位置に基づいて現在の可視インデックスを計算します
|
|
1154
|
+
if (activeVisibleStartIndex === null) {
|
|
1155
|
+
const currentScrollTop = latestScrollPositionRef.current
|
|
1156
|
+
const insetsTop = normalizeInsets(contentInsets).top
|
|
1157
|
+
const logicalScrollTop = toLogicalPositionWithInset(currentScrollTop, insetsTop)
|
|
1158
|
+
// Use materialize: false for performance, we just need the index
|
|
1159
|
+
const { index, cumulative } = fenwickTree.findIndexAtOrAfter(logicalScrollTop, { materializeOption: { materialize: false } })
|
|
1160
|
+
// findIndexAtOrAfter は「下端(cumulative) >= scrollTop の最小 index」を返すため、
|
|
1161
|
+
// アイテム index の下端がちょうど scrollTop に一致する場合、その行は完全にビューポート上方にある。
|
|
1162
|
+
// computeRenderingRanges (cumulative === effectiveScrollPosition の特例) と同じく可視先頭を index+1 に補正する。
|
|
1163
|
+
activeVisibleStartIndex = cumulative !== undefined && cumulative === logicalScrollTop ? index + 1 : index
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Allow for a small buffer in case of slight misalignments or stale refs
|
|
1167
|
+
// If the item is strictly above the visible start, it pushes content down.
|
|
1168
|
+
// アイテムが(ほぼ)確実に可視領域より上にある場合、コンテンツ全体を押し下げます
|
|
1169
|
+
if (activeVisibleStartIndex !== -1 && safeIndex < activeVisibleStartIndex && delta !== 0) {
|
|
1170
|
+
// Use latestScrollPositionRef as the source of truth for the current position
|
|
1171
|
+
// to avoid reading stale DOM values during batched updates (e.g. multiple items resizing at once).
|
|
1172
|
+
const currentPanePosition = latestScrollPositionRef.current
|
|
1173
|
+
const newPosition = currentPanePosition + delta
|
|
1174
|
+
|
|
1175
|
+
// 同一同期バッチ内では ScrollPane の sizeRef が旧 contentSize のままのため、scrollTo は
|
|
1176
|
+
// 旧最大値でクランプされ得る。issueCompensationScroll がカウンタのリークを防ぎつつ乖離時の
|
|
1177
|
+
// 再発行を予約し、論理位置 (latestScrollPositionRef) には補正目標を保持して二段目の収束先とする。
|
|
1178
|
+
issueCompensationScroll(newPosition)
|
|
1179
|
+
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
1180
|
+
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
|
|
1181
|
+
}
|
|
1182
|
+
},
|
|
1183
|
+
[fenwickTree, itemCount, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Scrolls to the requested logical index.
|
|
1188
|
+
*
|
|
1189
|
+
* 指定インデックスへのスクロールを実行。
|
|
1190
|
+
*/
|
|
1191
|
+
const scrollToIndex = useCallback(
|
|
1192
|
+
(index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => {
|
|
1193
|
+
if (!scrollPaneRef.current || itemCount === 0) {
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
const safeIndex = sanitizeIndex(index, itemCount)
|
|
1197
|
+
const safeIndexFrom = sanitizeIndex(safeIndex - overscanCount * 2, itemCount)
|
|
1198
|
+
const safeIndexTo = sanitizeIndex(safeIndex + overscanCount * 2, itemCount)
|
|
1199
|
+
const { cumulative: itemBottom, total, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } })
|
|
1200
|
+
|
|
1201
|
+
Logger.debug("[VirtualScroll] Scrolling to index:", safeIndex, "ItemBottom:", itemBottom, "Total height:", total, "ItemHeight:", itemHeight, "safeIndexFrom:", safeIndexFrom, "safeIndexTo:", safeIndexTo)
|
|
1202
|
+
|
|
1203
|
+
if (!total) {
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const itemTop = Math.max(itemBottom - itemHeight, 0)
|
|
1208
|
+
let targetLogicalPosition = itemTop
|
|
1209
|
+
|
|
1210
|
+
if (options?.align === "bottom") {
|
|
1211
|
+
targetLogicalPosition = itemBottom - viewportSize
|
|
1212
|
+
} else if (options?.align === "center") {
|
|
1213
|
+
targetLogicalPosition = itemTop + itemHeight / 2 - viewportSize / 2
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (options?.offset) {
|
|
1217
|
+
targetLogicalPosition -= options.offset
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Ensure we don't scroll past the content
|
|
1221
|
+
// targetLogicalPosition = minmax(targetLogicalPosition, 0, total - viewportSize)
|
|
1222
|
+
// Actually ScrollPane handles bounds usually, but let's be safe with 0
|
|
1223
|
+
targetLogicalPosition = Math.max(0, targetLogicalPosition)
|
|
1224
|
+
|
|
1225
|
+
const paneOffset = toPanePositionWithInset(targetLogicalPosition, resolvedInsets.top)
|
|
1226
|
+
|
|
1227
|
+
// Clamp the scroll position to the maximum allowed value to ensure state consistency with ScrollPane.
|
|
1228
|
+
// If we don't clamp, VirtualScroll might think it's at 501,000px while ScrollPane is capped at 499,991px.
|
|
1229
|
+
// This mismatch causes the visible range calculation to shift, potentially rendering items at the wrong relative position (e.g. at the top instead of bottom).
|
|
1230
|
+
// Use ScrollPane's content size directly to avoid costly calculations with large datasets.
|
|
1231
|
+
// スクロール位置を最大許容値にクランプして、ScrollPane との状態整合性を保ちます。
|
|
1232
|
+
// クランプしないと、VirtualScroll が 501,000px にあると認識しているのに、ScrollPane が 499,991px で止まっているという不整合が起きる可能性があります。
|
|
1233
|
+
// この不整合により可視範囲計算がずれ、アイテムが誤った相対位置(例:下端にあるべきアイテムが上端に来るなど)に描画される原因となります。
|
|
1234
|
+
const totalContentHeight = scrollPaneRef.current?.getContentSize() ?? total + resolvedInsets.top + resolvedInsets.bottom
|
|
1235
|
+
const maxScrollPosition = Math.max(0, totalContentHeight - viewportSize)
|
|
1236
|
+
const clampedPaneOffset = Math.min(paneOffset, maxScrollPosition)
|
|
1237
|
+
|
|
1238
|
+
// Set the pending target index to anchor the layout shift compensation
|
|
1239
|
+
// レイアウトシフト補正のアンカーとして、保留中のターゲットインデックスを設定します
|
|
1240
|
+
pendingVisibleStartIndexRef.current = {
|
|
1241
|
+
index: safeIndex,
|
|
1242
|
+
align: options?.align,
|
|
1243
|
+
offset: options?.offset,
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const currentPanePosition = scrollPaneRef.current?.getScrollPosition() ?? -1
|
|
1247
|
+
const shouldScroll = Math.abs(currentPanePosition - clampedPaneOffset) > 0.5
|
|
1248
|
+
|
|
1249
|
+
// Reset flags for strict accounting to avoid leaks
|
|
1250
|
+
// リークを防ぐため、補正関連のフラグをリセットします
|
|
1251
|
+
isProgrammaticScrollRef.current = false
|
|
1252
|
+
isCompensatingRef.current = 0
|
|
1253
|
+
|
|
1254
|
+
if (shouldScroll) {
|
|
1255
|
+
// Mark as programmatic to prevent clearing the anchor in handleScroll
|
|
1256
|
+
// handleScroll 内でアンカーが意図せずクリアされるのを防ぐため、プログラムによるスクロールとしてマークします
|
|
1257
|
+
isProgrammaticScrollRef.current = true
|
|
1258
|
+
// Increment compensating ref to protect anchor during system-initiated scroll
|
|
1259
|
+
// システム主導のスクロール中にアンカーを保護するため、補正中参照カウンタをインクリメントします
|
|
1260
|
+
isCompensatingRef.current += 1
|
|
1261
|
+
|
|
1262
|
+
scrollPaneRef.current?.scrollTo(clampedPaneOffset)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
setContentSize(total)
|
|
1266
|
+
updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
|
|
1267
|
+
|
|
1268
|
+
Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
|
|
1269
|
+
},
|
|
1270
|
+
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Scrolls to a raw offset while resolving to an index.
|
|
1275
|
+
*
|
|
1276
|
+
* オフセットをインデックスに変換しつつスクロール。
|
|
1277
|
+
*/
|
|
1278
|
+
const scrollTo = useCallback(
|
|
1279
|
+
(newPosition: number) => {
|
|
1280
|
+
if (!scrollPaneRef.current || itemCount === 0) {
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
const total = fenwickTree.getTotal()
|
|
1284
|
+
const safePosition = minmax(Math.floor(newPosition), 0, total)
|
|
1285
|
+
const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(safePosition, { materializeOption: { materialize: false } })
|
|
1286
|
+
|
|
1287
|
+
// Calculate offset relative to item top to ensure precise positioning
|
|
1288
|
+
// itemTop = cumulative - currentValue
|
|
1289
|
+
const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
|
|
1290
|
+
const offset = itemTop - safePosition
|
|
1291
|
+
|
|
1292
|
+
scrollToIndex(index, { offset })
|
|
1293
|
+
},
|
|
1294
|
+
[fenwickTree, itemCount, scrollToIndex],
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Imperative scroll entry supporting updater functions.
|
|
1299
|
+
*
|
|
1300
|
+
* アップデーター関数対応の命令的スクロール入口。
|
|
1301
|
+
*/
|
|
1302
|
+
const scrollToHandle = useCallback(
|
|
1303
|
+
(position: number | ((prev: number) => number)) => {
|
|
1304
|
+
const currentLogical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
|
|
1305
|
+
const resolvedLogical = typeof position === "function" ? position(currentLogical) : position
|
|
1306
|
+
scrollTo(resolvedLogical)
|
|
1307
|
+
const panePosition = scrollPaneRef.current?.getScrollPosition()
|
|
1308
|
+
const effectivePosition = typeof panePosition === "number" ? panePosition : latestScrollPositionRef.current
|
|
1309
|
+
updateScrollPositionImmediate(effectivePosition)
|
|
1310
|
+
return effectivePosition
|
|
1311
|
+
},
|
|
1312
|
+
[resolvedInsets.top, scrollTo, updateScrollPositionImmediate],
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Handles scroll events from the pane.
|
|
1317
|
+
*
|
|
1318
|
+
* ペインのスクロールイベントを処理。
|
|
1319
|
+
*/
|
|
1320
|
+
const handleScroll = useCallback(
|
|
1321
|
+
(newPosition: number, prevPosition: number) => {
|
|
1322
|
+
Logger.debug("[VirtualScroll] Scroll position changed:", newPosition)
|
|
1323
|
+
|
|
1324
|
+
const isProgrammatic = isProgrammaticScrollRef.current
|
|
1325
|
+
if (isProgrammatic) {
|
|
1326
|
+
isProgrammaticScrollRef.current = false
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const isCompensating = isCompensatingRef.current > 0
|
|
1330
|
+
if (isCompensating) {
|
|
1331
|
+
isCompensatingRef.current = 0
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// If manual scroll (neither programmatic nor compensating), clear the pending anchor
|
|
1335
|
+
// This ensures that manual scrolling immediately detaches from any previous scroll target
|
|
1336
|
+
if (!(isProgrammatic || isCompensating || isResizingRef.current)) {
|
|
1337
|
+
pendingVisibleStartIndexRef.current = null
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
updateScrollPositionImmediate(newPosition)
|
|
1341
|
+
|
|
1342
|
+
if (enableScrollToTopBottomButtons) {
|
|
1343
|
+
if (isProgrammatic || isCompensating) {
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const diff = newPosition - prevPosition
|
|
1348
|
+
Logger.debug("[VirtualScroll] Scroll diff:", diff, "New:", newPosition, "Prev:", prevPosition)
|
|
1349
|
+
if (Math.abs(diff) > 1) {
|
|
1350
|
+
const direction = diff > 0 ? "down" : "up"
|
|
1351
|
+
setScrollDirection(direction)
|
|
1352
|
+
setShowScrollButtons(true)
|
|
1353
|
+
Logger.debug("[VirtualScroll] Showing scroll buttons. Direction:", direction)
|
|
1354
|
+
|
|
1355
|
+
if (scrollButtonTimerRef.current) {
|
|
1356
|
+
clearTimeout(scrollButtonTimerRef.current)
|
|
1357
|
+
}
|
|
1358
|
+
scrollButtonTimerRef.current = setTimeout(() => {
|
|
1359
|
+
setShowScrollButtons(false)
|
|
1360
|
+
Logger.debug("[VirtualScroll] Hiding scroll buttons")
|
|
1361
|
+
}, 2000)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
},
|
|
1365
|
+
[updateScrollPositionImmediate, enableScrollToTopBottomButtons],
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
// レンダリング範囲を計算
|
|
1369
|
+
const logicalScrollPosition = useMemo(() => toLogicalPositionWithInset(scrollPosition, resolvedInsets.top), [resolvedInsets.top, scrollPosition])
|
|
1370
|
+
|
|
1371
|
+
const renderingRanges = useMemo(() => {
|
|
1372
|
+
// useMemo 内では State の contentSize ではなく、常に最新の計算結果を持つ fenwickTree.getTotal() を使用する。
|
|
1373
|
+
// これにより、アイテム数が大幅に減少した直後でも、古い contentSize (State) に基づく誤ったレンダリング範囲計算を防ぐことができる。
|
|
1374
|
+
// contentSize (State) の更新は非同期で行われるため、一瞬古い状態が残る可能性があるが、fenwickTree は同期的であり信頼性が高い。
|
|
1375
|
+
const currentTotalHeight = fenwickTree.getTotal()
|
|
1376
|
+
const ranges = computeRenderingRanges(logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree, currentTotalHeight)
|
|
1377
|
+
Logger.debug("[VirtualScroll] Calculated rendering range:", () => ({
|
|
1378
|
+
...ranges,
|
|
1379
|
+
scrollPosition: logicalScrollPosition,
|
|
1380
|
+
renderingContentSize: currentTotalHeight,
|
|
1381
|
+
overscanCount,
|
|
1382
|
+
viewportSize,
|
|
1383
|
+
}))
|
|
1384
|
+
return ranges
|
|
1385
|
+
}, [logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree]) // contentSize を依存配列から削除
|
|
1386
|
+
|
|
1387
|
+
const { renderingStartIndex, renderingEndIndex, visibleStartIndex, visibleEndIndex } = renderingRanges
|
|
1388
|
+
|
|
1389
|
+
const focusItemAtIndex = useCallback(
|
|
1390
|
+
(index: number, options?: { ensureVisible?: boolean }) => {
|
|
1391
|
+
if (!enableKeyboardNavigation || itemCount === 0) {
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
const safeIndex = sanitizeIndex(index, itemCount)
|
|
1395
|
+
const ensureVisible = options?.ensureVisible ?? true
|
|
1396
|
+
if (!ensureVisible) {
|
|
1397
|
+
const existingElement = itemRefs.current.get(safeIndex)
|
|
1398
|
+
if (existingElement) {
|
|
1399
|
+
pendingFocusIndexRef.current = null
|
|
1400
|
+
lastFocusedIndexRef.current = safeIndex
|
|
1401
|
+
tryFocusElement(existingElement)
|
|
1402
|
+
}
|
|
1403
|
+
return
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const prefix = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: false } })
|
|
1407
|
+
const itemHeight = prefix.currentValue
|
|
1408
|
+
const itemTop = Math.max(prefix.cumulative - itemHeight, 0)
|
|
1409
|
+
const itemBottom = itemTop + itemHeight
|
|
1410
|
+
const viewportTop = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
|
|
1411
|
+
const viewportBottom = viewportTop + viewportSize
|
|
1412
|
+
const needsScroll = itemTop < viewportTop || itemBottom > viewportBottom
|
|
1413
|
+
if (needsScroll) {
|
|
1414
|
+
scrollToIndex(safeIndex)
|
|
1415
|
+
// オーバースキャン内で既にマウント済みの行は、スクロールしても ref コールバックが
|
|
1416
|
+
// 再実行されない (handleRef の identity 不変) ため、ここで直接フォーカスを適用する。
|
|
1417
|
+
// マウント済み要素へフォーカスできたら pendingFocusIndexRef を残さない
|
|
1418
|
+
// (残すと後刻の無関係な再マウント時にフォーカスを奪ってしまう)。
|
|
1419
|
+
const mounted = itemRefs.current.get(safeIndex)
|
|
1420
|
+
if (mounted) {
|
|
1421
|
+
pendingFocusIndexRef.current = null
|
|
1422
|
+
lastFocusedIndexRef.current = safeIndex
|
|
1423
|
+
tryFocusElement(mounted)
|
|
1424
|
+
} else {
|
|
1425
|
+
pendingFocusIndexRef.current = safeIndex
|
|
1426
|
+
}
|
|
1427
|
+
return
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const element = itemRefs.current.get(safeIndex)
|
|
1431
|
+
if (element) {
|
|
1432
|
+
pendingFocusIndexRef.current = null
|
|
1433
|
+
lastFocusedIndexRef.current = safeIndex
|
|
1434
|
+
tryFocusElement(element)
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
pendingFocusIndexRef.current = safeIndex
|
|
1438
|
+
},
|
|
1439
|
+
[enableKeyboardNavigation, itemCount, fenwickTree, resolvedInsets.top, scrollToIndex, tryFocusElement, viewportSize],
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
const handleItemKeyDown = useCallback(
|
|
1443
|
+
(event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
|
|
1444
|
+
if (!enableKeyboardNavigation || event.defaultPrevented) {
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
if (event.altKey || event.metaKey || event.ctrlKey) {
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
const target = event.target as HTMLElement | null
|
|
1451
|
+
if (target) {
|
|
1452
|
+
const tagName = target.tagName
|
|
1453
|
+
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
if (target.isContentEditable) {
|
|
1457
|
+
return
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (event.key === "ArrowDown") {
|
|
1461
|
+
if (index < itemCount - 1) {
|
|
1462
|
+
event.preventDefault()
|
|
1463
|
+
focusItemAtIndex(index + 1)
|
|
1464
|
+
}
|
|
1465
|
+
return
|
|
1466
|
+
}
|
|
1467
|
+
if (event.key === "ArrowUp") {
|
|
1468
|
+
if (index > 0) {
|
|
1469
|
+
event.preventDefault()
|
|
1470
|
+
focusItemAtIndex(index - 1)
|
|
1471
|
+
}
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
if (event.key === "PageDown") {
|
|
1475
|
+
if (index < itemCount - 1) {
|
|
1476
|
+
event.preventDefault()
|
|
1477
|
+
// 可視範囲は毎レンダー同期している currentRangeRef から読む。
|
|
1478
|
+
// visibleStart/EndIndex を依存配列に入れると、行境界を跨ぐたびに handleItemKeyDown の
|
|
1479
|
+
// identity が変わり全 VirtualScrollItem の React.memo が無効化されるため。
|
|
1480
|
+
const { visibleStartIndex: vStart, visibleEndIndex: vEnd } = currentRangeRef.current
|
|
1481
|
+
const visibleCount = Math.max(vEnd - vStart + 1, 1)
|
|
1482
|
+
const delta = Math.max(visibleCount, 1)
|
|
1483
|
+
const targetIndex = sanitizeIndex(Math.min(index + delta, itemCount - 1), itemCount)
|
|
1484
|
+
focusItemAtIndex(targetIndex)
|
|
1485
|
+
}
|
|
1486
|
+
return
|
|
1487
|
+
}
|
|
1488
|
+
if (event.key === "PageUp") {
|
|
1489
|
+
if (index > 0) {
|
|
1490
|
+
event.preventDefault()
|
|
1491
|
+
const { visibleStartIndex: vStart, visibleEndIndex: vEnd } = currentRangeRef.current
|
|
1492
|
+
const visibleCount = Math.max(vEnd - vStart + 1, 1)
|
|
1493
|
+
const delta = Math.max(visibleCount, 1)
|
|
1494
|
+
const targetIndex = sanitizeIndex(index - delta, itemCount)
|
|
1495
|
+
focusItemAtIndex(targetIndex)
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
},
|
|
1499
|
+
[enableKeyboardNavigation, itemCount, focusItemAtIndex],
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
const handleItemFocus = useCallback(
|
|
1503
|
+
(index: number) => {
|
|
1504
|
+
if (!enableKeyboardNavigation) {
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
const safeIndex = sanitizeIndex(index, itemCount)
|
|
1508
|
+
pendingFocusIndexRef.current = null
|
|
1509
|
+
lastFocusedIndexRef.current = safeIndex
|
|
1510
|
+
onItemFocus?.(safeIndex)
|
|
1511
|
+
},
|
|
1512
|
+
[enableKeyboardNavigation, itemCount, onItemFocus],
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
// レンダリング範囲が変更されたらコールバックを呼ぶ
|
|
1516
|
+
useEffect(() => {
|
|
1517
|
+
const scrollPaneScrollPosition = scrollPaneRef.current?.getScrollPosition() ?? 0
|
|
1518
|
+
const paneScrollPosition = latestScrollPositionRef.current
|
|
1519
|
+
const logicalPosition = toLogicalPositionWithInset(paneScrollPosition, resolvedInsets.top)
|
|
1520
|
+
|
|
1521
|
+
Logger.debug("[VirtualScroll] Range change effect triggered", () => ({
|
|
1522
|
+
renderingStartIndex,
|
|
1523
|
+
renderingEndIndex,
|
|
1524
|
+
visibleStartIndex,
|
|
1525
|
+
visibleEndIndex,
|
|
1526
|
+
scrollPositionState: scrollPosition,
|
|
1527
|
+
paneScrollPosition,
|
|
1528
|
+
logicalScrollPosition: logicalPosition,
|
|
1529
|
+
contentSize,
|
|
1530
|
+
scrollPaneScrollPosition,
|
|
1531
|
+
}))
|
|
1532
|
+
|
|
1533
|
+
scheduleRangeEffect({
|
|
1534
|
+
renderingStartIndex,
|
|
1535
|
+
renderingEndIndex,
|
|
1536
|
+
visibleStartIndex,
|
|
1537
|
+
visibleEndIndex,
|
|
1538
|
+
scrollPosition: logicalPosition,
|
|
1539
|
+
totalHeight: contentSize,
|
|
1540
|
+
})
|
|
1541
|
+
}, [contentSize, renderingEndIndex, renderingStartIndex, resolvedInsets.top, scheduleRangeEffect, scrollPosition, visibleEndIndex, visibleStartIndex])
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Produces visible item nodes and schedules height reconciliation.
|
|
1545
|
+
*
|
|
1546
|
+
* 可視アイテムを描画しつつ高さとの差分更新を手配。
|
|
1547
|
+
*/
|
|
1548
|
+
const renderOverlay = useCallback(() => {
|
|
1549
|
+
if (!enableScrollToTopBottomButtons) {
|
|
1550
|
+
return null
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const isVisible = showScrollButtons && scrollDirection !== null
|
|
1554
|
+
const isTop = scrollDirection === "up"
|
|
1555
|
+
|
|
1556
|
+
return (
|
|
1557
|
+
<div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible}>
|
|
1558
|
+
{isTop ? (
|
|
1559
|
+
<div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-top">
|
|
1560
|
+
<button
|
|
1561
|
+
type="button"
|
|
1562
|
+
className="aqvs-scroll-to-edge-button"
|
|
1563
|
+
onClick={(e) => {
|
|
1564
|
+
e.stopPropagation()
|
|
1565
|
+
isProgrammaticScrollRef.current = true
|
|
1566
|
+
scrollToIndex(0)
|
|
1567
|
+
setShowScrollButtons(false)
|
|
1568
|
+
}}>
|
|
1569
|
+
Top
|
|
1570
|
+
</button>
|
|
1571
|
+
</div>
|
|
1572
|
+
) : (
|
|
1573
|
+
<div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-bottom">
|
|
1574
|
+
<button
|
|
1575
|
+
type="button"
|
|
1576
|
+
className="aqvs-scroll-to-edge-button"
|
|
1577
|
+
onClick={(e) => {
|
|
1578
|
+
e.stopPropagation()
|
|
1579
|
+
isProgrammaticScrollRef.current = true
|
|
1580
|
+
scrollToIndex(itemCount - 1)
|
|
1581
|
+
setShowScrollButtons(false)
|
|
1582
|
+
}}>
|
|
1583
|
+
Bottom
|
|
1584
|
+
</button>
|
|
1585
|
+
</div>
|
|
1586
|
+
)}
|
|
1587
|
+
</div>
|
|
1588
|
+
)
|
|
1589
|
+
}, [enableScrollToTopBottomButtons, showScrollButtons, scrollDirection, scrollToIndex, itemCount])
|
|
1590
|
+
|
|
1591
|
+
// 量子化アンカー (fix: LayoutUnit/f32 精度対策)。行 top はコンテンツ絶対座標そのままではなく
|
|
1592
|
+
// 「絶対座標 - アンカー」で描画し、ラッパー側 translateY にアンカーを足し戻す。
|
|
1593
|
+
// アンカーは描画ウィンドウ先頭が現アンカーから ANCHOR_REBASE_DISTANCE を超えて離れたときだけ
|
|
1594
|
+
// 再基準化する (通常リストでは 0 のままとなり memo 安定性は従来と同一。再基準化フレームのみ
|
|
1595
|
+
// 全行の top が変わるが稀であり許容)。
|
|
1596
|
+
// ref は「最後に採用したアンカー」の再基準化判定用。描画に使う値は memo の返り値 (renderAnchor)
|
|
1597
|
+
// から取り、行 top と translateY が常に同一アンカー基準で整合するようにする。
|
|
1598
|
+
const renderAnchorRef = useRef(0)
|
|
1599
|
+
|
|
1600
|
+
const { visibleItems, renderAnchor } = useMemo(() => {
|
|
1601
|
+
// contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
|
|
1602
|
+
// fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
|
|
1603
|
+
// fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
|
|
1604
|
+
// 両経路とも setContentSize を伴うため、contentSize を依存へ含めることで
|
|
1605
|
+
// 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
|
|
1606
|
+
void contentSize
|
|
1607
|
+
|
|
1608
|
+
if (itemCount === 0) {
|
|
1609
|
+
return {
|
|
1610
|
+
visibleItems: (
|
|
1611
|
+
<div className="aqvs-no-items-container">
|
|
1612
|
+
<div className="aqvs-no-items-text">No items</div>
|
|
1613
|
+
</div>
|
|
1614
|
+
),
|
|
1615
|
+
renderAnchor: 0,
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const safeRenderingStartIndex = sanitizeIndex(renderingStartIndex, itemCount)
|
|
1620
|
+
const safeRenderingEndIndex = sanitizeIndex(renderingEndIndex, itemCount)
|
|
1621
|
+
const { cumulative, currentValue: oldHeight } = fenwickTree.prefixSum(safeRenderingStartIndex, { materializeOption: { materialize: false } })
|
|
1622
|
+
const startPosition = cumulative - oldHeight
|
|
1623
|
+
|
|
1624
|
+
// 量子化アンカーの再基準化: 描画ウィンドウ先頭が現アンカーから一定距離を超えて離れたときだけ
|
|
1625
|
+
// アンカーを付け替える。これにより行 top (= 絶対座標 - アンカー) とラッパー translateY の
|
|
1626
|
+
// 双方が常に ±ANCHOR_REBASE_DISTANCE 近傍の小さな値に収まり、巨大リストの深部でも
|
|
1627
|
+
// ブラウザのレイアウト座標上限 (LayoutUnit) や compositor f32 の量子化誤差を踏まない。
|
|
1628
|
+
if (Number.isFinite(startPosition) && Math.abs(startPosition - renderAnchorRef.current) > ANCHOR_REBASE_DISTANCE) {
|
|
1629
|
+
renderAnchorRef.current = startPosition
|
|
1630
|
+
}
|
|
1631
|
+
const currentAnchor = renderAnchorRef.current
|
|
1632
|
+
|
|
1633
|
+
const toUpdateHeights: Array<{ index: number; value: number }> = []
|
|
1634
|
+
const nodes: ReactNode[] = []
|
|
1635
|
+
|
|
1636
|
+
let cumulativeSizeDelta = 0
|
|
1637
|
+
// 描画ウィンドウ先頭行の絶対 top から、各行の高さを加算して top を求める (O(k))。
|
|
1638
|
+
// 行ごとに prefixSum(i) を呼ぶと O(k·log n) + 多数の BigInt 割り当てが発生するため回避する。
|
|
1639
|
+
let runningBaseOffset = startPosition
|
|
1640
|
+
|
|
1641
|
+
for (let i = safeRenderingStartIndex; i <= safeRenderingEndIndex; i++) {
|
|
1642
|
+
if (nodes.length >= MAX_RENDERED_ITEMS) {
|
|
1643
|
+
// 病的な描画範囲 (高さ 0 行の巨大な連続など) で DOM ノード数が爆発しないよう安全上限で打ち切る。
|
|
1644
|
+
Logger.warn("[VirtualScroll] Rendered item count reached the safety cap; truncating render window.", { cap: MAX_RENDERED_ITEMS, safeRenderingStartIndex, safeRenderingEndIndex })
|
|
1645
|
+
break
|
|
1646
|
+
}
|
|
1647
|
+
const newHeight = getItemHeight(i)
|
|
1648
|
+
const cachedHeight = fenwickTree.get(i)
|
|
1649
|
+
const heightDelta = newHeight - cachedHeight
|
|
1650
|
+
|
|
1651
|
+
if (heightDelta !== 0) {
|
|
1652
|
+
toUpdateHeights.push({ index: i, value: newHeight })
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Adjust the item's top position by adding the cumulative size delta from previous items.
|
|
1656
|
+
// This ensures that if previous items have changed size, the current item is pushed down/up immediately
|
|
1657
|
+
// within the current render cycle, preventing visual glitches (overlapping items) before the re-render.
|
|
1658
|
+
//
|
|
1659
|
+
// 前のアイテムまでの累積的な高さの変化分(cumulativeSizeDelta)を加算して、アイテムの top 位置を補正します。
|
|
1660
|
+
// これにより、前のアイテムのサイズが変わった場合でも、現在のレンダリングサイクル内で即座に位置が調整され、
|
|
1661
|
+
// 再レンダリングまでの間に発生する表示崩れ(アイテムの重なりなど)を防ぐことができます。
|
|
1662
|
+
//
|
|
1663
|
+
// top は「コンテンツ絶対座標 (actualOffset) - 量子化アンカー」とする。描画ウィンドウ起点からの
|
|
1664
|
+
// 相対座標にすると、ウィンドウが 1 行ずれるたびに残存行の top prop が一斉に変化し、
|
|
1665
|
+
// VirtualScrollItem の React.memo が全行で無効化されてしまう。アンカーは再基準化時にしか
|
|
1666
|
+
// 動かないため memo 安定性は絶対座標と同等で、かつ巨大リストでも top が小さな値に収まる。
|
|
1667
|
+
// 上部インセット・スクロール追従・アンカーはラッパー側の transform で一括補正する。
|
|
1668
|
+
const actualOffset = runningBaseOffset + cumulativeSizeDelta
|
|
1669
|
+
const key = getItemKey ? getItemKey(i) : i
|
|
1670
|
+
|
|
1671
|
+
nodes.push(
|
|
1672
|
+
<VirtualScrollItem
|
|
1673
|
+
key={key}
|
|
1674
|
+
index={i}
|
|
1675
|
+
top={actualOffset - currentAnchor}
|
|
1676
|
+
height={newHeight}
|
|
1677
|
+
item={getItem(i)}
|
|
1678
|
+
clipItemHeight={clipItemHeight}
|
|
1679
|
+
enableKeyboardNavigation={enableKeyboardNavigation}
|
|
1680
|
+
onKeyDown={handleItemKeyDown}
|
|
1681
|
+
onFocus={handleItemFocus}
|
|
1682
|
+
registerItemRef={registerItemRef}>
|
|
1683
|
+
{children}
|
|
1684
|
+
</VirtualScrollItem>,
|
|
1685
|
+
)
|
|
1686
|
+
|
|
1687
|
+
cumulativeSizeDelta += heightDelta
|
|
1688
|
+
runningBaseOffset += cachedHeight
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (toUpdateHeights.length > 0) {
|
|
1692
|
+
Promise.resolve().then(() => {
|
|
1693
|
+
// アンマウント後のみ破棄する。初回レンダー由来のマイクロタスクは passive effect より
|
|
1694
|
+
// 先に実行され得るため、「マウント済みか」の判定では正当な初回照合まで破棄してしまう。
|
|
1695
|
+
if (isUnmountedRef.current) {
|
|
1696
|
+
return
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
let shiftAmount = 0
|
|
1700
|
+
// Calculate shift for items strictly above the viewport
|
|
1701
|
+
// renderingStartIndex (captured in closure) typically includes overscan, but what we care about is the VISIBLE start.
|
|
1702
|
+
// visibleStartIndex is also captured in this closure.
|
|
1703
|
+
for (const update of toUpdateHeights) {
|
|
1704
|
+
if (update.index < visibleStartIndex) {
|
|
1705
|
+
const oldVal = fenwickTree.get(update.index)
|
|
1706
|
+
shiftAmount += update.value - oldVal
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const total = fenwickTree.updates(toUpdateHeights)
|
|
1711
|
+
if (isUnmountedRef.current || typeof total !== "number") {
|
|
1712
|
+
return
|
|
1713
|
+
}
|
|
1714
|
+
setContentSize(total)
|
|
1715
|
+
Logger.debug("[VirtualScroll] Updated heights for items", toUpdateHeights, "New total height:", total)
|
|
1716
|
+
const panePosition = scrollPaneRef.current?.getScrollPosition() ?? latestScrollPositionRef.current
|
|
1717
|
+
|
|
1718
|
+
if (shiftAmount !== 0) {
|
|
1719
|
+
const newPosition = panePosition + shiftAmount
|
|
1720
|
+
// updateItemSize と同型: scrollTo は旧 contentSize でクランプされ得るため、
|
|
1721
|
+
// issueCompensationScroll でリークを防ぎつつ、乖離時は contentSize 同期 effect で再発行する。
|
|
1722
|
+
issueCompensationScroll(newPosition)
|
|
1723
|
+
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
1724
|
+
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (auto update)", { from: panePosition, to: newPosition, shiftAmount })
|
|
1725
|
+
} else if (panePosition !== latestScrollPositionRef.current) {
|
|
1726
|
+
updateScrollPositionImmediate(panePosition)
|
|
1727
|
+
}
|
|
1728
|
+
})
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
return { visibleItems: nodes, renderAnchor: currentAnchor }
|
|
1732
|
+
}, [
|
|
1733
|
+
children,
|
|
1734
|
+
clipItemHeight,
|
|
1735
|
+
contentSize,
|
|
1736
|
+
enableKeyboardNavigation,
|
|
1737
|
+
itemCount,
|
|
1738
|
+
fenwickTree,
|
|
1739
|
+
getItem,
|
|
1740
|
+
getItemKey,
|
|
1741
|
+
getItemHeight,
|
|
1742
|
+
handleItemFocus,
|
|
1743
|
+
handleItemKeyDown,
|
|
1744
|
+
issueCompensationScroll,
|
|
1745
|
+
registerItemRef,
|
|
1746
|
+
renderingEndIndex,
|
|
1747
|
+
renderingStartIndex,
|
|
1748
|
+
updateScrollPositionImmediate,
|
|
1749
|
+
visibleStartIndex,
|
|
1750
|
+
])
|
|
1751
|
+
|
|
1752
|
+
const renderVisibleItems = useCallback(
|
|
1753
|
+
(currentScrollPosition: number) => {
|
|
1754
|
+
const shouldUseThrottledPosition = (callbackThrottleMs ?? 0) > 0
|
|
1755
|
+
const diff = Math.abs(currentScrollPosition - scrollPosition)
|
|
1756
|
+
const rawEffectiveScrollPosition = shouldUseThrottledPosition && diff > 0.5 ? scrollPosition : currentScrollPosition
|
|
1757
|
+
const effectiveScrollPosition = toLogicalPositionWithInset(rawEffectiveScrollPosition, resolvedInsets.top)
|
|
1758
|
+
|
|
1759
|
+
Logger.debug("[VirtualScroll] Rendering visible items", () => ({
|
|
1760
|
+
currentScrollPosition,
|
|
1761
|
+
effectiveScrollPosition,
|
|
1762
|
+
renderingStartIndex,
|
|
1763
|
+
renderingEndIndex,
|
|
1764
|
+
itemCount,
|
|
1765
|
+
viewportSize,
|
|
1766
|
+
callbackThrottleMs,
|
|
1767
|
+
diff,
|
|
1768
|
+
rawEffectiveScrollPosition,
|
|
1769
|
+
}))
|
|
1770
|
+
|
|
1771
|
+
if (itemCount === 0) {
|
|
1772
|
+
return visibleItems
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// アイテム top は「コンテンツ絶対座標 - 量子化アンカー」なので、ラッパー側でスクロール追従・
|
|
1776
|
+
// 上部インセット・アンカーを一括補正する。ペイン座標 (rawEffectiveScrollPosition) を基準に
|
|
1777
|
+
// visual = insets.top + (top + anchor) - pane となるよう、ラッパーを insets.top + anchor - pane
|
|
1778
|
+
// だけ移動させる。アンカーと pane は同オーダーで追従するため translateY は常に小さな値に収まり、
|
|
1779
|
+
// compositor f32 の精度限界 (2^24px 付近からの量子化) を踏まない。ラッパーは CSS の
|
|
1780
|
+
// position:absolute (top:0 = パディング辺 = 0) のまま transform: translateY で動かすことで、
|
|
1781
|
+
// 毎フレームのリフローを避けコンポジタのみで完結させる。
|
|
1782
|
+
// アンカーは visibleItems と同じ memo の返り値を使い、行 top との整合を保証する。
|
|
1783
|
+
const containerTop = resolvedInsets.top + renderAnchor - rawEffectiveScrollPosition
|
|
1784
|
+
|
|
1785
|
+
const bottomInset =
|
|
1786
|
+
resolvedInsets.bottom > 0 ? (
|
|
1787
|
+
<div
|
|
1788
|
+
key="virtualscroll-bottom-inset"
|
|
1789
|
+
className="aqvs-bottom-inset"
|
|
1790
|
+
style={{
|
|
1791
|
+
// ラッパー座標での bottom インセット位置 = コンテンツ総高さ (最終行の下端) - アンカー。
|
|
1792
|
+
top: fenwickTree.getTotal() - renderAnchor,
|
|
1793
|
+
height: resolvedInsets.bottom,
|
|
1794
|
+
}}
|
|
1795
|
+
/>
|
|
1796
|
+
) : null
|
|
1797
|
+
|
|
1798
|
+
Logger.debug("[VirtualScroll] Rendering items", () => ({
|
|
1799
|
+
containerTop,
|
|
1800
|
+
logicalScrollPosition,
|
|
1801
|
+
resolvedInsets,
|
|
1802
|
+
effectiveScrollPosition,
|
|
1803
|
+
}))
|
|
1804
|
+
|
|
1805
|
+
return (
|
|
1806
|
+
<div className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${containerTop}px)`, willChange: "transform" }}>
|
|
1807
|
+
{visibleItems}
|
|
1808
|
+
{bottomInset}
|
|
1809
|
+
</div>
|
|
1810
|
+
)
|
|
1811
|
+
},
|
|
1812
|
+
[callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
const currentRange = useMemo<VirtualScrollRange>(
|
|
1816
|
+
() => ({
|
|
1817
|
+
renderingStartIndex: renderingRanges.renderingStartIndex,
|
|
1818
|
+
renderingEndIndex: renderingRanges.renderingEndIndex,
|
|
1819
|
+
visibleStartIndex: renderingRanges.visibleStartIndex,
|
|
1820
|
+
visibleEndIndex: renderingRanges.visibleEndIndex,
|
|
1821
|
+
scrollPosition: logicalScrollPosition,
|
|
1822
|
+
totalHeight: fenwickTree.getTotal(), // contentSize (State) ではなく最新値を反映
|
|
1823
|
+
}),
|
|
1824
|
+
// contentSize を依存に含めることで、updateItemSize/updates 等で木の総高さが変わった際に
|
|
1825
|
+
// (fenwickTree は安定参照のため getTotal() の変化だけでは再計算されない) 総高さを再取得する。
|
|
1826
|
+
[renderingRanges, logicalScrollPosition, fenwickTree, contentSize],
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
useEffect(() => {
|
|
1830
|
+
// 目的: 描画範囲を最新の状態で同期する ref を更新する。
|
|
1831
|
+
// 依存関係: currentRange
|
|
1832
|
+
currentRangeRef.current = currentRange
|
|
1833
|
+
}, [currentRange])
|
|
1834
|
+
|
|
1835
|
+
useImperativeHandle(
|
|
1836
|
+
ref,
|
|
1837
|
+
() => ({
|
|
1838
|
+
getScrollPosition: () => scrollPaneRef.current?.getScrollPosition() ?? -1,
|
|
1839
|
+
getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
|
|
1840
|
+
getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
|
|
1841
|
+
scrollTo: scrollToHandle,
|
|
1842
|
+
scrollToIndex,
|
|
1843
|
+
getFenwickTreeTotalHeight: () => fenwickTree.getTotal(),
|
|
1844
|
+
getFenwickSize: () => fenwickTree.getSize(),
|
|
1845
|
+
focusItemAtIndex,
|
|
1846
|
+
getRange: () => currentRangeRef.current,
|
|
1847
|
+
updateItemSize,
|
|
1848
|
+
}),
|
|
1849
|
+
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, updateItemSize],
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
|
|
1853
|
+
|
|
1854
|
+
return (
|
|
1855
|
+
<ScrollPane
|
|
1856
|
+
ref={scrollPaneRef}
|
|
1857
|
+
contentSize={totalContentHeight}
|
|
1858
|
+
viewportSize={viewportSize}
|
|
1859
|
+
className={className}
|
|
1860
|
+
testId={testId}
|
|
1861
|
+
onScroll={handleScroll}
|
|
1862
|
+
background={background}
|
|
1863
|
+
tapScrollCircleOptions={tapScrollCircleOptions}
|
|
1864
|
+
inertiaOptions={inertiaOptions}
|
|
1865
|
+
itemCount={itemCount}
|
|
1866
|
+
scrollBarWidth={scrollBarWidth}
|
|
1867
|
+
enableThumbDrag={enableThumbDrag}
|
|
1868
|
+
enableTrackClick={enableTrackClick}
|
|
1869
|
+
enableArrowButtons={enableArrowButtons}
|
|
1870
|
+
enablePointerDrag={enablePointerDrag}
|
|
1871
|
+
renderThumbOverlay={renderThumbOverlay}
|
|
1872
|
+
wheelSpeedMultiplier={wheelSpeedMultiplier}
|
|
1873
|
+
onWheelHorizontal={onWheelHorizontal}
|
|
1874
|
+
contentInsets={resolvedInsets}
|
|
1875
|
+
visibleStartIndex={visibleStartIndex}
|
|
1876
|
+
visibleEndIndex={visibleEndIndex}
|
|
1877
|
+
renderOverlay={renderOverlay}
|
|
1878
|
+
initialScrollPosition={initialValues.position}>
|
|
1879
|
+
{renderVisibleItems}
|
|
1880
|
+
</ScrollPane>
|
|
1881
|
+
)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* A highly performant virtual scroll component that supports variable item heights.
|
|
1886
|
+
* Uses a Fenwick Tree (Binary Indexed Tree) for efficient offset calculation and range queries.
|
|
1887
|
+
*
|
|
1888
|
+
* 可変のアイテム高さをサポートする高性能な仮想スクロールコンポーネント。
|
|
1889
|
+
* オフセット計算と範囲クエリを効率化するために Fenwick Tree (Binary Indexed Tree) を使用しています。
|
|
1890
|
+
*/
|
|
1891
|
+
export const VirtualScroll = forwardRef(VirtualScrollInner) as <T>(props: VirtualScrollProps<T> & { ref?: React.Ref<VirtualScrollHandle> }) => React.ReactElement
|