@aiquants/virtualscroll 3.11.1 → 3.11.3
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 +60 -0
- package/README.md +15 -5
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/TapScrollCircle.d.ts.map +1 -1
- package/dist/VirtualGrid.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +5 -0
- package/dist/VirtualScroll.d.ts +5 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +2367 -2197
- package/dist/residualQuantizer.d.ts.map +1 -1
- package/dist/useGridTapScroll.d.ts.map +1 -1
- package/dist/useWheelBridge.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/ScrollBar.tsx +61 -34
- package/src/ScrollPane.tsx +16 -0
- package/src/TapScrollCircle.tsx +8 -0
- package/src/VirtualGrid.tsx +70 -0
- package/src/VirtualScroll.tsx +197 -47
- package/src/residualQuantizer.ts +14 -0
- package/src/useGridTapScroll.ts +35 -9
- package/src/useWheelBridge.ts +14 -0
package/src/VirtualScroll.tsx
CHANGED
|
@@ -241,7 +241,12 @@ export type VirtualScrollScrollBarOptions = {
|
|
|
241
241
|
enableArrowButtonTabStops?: boolean
|
|
242
242
|
/**
|
|
243
243
|
* Whether to show the auto-hiding Top / Bottom pills (default `false`; texts from `labels.scrollToTop` / `labels.scrollToBottom`).
|
|
244
|
+
* A user scroll shows the pill of the edge it moves toward, or the other edge's pill once the pane rests at that edge; a pill
|
|
245
|
+
* shows only while the pane can still scroll toward its edge, so it hides as soon as the pane comes to rest there, and a pane
|
|
246
|
+
* that cannot scroll shows neither.
|
|
244
247
|
* 自動で隠れる Top / Bottom ピルを表示するかどうか (既定 `false`。文言は `labels.scrollToTop` / `labels.scrollToBottom`)。
|
|
248
|
+
* 利用者のスクロールは向かう端のピルを出し、ペインがその端に着いていれば反対の端のピルを出す。ピルはペインがまだその端の方へ
|
|
249
|
+
* スクロールできる間だけ見えるので、ペインがその端に着くとすぐ隠れ、スクロールできないペインはどちらも出さない。
|
|
245
250
|
*/
|
|
246
251
|
enableScrollToTopBottomButtons?: boolean
|
|
247
252
|
/** Renderer for UI anchored near the thumb. / つまみ付近に重ねる UI のレンダラー。 */
|
|
@@ -722,6 +727,54 @@ const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): Aligne
|
|
|
722
727
|
*/
|
|
723
728
|
const canonicalAlignedEdge = (alignment: AlignedEdge | null): AlignedEdge | null => (alignment !== null && alignment.panePosition <= 0 ? null : alignment)
|
|
724
729
|
|
|
730
|
+
/**
|
|
731
|
+
* The edge a scroll-to-edge pill scrolls to: `"top"` (the Top pill, to the first row) or `"bottom"` (the Bottom pill, to the
|
|
732
|
+
* last row).
|
|
733
|
+
*
|
|
734
|
+
* 端へ戻るピルが送る先の端。`"top"` (Top ピル。最初の行へ) か `"bottom"` (Bottom ピル。最後の行へ)。
|
|
735
|
+
*/
|
|
736
|
+
type EdgePill = "top" | "bottom"
|
|
737
|
+
|
|
738
|
+
/** How long a pill stays after the user scroll that showed it, unless the pane comes to rest at its edge first (ms) / ピルを出した利用者のスクロールからピルが消えるまでの時間 (ms。先にペインがその端に着けばそこで消える) */
|
|
739
|
+
const EDGE_PILL_HIDE_DELAY_MS = 2000
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Whether the pane can still scroll toward an edge: toward the top while its position exceeds 0, toward the bottom while
|
|
743
|
+
* its position stays under the max scroll position, each by more than `EDGE_POSITION_TOLERANCE` (closer counts as resting
|
|
744
|
+
* at that edge). A pane that cannot scroll (max scroll position 0) can scroll toward neither edge.
|
|
745
|
+
*
|
|
746
|
+
* ペインがまだその端の方へスクロールできるかの判定。上端へは位置が 0 を上回り、下端へは位置が最大位置を下回り、どちらも
|
|
747
|
+
* `EDGE_POSITION_TOLERANCE` を超えて離れている間だけ (それより近ければその端に着いている)。スクロールできないペイン
|
|
748
|
+
* (最大位置 0) はどちらの端へもスクロールできない。
|
|
749
|
+
*
|
|
750
|
+
* @param edge - The edge / 端
|
|
751
|
+
* @param panePosition - Pane position (px) / ペイン位置 (px)
|
|
752
|
+
* @param maxScrollPosition - The pane's max scroll position (px) / ペインの最大位置 (px)
|
|
753
|
+
* @returns True while the pane can scroll toward the edge / その端の方へスクロールできる間は true
|
|
754
|
+
*/
|
|
755
|
+
const canScrollTowardEdge = (edge: EdgePill, panePosition: number, maxScrollPosition: number): boolean => (edge === "top" ? panePosition > EDGE_POSITION_TOLERANCE : panePosition < maxScrollPosition - EDGE_POSITION_TOLERANCE)
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Picks the pill a user scroll shows: the pill of the edge the scroll moves toward while the pane can still scroll toward
|
|
759
|
+
* that edge; once the pane rests at that edge, the pill of the other edge; and no pill when the pane can scroll toward
|
|
760
|
+
* neither edge.
|
|
761
|
+
*
|
|
762
|
+
* 利用者のスクロールが出すピルを選ぶ処理。スクロールが向かう端の方へまだスクロールできる間はその端のピル、ペインがその端に
|
|
763
|
+
* 着いたら反対の端のピル、どちらの端へもスクロールできなければピル無し。
|
|
764
|
+
*
|
|
765
|
+
* @param toward - The edge the scroll moves toward / スクロールが向かう端
|
|
766
|
+
* @param panePosition - Pane position after the scroll (px) / スクロール後のペイン位置 (px)
|
|
767
|
+
* @param maxScrollPosition - The pane's max scroll position (px) / ペインの最大位置 (px)
|
|
768
|
+
* @returns The pill to show, or `null` for none / 出すピル (無ければ `null`)
|
|
769
|
+
*/
|
|
770
|
+
const pickEdgePill = (toward: EdgePill, panePosition: number, maxScrollPosition: number): EdgePill | null => {
|
|
771
|
+
if (canScrollTowardEdge(toward, panePosition, maxScrollPosition)) {
|
|
772
|
+
return toward
|
|
773
|
+
}
|
|
774
|
+
const opposite: EdgePill = toward === "top" ? "bottom" : "top"
|
|
775
|
+
return canScrollTowardEdge(opposite, panePosition, maxScrollPosition) ? opposite : null
|
|
776
|
+
}
|
|
777
|
+
|
|
725
778
|
/**
|
|
726
779
|
* The parts of the viewport that something drawn over the rows covers — the visible scroll-to-edge pill — as px from each
|
|
727
780
|
* edge to the far side of what covers it. Module-level export (NOT in the package barrel), the parameter type of
|
|
@@ -1124,10 +1177,9 @@ export const computeRenderingRangesHuge = (effectiveScrollPosition: number, view
|
|
|
1124
1177
|
if (!Number.isSafeInteger(indexNumber)) {
|
|
1125
1178
|
break
|
|
1126
1179
|
}
|
|
1180
|
+
// 後方の行は先頭の可視行より上にあり、木の値は負にならないので、その累積は先頭の可視行の上端 (有限の位置以下) を
|
|
1181
|
+
// 越えない。前方と違い、累積が溢れた行へは後方の走査が届かない
|
|
1127
1182
|
const { cumulative: runBottom } = fenwickTree.prefixSum(indexNumber, FENWICK_LOOKUP_ONLY)
|
|
1128
|
-
if (!Number.isFinite(runBottom)) {
|
|
1129
|
-
break
|
|
1130
|
-
}
|
|
1131
1183
|
const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, FENWICK_LOOKUP_ONLY)
|
|
1132
1184
|
const jumpBig = jumpIndex === -1 ? -1n : toSafeBigInt(jumpIndex)
|
|
1133
1185
|
if (jumpBig >= 0n && jumpBig < backwardStart) {
|
|
@@ -1244,9 +1296,10 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
|
|
|
1244
1296
|
if (itemHeight === 0) {
|
|
1245
1297
|
backwardZeroRun++
|
|
1246
1298
|
if (backwardZeroRun > ZERO_HEIGHT_RUN_LIMIT) {
|
|
1247
|
-
// 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)
|
|
1299
|
+
// 連続 0 行の直前にある非 0 行へ後方ジャンプ (木上の累積位置 -0.5 で探索)。後方の行は先頭の可視行より上にあり、
|
|
1300
|
+
// 木の値は負にならないので、その累積は先頭の可視行の上端 (有限の位置以下) を越えない
|
|
1248
1301
|
const { cumulative: runBottom } = fenwickTree.prefixSum(backwardCursor, FENWICK_LOOKUP_ONLY)
|
|
1249
|
-
const { index: jumpIndex } =
|
|
1302
|
+
const { index: jumpIndex } = fenwickTree.findIndexAtOrAfter(runBottom - 0.5, FENWICK_LOOKUP_ONLY)
|
|
1250
1303
|
if (jumpIndex !== -1 && jumpIndex < backwardCursor) {
|
|
1251
1304
|
// 0 行の連続を飛び越えて直前の非 0 行から走査を続行する
|
|
1252
1305
|
backwardCursor = jumpIndex
|
|
@@ -1301,8 +1354,8 @@ type OnRangeChangeCallback = (range: VirtualScrollRange) => void
|
|
|
1301
1354
|
* @param throttleMs Minimum time between invocations in ms / 実行間の最小時間(ミリ秒)
|
|
1302
1355
|
* @param invoke Wrapper function to execute the callback (e.g. for argument unwrapping) / コールバックを実行するラッパー関数
|
|
1303
1356
|
*/
|
|
1304
|
-
const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObject<Callback | undefined>, throttleMs: number
|
|
1305
|
-
const throttle = Math.max(0, throttleMs
|
|
1357
|
+
const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObject<Callback | undefined>, throttleMs: number, invoke: (callback: Callback, payload: Payload) => void) => {
|
|
1358
|
+
const throttle = Math.max(0, throttleMs)
|
|
1306
1359
|
// last: 最終実行時刻, id: RAF ID, arg: 待機中の引数
|
|
1307
1360
|
const state = useRef({ last: 0, id: null as number | null, arg: null as Payload | null }).current
|
|
1308
1361
|
|
|
@@ -1329,6 +1382,12 @@ const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObj
|
|
|
1329
1382
|
[state, callbackRef, invoke],
|
|
1330
1383
|
)
|
|
1331
1384
|
|
|
1385
|
+
/**
|
|
1386
|
+
* Schedules one invocation with the latest payload: at most once per animation frame, and not before `throttleMs` has passed
|
|
1387
|
+
* since the previous one; a payload that arrives while one is pending replaces it.
|
|
1388
|
+
* 最新のペイロードでの 1 回の実行を予約する処理。アニメーションのフレームごとに多くて 1 回で、前回から `throttleMs` が経つまでは実行しない。
|
|
1389
|
+
* 予約中に届いたペイロードは前のものを置き換える。
|
|
1390
|
+
*/
|
|
1332
1391
|
return useCallback(
|
|
1333
1392
|
(payload: Payload) => {
|
|
1334
1393
|
state.arg = payload
|
|
@@ -1575,19 +1634,19 @@ const VirtualScrollInner = <T,>(
|
|
|
1575
1634
|
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
1576
1635
|
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
1577
1636
|
const options = safeIndex > 0 || anchorOffsetPx > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
|
|
1578
|
-
const { cumulative,
|
|
1637
|
+
const { cumulative, currentValue } = fenwickTree.prefixSum(safeIndex, options)
|
|
1579
1638
|
const logicalOffset = Math.max(cumulative - currentValue + anchorOffsetPx, 0)
|
|
1580
1639
|
position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
|
|
1581
|
-
total =
|
|
1640
|
+
total = fenwickTree.getTotal()
|
|
1582
1641
|
} else if (typeof initialScrollIndex === "number") {
|
|
1583
1642
|
const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
|
|
1584
1643
|
const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
|
|
1585
1644
|
const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
|
|
1586
1645
|
const options = initialScrollIndex > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
|
|
1587
|
-
const { cumulative,
|
|
1646
|
+
const { cumulative, currentValue } = fenwickTree.prefixSum(initialScrollIndex, options)
|
|
1588
1647
|
const logicalOffset = Math.max(cumulative - currentValue, 0)
|
|
1589
1648
|
position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
|
|
1590
|
-
total =
|
|
1649
|
+
total = fenwickTree.getTotal()
|
|
1591
1650
|
} else if (typeof initialScrollOffset === "number") {
|
|
1592
1651
|
position = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
|
|
1593
1652
|
total = fenwickTree.getTotal()
|
|
@@ -1721,9 +1780,51 @@ const VirtualScrollInner = <T,>(
|
|
|
1721
1780
|
const pendingFocusIndexRef = useRef<number | null>(null)
|
|
1722
1781
|
const lastFocusedIndexRef = useRef<number | null>(null)
|
|
1723
1782
|
|
|
1724
|
-
|
|
1783
|
+
// スクロールの向きではなくピルそのものを持つ。端に着いたスクロールは反対の端のピルを出すので、向きからピルは決まらない。
|
|
1784
|
+
// 隠れた後も同じピルを描き続けるのは、隠れるフェードをそのピルのまま見せるため
|
|
1785
|
+
const [edgePill, setEdgePill] = useState<EdgePill | null>(null)
|
|
1725
1786
|
const [showScrollButtons, setShowScrollButtons] = useState(false)
|
|
1787
|
+
// 見えているピルの同期の写し (隠れている間は null)。スクロールの処理は状態を閉じ込めずに要否を決め、隠れているピルへ同じ値を
|
|
1788
|
+
// 書いて確定を足さない
|
|
1789
|
+
const shownEdgePillRef = useRef<EdgePill | null>(null)
|
|
1726
1790
|
const scrollButtonTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Hides the visible scroll-to-edge pill and cancels its auto-hide timer; writes no state while no pill shows.
|
|
1794
|
+
*
|
|
1795
|
+
* 見えている端へ戻るピルを隠し、自動非表示のタイマーを止める処理。ピルが見えていない間は状態を書かない。
|
|
1796
|
+
*/
|
|
1797
|
+
const hideEdgePill = useCallback((): void => {
|
|
1798
|
+
if (scrollButtonTimerRef.current !== null) {
|
|
1799
|
+
clearTimeout(scrollButtonTimerRef.current)
|
|
1800
|
+
scrollButtonTimerRef.current = null
|
|
1801
|
+
}
|
|
1802
|
+
if (shownEdgePillRef.current === null) {
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1805
|
+
shownEdgePillRef.current = null
|
|
1806
|
+
setShowScrollButtons(false)
|
|
1807
|
+
}, [])
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Shows a scroll-to-edge pill and (re)starts its auto-hide timer (`EDGE_PILL_HIDE_DELAY_MS`).
|
|
1811
|
+
*
|
|
1812
|
+
* 端へ戻るピルを出し、自動非表示のタイマー (`EDGE_PILL_HIDE_DELAY_MS`) を掛け直す処理。
|
|
1813
|
+
*
|
|
1814
|
+
* @param pill - The pill to show / 出すピル
|
|
1815
|
+
*/
|
|
1816
|
+
const showEdgePill = useCallback(
|
|
1817
|
+
(pill: EdgePill): void => {
|
|
1818
|
+
shownEdgePillRef.current = pill
|
|
1819
|
+
setEdgePill(pill)
|
|
1820
|
+
setShowScrollButtons(true)
|
|
1821
|
+
if (scrollButtonTimerRef.current !== null) {
|
|
1822
|
+
clearTimeout(scrollButtonTimerRef.current)
|
|
1823
|
+
}
|
|
1824
|
+
scrollButtonTimerRef.current = setTimeout(hideEdgePill, EDGE_PILL_HIDE_DELAY_MS)
|
|
1825
|
+
},
|
|
1826
|
+
[hideEdgePill],
|
|
1827
|
+
)
|
|
1727
1828
|
// 見せる操作は、画面に出ているピルを表示域 (行ラッパーの境界の箱の上端が表示域の上端) に対して測る
|
|
1728
1829
|
const edgeOverlayRef = useRef<HTMLDivElement>(null)
|
|
1729
1830
|
const edgePillRef = useRef<HTMLButtonElement>(null)
|
|
@@ -2528,9 +2629,17 @@ const VirtualScrollInner = <T,>(
|
|
|
2528
2629
|
const applyWheel = useCallback((event: WheelEvent): boolean => scrollPaneRef.current?.applyWheel(event) ?? false, [])
|
|
2529
2630
|
|
|
2530
2631
|
/**
|
|
2531
|
-
* Handles scroll events from the pane.
|
|
2632
|
+
* Handles scroll events from the pane. With the scroll-to-edge pills on, a scroll of any kind (user, programmatic or
|
|
2633
|
+
* compensating) that leaves the pane resting at the visible pill's edge hides that pill at once (`canScrollTowardEdge`),
|
|
2634
|
+
* and a user scroll of more than 1 px shows the pill `pickEdgePill` picks for its direction (none when the pane can
|
|
2635
|
+
* scroll toward neither edge) and restarts the auto-hide timer.
|
|
2636
|
+
*
|
|
2637
|
+
* ペインのスクロールイベントを処理。端へ戻るピルが有効なら、ペインを見えているピルの端に止めたスクロール (利用者・プログラム・
|
|
2638
|
+
* 補正のどれでも) はそのピルをすぐ隠し (`canScrollTowardEdge`)、1px を超える利用者のスクロールは向きから `pickEdgePill` が
|
|
2639
|
+
* 選ぶピルを出して (どちらの端へもスクロールできなければ出さない) 自動非表示のタイマーを掛け直す。
|
|
2532
2640
|
*
|
|
2533
|
-
*
|
|
2641
|
+
* @param newPosition - Pane position after the scroll (px) / スクロール後のペイン位置 (px)
|
|
2642
|
+
* @param prevPosition - Pane position before the scroll (px) / スクロール前のペイン位置 (px)
|
|
2534
2643
|
*/
|
|
2535
2644
|
const handleScroll = useCallback(
|
|
2536
2645
|
(newPosition: number, prevPosition: number) => {
|
|
@@ -2566,30 +2675,31 @@ const VirtualScrollInner = <T,>(
|
|
|
2566
2675
|
|
|
2567
2676
|
updateScrollPositionImmediate(newPosition)
|
|
2568
2677
|
|
|
2569
|
-
if (enableScrollToTopBottomButtons) {
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2678
|
+
if (!enableScrollToTopBottomButtons) {
|
|
2679
|
+
return
|
|
2680
|
+
}
|
|
2681
|
+
// 最大位置は確定済みの寸法でなく今の木から測る。補正のスクロールは確定前の新しい寸法でクランプされるため
|
|
2682
|
+
const maxScrollPosition = Math.max(0, fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom - viewportSize)
|
|
2683
|
+
const shownPill = shownEdgePillRef.current
|
|
2684
|
+
// 端に着いたピルはもう何も動かさず、焦点を受けた端の行を覆うだけなので、着いた経路を問わず自動非表示を待たずに隠す
|
|
2685
|
+
if (shownPill !== null && !canScrollTowardEdge(shownPill, newPosition, maxScrollPosition)) {
|
|
2686
|
+
hideEdgePill()
|
|
2687
|
+
}
|
|
2688
|
+
if (isProgrammatic || isCompensating) {
|
|
2689
|
+
return
|
|
2690
|
+
}
|
|
2581
2691
|
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2692
|
+
const diff = newPosition - prevPosition
|
|
2693
|
+
Logger.debug("[VirtualScroll] Scroll diff:", diff, "New:", newPosition, "Prev:", prevPosition)
|
|
2694
|
+
if (Math.abs(diff) > 1) {
|
|
2695
|
+
const pill = pickEdgePill(diff > 0 ? "bottom" : "top", newPosition, maxScrollPosition)
|
|
2696
|
+
if (pill !== null) {
|
|
2697
|
+
showEdgePill(pill)
|
|
2698
|
+
Logger.debug("[VirtualScroll] Showing scroll-to-edge pill:", pill)
|
|
2589
2699
|
}
|
|
2590
2700
|
}
|
|
2591
2701
|
},
|
|
2592
|
-
[updateScrollPositionImmediate, forgetAlignedEdge, enableScrollToTopBottomButtons, itemCount],
|
|
2702
|
+
[updateScrollPositionImmediate, forgetAlignedEdge, enableScrollToTopBottomButtons, itemCount, fenwickTree, resolvedInsets.top, resolvedInsets.bottom, viewportSize, hideEdgePill, showEdgePill],
|
|
2593
2703
|
)
|
|
2594
2704
|
|
|
2595
2705
|
// レンダリング範囲を計算
|
|
@@ -2834,10 +2944,14 @@ const VirtualScrollInner = <T,>(
|
|
|
2834
2944
|
|
|
2835
2945
|
/**
|
|
2836
2946
|
* Renders the auto-hiding top/bottom pill overlay (texts from the resolved `scrollToTop` /
|
|
2837
|
-
* `scrollToBottom` labels), neutralized while hidden.
|
|
2947
|
+
* `scrollToBottom` labels), neutralized while hidden. It draws one pill — the one the last user
|
|
2948
|
+
* scroll picked (`edgePill`; the Bottom pill before any) — which shows only while the pane can
|
|
2949
|
+
* still scroll toward that pill's edge (`handleScroll` and the size check hide it at the edge).
|
|
2838
2950
|
*
|
|
2839
2951
|
* 自動非表示の先頭/末尾ピルのオーバーレイ (文言は解決済みラベルの `scrollToTop` /
|
|
2840
|
-
* `scrollToBottom`)
|
|
2952
|
+
* `scrollToBottom`) を描画し、非表示中は無効化する処理。描くピルは 1 つで、最後の利用者の
|
|
2953
|
+
* スクロールが選んだもの (`edgePill`。スクロールの前は Bottom ピル)。ペインがまだそのピルの端の
|
|
2954
|
+
* 方へスクロールできる間だけ見える (端に着くと `handleScroll` と大きさの照合が隠す)。
|
|
2841
2955
|
*
|
|
2842
2956
|
* ❗ 非表示は `opacity: 0` で表現するため、要素は DOM に残り続ける (フェードのために
|
|
2843
2957
|
* アンマウントしない)。`opacity` はフォーカス可能性に影響せず、CSS の `pointer-events: none`
|
|
@@ -2868,8 +2982,8 @@ const VirtualScrollInner = <T,>(
|
|
|
2868
2982
|
return null
|
|
2869
2983
|
}
|
|
2870
2984
|
|
|
2871
|
-
const isVisible = showScrollButtons
|
|
2872
|
-
const isTop =
|
|
2985
|
+
const isVisible = showScrollButtons
|
|
2986
|
+
const isTop = edgePill === "top"
|
|
2873
2987
|
const pillTabIndex = isVisible && !scrollChromeIsPointerOnly ? 0 : -1
|
|
2874
2988
|
|
|
2875
2989
|
return (
|
|
@@ -2886,7 +3000,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2886
3000
|
e.stopPropagation()
|
|
2887
3001
|
isProgrammaticScrollRef.current = true
|
|
2888
3002
|
scrollToIndex(0)
|
|
2889
|
-
|
|
3003
|
+
hideEdgePill()
|
|
2890
3004
|
}}>
|
|
2891
3005
|
{resolvedLabels.scrollToTop}
|
|
2892
3006
|
</button>
|
|
@@ -2903,7 +3017,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2903
3017
|
e.stopPropagation()
|
|
2904
3018
|
isProgrammaticScrollRef.current = true
|
|
2905
3019
|
scrollToIndex(itemCount - 1)
|
|
2906
|
-
|
|
3020
|
+
hideEdgePill()
|
|
2907
3021
|
}}>
|
|
2908
3022
|
{resolvedLabels.scrollToBottom}
|
|
2909
3023
|
</button>
|
|
@@ -2911,7 +3025,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2911
3025
|
)}
|
|
2912
3026
|
</div>
|
|
2913
3027
|
)
|
|
2914
|
-
}, [enableScrollToTopBottomButtons, scrollChromeIsPointerOnly, showScrollButtons,
|
|
3028
|
+
}, [enableScrollToTopBottomButtons, scrollChromeIsPointerOnly, showScrollButtons, edgePill, scrollToIndex, hideEdgePill, itemCount, resolvedLabels])
|
|
2915
3029
|
|
|
2916
3030
|
// 量子化アンカー (fix: LayoutUnit/f32 精度対策)。行 top はコンテンツ絶対座標そのままではなく
|
|
2917
3031
|
// 「絶対座標 - アンカー」で描画し、ラッパー側 translateY にアンカーを足し戻す。
|
|
@@ -3031,13 +3145,12 @@ const VirtualScrollInner = <T,>(
|
|
|
3031
3145
|
}
|
|
3032
3146
|
|
|
3033
3147
|
// 照合した行の上端は描画の中で補ってあるが、木から導いた値 (getRange の総高さなど) は古い木のまま。総和が変わらない
|
|
3034
|
-
//
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
return
|
|
3038
|
-
}
|
|
3148
|
+
// 変化でも、次の確定で木から導き直させる。ここから先は同期で、上の確認の後にアンマウントは挟まらない
|
|
3149
|
+
changeTree(() => fenwickTree.updates(toUpdateHeights))
|
|
3150
|
+
const total = fenwickTree.getTotal()
|
|
3039
3151
|
writeContentSize(total)
|
|
3040
3152
|
Logger.debug("[VirtualScroll] Updated heights for items", toUpdateHeights, "New total height:", total)
|
|
3153
|
+
// 並行の描画では、初回の描画のマイクロタスクがその確定 (ペインの取り付け) より先に走ることがある
|
|
3041
3154
|
const panePosition = scrollPaneRef.current?.getScrollPosition() ?? latestScrollPositionRef.current
|
|
3042
3155
|
|
|
3043
3156
|
if (shiftAmount !== 0) {
|
|
@@ -3101,7 +3214,7 @@ const VirtualScrollInner = <T,>(
|
|
|
3101
3214
|
*/
|
|
3102
3215
|
const renderVisibleItems = useCallback(
|
|
3103
3216
|
(currentScrollPosition: number) => {
|
|
3104
|
-
const shouldUseThrottledPosition =
|
|
3217
|
+
const shouldUseThrottledPosition = callbackThrottleMs > 0
|
|
3105
3218
|
const diff = Math.abs(currentScrollPosition - scrollPosition)
|
|
3106
3219
|
const rawEffectiveScrollPosition = shouldUseThrottledPosition && diff > 0.5 ? scrollPosition : currentScrollPosition
|
|
3107
3220
|
const effectiveScrollPosition = toLogicalPositionWithInset(rawEffectiveScrollPosition, resolvedInsets.top)
|
|
@@ -3235,21 +3348,46 @@ const VirtualScrollInner = <T,>(
|
|
|
3235
3348
|
useImperativeHandle(
|
|
3236
3349
|
ref,
|
|
3237
3350
|
() => ({
|
|
3351
|
+
/**
|
|
3352
|
+
* Reads the logical scroll position (the pane's position less the top inset), or the `-1` sentinel while the pane is
|
|
3353
|
+
* unconnected.
|
|
3354
|
+
* 論理スクロール位置 (ペインの位置から上のインセットを引いたもの) を読む処理。ペインが未接続の間は番兵の `-1`。
|
|
3355
|
+
*/
|
|
3238
3356
|
getScrollPosition: () => {
|
|
3239
3357
|
// ❗ 論理座標で返す (2.0.0)。ペイン座標を返す実装へ戻さないこと: 消費者の換算コードが
|
|
3240
3358
|
// 座標混在バグの温床になる (この統一が本質修正)。inset は走行中も最新を ref から読む
|
|
3241
3359
|
const panePosition = scrollPaneRef.current?.getScrollPosition()
|
|
3242
3360
|
return typeof panePosition === "number" ? toLogicalPositionWithInset(panePosition, resolvedInsetsTopRef.current) : -1
|
|
3243
3361
|
},
|
|
3362
|
+
/**
|
|
3363
|
+
* Reads the pane's content size (insets included), or the `-1` sentinel while the pane is unconnected.
|
|
3364
|
+
* ペインの中身の寸法 (インセット込み) を読む処理。ペインが未接続の間は番兵の `-1`。
|
|
3365
|
+
*/
|
|
3244
3366
|
getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
|
|
3367
|
+
/**
|
|
3368
|
+
* Reads the pane's viewport size, or the `-1` sentinel while the pane is unconnected.
|
|
3369
|
+
* ペインの表示域の寸法を読む処理。ペインが未接続の間は番兵の `-1`。
|
|
3370
|
+
*/
|
|
3245
3371
|
getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
|
|
3246
3372
|
scrollTo: scrollToHandle,
|
|
3247
3373
|
scrollBy,
|
|
3248
3374
|
applyWheel,
|
|
3249
3375
|
scrollToIndex,
|
|
3376
|
+
/**
|
|
3377
|
+
* Reads the total of the row-height tree (insets excluded).
|
|
3378
|
+
* 行の高さの木の合計 (インセットを除く) を読む処理。
|
|
3379
|
+
*/
|
|
3250
3380
|
getFenwickTreeTotalHeight: () => fenwickTree.getTotal(),
|
|
3381
|
+
/**
|
|
3382
|
+
* Reads the number of rows the row-height tree holds.
|
|
3383
|
+
* 行の高さの木が持つ行の数を読む処理。
|
|
3384
|
+
*/
|
|
3251
3385
|
getFenwickSize: () => fenwickTree.getSize(),
|
|
3252
3386
|
focusItemAtIndex,
|
|
3387
|
+
/**
|
|
3388
|
+
* Reads the range the last commit published (one render behind).
|
|
3389
|
+
* 最後の確定が公開した範囲 (描画 1 回ぶん遅れる) を読む処理。
|
|
3390
|
+
*/
|
|
3253
3391
|
getRange: () => currentRangeRef.current,
|
|
3254
3392
|
getScrollAnchor,
|
|
3255
3393
|
updateItemSize,
|
|
@@ -3258,6 +3396,18 @@ const VirtualScrollInner = <T,>(
|
|
|
3258
3396
|
)
|
|
3259
3397
|
|
|
3260
3398
|
const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
|
|
3399
|
+
const paneMaxScrollPosition = Math.max(0, totalContentHeight - viewportSize)
|
|
3400
|
+
|
|
3401
|
+
useLayoutEffect(() => {
|
|
3402
|
+
// 目的: 中身や表示域の大きさが変わると、位置が動かなくても最大位置が動いてペインが端に着く (中身が表示域に収まれば両端)。
|
|
3403
|
+
// スクロールの知らせが来ないこの着き方でも、見えているピルを描画の前に隠す。端から離れても出し直さないのは、ピルを出すのが
|
|
3404
|
+
// 利用者のスクロールだけだから (下に行が足されても、Bottom ピルは次の利用者のスクロールで出る)
|
|
3405
|
+
// 依存関係: paneMaxScrollPosition, hideEdgePill
|
|
3406
|
+
const shownPill = shownEdgePillRef.current
|
|
3407
|
+
if (shownPill !== null && !canScrollTowardEdge(shownPill, latestScrollPositionRef.current, paneMaxScrollPosition)) {
|
|
3408
|
+
hideEdgePill()
|
|
3409
|
+
}
|
|
3410
|
+
}, [paneMaxScrollPosition, hideEdgePill])
|
|
3261
3411
|
|
|
3262
3412
|
// ライブリージョン無効時はペインをそのまま根要素として返す (既存消費者の DOM 形状を変えない)
|
|
3263
3413
|
const pane = (
|
package/src/residualQuantizer.ts
CHANGED
|
@@ -105,6 +105,12 @@ export const createResidualQuantizer = (options: ResidualQuantizerOptions): Resi
|
|
|
105
105
|
let residue = 0
|
|
106
106
|
|
|
107
107
|
return {
|
|
108
|
+
/**
|
|
109
|
+
* Adds a delta to the residue and emits the whole quanta it holds (a non-finite delta, or one beyond
|
|
110
|
+
* `Number.MAX_SAFE_INTEGER`, emits 0 and leaves the residue as it is).
|
|
111
|
+
* 差分を残差へ足し、残差が持つ量子の整数倍を放出する処理 (非有限の差分と `Number.MAX_SAFE_INTEGER` を越える差分は 0 を放出し、
|
|
112
|
+
* 残差を変えない)。
|
|
113
|
+
*/
|
|
108
114
|
push: (delta: number): number => {
|
|
109
115
|
// 非有限デルタは残差を汚さず 0 を返す (蓄積器に NaN が入ると以後全放出が死ぬ)。
|
|
110
116
|
// ❗ |delta| > Number.MAX_SAFE_INTEGER も同じ契約で見送る: 2^53 超では
|
|
@@ -125,7 +131,15 @@ export const createResidualQuantizer = (options: ResidualQuantizerOptions): Resi
|
|
|
125
131
|
residue -= emitted
|
|
126
132
|
return emitted
|
|
127
133
|
},
|
|
134
|
+
/**
|
|
135
|
+
* Reads the residue not yet emitted.
|
|
136
|
+
* まだ放出していない残差を読む処理。
|
|
137
|
+
*/
|
|
128
138
|
peekResidue: () => residue,
|
|
139
|
+
/**
|
|
140
|
+
* Drops the residue.
|
|
141
|
+
* 残差を捨てる処理。
|
|
142
|
+
*/
|
|
129
143
|
reset: () => {
|
|
130
144
|
residue = 0
|
|
131
145
|
},
|
package/src/useGridTapScroll.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* immobile 停止則・延長成長の再武装・fail-closed のキャンセル 3 系。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useCallback, useEffect, useRef, useState } from "react"
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
12
12
|
import { computeTapScrollVelocity, type TapScrollAxisSpeedParams } from "./computeTapScrollVelocity.ts"
|
|
13
13
|
import { TAP_SCROLL_CANCEL_EVENT, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS } from "./ScrollBar.tsx"
|
|
14
14
|
import type { TapScrollCircleDragState, TapScrollCircleHandle } from "./TapScrollCircle.tsx"
|
|
@@ -113,6 +113,8 @@ export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScro
|
|
|
113
113
|
const tapCircleHandleRef = useRef<TapScrollCircleHandle | null>(null)
|
|
114
114
|
const [isTapActive, setIsTapActive] = useState(false)
|
|
115
115
|
const frameRef = useRef<number | null>(null)
|
|
116
|
+
// 生きているのは、フックの部品を置いた確定から取り除く確定まで (レイアウトの副作用の本体と後始末が書く)
|
|
117
|
+
const isLiveRef = useRef(false)
|
|
116
118
|
const lastTimestampRef = useRef<number | null>(null)
|
|
117
119
|
const xDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
|
|
118
120
|
const yDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
|
|
@@ -181,14 +183,20 @@ export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScro
|
|
|
181
183
|
}, [])
|
|
182
184
|
|
|
183
185
|
/**
|
|
184
|
-
* One integration frame of the two-axis loop.
|
|
185
|
-
*
|
|
186
|
+
* One integration frame of the two-axis loop. A frame that runs after the commit that unmounts the hook's component (one
|
|
187
|
+
* the browser had already scheduled, or one a drag report started after that commit) moves nothing and ends the loop.
|
|
188
|
+
* 2 軸ループの 1 積分フレーム。フックの部品をアンマウントする確定の後に走るフレーム (ブラウザが予約済みだったもの、またはその確定の
|
|
189
|
+
* 後のドラッグの知らせが始めたもの) は何も動かさず、ループを終える。
|
|
186
190
|
*/
|
|
187
191
|
const step = useCallback(
|
|
188
192
|
(timestamp: number) => {
|
|
189
193
|
// この rAF ループ意味論は ScrollBar.tsx の tap ループ (stepAutoScroll) と双子 — 片方を直したらもう片方も直すこと
|
|
190
194
|
const current = paramsRef.current
|
|
191
195
|
const state = tapDragStateRef.current
|
|
196
|
+
if (!isLiveRef.current) {
|
|
197
|
+
stopLoop()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
192
200
|
if (!state.active || state.direction === 0) {
|
|
193
201
|
stopLoop()
|
|
194
202
|
return
|
|
@@ -348,20 +356,38 @@ export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScro
|
|
|
348
356
|
}, [enabled, resetTapScroll])
|
|
349
357
|
|
|
350
358
|
/**
|
|
351
|
-
* Cancel path (c): `enabled` → false resets
|
|
352
|
-
* キャンセル系 (c)
|
|
359
|
+
* Cancel path (c), first half: `enabled` → false resets.
|
|
360
|
+
* キャンセル系 (c) の前半: `enabled` → false でリセット。
|
|
353
361
|
*
|
|
354
|
-
* 目的:
|
|
355
|
-
* 依存関係: [enabled, resetTapScroll]
|
|
356
|
-
* クリーンアップ:
|
|
362
|
+
* 目的: 無効化時に rAF を残さない。
|
|
363
|
+
* 依存関係: [enabled, resetTapScroll]
|
|
364
|
+
* クリーンアップ: 不要 (アンマウントの停止は後半が担う)。
|
|
357
365
|
*/
|
|
358
366
|
useEffect(() => {
|
|
359
367
|
if (!enabled) {
|
|
360
368
|
resetTapScroll()
|
|
361
369
|
}
|
|
362
370
|
}, [enabled, resetTapScroll])
|
|
363
|
-
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Cancel path (c), second half: the liveness of the hook's component, and the stop at unmount. The loop stops in the commit
|
|
374
|
+
* that unmounts the component (a layout-effect cleanup), not in the passive cleanups after it: when the unmount is not
|
|
375
|
+
* synchronous, the browser can run a scheduled frame between that commit and the passive cleanups, and that frame would
|
|
376
|
+
* move the grid and call the host's `onScroll` after the grid is gone. A frame that still runs after the commit finds the
|
|
377
|
+
* hook not live (`step`).
|
|
378
|
+
* キャンセル系 (c) の後半: フックの部品が生きているかと、アンマウントでの停止。ループはその後の受け身の後始末ではなく、部品を
|
|
379
|
+
* アンマウントする確定 (レイアウトの副作用の後始末) で止める。アンマウントが同期でないとき、ブラウザはその確定と受け身の後始末の間に
|
|
380
|
+
* 予約済みのフレームを走らせられ、そのフレームはグリッドが消えた後にグリッドを動かしてホストの `onScroll` を呼んでしまう。確定の後に
|
|
381
|
+
* なお走るフレームは、フックが生きていないことを見て何もしない (`step`)。
|
|
382
|
+
*
|
|
383
|
+
* 目的: アンマウントの確定の後に位置を動かさない。
|
|
384
|
+
* 依存関係: [stopLoop] (同一性は不変なので、マウントで 1 回・アンマウントで 1 回)
|
|
385
|
+
* クリーンアップ: 生きていない印とループの停止。
|
|
386
|
+
*/
|
|
387
|
+
useLayoutEffect(() => {
|
|
388
|
+
isLiveRef.current = true
|
|
364
389
|
return () => {
|
|
390
|
+
isLiveRef.current = false
|
|
365
391
|
stopLoop()
|
|
366
392
|
}
|
|
367
393
|
}, [stopLoop])
|
package/src/useWheelBridge.ts
CHANGED
|
@@ -95,6 +95,12 @@ export const useWheelBridge = (target: RefObject<WheelBridgeTarget | null>, opti
|
|
|
95
95
|
// WeakMap なので、外し損ねた要素があっても要素ごと GC される。
|
|
96
96
|
const attachedRef = useRef<WeakMap<HTMLElement, (event: WheelEvent) => void>>(new WeakMap())
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The ref callback for an element outside the pane: it attaches one non-passive wheel listener per element, which applies each
|
|
100
|
+
* wheel event to the target, and returns the cleanup that removes it (React 19). Its identity never changes.
|
|
101
|
+
* ペインの外の要素に付ける ref コールバック。要素ごとに 1 つの非 passive なホイールのリスナーを付け (ホイールのイベントをそれぞれ対象へ当てる)、
|
|
102
|
+
* それを外す後始末を返す (React 19)。同一性は変わらない。
|
|
103
|
+
*/
|
|
98
104
|
return useCallback((node: HTMLElement | null) => {
|
|
99
105
|
// ❗ null 呼び出しでは何も外さない。どの要素の話か分からないため、ここで外すと
|
|
100
106
|
// 無関係の要素を巻き添えにする。解除は下のクリーンアップ (要素を知っている) が行う。
|
|
@@ -111,6 +117,10 @@ export const useWheelBridge = (target: RefObject<WheelBridgeTarget | null>, opti
|
|
|
111
117
|
return
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Applies one wheel event to the target unless the bridge is disabled.
|
|
122
|
+
* ブリッジが無効でなければ、ホイールのイベント 1 つを対象へ当てる処理。
|
|
123
|
+
*/
|
|
114
124
|
const listener = (event: WheelEvent) => {
|
|
115
125
|
if (optionsRef.current?.enableBridge === false) {
|
|
116
126
|
return
|
|
@@ -126,6 +136,10 @@ export const useWheelBridge = (target: RefObject<WheelBridgeTarget | null>, opti
|
|
|
126
136
|
node.addEventListener("wheel", listener, { passive: false })
|
|
127
137
|
attachedRef.current.set(node, listener)
|
|
128
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Removes the element's listener, when it is still the one this call attached.
|
|
141
|
+
* 要素のリスナーを、この呼び出しが付けたものである限り外す処理。
|
|
142
|
+
*/
|
|
129
143
|
// React 19 はこのクリーンアップを受け取ると `null` 呼び出しの代わりに実行する。
|
|
130
144
|
// ❗ listener の一致判定は**意図的な防御**である。ref コールバックの identity は固定
|
|
131
145
|
// (依存配列が空) なので、要素がマウントされている限り React は張り直さず、現状この分岐が
|