@aiquants/virtualscroll 3.9.2 → 3.11.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 (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +135 -40
  3. package/dist/ScrollBar.d.cts +45 -38
  4. package/dist/ScrollBar.d.ts +45 -38
  5. package/dist/ScrollBar.d.ts.map +1 -1
  6. package/dist/ScrollPane.d.cts +12 -14
  7. package/dist/ScrollPane.d.ts +12 -14
  8. package/dist/ScrollPane.d.ts.map +1 -1
  9. package/dist/VirtualGrid.d.cts +6 -6
  10. package/dist/VirtualGrid.d.ts +6 -6
  11. package/dist/VirtualGrid.d.ts.map +1 -1
  12. package/dist/VirtualScroll.d.cts +184 -30
  13. package/dist/VirtualScroll.d.ts +184 -30
  14. package/dist/VirtualScroll.d.ts.map +1 -1
  15. package/dist/cli.js +26 -47
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.js +2385 -2413
  18. package/dist/labels.d.cts +38 -7
  19. package/dist/labels.d.ts +38 -7
  20. package/dist/labels.d.ts.map +1 -1
  21. package/dist/logger.d.cts +1 -16
  22. package/dist/logger.d.ts +1 -16
  23. package/dist/logger.d.ts.map +1 -1
  24. package/dist/styles/virtualscroll.css +1 -1
  25. package/dist/styles/virtualscroll.standalone.css +1 -1
  26. package/dist/useFenwickMapTree.d.cts +21 -1
  27. package/dist/useFenwickMapTree.d.ts +21 -1
  28. package/dist/useFenwickMapTree.d.ts.map +1 -1
  29. package/dist/useLruCache.d.ts.map +1 -1
  30. package/dist/utils.d.cts +14 -0
  31. package/dist/utils.d.ts +14 -0
  32. package/dist/utils.d.ts.map +1 -1
  33. package/package.json +5 -2
  34. package/src/ScrollBar.tsx +115 -55
  35. package/src/ScrollPane.tsx +12 -14
  36. package/src/VirtualGrid.tsx +27 -25
  37. package/src/VirtualScroll.tsx +486 -234
  38. package/src/labels.ts +58 -7
  39. package/src/logger.ts +1 -25
  40. package/src/styles/virtualscroll.css +51 -3
  41. package/src/useFenwickMapTree.ts +60 -98
  42. package/src/useLruCache.ts +14 -8
  43. package/src/utils.ts +15 -0
@@ -1,10 +1,10 @@
1
- import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
1
+ import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react"
2
2
  import { type DevicePixelSnapEdge, snapToDevicePixelGrid, usePaintingDevicePixelRatio } from "./devicePixelGrid.ts"
3
3
  import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type VirtualScrollLocale } from "./labels.ts"
4
4
  import { Logger } from "./logger.ts"
5
5
  import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
6
6
  import { useFenwickMapTree } from "./useFenwickMapTree.ts"
7
- import { minmax } from "./utils.ts"
7
+ import { getAxisScale, keepFocusOnPress, minmax } from "./utils.ts"
8
8
 
9
9
  /**
10
10
  * Represents the current state of the rendered range and scroll metrics.
@@ -140,18 +140,41 @@ export type VirtualScrollHandle = {
140
140
  /**
141
141
  * Scrolls to a specific item index, landing exactly on the aligned position (clamped to the content). A `"top"`
142
142
  * (default) or `"bottom"` alignment is remembered at that position, so the device-pixel snap of the items wrapper
143
- * keeps the aligned edge there (see the README's device-pixel snapping section).
143
+ * keeps the aligned edge there (see the README's device-pixel snapping section). `"top"`, `"bottom"` and `"center"`
144
+ * align to the viewport itself, and `offset` (px) is subtracted from the aligned position.
145
+ *
146
+ * `"nearest"` is the reveal, the rule focus moves and the row keyboard steps use too: it scrolls the least distance that
147
+ * brings the row wholly into the viewport, minus the edge a visible scroll-to-edge pill covers (from that edge to the
148
+ * pill's far side), and does nothing when the row already lies wholly inside. A row above lands with its top at the
149
+ * band's top, a row below with its bottom at the band's bottom, and a row taller than the band with its top at the
150
+ * band's top; the landing is kept like a `"top"` / `"bottom"` alignment, so a pill that hides later moves nothing.
151
+ * `"nearest"` takes no `offset`.
144
152
  *
145
153
  * 指定したアイテムインデックスへスクロールする処理。揃えた位置 (中身の範囲へクランプ) へ厳密に着地する。`"top"` (既定) と
146
154
  * `"bottom"` の揃えはその位置で覚えるので、そこでは行ラッパーの装置の画素への揃えが揃えた端を守る (README の装置の画素への
147
- * 揃えの節を参照)。
155
+ * 揃えの節を参照)。`"top"`・`"bottom"`・`"center"` は表示域そのものに揃え、`offset` (px) を揃えた位置から引く。
156
+ *
157
+ * `"nearest"` は見せる操作で、フォーカスの移動と行のキー移動も同じ規則を使う。行が表示域から、見えている端へ戻るピルが覆う
158
+ * 端 (その端からピルの向こう側まで) を除いた帯にまるごと入る最短の距離だけスクロールし、既にまるごと入っていれば何もしない。
159
+ * 上の行は上端を帯の上端へ、下の行は下端を帯の下端へ、帯より高い行は上端を帯の上端へ着地させ、着地は `"top"` / `"bottom"`
160
+ * の揃えと同じく保つので、後でピルが隠れても何も動かない。`"nearest"` は `offset` を取らない。
161
+ *
162
+ * @throws {RangeError} When `"nearest"` comes with an `offset` / `"nearest"` に `offset` を渡したとき
148
163
  */
149
- scrollToIndex: (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => void
164
+ scrollToIndex: (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number } | { align: "nearest"; offset?: never }) => void
150
165
  /** Gets the total height managed by the Fenwick Tree / Fenwick Tree で管理されている総高さを取得 */
151
166
  getFenwickTreeTotalHeight: () => number
152
167
  /** Gets the number of items in the Fenwick Tree / Fenwick Tree 内のアイテム数を取得 */
153
168
  getFenwickSize: () => number
154
- /** Focuses an item at the specified index / 指定したインデックスのアイテムにフォーカス */
169
+ /**
170
+ * Focuses the row at the specified index (with `behaviorOptions.enableKeyboardNavigation`). With `ensureVisible` (default
171
+ * `true`) it first reveals the row like `scrollToIndex(index, { align: "nearest" })`, so a row a visible scroll-to-edge
172
+ * pill covers is brought out from under it; with `false` it focuses a rendered row without scrolling.
173
+ *
174
+ * 指定したインデックスの行へフォーカスする処理 (`behaviorOptions.enableKeyboardNavigation` のとき)。`ensureVisible` (既定
175
+ * `true`) では先に `scrollToIndex(index, { align: "nearest" })` と同じく行を見せるので、見えている端へ戻るピルが覆う行は
176
+ * その下から出る。`false` では描いてある行をスクロールせずにフォーカスする。
177
+ */
155
178
  focusItemAtIndex: (index: number, options?: { ensureVisible?: boolean }) => void
156
179
  /** Gets the current scroll range information / 現在のスクロール範囲情報を取得 */
157
180
  getRange: () => VirtualScrollRange
