@aiquants/virtualscroll 2.4.0 → 2.5.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -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.
@@ -236,6 +238,16 @@ export type ScrollPaneHandle = {
236
238
  getScrollPosition: () => number
237
239
  getContentSize: () => number
238
240
  getViewportSize: () => number
241
+ /**
242
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
243
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
244
+ *
245
+ * ペインの**外**に置いた要素 (列ヘッダー帯など) のホイールを、ペイン内と寸分違わぬ意味論で
246
+ * 流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横委譲先・慣性停止のすべてが
247
+ * ペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
248
+ * 直接使うより `useWheelBridge` (passive:false 登録と後始末込み) を推奨する。
249
+ */
250
+ applyWheel: (event: WheelEvent) => boolean
239
251
  }
240
252
 
241
253
  /**
@@ -465,6 +477,10 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
465
477
  // contentSize と viewportSize を ref として保持
466
478
  // const sizeRef = useRef({ contentSize, viewportSize })
467
479
 
480
+ // ❗ render 本体で代入する。layout effect へ移すと、サイズ変更と同じコミット内で走る
481
+ // 他の layout effect からの scrollTo が 1 コミット古い寸法でクランプされ、タップスクロールの
482
+ // 走破が短くなる (実機 E2E の等速性テストがサンプル数不足で落ちる形で退行が出る)。
483
+ // 既知の代償は下の wheelPolicyRef のコメントを参照。
468
484
  const sizeRef = useRef({ contentSize, viewportSize })
469
485
  sizeRef.current = { contentSize, viewportSize }
470
486
 
@@ -725,73 +741,132 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
725
741
  }
726
742
  }, [isScrollable, scrollTo, contentSize, viewportSize])
727
743
 
728
- useEffect(() => {
729
- // ホイールイベントのハンドラ
730
- const handleWheel = (event: WheelEvent) => {
731
- // ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする
732
- if (event.ctrlKey) {
733
- return
734
- }
744
+ // ホイール適用の方針値。保持されたハンドル越しの呼び出しでも凍結させないため ref で持つ。
745
+ //
746
+ // **代入のタイミングは `sizeRef` と必ず揃える。** 片方だけを layout effect にすると、
747
+ // 中断されたトランジション中に `applyWheel` は「スクロール可能」と判断して消費する一方、
748
+ // `scrollTo` は 1 コミット古い寸法でクランプして 0 を返す、という最悪の組み合わせになる
749
+ // (実測: 5,000 行の一覧が 1 ノッチで先頭へ飛び、以後 0 に貼り付く)。
750
+ //
751
+ // ❗ **既知の限界 (未修正)**: render 本体での ref 書き込みは、中断されたトランジションで
752
+ // **コミットされなかった値**を残す。その間のホイールは画面に出ていない寸法で判断されるため、
753
+ // 「スクロール可能な一覧なのにホイールが効かない」状態が保留中だけ起きる。両 ref を
754
+ // layout effect へ移すのが筋だが、それだけではタップスクロールの走破が 1 コミット遅れて
755
+ // 短くなる退行が出る (実機 E2E で実証済み)。恒久対応にはクランプ経路の見直しが必要。
756
+ const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
757
+ wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
735
758
 
736
- // Shiftキー押下による軸変換(deltaX=0, deltaY!=0) または 通常のdeltaX/deltaYから横優勢判定
737
- const usesShiftAxis = event.shiftKey && event.deltaX === 0
738
- const horizontalDelta = usesShiftAxis ? event.deltaY : event.deltaX
739
- const verticalDelta = usesShiftAxis ? 0 : event.deltaY
740
- const isHorizontalDominant = horizontalDelta !== 0 && Math.abs(horizontalDelta) >= Math.abs(verticalDelta)
759
+ /**
760
+ * Applies one wheel event with the pane's own rules and reports whether it was consumed.
761
+ *
762
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す処理。
763
+ *
764
+ * ❗ **ホイールの意味論はここが単一の真実である。** ペイン根に張るリスナーも、ペインの外の要素へ
765
+ * 同じ意味論を与える `useWheelBridge` も、この 1 つの関数を通す。軸分解 (`resolveWheelAxes`) だけを
766
+ * 共有して残り (速度倍率・スクロール可否・横委譲先・慣性停止) を各所に書くと、同じグリッドなのに
767
+ * 「ヘッダー帯の上だけスクロールが遅い」といった食い違いが静かに生まれる (橋渡し側にも設定口を
768
+ * 置いた実装途中の版で実測: 倍率 3 のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
769
+ *
770
+ * 判定順序そのものが仕様:
771
+ *
772
+ * 0. 本パッケージが既に適用済みの印があれば何もしない (ペインの入れ子・祖先の橋渡しでの二重適用防止)
773
+ * 1. ctrl+wheel はブラウザのズームなので横取りせず素通しする
774
+ * 2. 横優勢 (shift+ホイールを含む) は `onWheelHorizontal` へ委譲。未指定なら消費せず祖先へ委ねる
775
+ * 3. スクロール不能なら消費しない
776
+ * 4. px 換算後の縦成分が 0 なら消費しない (適用できる量が無いのに祖先のスクロールを奪わない)
777
+ * 5. 消費済みの印 + `preventDefault()` + 慣性停止 + 速度倍率を掛けて相対スクロール
778
+ *
779
+ * @param event - The wheel event to apply / 適用するホイールイベント
780
+ * @returns True when this pane applied the event / このペインが適用した場合に true。
781
+ * ❗ 適用時は `preventDefault()` も呼ぶが、`cancelable` でないイベント
782
+ * (合成イベントの既定) では preventDefault は効かないため、返値は
783
+ * 「`defaultPrevented` になったか」ではなく「**適用したか**」を意味する
784
+ */
785
+ const applyWheel = useCallback(
786
+ (event: WheelEvent): boolean => {
787
+ // ❗ **このペイン群が既に消費したイベントは二度と適用しない。** 冪等性の規則を
788
+ // 適用シームの内側に置くことで、どの呼び出し元 (ペイン根のリスナー / useWheelBridge /
789
+ // 消費側の直接呼び出し) からも同じ保証が効く。
790
+ //
791
+ // ❗ `event.defaultPrevented` で代用してはならない。あれは「誰かが止めた」であって
792
+ // 「我々が消費した」ではない。ページ全体のスクロールロック (`document` に capture +
793
+ // preventDefault を張る定番の実装) と区別できず、ロック中に一覧が
794
+ // **まったくスクロールできなくなる** (実測)。
795
+ if (wasWheelConsumed(event)) {
796
+ return false
797
+ }
798
+ // ❗ 値は ref から読む。lexical に閉じ込めると、消費側が保持したハンドルが
799
+ // **その時点の方針を凍結**する (実測: 一覧が絞り込みでスクロール不能になった後も
800
+ // 保持済みハンドルは preventDefault し続け、ページのスクロールを殺す)。
801
+ // `VirtualScrollHandle.applyWheel` は毎回 ref を辿るため凍結しない。両者の
802
+ // 非対称は `WheelBridgeTarget` が両方を等価に扱う設計と噛み合わない。
803
+ const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier } = wheelPolicyRef.current
804
+ const axes = resolveWheelAxes(event, currentViewportSize)
805
+
806
+ // ❗ ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
807
+ // 解決器はデルタを潰さないため、この判定を外すと ctrl+shift+ホイールを横スクロールとして食う
808
+ if (axes.isZoomGesture) {
809
+ return false
810
+ }
741
811
 
742
- if (isHorizontalDominant) {
812
+ if (axes.isHorizontalDominant) {
743
813
  // 横委譲コールバックが指定されている場合は横スクロールとして処理
744
814
  if (onWheelHorizontalRef.current) {
815
+ markWheelConsumed(event)
745
816
  event.preventDefault()
746
817
  stopInertia()
747
- let deltaX = horizontalDelta
748
- if (event.deltaMode === 1) {
749
- deltaX *= 16 // DOM_DELTA_LINE
750
- } else if (event.deltaMode === 2) {
751
- deltaX *= viewportSize // DOM_DELTA_PAGE
752
- }
753
- onWheelHorizontalRef.current(deltaX)
818
+ onWheelHorizontalRef.current(axes.horizontalDelta)
819
+ return true
754
820
  }
755
821
  // ハンドラ未指定時であっても、横優勢スクロールや Shift+ホイール は
756
822
  // 縦スクロールとして横取りせず、親要素のネイティブ横スクロールへ委ねる
757
- return
823
+ return false
758
824
  }
759
825
 
760
- if (!isScrollable) {
761
- return
826
+ if (!currentIsScrollable) {
827
+ return false
762
828
  }
763
829
 
764
830
  // 縦方向の移動が無いホイール(水平スワイプ等)は祖先のスクロールへ委ねる
765
831
  // A wheel event without vertical delta (e.g. horizontal swipe) is left to ancestor scrolling.
766
- if (event.deltaY === 0) {
767
- return
832
+ //
833
+ // ❗ 判定は **px 換算後** の値で行う。`deltaMode: DOM_DELTA_PAGE` かつ帯が 0 のとき
834
+ // (自己計測の初回コミット等) 換算後は 0 になり、適用しても 1px も動かない。ここで
835
+ // `event.deltaY` (生値) を見ると preventDefault だけして何も起きず、**祖先のスクロールも
836
+ // 奪う**死角になる。「適用できる量が無いなら消費しない」に揃える。
837
+ if (axes.verticalDelta === 0) {
838
+ return false
768
839
  }
769
840
 
841
+ markWheelConsumed(event)
770
842
  event.preventDefault()
771
-
772
843
  stopInertia()
773
844
 
774
- let deltaY = event.deltaY
775
-
776
- // deltaMode に応じてスクロール量を調整
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
845
+ let deltaY = axes.verticalDelta
846
+ if (currentWheelSpeedMultiplier !== 1) {
847
+ deltaY *= currentWheelSpeedMultiplier
788
848
  }
789
849
 
790
850
  // ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
791
- Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
851
+ Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier: currentWheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
792
852
 
793
853
  // スクロール位置を更新
794
854
  scrollTo((prev) => prev + deltaY)
855
+ return true
856
+ },
857
+ [scrollTo, stopInertia],
858
+ )
859
+
860
+ useEffect(() => {
861
+ // ホイールイベントのハンドラ (適用規則そのものは applyWheel が持つ)
862
+ //
863
+ // ❗ ここに `defaultPrevented` のガードを置いてはならない。ページ全体のスクロールロック
864
+ // (`document` に capture + preventDefault を張る定番の実装) を掛けただけで一覧が
865
+ // **まったくスクロールできなくなる** (実測: ガード無しではスクロールできる)。
866
+ // 二重適用の防止は `applyWheel` 内の「自分が消費した印」が担う (ペインの入れ子・
867
+ // 祖先の橋渡しのいずれも、印を見て 2 回目を弾く)。
868
+ const handleWheel = (event: WheelEvent) => {
869
+ applyWheel(event)
795
870
  }
796
871
 
797
872
  /**
@@ -825,7 +900,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
825
900
  scrollContainer.removeEventListener("pointerdown", handlePaneInteraction, { capture: true })
826
901
  }
827
902
  }
828
- }, [isScrollable, scrollTo, stopInertia, viewportSize, wheelSpeedMultiplier])
903
+ }, [applyWheel, stopInertia])
829
904
 
830
905
  useImperativeHandle(
831
906
  ref,
@@ -837,8 +912,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
837
912
  getScrollPosition: () => scrollPositionRef.current,
838
913
  getContentSize: () => contentSize,
839
914
  getViewportSize: () => viewportSize,
915
+ applyWheel,
840
916
  }),
841
- [scrollTo, contentSize, viewportSize, stopInertia],
917
+ [scrollTo, contentSize, viewportSize, stopInertia, applyWheel],
842
918
  )
843
919
 
844
920
  const id = useId()