@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.
Files changed (40) hide show
  1. package/README.md +20 -13
  2. package/dist/ScrollPane.d.cts +2 -0
  3. package/dist/ScrollPane.d.ts +2 -0
  4. package/dist/ScrollPane.d.ts.map +1 -1
  5. package/dist/VirtualScroll.d.cts +2 -0
  6. package/dist/VirtualScroll.d.ts +2 -0
  7. package/dist/VirtualScroll.d.ts.map +1 -1
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.cts +6 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1115 -1112
  13. package/dist/styles/virtualscroll.css +1 -1
  14. package/dist/styles/virtualscroll.standalone.css +3 -0
  15. package/package.json +6 -4
  16. package/src/ScrollBar.spec.tsx +620 -0
  17. package/src/ScrollBar.tsx +1397 -0
  18. package/src/ScrollPane.spec.tsx +482 -0
  19. package/src/ScrollPane.tsx +913 -0
  20. package/src/TapScrollCircle.spec.tsx +275 -0
  21. package/src/TapScrollCircle.tsx +363 -0
  22. package/src/VirtualScroll.spec.ts +623 -0
  23. package/src/VirtualScroll.tsx +1891 -0
  24. package/src/cli.server.spec.ts +137 -0
  25. package/src/cli.server.ts +110 -0
  26. package/src/index.ts +24 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +7 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +298 -0
  32. package/src/tapScrollCircleSampleVisual.tsx +74 -0
  33. package/src/useFenwickMapTree.huge.spec.ts +388 -0
  34. package/src/useFenwickMapTree.spec.ts +1518 -0
  35. package/src/useFenwickMapTree.ts +1368 -0
  36. package/src/useHeightCache.ts +32 -0
  37. package/src/useLruCache.spec.ts +382 -0
  38. package/src/useLruCache.ts +301 -0
  39. package/src/utils.spec.ts +39 -0
  40. 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