@@ -196,21 +219,23 @@ export type VirtualScrollScrollBarOptions = {
196
219
  /** Whether the arrow buttons scroll (default `true`). / 矢印ボタンによるスクロールを許可するかどうか (既定 `true`)。 */
197
220
  enableArrowButtons?: boolean
198
221
  /**
199
- * Whether the scrollbar's arrow buttons are Tab stops (default `true`). Set it to `false` when
200
- * the host already provides keyboard scrolling (roving row focus with Arrow / Page / Home / End):
201
- * the arrows then only add two redundant Tab stops per bar — native scrollbars are never Tab
202
- * stops either. `false` changes `tabIndex` alone (`-1`): the arrows stay pointer-operable and
203
- * named, and Enter / Space still scroll when one is focused from script.
204
- * The default stays `true` because the rows are not Tab stops, so without a host keyboard model
205
- * the arrows are the only scrolling control a keyboard user can reach with Tab. Full contract:
222
+ * Whether the scroll chrome is the keyboard user's scrolling control (default `true`), or pointer-only because the
223
+ * host scrolls the list by keyboard itself (`false`: roving row focus with Arrow / Page / Home / End).
224
+ * `true`: the scrollbar is a named `role="scrollbar"` with a named `role="slider"` thumb, and its arrows (and the
225
+ * visible scroll-to-edge pills) are Tab stops — the rows are not Tab stops, so without a host keyboard model they are
226
+ * the only scrolling controls a keyboard user can reach with Tab.
227
+ * `false`: the scrollbar and the scroll-to-edge pills become pointer-only, like a native scrollbar: both carry
228
+ * `aria-hidden="true"`, nothing in them is a Tab stop, and a press on them never moves focus (neither onto them nor
229
+ * away from where the host put it); pointer scrolling and the pills' clicks are unchanged. Full contract:
206
230
  * `ScrollBarProps["enableArrowButtonTabStops"]`.
207
- * スクロールバーの矢印ボタンを Tab の止まり先にするかどうか (既定 `true`)。ホストがキーボード
208
- * スクロールを既に提供する場合 (行のロービングフォーカスと矢印 / Page / Home / End) に `false` —
209
- * 矢印はバー 1 本あたり冗長な Tab の止まり先を 2 つ足すだけになるため (ネイティブのスクロールバーも
210
- * Tab の止まり先にならない)。`false` が変えるのは `tabIndex` (`-1`) だけで、ポインタ操作・
211
- * アクセシブルネームと、スクリプトからフォーカスした矢印の Enter / Space は維持。既定が `true`
212
- * なのは、行が Tab の止まり先ではないため、ホストのキーボードモデルが無ければ矢印がキーボード
213
- * 利用者の Tab で届く唯一のスクロール操作部品だから。契約の全文は
231
+ * スクロールの部品がキーボード利用者のスクロール操作部品か (既定 `true`)、ホストが一覧のキーボードスクロールを
232
+ * 自分で持つためポインタ専用か (`false`: 行のロービングフォーカスと矢印 / Page / Home / End) の指定。
233
+ * `true`: スクロールバーは名前付きの `role="scrollbar"` で名前付きの `role="slider"` のつまみを持ち、矢印 (と見えている
234
+ * 端へ戻るピル) は Tab の止まり先 — 行は Tab の止まり先ではないため、ホストのキーボードモデルが無ければ Tab で届く
235
+ * 唯一のスクロール操作部品。
236
+ * `false`: スクロールバーと端へ戻るピルはネイティブのスクロールバーと同じくポインタ専用になる。どちらも
237
+ * `aria-hidden="true"` を持ち、中に Tab の止まり先は無く、押下はフォーカスを動かさない (部品へも、ホストが置いた
238
+ * 場所の外へも)。ポインタのスクロールとピルの click は変わらない。契約の全文は
214
239
  * `ScrollBarProps["enableArrowButtonTabStops"]`。
215
240
  */
216
241
  enableArrowButtonTabStops?: boolean
@@ -314,12 +339,12 @@ export type VirtualScrollLiveRegionOptions = {
314
339
  * Formats the announcement text. Called after the visible range settles; returning the same
315
340
  * string as last time leaves the DOM untouched (no re-announcement), returning `""` clears
316
341
  * the region. The live-region wording and its language are consumer-owned: the package's
317
- * built-in catalog (`locale` / `labels`) covers only the seven chrome strings (scrollbar arrow
318
- * aria-labels, the scroll-to-edge pills and the empty state), never announcements.
342
+ * built-in catalog (`locale` / `labels`) covers only the eleven chrome strings (the scrollbar's
343
+ * accessible names, the scroll-to-edge pills and the empty state), never announcements.
319
344
  * 読み上げ文言を組み立てる。可視範囲が静定した後に呼ばれ、前回と同じ文字列なら DOM を
320
345
  * 触らない (再読み上げしない)。`""` を返すとリージョンを空にする。読み上げの文面と言語は
321
- * 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム 7 文言
322
- * (スクロールバー矢印の aria-label、端スクロールピル、空状態) だけで、読み上げは対象外。
346
+ * 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム 11 文言
347
+ * (スクロールバーのアクセシブルネーム、端スクロールピル、空状態) だけで、読み上げは対象外。
323
348
  *
324
349
  * @param range - The settled visible range / 静定した可視範囲
325
350
  * @returns Announcement text / 読み上げ文言
@@ -428,13 +453,14 @@ export type VirtualScrollProps<T> = {
428
453
  */
429
454
  liveRegion?: VirtualScrollLiveRegionOptions
430
455
  /**
431
- * UI chrome locale of the built-in strings (default `"en"`): the scrollbar arrow aria-labels,
432
- * the scroll-to-edge pills and the empty-state text. Live-region wording is not affected (see
456
+ * UI chrome locale of the built-in strings (default `"en"`): the scrollbar's accessible names
457
+ * (the bar, its thumb and its arrows), the scroll-to-edge pills and the empty-state text, and of the
458
+ * percentage the bar and its thumb carry as their value text. Live-region wording is not affected (see
433
459
  * {@link VirtualScrollLiveRegionOptions.format}). An unsupported value throws a RangeError at
434
460
  * render; no language negotiation happens.
435
- * 内蔵文言 (スクロールバー矢印の aria-label、端スクロールピル、空状態文言) の UI クロームロケール
436
- * (既定 `"en"`)。ライブリージョンの文言には影響しない ({@link VirtualScrollLiveRegionOptions.format}
437
- * 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
461
+ * 内蔵文言 (スクロールバーのアクセシブルネーム — バー・つまみ・矢印 —、端スクロールピル、空状態文言) と、バーとつまみが
462
+ * 値の文字として持つ百分率の UI クロームロケール (既定 `"en"`)。ライブリージョンの文言には影響しない
463
+ * ({@link VirtualScrollLiveRegionOptions.format} 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
438
464
  */
439
465
  locale?: VirtualScrollLocale
440
466
  /**
@@ -696,6 +722,70 @@ const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): Aligne
696
722
  */
697
723
  const canonicalAlignedEdge = (alignment: AlignedEdge | null): AlignedEdge | null => (alignment !== null && alignment.panePosition <= 0 ? null : alignment)
698
724
 
725
+ /**
726
+ * The parts of the viewport that something drawn over the rows covers — the visible scroll-to-edge pill — as px from each
727
+ * edge to the far side of what covers it. Module-level export (NOT in the package barrel), the parameter type of
728
+ * `resolveRevealAlignment`.
729
+ *
730
+ * 行の上に描いたもの (見えている端へ戻るピル) が覆う表示域の部分。各端から覆うものの向こう側までの px。モジュールレベル
731
+ * export (バレル非公開)。`resolveRevealAlignment` の引数の型。
732
+ */
733
+ export type ObscuredInsets = {
734
+ /** Covered px from the top edge / 上端から覆われた px */
735
+ readonly top: number
736
+ /** Covered px from the bottom edge / 下端から覆われた px */
737
+ readonly bottom: number
738
+ }
739
+
740
+ /** A viewport nothing covers / 何も覆わない表示域 */
741
+ const NO_OBSCURED_INSETS: ObscuredInsets = Object.freeze({ top: 0, bottom: 0 })
742
+
743
+ /**
744
+ * How a reveal lands a row: with its top at the top of the band the obscured insets leave (`"top"`) or with its bottom at the
745
+ * band's bottom (`"bottom"`), as the alignment and the `offset` `scrollToIndex` subtracts from the position aligned to the
746
+ * viewport's edge. Module-level export (NOT in the package barrel), the return type of `resolveRevealAlignment`.
747
+ *
748
+ * 見せる操作が行を着地させる揃え。覆われた部分を除いた帯の上端へ行の上端を (`"top"`)、帯の下端へ行の下端を (`"bottom"`) 置く
749
+ * 揃えと、表示域の端に揃えた位置から `scrollToIndex` が引く `offset`。モジュールレベル export (バレル非公開)。
750
+ * `resolveRevealAlignment` の戻り値の型。
751
+ */
752
+ export type RevealAlignment = {
753
+ /** The edge of the band the row lands at / 行が着地する帯の端 */
754
+ readonly align: "top" | "bottom"
755
+ /** Px subtracted from the position aligned to the viewport's edge (the covered px; negative at the bottom) / 表示域の端に揃えた位置から引く px (覆われた px。下端では負) */
756
+ readonly offset: number
757
+ }
758
+
759
+ /**
760
+ * Resolves the reveal of a row — the one rule of `scrollToIndex` with `align: "nearest"`, of `focusItemAtIndex` and of the
761
+ * row keyboard steps: the least scroll that brings the row wholly into the band of the viewport the obscured insets leave.
762
+ * A row above the band lands with its top at the band's top, a row below with its bottom at the band's bottom, and a row
763
+ * taller than the band with its top at the band's top. A row within `EDGE_POSITION_TOLERANCE` of the band counts as inside.
764
+ * Module-level export (NOT in the package barrel).
765
+ *
766
+ * 行を見せる操作を解決する処理。`align: "nearest"` の `scrollToIndex`・`focusItemAtIndex`・行のキー移動に共通のただ 1 つの
767
+ * 規則で、覆われた部分を除いた表示域の帯へ行をまるごと入れる最短のスクロール。帯より上の行は上端を帯の上端へ、下の行は
768
+ * 下端を帯の下端へ、帯より高い行は上端を帯の上端へ着地させる。帯から `EDGE_POSITION_TOLERANCE` 以内の行は中にあるとみなす。
769
+ * モジュールレベル export (バレル非公開)。
770
+ *
771
+ * @param row - The row's logical top and bottom (px) / 行の論理上端と論理下端 (px)
772
+ * @param position - Logical scroll position (px) / 論理スクロール位置 (px)
773
+ * @param viewportSize - Viewport size (px) / 表示域の高さ (px)
774
+ * @param insets - The obscured insets / 覆われた部分
775
+ * @returns How to land the row, or `null` when it already lies wholly inside the band / 行の着地のさせ方 (既に帯にまるごと入っていれば `null`)
776
+ */
777
+ export const resolveRevealAlignment = (row: { readonly top: number; readonly bottom: number }, position: number, viewportSize: number, insets: ObscuredInsets): RevealAlignment | null => {
778
+ const bandTop = position + insets.top
779
+ const bandBottom = position + viewportSize - insets.bottom
780
+ if (row.top >= bandTop - EDGE_POSITION_TOLERANCE && row.bottom <= bandBottom + EDGE_POSITION_TOLERANCE) {
781
+ return null
782
+ }
783
+ if (row.top < bandTop || row.bottom - row.top > bandBottom - bandTop) {
784
+ return { align: "top", offset: insets.top }
785
+ }
786
+ return { align: "bottom", offset: -insets.bottom }
787
+ }
788
+
699
789
  /**
700
790
  * Chooses the edge the items-wrapper translate keeps when it is snapped to the device-pixel grid
701
791
  * (`snapToDevicePixelGrid`), so that aligned content never loses part of its edge gutter to the snap:
@@ -754,6 +844,8 @@ type ItemsWrapperProps = {
754
844
  readonly snapEdge: DevicePixelSnapEdge
755
845
  /** The rendered rows and the bottom inset / 描いた行と下のインセット */
756
846
  readonly children: ReactNode
847
+ /** Receives the boundary box, whose top is the top of the viewport (the reveals measure a visible pill against it) / 境界の箱を受け取る ref。箱の上端は表示域の上端 (見せる操作は見えているピルをこれに対して測る) */
848
+ readonly boundaryRef: React.Ref<HTMLDivElement>
757
849
  }
758
850
 
759
851
  /**
@@ -765,18 +857,29 @@ type ItemsWrapperProps = {
765
857
  * render anchor is a whole number of device px — for example when every row height is a multiple of a lattice L with
766
858
  * L × ratio a whole number (L = 4 px at ratios in quarter steps).
767
859
  *
860
+ * The wrapper sits in `.aqvs-items-boundary`, its containing block and a relayout boundary: a positioned box with size and
861
+ * layout containment that is neither a flex nor a grid item, attached to the top, left and right padding edges of the pane
862
+ * content (so the wrapper keeps its position and width) with a height of 0. A step that mounts or unmounts rows therefore
863
+ * lays out from that box instead of from the document root. The rows overflow it unclipped, as ink overflow that is not
864
+ * part of the pane content's scrollable area, and the box takes no hit test of its own.
865
+ *
768
866
  * 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`) で、平行移動は描くウィンドウの装置の画素の格子へ
769
867
  * `snapEdge` の側で揃える (`snapToDevicePixelGrid`。比は `usePaintingDevicePixelRatio` が描画の中で読むので、自分の
770
868
  * ウィンドウの文書に描いた一覧は最初の確定から揃う)。揃えるのは層の平行移動だけ。層の中の行は厳密なレイアウトの位置に
771
869
  * 描かれ、角の丸い枠線・輪・輪郭はそこで滲む。行が装置の画素の整数から始まるのは、描画のアンカーからの行の位置が装置 px の
772
870
  * 整数のときだけ (例: どの行の高さも、L × 比が整数になる格子 L の倍数のとき。比が 4 分の 1 刻みなら L = 4px)。
773
871
  *
774
- * @param props - The exact translate, the edge it keeps and the wrapped rows / 厳密な平行移動・守る端・包む行
775
- * @returns The wrapper element / ラッパーの要素
872
+ * ラッパーは包含ブロックで配置の境界の `.aqvs-items-boundary` の中に置く。位置指定され、大きさと配置を封じ込め、flex の子でも
873
+ * grid の子でもない箱で、ペインの中身のパディングの上・左・右の辺に付き (ラッパーの位置と幅は変わらない)、高さは 0。行を足し引き
874
+ * する 1 段の配置は、文書の根ではなくこの箱から始まる。行は箱から切り取られずにはみ出し (ペインの中身のスクロールできる範囲に
875
+ * 数えないインクのはみ出し)、箱自身は当たり判定を取らない。
876
+ *
877
+ * @param props - The exact translate, the edge it keeps, the wrapped rows and the boundary box's ref / 厳密な平行移動・守る端・包む行・境界の箱の ref
878
+ * @returns The boundary box holding the wrapper element / ラッパーの要素を包む境界の箱
776
879
  * @throws {Error} When the wrapper attaches to a document without a window (see `usePaintingDevicePixelRatio`) / ウィンドウを持たない文書へ取り付いたとき (`usePaintingDevicePixelRatio` を参照)
777
880
  * @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
778
881
  */
779
- const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) => {
882
+ const ItemsWrapper = ({ translateY, snapEdge, children, boundaryRef }: ItemsWrapperProps) => {
780
883
  const { ratio, attach } = usePaintingDevicePixelRatio("[VirtualScroll] the items wrapper")
781
884
  // ❗ ラッパーは合成層なので、平行移動が装置の画素の端数を持つとブラウザは層ごと再標本化する (実測: -2440.5px で
782
885
  // 2px の輪郭・2px の隙間・2px の輪が 3+1+3 行に滲み、下辺の隙間が消える)。揃えるのは層の平行移動だけ —
@@ -785,9 +888,13 @@ const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) =>
785
888
  // ❗ 層の様式は will-change: transform だけ。perspective や 3D の変換を足すと Chromium は描画の切り取り (cull rect) を
786
889
  // 外して描くが、行を非 2D の変換の下で合成するので画素比によっては層ごと再標本化する (実測: 比 1.25 の明色で選択の輪と
787
890
  // フォーカスの輪の間の帯が混ざる)。窓をずらす 1 段がはみ出しを切る中身の行を描き直す費用は、その代わりに受け入れる
891
+ // ❗ ペインの中身は flex の子で配置の境界になれない。境界の箱を挟まないと、行を足し引きする 1 段の配置が毎回文書の根から
892
+ // 始まる (実測: 4 倍の CPU で 1 段の配置の時間のすべてが文書の根から)
788
893
  return (
789
- <div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
790
- {children}
894
+ <div ref={boundaryRef} className="aqvs-items-boundary">
895
+ <div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
896
+ {children}
897
+ </div>
791
898
  </div>
792
899
  )
793
900
  }
@@ -799,6 +906,17 @@ const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) =>
799
906
  */
