@aiquants/virtualscroll 3.10.0 → 3.11.1

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.
@@ -3,8 +3,8 @@ import { type DevicePixelSnapEdge, snapToDevicePixelGrid, usePaintingDevicePixel
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
- import { useFenwickMapTree } from "./useFenwickMapTree.ts"
7
- import { keepFocusOnPress, minmax } from "./utils.ts"
6
+ import { FENWICK_LOOKUP_ONLY, useFenwickMapTree, useFenwickTreeRevision } from "./useFenwickMapTree.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
@@ -431,12 +454,13 @@ export type VirtualScrollProps<T> = {
431
454
  liveRegion?: VirtualScrollLiveRegionOptions
432
455
  /**
433
456
  * UI chrome locale of the built-in strings (default `"en"`): the scrollbar's accessible names
434
- * (the bar, its thumb and its arrows), the scroll-to-edge pills and the empty-state text. Live-region wording is not affected (see
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
435
459
  * {@link VirtualScrollLiveRegionOptions.format}). An unsupported value throws a RangeError at
436
460
  * render; no language negotiation happens.
437
- * 内蔵文言 (スクロールバーのアクセシブルネーム — バー・つまみ・矢印 —、端スクロールピル、空状態文言) の UI クロームロケール
438
- * (既定 `"en"`)。ライブリージョンの文言には影響しない ({@link VirtualScrollLiveRegionOptions.format}
439
- * 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
461
+ * 内蔵文言 (スクロールバーのアクセシブルネーム — バー・つまみ・矢印 —、端スクロールピル、空状態文言) と、バーとつまみが
462
+ * 値の文字として持つ百分率の UI クロームロケール (既定 `"en"`)。ライブリージョンの文言には影響しない
463
+ * ({@link VirtualScrollLiveRegionOptions.format} 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
440
464
  */
441
465
  locale?: VirtualScrollLocale
442
466
  /**
@@ -698,6 +722,70 @@ const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): Aligne
698
722
  */
699
723
  const canonicalAlignedEdge = (alignment: AlignedEdge | null): AlignedEdge | null => (alignment !== null && alignment.panePosition <= 0 ? null : alignment)
700
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
+
701
789
  /**
702
790
  * Chooses the edge the items-wrapper translate keeps when it is snapped to the device-pixel grid
703
791
  * (`snapToDevicePixelGrid`), so that aligned content never loses part of its edge gutter to the snap:
@@ -756,6 +844,8 @@ type ItemsWrapperProps = {
756
844
  readonly snapEdge: DevicePixelSnapEdge
757
845
  /** The rendered rows and the bottom inset / 描いた行と下のインセット */
758
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>
759
849
  }
760
850
 
761
851
  /**
@@ -784,12 +874,12 @@ type ItemsWrapperProps = {
784
874
  * する 1 段の配置は、文書の根ではなくこの箱から始まる。行は箱から切り取られずにはみ出し (ペインの中身のスクロールできる範囲に
785
875
  * 数えないインクのはみ出し)、箱自身は当たり判定を取らない。
786
876
  *
787
- * @param props - The exact translate, the edge it keeps and the wrapped rows / 厳密な平行移動・守る端・包む行
877
+ * @param props - The exact translate, the edge it keeps, the wrapped rows and the boundary box's ref / 厳密な平行移動・守る端・包む行・境界の箱の ref
788
878
  * @returns The boundary box holding the wrapper element / ラッパーの要素を包む境界の箱
789
879
  * @throws {Error} When the wrapper attaches to a document without a window (see `usePaintingDevicePixelRatio`) / ウィンドウを持たない文書へ取り付いたとき (`usePaintingDevicePixelRatio` を参照)
790
880
  * @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
791
881
  */
792
- const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) => {
882
+ const ItemsWrapper = ({ translateY, snapEdge, children, boundaryRef }: ItemsWrapperProps) => {
793
883
  const { ratio, attach } = usePaintingDevicePixelRatio("[VirtualScroll] the items wrapper")
794
884
  // ❗ ラッパーは合成層なので、平行移動が装置の画素の端数を持つとブラウザは層ごと再標本化する (実測: -2440.5px で
795
885
  // 2px の輪郭・2px の隙間・2px の輪が 3+1+3 行に滲み、下辺の隙間が消える)。揃えるのは層の平行移動だけ —
@@ -801,7 +891,7 @@ const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) =>
801
891
  // ❗ ペインの中身は flex の子で配置の境界になれない。境界の箱を挟まないと、行を足し引きする 1 段の配置が毎回文書の根から
802
892
  // 始まる (実測: 4 倍の CPU で 1 段の配置の時間のすべてが文書の根から)
803
893
  return (
804
- <div className="aqvs-items-boundary">
894
+ <div ref={boundaryRef} className="aqvs-items-boundary">
805
895
  <div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
806
896
  {children}
807
897
  </div>
@@ -828,27 +918,103 @@ const toSafeBigInt = (value: number): bigint => {
828
918
  }
829
919
 
830
920
  /**
831
- * Computes rendering ranges for collections exceeding Number.MAX_SAFE_INTEGER.
832
- * Uses BigInt math to handle positions that cannot be represented by standard JavaScript numbers.
921
+ * The first visible row at a logical scroll position (`resolveVisibleStartRow`).
922
+ *
923
+ * 論理スクロール位置での先頭の可視行 (`resolveVisibleStartRow`)。
924
+ */
925
+ export type VisibleStartRow = {
926
+ /** Index of the first visible row / 先頭の可視行の index */
927
+ readonly index: number
928
+ /** Logical top of that row (px); `position - top` is the part hidden above the viewport top / その行の論理上端 (px)。`position - top` がビューポートの上端より上に隠れた量 */
929
+ readonly top: number
930
+ }
931
+
932
+ /**
933
+ * The part of the Fenwick tree `resolveVisibleStartRow` reads.
934
+ *
935
+ * `resolveVisibleStartRow` が読む Fenwick 木の部分。
936
+ */
937
+ type VisibleStartRowTree = Pick<ReturnType<typeof useFenwickMapTree>, "findIndexAtOrAfter" | "prefixSum">
938
+
939
+ /**
940
+ * Resolves the first visible row at a logical scroll position by the visible-start boundary rule — the one rule that
941
+ * `scrollTo` pins, `getScrollAnchor` reports, `updateItemSize` compensates above and `computeRenderingRanges` renders from
942
+ * (on both of its paths, `computeRenderingRangesHuge` included):
943
+ *
944
+ * - the row whose span contains the position: `top <= position < top + height`;
945
+ * - a row whose bottom equals the position lies entirely above the viewport, so the next row starts exactly there and is the
946
+ * first visible row, at offset 0;
947
+ * - at the end, the last row stays the first visible row (the end clamp): when the position equals its bottom, and when the
948
+ * position lies past the end of the content (a transient after the list shrinks, before the pane clamps).
949
+ *
950
+ * Module-level export (NOT in the package barrel).
951
+ *
952
+ * 先頭の可視行を、可視の先頭の境界の規則で論理スクロール位置から解決する処理。`scrollTo` が留め、`getScrollAnchor` が知らせ、
953
+ * `updateItemSize` がその上の変化を補正し、`computeRenderingRanges` が (`computeRenderingRangesHuge` を含む両方の経路で) 描き
954
+ * 始める、ただ 1 つの規則。
955
+ *
956
+ * - 位置を含む行 (`上端 <= 位置 < 上端 + 高さ`)。
957
+ * - 下端が位置に一致する行は丸ごとビューポートの上にあるので、ちょうどそこから始まる次の行がオフセット 0 で先頭の可視行。
958
+ * - 末尾では最後の行が先頭の可視行のまま (末尾のクランプ)。位置がその下端に一致するときと、位置が中身の末尾を越えるとき
959
+ * (一覧が縮んでからペインがクランプするまでの過渡状態)。
960
+ *
961
+ * モジュールレベル export (バレル非公開)。
962
+ *
963
+ * @param fenwickTree - The row-height tree / 行の高さの木
964
+ * @param position - Logical scroll position (px, 0 or more) / 論理スクロール位置 (px。0 以上)
965
+ * @param itemCount - Number of rows, 1 or more / 行の数 (1 以上)
966
+ * @returns The first visible row and its logical top / 先頭の可視行とその論理上端
967
+ */
968
+ export const resolveVisibleStartRow = (fenwickTree: VisibleStartRowTree, position: number, itemCount: number): VisibleStartRow => {
969
+ const lastIndex = itemCount - 1
970
+ const found = fenwickTree.findIndexAtOrAfter(position, FENWICK_LOOKUP_ONLY)
971
+ if (found.index === -1 || found.index > lastIndex || found.cumulative === undefined || found.currentValue === undefined) {
972
+ const last = fenwickTree.prefixSum(lastIndex, FENWICK_LOOKUP_ONLY)
973
+ return { index: lastIndex, top: last.cumulative - last.currentValue }
974
+ }
975
+ if (found.cumulative === position && found.index < lastIndex) {
976
+ return { index: found.index + 1, top: found.cumulative }
977
+ }
978
+ return { index: found.index, top: found.cumulative - found.currentValue }
979
+ }
980
+
981
+ /**
982
+ * Computes the rendering ranges of a list of `Number.MAX_SAFE_INTEGER` rows or more. The first visible row comes from the one
983
+ * visible-start boundary rule, `resolveVisibleStartRow`, exactly as in `computeRenderingRanges`, and the forward scan starts at
984
+ * that row's hidden offset as there; only then are the indices converted to `bigint`. The scans keep their own `bigint` loops
985
+ * 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
986
+ * 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.
987
+ * Below 2^53 both paths resolve every row boundary, zero-height rows at position 0 and the end clamp to the same ranges.
988
+ * Module-level export (NOT in the package barrel), for the spec that pins that agreement.
833
989
  *
834
- * Number.MAX_SAFE_INTEGER を超える巨大なコレクションに対して描画範囲を算出します。
835
- * 標準的な JavaScript 数値では表現できない位置を扱うために、内部計算には BigInt を使用します。
990
+ * `Number.MAX_SAFE_INTEGER` 行以上の一覧の描画範囲を求める処理。先頭の可視行は `computeRenderingRanges` とまったく同じく、ただ
991
+ * 1 つの可視の先頭の境界の規則 `resolveVisibleStartRow` から得て、前方の走査も同じくその行の隠れた量から始め、そのあとで添字を
992
+ * `bigint` へ変える。走査が `bigint` の繰り返しを持つのは、2^53 を越えると `number` では添字を 1 ずつ進められない
993
+ * (`x + 1 === x` なので、高さ 0 の行を `number` で走査すると終わらない) ため。木の引き当ては `number` の添字を取るので、その範囲の
994
+ * 先頭の行は木の答えと同じ精度。2^53 未満では、どちらの経路もどの行の境界・位置 0 の高さ 0 の行・末尾のクランプでも同じ範囲を
995
+ * 返す。モジュールレベル export (バレル非公開)。その一致を固定する spec のため。
836
996
  *
837
- * @param effectiveScrollPosition Clamped scroll position / 丸められた有効なスクロール位置
838
- * @param viewportSize Height of the viewport / ビューポートの高さ
839
- * @param overscanCount Number of items to render outside the viewport / ビューポート外に描画するアイテム数
840
- * @param itemSize Total number of items / 総アイテム数
841
- * @param getItemHeight Function to get height of an item / アイテムの高さを取得する関数
842
- * @param fenwickTree Fenwick Tree instance / Fenwick Tree インスタンス
843
- * @param totalHeight Total content height / コンテンツ総高さ
844
- * @param hasFiniteTotal Whether the total height is finite / 総高さが有限かどうか
997
+ * @param effectiveScrollPosition - Logical scroll position, clamped to the content (px) / 中身へクランプした論理スクロール位置 (px)
998
+ * @param viewportSize - Height of the viewport (px) / 表示域の高さ (px)
999
+ * @param overscanCount - Rows rendered beyond each edge / 端の外に描く行の数
1000
+ * @param itemSize - Number of rows / 行の数
1001
+ * @param getItemHeight - Height of a row / 行の高さ
1002
+ * @param fenwickTree - The row-height tree / 行の高さの木
1003
+ * @returns The rendering and visible ranges / 描画範囲と可視範囲
845
1004
  */
846
- const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number, hasFiniteTotal: boolean) => {
1005
+ export const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>) => {
847
1006
  const sizeBig = toSafeBigInt(itemSize)
848
1007
  if (sizeBig === 0n) {
849
1008
  return { renderingStartIndex: 0, renderingEndIndex: 0, visibleStartIndex: 0, visibleEndIndex: 0 }
850
1009
  }
851
1010
 
1011
+ /**
1012
+ * Clamps a row index into the list.
1013
+ * 行の添字を一覧の中へクランプする処理。
1014
+ *
1015
+ * @param value - Row index / 行の添字
1016
+ * @returns The clamped index / クランプした添字
1017
+ */
852
1018
  const clampBig = (value: bigint): bigint => {
853
1019
  if (value < 0n) {
854
1020
  return 0n
@@ -859,46 +1025,31 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
859
1025
  return value
860
1026
  }
861
1027
 
862
- const materializeOption = { materializeOption: { materialize: false } }
863
- const { index: rawStartIndex, cumulative } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, materializeOption)
864
-
865
- let startBig: bigint
866
- if (rawStartIndex === -1) {
867
- startBig = sizeBig - 1n
868
- } else {
869
- if (cumulative === effectiveScrollPosition) {
870
- startBig = toSafeBigInt(rawStartIndex + 1)
871
- } else {
872
- startBig = toSafeBigInt(rawStartIndex)
873
- }
874
-
875
- if (startBig >= sizeBig) {
876
- startBig = sizeBig - 1n
877
- }
878
- }
879
-
880
- if (effectiveScrollPosition <= 0) {
881
- startBig = 0n
882
- }
883
-
884
- if (hasFiniteTotal && effectiveScrollPosition >= totalHeight) {
885
- startBig = sizeBig - 1n
886
- }
1028
+ const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
1029
+ let startBig = clampBig(toSafeBigInt(startRow.index))
1030
+ const initialOffset = startRow.top - effectiveScrollPosition
887
1031
 
888
1032
  // 高さ 0 (折りたたみ行) はスキップして走査を継続する (散発的な 0 行は正当な入力)。
889
1033
  // 走査を打ち切るのは負値・非有限の高さ (不正入力) のみ。0 行が大量に連続する場合は
890
1034
  // 連続数が閾値を超えた時点で Fenwick 木により次の非 0 行へ O(log n) でジャンプし、有界性を保つ。
1035
+ /**
1036
+ * Finds the first row past a long run of zero-height rows that ends at `lastScanned`, through the tree.
1037
+ * `lastScanned` で終わる高さ 0 の行の長い連続の後の最初の行を、木で探す処理。
1038
+ *
1039
+ * @param lastScanned - The last scanned row / 最後に走査した行
1040
+ * @returns The row to continue from, or `null` when the tree gives none ahead / 続ける行 (木が先を返さなければ `null`)
1041
+ */
891
1042
  const jumpPastZeroRun = (lastScanned: bigint): bigint | null => {
892
1043
  const lastNumber = Number(lastScanned)
893
1044
  if (!Number.isSafeInteger(lastNumber)) {
894
1045
  // number へ安全に変換できない領域では木のクエリが不正確になるため打ち切る
895
1046
  return null
896
1047
  }
897
- const { cumulative: runBottom } = fenwickTree.prefixSum(lastNumber, { materializeOption: { materialize: false } })
1048
+ const { cumulative: runBottom } = fenwickTree.prefixSum(lastNumber, FENWICK_LOOKUP_ONLY)
898
1049
  if (!Number.isFinite(runBottom)) {
899
1050
  return null
900
1051
  }
901
- const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom + 0.5, { materializeOption: { materialize: false } })
1052
+ const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom + 0.5, FENWICK_LOOKUP_ONLY)
902
1053
  if (jumpIndex === -1) {
903
1054
  return null
904
1055
  }
@@ -906,11 +1057,18 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
906
1057
  return jumpBig > lastScanned ? jumpBig : null
907
1058
  }
