@aiquants/virtualscroll 3.9.1 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +95 -29
- package/dist/ScrollBar.d.cts +41 -35
- package/dist/ScrollBar.d.ts +41 -35
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.cts +12 -14
- package/dist/ScrollPane.d.ts +12 -14
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualGrid.d.cts +6 -6
- package/dist/VirtualGrid.d.ts +6 -6
- package/dist/VirtualGrid.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +72 -23
- package/dist/VirtualScroll.d.ts +72 -23
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +2048 -2025
- package/dist/labels.d.cts +38 -7
- package/dist/labels.d.ts +38 -7
- package/dist/labels.d.ts.map +1 -1
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +1 -1
- package/dist/utils.d.cts +14 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/ScrollBar.tsx +91 -49
- package/src/ScrollPane.tsx +12 -14
- package/src/VirtualGrid.tsx +6 -6
- package/src/VirtualScroll.tsx +235 -116
- package/src/labels.ts +58 -7
- package/src/styles/virtualscroll.css +51 -3
- package/src/utils.ts +15 -0
package/src/VirtualScroll.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type Virt
|
|
|
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 { keepFocusOnPress, minmax } from "./utils.ts"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Represents the current state of the rendered range and scroll metrics.
|
|
@@ -196,21 +196,23 @@ export type VirtualScrollScrollBarOptions = {
|
|
|
196
196
|
/** Whether the arrow buttons scroll (default `true`). / 矢印ボタンによるスクロールを許可するかどうか (既定 `true`)。 */
|
|
197
197
|
enableArrowButtons?: boolean
|
|
198
198
|
/**
|
|
199
|
-
* Whether the
|
|
200
|
-
* the
|
|
201
|
-
* the
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
199
|
+
* Whether the scroll chrome is the keyboard user's scrolling control (default `true`), or pointer-only because the
|
|
200
|
+
* host scrolls the list by keyboard itself (`false`: roving row focus with Arrow / Page / Home / End).
|
|
201
|
+
* `true`: the scrollbar is a named `role="scrollbar"` with a named `role="slider"` thumb, and its arrows (and the
|
|
202
|
+
* visible scroll-to-edge pills) are Tab stops — the rows are not Tab stops, so without a host keyboard model they are
|
|
203
|
+
* the only scrolling controls a keyboard user can reach with Tab.
|
|
204
|
+
* `false`: the scrollbar and the scroll-to-edge pills become pointer-only, like a native scrollbar: both carry
|
|
205
|
+
* `aria-hidden="true"`, nothing in them is a Tab stop, and a press on them never moves focus (neither onto them nor
|
|
206
|
+
* away from where the host put it); pointer scrolling and the pills' clicks are unchanged. Full contract:
|
|
206
207
|
* `ScrollBarProps["enableArrowButtonTabStops"]`.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* Tab
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
208
|
+
* スクロールの部品がキーボード利用者のスクロール操作部品か (既定 `true`)、ホストが一覧のキーボードスクロールを
|
|
209
|
+
* 自分で持つためポインタ専用か (`false`: 行のロービングフォーカスと矢印 / Page / Home / End) の指定。
|
|
210
|
+
* `true`: スクロールバーは名前付きの `role="scrollbar"` で名前付きの `role="slider"` のつまみを持ち、矢印 (と見えている
|
|
211
|
+
* 端へ戻るピル) は Tab の止まり先 — 行は Tab の止まり先ではないため、ホストのキーボードモデルが無ければ Tab で届く
|
|
212
|
+
* 唯一のスクロール操作部品。
|
|
213
|
+
* `false`: スクロールバーと端へ戻るピルはネイティブのスクロールバーと同じくポインタ専用になる。どちらも
|
|
214
|
+
* `aria-hidden="true"` を持ち、中に Tab の止まり先は無く、押下はフォーカスを動かさない (部品へも、ホストが置いた
|
|
215
|
+
* 場所の外へも)。ポインタのスクロールとピルの click は変わらない。契約の全文は
|
|
214
216
|
* `ScrollBarProps["enableArrowButtonTabStops"]`。
|
|
215
217
|
*/
|
|
216
218
|
enableArrowButtonTabStops?: boolean
|
|
@@ -314,12 +316,12 @@ export type VirtualScrollLiveRegionOptions = {
|
|
|
314
316
|
* Formats the announcement text. Called after the visible range settles; returning the same
|
|
315
317
|
* string as last time leaves the DOM untouched (no re-announcement), returning `""` clears
|
|
316
318
|
* the region. The live-region wording and its language are consumer-owned: the package's
|
|
317
|
-
* built-in catalog (`locale` / `labels`) covers only the
|
|
318
|
-
*
|
|
319
|
+
* built-in catalog (`locale` / `labels`) covers only the eleven chrome strings (the scrollbar's
|
|
320
|
+
* accessible names, the scroll-to-edge pills and the empty state), never announcements.
|
|
319
321
|
* 読み上げ文言を組み立てる。可視範囲が静定した後に呼ばれ、前回と同じ文字列なら DOM を
|
|
320
322
|
* 触らない (再読み上げしない)。`""` を返すとリージョンを空にする。読み上げの文面と言語は
|
|
321
|
-
* 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム
|
|
322
|
-
* (
|
|
323
|
+
* 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム 11 文言
|
|
324
|
+
* (スクロールバーのアクセシブルネーム、端スクロールピル、空状態) だけで、読み上げは対象外。
|
|
323
325
|
*
|
|
324
326
|
* @param range - The settled visible range / 静定した可視範囲
|
|
325
327
|
* @returns Announcement text / 読み上げ文言
|
|
@@ -428,11 +430,11 @@ export type VirtualScrollProps<T> = {
|
|
|
428
430
|
*/
|
|
429
431
|
liveRegion?: VirtualScrollLiveRegionOptions
|
|
430
432
|
/**
|
|
431
|
-
* UI chrome locale of the built-in strings (default `"en"`): the scrollbar
|
|
432
|
-
* the scroll-to-edge pills and the empty-state text. Live-region wording is not affected (see
|
|
433
|
+
* 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
|
|
433
435
|
* {@link VirtualScrollLiveRegionOptions.format}). An unsupported value throws a RangeError at
|
|
434
436
|
* render; no language negotiation happens.
|
|
435
|
-
* 内蔵文言 (
|
|
437
|
+
* 内蔵文言 (スクロールバーのアクセシブルネーム — バー・つまみ・矢印 —、端スクロールピル、空状態文言) の UI クロームロケール
|
|
436
438
|
* (既定 `"en"`)。ライブリージョンの文言には影響しない ({@link VirtualScrollLiveRegionOptions.format}
|
|
437
439
|
* 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
|
|
438
440
|
*/
|
|
@@ -678,6 +680,24 @@ const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): Aligne
|
|
|
678
680
|
return align === "bottom" ? "end" : "start"
|
|
679
681
|
}
|
|
680
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Returns the canonical form of an alignment to remember: `null` for one that can never change the edge the items
|
|
685
|
+
* wrapper keeps, the alignment itself otherwise. An alignment at pane position 0 or below holds only at positions within
|
|
686
|
+
* `EDGE_POSITION_TOLERANCE` of position 0, where `resolveItemsWrapperSnapEdge` answers `"start"` from the position
|
|
687
|
+
* alone, so it means the same as remembering nothing. Storing only the canonical form keeps one state for one meaning:
|
|
688
|
+
* a list the user scrolled back to the top and a list `scrollToIndex` aligned there hold the same state, so aligning it
|
|
689
|
+
* to the top again writes nothing.
|
|
690
|
+
*
|
|
691
|
+
* 覚える揃えの正規形を返す処理。行ラッパーが守る端を決して変えない揃えは `null`、それ以外は揃えそのもの。ペイン位置 0 以下の
|
|
692
|
+
* 揃えが成り立つのは位置 0 から `EDGE_POSITION_TOLERANCE` 以内の位置だけで、そこでは `resolveItemsWrapperSnapEdge` が位置
|
|
693
|
+
* だけから `"start"` を返すため、何も覚えないのと同じ意味。正規形だけを持つことで 1 つの意味に 1 つの状態となり、利用者が
|
|
694
|
+
* 先頭へ戻した一覧と `scrollToIndex` が先頭へ揃えた一覧が同じ状態を持ち、先頭へ揃え直しても何も書かない。
|
|
695
|
+
*
|
|
696
|
+
* @param alignment - The alignment, or `null` / 揃え (無ければ `null`)
|
|
697
|
+
* @returns The canonical alignment, or `null` / 正規形の揃え (無ければ `null`)
|
|
698
|
+
*/
|
|
699
|
+
const canonicalAlignedEdge = (alignment: AlignedEdge | null): AlignedEdge | null => (alignment !== null && alignment.panePosition <= 0 ? null : alignment)
|
|
700
|
+
|
|
681
701
|
/**
|
|
682
702
|
* Chooses the edge the items-wrapper translate keeps when it is snapped to the device-pixel grid
|
|
683
703
|
* (`snapToDevicePixelGrid`), so that aligned content never loses part of its edge gutter to the snap:
|
|
@@ -747,14 +767,25 @@ type ItemsWrapperProps = {
|
|
|
747
767
|
* render anchor is a whole number of device px — for example when every row height is a multiple of a lattice L with
|
|
748
768
|
* L × ratio a whole number (L = 4 px at ratios in quarter steps).
|
|
749
769
|
*
|
|
770
|
+
* The wrapper sits in `.aqvs-items-boundary`, its containing block and a relayout boundary: a positioned box with size and
|
|
771
|
+
* layout containment that is neither a flex nor a grid item, attached to the top, left and right padding edges of the pane
|
|
772
|
+
* content (so the wrapper keeps its position and width) with a height of 0. A step that mounts or unmounts rows therefore
|
|
773
|
+
* lays out from that box instead of from the document root. The rows overflow it unclipped, as ink overflow that is not
|
|
774
|
+
* part of the pane content's scrollable area, and the box takes no hit test of its own.
|
|
775
|
+
*
|
|
750
776
|
* 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`) で、平行移動は描くウィンドウの装置の画素の格子へ
|
|
751
777
|
* `snapEdge` の側で揃える (`snapToDevicePixelGrid`。比は `usePaintingDevicePixelRatio` が描画の中で読むので、自分の
|
|
752
778
|
* ウィンドウの文書に描いた一覧は最初の確定から揃う)。揃えるのは層の平行移動だけ。層の中の行は厳密なレイアウトの位置に
|
|
753
779
|
* 描かれ、角の丸い枠線・輪・輪郭はそこで滲む。行が装置の画素の整数から始まるのは、描画のアンカーからの行の位置が装置 px の
|
|
754
780
|
* 整数のときだけ (例: どの行の高さも、L × 比が整数になる格子 L の倍数のとき。比が 4 分の 1 刻みなら L = 4px)。
|
|
755
781
|
*
|
|
782
|
+
* ラッパーは包含ブロックで配置の境界の `.aqvs-items-boundary` の中に置く。位置指定され、大きさと配置を封じ込め、flex の子でも
|
|
783
|
+
* grid の子でもない箱で、ペインの中身のパディングの上・左・右の辺に付き (ラッパーの位置と幅は変わらない)、高さは 0。行を足し引き
|
|
784
|
+
* する 1 段の配置は、文書の根ではなくこの箱から始まる。行は箱から切り取られずにはみ出し (ペインの中身のスクロールできる範囲に
|
|
785
|
+
* 数えないインクのはみ出し)、箱自身は当たり判定を取らない。
|
|
786
|
+
*
|
|
756
787
|
* @param props - The exact translate, the edge it keeps and the wrapped rows / 厳密な平行移動・守る端・包む行
|
|
757
|
-
* @returns The wrapper element /
|
|
788
|
+
* @returns The boundary box holding the wrapper element / ラッパーの要素を包む境界の箱
|
|
758
789
|
* @throws {Error} When the wrapper attaches to a document without a window (see `usePaintingDevicePixelRatio`) / ウィンドウを持たない文書へ取り付いたとき (`usePaintingDevicePixelRatio` を参照)
|
|
759
790
|
* @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
|
|
760
791
|
*/
|
|
@@ -767,9 +798,13 @@ const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) =>
|
|
|
767
798
|
// ❗ 層の様式は will-change: transform だけ。perspective や 3D の変換を足すと Chromium は描画の切り取り (cull rect) を
|
|
768
799
|
// 外して描くが、行を非 2D の変換の下で合成するので画素比によっては層ごと再標本化する (実測: 比 1.25 の明色で選択の輪と
|
|
769
800
|
// フォーカスの輪の間の帯が混ざる)。窓をずらす 1 段がはみ出しを切る中身の行を描き直す費用は、その代わりに受け入れる
|
|
801
|
+
// ❗ ペインの中身は flex の子で配置の境界になれない。境界の箱を挟まないと、行を足し引きする 1 段の配置が毎回文書の根から
|
|
802
|
+
// 始まる (実測: 4 倍の CPU で 1 段の配置の時間のすべてが文書の根から)
|
|
770
803
|
return (
|
|
771
|
-
<div
|
|
772
|
-
{
|
|
804
|
+
<div className="aqvs-items-boundary">
|
|
805
|
+
<div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
|
|
806
|
+
{children}
|
|
807
|
+
</div>
|
|
773
808
|
</div>
|
|
774
809
|
)
|
|
775
810
|
}
|
|
@@ -982,9 +1017,72 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
|
|
|
982
1017
|
* 高精度タイムスタンプを可能なら取得。
|
|
983
1018
|
*/
|
|
984
1019
|
/**
|
|
985
|
-
*
|
|
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.
|
|
986
1033
|
*
|
|
987
|
-
*
|
|
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
|
+
/**
|
|
1082
|
+
* Calculates rendering boundaries from current scroll metrics. The visible rows start at the row `resolveVisibleStartRow`
|
|
1083
|
+
* resolves.
|
|
1084
|
+
*
|
|
1085
|
+
* 現在のスクロール情報から描画範囲を算出。可視の行は `resolveVisibleStartRow` が解決する行から始まる。
|
|
988
1086
|
*/
|
|
989
1087
|
export const computeRenderingRanges = (scrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number) => {
|
|
990
1088
|
if (itemSize === 0) {
|
|
@@ -995,31 +1093,9 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
|
|
|
995
1093
|
if (itemSize >= Number.MAX_SAFE_INTEGER) {
|
|
996
1094
|
return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree, totalHeight, hasFiniteTotal)
|
|
997
1095
|
}
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
? (() => {
|
|
1002
|
-
// Fenwick がインデックスを返さない場合は末尾に合わせて開始位置を再計算
|
|
1003
|
-
if (viewportSize <= 0) {
|
|
1004
|
-
return itemSize - 1
|
|
1005
|
-
}
|
|
1006
|
-
return (cumulative ?? 0) < effectiveScrollPosition + (currentValue ?? 0) ? itemSize - 1 : 0
|
|
1007
|
-
})()
|
|
1008
|
-
: rawStartIndex
|
|
1009
|
-
let visibleStartIndex = sanitizeIndex(startIndex, itemSize)
|
|
1010
|
-
|
|
1011
|
-
let visibleHeight = 0
|
|
1012
|
-
if (rawStartIndex !== -1 && cumulative === effectiveScrollPosition) {
|
|
1013
|
-
visibleStartIndex = sanitizeIndex(rawStartIndex + 1, itemSize)
|
|
1014
|
-
visibleHeight = 0
|
|
1015
|
-
} else if (visibleStartIndex === rawStartIndex && cumulative !== undefined && currentValue !== undefined) {
|
|
1016
|
-
const itemTop = cumulative - currentValue
|
|
1017
|
-
visibleHeight = itemTop - effectiveScrollPosition
|
|
1018
|
-
} else {
|
|
1019
|
-
const { cumulative: startCumulative, currentValue: startHeight } = fenwickTree.prefixSum(visibleStartIndex, { materializeOption: { materialize: false } })
|
|
1020
|
-
const itemTop = (startCumulative ?? 0) - (startHeight ?? 0)
|
|
1021
|
-
visibleHeight = itemTop - effectiveScrollPosition
|
|
1022
|
-
}
|
|
1096
|
+
const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
|
|
1097
|
+
let visibleStartIndex = startRow.index
|
|
1098
|
+
let visibleHeight = startRow.top - effectiveScrollPosition
|
|
1023
1099
|
|
|
1024
1100
|
const initialOffset = visibleHeight
|
|
1025
1101
|
|
|
@@ -1303,6 +1379,8 @@ const VirtualScrollInner = <T,>(
|
|
|
1303
1379
|
ref: React.Ref<VirtualScrollHandle>,
|
|
1304
1380
|
) => {
|
|
1305
1381
|
const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableArrowButtonTabStops, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
|
|
1382
|
+
// 合図はスクロールバーと同じ 1 つ (既定 true)。端へ戻るピルもバーと同じくポインタ専用になる
|
|
1383
|
+
const scrollChromeIsPointerOnly = enableArrowButtonTabStops === false
|
|
1306
1384
|
const resolvedLabels = useMemo(() => resolveVirtualScrollLabels(locale, labels), [locale, labels])
|
|
1307
1385
|
|
|
1308
1386
|
const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
|
|
@@ -1428,26 +1506,48 @@ const VirtualScrollInner = <T,>(
|
|
|
1428
1506
|
|
|
1429
1507
|
const [scrollPosition, setScrollPosition] = useState(initialValues.position)
|
|
1430
1508
|
const [contentSize, setContentSize] = useState<number>(initialValues.total)
|
|
1431
|
-
// 初期位置の index とアンカーは上端揃えの scrollToIndex
|
|
1432
|
-
//
|
|
1433
|
-
|
|
1434
|
-
const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" || initialValues.position <= EDGE_POSITION_TOLERANCE ? { edge: "start", panePosition: initialValues.position } : null))
|
|
1509
|
+
// 初期位置の index とアンカーは上端揃えの scrollToIndex と同じ位置なので、その位置の上端揃えとして覚えておく。覚えずに
|
|
1510
|
+
// 始めると、同じ行へ揃え直すだけの最初の scrollToIndex が状態を書き、確定を 1 回足す。位置 0 では正規形が null になる
|
|
1511
|
+
const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" ? canonicalAlignedEdge({ edge: "start", panePosition: initialValues.position }) : null))
|
|
1435
1512
|
// 状態の同期の写し。手動スクロールのたびに状態へ null を書くと、同じ値でも React が描画を予約し得るため、写しで要否を決める
|
|
1436
1513
|
const alignedEdgeRef = useRef(alignedEdge)
|
|
1514
|
+
// contentSize の同期の写し。確定の直後は同じ値の setState でも React が描画を 1 回予約するため、書くかどうかを写しで決める
|
|
1515
|
+
const contentSizeRef = useRef(contentSize)
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Writes the content size the pane and the scroll bar are sized by, and its synchronous copy, only when the value
|
|
1519
|
+
* differs from the one last written (every writer of the content size goes through here, so the copy is the value the
|
|
1520
|
+
* state settles on).
|
|
1521
|
+
*
|
|
1522
|
+
* ペインとスクロールバーの寸法の元になる中身の寸法と、その同期の写しへ、最後に書いた値と違うときだけ書く処理 (中身の寸法の
|
|
1523
|
+
* 書き手はすべてここを通るため、写しは状態が落ち着く値)。
|
|
1524
|
+
*
|
|
1525
|
+
* @param next - The content size (px) / 中身の寸法 (px)
|
|
1526
|
+
*/
|
|
1527
|
+
const writeContentSize = useCallback((next: number): void => {
|
|
1528
|
+
if (contentSizeRef.current === next) {
|
|
1529
|
+
return
|
|
1530
|
+
}
|
|
1531
|
+
contentSizeRef.current = next
|
|
1532
|
+
setContentSize(next)
|
|
1533
|
+
}, [])
|
|
1437
1534
|
|
|
1438
1535
|
/**
|
|
1439
|
-
* Remembers the alignment that holds at a pane position, or forgets it with `null`:
|
|
1440
|
-
*
|
|
1536
|
+
* Remembers the alignment that holds at a pane position, or forgets it with `null`: reduces it to its canonical form
|
|
1537
|
+
* (`canonicalAlignedEdge`, so an alignment at position 0 is the same as none), then writes the state the items wrapper
|
|
1538
|
+
* snaps with and its synchronous copy, only when the canonical form differs from the remembered one in edge or pane
|
|
1539
|
+
* position.
|
|
1441
1540
|
*
|
|
1442
|
-
* あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)
|
|
1443
|
-
*
|
|
1541
|
+
* あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。揃えを正規形にし (`canonicalAlignedEdge`。位置 0 の揃えは
|
|
1542
|
+
* 揃え無しと同じ)、行ラッパーが揃えに使う状態と、その同期の写しへ、正規形が覚えた揃えと辺かペイン位置で違うときだけ書く。
|
|
1444
1543
|
*
|
|
1445
|
-
* @param
|
|
1544
|
+
* @param alignment - The alignment, or `null` / 揃え (無ければ `null`)
|
|
1446
1545
|
*/
|
|
1447
|
-
const rememberAlignedEdge = useCallback((
|
|
1546
|
+
const rememberAlignedEdge = useCallback((alignment: AlignedEdge | null): void => {
|
|
1448
1547
|
const current = alignedEdgeRef.current
|
|
1548
|
+
const next = canonicalAlignedEdge(alignment)
|
|
1449
1549
|
// 同じ揃えでも新しいオブジェクトを書けば React は描画を予約する。同じ位置への scrollToIndex (ホストが打鍵ごとに
|
|
1450
|
-
//
|
|
1550
|
+
// 揃え直すなど) のたびに確定が 1 回増えるため、辺と位置が同じなら書かない
|
|
1451
1551
|
if (current === next || (current !== null && next !== null && current.edge === next.edge && current.panePosition === next.panePosition)) {
|
|
1452
1552
|
return
|
|
1453
1553
|
}
|
|
@@ -1457,10 +1557,15 @@ const VirtualScrollInner = <T,>(
|
|
|
1457
1557
|
|
|
1458
1558
|
/**
|
|
1459
1559
|
* Moves the remembered alignment along with a position change that keeps the aligned row in place (layout-shift
|
|
1460
|
-
* compensation): when the alignment held at `fromPanePosition`, it now holds at `toPanePosition`.
|
|
1560
|
+
* compensation): when the alignment held at `fromPanePosition`, it now holds at `toPanePosition`. Only a remembered
|
|
1561
|
+
* alignment moves: the top edge a list keeps at position 0 comes from the position (`resolveItemsWrapperSnapEdge`), not
|
|
1562
|
+
* from the state (`canonicalAlignedEdge`), so a move away from position 0 carries no edge, and an alignment carried to
|
|
1563
|
+
* position 0 or below is dropped.
|
|
1461
1564
|
*
|
|
1462
1565
|
* 揃えた行をその場に留める位置の変化 (レイアウトシフトの補正) に合わせて、覚えた揃えを動かす処理。揃えが
|
|
1463
|
-
* `fromPanePosition` で成り立っていたなら、`toPanePosition`
|
|
1566
|
+
* `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。動くのは覚えた揃えだけ。位置 0 の一覧が守る上端は
|
|
1567
|
+
* 状態ではなく位置から決まる (`resolveItemsWrapperSnapEdge`・`canonicalAlignedEdge`) ため、位置 0 から離れる変化は端を
|
|
1568
|
+
* 運ばず、位置 0 以下へ運んだ揃えは消える。
|
|
1464
1569
|
*
|
|
1465
1570
|
* @param fromPanePosition - Pane position before the change / 変化の前のペイン位置
|
|
1466
1571
|
* @param toPanePosition - Pane position after the change / 変化の後のペイン位置
|
|
@@ -1826,9 +1931,9 @@ const VirtualScrollInner = <T,>(
|
|
|
1826
1931
|
const didApplyInitialOffsetRef = useRef(false)
|
|
1827
1932
|
|
|
1828
1933
|
/**
|
|
1829
|
-
*
|
|
1934
|
+
* Applies `initialScrollOffset` once, as the highest-priority initial position, synchronising the pane and the logical position.
|
|
1830
1935
|
*
|
|
1831
|
-
*
|
|
1936
|
+
* `initialScrollOffset` を最優先の初期位置として一度だけ適用し、ペインと論理位置を同期させる effect。
|
|
1832
1937
|
*/
|
|
1833
1938
|
useEffect(() => {
|
|
1834
1939
|
if (didApplyInitialOffsetRef.current) return
|
|
@@ -1861,7 +1966,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1861
1966
|
|
|
1862
1967
|
const totalHeight = fenwickTree.getTotal()
|
|
1863
1968
|
if (contentSize !== totalHeight) {
|
|
1864
|
-
|
|
1969
|
+
writeContentSize(totalHeight)
|
|
1865
1970
|
return
|
|
1866
1971
|
}
|
|
1867
1972
|
|
|
@@ -1933,7 +2038,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1933
2038
|
}
|
|
1934
2039
|
}
|
|
1935
2040
|
isResizingRef.current = false
|
|
1936
|
-
}, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
|
|
2041
|
+
}, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom, writeContentSize])
|
|
1937
2042
|
|
|
1938
2043
|
useEffect(() => {
|
|
1939
2044
|
// 目的: 上部インセットが動的に変更された場合、論理スクロール位置を維持したまま、ペインのスクロール位置を再計算してずらす。
|
|
@@ -1961,13 +2066,15 @@ const VirtualScrollInner = <T,>(
|
|
|
1961
2066
|
* Updates the size of a specific item. Contract: after this call, `getItemHeight(index)` must
|
|
1962
2067
|
* return the same `size`; `getItemHeight` is the source of truth, so rows inside the current
|
|
1963
2068
|
* rendering window (including overscan) are otherwise reverted to the `getItemHeight` value by
|
|
1964
|
-
* height reconciliation on the next render. A change above the first visible row
|
|
2069
|
+
* height reconciliation on the next render. A change above the first visible row (the row of the pending
|
|
2070
|
+
* alignment while one is pending, otherwise the row `resolveVisibleStartRow` resolves) moves the scroll
|
|
1965
2071
|
* position by the same delta (layout-shift compensation), carries the remembered alignment along and
|
|
1966
2072
|
* is reported through `onScrollAdjust` (`"item-resize"`) before this returns.
|
|
1967
2073
|
*
|
|
1968
2074
|
* 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
|
|
1969
2075
|
* `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
|
|
1970
|
-
* 次レンダーの高さ照合で `getItemHeight`
|
|
2076
|
+
* 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行 (保留中の揃えがあればその行、
|
|
2077
|
+
* 無ければ `resolveVisibleStartRow` が解決する行) より上の変化はスクロール位置を
|
|
1971
2078
|
* 同じ差だけ動かし (レイアウトシフトの補正)、覚えた揃えも一緒に動かして、戻る前に `onScrollAdjust`
|
|
1972
2079
|
* (`"item-resize"`) で知らせる。
|
|
1973
2080
|
*
|
|
@@ -1985,7 +2092,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1985
2092
|
|
|
1986
2093
|
const total = fenwickTree.update(safeIndex, size)
|
|
1987
2094
|
if (total !== undefined) {
|
|
1988
|
-
|
|
2095
|
+
writeContentSize(total)
|
|
1989
2096
|
}
|
|
1990
2097
|
Logger.debug("[VirtualScroll] Updated item size manually", { index: safeIndex, size, total })
|
|
1991
2098
|
|
|
@@ -2000,18 +2107,12 @@ const VirtualScrollInner = <T,>(
|
|
|
2000
2107
|
const currentScrollTop = latestScrollPositionRef.current
|
|
2001
2108
|
const insetsTop = normalizeInsets(contentInsets).top
|
|
2002
2109
|
const logicalScrollTop = toLogicalPositionWithInset(currentScrollTop, insetsTop)
|
|
2003
|
-
|
|
2004
|
-
const { index, cumulative } = fenwickTree.findIndexAtOrAfter(logicalScrollTop, { materializeOption: { materialize: false } })
|
|
2005
|
-
// findIndexAtOrAfter は「下端(cumulative) >= scrollTop の最小 index」を返すため、
|
|
2006
|
-
// アイテム index の下端がちょうど scrollTop に一致する場合、その行は完全にビューポート上方にある。
|
|
2007
|
-
// computeRenderingRanges (cumulative === effectiveScrollPosition の特例) と同じく可視先頭を index+1 に補正する。
|
|
2008
|
-
activeVisibleStartIndex = cumulative !== undefined && cumulative === logicalScrollTop ? index + 1 : index
|
|
2110
|
+
activeVisibleStartIndex = resolveVisibleStartRow(fenwickTree, logicalScrollTop, itemCount).index
|
|
2009
2111
|
}
|
|
2010
2112
|
|
|
2011
|
-
// Allow for a small buffer in case of slight misalignments or stale refs
|
|
2012
2113
|
// If the item is strictly above the visible start, it pushes content down.
|
|
2013
|
-
//
|
|
2014
|
-
if (
|
|
2114
|
+
// アイテムが可視領域より上にある場合、コンテンツ全体を押し下げます
|
|
2115
|
+
if (safeIndex < activeVisibleStartIndex && delta !== 0) {
|
|
2015
2116
|
// Use latestScrollPositionRef as the source of truth for the current position
|
|
2016
2117
|
// to avoid reading stale DOM values during batched updates (e.g. multiple items resizing at once).
|
|
2017
2118
|
const currentPanePosition = latestScrollPositionRef.current
|
|
@@ -2028,16 +2129,23 @@ const VirtualScrollInner = <T,>(
|
|
|
2028
2129
|
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
|
|
2029
2130
|
}
|
|
2030
2131
|
},
|
|
2031
|
-
[fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
|
|
2132
|
+
[fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
|
|
2032
2133
|
)
|
|
2033
2134
|
|
|
2034
2135
|
/**
|
|
2035
2136
|
* Scrolls to the requested logical index: lands the pane exactly on the aligned position (clamped to the content),
|
|
2036
2137
|
* pins it as the pending alignment for drift correction, and remembers the alignment's edge at that position for
|
|
2037
|
-
* the device-pixel snap of the items wrapper (`resolveItemsWrapperSnapEdge
|
|
2138
|
+
* the device-pixel snap of the items wrapper (`resolveItemsWrapperSnapEdge`, in its canonical form). The content size
|
|
2139
|
+
* and the scroll position are written only when they differ from their synchronous copies (`writeContentSize`; for the
|
|
2140
|
+
* position, `latestScrollPositionRef` while the render loop is stopped, read before the pane moves), so a call that
|
|
2141
|
+
* 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.
|
|
2038
2143
|
*
|
|
2039
2144
|
* 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
|
|
2040
|
-
*
|
|
2145
|
+
* 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を正規形で覚える
|
|
2146
|
+
* (`resolveItemsWrapperSnapEdge`)。中身の寸法とスクロール位置は同期の写し (`writeContentSize`。位置は描画ループが止まって
|
|
2147
|
+
* いる間の `latestScrollPositionRef` を、ペインを動かす前に読む) と違うときだけ書くため、一覧をその場に留める呼び出し (同じ行・揃え・中身) は状態を書かず、描画を予約せず、`onScroll` へ位置を
|
|
2148
|
+
* 知らせない (現在位置への `scrollTo` と同じ)。
|
|
2041
2149
|
*
|
|
2042
2150
|
* @param index - Item index / アイテムのインデックス
|
|
2043
2151
|
* @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
|
|
@@ -2099,6 +2207,10 @@ const VirtualScrollInner = <T,>(
|
|
|
2099
2207
|
const edge = edgeOfAlignment(options?.align)
|
|
2100
2208
|
rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
|
|
2101
2209
|
|
|
2210
|
+
// ❗ 判定はペインを動かす前に行う。ペインの scrollTo はスクロールの処理を同期で呼び、位置の写しを着地位置へ進めて
|
|
2211
|
+
// 描画ループを起こすが、状態はそのループの次のフレームまで古いまま。描画ループが止まっている間だけ、状態 (予約済みの
|
|
2212
|
+
// 値を含む) が位置の写しと一致する
|
|
2213
|
+
const stateHoldsLanding = !renderLoopRef.current.loopActive && latestScrollPositionRef.current === clampedPaneOffset
|
|
2102
2214
|
const currentPanePosition = scrollPaneRef.current?.getScrollPosition() ?? -1
|
|
2103
2215
|
// ❗ 揃えは厳密に着地させる。半ピクセル未満のずれを残すと、揃えた行の端の余白をそのずれが削り、行ラッパーを
|
|
2104
2216
|
// 揃えた端の側へ丸めても取り戻せない (覚えた揃えもペインの位置と一致せず効かない)
|
|
@@ -2120,12 +2232,15 @@ const VirtualScrollInner = <T,>(
|
|
|
2120
2232
|
scrollPaneRef.current?.scrollTo(clampedPaneOffset)
|
|
2121
2233
|
}
|
|
2122
2234
|
|
|
2123
|
-
|
|
2124
|
-
|
|
2235
|
+
writeContentSize(total)
|
|
2236
|
+
// 確定の直後は同じ値の setState でも React が描画を 1 回予約するため、状態が既に着地位置なら書かない
|
|
2237
|
+
if (!stateHoldsLanding) {
|
|
2238
|
+
updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
|
|
2239
|
+
}
|
|
2125
2240
|
|
|
2126
2241
|
Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
|
|
2127
2242
|
},
|
|
2128
|
-
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate],
|
|
2243
|
+
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
|
|
2129
2244
|
)
|
|
2130
2245
|
|
|
2131
2246
|
// アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
|
|
@@ -2144,9 +2259,15 @@ const VirtualScrollInner = <T,>(
|
|
|
2144
2259
|
}, [itemCount, scrollToIndex])
|
|
2145
2260
|
|
|
2146
2261
|
/**
|
|
2147
|
-
* Scrolls to a raw offset
|
|
2262
|
+
* Scrolls to a raw logical offset by pinning the first visible row there (`resolveVisibleStartRow`) with the offset of
|
|
2263
|
+
* its top, so the pending alignment is the row `getScrollAnchor` reports: a position on a row boundary pins the row that
|
|
2264
|
+
* starts there at offset 0, never the hidden row above it.
|
|
2265
|
+
*
|
|
2266
|
+
* 論理オフセットへのスクロール。その位置の先頭の可視行 (`resolveVisibleStartRow`) を、その上端からのオフセットで留めるので、
|
|
2267
|
+
* 保留中の揃えは `getScrollAnchor` が知らせる行になる。行の境界の位置では、そこから始まる行をオフセット 0 で留め、上に
|
|
2268
|
+
* 隠れた行は留めない。
|
|
2148
2269
|
*
|
|
2149
|
-
*
|
|
2270
|
+
* @param newPosition - Logical position in px (floored, then clamped to the content) / 論理位置 (px。切り捨ててから中身の範囲へクランプ)
|
|
2150
2271
|
*/
|
|
2151
2272
|
const scrollTo = useCallback(
|
|
2152
2273
|
(newPosition: number) => {
|
|
@@ -2155,43 +2276,33 @@ const VirtualScrollInner = <T,>(
|
|
|
2155
2276
|
}
|
|
2156
2277
|
const total = fenwickTree.getTotal()
|
|
2157
2278
|
const safePosition = minmax(Math.floor(newPosition), 0, total)
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
// Calculate offset relative to item top to ensure precise positioning
|
|
2161
|
-
// itemTop = cumulative - currentValue
|
|
2162
|
-
const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
|
|
2163
|
-
const offset = itemTop - safePosition
|
|
2164
|
-
|
|
2165
|
-
scrollToIndex(index, { offset })
|
|
2279
|
+
const startRow = resolveVisibleStartRow(fenwickTree, safePosition, itemCount)
|
|
2280
|
+
scrollToIndex(startRow.index, { offset: startRow.top - safePosition })
|
|
2166
2281
|
},
|
|
2167
2282
|
[fenwickTree, itemCount, scrollToIndex],
|
|
2168
2283
|
)
|
|
2169
2284
|
|
|
2170
2285
|
/**
|
|
2171
|
-
* Captures the current top-row anchor for exact position restore across remounts
|
|
2172
|
-
*
|
|
2286
|
+
* Captures the current top-row anchor for exact position restore across remounts: the first visible row that
|
|
2287
|
+
* `resolveVisibleStartRow` resolves (the same row `scrollTo` pins) and the px its top is hidden above the viewport top.
|
|
2288
|
+
* Returns `null` while the list is empty.
|
|
2289
|
+
* 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。行は `resolveVisibleStartRow` が解決する
|
|
2290
|
+
* 先頭の可視行 (`scrollTo` が留める行と同じ) で、空の一覧では `null`。
|
|
2173
2291
|
*
|
|
2174
2292
|
* offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
|
|
2175
2293
|
* 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
|
|
2176
2294
|
* ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
|
|
2177
2295
|
* 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
|
|
2296
|
+
*
|
|
2297
|
+
* @returns The anchor, or `null` without rows / アンカー (行が無ければ `null`)
|
|
2178
2298
|
*/
|
|
2179
2299
|
const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
|
|
2180
2300
|
if (itemCount === 0) {
|
|
2181
2301
|
return null
|
|
2182
2302
|
}
|
|
2183
2303
|
const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2186
|
-
// 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
|
|
2187
|
-
return { index: itemCount - 1, offsetPx: 0 }
|
|
2188
|
-
}
|
|
2189
|
-
if (cumulative === logical) {
|
|
2190
|
-
// 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
|
|
2191
|
-
return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
|
|
2192
|
-
}
|
|
2193
|
-
const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
|
|
2194
|
-
return { index, offsetPx: Math.max(0, logical - itemTop) }
|
|
2304
|
+
const startRow = resolveVisibleStartRow(fenwickTree, logical, itemCount)
|
|
2305
|
+
return { index: startRow.index, offsetPx: Math.max(0, logical - startRow.top) }
|
|
2195
2306
|
}, [fenwickTree, itemCount, resolvedInsets.top])
|
|
2196
2307
|
|
|
2197
2308
|
/**
|
|
@@ -2591,6 +2702,12 @@ const VirtualScrollInner = <T,>(
|
|
|
2591
2702
|
* ポインタ軸の下限は CSS 側の `.aqvs-scroll-to-edge-overlay[data-visible="false"]
|
|
2592
2703
|
* .aqvs-scroll-to-edge-button { pointer-events: none }` が担う (配布 CSS 未読込のホストでは
|
|
2593
2704
|
* `inert` が、`inert` 未実装のブラウザでは CSS が、互いの穴を埋める)。
|
|
2705
|
+
*
|
|
2706
|
+
* ホストがキーボードのスクロールを持つ (`scrollBarOptions.enableArrowButtonTabStops: false`) と、ピルはスクロールバーと
|
|
2707
|
+
* 同じくポインタ専用になる。オーバーレイは見えている間も `aria-hidden="true"` で、ピルは Tab 順に入らず、オーバーレイ
|
|
2708
|
+
* が押下の既定動作を取り消すのでピルの押下はフォーカスを動かさない (click は届く)。
|
|
2709
|
+
*
|
|
2710
|
+
* @returns The overlay, or `null` without the pills / オーバーレイ (ピルを出さないなら `null`)
|
|
2594
2711
|
*/
|
|
2595
2712
|
const renderOverlay = useCallback(() => {
|
|
2596
2713
|
if (!enableScrollToTopBottomButtons) {
|
|
@@ -2599,16 +2716,17 @@ const VirtualScrollInner = <T,>(
|
|
|
2599
2716
|
|
|
2600
2717
|
const isVisible = showScrollButtons && scrollDirection !== null
|
|
2601
2718
|
const isTop = scrollDirection === "up"
|
|
2719
|
+
const pillTabIndex = isVisible && !scrollChromeIsPointerOnly ? 0 : -1
|
|
2602
2720
|
|
|
2603
2721
|
return (
|
|
2604
|
-
<div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible}>
|
|
2722
|
+
<div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible} aria-hidden={scrollChromeIsPointerOnly ? true : undefined} onPointerDown={scrollChromeIsPointerOnly ? keepFocusOnPress : undefined}>
|
|
2605
2723
|
{isTop ? (
|
|
2606
2724
|
<div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-top">
|
|
2607
2725
|
<button
|
|
2608
2726
|
type="button"
|
|
2609
2727
|
className="aqvs-scroll-to-edge-button"
|
|
2610
2728
|
// 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
|
|
2611
|
-
tabIndex={
|
|
2729
|
+
tabIndex={pillTabIndex}
|
|
2612
2730
|
onClick={(e) => {
|
|
2613
2731
|
e.stopPropagation()
|
|
2614
2732
|
isProgrammaticScrollRef.current = true
|
|
@@ -2624,7 +2742,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2624
2742
|
type="button"
|
|
2625
2743
|
className="aqvs-scroll-to-edge-button"
|
|
2626
2744
|
// 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
|
|
2627
|
-
tabIndex={
|
|
2745
|
+
tabIndex={pillTabIndex}
|
|
2628
2746
|
onClick={(e) => {
|
|
2629
2747
|
e.stopPropagation()
|
|
2630
2748
|
isProgrammaticScrollRef.current = true
|
|
@@ -2637,7 +2755,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2637
2755
|
)}
|
|
2638
2756
|
</div>
|
|
2639
2757
|
)
|
|
2640
|
-
}, [enableScrollToTopBottomButtons, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
|
|
2758
|
+
}, [enableScrollToTopBottomButtons, scrollChromeIsPointerOnly, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
|
|
2641
2759
|
|
|
2642
2760
|
// 量子化アンカー (fix: LayoutUnit/f32 精度対策)。行 top はコンテンツ絶対座標そのままではなく
|
|
2643
2761
|
// 「絶対座標 - アンカー」で描画し、ラッパー側 translateY にアンカーを足し戻す。
|
|
@@ -2652,7 +2770,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2652
2770
|
// contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
|
|
2653
2771
|
// fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
|
|
2654
2772
|
// fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
|
|
2655
|
-
//
|
|
2773
|
+
// 両経路とも中身の寸法の書き込み (writeContentSize) を伴うため、contentSize を依存へ含めることで
|
|
2656
2774
|
// 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
|
|
2657
2775
|
void contentSize
|
|
2658
2776
|
|
|
@@ -2763,7 +2881,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2763
2881
|
if (isUnmountedRef.current || typeof total !== "number") {
|
|
2764
2882
|
return
|
|
2765
2883
|
}
|
|
2766
|
-
|
|
2884
|
+
writeContentSize(total)
|
|
2767
2885
|
Logger.debug("[VirtualScroll] Updated heights for items", toUpdateHeights, "New total height:", total)
|
|
2768
2886
|
const panePosition = scrollPaneRef.current?.getScrollPosition() ?? latestScrollPositionRef.current
|
|
2769
2887
|
|
|
@@ -2807,6 +2925,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2807
2925
|
resolvedLabels,
|
|
2808
2926
|
updateScrollPositionImmediate,
|
|
2809
2927
|
visibleStartIndex,
|
|
2928
|
+
writeContentSize,
|
|
2810
2929
|
])
|
|
2811
2930
|
|
|
2812
2931
|
/**
|