800
907
  const DEFAULT_HORIZONTAL_KEY_STEP = 40
801
908
 
909
+ /**
910
+ * Advances VirtualScroll's tree revision by one (the reducer of the revision that the memos reading the row-height tree
911
+ * depend on).
912
+ *
913
+ * VirtualScroll の木の版数を 1 つ進める処理 (行の高さの木を読む memo が依存する版数の reducer)。
914
+ *
915
+ * @param revision - The current revision / 今の版数
916
+ * @returns The next revision / 次の版数
917
+ */
918
+ const nextTreeRevision = (revision: number): number => revision + 1
919
+
802
920
  /**
803
921
  * Converts a numeric size into a non-negative bigint for large collection handling.
804
922
  *
@@ -811,27 +929,106 @@ const toSafeBigInt = (value: number): bigint => {
811
929
  }
812
930
 
813
931
  /**
814
- * Computes rendering ranges for collections exceeding Number.MAX_SAFE_INTEGER.
815
- * Uses BigInt math to handle positions that cannot be represented by standard JavaScript numbers.
932
+ * The first visible row at a logical scroll position (`resolveVisibleStartRow`).
933
+ *
934
+ * 論理スクロール位置での先頭の可視行 (`resolveVisibleStartRow`)。
935
+ */
936
+ export type VisibleStartRow = {
937
+ /** Index of the first visible row / 先頭の可視行の index */
938
+ readonly index: number
939
+ /** Logical top of that row (px); `position - top` is the part hidden above the viewport top / その行の論理上端 (px)。`position - top` がビューポートの上端より上に隠れた量 */
940
+ readonly top: number
941
+ }
942
+
943
+ /**
944
+ * The part of the Fenwick tree `resolveVisibleStartRow` reads.
945
+ *
946
+ * `resolveVisibleStartRow` が読む Fenwick 木の部分。
947
+ */
948
+ type VisibleStartRowTree = Pick<ReturnType<typeof useFenwickMapTree>, "findIndexAtOrAfter" | "prefixSum">
949
+
950
+ /** Tree reads that only look the rows up, never materialising them / 行を引くだけで具現化しない木の読み方 */
951
+ const LOOKUP_ONLY = { materializeOption: { materialize: false } } as const
952
+
953
+ /**
954
+ * Resolves the first visible row at a logical scroll position by the visible-start boundary rule — the one rule that
955
+ * `scrollTo` pins, `getScrollAnchor` reports, `updateItemSize` compensates above and `computeRenderingRanges` renders from
956
+ * (on both of its paths, `computeRenderingRangesHuge` included):
957
+ *
958
+ * - the row whose span contains the position: `top <= position < top + height`;
959
+ * - a row whose bottom equals the position lies entirely above the viewport, so the next row starts exactly there and is the
960
+ * first visible row, at offset 0;
961
+ * - at the end, the last row stays the first visible row (the end clamp): when the position equals its bottom, and when the
962
+ * position lies past the end of the content (a transient after the list shrinks, before the pane clamps).
963
+ *
964
+ * Module-level export (NOT in the package barrel).
965
+ *
966
+ * 先頭の可視行を、可視の先頭の境界の規則で論理スクロール位置から解決する処理。`scrollTo` が留め、`getScrollAnchor` が知らせ、
967
+ * `updateItemSize` がその上の変化を補正し、`computeRenderingRanges` が (`computeRenderingRangesHuge` を含む両方の経路で) 描き
968
+ * 始める、ただ 1 つの規則。
969
+ *
970
+ * - 位置を含む行 (`上端 <= 位置 < 上端 + 高さ`)。
971
+ * - 下端が位置に一致する行は丸ごとビューポートの上にあるので、ちょうどそこから始まる次の行がオフセット 0 で先頭の可視行。
972
+ * - 末尾では最後の行が先頭の可視行のまま (末尾のクランプ)。位置がその下端に一致するときと、位置が中身の末尾を越えるとき
973
+ * (一覧が縮んでからペインがクランプするまでの過渡状態)。
816
974
  *
817
- * Number.MAX_SAFE_INTEGER を超える巨大なコレクションに対して描画範囲を算出します。
818
- * 標準的な JavaScript 数値では表現できない位置を扱うために、内部計算には BigInt を使用します。
975
+ * モジュールレベル export (バレル非公開)。
819
976
  *
820
- * @param effectiveScrollPosition Clamped scroll position / 丸められた有効なスクロール位置
821
- * @param viewportSize Height of the viewport / ビューポートの高さ
822
- * @param overscanCount Number of items to render outside the viewport / ビューポート外に描画するアイテム数
823
- * @param itemSize Total number of items / 総アイテム数
824
- * @param getItemHeight Function to get height of an item / アイテムの高さを取得する関数
825
- * @param fenwickTree Fenwick Tree instance / Fenwick Tree インスタンス
826
- * @param totalHeight Total content height / コンテンツ総高さ
827
- * @param hasFiniteTotal Whether the total height is finite / 総高さが有限かどうか
977
+ * @param fenwickTree - The row-height tree / 行の高さの木
978
+ * @param position - Logical scroll position (px, 0 or more) / 論理スクロール位置 (px。0 以上)
979
+ * @param itemCount - Number of rows, 1 or more / 行の数 (1 以上)
980
+ * @returns The first visible row and its logical top / 先頭の可視行とその論理上端
828
981
  */