908
1059
 
909
- const accumulateForward = (initial: bigint) => {
910
- let height = 0
1060
+ /**
1061
+ * Adds row heights from a row onward until they fill the viewport.
1062
+ * ある行から先の行の高さを、表示域が埋まるまで足す処理。
1063
+ *
1064
+ * @param initial - The first row / 最初の行
1065
+ * @param initialHeight - The height already counted before it (the first row's hidden part, negative) / その前に数えた高さ (最初の行の隠れた量で負)
1066
+ * @returns The counted height and the last counted row / 数えた高さと最後に数えた行
1067
+ */
1068
+ const accumulateForward = (initial: bigint, initialHeight: number) => {
1069
+ let height = initialHeight
911
1070
  let cursor = initial
912
1071
  let last = initial
913
- let iterations = 0n
914
1072
  let zeroRun = 0
915
1073
  while (cursor < sizeBig && height < viewportSize) {
916
1074
  const cursorNumber = Number(cursor)
@@ -918,7 +1076,6 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
918
1076
  height += currentHeight
919
1077
  last = cursor
920
1078
  cursor += 1n
921
- iterations += 1n
922
1079
  if (!Number.isFinite(currentHeight) || currentHeight < 0) {
923
1080
  break
924
1081
  }
@@ -942,17 +1099,15 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
942
1099
  zeroRun = 0
943
1100
  }
944
1101
  }
945
- if (iterations === 0n) {
946
- last = initial
947
- }
948
1102
  return { height, end: last }
949
1103
  }
