@aiquants/virtualscroll 2.4.0 → 2.6.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 +164 -0
- package/README.md +144 -7
- package/dist/ScrollPane.d.cts +23 -1
- package/dist/ScrollPane.d.ts +23 -1
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +109 -3
- package/dist/VirtualScroll.d.ts +109 -3
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1540 -1473
- package/dist/useWheelBridge.d.cts +64 -0
- package/dist/useWheelBridge.d.ts +65 -0
- package/dist/useWheelBridge.d.ts.map +1 -0
- package/dist/wheelAxes.d.cts +48 -0
- package/dist/wheelAxes.d.ts +49 -0
- package/dist/wheelAxes.d.ts.map +1 -0
- package/dist/wheelConsumption.d.cts +29 -0
- package/dist/wheelConsumption.d.ts +30 -0
- package/dist/wheelConsumption.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/ScrollPane.tsx +148 -49
- package/src/VirtualScroll.tsx +288 -20
- package/src/index.ts +1 -0
- package/src/useWheelBridge.ts +141 -0
- package/src/wheelAxes.ts +90 -0
- package/src/wheelConsumption.ts +36 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RefCallback, RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Anything that can apply a wheel event with the pane's own rules.
|
|
4
|
+
* ペイン自身の規則でホイールイベントを適用できるもの。
|
|
5
|
+
*
|
|
6
|
+
* `VirtualScrollHandle` と `ScrollPaneHandle` の両方が構造的にこれを満たすため、どちらのハンドルでも
|
|
7
|
+
* 同じフックが使える (`ScrollPane` を直接使う消費側が橋渡しを自作せずに済む)。
|
|
8
|
+
*/
|
|
9
|
+
export type WheelBridgeTarget = {
|
|
10
|
+
applyWheel: (event: WheelEvent) => boolean;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Options for {@link useWheelBridge}.
|
|
14
|
+
* {@link useWheelBridge} のオプション。
|
|
15
|
+
*/
|
|
16
|
+
export type WheelBridgeOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Set to `false` to detach the bridge without unmounting the element.
|
|
19
|
+
* 要素をアンマウントせずに橋渡しを止めたいとき `false` にする。既定 `true`。
|
|
20
|
+
*/
|
|
21
|
+
enableBridge?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Bridges wheel events from an element outside the pane into the list, with the pane's own semantics.
|
|
25
|
+
* ペイン外の要素のホイールを、ペイン自身と同じ意味論で一覧へ橋渡しするフック。
|
|
26
|
+
*
|
|
27
|
+
* 返り値を対象要素の `ref` に渡すだけでよい。
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const listRef = useRef<VirtualScrollHandle>(null)
|
|
31
|
+
* const headerRef = useWheelBridge(listRef)
|
|
32
|
+
*
|
|
33
|
+
* <div ref={headerRef}>列ヘッダー (ペインの外)</div>
|
|
34
|
+
* <VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ... />
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* ❗ **ホイールの意味論に影響する設定は一切受け取らないのが意図的な設計である。** 速度倍率・
|
|
38
|
+
* 横成分の委譲先・スクロール可否・慣性停止・軸分解のすべては、ハンドルの向こう側 (`applyWheel`) が
|
|
39
|
+
* 持つ。橋渡し側にも同じ設定口を用意すると同じグリッドなのに「ヘッダー帯の上だけスクロールが遅い」
|
|
40
|
+
* 「帯の上でだけ横に動かない」といった食い違いが静かに生まれる (橋渡し側にも設定口を置いた
|
|
41
|
+
* 実装途中の版で実測: `wheelSpeedMultiplier: 3` のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
|
|
42
|
+
* 唯一のオプション {@link WheelBridgeOptions.enableBridge} は意味論ではなく**橋渡し自体の ON/OFF** である。
|
|
43
|
+
* このフックの責務は **`{ passive: false }` での登録・後始末・その ON/OFF だけ**である。
|
|
44
|
+
*
|
|
45
|
+
* ❗ **`onWheel` プロップでは代用できない。** React 19 は `onWheel` を passive リスナーとして登録するため
|
|
46
|
+
* `preventDefault()` が効かず、ページ全体がスクロールしてしまう。
|
|
47
|
+
*
|
|
48
|
+
* ❗ **`handle.scrollTo` で自前に橋渡ししてはならない。** あれはジャンプ用 API で、絶対位置を丸めるため
|
|
49
|
+
* 1px 未満のデルタが消え、さらにスクロールアンカーを張るためサイズ変化のたびに一覧が過去の位置へ戻る。
|
|
50
|
+
*
|
|
51
|
+
* 自前の ref も必要なら合成してよい (多重登録に対して安全に作ってある)。
|
|
52
|
+
* ❗ **戻り値をそのまま `return` すること。** React 19 はこれをクリーンアップとして実行する。
|
|
53
|
+
* 返さない場合も動作は正しいままだが、`null` 呼び出しでは何も外さない設計のため、リスナーは
|
|
54
|
+
* 要素ごと GC されるまで残る。
|
|
55
|
+
*
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <div ref={(node) => { myRef.current = node; return headerRef(node) }} />
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @param target - Ref to the list's imperative handle (`VirtualScrollHandle` / `ScrollPaneHandle`) / 一覧の命令ハンドルへの ref
|
|
61
|
+
* @param options - Bridge options / 橋渡しのオプション
|
|
62
|
+
* @returns A ref callback to attach to the source element / 発生元の要素へ渡す ref コールバック
|
|
63
|
+
*/
|
|
64
|
+
export declare const useWheelBridge: (target: RefObject<WheelBridgeTarget | null>, options?: WheelBridgeOptions) => RefCallback<HTMLElement>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { RefCallback, RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Anything that can apply a wheel event with the pane's own rules.
|
|
4
|
+
* ペイン自身の規則でホイールイベントを適用できるもの。
|
|
5
|
+
*
|
|
6
|
+
* `VirtualScrollHandle` と `ScrollPaneHandle` の両方が構造的にこれを満たすため、どちらのハンドルでも
|
|
7
|
+
* 同じフックが使える (`ScrollPane` を直接使う消費側が橋渡しを自作せずに済む)。
|
|
8
|
+
*/
|
|
9
|
+
export type WheelBridgeTarget = {
|
|
10
|
+
applyWheel: (event: WheelEvent) => boolean;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Options for {@link useWheelBridge}.
|
|
14
|
+
* {@link useWheelBridge} のオプション。
|
|
15
|
+
*/
|
|
16
|
+
export type WheelBridgeOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Set to `false` to detach the bridge without unmounting the element.
|
|
19
|
+
* 要素をアンマウントせずに橋渡しを止めたいとき `false` にする。既定 `true`。
|
|
20
|
+
*/
|
|
21
|
+
enableBridge?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Bridges wheel events from an element outside the pane into the list, with the pane's own semantics.
|
|
25
|
+
* ペイン外の要素のホイールを、ペイン自身と同じ意味論で一覧へ橋渡しするフック。
|
|
26
|
+
*
|
|
27
|
+
* 返り値を対象要素の `ref` に渡すだけでよい。
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const listRef = useRef<VirtualScrollHandle>(null)
|
|
31
|
+
* const headerRef = useWheelBridge(listRef)
|
|
32
|
+
*
|
|
33
|
+
* <div ref={headerRef}>列ヘッダー (ペインの外)</div>
|
|
34
|
+
* <VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ... />
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* ❗ **ホイールの意味論に影響する設定は一切受け取らないのが意図的な設計である。** 速度倍率・
|
|
38
|
+
* 横成分の委譲先・スクロール可否・慣性停止・軸分解のすべては、ハンドルの向こう側 (`applyWheel`) が
|
|
39
|
+
* 持つ。橋渡し側にも同じ設定口を用意すると同じグリッドなのに「ヘッダー帯の上だけスクロールが遅い」
|
|
40
|
+
* 「帯の上でだけ横に動かない」といった食い違いが静かに生まれる (橋渡し側にも設定口を置いた
|
|
41
|
+
* 実装途中の版で実測: `wheelSpeedMultiplier: 3` のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
|
|
42
|
+
* 唯一のオプション {@link WheelBridgeOptions.enableBridge} は意味論ではなく**橋渡し自体の ON/OFF** である。
|
|
43
|
+
* このフックの責務は **`{ passive: false }` での登録・後始末・その ON/OFF だけ**である。
|
|
44
|
+
*
|
|
45
|
+
* ❗ **`onWheel` プロップでは代用できない。** React 19 は `onWheel` を passive リスナーとして登録するため
|
|
46
|
+
* `preventDefault()` が効かず、ページ全体がスクロールしてしまう。
|
|
47
|
+
*
|
|
48
|
+
* ❗ **`handle.scrollTo` で自前に橋渡ししてはならない。** あれはジャンプ用 API で、絶対位置を丸めるため
|
|
49
|
+
* 1px 未満のデルタが消え、さらにスクロールアンカーを張るためサイズ変化のたびに一覧が過去の位置へ戻る。
|
|
50
|
+
*
|
|
51
|
+
* 自前の ref も必要なら合成してよい (多重登録に対して安全に作ってある)。
|
|
52
|
+
* ❗ **戻り値をそのまま `return` すること。** React 19 はこれをクリーンアップとして実行する。
|
|
53
|
+
* 返さない場合も動作は正しいままだが、`null` 呼び出しでは何も外さない設計のため、リスナーは
|
|
54
|
+
* 要素ごと GC されるまで残る。
|
|
55
|
+
*
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <div ref={(node) => { myRef.current = node; return headerRef(node) }} />
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @param target - Ref to the list's imperative handle (`VirtualScrollHandle` / `ScrollPaneHandle`) / 一覧の命令ハンドルへの ref
|
|
61
|
+
* @param options - Bridge options / 橋渡しのオプション
|
|
62
|
+
* @returns A ref callback to attach to the source element / 発生元の要素へ渡す ref コールバック
|
|
63
|
+
*/
|
|
64
|
+
export declare const useWheelBridge: (target: RefObject<WheelBridgeTarget | null>, options?: WheelBridgeOptions) => RefCallback<HTMLElement>;
|
|
65
|
+
//# sourceMappingURL=useWheelBridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWheelBridge.d.ts","sourceRoot":"","sources":["../src/useWheelBridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAwC,MAAM,OAAO,CAAA;AAE9F;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC5B,UAAU,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAA;CAC7C,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC7B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,cAAc,GAAI,QAAQ,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,EAAE,UAAU,kBAAkB,KAAG,WAAW,CAAC,WAAW,CA+DjI,CAAA"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module wheelAxes
|
|
3
|
+
* @description Single source of truth for how a wheel event is split into scroll axes.
|
|
4
|
+
* Used only by `ScrollPane.applyWheel`, which is itself the single seam every wheel caller goes
|
|
5
|
+
* through, so the rules cannot drift between the pane and elements outside it.
|
|
6
|
+
*
|
|
7
|
+
* @description ホイールイベントをスクロール軸へ分解する規則の単一の真実。呼び出し元は
|
|
8
|
+
* `ScrollPane.applyWheel` **だけ**であり、その `applyWheel` がすべてのホイール呼び出し元
|
|
9
|
+
* (ペイン根のリスナー / `useWheelBridge` / 消費側の直接呼び出し) の唯一のシームなので、
|
|
10
|
+
* ペイン内外で規則が食い違わない。
|
|
11
|
+
*
|
|
12
|
+
* ❗ **公開 API ではない** (`index.ts` から export しない)。公開すると `LINE_HEIGHT_PX = 16`・
|
|
13
|
+
* 同値時の横倒し・4 フィールドの形が恒久的に凍結される。
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Wheel axes resolved from a raw wheel event.
|
|
17
|
+
* 生のホイールイベントから解決した軸情報。
|
|
18
|
+
*/
|
|
19
|
+
export type ResolvedWheelAxes = {
|
|
20
|
+
/** True when ctrl is held, i.e. the browser treats it as zoom. Deltas are still reported as-is. / ctrl 押下 (ブラウザがズームとして扱う) の場合に true。デルタはそのまま報告する。 */
|
|
21
|
+
isZoomGesture: boolean;
|
|
22
|
+
/** True when the horizontal component dominates (includes shift+wheel). / 横成分が優勢な場合に true (shift+ホイールを含む)。 */
|
|
23
|
+
isHorizontalDominant: boolean;
|
|
24
|
+
/** Horizontal delta in px (deltaMode already converted). / px 単位の横方向デルタ (deltaMode 変換済み)。 */
|
|
25
|
+
horizontalDelta: number;
|
|
26
|
+
/** Vertical delta in px (deltaMode already converted). / px 単位の縦方向デルタ (deltaMode 変換済み)。 */
|
|
27
|
+
verticalDelta: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Splits a wheel event into scroll axes, in pixels, applying the package's gesture rules.
|
|
31
|
+
* ホイールイベントを px 単位の軸へ分解し、本パッケージのジェスチャ規則を適用する処理。
|
|
32
|
+
*
|
|
33
|
+
* 規則 (判定順序そのものが仕様):
|
|
34
|
+
*
|
|
35
|
+
* 1. **shift+ホイール**は `deltaX === 0` のとき縦量を横として読む (Windows 系の慣習)。ブラウザが既に
|
|
36
|
+
* `deltaX` へマップ済みなら通常の軸のまま扱う。
|
|
37
|
+
* 2. **横優勢判定**は横成分と縦成分の絶対値比較。等しい場合 (`|h| === |v|`) は横に倒す。ただし
|
|
38
|
+
* **両軸とも 0 のときは横優勢としない** — 慣性の惰性通知で毎 tick 送られる 0/0 を横として
|
|
39
|
+
* 消費すると、ページ側のスクロールを無言で飲み込む。
|
|
40
|
+
*
|
|
41
|
+
* ❗ **これは解決器であって方針ではない。** `isZoomGesture` (ctrl 押下) が true でもデルタは
|
|
42
|
+
* そのまま返す。「ズームは横取りしない」という**方針**は適用側 (`ScrollPane.applyWheel`) が持つ。
|
|
43
|
+
*
|
|
44
|
+
* @param event - The wheel event / ホイールイベント
|
|
45
|
+
* @param viewportSize - Viewport length for `DOM_DELTA_PAGE` conversion / `DOM_DELTA_PAGE` 変換に使うビューポート長
|
|
46
|
+
* @returns The resolved axes / 解決した軸情報
|
|
47
|
+
*/
|
|
48
|
+
export declare const resolveWheelAxes: (event: WheelEvent, viewportSize: number) => ResolvedWheelAxes;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module wheelAxes
|
|
3
|
+
* @description Single source of truth for how a wheel event is split into scroll axes.
|
|
4
|
+
* Used only by `ScrollPane.applyWheel`, which is itself the single seam every wheel caller goes
|
|
5
|
+
* through, so the rules cannot drift between the pane and elements outside it.
|
|
6
|
+
*
|
|
7
|
+
* @description ホイールイベントをスクロール軸へ分解する規則の単一の真実。呼び出し元は
|
|
8
|
+
* `ScrollPane.applyWheel` **だけ**であり、その `applyWheel` がすべてのホイール呼び出し元
|
|
9
|
+
* (ペイン根のリスナー / `useWheelBridge` / 消費側の直接呼び出し) の唯一のシームなので、
|
|
10
|
+
* ペイン内外で規則が食い違わない。
|
|
11
|
+
*
|
|
12
|
+
* ❗ **公開 API ではない** (`index.ts` から export しない)。公開すると `LINE_HEIGHT_PX = 16`・
|
|
13
|
+
* 同値時の横倒し・4 フィールドの形が恒久的に凍結される。
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Wheel axes resolved from a raw wheel event.
|
|
17
|
+
* 生のホイールイベントから解決した軸情報。
|
|
18
|
+
*/
|
|
19
|
+
export type ResolvedWheelAxes = {
|
|
20
|
+
/** True when ctrl is held, i.e. the browser treats it as zoom. Deltas are still reported as-is. / ctrl 押下 (ブラウザがズームとして扱う) の場合に true。デルタはそのまま報告する。 */
|
|
21
|
+
isZoomGesture: boolean;
|
|
22
|
+
/** True when the horizontal component dominates (includes shift+wheel). / 横成分が優勢な場合に true (shift+ホイールを含む)。 */
|
|
23
|
+
isHorizontalDominant: boolean;
|
|
24
|
+
/** Horizontal delta in px (deltaMode already converted). / px 単位の横方向デルタ (deltaMode 変換済み)。 */
|
|
25
|
+
horizontalDelta: number;
|
|
26
|
+
/** Vertical delta in px (deltaMode already converted). / px 単位の縦方向デルタ (deltaMode 変換済み)。 */
|
|
27
|
+
verticalDelta: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Splits a wheel event into scroll axes, in pixels, applying the package's gesture rules.
|
|
31
|
+
* ホイールイベントを px 単位の軸へ分解し、本パッケージのジェスチャ規則を適用する処理。
|
|
32
|
+
*
|
|
33
|
+
* 規則 (判定順序そのものが仕様):
|
|
34
|
+
*
|
|
35
|
+
* 1. **shift+ホイール**は `deltaX === 0` のとき縦量を横として読む (Windows 系の慣習)。ブラウザが既に
|
|
36
|
+
* `deltaX` へマップ済みなら通常の軸のまま扱う。
|
|
37
|
+
* 2. **横優勢判定**は横成分と縦成分の絶対値比較。等しい場合 (`|h| === |v|`) は横に倒す。ただし
|
|
38
|
+
* **両軸とも 0 のときは横優勢としない** — 慣性の惰性通知で毎 tick 送られる 0/0 を横として
|
|
39
|
+
* 消費すると、ページ側のスクロールを無言で飲み込む。
|
|
40
|
+
*
|
|
41
|
+
* ❗ **これは解決器であって方針ではない。** `isZoomGesture` (ctrl 押下) が true でもデルタは
|
|
42
|
+
* そのまま返す。「ズームは横取りしない」という**方針**は適用側 (`ScrollPane.applyWheel`) が持つ。
|
|
43
|
+
*
|
|
44
|
+
* @param event - The wheel event / ホイールイベント
|
|
45
|
+
* @param viewportSize - Viewport length for `DOM_DELTA_PAGE` conversion / `DOM_DELTA_PAGE` 変換に使うビューポート長
|
|
46
|
+
* @returns The resolved axes / 解決した軸情報
|
|
47
|
+
*/
|
|
48
|
+
export declare const resolveWheelAxes: (event: WheelEvent, viewportSize: number) => ResolvedWheelAxes;
|
|
49
|
+
//# sourceMappingURL=wheelAxes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wheelAxes.d.ts","sourceRoot":"","sources":["../src/wheelAxes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC5B,qJAAqJ;IACrJ,aAAa,EAAE,OAAO,CAAA;IACtB,8GAA8G;IAC9G,oBAAoB,EAAE,OAAO,CAAA;IAC7B,6FAA6F;IAC7F,eAAe,EAAE,MAAM,CAAA;IACvB,2FAA2F;IAC3F,aAAa,EAAE,MAAM,CAAA;CACxB,CAAA;AAuBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,UAAU,EAAE,cAAc,MAAM,KAAG,iBAgB1E,CAAA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module wheelConsumption
|
|
3
|
+
* @description Tracks which wheel events this package has already applied.
|
|
4
|
+
*
|
|
5
|
+
* @description 本パッケージが既に適用したホイールイベントを記録するモジュール。
|
|
6
|
+
*
|
|
7
|
+
* ❗ **`event.defaultPrevented` では代用できない。** あれは「誰かがこのイベントを止めた」であって
|
|
8
|
+
* 「**我々が**消費した」ではない。両者を混同すると、ページ全体のスクロールロック
|
|
9
|
+
* (`document` に capture + `preventDefault` を張る定番の実装) を掛けただけで一覧が
|
|
10
|
+
* まったくスクロールできなくなる。逆に印が無いと、ペインの入れ子や祖先に張った `useWheelBridge` で
|
|
11
|
+
* **同じデルタが 2 回適用**される (実測: 内側と外側の一覧が 1 ノッチで両方 100px 動く)。
|
|
12
|
+
*
|
|
13
|
+
* `WeakSet` なのでイベントオブジェクトの寿命を延ばさない。
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Marks a wheel event as applied by this package.
|
|
17
|
+
* ホイールイベントを「本パッケージが適用済み」として記録する処理。
|
|
18
|
+
*
|
|
19
|
+
* @param event - The wheel event that was applied / 適用したホイールイベント
|
|
20
|
+
*/
|
|
21
|
+
export declare const markWheelConsumed: (event: WheelEvent) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Reports whether this package already applied the given wheel event.
|
|
24
|
+
* 指定のホイールイベントを本パッケージが既に適用済みかどうかを返す処理。
|
|
25
|
+
*
|
|
26
|
+
* @param event - The wheel event to test / 判定するホイールイベント
|
|
27
|
+
* @returns True when it was already applied / 既に適用済みの場合に true
|
|
28
|
+
*/
|
|
29
|
+
export declare const wasWheelConsumed: (event: WheelEvent) => boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module wheelConsumption
|
|
3
|
+
* @description Tracks which wheel events this package has already applied.
|
|
4
|
+
*
|
|
5
|
+
* @description 本パッケージが既に適用したホイールイベントを記録するモジュール。
|
|
6
|
+
*
|
|
7
|
+
* ❗ **`event.defaultPrevented` では代用できない。** あれは「誰かがこのイベントを止めた」であって
|
|
8
|
+
* 「**我々が**消費した」ではない。両者を混同すると、ページ全体のスクロールロック
|
|
9
|
+
* (`document` に capture + `preventDefault` を張る定番の実装) を掛けただけで一覧が
|
|
10
|
+
* まったくスクロールできなくなる。逆に印が無いと、ペインの入れ子や祖先に張った `useWheelBridge` で
|
|
11
|
+
* **同じデルタが 2 回適用**される (実測: 内側と外側の一覧が 1 ノッチで両方 100px 動く)。
|
|
12
|
+
*
|
|
13
|
+
* `WeakSet` なのでイベントオブジェクトの寿命を延ばさない。
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Marks a wheel event as applied by this package.
|
|
17
|
+
* ホイールイベントを「本パッケージが適用済み」として記録する処理。
|
|
18
|
+
*
|
|
19
|
+
* @param event - The wheel event that was applied / 適用したホイールイベント
|
|
20
|
+
*/
|
|
21
|
+
export declare const markWheelConsumed: (event: WheelEvent) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Reports whether this package already applied the given wheel event.
|
|
24
|
+
* 指定のホイールイベントを本パッケージが既に適用済みかどうかを返す処理。
|
|
25
|
+
*
|
|
26
|
+
* @param event - The wheel event to test / 判定するホイールイベント
|
|
27
|
+
* @returns True when it was already applied / 既に適用済みの場合に true
|
|
28
|
+
*/
|
|
29
|
+
export declare const wasWheelConsumed: (event: WheelEvent) => boolean;
|
|
30
|
+
//# sourceMappingURL=wheelConsumption.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wheelConsumption.d.ts","sourceRoot":"","sources":["../src/wheelConsumption.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAAU,KAAG,IAErD,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,UAAU,KAAG,OAAyC,CAAA"}
|
package/package.json
CHANGED
package/src/ScrollPane.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge"
|
|
|
3
3
|
import { Logger } from "./logger.ts"
|
|
4
4
|
import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
|
|
5
5
|
import { minmax } from "./utils.ts"
|
|
6
|
+
import { resolveWheelAxes } from "./wheelAxes.ts"
|
|
7
|
+
import { markWheelConsumed, wasWheelConsumed } from "./wheelConsumption.ts"
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Props for the ScrollPane component.
|
|
@@ -232,10 +234,29 @@ const toggleListeners = (target: HTMLElement | Window, entries: ListenerEntry[],
|
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
export type ScrollPaneHandle = {
|
|
235
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Jumps to a position, clamped against the last-committed dims (or an explicit override).
|
|
239
|
+
* 位置へジャンプする。クランプは**最後に commit された寸法** (または明示指定) に対して行う。
|
|
240
|
+
*
|
|
241
|
+
* `dimsOverride` は「commit より新しい寸法を知っている呼び出し元」専用の口である
|
|
242
|
+
* (例: Fenwick 木を同期更新した直後のレイアウトシフト補正は、まだ commit されていない
|
|
243
|
+
* 木の合計高さでクランプしたい)。通常の呼び出しでは指定しないこと — 指定を誤ると
|
|
244
|
+
* 画面に出ていない寸法でクランプされ、A-1 の汚染を自分の手で再現することになる。
|
|
245
|
+
*/
|
|
246
|
+
scrollTo: (newPosition: number | ((prev: number) => number), dimsOverride?: { contentSize: number; viewportSize: number }) => number
|
|
236
247
|
getScrollPosition: () => number
|
|
237
248
|
getContentSize: () => number
|
|
238
249
|
getViewportSize: () => number
|
|
250
|
+
/**
|
|
251
|
+
* Applies one wheel event with the pane's own rules; returns whether it was consumed.
|
|
252
|
+
* ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
|
|
253
|
+
*
|
|
254
|
+
* ペインの**外**に置いた要素 (列ヘッダー帯など) のホイールを、ペイン内と寸分違わぬ意味論で
|
|
255
|
+
* 流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横委譲先・慣性停止のすべてが
|
|
256
|
+
* ペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
|
|
257
|
+
* 直接使うより `useWheelBridge` (passive:false 登録と後始末込み) を推奨する。
|
|
258
|
+
*/
|
|
259
|
+
applyWheel: (event: WheelEvent) => boolean
|
|
239
260
|
}
|
|
240
261
|
|
|
241
262
|
/**
|
|
@@ -465,8 +486,20 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
465
486
|
// contentSize と viewportSize を ref として保持
|
|
466
487
|
// const sizeRef = useRef({ contentSize, viewportSize })
|
|
467
488
|
|
|
489
|
+
// ❗ **render 本体では代入しない (layout effect のみ = 常に「最後に commit された寸法」)。**
|
|
490
|
+
// render 中に書くと、中断されたトランジションの**コミットされなかった寸法**が残り、
|
|
491
|
+
// 保留中のホイール/ジャンプが画面に出ていない寸法でクランプされる (実測: 保留中の縮小で
|
|
492
|
+
// 一覧が先頭へ飛ぶ / ホイールが死ぬ。transitionClamp.spec.tsx が固定)。
|
|
493
|
+
//
|
|
494
|
+
// かつて「layout effect 化はタップスクロール E2E を退行させる」と記録されていたが、
|
|
495
|
+
// 8 回 x 3 系列の A/B/A 対照実験で**反証済み** (失敗は書き込み位置と無関係の
|
|
496
|
+
// コールドスタート負荷フレークで、両変種とも 6-7/8 で同率に失敗した)。加えて全計 1,893 回の
|
|
497
|
+
// scrollTo クランプ監査で committed 値と render 新値の食い違いは 0 件 — 同 commit 内で
|
|
498
|
+
// 新値を要する呼び出し元は存在しない (子 effect は本 effect より先に走るが、その窓に
|
|
499
|
+
// scrollTo 呼び出し元は無いことを確認済み)。commit より新しい寸法が要る唯一の呼び出し元
|
|
500
|
+
// (レイアウトシフト補正) は「committed + 今適用した delta」を `dimsOverride` で明示的に
|
|
501
|
+
// 渡す (木合計は渡さない — 木は破棄された WIP render にも変異される共有物)。
|
|
468
502
|
const sizeRef = useRef({ contentSize, viewportSize })
|
|
469
|
-
sizeRef.current = { contentSize, viewportSize }
|
|
470
503
|
|
|
471
504
|
const isScrollable = useMemo(() => contentSize > viewportSize, [contentSize, viewportSize])
|
|
472
505
|
|
|
@@ -504,8 +537,18 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
504
537
|
// )
|
|
505
538
|
|
|
506
539
|
const scrollTo = useCallback(
|
|
507
|
-
(newPosition: number | ((prev: number) => number)) => {
|
|
508
|
-
|
|
540
|
+
(newPosition: number | ((prev: number) => number), dimsOverride?: { contentSize: number; viewportSize: number }) => {
|
|
541
|
+
// 既定は「最後に commit された寸法」。dimsOverride は commit より新しい寸法を
|
|
542
|
+
// 知っている呼び出し元 (レイアウトシフト補正) だけが渡す。
|
|
543
|
+
// ❗ 非有限の override は警告して無視する (position の NaN ガードと同じ姿勢)。
|
|
544
|
+
// NaN の contentSize は「スクロール不能」判定へ落ち、位置を 0 へ強制 +
|
|
545
|
+
// onScroll(0) まで発火してしまう
|
|
546
|
+
let effectiveDims = dimsOverride ?? sizeRef.current
|
|
547
|
+
if (dimsOverride && !(Number.isFinite(dimsOverride.contentSize) && Number.isFinite(dimsOverride.viewportSize))) {
|
|
548
|
+
Logger.warn("[ScrollPane] scrollTo received non-finite dimsOverride; falling back to the committed dims", { dimsOverride })
|
|
549
|
+
effectiveDims = sizeRef.current
|
|
550
|
+
}
|
|
551
|
+
const { contentSize: currentContentSize, viewportSize: currentViewportSize } = effectiveDims
|
|
509
552
|
const currentIsScrollable = currentContentSize > currentViewportSize
|
|
510
553
|
const prevPosition = scrollPositionRef.current
|
|
511
554
|
|
|
@@ -725,73 +768,128 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
725
768
|
}
|
|
726
769
|
}, [isScrollable, scrollTo, contentSize, viewportSize])
|
|
727
770
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
771
|
+
// ホイール適用の方針値。保持されたハンドル越しの呼び出しでも凍結させないため ref で持つ。
|
|
772
|
+
//
|
|
773
|
+
// ❗ **代入のタイミングは `sizeRef` と必ず揃える (どちらも layout effect のみ)。**
|
|
774
|
+
// 片方だけ render 本体に残すと、中断されたトランジション中に `applyWheel` と `scrollTo` が
|
|
775
|
+
// 別々の世界の寸法で判断する最悪の組み合わせになる。
|
|
776
|
+
const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
|
|
777
|
+
useLayoutEffect(() => {
|
|
778
|
+
// sizeRef と同じ理由で「最後に commit された値」だけを保持する (A-1。sizeRef のコメント参照)
|
|
779
|
+
wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
|
|
780
|
+
}, [isScrollable, viewportSize, wheelSpeedMultiplier])
|
|
735
781
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
782
|
+
/**
|
|
783
|
+
* Applies one wheel event with the pane's own rules and reports whether it was consumed.
|
|
784
|
+
*
|
|
785
|
+
* ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す処理。
|
|
786
|
+
*
|
|
787
|
+
* ❗ **ホイールの意味論はここが単一の真実である。** ペイン根に張るリスナーも、ペインの外の要素へ
|
|
788
|
+
* 同じ意味論を与える `useWheelBridge` も、この 1 つの関数を通す。軸分解 (`resolveWheelAxes`) だけを
|
|
789
|
+
* 共有して残り (速度倍率・スクロール可否・横委譲先・慣性停止) を各所に書くと、同じグリッドなのに
|
|
790
|
+
* 「ヘッダー帯の上だけスクロールが遅い」といった食い違いが静かに生まれる (橋渡し側にも設定口を
|
|
791
|
+
* 置いた実装途中の版で実測: 倍率 3 のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
|
|
792
|
+
*
|
|
793
|
+
* 判定順序そのものが仕様:
|
|
794
|
+
*
|
|
795
|
+
* 0. 本パッケージが既に適用済みの印があれば何もしない (ペインの入れ子・祖先の橋渡しでの二重適用防止)
|
|
796
|
+
* 1. ctrl+wheel はブラウザのズームなので横取りせず素通しする
|
|
797
|
+
* 2. 横優勢 (shift+ホイールを含む) は `onWheelHorizontal` へ委譲。未指定なら消費せず祖先へ委ねる
|
|
798
|
+
* 3. スクロール不能なら消費しない
|
|
799
|
+
* 4. px 換算後の縦成分が 0 なら消費しない (適用できる量が無いのに祖先のスクロールを奪わない)
|
|
800
|
+
* 5. 消費済みの印 + `preventDefault()` + 慣性停止 + 速度倍率を掛けて相対スクロール
|
|
801
|
+
*
|
|
802
|
+
* @param event - The wheel event to apply / 適用するホイールイベント
|
|
803
|
+
* @returns True when this pane applied the event / このペインが適用した場合に true。
|
|
804
|
+
* ❗ 適用時は `preventDefault()` も呼ぶが、`cancelable` でないイベント
|
|
805
|
+
* (合成イベントの既定) では preventDefault は効かないため、返値は
|
|
806
|
+
* 「`defaultPrevented` になったか」ではなく「**適用したか**」を意味する
|
|
807
|
+
*/
|
|
808
|
+
const applyWheel = useCallback(
|
|
809
|
+
(event: WheelEvent): boolean => {
|
|
810
|
+
// ❗ **このペイン群が既に消費したイベントは二度と適用しない。** 冪等性の規則を
|
|
811
|
+
// 適用シームの内側に置くことで、どの呼び出し元 (ペイン根のリスナー / useWheelBridge /
|
|
812
|
+
// 消費側の直接呼び出し) からも同じ保証が効く。
|
|
813
|
+
//
|
|
814
|
+
// ❗ `event.defaultPrevented` で代用してはならない。あれは「誰かが止めた」であって
|
|
815
|
+
// 「我々が消費した」ではない。ページ全体のスクロールロック (`document` に capture +
|
|
816
|
+
// preventDefault を張る定番の実装) と区別できず、ロック中に一覧が
|
|
817
|
+
// **まったくスクロールできなくなる** (実測)。
|
|
818
|
+
if (wasWheelConsumed(event)) {
|
|
819
|
+
return false
|
|
820
|
+
}
|
|
821
|
+
// ❗ 値は ref から読む。lexical に閉じ込めると、消費側が保持したハンドルが
|
|
822
|
+
// **その時点の方針を凍結**する (実測: 一覧が絞り込みでスクロール不能になった後も
|
|
823
|
+
// 保持済みハンドルは preventDefault し続け、ページのスクロールを殺す)。
|
|
824
|
+
// `VirtualScrollHandle.applyWheel` は毎回 ref を辿るため凍結しない。両者の
|
|
825
|
+
// 非対称は `WheelBridgeTarget` が両方を等価に扱う設計と噛み合わない。
|
|
826
|
+
const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier } = wheelPolicyRef.current
|
|
827
|
+
const axes = resolveWheelAxes(event, currentViewportSize)
|
|
828
|
+
|
|
829
|
+
// ❗ ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
|
|
830
|
+
// 解決器はデルタを潰さないため、この判定を外すと ctrl+shift+ホイールを横スクロールとして食う
|
|
831
|
+
if (axes.isZoomGesture) {
|
|
832
|
+
return false
|
|
833
|
+
}
|
|
741
834
|
|
|
742
|
-
if (isHorizontalDominant) {
|
|
835
|
+
if (axes.isHorizontalDominant) {
|
|
743
836
|
// 横委譲コールバックが指定されている場合は横スクロールとして処理
|
|
744
837
|
if (onWheelHorizontalRef.current) {
|
|
838
|
+
markWheelConsumed(event)
|
|
745
839
|
event.preventDefault()
|
|
746
840
|
stopInertia()
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
deltaX *= 16 // DOM_DELTA_LINE
|
|
750
|
-
} else if (event.deltaMode === 2) {
|
|
751
|
-
deltaX *= viewportSize // DOM_DELTA_PAGE
|
|
752
|
-
}
|
|
753
|
-
onWheelHorizontalRef.current(deltaX)
|
|
841
|
+
onWheelHorizontalRef.current(axes.horizontalDelta)
|
|
842
|
+
return true
|
|
754
843
|
}
|
|
755
844
|
// ハンドラ未指定時であっても、横優勢スクロールや Shift+ホイール は
|
|
756
845
|
// 縦スクロールとして横取りせず、親要素のネイティブ横スクロールへ委ねる
|
|
757
|
-
return
|
|
846
|
+
return false
|
|
758
847
|
}
|
|
759
848
|
|
|
760
|
-
if (!
|
|
761
|
-
return
|
|
849
|
+
if (!currentIsScrollable) {
|
|
850
|
+
return false
|
|
762
851
|
}
|
|
763
852
|
|
|
764
853
|
// 縦方向の移動が無いホイール(水平スワイプ等)は祖先のスクロールへ委ねる
|
|
765
854
|
// A wheel event without vertical delta (e.g. horizontal swipe) is left to ancestor scrolling.
|
|
766
|
-
|
|
767
|
-
|
|
855
|
+
//
|
|
856
|
+
// ❗ 判定は **px 換算後** の値で行う。`deltaMode: DOM_DELTA_PAGE` かつ帯が 0 のとき
|
|
857
|
+
// (自己計測の初回コミット等) 換算後は 0 になり、適用しても 1px も動かない。ここで
|
|
858
|
+
// `event.deltaY` (生値) を見ると preventDefault だけして何も起きず、**祖先のスクロールも
|
|
859
|
+
// 奪う**死角になる。「適用できる量が無いなら消費しない」に揃える。
|
|
860
|
+
if (axes.verticalDelta === 0) {
|
|
861
|
+
return false
|
|
768
862
|
}
|
|
769
863
|
|
|
864
|
+
markWheelConsumed(event)
|
|
770
865
|
event.preventDefault()
|
|
771
|
-
|
|
772
866
|
stopInertia()
|
|
773
867
|
|
|
774
|
-
let deltaY =
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (event.deltaMode === 1) {
|
|
778
|
-
// DOM_DELTA_LINE: 行単位のスクロール
|
|
779
|
-
const lineHeight = 16 // 1行のおおよその高さ (ピクセル)
|
|
780
|
-
deltaY *= lineHeight
|
|
781
|
-
} else if (event.deltaMode === 2) {
|
|
782
|
-
// DOM_DELTA_PAGE: ページ単位のスクロール
|
|
783
|
-
deltaY *= viewportSize // ビューポートの高さを基準にスクロール
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
if (wheelSpeedMultiplier !== 1) {
|
|
787
|
-
deltaY *= wheelSpeedMultiplier
|
|
868
|
+
let deltaY = axes.verticalDelta
|
|
869
|
+
if (currentWheelSpeedMultiplier !== 1) {
|
|
870
|
+
deltaY *= currentWheelSpeedMultiplier
|
|
788
871
|
}
|
|
789
872
|
|
|
790
873
|
// ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
|
|
791
|
-
Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
|
|
874
|
+
Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier: currentWheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
|
|
792
875
|
|
|
793
876
|
// スクロール位置を更新
|
|
794
877
|
scrollTo((prev) => prev + deltaY)
|
|
878
|
+
return true
|
|
879
|
+
},
|
|
880
|
+
[scrollTo, stopInertia],
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
// ホイールイベントのハンドラ (適用規則そのものは applyWheel が持つ)
|
|
885
|
+
//
|
|
886
|
+
// ❗ ここに `defaultPrevented` のガードを置いてはならない。ページ全体のスクロールロック
|
|
887
|
+
// (`document` に capture + preventDefault を張る定番の実装) を掛けただけで一覧が
|
|
888
|
+
// **まったくスクロールできなくなる** (実測: ガード無しではスクロールできる)。
|
|
889
|
+
// 二重適用の防止は `applyWheel` 内の「自分が消費した印」が担う (ペインの入れ子・
|
|
890
|
+
// 祖先の橋渡しのいずれも、印を見て 2 回目を弾く)。
|
|
891
|
+
const handleWheel = (event: WheelEvent) => {
|
|
892
|
+
applyWheel(event)
|
|
795
893
|
}
|
|
796
894
|
|
|
797
895
|
/**
|
|
@@ -825,20 +923,21 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
825
923
|
scrollContainer.removeEventListener("pointerdown", handlePaneInteraction, { capture: true })
|
|
826
924
|
}
|
|
827
925
|
}
|
|
828
|
-
}, [
|
|
926
|
+
}, [applyWheel, stopInertia])
|
|
829
927
|
|
|
830
928
|
useImperativeHandle(
|
|
831
929
|
ref,
|
|
832
930
|
() => ({
|
|
833
|
-
scrollTo: (pos) => {
|
|
931
|
+
scrollTo: (pos, dimsOverride) => {
|
|
834
932
|
stopInertia()
|
|
835
|
-
return scrollTo(pos)
|
|
933
|
+
return scrollTo(pos, dimsOverride)
|
|
836
934
|
},
|
|
837
935
|
getScrollPosition: () => scrollPositionRef.current,
|
|
838
936
|
getContentSize: () => contentSize,
|
|
839
937
|
getViewportSize: () => viewportSize,
|
|
938
|
+
applyWheel,
|
|
840
939
|
}),
|
|
841
|
-
[scrollTo, contentSize, viewportSize, stopInertia],
|
|
940
|
+
[scrollTo, contentSize, viewportSize, stopInertia, applyWheel],
|
|
842
941
|
)
|
|
843
942
|
|
|
844
943
|
const id = useId()
|