829
- const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number, hasFiniteTotal: boolean) => {
982
+ export const resolveVisibleStartRow = (fenwickTree: VisibleStartRowTree, position: number, itemCount: number): VisibleStartRow => {
983
+ const lastIndex = itemCount - 1
984
+ const found = fenwickTree.findIndexAtOrAfter(position, LOOKUP_ONLY)
985
+ if (found.index === -1 || found.index > lastIndex || found.cumulative === undefined || found.currentValue === undefined) {
986
+ const last = fenwickTree.prefixSum(lastIndex, LOOKUP_ONLY)
987
+ return { index: lastIndex, top: last.cumulative - last.currentValue }
988
+ }
989
+ if (found.cumulative === position && found.index < lastIndex) {
990
+ return { index: found.index + 1, top: found.cumulative }
991
+ }
992
+ return { index: found.index, top: found.cumulative - found.currentValue }
993
+ }
994
+
995
+ /**
996
+ * Computes the rendering ranges of a list of `Number.MAX_SAFE_INTEGER` rows or more. The first visible row comes from the one
997
+ * visible-start boundary rule, `resolveVisibleStartRow`, exactly as in `computeRenderingRanges`, and the forward scan starts at
998
+ * that row's hidden offset as there; only then are the indices converted to `bigint`. The scans keep their own `bigint` loops
999
+ * because past 2^53 an index cannot be stepped by 1 in `number` (`x + 1 === x`, so a `number` loop over zero-height rows would
1000
+ * never end); the tree's own lookups take `number` indices, so in that range the start row is as exact as the tree's answer.
1001
+ * Below 2^53 both paths resolve every row boundary, zero-height rows at position 0 and the end clamp to the same ranges.
1002
+ * Module-level export (NOT in the package barrel), for the spec that pins that agreement.
1003
+ *
1004
+ * `Number.MAX_SAFE_INTEGER` 行以上の一覧の描画範囲を求める処理。先頭の可視行は `computeRenderingRanges` とまったく同じく、ただ
1005
+ * 1 つの可視の先頭の境界の規則 `resolveVisibleStartRow` から得て、前方の走査も同じくその行の隠れた量から始め、そのあとで添字を
1006
+ * `bigint` へ変える。走査が `bigint` の繰り返しを持つのは、2^53 を越えると `number` では添字を 1 ずつ進められない
1007
+ * (`x + 1 === x` なので、高さ 0 の行を `number` で走査すると終わらない) ため。木の引き当ては `number` の添字を取るので、その範囲の
1008
+ * 先頭の行は木の答えと同じ精度。2^53 未満では、どちらの経路もどの行の境界・位置 0 の高さ 0 の行・末尾のクランプでも同じ範囲を
1009
+ * 返す。モジュールレベル export (バレル非公開)。その一致を固定する spec のため。
1010
+ *
1011
+ * @param effectiveScrollPosition - Logical scroll position, clamped to the content (px) / 中身へクランプした論理スクロール位置 (px)
1012
+ * @param viewportSize - Height of the viewport (px) / 表示域の高さ (px)
1013
+ * @param overscanCount - Rows rendered beyond each edge / 端の外に描く行の数
1014
+ * @param itemSize - Number of rows / 行の数
1015
+ * @param getItemHeight - Height of a row / 行の高さ
1016
+ * @param fenwickTree - The row-height tree / 行の高さの木
1017
+ * @returns The rendering and visible ranges / 描画範囲と可視範囲
1018
+ */
1019
+ export const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>) => {
830
1020
  const sizeBig = toSafeBigInt(itemSize)
831
1021
  if (sizeBig === 0n) {
832
1022
  return { renderingStartIndex: 0, renderingEndIndex: 0, visibleStartIndex: 0, visibleEndIndex: 0 }
833
1023
  }
834
1024
 
1025
+ /**
1026
+ * Clamps a row index into the list.
1027
+ * 行の添字を一覧の中へクランプする処理。
1028
+ *
1029
+ * @param value - Row index / 行の添字
1030
+ * @returns The clamped index / クランプした添字
1031
+ */
835
1032
  const clampBig = (value: bigint): bigint => {
836
1033
  if (value < 0n) {
837
1034
  return 0n
@@ -842,46 +1039,31 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
842
1039
  return value
843
1040
  }
844
1041
 
845
- const materializeOption = { materializeOption: { materialize: false } }
846
- const { index: rawStartIndex, cumulative } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, materializeOption)
847
-
848
- let startBig: bigint
849
- if (rawStartIndex === -1) {
850
- startBig = sizeBig - 1n
851
- } else {
852
- if (cumulative === effectiveScrollPosition) {
853
- startBig = toSafeBigInt(rawStartIndex + 1)
854
- } else {
855
- startBig = toSafeBigInt(rawStartIndex)
856
- }
857
-
858
- if (startBig >= sizeBig) {
859
- startBig = sizeBig - 1n
860
- }
861
- }
862
-
863
- if (effectiveScrollPosition <= 0) {
864
- startBig = 0n
865
- }
866
-
867
- if (hasFiniteTotal && effectiveScrollPosition >= totalHeight) {
868
- startBig = sizeBig - 1n
869
- }
1042
+ const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
1043
+ let startBig = clampBig(toSafeBigInt(startRow.index))
1044
+ const initialOffset = startRow.top - effectiveScrollPosition
870
1045
 
871
1046
  // 高さ 0 (折りたたみ行) はスキップして走査を継続する (散発的な 0 行は正当な入力)。
872
1047
  // 走査を打ち切るのは負値・非有限の高さ (不正入力) のみ。0 行が大量に連続する場合は
873
1048
  // 連続数が閾値を超えた時点で Fenwick 木により次の非 0 行へ O(log n) でジャンプし、有界性を保つ。
1049
+ /**
1050
+ * Finds the first row past a long run of zero-height rows that ends at `lastScanned`, through the tree.
1051
+ * `lastScanned` で終わる高さ 0 の行の長い連続の後の最初の行を、木で探す処理。
1052
+ *
1053
+ * @param lastScanned - The last scanned row / 最後に走査した行
1054
+ * @returns The row to continue from, or `null` when the tree gives none ahead / 続ける行 (木が先を返さなければ `null`)
1055
+ */
874
1056
  const jumpPastZeroRun = (lastScanned: bigint): bigint | null => {
875
1057
  const lastNumber = Number(lastScanned)
876
1058
  if (!Number.isSafeInteger(lastNumber)) {
877
1059
  // number へ安全に変換できない領域では木のクエリが不正確になるため打ち切る
878
1060
  return null
879
1061
  }
880
- const { cumulative: runBottom } = fenwickTree.prefixSum(lastNumber, { materializeOption: { materialize: false } })
1062
+ const { cumulative: runBottom } = fenwickTree.prefixSum(lastNumber, LOOKUP_ONLY)
881
1063
  if (!Number.isFinite(runBottom)) {
882
1064
  return null
883
1065
  }
884
- const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom + 0.5, { materializeOption: { materialize: false } })
1066
+ const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom + 0.5, LOOKUP_ONLY)
885
1067
  if (jumpIndex === -1) {
886
1068
  return null
887
1069
  }
@@ -889,11 +1071,18 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
889
1071
  return jumpBig > lastScanned ? jumpBig : null
890
1072
  }
891
1073
 
892
- const accumulateForward = (initial: bigint) => {
893
- let height = 0
1074
+ /**
1075
+ * Adds row heights from a row onward until they fill the viewport.
1076
+ * ある行から先の行の高さを、表示域が埋まるまで足す処理。
1077
+ *
1078
+ * @param initial - The first row / 最初の行
1079
+ * @param initialHeight - The height already counted before it (the first row's hidden part, negative) / その前に数えた高さ (最初の行の隠れた量で負)
1080
+ * @returns The counted height and the last counted row / 数えた高さと最後に数えた行
1081
+ */
1082
+ const accumulateForward = (initial: bigint, initialHeight: number) => {
1083
+ let height = initialHeight
894
1084
  let cursor = initial
895
1085
  let last = initial
896
- let iterations = 0n
897
1086
  let zeroRun = 0
898
1087
  while (cursor < sizeBig && height < viewportSize) {
899
1088
  const cursorNumber = Number(cursor)
@@ -901,7 +1090,6 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
901
1090
  height += currentHeight
902
1091
  last = cursor
903
1092
  cursor += 1n
904
- iterations += 1n
905
1093
  if (!Number.isFinite(currentHeight) || currentHeight < 0) {
906
1094
  break
907
1095
  }
@@ -925,17 +1113,15 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
925
1113
  zeroRun = 0
926
1114
  }
927
1115
  }
928
- if (iterations === 0n) {
929
- last = initial
930
- }
931
1116
  return { height, end: last }
932
1117
  }
933
1118
 
934
- let { height: forwardHeight, end: forwardEnd } = accumulateForward(startBig)
1119
+ let { height: forwardHeight, end: forwardEnd } = accumulateForward(startBig, initialOffset)
935
1120
 
936
1121
  if (forwardHeight < viewportSize && startBig > 0n) {
937
1122
  let backwardStart = startBig
938
- let backwardHeight = forwardHeight
1123
+ // 先頭の行の隠れた量は前方の走査が引いてあるので戻す (computeRenderingRanges と同じ数え方)
1124
+ let backwardHeight = forwardHeight + Math.abs(Math.min(0, initialOffset))
939
1125
  let backwardZeroRun = 0
940
1126
  while (backwardStart > 0n && backwardHeight < viewportSize) {
941
1127
  backwardStart -= 1n
@@ -949,15 +1135,14 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
949
1135
  backwardZeroRun += 1
950
1136
  if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
951
1137
  // 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
952
- const indexNum = Number(backwardStart)
953
- if (!Number.isSafeInteger(indexNum)) {
1138
+ if (!Number.isSafeInteger(indexNumber)) {
954
1139
  break
955
1140
  }
956
- const { cumulative: runBottom } = fenwickTree.prefixSum(indexNum, { materializeOption: { materialize: false } })
1141
+ const { cumulative: runBottom } = fenwickTree.prefixSum(indexNumber, LOOKUP_ONLY)
957
1142
  if (!Number.isFinite(runBottom)) {
958
1143
  break
959
1144
  }
960
- const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, { materializeOption: { materialize: false } })
1145
+ const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, LOOKUP_ONLY)
961
1146
  const jumpBig = jumpIndex === -1 ? -1n : toSafeBigInt(jumpIndex)
962
1147
  if (jumpBig >= 0n && jumpBig < backwardStart) {
963
1148
  // 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
@@ -976,7 +1161,7 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
976
1161
  }
977
1162
  }
978
1163
  startBig = clampBig(backwardStart)
979
- const forward = accumulateForward(startBig)
1164
+ const forward = accumulateForward(startBig, 0)
980
1165
  forwardHeight = forward.height