950
1104
 
951
- let { height: forwardHeight, end: forwardEnd } = accumulateForward(startBig)
1105
+ let { height: forwardHeight, end: forwardEnd } = accumulateForward(startBig, initialOffset)
952
1106
 
953
1107
  if (forwardHeight < viewportSize && startBig > 0n) {
954
1108
  let backwardStart = startBig
955
- let backwardHeight = forwardHeight
1109
+ // 先頭の行の隠れた量は前方の走査が引いてあるので戻す (computeRenderingRanges と同じ数え方)
1110
+ let backwardHeight = forwardHeight + Math.abs(Math.min(0, initialOffset))
956
1111
  let backwardZeroRun = 0
957
1112
  while (backwardStart > 0n && backwardHeight < viewportSize) {
958
1113
  backwardStart -= 1n
@@ -966,15 +1121,14 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
966
1121
  backwardZeroRun += 1
967
1122
  if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
968
1123
  // 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
969
- const indexNum = Number(backwardStart)
970
- if (!Number.isSafeInteger(indexNum)) {
1124
+ if (!Number.isSafeInteger(indexNumber)) {
971
1125
  break
972
1126
  }
973
- const { cumulative: runBottom } = fenwickTree.prefixSum(indexNum, { materializeOption: { materialize: false } })
1127
+ const { cumulative: runBottom } = fenwickTree.prefixSum(indexNumber, FENWICK_LOOKUP_ONLY)
974
1128
  if (!Number.isFinite(runBottom)) {
975
1129
  break
976
1130
  }
977
- const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, { materializeOption: { materialize: false } })
1131
+ const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, FENWICK_LOOKUP_ONLY)
978
1132
  const jumpBig = jumpIndex === -1 ? -1n : toSafeBigInt(jumpIndex)
979
1133
  if (jumpBig >= 0n && jumpBig < backwardStart) {
980
1134
  // 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
@@ -993,7 +1147,7 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
993
1147
  }
994
1148
  }
995
1149
  startBig = clampBig(backwardStart)
996
- const forward = accumulateForward(startBig)
1150
+ const forward = accumulateForward(startBig, 0)
997
1151
  forwardHeight = forward.height
998
1152
  forwardEnd = forward.end
999
1153
  }
@@ -1011,73 +1165,6 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
1011
1165
  }
1012
1166
  }
1013
1167
 
