@aiquants/virtualscroll 2.6.0 → 3.0.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 +56 -0
- package/README.md +17 -13
- package/dist/ScrollPane.d.cts +15 -0
- package/dist/ScrollPane.d.ts +15 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +20 -9
- package/dist/VirtualScroll.d.ts +20 -9
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1205 -1210
- package/package.json +1 -1
- package/src/ScrollPane.tsx +44 -9
- package/src/VirtualScroll.tsx +28 -42
package/package.json
CHANGED
package/src/ScrollPane.tsx
CHANGED
|
@@ -99,6 +99,21 @@ export type ScrollPaneProps = {
|
|
|
99
99
|
renderThumbOverlay?: (props: ScrollBarThumbOverlayRenderProps) => React.ReactNode
|
|
100
100
|
/** Multiplier applied to wheel delta for faster or slower scrolling. / スクロール速度を調整するためのホイールデルタの倍率。 */
|
|
101
101
|
wheelSpeedMultiplier?: number
|
|
102
|
+
/**
|
|
103
|
+
* How wheel input behaves at the pane's edges (default: "auto").
|
|
104
|
+
* ペインの端でホイールがどう振る舞うか (既定: "auto")。
|
|
105
|
+
*
|
|
106
|
+
* - `"auto"` (既定): その向きへ**もう動けない**縦ホイールは消費せず、祖先 (ページ) の
|
|
107
|
+
* スクロールへ連鎖させる。ネイティブのスクロール領域と同じ挙動
|
|
108
|
+
* (CSS `overscroll-behavior: auto` 相当)。端に**達する**ノッチは部分消費して端で止まり、
|
|
109
|
+
* **次の**ノッチから連鎖する (ネイティブと同じ段付き)。
|
|
110
|
+
* - `"contain"`: 端でも消費し続け、ページへ連鎖させない (2.6.0 以前の挙動)。
|
|
111
|
+
* モーダル内の一覧など「背後を絶対に動かしたくない」消費側向け。
|
|
112
|
+
*
|
|
113
|
+
* ❗ 対象は**縦ホイールのみ**。横成分は `onWheelHorizontal` の所有者 (消費側) の責務であり、
|
|
114
|
+
* ポインタドラッグ / タッチの連鎖はネイティブ相当の慣性譲渡が別問題のため対象外。
|
|
115
|
+
*/
|
|
116
|
+
overscrollBehavior?: "auto" | "contain"
|
|
102
117
|
/**
|
|
103
118
|
* Callback delegating horizontal wheel/trackpad delta to an upstream owner.
|
|
104
119
|
* When provided, horizontal-dominant (or shift+wheel) gestures are consumed here
|
|
@@ -287,6 +302,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
287
302
|
itemCount,
|
|
288
303
|
renderThumbOverlay,
|
|
289
304
|
wheelSpeedMultiplier = 1,
|
|
305
|
+
overscrollBehavior = "auto",
|
|
290
306
|
onWheelHorizontal,
|
|
291
307
|
contentInsets,
|
|
292
308
|
visibleStartIndex,
|
|
@@ -773,11 +789,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
773
789
|
// ❗ **代入のタイミングは `sizeRef` と必ず揃える (どちらも layout effect のみ)。**
|
|
774
790
|
// 片方だけ render 本体に残すと、中断されたトランジション中に `applyWheel` と `scrollTo` が
|
|
775
791
|
// 別々の世界の寸法で判断する最悪の組み合わせになる。
|
|
776
|
-
const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
|
|
792
|
+
const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior })
|
|
777
793
|
useLayoutEffect(() => {
|
|
778
794
|
// sizeRef と同じ理由で「最後に commit された値」だけを保持する (A-1。sizeRef のコメント参照)
|
|
779
|
-
wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
|
|
780
|
-
}, [isScrollable, viewportSize, wheelSpeedMultiplier])
|
|
795
|
+
wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior }
|
|
796
|
+
}, [isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior])
|
|
781
797
|
|
|
782
798
|
/**
|
|
783
799
|
* Applies one wheel event with the pane's own rules and reports whether it was consumed.
|
|
@@ -797,7 +813,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
797
813
|
* 2. 横優勢 (shift+ホイールを含む) は `onWheelHorizontal` へ委譲。未指定なら消費せず祖先へ委ねる
|
|
798
814
|
* 3. スクロール不能なら消費しない
|
|
799
815
|
* 4. px 換算後の縦成分が 0 なら消費しない (適用できる量が無いのに祖先のスクロールを奪わない)
|
|
800
|
-
* 5.
|
|
816
|
+
* 5. 実効 delta の確定 (速度倍率を先に適用)
|
|
817
|
+
* 6. `overscrollBehavior` が "contain" でなければ端判定: その向きへもう動けなければ
|
|
818
|
+
* 消費せず祖先へ連鎖 (2.7.0)
|
|
819
|
+
* 7. 消費済みの印 + `preventDefault()` + 慣性停止
|
|
820
|
+
* 8. 相対スクロール
|
|
801
821
|
*
|
|
802
822
|
* @param event - The wheel event to apply / 適用するホイールイベント
|
|
803
823
|
* @returns True when this pane applied the event / このペインが適用した場合に true。
|
|
@@ -823,7 +843,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
823
843
|
// 保持済みハンドルは preventDefault し続け、ページのスクロールを殺す)。
|
|
824
844
|
// `VirtualScrollHandle.applyWheel` は毎回 ref を辿るため凍結しない。両者の
|
|
825
845
|
// 非対称は `WheelBridgeTarget` が両方を等価に扱う設計と噛み合わない。
|
|
826
|
-
const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier } = wheelPolicyRef.current
|
|
846
|
+
const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier, overscrollBehavior: currentOverscrollBehavior } = wheelPolicyRef.current
|
|
827
847
|
const axes = resolveWheelAxes(event, currentViewportSize)
|
|
828
848
|
|
|
829
849
|
// ❗ ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
|
|
@@ -861,15 +881,30 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
861
881
|
return false
|
|
862
882
|
}
|
|
863
883
|
|
|
864
|
-
markWheelConsumed(event)
|
|
865
|
-
event.preventDefault()
|
|
866
|
-
stopInertia()
|
|
867
|
-
|
|
868
884
|
let deltaY = axes.verticalDelta
|
|
869
885
|
if (currentWheelSpeedMultiplier !== 1) {
|
|
870
886
|
deltaY *= currentWheelSpeedMultiplier
|
|
871
887
|
}
|
|
872
888
|
|
|
889
|
+
// ❗ 端の連鎖 (A-2): その向きへ**もう動けない**ホイールは消費せず祖先へ委ねる
|
|
890
|
+
// (ネイティブの `overscroll-behavior: auto` 相当)。判定は倍率適用後の実効 delta の
|
|
891
|
+
// 向き × committed 位置で行う。端に**達する**ノッチはここを素通りして部分消費され
|
|
892
|
+
// (クランプが端で止める)、次のノッチからこの分岐で連鎖する — ネイティブと同じ段付き。
|
|
893
|
+
// `"contain"` は 2.6.0 以前のトラップ挙動 (モーダル内の一覧など背後を動かしたくない
|
|
894
|
+
// 消費側向け) を明示的に維持する。
|
|
895
|
+
if (currentOverscrollBehavior !== "contain") {
|
|
896
|
+
const currentPosition = scrollPositionRef.current
|
|
897
|
+
// contentSize は sizeRef (committed。wheelPolicyRef と同じ layout effect 同期) から読む
|
|
898
|
+
const maxScrollPosition = Math.max(sizeRef.current.contentSize - currentViewportSize, 0)
|
|
899
|
+
if ((deltaY > 0 && currentPosition >= maxScrollPosition) || (deltaY < 0 && currentPosition <= 0)) {
|
|
900
|
+
return false
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
markWheelConsumed(event)
|
|
905
|
+
event.preventDefault()
|
|
906
|
+
stopInertia()
|
|
907
|
+
|
|
873
908
|
// ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
|
|
874
909
|
Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier: currentWheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
|
|
875
910
|
|
package/src/VirtualScroll.tsx
CHANGED
|
@@ -150,6 +150,11 @@ export type VirtualScrollBehaviorOptions = {
|
|
|
150
150
|
enableKeyboardNavigation?: boolean
|
|
151
151
|
wheelSpeedMultiplier?: number
|
|
152
152
|
inertiaOptions?: ScrollPaneProps["inertiaOptions"]
|
|
153
|
+
/**
|
|
154
|
+
* How wheel input behaves at the list's edges (default: "auto" = chain to ancestors).
|
|
155
|
+
* 一覧の端でホイールがどう振る舞うか (既定: "auto" = 祖先へ連鎖)。`ScrollPaneProps["overscrollBehavior"]` 参照。
|
|
156
|
+
*/
|
|
157
|
+
overscrollBehavior?: ScrollPaneProps["overscrollBehavior"]
|
|
153
158
|
clipItemHeight?: boolean
|
|
154
159
|
/**
|
|
155
160
|
* When `true`, the underlying Fenwick tree fully re-samples on every `getItemHeight` change
|
|
@@ -248,15 +253,21 @@ export type VirtualScrollProps<T> = {
|
|
|
248
253
|
* `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
|
|
249
254
|
*
|
|
250
255
|
* - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
|
|
251
|
-
* - `["
|
|
252
|
-
* 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
|
|
253
|
-
* - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
|
|
254
|
-
* - `["arrow", "shift-arrow"]`: 両方。
|
|
256
|
+
* - `["arrow"]`: 素の `←/→` のみ。
|
|
255
257
|
*
|
|
256
|
-
* ❗
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
258
|
+
* ❗ **`Shift + ←/→` は常に消費しない (3.0.0 で `"shift-arrow"` 入力を撤去)。** ブラウザ標準では
|
|
259
|
+
* Shift+矢印は選択範囲の伸縮であり、横取りすると行内テキストを選び直す手段がキーボードから消える。
|
|
260
|
+
* 2.x では「選択があるときだけ譲る」検出 (`getSelection` の交差判定) で共存させていたが、
|
|
261
|
+
* (a) Shadow DOM 内では選択が可搬に検出できない (Chromium の `window.getSelection()` は shadow root
|
|
262
|
+
* 内部を見せず、内部選択は非標準 API でしか取れない)、(b) `Ctrl+A` の全選択では全行が選択と交差して
|
|
263
|
+
* 横キーボードスクロールが選択解除まで全面ロックアウトする、という構造的欠陥が残った。検出の
|
|
264
|
+
* 精度を上げる案はどれも別の正しいユーザー意図を誤判定するため、機能ごと撤去して Shift 側を
|
|
265
|
+
* 無条件でブラウザへ返す。横スクロールの Shift 系入力が必要なら `shift+ホイール` が引き続き使える
|
|
266
|
+
* (`resolveWheelAxes` の軸規則。こちらは選択と衝突しない)。
|
|
267
|
+
*
|
|
268
|
+
* ❗ **語彙が 1 つでも配列なのは意図的である。** 種別配列は `pointerDragInputs` と同じ本パッケージの
|
|
269
|
+
* 作法であり、将来の入力種別追加が型の破壊なしにできる。boolean へ畳むと prop 名が変わり、横軸を
|
|
270
|
+
* `Omit<>` で封じているラッパー (例: `@aiquants/directory-tree`) の封印リストまで連鎖破壊する。
|
|
260
271
|
*
|
|
261
272
|
* ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
|
|
262
273
|
* capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
|
|
@@ -289,7 +300,7 @@ export type VirtualScrollProps<T> = {
|
|
|
289
300
|
* `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
|
|
290
301
|
* そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
|
|
291
302
|
*/
|
|
292
|
-
horizontalKeyInputs?: readonly
|
|
303
|
+
horizontalKeyInputs?: readonly "arrow"[]
|
|
293
304
|
/**
|
|
294
305
|
* Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
|
|
295
306
|
* 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
|
|
@@ -385,32 +396,6 @@ const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
|
|
|
385
396
|
*/
|
|
386
397
|
const DEFAULT_HORIZONTAL_KEY_STEP = 40
|
|
387
398
|
|
|
388
|
-
/**
|
|
389
|
-
* Reports whether a non-collapsed text selection currently sits inside the given element.
|
|
390
|
-
* 指定要素の中に折り畳まれていないテキスト選択が存在するかを返す処理。
|
|
391
|
-
*
|
|
392
|
-
* `Shift + ←/→` はブラウザ標準では選択範囲の伸縮である。行の中でテキストを選んでいる最中に
|
|
393
|
-
* 横スクロールへ横取りすると、**選び直す手段がキーボードから消える**。選択が空 (キャレットだけ) の
|
|
394
|
-
* ときは伸縮の起点が無いため横取りしてよい。
|
|
395
|
-
*
|
|
396
|
-
* @param element - Element to test the selection against / 選択範囲の所在を調べる要素
|
|
397
|
-
* @returns True when a non-collapsed selection is inside the element / 折り畳まれていない選択が内側にある場合に true
|
|
398
|
-
*/
|
|
399
|
-
const hasTextSelectionWithin = (element: HTMLElement): boolean => {
|
|
400
|
-
const selection = element.ownerDocument.defaultView?.getSelection()
|
|
401
|
-
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
402
|
-
return false
|
|
403
|
-
}
|
|
404
|
-
// ❗ 起点 (anchorNode) の包含では不十分。`Ctrl+A` のようにページ全体を選ぶと起点は行の外に落ち、
|
|
405
|
-
// 「行の上に見えている選択」を取りこぼす (実ブラウザで実測)。範囲が行と**交差**するかで判定する。
|
|
406
|
-
for (let index = 0; index < selection.rangeCount; index += 1) {
|
|
407
|
-
if (selection.getRangeAt(index).intersectsNode(element)) {
|
|
408
|
-
return true
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
return false
|
|
412
|
-
}
|
|
413
|
-
|
|
414
399
|
/**
|
|
415
400
|
* Converts a numeric size into a non-negative bigint for large collection handling.
|
|
416
401
|
*
|
|
@@ -914,7 +899,7 @@ const VirtualScrollInner = <T,>(
|
|
|
914
899
|
) => {
|
|
915
900
|
const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
|
|
916
901
|
|
|
917
|
-
const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
|
|
902
|
+
const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
|
|
918
903
|
|
|
919
904
|
// viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
|
|
920
905
|
// 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
|
|
@@ -1910,14 +1895,14 @@ const VirtualScrollInner = <T,>(
|
|
|
1910
1895
|
if (!emitHorizontal) {
|
|
1911
1896
|
return
|
|
1912
1897
|
}
|
|
1913
|
-
//
|
|
1914
|
-
|
|
1915
|
-
|
|
1898
|
+
// ❗ Shift 併用は無条件で消費しない (3.0.0 で "shift-arrow" 入力を撤去)。ブラウザ標準では
|
|
1899
|
+
// 選択範囲の伸縮であり、選択の有無での条件分岐はしない — 選択検出は Shadow DOM で
|
|
1900
|
+
// 可搬に成立せず、Ctrl+A 全選択では全行が交差して解除まで全面ロックアウトするため
|
|
1901
|
+
if (event.shiftKey) {
|
|
1916
1902
|
return
|
|
1917
1903
|
}
|
|
1918
|
-
//
|
|
1919
|
-
|
|
1920
|
-
if (event.shiftKey && hasTextSelectionWithin(event.currentTarget)) {
|
|
1904
|
+
// 押されたジェスチャが許可種別に含まれるかを確認する
|
|
1905
|
+
if (!horizontalKeyInputs?.includes("arrow")) {
|
|
1921
1906
|
return
|
|
1922
1907
|
}
|
|
1923
1908
|
// ❗ 不正な移動量は既定へ黙って読み替えず、消費もしない (Strict No-Fallback)。
|
|
@@ -2365,6 +2350,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2365
2350
|
background={background}
|
|
2366
2351
|
tapScrollCircleOptions={tapScrollCircleOptions}
|
|
2367
2352
|
inertiaOptions={inertiaOptions}
|
|
2353
|
+
overscrollBehavior={overscrollBehavior}
|
|
2368
2354
|
itemCount={itemCount}
|
|
2369
2355
|
scrollBarWidth={scrollBarWidth}
|
|
2370
2356
|
enableThumbDrag={enableThumbDrag}
|