981
1166
  forwardEnd = forward.end
982
1167
  }
@@ -995,14 +1180,10 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
995
1180
  }
996
1181
 
997
1182
  /**
998
- * Retrieves a high-resolution timestamp when available.
999
- *
1000
- * 高精度タイムスタンプを可能なら取得。
1001
- */
1002
- /**
1003
- * Calculates rendering boundaries from current scroll metrics.
1183
+ * Calculates rendering boundaries from current scroll metrics. The visible rows start at the row `resolveVisibleStartRow`
1184
+ * resolves.
1004
1185
  *
1005
- * 現在のスクロール情報から描画範囲を算出。
1186
+ * 現在のスクロール情報から描画範囲を算出。可視の行は `resolveVisibleStartRow` が解決する行から始まる。
1006
1187
  */
1007
1188
  export const computeRenderingRanges = (scrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number) => {
1008
1189
  if (itemSize === 0) {
@@ -1011,33 +1192,11 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
1011
1192
  const hasFiniteTotal = Number.isFinite(totalHeight)
1012
1193
  const effectiveScrollPosition = hasFiniteTotal ? Math.min(Math.max(0, scrollPosition), totalHeight) : Math.max(0, scrollPosition)
1013
1194
  if (itemSize >= Number.MAX_SAFE_INTEGER) {
1014
- return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree, totalHeight, hasFiniteTotal)
1015
- }
1016
- const { index: rawStartIndex, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, { materializeOption: { materialize: false } })
1017
- const startIndex =
1018
- rawStartIndex === -1
1019
- ? (() => {
1020
- // Fenwick がインデックスを返さない場合は末尾に合わせて開始位置を再計算
1021
- if (viewportSize <= 0) {
1022
- return itemSize - 1
1023
- }
1024
- return (cumulative ?? 0) < effectiveScrollPosition + (currentValue ?? 0) ? itemSize - 1 : 0
1025
- })()
1026
- : rawStartIndex
1027
- let visibleStartIndex = sanitizeIndex(startIndex, itemSize)
1028
-
1029
- let visibleHeight = 0
1030
- if (rawStartIndex !== -1 && cumulative === effectiveScrollPosition) {
1031
- visibleStartIndex = sanitizeIndex(rawStartIndex + 1, itemSize)
1032
- visibleHeight = 0
1033
- } else if (visibleStartIndex === rawStartIndex && cumulative !== undefined && currentValue !== undefined) {
1034
- const itemTop = cumulative - currentValue
1035
- visibleHeight = itemTop - effectiveScrollPosition
1036
- } else {
1037
- const { cumulative: startCumulative, currentValue: startHeight } = fenwickTree.prefixSum(visibleStartIndex, { materializeOption: { materialize: false } })
1038
- const itemTop = (startCumulative ?? 0) - (startHeight ?? 0)
1039
- visibleHeight = itemTop - effectiveScrollPosition
1195
+ return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree)
1040
1196
  }
1197
+ const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
1198
+ let visibleStartIndex = startRow.index
1199
+ let visibleHeight = startRow.top - effectiveScrollPosition
1041
1200
 
1042
1201
  const initialOffset = visibleHeight
1043
1202
 