1014
- /**
1015
- * Retrieves a high-resolution timestamp when available.
1016
- *
1017
- * 高精度タイムスタンプを可能なら取得。
1018
- */
1019
- /**
1020
- * The first visible row at a logical scroll position (`resolveVisibleStartRow`).
1021
- *
1022
- * 論理スクロール位置での先頭の可視行 (`resolveVisibleStartRow`)。
1023
- */
1024
- export type VisibleStartRow = {
1025
- /** Index of the first visible row / 先頭の可視行の index */
1026
- readonly index: number
1027
- /** Logical top of that row (px); `position - top` is the part hidden above the viewport top / その行の論理上端 (px)。`position - top` がビューポートの上端より上に隠れた量 */
1028
- readonly top: number
1029
- }
1030
-
1031
- /**
1032
- * The part of the Fenwick tree `resolveVisibleStartRow` reads.
1033
- *
1034
- * `resolveVisibleStartRow` が読む Fenwick 木の部分。
1035
- */
1036
- type VisibleStartRowTree = Pick<ReturnType<typeof useFenwickMapTree>, "findIndexAtOrAfter" | "prefixSum">
1037
-
1038
- /** Tree reads that only look the rows up, never materialising them / 行を引くだけで具現化しない木の読み方 */
1039
- const LOOKUP_ONLY = { materializeOption: { materialize: false } } as const
1040
-
1041
- /**
1042
- * Resolves the first visible row at a logical scroll position by the visible-start boundary rule — the one rule that
1043
- * `scrollTo` pins, `getScrollAnchor` reports, `updateItemSize` compensates above and `computeRenderingRanges` renders from:
1044
- *
1045
- * - the row whose span contains the position: `top <= position < top + height`;
1046
- * - a row whose bottom equals the position lies entirely above the viewport, so the next row starts exactly there and is the
1047
- * first visible row, at offset 0;
1048
- * - at the end, the last row stays the first visible row (the end clamp): when the position equals its bottom, and when the
1049
- * position lies past the end of the content (a transient after the list shrinks, before the pane clamps).
1050
- *
1051
- * Module-level export (NOT in the package barrel).
1052
- *
1053
- * 先頭の可視行を、可視の先頭の境界の規則で論理スクロール位置から解決する処理。`scrollTo` が留め、`getScrollAnchor` が知らせ、
1054
- * `updateItemSize` がその上の変化を補正し、`computeRenderingRanges` が描き始める、ただ 1 つの規則。
1055
- *
1056
- * - 位置を含む行 (`上端 <= 位置 < 上端 + 高さ`)。
1057
- * - 下端が位置に一致する行は丸ごとビューポートの上にあるので、ちょうどそこから始まる次の行がオフセット 0 で先頭の可視行。
1058
- * - 末尾では最後の行が先頭の可視行のまま (末尾のクランプ)。位置がその下端に一致するときと、位置が中身の末尾を越えるとき
1059
- * (一覧が縮んでからペインがクランプするまでの過渡状態)。
1060
- *
1061
- * モジュールレベル export (バレル非公開)。
1062
- *
1063
- * @param fenwickTree - The row-height tree / 行の高さの木
1064
- * @param position - Logical scroll position (px, 0 or more) / 論理スクロール位置 (px。0 以上)
1065
- * @param itemCount - Number of rows, 1 or more / 行の数 (1 以上)
1066
- * @returns The first visible row and its logical top / 先頭の可視行とその論理上端
1067
- */
1068
- export const resolveVisibleStartRow = (fenwickTree: VisibleStartRowTree, position: number, itemCount: number): VisibleStartRow => {
1069
- const lastIndex = itemCount - 1
1070
- const found = fenwickTree.findIndexAtOrAfter(position, LOOKUP_ONLY)
1071
- if (found.index === -1 || found.index > lastIndex || found.cumulative === undefined || found.currentValue === undefined) {
1072
- const last = fenwickTree.prefixSum(lastIndex, LOOKUP_ONLY)
1073
- return { index: lastIndex, top: last.cumulative - last.currentValue }
1074
- }
1075
- if (found.cumulative === position && found.index < lastIndex) {
1076
- return { index: found.index + 1, top: found.cumulative }
1077
- }
1078
- return { index: found.index, top: found.cumulative - found.currentValue }
1079
- }
1080
-
1081
1168
  /**
1082
1169
  * Calculates rendering boundaries from current scroll metrics. The visible rows start at the row `resolveVisibleStartRow`
1083
1170
  * resolves.
@@ -1091,7 +1178,7 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
1091
1178
  const hasFiniteTotal = Number.isFinite(totalHeight)
1092
1179
  const effectiveScrollPosition = hasFiniteTotal ? Math.min(Math.max(0, scrollPosition), totalHeight) : Math.max(0, scrollPosition)
1093
1180
  if (itemSize >= Number.MAX_SAFE_INTEGER) {
1094
- return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree, totalHeight, hasFiniteTotal)
1181
+ return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree)
1095
1182
  }
1096
1183
  const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
1097
1184
  let visibleStartIndex = startRow.index
@@ -1118,8 +1205,8 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
1118
1205
  if (currentHeight === 0) {
1119
1206
  zeroRun++
1120
1207
  if (zeroRun > ZERO_HEIGHT_RUN_LIMIT) {
1121
- const { cumulative: runBottom } = fenwickTree.prefixSum(forwardCursor - 1, { materializeOption: { materialize: false } })
1122
- const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom + 0.5, { materializeOption: { materialize: false } }) : { index: -1 }
1208
+ const { cumulative: runBottom } = fenwickTree.prefixSum(forwardCursor - 1, FENWICK_LOOKUP_ONLY)
1209
+ const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom + 0.5, FENWICK_LOOKUP_ONLY) : { index: -1 }
1123
1210
  if (jumpIndex > forwardCursor) {
1124
1211
  // 0 行の連続を飛び越えて次の非 0 行から走査を続行する
1125
1212
  forwardCursor = jumpIndex
@@ -1158,8 +1245,8 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
1158
1245
  backwardZeroRun++
1159
1246
  if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
1160
1247
  // 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
1161
- const { cumulative: runBottom } = fenwickTree.prefixSum(backwardCursor, { materializeOption: { materialize: false } })
1162
- const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom - 0.5, { materializeOption: { materialize: false } }) : { index: -1 }
1248
+ const { cumulative: runBottom } = fenwickTree.prefixSum(backwardCursor, FENWICK_LOOKUP_ONLY)
1249
+ const { index: jumpIndex } = Number.isFinite(runBottom) ? fenwickTree.findIndexAtOrAfter(runBottom - 0.5, FENWICK_LOOKUP_ONLY) : { index: -1 }
1163
1250
  if (jumpIndex !== -1 && jumpIndex < backwardCursor) {
1164
1251
  // 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
1165
1252
  backwardCursor = jumpIndex
@@ -1470,6 +1557,12 @@ const VirtualScrollInner = <T,>(
1470
1557
  )
1471
1558
  const fenwickTree = useFenwickMapTree(itemCount, getItemHeight, fenwickTreeOptions)
1472
1559
 
1560
+ // ❗ 木は同一性を保ったまま描画の外で書き換わる (updateItemSize・高さの照合・scrollToIndex の具現化)。接頭辞和の変化は総和が
1561
+ // 変わらなくても (差の和が 0 の測り直しの組) 描いた行の上端と高さ、描画範囲を変えるので、描画の外の変更はどれも changeTree を
1562
+ // 通し、木を読むどの memo も treeRevision に依存させる。総和 (中身の寸法) は相殺で変わらず、同じ値の書き込みは React が捨てるため、
1563
+ // 変化の合図にならない
1564
+ const { revision: treeRevision, change: changeTree } = useFenwickTreeRevision(fenwickTree)
1565
+
1473
1566
  const [initialValues] = useState(() => {
1474
1567
  let position = 0
1475
1568
  let total = 0
@@ -1631,6 +1724,43 @@ const VirtualScrollInner = <T,>(
1631
1724
  const [scrollDirection, setScrollDirection] = useState<"up" | "down" | null>(null)
1632
1725
  const [showScrollButtons, setShowScrollButtons] = useState(false)
1633
1726
  const scrollButtonTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1727
+ // 見せる操作は、画面に出ているピルを表示域 (行ラッパーの境界の箱の上端が表示域の上端) に対して測る
1728
+ const edgeOverlayRef = useRef<HTMLDivElement>(null)
1729
+ const edgePillRef = useRef<HTMLButtonElement>(null)
1730
+ const itemsBoundaryRef = useRef<HTMLDivElement>(null)
1731
+
1732
+ /**
1733
+ * Reads the parts of the viewport the visible scroll-to-edge pill covers (`ObscuredInsets`), in the px the rows are laid
1734
+ * out in: the pill's box is measured on screen against the viewport's top (the items boundary box) and divided by the
1735
+ * vertical scale of any transformed ancestor (`getAxisScale` on the overlay, which spans the viewport), and the pill covers
1736
+ * the edge it lies nearer to, from that edge to its far side. Nothing is covered while no pill shows (the overlay's
1737
+ * `data-visible` is not `"true"`, or the pills are off), nor while the viewport is not laid out (every box is empty).
1738
+ *
1739
+ * 見えている端へ戻るピルが覆う表示域の部分 (`ObscuredInsets`) を、行を配置する px で読む処理。ピルの箱を画面の上で表示域の
1740
+ * 上端 (行ラッパーの境界の箱) に対して測り、変換を持つ祖先の縦の拡大縮小 (表示域にまたがる覆いの `getAxisScale`) で割る。
1741
+ * ピルが覆うのは近い方の端で、その端からピルの向こう側まで。ピルが見えていない間 (覆いの `data-visible` が `"true"` でない、
1742
+ * またはピルが無効) と、表示域が配置されていない間 (どの箱も空) は何も覆わない。
1743
+ *
1744
+ * @returns The covered parts, each clamped to the viewport / 覆われた部分 (どちらも表示域の中へクランプ)
1745
+ */
1746
+ const readObscuredInsets = useCallback((): ObscuredInsets => {
1747
+ const overlay = edgeOverlayRef.current
1748
+ const pill = edgePillRef.current
1749
+ const boundary = itemsBoundaryRef.current
1750
+ if (overlay === null || pill === null || boundary === null || overlay.dataset.visible !== "true") {
1751
+ return NO_OBSCURED_INSETS
1752
+ }
1753
+ // 境界の箱は高さ 0 で縦の拡大縮小を測れないので、表示域と同じ高さの覆いで測る (測れなければ 1 なので割っても壊れない)
1754
+ const scale = getAxisScale(overlay, "y")
1755
+ const viewportTop = boundary.getBoundingClientRect().top
1756
+ const pillBox = pill.getBoundingClientRect()
1757
+ const pillTop = (pillBox.top - viewportTop) / scale
1758
+ const pillBottom = (pillBox.bottom - viewportTop) / scale
1759
+ if (pillTop + pillBottom < viewportSize) {
1760
+ return { top: minmax(pillBottom, 0, viewportSize), bottom: 0 }
1761
+ }
1762
+ return { top: 0, bottom: minmax(viewportSize - pillTop, 0, viewportSize) }
1763
+ }, [viewportSize])
1634
1764
  const isProgrammaticScrollRef = useRef(false)
