@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
package/src/VirtualScroll.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
|
1
|
+
import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
2
2
|
import { Logger } from "./logger.ts"
|
|
3
3
|
import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
|
|
4
4
|
import { useFenwickMapTree } from "./useFenwickMapTree.ts"
|
|
@@ -40,11 +40,54 @@ export type VirtualScrollRange = {
|
|
|
40
40
|
*/
|
|
41
41
|
export type VirtualScrollHandle = {
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Jumps to a LOGICAL position (updater form receives the current logical position).
|
|
44
44
|
* Returns the applied (clamped) LOGICAL position.
|
|
45
|
-
*
|
|
45
|
+
* 論理位置へ**ジャンプ**する (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
|
|
46
|
+
*
|
|
47
|
+
* ❗ **連続的なスクロール入力 (ホイール等) の橋渡しには使わないこと。`scrollBy` を使う。**
|
|
48
|
+
* これは「行を狙って飛ぶ」ための API であり、ペイン自身のホイール処理とは意味が 2 点異なる:
|
|
49
|
+
*
|
|
50
|
+
* 1. **絶対位置を `Math.floor` する** (行解決のため)。デルタを足して渡すと 1px 未満の移動が
|
|
51
|
+
* 毎回消えるため、精密トラックパッドでは**一切動かない**。
|
|
52
|
+
* 2. **スクロールアンカーを張る** (`scrollToIndex` 経由)。狙った行に留まり続けるための仕様だが、
|
|
53
|
+
* 以後 `contentSize` / `itemCount` / `viewportSize` / インセットが変わるたびにドリフト補正が
|
|
54
|
+
* その行へ**再ピン留め**するため、絞り込みなどで一覧が過去の位置へ戻って見える。
|
|
55
|
+
*
|
|
56
|
+
* ❗ `scrollTo(handle.getScrollPosition() + delta)` と書かないこと。ペイン未接続時の
|
|
57
|
+
* `getScrollPosition()` は番兵 `-1` を返すため `-1 + delta` が混入する。相対移動は `scrollBy`
|
|
58
|
+
* (推奨) か updater 形式 `scrollTo(prev => prev + delta)` を使う (updater の `prev` は内部の
|
|
59
|
+
* 最新位置から取るため番兵が混入しない)。
|
|
46
60
|
*/
|
|
47
61
|
scrollTo: (position: number | ((prev: number) => number)) => number
|
|
62
|
+
/**
|
|
63
|
+
* Scrolls by a delta using the exact semantics of the pane's own wheel handling.
|
|
64
|
+
* ペイン自身のホイール処理と**同一の意味**でデルタ分スクロールする。
|
|
65
|
+
*
|
|
66
|
+
* ペインの外に置いた要素 (列ヘッダー帯・固定フッター・横スクロールバー行など) の上での
|
|
67
|
+
* ホイールを一覧へ橋渡しするための API。ホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、
|
|
68
|
+
* ペイン外はホイールの死角になる。この口はその死角を埋める。
|
|
69
|
+
*
|
|
70
|
+
* `scrollTo` との違い (どちらもペイン自身のホイールと同じ扱いにするための選択):
|
|
71
|
+
*
|
|
72
|
+
* - **float のまま相対加算する** (丸めない)。1px 未満のデルタも積み上がって動く。
|
|
73
|
+
* - **アンカーを張らない**。手動スクロールとして扱われるため、保留アンカーはむしろ**解除される**
|
|
74
|
+
* (ペイン上でホイールを回したときと同じ)。以後のサイズ変化で位置が巻き戻ることがない。
|
|
75
|
+
* - 慣性は停止する (ペイン上のホイールと同じ)。
|
|
76
|
+
*
|
|
77
|
+
* @param delta - Signed pixels to scroll (positive scrolls down) / 符号付きの移動量 (正で下方向)
|
|
78
|
+
* @returns The applied (clamped) LOGICAL position / 適用後 (クランプ後) の論理位置
|
|
79
|
+
*/
|
|
80
|
+
scrollBy: (delta: number) => number
|
|
81
|
+
/**
|
|
82
|
+
* Applies one wheel event with the pane's own rules; returns whether it was consumed.
|
|
83
|
+
* ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
|
|
84
|
+
*
|
|
85
|
+
* 一覧の**外**に置いた要素 (列ヘッダー帯・固定フッター・空表示など) のホイールを、ペイン内と
|
|
86
|
+
* 寸分違わぬ意味論で流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横成分の委譲先・
|
|
87
|
+
* 慣性停止のすべてがペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
|
|
88
|
+
* 直接呼ぶより `useWheelBridge` (passive:false 登録と後始末込み) を使うこと。
|
|
89
|
+
*/
|
|
90
|
+
applyWheel: (event: WheelEvent) => boolean
|
|
48
91
|
/**
|
|
49
92
|
* Current LOGICAL scroll position. -1 when the pane is not connected.
|
|
50
93
|
* 現在の論理スクロール位置。ペイン未接続時は -1。
|
|
@@ -194,8 +237,71 @@ export type VirtualScrollProps<T> = {
|
|
|
194
237
|
onItemFocus?: (index: number) => void
|
|
195
238
|
scrollBarOptions?: VirtualScrollScrollBarOptions
|
|
196
239
|
behaviorOptions?: VirtualScrollBehaviorOptions
|
|
197
|
-
/** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。
|
|
240
|
+
/** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。
|
|
241
|
+
*
|
|
242
|
+
* ❗ **ホイール由来だけの口ではない。** `horizontalKeyInputs` を指定すると**キーボード由来の横量**も
|
|
243
|
+
* ここへ流れる。名前は 2.x で公開済みのため据え置くが、意味は「横軸が N px 動いた。横軸はあなたの所有物である」。
|
|
244
|
+
*/
|
|
198
245
|
onWheelHorizontal?: (deltaX: number) => void
|
|
246
|
+
/**
|
|
247
|
+
* Keyboard gestures that emit a horizontal scroll delta through `onWheelHorizontal` (default: none).
|
|
248
|
+
* `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
|
|
249
|
+
*
|
|
250
|
+
* - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
|
|
251
|
+
* - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
|
|
252
|
+
* 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
|
|
253
|
+
* - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
|
|
254
|
+
* - `["arrow", "shift-arrow"]`: 両方。
|
|
255
|
+
*
|
|
256
|
+
* ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
|
|
257
|
+
* 段階的な文字列にすると `"arrows"` が `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
|
|
258
|
+
* `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
|
|
259
|
+
* 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
|
|
260
|
+
*
|
|
261
|
+
* ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
|
|
262
|
+
* capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
|
|
263
|
+
* ツリーの展開/折りたたみのような既存操作を奪ってしまう。消費するのは `preventDefault()` のみで
|
|
264
|
+
* **伝播は止めない** (縦の矢印キーと同じ契約)。`stopPropagation()` は行ハンドラだけでなく
|
|
265
|
+
* `document` / `window` の bubble リスナーごとイベントを消し、ホットキーライブラリや
|
|
266
|
+
* キー入力のテレメトリまで巻き添えにするため採らない。消費側が二重動作を避ける手段は
|
|
267
|
+
* `defaultPrevented` の確認である。
|
|
268
|
+
*
|
|
269
|
+
* 前提: 横軸を所有するのは消費側なので、`onWheelHorizontal` が未指定なら何も起きない
|
|
270
|
+
* (キーイベントも消費しない)。行にフォーカスがあるときだけ働くため
|
|
271
|
+
* `behaviorOptions.enableKeyboardNavigation` も必要。
|
|
272
|
+
*
|
|
273
|
+
* ❗ **符号は物理キー基準** (`→` が正、`←` が負) である。横軸の向きを知っているのは消費側だけなので、
|
|
274
|
+
* RTL (`direction: rtl`) の一覧では消費側が受け取った値を反転すること。パッケージ側で
|
|
275
|
+
* `direction` を推測すると、横スクロールの実体 (CSS 変数・`scrollLeft`・transform) がどの要素の
|
|
276
|
+
* どの座標系かを知らないまま符号を決めることになり、当たらない前提を増やすだけになる。
|
|
277
|
+
*
|
|
278
|
+
* ❗ **働くのは行そのものにフォーカスがあるときだけである** (`event.target === event.currentTarget`)。
|
|
279
|
+
* 行の**中**の要素 (自作のタブ・ラジオ相当・スライダー相当など `role` だけで矢印キー操作を実装した
|
|
280
|
+
* ウィジェット、リンク、ネイティブの横スクロール領域) にフォーカスがある間は奪わない。
|
|
281
|
+
* 入力要素の allowlist だけでは `role` 実装のウィジェットを守れないため、対象そのもので判定する。
|
|
282
|
+
*
|
|
283
|
+
* 長押しの連続スクロールは **OS のキーリピート**に委ねる (`keydown` が繰り返し届く)。スクロールバーの
|
|
284
|
+
* 矢印ボタンが明示的なリピートタイマーを持つ (`ARROW_HOLD_DELAY` / `ARROW_HOLD_INTERVAL`) のは
|
|
285
|
+
* ポインタ押下にリピートが存在しないためで、キーボードとの非対称は入力機構の差に由来する。
|
|
286
|
+
*
|
|
287
|
+
* ❗ **`onWheelHorizontal` と同じ「横軸のシーム」に属するため、意図的にトップレベルに置いてある。**
|
|
288
|
+
* `behaviorOptions` の中へ入れると、横軸を自前で所有するラッパー (例: `@aiquants/directory-tree` は
|
|
289
|
+
* `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
|
|
290
|
+
* そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
|
|
291
|
+
*/
|
|
292
|
+
horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[]
|
|
293
|
+
/**
|
|
294
|
+
* Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
|
|
295
|
+
* 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
|
|
296
|
+
*
|
|
297
|
+
* ❗ **有限かつ正の値のみを受け付ける。** `0` / 負値 / `NaN` / `Infinity` を渡すと
|
|
298
|
+
* **キーを消費しない** (既定値へ黙って読み替えることはしない)。0 を既定へ差し替えると
|
|
299
|
+
* 呼び出し側の明示的な指定を握り潰し、0 のまま通すと「キーを食うのに 1px も動かない」
|
|
300
|
+
* 死んだ操作になる。
|
|
301
|
+
* 警告は `keydown` ではなく **prop の変化時に 1 回だけ**出す (キーリピート中に
|
|
302
|
+
* コンソールが溢れるため)。
|
|
303
|
+
*/
|
|
304
|
+
horizontalKeyStep?: number
|
|
199
305
|
/**
|
|
200
306
|
* ARIA / identity attributes applied to the scrollable CONTENT element — the element that
|
|
201
307
|
* directly owns the rendered rows and excludes the scrollbar and overlay chrome.
|
|
@@ -272,6 +378,39 @@ const MAX_RENDERED_ITEMS = 2000
|
|
|
272
378
|
*/
|
|
273
379
|
const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
|
|
274
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Pixels emitted per horizontal arrow key press. Matches the ~40px browsers scroll for an arrow key.
|
|
383
|
+
*
|
|
384
|
+
* 横矢印キー 1 回あたりの移動量。ブラウザが矢印キーでスクロールする約 40px に合わせた既定値。
|
|
385
|
+
*/
|
|
386
|
+
const DEFAULT_HORIZONTAL_KEY_STEP = 40
|
|
387
|
+
|
|
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
|
+
|
|
275
414
|
/**
|
|
276
415
|
* Converts a numeric size into a non-negative bigint for large collection handling.
|
|
277
416
|
*
|
|
@@ -767,6 +906,8 @@ const VirtualScrollInner = <T,>(
|
|
|
767
906
|
scrollBarOptions,
|
|
768
907
|
behaviorOptions,
|
|
769
908
|
onWheelHorizontal,
|
|
909
|
+
horizontalKeyInputs,
|
|
910
|
+
horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
|
|
770
911
|
contentProps,
|
|
771
912
|
}: VirtualScrollProps<T>,
|
|
772
913
|
ref: React.Ref<VirtualScrollHandle>,
|
|
@@ -901,6 +1042,32 @@ const VirtualScrollInner = <T,>(
|
|
|
901
1042
|
const previousTopInsetRef = useRef(resolvedInsets.top)
|
|
902
1043
|
const onScrollRef = useRef<OnScrollCallback | undefined>(onScroll ?? undefined)
|
|
903
1044
|
const onRangeChangeRef = useRef<OnRangeChangeCallback | undefined>(onRangeChange ?? undefined)
|
|
1045
|
+
// 横委譲コールバックを ref 経由で読む。行キーハンドラ (handleItemKeyDown) は全行の React.memo が
|
|
1046
|
+
// 依存する安定参照でなければならず、コールバックの差し替えで identity を変えられないため。
|
|
1047
|
+
const onWheelHorizontalRef = useRef<((deltaX: number) => void) | undefined>(onWheelHorizontal)
|
|
1048
|
+
|
|
1049
|
+
// ❗ 恒久的に機能しない組み合わせは黙って無視しない。1 回の無効なキーが警告を出すのに
|
|
1050
|
+
// 「機能まるごと死んでいる」が無言なのは筋が通らない (README のリストボックス指南は
|
|
1051
|
+
// enableKeyboardNavigation: false を勧めるため、実際に踏まれる)
|
|
1052
|
+
const hasHorizontalKeyInputs = (horizontalKeyInputs?.length ?? 0) > 0
|
|
1053
|
+
const hasOnWheelHorizontal = Boolean(onWheelHorizontal)
|
|
1054
|
+
useEffect(() => {
|
|
1055
|
+
if (!hasHorizontalKeyInputs) {
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
if (!enableKeyboardNavigation) {
|
|
1059
|
+
Logger.warn("[VirtualScroll] horizontalKeyInputs is set but behaviorOptions.enableKeyboardNavigation is false; horizontal keyboard scrolling is disabled because the row key handler is not mounted.")
|
|
1060
|
+
}
|
|
1061
|
+
if (!hasOnWheelHorizontal) {
|
|
1062
|
+
Logger.warn("[VirtualScroll] horizontalKeyInputs is set but onWheelHorizontal is missing; horizontal keyboard scrolling is disabled because the package does not own the horizontal axis.")
|
|
1063
|
+
}
|
|
1064
|
+
// ❗ 移動量の検証もここで行う。keydown 側で警告するとキーリピート中に毎フレーム出力される
|
|
1065
|
+
// (Logger の既定水準は WARN なので本番でも出る)
|
|
1066
|
+
if (!(Number.isFinite(horizontalKeyStep) && horizontalKeyStep > 0)) {
|
|
1067
|
+
Logger.warn(`[VirtualScroll] horizontalKeyStep must be a finite positive number, received ${horizontalKeyStep}. Horizontal arrow keys are left untouched.`)
|
|
1068
|
+
}
|
|
1069
|
+
}, [hasHorizontalKeyInputs, hasOnWheelHorizontal, enableKeyboardNavigation, horizontalKeyStep])
|
|
1070
|
+
|
|
904
1071
|
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
905
1072
|
const pendingFocusIndexRef = useRef<number | null>(null)
|
|
906
1073
|
const lastFocusedIndexRef = useRef<number | null>(null)
|
|
@@ -921,12 +1088,15 @@ const VirtualScrollInner = <T,>(
|
|
|
921
1088
|
isResizingRef.current = true
|
|
922
1089
|
}
|
|
923
1090
|
|
|
924
|
-
useEffect
|
|
1091
|
+
// ❗ useEffect ではなく useLayoutEffect である。差し替え直後の keydown / wheel が
|
|
1092
|
+
// **1 フレーム古いコールバック**を読む窓を消すため (通常の effect はペイントを跨いで遅延しうる)
|
|
1093
|
+
useLayoutEffect(() => {
|
|
925
1094
|
// 目的: 外部から渡されたコールバックの参照を最新状態に保つ。
|
|
926
|
-
// 依存関係: onRangeChange, onScroll
|
|
1095
|
+
// 依存関係: onRangeChange, onScroll, onWheelHorizontal
|
|
927
1096
|
onScrollRef.current = onScroll ?? undefined
|
|
928
1097
|
onRangeChangeRef.current = onRangeChange ?? undefined
|
|
929
|
-
|
|
1098
|
+
onWheelHorizontalRef.current = onWheelHorizontal
|
|
1099
|
+
}, [onRangeChange, onScroll, onWheelHorizontal])
|
|
930
1100
|
|
|
931
1101
|
const tryFocusElement = useCallback(
|
|
932
1102
|
(element: HTMLElement | null) => {
|
|
@@ -1135,7 +1305,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1135
1305
|
* 乖離した場合 (同一同期バッチ内の旧 contentSize によるクランプ) は、新 contentSize
|
|
1136
1306
|
* コミット後の補正再発行を予約。
|
|
1137
1307
|
*/
|
|
1138
|
-
const issueCompensationScroll = useCallback((targetPosition: number): number => {
|
|
1308
|
+
const issueCompensationScroll = useCallback((targetPosition: number, appliedDelta = 0): number => {
|
|
1139
1309
|
const pane = scrollPaneRef.current
|
|
1140
1310
|
if (!pane) {
|
|
1141
1311
|
return targetPosition
|
|
@@ -1143,7 +1313,20 @@ const VirtualScrollInner = <T,>(
|
|
|
1143
1313
|
// クランプで位置が変化しない場合 onScroll は発火しない。事前位置と比較して増分を戻すために保持する
|
|
1144
1314
|
const beforePosition = pane.getScrollPosition()
|
|
1145
1315
|
isCompensatingRef.current += 1
|
|
1146
|
-
|
|
1316
|
+
// ❗ クランプは **「committed 寸法 + 今適用した delta」**で行う (dimsOverride)。この補正は木を
|
|
1317
|
+
// 同期更新した**直後・commit 前**に走るため、ペインの committed sizeRef は必ず 1 commit 古く、
|
|
1318
|
+
// 既定のままだと旧最大値でクランプされ二段補正 (§8.6) の再発行に頼ることになる。
|
|
1319
|
+
//
|
|
1320
|
+
// ❗ `fenwickTree.getTotal()` ではなく committed + delta を使う。木は**破棄された WIP render
|
|
1321
|
+
// にも変異される共有物** (保留トランジション中は未 commit の行数の合計を返すことを
|
|
1322
|
+
// transitionClamp.spec.tsx の前提アサートで実証済み) であり、木合計でのクランプは
|
|
1323
|
+
// fenwick の並行安全設計の内部タイミングに依存する。committed + delta は定義から
|
|
1324
|
+
// 「この補正が保とうとしている committed 空間 + 今適用した変化」そのものであり、
|
|
1325
|
+
// WIP 変異のタイミングと無関係に正しい (通常時は木合計と同値で、一段目がそのまま
|
|
1326
|
+
// 目標へ届き、二段補正はバックストップとして休眠する)
|
|
1327
|
+
const freshContentSize = pane.getContentSize() + appliedDelta
|
|
1328
|
+
const freshViewportSize = pane.getViewportSize()
|
|
1329
|
+
const appliedPosition = pane.scrollTo(targetPosition, { contentSize: freshContentSize, viewportSize: freshViewportSize })
|
|
1147
1330
|
if (appliedPosition === beforePosition) {
|
|
1148
1331
|
// onScroll 不発: 補正カウンタの増分を巻き戻し、次の手動スクロールが補正扱いされるのを防ぐ
|
|
1149
1332
|
isCompensatingRef.current = Math.max(0, isCompensatingRef.current - 1)
|
|
@@ -1337,7 +1520,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1337
1520
|
// 同一同期バッチ内では ScrollPane の sizeRef が旧 contentSize のままのため、scrollTo は
|
|
1338
1521
|
// 旧最大値でクランプされ得る。issueCompensationScroll がカウンタのリークを防ぎつつ乖離時の
|
|
1339
1522
|
// 再発行を予約し、論理位置 (latestScrollPositionRef) には補正目標を保持して二段目の収束先とする。
|
|
1340
|
-
issueCompensationScroll(newPosition)
|
|
1523
|
+
issueCompensationScroll(newPosition, delta)
|
|
1341
1524
|
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
1342
1525
|
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
|
|
1343
1526
|
}
|
|
@@ -1518,6 +1701,43 @@ const VirtualScrollInner = <T,>(
|
|
|
1518
1701
|
[resolvedInsets.top, scrollTo, updateScrollPositionImmediate],
|
|
1519
1702
|
)
|
|
1520
1703
|
|
|
1704
|
+
/**
|
|
1705
|
+
* Applies a delta through the pane's own wheel seam (float accumulation, no anchor).
|
|
1706
|
+
*
|
|
1707
|
+
* ペイン自身のホイールと同じ口 (float 相対加算・アンカー不変) でデルタを適用する処理。
|
|
1708
|
+
*
|
|
1709
|
+
* ❗ **`scrollTo` を経由してはならない。** あちらは行解決のため絶対位置を `Math.floor` し、
|
|
1710
|
+
* `scrollToIndex` 経由で保留アンカーを張る。デルタの橋渡しに使うと (1) 1px 未満の移動が毎回
|
|
1711
|
+
* 消えて精密トラックパッドで動かず、(2) 以後のサイズ変化のたびにドリフト補正が過去の行へ
|
|
1712
|
+
* 再ピン留めして一覧が勝手に戻る。ここでは `ScrollPaneHandle.scrollTo` の updater 形式へ
|
|
1713
|
+
* そのまま流し、ペイン内のホイール (`scrollTo(prev => prev + deltaY)`) と**同一の経路**を通す。
|
|
1714
|
+
*
|
|
1715
|
+
* 位置の反映は `onScroll` → `handleScroll` が行う (ペイン上のホイールと完全に同じ)。ここで
|
|
1716
|
+
* 追加の `updateScrollPositionImmediate` を呼ばないのは、経路を 1 本に保つためである。
|
|
1717
|
+
* 非有限デルタのガードとクランプは `ScrollPane.scrollTo` が単一の防波堤として担う。
|
|
1718
|
+
*/
|
|
1719
|
+
const scrollBy = useCallback((delta: number): number => {
|
|
1720
|
+
const pane = scrollPaneRef.current
|
|
1721
|
+
if (!pane) {
|
|
1722
|
+
// ペイン未接続時は動かしようがない。番兵 (-1) は返さず現在の論理位置をそのまま返す
|
|
1723
|
+
return toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsetsTopRef.current)
|
|
1724
|
+
}
|
|
1725
|
+
const appliedPanePosition = pane.scrollTo((previous) => previous + delta)
|
|
1726
|
+
return toLogicalPositionWithInset(appliedPanePosition, resolvedInsetsTopRef.current)
|
|
1727
|
+
}, [])
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Applies one wheel event with the pane's own rules; returns whether it was consumed.
|
|
1731
|
+
* ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す処理。
|
|
1732
|
+
*
|
|
1733
|
+
* ペイン (`ScrollPane`) が持つ 1 つの適用関数へそのまま委譲する。ここで規則を書き直さないことが
|
|
1734
|
+
* 「ペイン内と帯の上で挙動が食い違わない」ことの根拠である。
|
|
1735
|
+
*
|
|
1736
|
+
* @param event - The wheel event to apply / 適用するホイールイベント
|
|
1737
|
+
* @returns True when the event was consumed / 消費した場合に true
|
|
1738
|
+
*/
|
|
1739
|
+
const applyWheel = useCallback((event: WheelEvent): boolean => scrollPaneRef.current?.applyWheel(event) ?? false, [])
|
|
1740
|
+
|
|
1521
1741
|
/**
|
|
1522
1742
|
* Handles scroll events from the pane.
|
|
1523
1743
|
*
|
|
@@ -1539,7 +1759,18 @@ const VirtualScrollInner = <T,>(
|
|
|
1539
1759
|
|
|
1540
1760
|
// If manual scroll (neither programmatic nor compensating), clear the pending anchor
|
|
1541
1761
|
// This ensures that manual scrolling immediately detaches from any previous scroll target
|
|
1542
|
-
|
|
1762
|
+
// ❗ isResizingRef は render 本体で立つ (in-commit の §9 クランプ連鎖が読むため必須) が、
|
|
1763
|
+
// 中断されたトランジションの render でも立ってしまい、commit が無いので降りない。
|
|
1764
|
+
// このクロージャの itemCount は **commit を通ってしか届かない** (破棄 render のクロージャは
|
|
1765
|
+
// committed ツリーに残らない) ため、ref と閉じ込め値の照合で「committed な resize か」を
|
|
1766
|
+
// 判別できる。保留中 (ref=新 / クロージャ=旧) は不一致 → 手動スクロール扱いでアンカーが
|
|
1767
|
+
// 正しく外れる。resize の commit 内連鎖 (ref=新 / クロージャ=新) は一致 → 従来どおり。
|
|
1768
|
+
// ❗ 純粋な同数往復 (500→300 保留→500 bailout) は照合が正しく効く (bailout は ref の
|
|
1769
|
+
// 再前進も防ぐため不一致になる)。残存する盲点は「戻りに別 state 変更が同伴して一覧が
|
|
1770
|
+
// 再 render される」形のみ (ref が committed と同値へ前進し、同伴 commit ではリセット
|
|
1771
|
+
// 経路が走らない)。仕様書 §8.4 の既知の盲点を参照
|
|
1772
|
+
const isCommittedResize = isResizingRef.current && prevItemCountRef.current === itemCount
|
|
1773
|
+
if (!(isProgrammatic || isCompensating || isCommittedResize)) {
|
|
1543
1774
|
pendingVisibleStartIndexRef.current = null
|
|
1544
1775
|
}
|
|
1545
1776
|
|
|
@@ -1568,7 +1799,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1568
1799
|
}
|
|
1569
1800
|
}
|
|
1570
1801
|
},
|
|
1571
|
-
[updateScrollPositionImmediate, enableScrollToTopBottomButtons],
|
|
1802
|
+
[updateScrollPositionImmediate, enableScrollToTopBottomButtons, itemCount],
|
|
1572
1803
|
)
|
|
1573
1804
|
|
|
1574
1805
|
// レンダリング範囲を計算
|
|
@@ -1653,15 +1884,50 @@ const VirtualScrollInner = <T,>(
|
|
|
1653
1884
|
if (event.altKey || event.metaKey || event.ctrlKey) {
|
|
1654
1885
|
return
|
|
1655
1886
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1887
|
+
// ❗ **行そのものにフォーカスがあるときだけ働く。** ハンドラは capture フェーズに付くため、
|
|
1888
|
+
// 行の中の要素にフォーカスがある状態で消費すると、その要素へ keydown が
|
|
1889
|
+
// **どちらのフェーズでも一切届かなくなる** (実ブラウザで実測)。入力要素の allowlist だけでは
|
|
1890
|
+
// `role` で実装したスライダー相当・タブ相当や、ネイティブの縦横スクロール領域を守れない。
|
|
1891
|
+
//
|
|
1892
|
+
// ❗ 縦横の**両方**に適用する。横だけに掛けると、同じウィジェットが `←→` は受け取れるのに
|
|
1893
|
+
// `↑↓` は奪われたうえフォーカスまで隣の行へ飛ばされる、という一貫性の無い状態になる。
|
|
1894
|
+
if (event.target !== event.currentTarget) {
|
|
1895
|
+
return
|
|
1896
|
+
}
|
|
1897
|
+
// ❗ 行ラッパー自身が編集可能な文脈にある場合はキーを奪わない。上の判定で
|
|
1898
|
+
// `target === currentTarget` (常に本パッケージの `div`) が確定しているため、
|
|
1899
|
+
// `INPUT` / `TEXTAREA` / `SELECT` のタグ判定はここでは**到達不能**であり撤去した
|
|
1900
|
+
// (それらは行の中の要素であり、上の判定で既に見送られる)。
|
|
1901
|
+
// 一方 `isContentEditable` は**祖先からの継承**で true になりうるため残す
|
|
1902
|
+
// (消費側が一覧を編集可能な領域で囲むケース)。
|
|
1903
|
+
if (event.currentTarget.isContentEditable) {
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
1907
|
+
// 横軸を所有するのは消費側なので、委譲先が無ければキーイベントを消費しない
|
|
1908
|
+
// (ツリーの展開/折りたたみなど行側の操作をそのまま通す)。
|
|
1909
|
+
const emitHorizontal = onWheelHorizontalRef.current
|
|
1910
|
+
if (!emitHorizontal) {
|
|
1660
1911
|
return
|
|
1661
1912
|
}
|
|
1662
|
-
|
|
1913
|
+
// 押されたジェスチャが許可種別に含まれるかを確認する
|
|
1914
|
+
const gesture = event.shiftKey ? "shift-arrow" : "arrow"
|
|
1915
|
+
if (!horizontalKeyInputs?.includes(gesture)) {
|
|
1663
1916
|
return
|
|
1664
1917
|
}
|
|
1918
|
+
// ❗ テキスト選択中の Shift+←/→ は選択範囲の伸縮であり、横スクロールで奪ってはならない
|
|
1919
|
+
// (実ブラウザで実測: 奪うと行内のテキストを選び直せなくなる)
|
|
1920
|
+
if (event.shiftKey && hasTextSelectionWithin(event.currentTarget)) {
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
// ❗ 不正な移動量は既定へ黙って読み替えず、消費もしない (Strict No-Fallback)。
|
|
1924
|
+
// 警告は上の effect が prop 変化のたびに 1 回だけ出す (キーリピートで溢れさせない)
|
|
1925
|
+
if (!(Number.isFinite(horizontalKeyStep) && horizontalKeyStep > 0)) {
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
event.preventDefault()
|
|
1929
|
+
emitHorizontal(event.key === "ArrowLeft" ? -horizontalKeyStep : horizontalKeyStep)
|
|
1930
|
+
return
|
|
1665
1931
|
}
|
|
1666
1932
|
if (event.key === "ArrowDown") {
|
|
1667
1933
|
if (index < itemCount - 1) {
|
|
@@ -1702,7 +1968,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1702
1968
|
}
|
|
1703
1969
|
}
|
|
1704
1970
|
},
|
|
1705
|
-
[enableKeyboardNavigation, itemCount, focusItemAtIndex],
|
|
1971
|
+
[enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
|
|
1706
1972
|
)
|
|
1707
1973
|
|
|
1708
1974
|
const handleItemFocus = useCallback(
|
|
@@ -1947,7 +2213,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1947
2213
|
const newPosition = panePosition + shiftAmount
|
|
1948
2214
|
// updateItemSize と同型: scrollTo は旧 contentSize でクランプされ得るため、
|
|
1949
2215
|
// issueCompensationScroll でリークを防ぎつつ、乖離時は contentSize 同期 effect で再発行する。
|
|
1950
|
-
issueCompensationScroll(newPosition)
|
|
2216
|
+
issueCompensationScroll(newPosition, shiftAmount)
|
|
1951
2217
|
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
1952
2218
|
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (auto update)", { from: panePosition, to: newPosition, shiftAmount })
|
|
1953
2219
|
} else if (panePosition !== latestScrollPositionRef.current) {
|
|
@@ -2072,6 +2338,8 @@ const VirtualScrollInner = <T,>(
|
|
|
2072
2338
|
getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
|
|
2073
2339
|
getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
|
|
2074
2340
|
scrollTo: scrollToHandle,
|
|
2341
|
+
scrollBy,
|
|
2342
|
+
applyWheel,
|
|
2075
2343
|
scrollToIndex,
|
|
2076
2344
|
getFenwickTreeTotalHeight: () => fenwickTree.getTotal(),
|
|
2077
2345
|
getFenwickSize: () => fenwickTree.getSize(),
|
|
@@ -2080,7 +2348,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2080
2348
|
getScrollAnchor,
|
|
2081
2349
|
updateItemSize,
|
|
2082
2350
|
}),
|
|
2083
|
-
[scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
|
|
2351
|
+
[scrollToHandle, scrollBy, applyWheel, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
|
|
2084
2352
|
)
|
|
2085
2353
|
|
|
2086
2354
|
const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { tapScrollCircleSampleVisual } from "./tapScrollCircleSampleVisual.tsx"
|
|
|
12
12
|
export { FenwickMapTree, useFenwickMapTree } from "./useFenwickMapTree.ts"
|
|
13
13
|
export { useHeightCache } from "./useHeightCache.ts"
|
|
14
14
|
export { useLruCache } from "./useLruCache.ts"
|
|
15
|
+
export { useWheelBridge, type WheelBridgeOptions, type WheelBridgeTarget } from "./useWheelBridge.ts"
|
|
15
16
|
export { minmax } from "./utils.ts"
|
|
16
17
|
export {
|
|
17
18
|
VirtualScroll,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module useWheelBridge
|
|
3
|
+
* @description Gives elements placed OUTSIDE the scroll pane the pane's own wheel semantics.
|
|
4
|
+
*
|
|
5
|
+
* @description スクロールペインの**外**に置いた要素へ、ペイン自身のホイール意味論を与えるフック。
|
|
6
|
+
*
|
|
7
|
+
* ペインのホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、その外に置いた要素
|
|
8
|
+
* (列ヘッダー帯・固定フッター・横スクロールバー行・空表示など) はホイールの死角になる。明細表では
|
|
9
|
+
* 列ヘッダーを仮想スクロールの外に固定するのが定石なので、この死角は必ず生じる。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type RefCallback, type RefObject, useCallback, useLayoutEffect, useRef } from "react"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Anything that can apply a wheel event with the pane's own rules.
|
|
16
|
+
* ペイン自身の規則でホイールイベントを適用できるもの。
|
|
17
|
+
*
|
|
18
|
+
* `VirtualScrollHandle` と `ScrollPaneHandle` の両方が構造的にこれを満たすため、どちらのハンドルでも
|
|
19
|
+
* 同じフックが使える (`ScrollPane` を直接使う消費側が橋渡しを自作せずに済む)。
|
|
20
|
+
*/
|
|
21
|
+
export type WheelBridgeTarget = {
|
|
22
|
+
applyWheel: (event: WheelEvent) => boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for {@link useWheelBridge}.
|
|
27
|
+
* {@link useWheelBridge} のオプション。
|
|
28
|
+
*/
|
|
29
|
+
export type WheelBridgeOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* Set to `false` to detach the bridge without unmounting the element.
|
|
32
|
+
* 要素をアンマウントせずに橋渡しを止めたいとき `false` にする。既定 `true`。
|
|
33
|
+
*/
|
|
34
|
+
enableBridge?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bridges wheel events from an element outside the pane into the list, with the pane's own semantics.
|
|
39
|
+
* ペイン外の要素のホイールを、ペイン自身と同じ意味論で一覧へ橋渡しするフック。
|
|
40
|
+
*
|
|
41
|
+
* 返り値を対象要素の `ref` に渡すだけでよい。
|
|
42
|
+
*
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const listRef = useRef<VirtualScrollHandle>(null)
|
|
45
|
+
* const headerRef = useWheelBridge(listRef)
|
|
46
|
+
*
|
|
47
|
+
* <div ref={headerRef}>列ヘッダー (ペインの外)</div>
|
|
48
|
+
* <VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ... />
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ❗ **ホイールの意味論に影響する設定は一切受け取らないのが意図的な設計である。** 速度倍率・
|
|
52
|
+
* 横成分の委譲先・スクロール可否・慣性停止・軸分解のすべては、ハンドルの向こう側 (`applyWheel`) が
|
|
53
|
+
* 持つ。橋渡し側にも同じ設定口を用意すると同じグリッドなのに「ヘッダー帯の上だけスクロールが遅い」
|
|
54
|
+
* 「帯の上でだけ横に動かない」といった食い違いが静かに生まれる (橋渡し側にも設定口を置いた
|
|
55
|
+
* 実装途中の版で実測: `wheelSpeedMultiplier: 3` のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
|
|
56
|
+
* 唯一のオプション {@link WheelBridgeOptions.enableBridge} は意味論ではなく**橋渡し自体の ON/OFF** である。
|
|
57
|
+
* このフックの責務は **`{ passive: false }` での登録・後始末・その ON/OFF だけ**である。
|
|
58
|
+
*
|
|
59
|
+
* ❗ **`onWheel` プロップでは代用できない。** React 19 は `onWheel` を passive リスナーとして登録するため
|
|
60
|
+
* `preventDefault()` が効かず、ページ全体がスクロールしてしまう。
|
|
61
|
+
*
|
|
62
|
+
* ❗ **`handle.scrollTo` で自前に橋渡ししてはならない。** あれはジャンプ用 API で、絶対位置を丸めるため
|
|
63
|
+
* 1px 未満のデルタが消え、さらにスクロールアンカーを張るためサイズ変化のたびに一覧が過去の位置へ戻る。
|
|
64
|
+
*
|
|
65
|
+
* 自前の ref も必要なら合成してよい (多重登録に対して安全に作ってある)。
|
|
66
|
+
* ❗ **戻り値をそのまま `return` すること。** React 19 はこれをクリーンアップとして実行する。
|
|
67
|
+
* 返さない場合も動作は正しいままだが、`null` 呼び出しでは何も外さない設計のため、リスナーは
|
|
68
|
+
* 要素ごと GC されるまで残る。
|
|
69
|
+
*
|
|
70
|
+
* ```tsx
|
|
71
|
+
* <div ref={(node) => { myRef.current = node; return headerRef(node) }} />
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* @param target - Ref to the list's imperative handle (`VirtualScrollHandle` / `ScrollPaneHandle`) / 一覧の命令ハンドルへの ref
|
|
75
|
+
* @param options - Bridge options / 橋渡しのオプション
|
|
76
|
+
* @returns A ref callback to attach to the source element / 発生元の要素へ渡す ref コールバック
|
|
77
|
+
*/
|
|
78
|
+
export const useWheelBridge = (target: RefObject<WheelBridgeTarget | null>, options?: WheelBridgeOptions): RefCallback<HTMLElement> => {
|
|
79
|
+
// オプションは ref 経由で読む。ref コールバックの identity を安定させ、
|
|
80
|
+
// 設定の差し替えのたびにリスナーを張り直さないため。
|
|
81
|
+
const optionsRef = useRef(options)
|
|
82
|
+
// ❗ 対象ハンドルも ref 越しに読む。`target` を依存に置くと、消費側が別の一覧へ差し替えたときに
|
|
83
|
+
// ref コールバックの identity が変わり、React が `null` → `node` と呼び直す。台帳は要素単位で
|
|
84
|
+
// 「既に張ってある」と判断して**張り直さない**ため、古い listener が古い一覧を掴んだままになる
|
|
85
|
+
// (実測: 差し替え後の一覧が一切動かない)。identity を固定すれば張り直し自体が不要になる。
|
|
86
|
+
const targetRef = useRef(target)
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
optionsRef.current = options
|
|
89
|
+
targetRef.current = target
|
|
90
|
+
}, [options, target])
|
|
91
|
+
|
|
92
|
+
// リスナーを張った要素の台帳。**要素ごと**に持つ。単一スロットにすると、ある要素の
|
|
93
|
+
// ref が呼び直されただけで**別の要素**のリスナーを剥がしてしまう (実測: ヘッダー帯と
|
|
94
|
+
// フッター帯で同じフックを使い回すと、最初の再レンダーで生きている帯が入れ替わる)。
|
|
95
|
+
// WeakMap なので、外し損ねた要素があっても要素ごと GC される。
|
|
96
|
+
const attachedRef = useRef<WeakMap<HTMLElement, (event: WheelEvent) => void>>(new WeakMap())
|
|
97
|
+
|
|
98
|
+
return useCallback((node: HTMLElement | null) => {
|
|
99
|
+
// ❗ null 呼び出しでは何も外さない。どの要素の話か分からないため、ここで外すと
|
|
100
|
+
// 無関係の要素を巻き添えにする。解除は下のクリーンアップ (要素を知っている) が行う。
|
|
101
|
+
if (!node) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// ❗ 既に張ってあれば何もしない。React 19 のクリーンアップを**受け取らない**合成 ref
|
|
105
|
+
// (`ref={(n) => { myRef.current = n; headerRef(n) }}`) では React は毎レンダーで
|
|
106
|
+
// null → node と呼び直すため、冪等でないとリスナーが積み上がる (再レンダー 4 回で 5 本)。
|
|
107
|
+
// ❗ 直ちに N 倍スクロールするわけではない (`applyWheel` 内の「消費済み印」が 2 本目以降を
|
|
108
|
+
// 弾く)。実害はリスナーの増殖そのもの = ホイールのたびに N 個のハンドラが起動する
|
|
109
|
+
// ホットパスの劣化であり、位置の比較では検出できない。だから本数で固定してある。
|
|
110
|
+
if (attachedRef.current.has(node)) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const listener = (event: WheelEvent) => {
|
|
115
|
+
if (optionsRef.current?.enableBridge === false) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
// ❗ 二重適用の防止は `applyWheel` の内側が持つ (「自分が消費した印」を見る)。
|
|
119
|
+
// ここで `defaultPrevented` を見てはならない。ペイン本体はそれを見ないため、
|
|
120
|
+
// ページ全体のスクロールロック中に「ペインは動くのに帯の上だけ死ぬ」という
|
|
121
|
+
// 食い違いが生まれる (実測)。
|
|
122
|
+
targetRef.current.current?.applyWheel(event)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ❗ passive: false でなければ preventDefault が効かない (React の onWheel は passive)
|
|
126
|
+
node.addEventListener("wheel", listener, { passive: false })
|
|
127
|
+
attachedRef.current.set(node, listener)
|
|
128
|
+
|
|
129
|
+
// React 19 はこのクリーンアップを受け取ると `null` 呼び出しの代わりに実行する。
|
|
130
|
+
// ❗ listener の一致判定は**意図的な防御**である。ref コールバックの identity は固定
|
|
131
|
+
// (依存配列が空) なので、要素がマウントされている限り React は張り直さず、現状この分岐が
|
|
132
|
+
// 偽になる経路は無い。依存配列を増やした瞬間に「古いクリーンアップが新しい登録を消す」
|
|
133
|
+
// 事故が起きるため、ブラックボックステストで踏めなくても残す。
|
|
134
|
+
return () => {
|
|
135
|
+
if (attachedRef.current.get(node) === listener) {
|
|
136
|
+
node.removeEventListener("wheel", listener)
|
|
137
|
+
attachedRef.current.delete(node)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}, [])
|
|
141
|
+
}
|