@@ -1321,6 +1480,8 @@ const VirtualScrollInner = <T,>(
1321
1480
  ref: React.Ref<VirtualScrollHandle>,
1322
1481
  ) => {
1323
1482
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableArrowButtonTabStops, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
1483
+ // 合図はスクロールバーと同じ 1 つ (既定 true)。端へ戻るピルもバーと同じくポインタ専用になる
1484
+ const scrollChromeIsPointerOnly = enableArrowButtonTabStops === false
1324
1485
  const resolvedLabels = useMemo(() => resolveVirtualScrollLabels(locale, labels), [locale, labels])
1325
1486
 
1326
1487
  const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
@@ -1410,6 +1571,36 @@ const VirtualScrollInner = <T,>(
1410
1571
  )
1411
1572
  const fenwickTree = useFenwickMapTree(itemCount, getItemHeight, fenwickTreeOptions)
1412
1573
 
1574
+ // ❗ 木は同一性を保ったまま描画の外で書き換わる (updateItemSize・高さの照合・scrollToIndex の具現化)。接頭辞和の変化は総和が
1575
+ // 変わらなくても (差の和が 0 の測り直しの組) 描いた行の上端と高さ、描画範囲を変えるので、木を読むどの memo もこの版数に
1576
+ // 依存させる。総和 (中身の寸法) は相殺で変わらず、同じ値の書き込みは React が捨てるため、変化の合図にならない
1577
+ const [treeRevision, advanceTreeRevision] = useReducer(nextTreeRevision, 0)
1578
+
1579
+ /**
1580
+ * Runs one change of the row-height tree made outside render (`updateItemSize`, the height reconciliation, the
1581
+ * materialisation of `scrollToIndex`) and, when the tree's revision moved (`FenwickMapTree.revision`), advances the tree
1582
+ * revision, so the next commit re-places the rendered rows and re-resolves the rendering range whether or not the total
1583
+ * changed.
1584
+ *
1585
+ * 描画の外で行う行の高さの木の変更を 1 回実行し (`updateItemSize`・高さの照合・`scrollToIndex` の具現化)、木の版
1586
+ * (`FenwickMapTree.revision`) が動いたら木の版数を進める処理。総和が変わったかどうかによらず、次の確定が描いた行を置き直し、
1587
+ * 描画範囲を解決し直す。
1588
+ *
1589
+ * @param change - The change of the tree / 木の変更
1590
+ * @returns What `change` returned / `change` の戻り値
1591
+ */
1592
+ const changeTree = useCallback(
1593
+ <Result,>(change: () => Result): Result => {
1594
+ const revisionBefore = fenwickTree.revision
1595
+ const result = change()
1596
+ if (fenwickTree.revision !== revisionBefore) {
1597
+ advanceTreeRevision()
1598
+ }
1599
+ return result
1600
+ },
1601
+ [fenwickTree],
1602
+ )
1603
+
1413
1604
  const [initialValues] = useState(() => {
1414
1605
  let position = 0
1415
1606
  let total = 0
@@ -1571,6 +1762,43 @@ const VirtualScrollInner = <T,>(
1571
1762
  const [scrollDirection, setScrollDirection] = useState<"up" | "down" | null>(null)
1572
1763
  const [showScrollButtons, setShowScrollButtons] = useState(false)
1573
1764
  const scrollButtonTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1765
+ // 見せる操作は、画面に出ているピルを表示域 (行ラッパーの境界の箱の上端が表示域の上端) に対して測る
1766
+ const edgeOverlayRef = useRef<HTMLDivElement>(null)
1767
+ const edgePillRef = useRef<HTMLButtonElement>(null)
1768
+ const itemsBoundaryRef = useRef<HTMLDivElement>(null)
1769
+
1770
+ /**
1771
+ * Reads the parts of the viewport the visible scroll-to-edge pill covers (`ObscuredInsets`), in the px the rows are laid
1772
+ * out in: the pill's box is measured on screen against the viewport's top (the items boundary box) and divided by the
1773
+ * vertical scale of any transformed ancestor (`getAxisScale` on the overlay, which spans the viewport), and the pill covers
1774
+ * the edge it lies nearer to, from that edge to its far side. Nothing is covered while no pill shows (the overlay's
1775
+ * `data-visible` is not `"true"`, or the pills are off), nor while the viewport is not laid out (every box is empty).
1776
+ *
1777
+ * 見えている端へ戻るピルが覆う表示域の部分 (`ObscuredInsets`) を、行を配置する px で読む処理。ピルの箱を画面の上で表示域の
1778
+ * 上端 (行ラッパーの境界の箱) に対して測り、変換を持つ祖先の縦の拡大縮小 (表示域にまたがる覆いの `getAxisScale`) で割る。
1779
+ * ピルが覆うのは近い方の端で、その端からピルの向こう側まで。ピルが見えていない間 (覆いの `data-visible` が `"true"` でない、
1780
+ * またはピルが無効) と、表示域が配置されていない間 (どの箱も空) は何も覆わない。
1781
+ *
1782
+ * @returns The covered parts, each clamped to the viewport / 覆われた部分 (どちらも表示域の中へクランプ)
1783
+ */
1784
+ const readObscuredInsets = useCallback((): ObscuredInsets => {
1785
+ const overlay = edgeOverlayRef.current
1786
+ const pill = edgePillRef.current
1787
+ const boundary = itemsBoundaryRef.current
1788
+ if (overlay === null || pill === null || boundary === null || overlay.dataset.visible !== "true") {
1789
+ return NO_OBSCURED_INSETS
1790
+ }
1791
+ // 境界の箱は高さ 0 で縦の拡大縮小を測れないので、表示域と同じ高さの覆いで測る (測れなければ 1 なので割っても壊れない)
1792
+ const scale = getAxisScale(overlay, "y")
1793
+ const viewportTop = boundary.getBoundingClientRect().top
1794
+ const pillBox = pill.getBoundingClientRect()
1795
+ const pillTop = (pillBox.top - viewportTop) / scale
1796
+ const pillBottom = (pillBox.bottom - viewportTop) / scale
1797
+ if (pillTop + pillBottom < viewportSize) {
1798
+ return { top: minmax(pillBottom, 0, viewportSize), bottom: 0 }
1799
+ }
1800
+ return { top: 0, bottom: minmax(viewportSize - pillTop, 0, viewportSize) }
1801
+ }, [viewportSize])
1574
1802
  const isProgrammaticScrollRef = useRef(false)
1575
1803
  const isCompensatingRef = useRef(0)
1576
1804
  // レイアウトシフト補正の scrollTo が ScrollPane の旧 contentSize でクランプされた場合に true。
@@ -1871,9 +2099,9 @@ const VirtualScrollInner = <T,>(
1871
2099
  const didApplyInitialOffsetRef = useRef(false)
1872
2100
 
1873
2101
  /**
1874
- * Flushes queued onScroll notifications.
2102
+ * Applies `initialScrollOffset` once, as the highest-priority initial position, synchronising the pane and the logical position.
1875
2103
  *
1876
- * キューされた onScroll 通知を実行。
2104
+ * `initialScrollOffset` を最優先の初期位置として一度だけ適用し、ペインと論理位置を同期させる effect。
1877
2105
  */
1878
2106
  useEffect(() => {
1879
2107
  if (didApplyInitialOffsetRef.current) return
@@ -2006,15 +2234,22 @@ const VirtualScrollInner = <T,>(
2006
2234
  * Updates the size of a specific item. Contract: after this call, `getItemHeight(index)` must
2007
2235
  * return the same `size`; `getItemHeight` is the source of truth, so rows inside the current
2008
2236
  * rendering window (including overscan) are otherwise reverted to the `getItemHeight` value by
2009
- * height reconciliation on the next render. A change above the first visible row moves the scroll
2237
+ * height reconciliation on the next render. A change above the first visible row (the row of the pending
2238
+ * alignment while one is pending, otherwise the row `resolveVisibleStartRow` resolves) moves the scroll
2010
2239
  * position by the same delta (layout-shift compensation), carries the remembered alignment along and
2011
- * is reported through `onScrollAdjust` (`"item-resize"`) before this returns.
2240
+ * is reported through `onScrollAdjust` (`"item-resize"`) before this returns. Every change of the size
2241
+ * advances the tree revision (`changeTree`), so the next commit re-places the rendered rows and
2242
+ * re-resolves the rendering range, whether or not the total changes (several calls in one batch whose
2243
+ * changes cancel out included).
2012
2244
  *
2013
2245
  * 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
2014
2246
  * `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
2015
- * 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行より上の変化はスクロール位置を
2247
+ * 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行 (保留中の揃えがあればその行、
2248
+ * 無ければ `resolveVisibleStartRow` が解決する行) より上の変化はスクロール位置を
2016
2249
  * 同じ差だけ動かし (レイアウトシフトの補正)、覚えた揃えも一緒に動かして、戻る前に `onScrollAdjust`
2017
- * (`"item-resize"`) で知らせる。
2250
+ * (`"item-resize"`) で知らせる。サイズのどの変化も木の版数を進める (`changeTree`) ので、総和が変わるか
2251
+ * どうかによらず (同じバッチの中で差が打ち消し合う複数の呼び出しも含む)、次の確定が描いた行を置き直し、
2252
+ * 描画範囲を解決し直す。
2018
2253
  *
2019
2254
  * @param index - Item index / アイテムのインデックス
2020
2255
  * @param size - New size in px / 新しいサイズ (px)
@@ -2028,7 +2263,7 @@ const VirtualScrollInner = <T,>(
2028
2263
  const oldSize = fenwickTree.get(safeIndex)
2029
2264
  const delta = size - oldSize
2030
2265
 
2031
- const total = fenwickTree.update(safeIndex, size)
2266
+ const total = changeTree(() => fenwickTree.update(safeIndex, size))
2032
2267
  if (total !== undefined) {
2033
2268
  writeContentSize(total)
2034
2269
  }
@@ -2045,18 +2280,12 @@ const VirtualScrollInner = <T,>(
2045
2280
  const currentScrollTop = latestScrollPositionRef.current
2046
2281
  const insetsTop = normalizeInsets(contentInsets).top
2047
2282
  const logicalScrollTop = toLogicalPositionWithInset(currentScrollTop, insetsTop)
2048
- // Use materialize: false for performance, we just need the index
2049
- const { index, cumulative } = fenwickTree.findIndexAtOrAfter(logicalScrollTop, { materializeOption: { materialize: false } })
2050
- // findIndexAtOrAfter は「下端(cumulative) >= scrollTop の最小 index」を返すため、
2051
- // アイテム index の下端がちょうど scrollTop に一致する場合、その行は完全にビューポート上方にある。
2052
- // computeRenderingRanges (cumulative === effectiveScrollPosition の特例) と同じく可視先頭を index+1 に補正する。
2053
- activeVisibleStartIndex = cumulative !== undefined && cumulative === logicalScrollTop ? index + 1 : index
2283
+ activeVisibleStartIndex = resolveVisibleStartRow(fenwickTree, logicalScrollTop, itemCount).index
2054
2284
  }
2055
2285
 
2056
- // Allow for a small buffer in case of slight misalignments or stale refs
2057
2286
  // If the item is strictly above the visible start, it pushes content down.
2058
- // アイテムが(ほぼ)確実に可視領域より上にある場合、コンテンツ全体を押し下げます
2059
- if (activeVisibleStartIndex !== -1 && safeIndex < activeVisibleStartIndex && delta !== 0) {
2287
+ // アイテムが可視領域より上にある場合、コンテンツ全体を押し下げます
2288
+ if (safeIndex < activeVisibleStartIndex && delta !== 0) {
2060
2289
  // Use latestScrollPositionRef as the source of truth for the current position
2061
2290
  // to avoid reading stale DOM values during batched updates (e.g. multiple items resizing at once).
2062
2291
  const currentPanePosition = latestScrollPositionRef.current
@@ -2073,7 +2302,7 @@ const VirtualScrollInner = <T,>(
2073
2302
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
2074
2303
  }
2075
2304
  },
2076
- [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2305
+ [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, changeTree, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2077
2306
  )
2078
2307
 
2079
2308
  /**
@@ -2083,26 +2312,56 @@ const VirtualScrollInner = <T,>(
2083
2312
  * and the scroll position are written only when they differ from their synchronous copies (`writeContentSize`; for the
2084
2313
  * position, `latestScrollPositionRef` while the render loop is stopped, read before the pane moves), so a call that
2085
2314
  * leaves the list where it is (the same row, alignment and content) writes no state, schedules no render and reports
2086
- * no position to `onScroll`, like `scrollTo` to the current position.
2315
+ * no position to `onScroll`, like `scrollTo` to the current position. The tree revision advances (`changeTree`) only
2316
+ * when materialising the rows around the target (±`overscanCount * 2`) changed the tree.
2317
+ *
2318
+ * `align: "nearest"` (the reveal) first resolves, from the tree as it is, how the row lands in the band the visible
2319
+ * scroll-to-edge pill leaves (`resolveRevealAlignment` over `readObscuredInsets`): a row already wholly inside returns
2320
+ * without touching anything, and any other row lands with the resolved `"top"` / `"bottom"` alignment and its offset,
2321
+ * like an explicit call with them.
2087
2322
  *
2088
2323
  * 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
2089
2324
  * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を正規形で覚える
2090
2325
  * (`resolveItemsWrapperSnapEdge`)。中身の寸法とスクロール位置は同期の写し (`writeContentSize`。位置は描画ループが止まって
2091
2326
  * いる間の `latestScrollPositionRef` を、ペインを動かす前に読む) と違うときだけ書くため、一覧をその場に留める呼び出し (同じ行・揃え・中身) は状態を書かず、描画を予約せず、`onScroll` へ位置を
2092
- * 知らせない (現在位置への `scrollTo` と同じ)。
2327
+ * 知らせない (現在位置への `scrollTo` と同じ)。揃える行の周り (±`overscanCount * 2`) の具現化が木を変えたときだけ、木の版数を
2328
+ * 進める (`changeTree`)。
2329
+ *
2330
+ * `align: "nearest"` (見せる操作) は、見えている端へ戻るピルが残す帯へ行をどう着地させるかを今の木から先に解決する
2331
+ * (`readObscuredInsets` に対する `resolveRevealAlignment`)。既にまるごと入っている行は何にも触れずに戻り、それ以外の行は
2332
+ * 解決した `"top"` / `"bottom"` の揃えと offset で、それを明示した呼び出しと同じく着地させる。
2093
2333
  *
2094
2334
  * @param index - Item index / アイテムのインデックス
2095
- * @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
2335
+ * @param options - Alignment (`"top"` by default) and offset, or the reveal `{ align: "nearest" }` / 揃え方 (既定は `"top"`) と offset、または見せる操作 `{ align: "nearest" }`
2336
+ * @throws {RangeError} When `"nearest"` comes with an `offset` / `"nearest"` に `offset` を渡したとき
2096
2337
  */
2097
2338
  const scrollToIndex = useCallback(
2098
- (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => {
2339
+ (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number } | { align: "nearest"; offset?: never }) => {
2099
2340
  if (!scrollPaneRef.current || itemCount === 0) {
2100
2341
  return
2101
2342
  }
2102
2343
  const safeIndex = sanitizeIndex(index, itemCount)
2344
+ let alignment: { align?: "top" | "bottom" | "center"; offset?: number } | undefined
2345
+ if (options?.align === "nearest") {
2346
+ // 型は offset を許さないが、型の無い呼び出し元の offset を黙って捨てない
2347
+ const offset: unknown = (options as { readonly offset?: unknown }).offset
2348
+ if (offset !== undefined) {
2349
+ throw new RangeError(`[VirtualScroll] scrollToIndex: align "nearest" takes no offset (it lands the row at the edge of the band a visible scroll-to-edge pill leaves); received an offset of type ${typeof offset}.`)
2350
+ }
2351
+ // 見せるかどうかは今の木で決める。描画の窓とその周りの行は照合で実際の高さを持ち、窓から遠い行は表示域の中にない
2352
+ const row = fenwickTree.prefixSum(safeIndex, LOOKUP_ONLY)
2353
+ const reveal = resolveRevealAlignment({ top: row.cumulative - row.currentValue, bottom: row.cumulative }, toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top), viewportSize, readObscuredInsets())
2354
+ if (reveal === null) {
2355
+ return
2356
+ }
2357
+ alignment = reveal
2358
+ } else {
2359
+ alignment = options
2360
+ }
2103
2361
  const safeIndexFrom = sanitizeIndex(safeIndex - overscanCount * 2, itemCount)
2104
2362
  const safeIndexTo = sanitizeIndex(safeIndex + overscanCount * 2, itemCount)
2105
- const { cumulative: itemBottom, total, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } })
2363
+ // 揃える行の周りの具現化は推定の高さを実際の高さへ置き換え、描いた行の上端も変え得る (位置が動かない揃え直しでも)
2364
+ const { cumulative: itemBottom, total, currentValue: itemHeight } = changeTree(() => fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } }))
2106
2365
 
2107
2366
  Logger.debug("[VirtualScroll] Scrolling to index:", safeIndex, "ItemBottom:", itemBottom, "Total height:", total, "ItemHeight:", itemHeight, "safeIndexFrom:", safeIndexFrom, "safeIndexTo:", safeIndexTo)
2108
2367
 
@@ -2113,14 +2372,14 @@ const VirtualScrollInner = <T,>(
2113
2372
  const itemTop = Math.max(itemBottom - itemHeight, 0)
2114
2373
  let targetLogicalPosition = itemTop
2115
2374
 
2116
- if (options?.align === "bottom") {
2375
+ if (alignment?.align === "bottom") {
2117
2376
  targetLogicalPosition = itemBottom - viewportSize
2118
- } else if (options?.align === "center") {
2377
+ } else if (alignment?.align === "center") {
2119
2378
  targetLogicalPosition = itemTop + itemHeight / 2 - viewportSize / 2
2120
2379
  }
2121
2380
 
2122
- if (options?.offset) {
2123
- targetLogicalPosition -= options.offset
2381
+ if (alignment?.offset) {
2382
+ targetLogicalPosition -= alignment.offset
2124
2383
  }
2125
2384
 
2126
2385
  // Ensure we don't scroll past the content
@@ -2145,10 +2404,10 @@ const VirtualScrollInner = <T,>(
2145
2404
  // レイアウトシフト補正のアンカーとして、保留中のターゲットインデックスを設定します
2146
2405
  pendingVisibleStartIndexRef.current = {
2147
2406
  index: safeIndex,
2148
- align: options?.align,
2149
- offset: options?.offset,
2407
+ align: alignment?.align,
2408
+ offset: alignment?.offset,
2150
2409
  }
2151
- const edge = edgeOfAlignment(options?.align)
2410
+ const edge = edgeOfAlignment(alignment?.align)
2152
2411
  rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
2153
2412
 
2154
2413
  // ❗ 判定はペインを動かす前に行う。ペインの scrollTo はスクロールの処理を同期で呼び、位置の写しを着地位置へ進めて
@@ -2184,7 +2443,7 @@ const VirtualScrollInner = <T,>(
2184
2443
 
2185
2444
  Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
2186
2445
  },
2187
- [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2446
+ [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, changeTree, readObscuredInsets, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2188
2447
  )
2189
2448
 
2190
2449
  // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
@@ -2203,9 +2462,15 @@ const VirtualScrollInner = <T,>(
2203
2462
  }, [itemCount, scrollToIndex])
2204
2463
 
2205
2464
  /**
2206
- * Scrolls to a raw offset while resolving to an index.
2465
+ * Scrolls to a raw logical offset by pinning the first visible row there (`resolveVisibleStartRow`) with the offset of
2466
+ * its top, so the pending alignment is the row `getScrollAnchor` reports: a position on a row boundary pins the row that
2467
+ * starts there at offset 0, never the hidden row above it.
2468
+ *
2469
+ * 論理オフセットへのスクロール。その位置の先頭の可視行 (`resolveVisibleStartRow`) を、その上端からのオフセットで留めるので、
2470
+ * 保留中の揃えは `getScrollAnchor` が知らせる行になる。行の境界の位置では、そこから始まる行をオフセット 0 で留め、上に
2471
+ * 隠れた行は留めない。
2207
2472
  *
2208
- * オフセットをインデックスに変換しつつスクロール。
2473
+ * @param newPosition - Logical position in px (floored, then clamped to the content) / 論理位置 (px。切り捨ててから中身の範囲へクランプ)
2209
2474
  */
2210
2475
  const scrollTo = useCallback(
2211
2476
  (newPosition: number) => {
@@ -2214,43 +2479,33 @@ const VirtualScrollInner = <T,>(
2214
2479
  }
2215
2480
  const total = fenwickTree.getTotal()
2216
2481
  const safePosition = minmax(Math.floor(newPosition), 0, total)
2217
- const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(safePosition, { materializeOption: { materialize: false } })
2218
-
2219
- // Calculate offset relative to item top to ensure precise positioning
2220
- // itemTop = cumulative - currentValue
2221
- const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
2222
- const offset = itemTop - safePosition
2223
-
2224
- scrollToIndex(index, { offset })
2482
+ const startRow = resolveVisibleStartRow(fenwickTree, safePosition, itemCount)
2483
+ scrollToIndex(startRow.index, { offset: startRow.top - safePosition })
2225
2484
  },
2226
2485
  [fenwickTree, itemCount, scrollToIndex],
2227
2486
  )
2228
2487
 
2229
2488
  /**
2230
- * Captures the current top-row anchor for exact position restore across remounts.
2231
- * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。
2489
+ * Captures the current top-row anchor for exact position restore across remounts: the first visible row that
2490
+ * `resolveVisibleStartRow` resolves (the same row `scrollTo` pins) and the px its top is hidden above the viewport top.
2491
+ * Returns `null` while the list is empty.
2492
+ * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。行は `resolveVisibleStartRow` が解決する
2493
+ * 先頭の可視行 (`scrollTo` が留める行と同じ) で、空の一覧では `null`。
2232
2494
  *
2233
2495
  * offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
2234
2496
  * 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
2235
2497
  * ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
2236
2498
  * 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
2499
+ *
2500
+ * @returns The anchor, or `null` without rows / アンカー (行が無ければ `null`)
2237
2501
  */
2238
2502
  const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
2239
2503
  if (itemCount === 0) {
2240
2504
  return null
2241
2505
  }
2242
2506
  const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
2243
- const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(logical, { materializeOption: { materialize: false } })
2244
- if (index === -1) {
2245
- // 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
2246
- return { index: itemCount - 1, offsetPx: 0 }
2247
- }
2248
- if (cumulative === logical) {
2249
- // 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
2250
- return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
2251
- }
2252
- const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
2253
- return { index, offsetPx: Math.max(0, logical - itemTop) }
2507
+ const startRow = resolveVisibleStartRow(fenwickTree, logical, itemCount)
2508
+ return { index: startRow.index, offsetPx: Math.max(0, logical - startRow.top) }
2254
2509
  }, [fenwickTree, itemCount, resolvedInsets.top])
2255
2510
 
2256
2511
  /**
@@ -2379,6 +2634,8 @@ const VirtualScrollInner = <T,>(
2379
2634
  const logicalScrollPosition = useMemo(() => toLogicalPositionWithInset(scrollPosition, resolvedInsets.top), [resolvedInsets.top, scrollPosition])
2380
2635
 
2381
2636
  const renderingRanges = useMemo(() => {
2637
+ // 範囲は木から解くので、位置が動かなくても木の変化 (総和が変わらない変化を含む) で解き直す
2638
+ void treeRevision
2382
2639
  // useMemo 内では State の contentSize ではなく、常に最新の計算結果を持つ fenwickTree.getTotal() を使用する。
2383
2640
  // これにより、アイテム数が大幅に減少した直後でも、古い contentSize (State) に基づく誤ったレンダリング範囲計算を防ぐことができる。
2384
2641
  // contentSize (State) の更新は非同期で行われるため、一瞬古い状態が残る可能性があるが、fenwickTree は同期的であり信頼性が高い。
@@ -2392,10 +2649,23 @@ const VirtualScrollInner = <T,>(
2392
2649
  viewportSize,
2393
2650
  }))
2394
2651
  return ranges
2395
- }, [logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree]) // contentSize を依存配列から削除
2652
+ }, [logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree, treeRevision])
2396
2653
 
2397
2654
  const { renderingStartIndex, renderingEndIndex, visibleStartIndex, visibleEndIndex } = renderingRanges
2398
2655
 
2656
+ /**
2657
+ * Focuses the row at an index. With `ensureVisible` (default `true`) the row is first revealed by the one reveal rule
2658
+ * (`scrollToIndex` with `align: "nearest"`: the least scroll into the band a visible scroll-to-edge pill leaves), then a
2659
+ * rendered row takes focus at once and a row not rendered yet takes it when it mounts (`pendingFocusIndexRef`). With
2660
+ * `false` only a rendered row takes focus, without scrolling.
2661
+ *
2662
+ * 指定 index の行へフォーカスする処理。`ensureVisible` (既定 `true`) では先にただ 1 つの見せる規則 (`align: "nearest"` の
2663
+ * `scrollToIndex`。見えている端へ戻るピルが残す帯への最短のスクロール) で行を見せ、描いてある行はすぐに、まだ描いていない行は
2664
+ * マウントしたときに (`pendingFocusIndexRef`) フォーカスを受ける。`false` では描いてある行だけがスクロールせずにフォーカスを受ける。
2665
+ *
2666
+ * @param index - Row index / 行の index
2667
+ * @param options - Whether to reveal the row first (default `true`) / 先に行を見せるか (既定 `true`)
2668
+ */
2399
2669
  const focusItemAtIndex = useCallback(
2400
2670
  (index: number, options?: { ensureVisible?: boolean }) => {
2401
2671
  if (!enableKeyboardNavigation || itemCount === 0) {
@@ -2403,50 +2673,24 @@ const VirtualScrollInner = <T,>(
2403
2673
  }
2404
2674
  const safeIndex = sanitizeIndex(index, itemCount)
2405
2675
  const ensureVisible = options?.ensureVisible ?? true
2406
- if (!ensureVisible) {
2407
- const existingElement = itemRefs.current.get(safeIndex)
2408
- if (existingElement) {
2409
- pendingFocusIndexRef.current = null
2410
- lastFocusedIndexRef.current = safeIndex
2411
- tryFocusElement(existingElement)
2412
- }
2413
- return
2414
- }
2415
-
2416
- const prefix = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: false } })
2417
- const itemHeight = prefix.currentValue
2418
- const itemTop = Math.max(prefix.cumulative - itemHeight, 0)
2419
- const itemBottom = itemTop + itemHeight
2420
- const viewportTop = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
2421
- const viewportBottom = viewportTop + viewportSize
2422
- const needsScroll = itemTop < viewportTop || itemBottom > viewportBottom
2423
- if (needsScroll) {
2424
- scrollToIndex(safeIndex)
2425
- // オーバースキャン内で既にマウント済みの行は、スクロールしても ref コールバックが
2426
- // 再実行されない (handleRef の identity 不変) ため、ここで直接フォーカスを適用する。
2427
- // マウント済み要素へフォーカスできたら pendingFocusIndexRef を残さない
2428
- // (残すと後刻の無関係な再マウント時にフォーカスを奪ってしまう)。
2429
- const mounted = itemRefs.current.get(safeIndex)
2430
- if (mounted) {
2431
- pendingFocusIndexRef.current = null
2432
- lastFocusedIndexRef.current = safeIndex
2433
- tryFocusElement(mounted)
2434
- } else {
2435
- pendingFocusIndexRef.current = safeIndex
2436
- }
2437
- return
2676
+ if (ensureVisible) {
2677
+ scrollToIndex(safeIndex, { align: "nearest" })
2438
2678
  }
2439
-
2440
- const element = itemRefs.current.get(safeIndex)
2441
- if (element) {
2679
+ // オーバースキャン内で既にマウント済みの行は、スクロールしても ref コールバックが再実行されない (handleRef の
2680
+ // identity 不変) ため、ここで直接フォーカスを適用する。マウント済み要素へフォーカスできたら pendingFocusIndexRef を
2681
+ // 残さない (残すと後刻の無関係な再マウント時にフォーカスを奪ってしまう)
2682
+ const mounted = itemRefs.current.get(safeIndex)
2683
+ if (mounted) {
2442
2684
  pendingFocusIndexRef.current = null
2443
2685
  lastFocusedIndexRef.current = safeIndex
2444
- tryFocusElement(element)
2686
+ tryFocusElement(mounted)
2445
2687
  return
2446
2688
  }
2447
- pendingFocusIndexRef.current = safeIndex
2689
+ if (ensureVisible) {
2690
+ pendingFocusIndexRef.current = safeIndex
2691
+ }
2448
2692
  },
2449
- [enableKeyboardNavigation, itemCount, fenwickTree, resolvedInsets.top, scrollToIndex, tryFocusElement, viewportSize],
2693
+ [enableKeyboardNavigation, itemCount, scrollToIndex, tryFocusElement],
2450
2694
  )
2451
2695
 
2452
2696
  const handleItemKeyDown = useCallback(
@@ -2650,6 +2894,12 @@ const VirtualScrollInner = <T,>(
2650
2894
  * ポインタ軸の下限は CSS 側の `.aqvs-scroll-to-edge-overlay[data-visible="false"]
2651
2895
  * .aqvs-scroll-to-edge-button { pointer-events: none }` が担う (配布 CSS 未読込のホストでは
2652
2896
  * `inert` が、`inert` 未実装のブラウザでは CSS が、互いの穴を埋める)。
2897
+ *
2898
+ * ホストがキーボードのスクロールを持つ (`scrollBarOptions.enableArrowButtonTabStops: false`) と、ピルはスクロールバーと
2899
+ * 同じくポインタ専用になる。オーバーレイは見えている間も `aria-hidden="true"` で、ピルは Tab 順に入らず、オーバーレイ
2900
+ * が押下の既定動作を取り消すのでピルの押下はフォーカスを動かさない (click は届く)。
2901
+ *
2902
+ * @returns The overlay, or `null` without the pills / オーバーレイ (ピルを出さないなら `null`)
2653
2903
  */
2654
2904
  const renderOverlay = useCallback(() => {
2655
2905
  if (!enableScrollToTopBottomButtons) {
@@ -2658,16 +2908,18 @@ const VirtualScrollInner = <T,>(
2658
2908
 
2659
2909
  const isVisible = showScrollButtons && scrollDirection !== null
2660
2910
  const isTop = scrollDirection === "up"
2911
+ const pillTabIndex = isVisible && !scrollChromeIsPointerOnly ? 0 : -1
2661
2912
 
2662
2913
  return (
2663
- <div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible}>
2914
+ <div ref={edgeOverlayRef} className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible} aria-hidden={scrollChromeIsPointerOnly ? true : undefined} onPointerDown={scrollChromeIsPointerOnly ? keepFocusOnPress : undefined}>
2664
2915
  {isTop ? (
2665
2916
  <div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-top">
2666
2917
  <button
2918
+ ref={edgePillRef}
2667
2919
  type="button"
2668
2920
  className="aqvs-scroll-to-edge-button"
2669
2921
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
2670
- tabIndex={isVisible ? 0 : -1}
2922
+ tabIndex={pillTabIndex}
2671
2923
  onClick={(e) => {
2672
2924
  e.stopPropagation()
2673
2925
  isProgrammaticScrollRef.current = true
@@ -2680,10 +2932,11 @@ const VirtualScrollInner = <T,>(
2680
2932
  ) : (
2681
2933
  <div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-bottom">
2682
2934
  <button
2935
+ ref={edgePillRef}
2683
2936
  type="button"
2684
2937
  className="aqvs-scroll-to-edge-button"
2685
2938
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
2686
- tabIndex={isVisible ? 0 : -1}
2939
+ tabIndex={pillTabIndex}
2687
2940
  onClick={(e) => {
2688
2941
  e.stopPropagation()
2689
2942
  isProgrammaticScrollRef.current = true
@@ -2696,7 +2949,7 @@ const VirtualScrollInner = <T,>(
2696
2949
  )}
2697
2950
  </div>
2698
2951
  )
2699
- }, [enableScrollToTopBottomButtons, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
2952
+ }, [enableScrollToTopBottomButtons, scrollChromeIsPointerOnly, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
2700
2953
 
2701
2954
  // 量子化アンカー (fix: LayoutUnit/f32 精度対策)。行 top はコンテンツ絶対座標そのままではなく
2702
2955
  // 「絶対座標 - アンカー」で描画し、ラッパー側 translateY にアンカーを足し戻す。
@@ -2708,12 +2961,9 @@ const VirtualScrollInner = <T,>(
2708
2961
  const renderAnchorRef = useRef(0)
2709
2962
 
2710
2963
  const { visibleItems, renderAnchor } = useMemo(() => {
2711
- // contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
2712
- // fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
2713
- // fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
2714
- // 両経路とも中身の寸法の書き込み (writeContentSize) を伴うため、contentSize を依存へ含めることで
2715
- // 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
2716
- void contentSize
2964
+ // 行の上端と高さは木の接頭辞和から決まる。木は同一性を保ったまま描画の外で変わるので、木の版数で失効させる。総和は
2965
+ // 差の和が 0 の変化では変わらないため、中身の寸法を依存にしても上端は古いまま残る (報告する座標と描いた行の食い違い)
2966
+ void treeRevision
2717
2967
 
2718
2968
  if (itemCount === 0) {
2719
2969
  return {
@@ -2818,7 +3068,9 @@ const VirtualScrollInner = <T,>(
2818
3068
  }
2819
3069
  }
2820
3070
 
2821
- const total = fenwickTree.updates(toUpdateHeights)
3071
+ // 照合した行の上端は描画の中で補ってあるが、木から導いた値 (getRange の総高さなど) は古い木のまま。総和が変わらない
3072
+ // 変化でも、次の確定で木から導き直させる
3073
+ const total = changeTree(() => fenwickTree.updates(toUpdateHeights))
2822
3074
  if (isUnmountedRef.current || typeof total !== "number") {
2823
3075
  return
2824
3076
  }
@@ -2846,9 +3098,9 @@ const VirtualScrollInner = <T,>(
2846
3098
  }, [
2847
3099
  applySelfAdjustment,
2848
3100
  carryAlignedEdge,
3101
+ changeTree,
2849
3102
  children,
2850
3103
  clipItemHeight,
2851
- contentSize,
2852
3104
  enableKeyboardNavigation,
2853
3105
  itemCount,
2854
3106
  fenwickTree,
@@ -2864,6 +3116,7 @@ const VirtualScrollInner = <T,>(
2864
3116
  renderingEndIndex,
2865
3117
  renderingStartIndex,
2866
3118
  resolvedLabels,
3119
+ treeRevision,
2867
3120
  updateScrollPositionImmediate,
2868
3121
  visibleStartIndex,
2869
3122
  writeContentSize,
@@ -2942,7 +3195,7 @@ const VirtualScrollInner = <T,>(
2942
3195
  }))
2943
3196
 
2944
3197
  return (
2945
- <ItemsWrapper translateY={containerTop} snapEdge={snapEdge}>
3198
+ <ItemsWrapper translateY={containerTop} snapEdge={snapEdge} boundaryRef={itemsBoundaryRef}>
2946
3199
  {visibleItems}
2947
3200
  {bottomInset}
2948
3201
  </ItemsWrapper>
@@ -2951,19 +3204,18 @@ const VirtualScrollInner = <T,>(
2951
3204
  [alignedEdge, callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
2952
3205
  )
2953
3206
 
2954
- const currentRange = useMemo<VirtualScrollRange>(
2955
- () => ({
3207
+ const currentRange = useMemo<VirtualScrollRange>(() => {
3208
+ // 総高さは木から読む。木は同一性を保ったまま変わるので、木の版数で読み直す
3209
+ void treeRevision
3210
+ return {
2956
3211
  renderingStartIndex: renderingRanges.renderingStartIndex,
2957
3212
  renderingEndIndex: renderingRanges.renderingEndIndex,
2958
3213
  visibleStartIndex: renderingRanges.visibleStartIndex,
2959
3214
  visibleEndIndex: renderingRanges.visibleEndIndex,
2960
3215
  scrollPosition: logicalScrollPosition,
2961
- totalHeight: fenwickTree.getTotal(), // contentSize (State) ではなく最新値を反映
2962
- }),
2963
- // contentSize を依存に含めることで、updateItemSize/updates 等で木の総高さが変わった際に
2964
- // (fenwickTree は安定参照のため getTotal() の変化だけでは再計算されない) 総高さを再取得する。
2965
- [renderingRanges, logicalScrollPosition, fenwickTree, contentSize],
2966
- )
3216
+ totalHeight: fenwickTree.getTotal(),
3217
+ }
3218
+ }, [renderingRanges, logicalScrollPosition, fenwickTree, treeRevision])
2967
3219
 
2968
3220
  useEffect(() => {
2969
3221
  // 目的: 描画範囲を最新の状態で同期する ref を更新する。