1635
1765
  const isCompensatingRef = useRef(0)
1636
1766
  // レイアウトシフト補正の scrollTo が ScrollPane の旧 contentSize でクランプされた場合に true。
@@ -1995,7 +2125,7 @@ const VirtualScrollInner = <T,>(
1995
2125
  if (pendingVisibleStartIndexRef.current !== null) {
1996
2126
  const { index, align, offset } = pendingVisibleStartIndexRef.current
1997
2127
  const safeIndex = sanitizeIndex(index, itemCount)
1998
- const { cumulative: itemBottom, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: false } })
2128
+ const { cumulative: itemBottom, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, FENWICK_LOOKUP_ONLY)
1999
2129
 
2000
2130
  if (itemBottom !== undefined && itemHeight !== undefined) {
2001
2131
  const itemTop = Math.max(itemBottom - itemHeight, 0)
@@ -2069,14 +2199,19 @@ const VirtualScrollInner = <T,>(
2069
2199
  * height reconciliation on the next render. A change above the first visible row (the row of the pending
2070
2200
  * alignment while one is pending, otherwise the row `resolveVisibleStartRow` resolves) moves the scroll
2071
2201
  * position by the same delta (layout-shift compensation), carries the remembered alignment along and
2072
- * is reported through `onScrollAdjust` (`"item-resize"`) before this returns.
2202
+ * is reported through `onScrollAdjust` (`"item-resize"`) before this returns. Every change of the size
2203
+ * advances the tree revision (`changeTree`), so the next commit re-places the rendered rows and
2204
+ * re-resolves the rendering range, whether or not the total changes (several calls in one batch whose
2205
+ * changes cancel out included).
2073
2206
  *
2074
2207
  * 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
2075
2208
  * `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
2076
2209
  * 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行 (保留中の揃えがあればその行、
2077
2210
  * 無ければ `resolveVisibleStartRow` が解決する行) より上の変化はスクロール位置を
2078
2211
  * 同じ差だけ動かし (レイアウトシフトの補正)、覚えた揃えも一緒に動かして、戻る前に `onScrollAdjust`
2079
- * (`"item-resize"`) で知らせる。
2212
+ * (`"item-resize"`) で知らせる。サイズのどの変化も木の版数を進める (`changeTree`) ので、総和が変わるか
2213
+ * どうかによらず (同じバッチの中で差が打ち消し合う複数の呼び出しも含む)、次の確定が描いた行を置き直し、
2214
+ * 描画範囲を解決し直す。
2080
2215
  *
2081
2216
  * @param index - Item index / アイテムのインデックス
2082
2217
  * @param size - New size in px / 新しいサイズ (px)
@@ -2090,7 +2225,7 @@ const VirtualScrollInner = <T,>(
2090
2225
  const oldSize = fenwickTree.get(safeIndex)
2091
2226
  const delta = size - oldSize
2092
2227
 
2093
- const total = fenwickTree.update(safeIndex, size)
2228
+ const total = changeTree(() => fenwickTree.update(safeIndex, size))
2094
2229
  if (total !== undefined) {
2095
2230
  writeContentSize(total)
2096
2231
  }
@@ -2129,7 +2264,7 @@ const VirtualScrollInner = <T,>(
2129
2264
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
2130
2265
  }
2131
2266
  },
2132
- [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2267
+ [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, changeTree, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2133
2268
  )
2134
2269
 
2135
2270
  /**
@@ -2139,26 +2274,56 @@ const VirtualScrollInner = <T,>(
2139
2274
  * and the scroll position are written only when they differ from their synchronous copies (`writeContentSize`; for the
2140
2275
  * position, `latestScrollPositionRef` while the render loop is stopped, read before the pane moves), so a call that
2141
2276
  * leaves the list where it is (the same row, alignment and content) writes no state, schedules no render and reports
2142
- * no position to `onScroll`, like `scrollTo` to the current position.
2277
+ * no position to `onScroll`, like `scrollTo` to the current position. The tree revision advances (`changeTree`) only
2278
+ * when materialising the rows around the target (±`overscanCount * 2`) changed the tree.
2279
+ *
2280
+ * `align: "nearest"` (the reveal) first resolves, from the tree as it is, how the row lands in the band the visible
2281
+ * scroll-to-edge pill leaves (`resolveRevealAlignment` over `readObscuredInsets`): a row already wholly inside returns
2282
+ * without touching anything, and any other row lands with the resolved `"top"` / `"bottom"` alignment and its offset,
2283
+ * like an explicit call with them.
2143
2284
  *
2144
2285
  * 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
2145
2286
  * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を正規形で覚える
2146
2287
  * (`resolveItemsWrapperSnapEdge`)。中身の寸法とスクロール位置は同期の写し (`writeContentSize`。位置は描画ループが止まって
2147
2288
  * いる間の `latestScrollPositionRef` を、ペインを動かす前に読む) と違うときだけ書くため、一覧をその場に留める呼び出し (同じ行・揃え・中身) は状態を書かず、描画を予約せず、`onScroll` へ位置を
2148
- * 知らせない (現在位置への `scrollTo` と同じ)。
2289
+ * 知らせない (現在位置への `scrollTo` と同じ)。揃える行の周り (±`overscanCount * 2`) の具現化が木を変えたときだけ、木の版数を
2290
+ * 進める (`changeTree`)。
2291
+ *
2292
+ * `align: "nearest"` (見せる操作) は、見えている端へ戻るピルが残す帯へ行をどう着地させるかを今の木から先に解決する
2293
+ * (`readObscuredInsets` に対する `resolveRevealAlignment`)。既にまるごと入っている行は何にも触れずに戻り、それ以外の行は
2294
+ * 解決した `"top"` / `"bottom"` の揃えと offset で、それを明示した呼び出しと同じく着地させる。
2149
2295
  *
2150
2296
  * @param index - Item index / アイテムのインデックス
2151
- * @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
2297
+ * @param options - Alignment (`"top"` by default) and offset, or the reveal `{ align: "nearest" }` / 揃え方 (既定は `"top"`) と offset、または見せる操作 `{ align: "nearest" }`
2298
+ * @throws {RangeError} When `"nearest"` comes with an `offset` / `"nearest"` に `offset` を渡したとき
2152
2299
  */
2153
2300
  const scrollToIndex = useCallback(
2154
- (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => {
2301
+ (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number } | { align: "nearest"; offset?: never }) => {
2155
2302
  if (!scrollPaneRef.current || itemCount === 0) {
2156
2303
  return
2157
2304
  }
2158
2305
  const safeIndex = sanitizeIndex(index, itemCount)
2306
+ let alignment: { align?: "top" | "bottom" | "center"; offset?: number } | undefined
2307
+ if (options?.align === "nearest") {
2308
+ // 型は offset を許さないが、型の無い呼び出し元の offset を黙って捨てない
2309
+ const offset: unknown = (options as { readonly offset?: unknown }).offset
2310
+ if (offset !== undefined) {
2311
+ 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}.`)
2312
+ }
2313
+ // 見せるかどうかは今の木で決める。描画の窓とその周りの行は照合で実際の高さを持ち、窓から遠い行は表示域の中にない
2314
+ const row = fenwickTree.prefixSum(safeIndex, FENWICK_LOOKUP_ONLY)
2315
+ const reveal = resolveRevealAlignment({ top: row.cumulative - row.currentValue, bottom: row.cumulative }, toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top), viewportSize, readObscuredInsets())
2316
+ if (reveal === null) {
2317
+ return
2318
+ }
2319
+ alignment = reveal
2320
+ } else {
2321
+ alignment = options
2322
+ }
2159
2323
  const safeIndexFrom = sanitizeIndex(safeIndex - overscanCount * 2, itemCount)
2160
2324
  const safeIndexTo = sanitizeIndex(safeIndex + overscanCount * 2, itemCount)
2161
- const { cumulative: itemBottom, total, currentValue: itemHeight } = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } })
2325
+ // 揃える行の周りの具現化は推定の高さを実際の高さへ置き換え、描いた行の上端も変え得る (位置が動かない揃え直しでも)
2326
+ const { cumulative: itemBottom, total, currentValue: itemHeight } = changeTree(() => fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } }))
2162
2327
 
2163
2328
  Logger.debug("[VirtualScroll] Scrolling to index:", safeIndex, "ItemBottom:", itemBottom, "Total height:", total, "ItemHeight:", itemHeight, "safeIndexFrom:", safeIndexFrom, "safeIndexTo:", safeIndexTo)
2164
2329
 
@@ -2169,14 +2334,14 @@ const VirtualScrollInner = <T,>(
2169
2334
  const itemTop = Math.max(itemBottom - itemHeight, 0)
2170
2335
  let targetLogicalPosition = itemTop
2171
2336
 
2172
- if (options?.align === "bottom") {
2337
+ if (alignment?.align === "bottom") {
2173
2338
  targetLogicalPosition = itemBottom - viewportSize
2174
- } else if (options?.align === "center") {
2339
+ } else if (alignment?.align === "center") {
2175
2340
  targetLogicalPosition = itemTop + itemHeight / 2 - viewportSize / 2
2176
2341
  }
2177
2342
 
2178
- if (options?.offset) {
2179
- targetLogicalPosition -= options.offset
2343
+ if (alignment?.offset) {
2344
+ targetLogicalPosition -= alignment.offset
2180
2345
  }
2181
2346
 
2182
2347
  // Ensure we don't scroll past the content
@@ -2201,10 +2366,10 @@ const VirtualScrollInner = <T,>(
2201
2366
  // レイアウトシフト補正のアンカーとして、保留中のターゲットインデックスを設定します
2202
2367
  pendingVisibleStartIndexRef.current = {
2203
2368
  index: safeIndex,
2204
- align: options?.align,
2205
- offset: options?.offset,
2369
+ align: alignment?.align,
2370
+ offset: alignment?.offset,
2206
2371
  }
2207
- const edge = edgeOfAlignment(options?.align)
2372
+ const edge = edgeOfAlignment(alignment?.align)
2208
2373
  rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
2209
2374
 
2210
2375
  // ❗ 判定はペインを動かす前に行う。ペインの scrollTo はスクロールの処理を同期で呼び、位置の写しを着地位置へ進めて
@@ -2240,7 +2405,7 @@ const VirtualScrollInner = <T,>(
2240
2405
 
2241
2406
  Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
2242
2407
  },
2243
- [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2408
+ [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, changeTree, readObscuredInsets, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2244
2409
  )
2245
2410
 
2246
2411
  // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
@@ -2431,6 +2596,8 @@ const VirtualScrollInner = <T,>(
2431
2596
  const logicalScrollPosition = useMemo(() => toLogicalPositionWithInset(scrollPosition, resolvedInsets.top), [resolvedInsets.top, scrollPosition])
2432
2597
 
2433
2598
  const renderingRanges = useMemo(() => {
2599
+ // 範囲は木から解くので、位置が動かなくても木の変化 (総和が変わらない変化を含む) で解き直す
2600
+ void treeRevision
2434
2601
  // useMemo 内では State の contentSize ではなく、常に最新の計算結果を持つ fenwickTree.getTotal() を使用する。
2435
2602
  // これにより、アイテム数が大幅に減少した直後でも、古い contentSize (State) に基づく誤ったレンダリング範囲計算を防ぐことができる。
2436
2603
  // contentSize (State) の更新は非同期で行われるため、一瞬古い状態が残る可能性があるが、fenwickTree は同期的であり信頼性が高い。
@@ -2444,10 +2611,23 @@ const VirtualScrollInner = <T,>(
2444
2611
  viewportSize,
2445
2612
  }))
2446
2613
  return ranges
2447
- }, [logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree]) // contentSize を依存配列から削除
2614
+ }, [logicalScrollPosition, viewportSize, overscanCount, itemCount, getItemHeight, fenwickTree, treeRevision])
2448
2615
 
2449
2616
  const { renderingStartIndex, renderingEndIndex, visibleStartIndex, visibleEndIndex } = renderingRanges
2450
2617
 
2618
+ /**
2619
+ * Focuses the row at an index. With `ensureVisible` (default `true`) the row is first revealed by the one reveal rule
2620
+ * (`scrollToIndex` with `align: "nearest"`: the least scroll into the band a visible scroll-to-edge pill leaves), then a
2621
+ * rendered row takes focus at once and a row not rendered yet takes it when it mounts (`pendingFocusIndexRef`). With
2622
+ * `false` only a rendered row takes focus, without scrolling.
2623
+ *
2624
+ * 指定 index の行へフォーカスする処理。`ensureVisible` (既定 `true`) では先にただ 1 つの見せる規則 (`align: "nearest"` の
2625
+ * `scrollToIndex`。見えている端へ戻るピルが残す帯への最短のスクロール) で行を見せ、描いてある行はすぐに、まだ描いていない行は
2626
+ * マウントしたときに (`pendingFocusIndexRef`) フォーカスを受ける。`false` では描いてある行だけがスクロールせずにフォーカスを受ける。
2627
+ *
2628
+ * @param index - Row index / 行の index
2629
+ * @param options - Whether to reveal the row first (default `true`) / 先に行を見せるか (既定 `true`)
2630
+ */
2451
2631
  const focusItemAtIndex = useCallback(
2452
2632
  (index: number, options?: { ensureVisible?: boolean }) => {
2453
2633
  if (!enableKeyboardNavigation || itemCount === 0) {
@@ -2455,50 +2635,24 @@ const VirtualScrollInner = <T,>(
2455
2635
  }
2456
2636
  const safeIndex = sanitizeIndex(index, itemCount)
2457
2637
  const ensureVisible = options?.ensureVisible ?? true
2458
- if (!ensureVisible) {
2459
- const existingElement = itemRefs.current.get(safeIndex)
2460
- if (existingElement) {
2461
- pendingFocusIndexRef.current = null
2462
- lastFocusedIndexRef.current = safeIndex
2463
- tryFocusElement(existingElement)
2464
- }
2465
- return
2466
- }
2467
-
2468
- const prefix = fenwickTree.prefixSum(safeIndex, { materializeOption: { materialize: false } })
2469
- const itemHeight = prefix.currentValue
2470
- const itemTop = Math.max(prefix.cumulative - itemHeight, 0)
2471
- const itemBottom = itemTop + itemHeight
2472
- const viewportTop = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
2473
- const viewportBottom = viewportTop + viewportSize
2474
- const needsScroll = itemTop < viewportTop || itemBottom > viewportBottom
2475
- if (needsScroll) {
2476
- scrollToIndex(safeIndex)
2477
- // オーバースキャン内で既にマウント済みの行は、スクロールしても ref コールバックが
2478
- // 再実行されない (handleRef の identity 不変) ため、ここで直接フォーカスを適用する。
2479
- // マウント済み要素へフォーカスできたら pendingFocusIndexRef を残さない
2480
- // (残すと後刻の無関係な再マウント時にフォーカスを奪ってしまう)。
2481
- const mounted = itemRefs.current.get(safeIndex)
2482
- if (mounted) {
2483
- pendingFocusIndexRef.current = null
2484
- lastFocusedIndexRef.current = safeIndex
2485
- tryFocusElement(mounted)
2486
- } else {
2487
- pendingFocusIndexRef.current = safeIndex
2488
- }
2489
- return
2638
+ if (ensureVisible) {
2639
+ scrollToIndex(safeIndex, { align: "nearest" })
2490
2640
  }
2491
-
2492
- const element = itemRefs.current.get(safeIndex)
2493
- if (element) {
2641
+ // オーバースキャン内で既にマウント済みの行は、スクロールしても ref コールバックが再実行されない (handleRef の
2642
+ // identity 不変) ため、ここで直接フォーカスを適用する。マウント済み要素へフォーカスできたら pendingFocusIndexRef を
2643
+ // 残さない (残すと後刻の無関係な再マウント時にフォーカスを奪ってしまう)
2644
+ const mounted = itemRefs.current.get(safeIndex)
2645
+ if (mounted) {
2494
2646
  pendingFocusIndexRef.current = null
2495
2647
  lastFocusedIndexRef.current = safeIndex
2496
- tryFocusElement(element)
2648
+ tryFocusElement(mounted)
2497
2649
  return
2498
2650
  }
2499
- pendingFocusIndexRef.current = safeIndex
2651
+ if (ensureVisible) {
2652
+ pendingFocusIndexRef.current = safeIndex
2653
+ }
2500
2654
  },
2501
- [enableKeyboardNavigation, itemCount, fenwickTree, resolvedInsets.top, scrollToIndex, tryFocusElement, viewportSize],
2655
+ [enableKeyboardNavigation, itemCount, scrollToIndex, tryFocusElement],
2502
2656
  )
2503
2657
 
2504
2658
  const handleItemKeyDown = useCallback(
@@ -2719,10 +2873,11 @@ const VirtualScrollInner = <T,>(
2719
2873
  const pillTabIndex = isVisible && !scrollChromeIsPointerOnly ? 0 : -1
2720
2874
 
2721
2875
  return (
2722
- <div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible} aria-hidden={scrollChromeIsPointerOnly ? true : undefined} onPointerDown={scrollChromeIsPointerOnly ? keepFocusOnPress : undefined}>
2876
+ <div ref={edgeOverlayRef} className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible} aria-hidden={scrollChromeIsPointerOnly ? true : undefined} onPointerDown={scrollChromeIsPointerOnly ? keepFocusOnPress : undefined}>
2723
2877
  {isTop ? (
2724
2878
  <div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-top">
2725
2879
  <button
2880
+ ref={edgePillRef}
2726
2881
  type="button"
2727
2882
  className="aqvs-scroll-to-edge-button"
2728
2883
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
@@ -2739,6 +2894,7 @@ const VirtualScrollInner = <T,>(
2739
2894
  ) : (
2740
2895
  <div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-bottom">
2741
2896
  <button
2897
+ ref={edgePillRef}
2742
2898
  type="button"
2743
2899
  className="aqvs-scroll-to-edge-button"
2744
2900
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
@@ -2767,12 +2923,9 @@ const VirtualScrollInner = <T,>(
2767
2923
  const renderAnchorRef = useRef(0)
2768
2924
 
2769
2925
  const { visibleItems, renderAnchor } = useMemo(() => {
2770
- // contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
2771
- // fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
2772
- // fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
2773
- // 両経路とも中身の寸法の書き込み (writeContentSize) を伴うため、contentSize を依存へ含めることで
2774
- // 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
2775
- void contentSize
2926
+ // 行の上端と高さは木の接頭辞和から決まる。木は同一性を保ったまま描画の外で変わるので、木の版数で失効させる。総和は
2927
+ // 差の和が 0 の変化では変わらないため、中身の寸法を依存にしても上端は古いまま残る (報告する座標と描いた行の食い違い)
2928
+ void treeRevision
2776
2929
 
2777
2930
  if (itemCount === 0) {
2778
2931
  return {
@@ -2787,7 +2940,7 @@ const VirtualScrollInner = <T,>(
2787
2940
 
2788
2941
  const safeRenderingStartIndex = sanitizeIndex(renderingStartIndex, itemCount)
2789
2942
  const safeRenderingEndIndex = sanitizeIndex(renderingEndIndex, itemCount)
2790
- const { cumulative, currentValue: oldHeight } = fenwickTree.prefixSum(safeRenderingStartIndex, { materializeOption: { materialize: false } })
2943
+ const { cumulative, currentValue: oldHeight } = fenwickTree.prefixSum(safeRenderingStartIndex, FENWICK_LOOKUP_ONLY)
2791
2944
  const startPosition = cumulative - oldHeight
2792
2945
 
2793
2946
  // 量子化アンカーの再基準化: 描画ウィンドウ先頭が現アンカーから一定距離を超えて離れたときだけ
@@ -2877,7 +3030,9 @@ const VirtualScrollInner = <T,>(
2877
3030
  }
2878
3031
  }
2879
3032
 
2880
- const total = fenwickTree.updates(toUpdateHeights)
3033
+ // 照合した行の上端は描画の中で補ってあるが、木から導いた値 (getRange の総高さなど) は古い木のまま。総和が変わらない
3034
+ // 変化でも、次の確定で木から導き直させる
3035
+ const total = changeTree(() => fenwickTree.updates(toUpdateHeights))
2881
3036
  if (isUnmountedRef.current || typeof total !== "number") {
2882
3037
  return
2883
3038
  }
@@ -2905,9 +3060,9 @@ const VirtualScrollInner = <T,>(
2905
3060
  }, [
2906
3061
  applySelfAdjustment,
2907
3062
  carryAlignedEdge,
3063
+ changeTree,
2908
3064
  children,
2909
3065
  clipItemHeight,
2910
- contentSize,
2911
3066
  enableKeyboardNavigation,
2912
3067
  itemCount,
2913
3068
  fenwickTree,
@@ -2923,6 +3078,7 @@ const VirtualScrollInner = <T,>(
2923
3078
  renderingEndIndex,
2924
3079
  renderingStartIndex,
2925
3080
  resolvedLabels,
3081
+ treeRevision,
2926
3082
  updateScrollPositionImmediate,
2927
3083
  visibleStartIndex,
2928
3084
  writeContentSize,
@@ -3001,7 +3157,7 @@ const VirtualScrollInner = <T,>(
3001
3157
  }))
3002
3158
 
3003
3159
  return (
3004
- <ItemsWrapper translateY={containerTop} snapEdge={snapEdge}>
3160
+ <ItemsWrapper translateY={containerTop} snapEdge={snapEdge} boundaryRef={itemsBoundaryRef}>
3005
3161
  {visibleItems}
3006
3162
  {bottomInset}
3007
3163
  </ItemsWrapper>
@@ -3010,19 +3166,18 @@ const VirtualScrollInner = <T,>(
3010
3166
  [alignedEdge, callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
3011
3167
  )
3012
3168
 
3013
- const currentRange = useMemo<VirtualScrollRange>(
3014
- () => ({
3169
+ const currentRange = useMemo<VirtualScrollRange>(() => {
3170
+ // 総高さは木から読む。木は同一性を保ったまま変わるので、木の版数で読み直す
3171
+ void treeRevision
3172
+ return {
3015
3173
  renderingStartIndex: renderingRanges.renderingStartIndex,
3016
3174
  renderingEndIndex: renderingRanges.renderingEndIndex,
3017
3175
  visibleStartIndex: renderingRanges.visibleStartIndex,
3018
3176
  visibleEndIndex: renderingRanges.visibleEndIndex,
3019
3177
  scrollPosition: logicalScrollPosition,
3020
- totalHeight: fenwickTree.getTotal(), // contentSize (State) ではなく最新値を反映
3021
- }),
3022
- // contentSize を依存に含めることで、updateItemSize/updates 等で木の総高さが変わった際に
3023
- // (fenwickTree は安定参照のため getTotal() の変化だけでは再計算されない) 総高さを再取得する。
3024
- [renderingRanges, logicalScrollPosition, fenwickTree, contentSize],
3025
- )
3178
+ totalHeight: fenwickTree.getTotal(),
3179
+ }
3180
+ }, [renderingRanges, logicalScrollPosition, fenwickTree, treeRevision])
3026
3181
 
3027
3182
  useEffect(() => {
3028
3183
  // 目的: 描画範囲を最新の状態で同期する ref を更新する。