@aiquants/virtualscroll 1.22.0 → 1.22.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "1.22.0",
3
+ "version": "1.22.1",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
package/src/ScrollBar.tsx CHANGED
@@ -1410,6 +1410,13 @@ export const ScrollBar = ({
1410
1410
  * pointerup を待たずにドラッグ状態を破棄し、stale な pointerId の残留を防ぐ。
1411
1411
  */
1412
1412
  const handleLostPointerCaptureOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
1413
+ // ❗ 発生源がトラック自身のときだけ反応する。`lostpointercapture` はバブリングするため、
1414
+ // 子孫であるつまみやオーバーレイのキャプチャ遷移まで拾ってしまう。タッチ・ペンでは仕様
1415
+ // (暗黙のポインタキャプチャ) により着地点の子孫が先にキャプチャを持つので、この取り違えは
1416
+ // 実際に起こり得る。トラックのドラッグ状態を壊してよいのはトラック自身の喪失だけ。
1417
+ if (event.target !== event.currentTarget) {
1418
+ return
1419
+ }
1413
1420
  if (trackDragStateRef.current.pointerId !== event.pointerId) {
1414
1421
  return
1415
1422
  }
@@ -129,6 +129,21 @@ const isPointerDragAllowed = (pointerType: string, allowed: readonly ("mouse" |
129
129
  return !known || allowed.includes(pointerType)
130
130
  }
131
131
 
132
+ /**
133
+ * Reports whether a pointer type can emit move events while no button is pressed.
134
+ * ボタン非押下のまま移動イベントを出し得るポインタ種別かどうかの判定処理。
135
+ *
136
+ * ❗ **タッチを含めてはならない。** 接地しているタッチポインタは仕様上つねに
137
+ * `buttons === 1` であり、ホバーが存在しないため接地終了後は二度とイベントを出さない。
138
+ * つまりタッチで `buttons === 0` が観測されるのはエンジン側の不具合に限られ
139
+ * (旧 Firefox の既知バグ等)、それを「pointerup の喪失」と解釈してドラッグを畳むと、
140
+ * エンジンの不具合がそのままスクロール不能として現れる。
141
+ *
142
+ * @param pointerType The event's pointer type. イベントのポインタ種別。
143
+ * @returns True when button-released moves are meaningful for the type. 非押下の移動が意味を持つ種別なら true。
144
+ */
145
+ const isHoverCapablePointer = (pointerType: string): boolean => pointerType === "mouse" || pointerType === "pen"
146
+
132
147
  const DEFAULT_INERTIA_OPTIONS: ResolvedScrollPaneInertiaOptions = {
133
148
  maxVelocity: 6,
134
149
  minVelocity: 0.02,
@@ -825,6 +840,43 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
825
840
  pushVelocitySample(event.clientY)
826
841
  }
827
842
 
843
+ /**
844
+ * Reports whether a terminal pointer event is authoritative enough to end this drag.
845
+ * 終端ポインタイベントがこのペインのドラッグを終わらせる権威を持つかどうかの判定処理。
846
+ *
847
+ * `true` を返すのは次のどちらかのときだけである。
848
+ *
849
+ * 1. ユーザーエージェントが発火したイベント (`isTrusted === true`)。
850
+ * 本物の中断はジェスチャの終了そのものなので、`target` が何であれ必ず従う。
851
+ * 2. このペイン自身を `target` とするイベント。宛先が明示されているため、
852
+ * 利用側やテストが意図してこのペインへ直接投げたものと解釈できる。
853
+ *
854
+ * 逆に `false` を返す — つまり従わない — のは「**`isTrusted` でなく、かつ
855
+ * このペイン以外を `target` とするイベント**」である。子孫・兄弟・`window` の
856
+ * いずれを `target` としていても区別なく従わない。生きた `pointerId` を載せた
857
+ * `pointercancel` を子孫へ `dispatchEvent` する実装 (長押しメニュー等) は
858
+ * 珍しくなく、それは `window` までバブルして届くため、真に受けると
859
+ * **健全なドラッグが指を離すまで死ぬ** (実ブラウザで再現確認済み)。
860
+ *
861
+ * ❗ **キャプチャの保持状況で判定してはならない。** `hasPointerCapture()` は
862
+ * `setPointerCapture()` 直後の**保留中**のキャプチャに対しても `true` を返す。
863
+ * ユーザーエージェントが `gotpointercapture` の処理前にポインタを取り消すと、
864
+ * 本物の `pointercancel` も後続の `lostpointercapture` も**旧保持者である子孫宛**の
865
+ * まま届くため、キャプチャ保持を条件にすると後始末の経路が 1 本残らず塞がり、
866
+ * `shouldCancelNextClick` が residue として残ってペイン内の次のクリックを 1 回
867
+ * 握り潰す。これは**キャプチャ保持を条件にしていた旧実装で実測した事象**であり、
868
+ * `isTrusted` を基準にする現在の実装ではそもそも成立しない (この取り違えを
869
+ * 構造的に起こさないため)。
870
+ *
871
+ * ❗ この判定は**終端イベント (`pointerup` / `pointercancel`) 専用**である。
872
+ * `pointermove` に適用してはならない。キャプチャ取得前の本物の `pointermove` は
873
+ * ヒット先の子孫を `target` として届くため、絞ると追従そのものが止まる。
874
+ *
875
+ * @param event The terminal pointer event to classify. 判定対象の終端ポインタイベント。
876
+ * @returns True when the event may end this pane's drag. ドラッグを終わらせてよい場合に true。
877
+ */
878
+ const isAuthoritativeForDrag = (event: PointerEvent) => event.isTrusted || event.target === element
879
+
828
880
  // pointerup を受け取れないままドラッグ継続が不可能になったときの共通終了処理。
829
881
  // Shared teardown for cases where the drag can no longer continue without receiving a pointerup.
830
882
  const abortDrag = (pointerId: number) => {
@@ -844,7 +896,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
844
896
  }
845
897
  // ボタン非押下の pointermove は pointerup の喪失 (iframe 上での解放・Alt+Tab 等) を意味する。
846
898
  // stale な dragState を破棄し、hover 移動がカーソル追従スクロール化するのを根絶する。
847
- if (event.buttons === 0) {
899
+ // ホバーできる種別に限定する。接地中のタッチが 0 を報告するのはエンジンの不具合だけで、
900
+ // 従うとその不具合がドラッグの即死になる (判定理由は isHoverCapablePointer を参照)。
901
+ if (event.buttons === 0 && isHoverCapablePointer(event.pointerType)) {
848
902
  abortDrag(event.pointerId)
849
903
  return
850
904
  }
@@ -906,6 +960,14 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
906
960
  armOrphanedClickSuppression()
907
961
  return
908
962
  }
963
+ // ❗ ここには `isAuthoritativeForDrag` を掛けていない。掛けないのは意図的である。
964
+ // `pointerup` は「ジェスチャが正常に終わった」の意味しか持たず、合成されても
965
+ // 状態が壊れるのではなく 1 回のドラッグが早く終わるだけで、次の pointerdown から
966
+ // 通常どおり再開できる。`pointercancel` のように**ドラッグ枠を残したまま
967
+ // 追従だけ止める**という壊れ方をしないため、絞る必要が無い。
968
+ // 一方で絞ると、window を target として `pointerup` を投げる利用側・テストの
969
+ // ジェスチャが終了できなくなる (本物の `pointerup` の target は要素であって
970
+ // window ではないが、合成側はそこまで再現しないのが普通)。
909
971
 
910
972
  if (state.isDragging && state.shouldCancelNextClick && event.cancelable) {
911
973
  event.preventDefault()
@@ -958,11 +1020,39 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
958
1020
  }
959
1021
 
960
1022
  const handlePointerDown = (event: PointerEvent) => {
961
- // アクティブなドラッグ中 (pointerId 保持中) の別ポインタの pointerdown は無視する (先勝ち)。
962
- // 上書きを許すと 1 本目のドラッグが乗っ取られ、shouldCancelNextClick のクリアで
963
- // 1 本目解放時の click 抑止も失われるため。
964
1023
  if (dragStateRef.current.pointerId !== null) {
965
- return
1024
+ // アクティブなドラッグ中の 2 本目以降 (非プライマリ) の pointerdown は無視する (先勝ち)。
1025
+ // 上書きを許すと 1 本目のドラッグが乗っ取られ、shouldCancelNextClick のクリアで
1026
+ // 1 本目解放時の click 抑止も失われるため。
1027
+ if (!event.isPrimary) {
1028
+ return
1029
+ }
1030
+ // 別種別のポインタは、記録中のドラッグがまだ生きている可能性がある
1031
+ // (プライマリは種別ごとに 1 つ存在し得るため) ので奪わせない。
1032
+ //
1033
+ // ❗ この制限の代償: **異種のポインタからは残骸を明け渡せない**。
1034
+ // 例えばタッチの残骸が残った状態でマウスに持ち替えても奪還できず、
1035
+ // さらにここで早期 return するため `shouldCancelNextClick` の残骸も消えず、
1036
+ // マウスの次のクリックが 1 回握り潰される。それでも、生きているドラッグを
1037
+ // 誤って奪う方が実害が大きいと判断した。残骸は同種のポインタで明け渡せる。
1038
+ if (event.pointerType !== dragStateRef.current.pointerType) {
1039
+ return
1040
+ }
1041
+ // **同種のプライマリ**が新たに接地したなら、前のポインタは既に終了しているとみなす。
1042
+ // Pointer Events Level 3 は「その種別にアクティブなポインタが他に無いときに
1043
+ // プライマリになる」と定めており、規範的な MUST ではないが主要 UA はこう振る舞う
1044
+ // (Chromium で実測確認済み)。なおマウスで 2 回目の pointerdown が来ない本当の根拠は
1045
+ // chorded button の遷移規則 (押されているボタンが 0 個から 1 個以上になったときだけ
1046
+ // pointerdown を発火する) であって、プライマリの規則ではない。
1047
+ //
1048
+ // dragState が残っているのは終端イベント (pointerup / pointercancel) を
1049
+ // 取りこぼしたということなので、残骸を捨てて新しいジェスチャへ明け渡す。
1050
+ // ここを無条件の先勝ちにすると、タッチでは復帰手段が一切無くなる:
1051
+ // タッチはホバーが無いため接地終了後にイベントを出さず、`buttons === 0` による
1052
+ // 自己修復は届かない。pointerId は仕様上 UA が再利用してよい (MAY) ため、
1053
+ // 番号の一致に依存した復帰も当てにできない。結果としてペインは
1054
+ // アンマウントされるまで永久に操作不能のまま固まる。
1055
+ abortDrag(dragStateRef.current.pointerId)
966
1056
  }
967
1057
  // 進行中ドラッグが無いのに抑止が残っているなら、click が来なかった前ジェスチャの残骸。
968
1058
  // 新しいジェスチャへ持ち越すと無関係なクリックを飲み込むのでここで捨てる
@@ -1008,10 +1098,40 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1008
1098
  armOrphanedClickSuppression()
1009
1099
  return
1010
1100
  }
1101
+ // アプリ層が投げた合成 pointercancel には従わない (判定理由は isAuthoritativeForDrag を参照)
1102
+ if (!isAuthoritativeForDrag(event)) {
1103
+ return
1104
+ }
1011
1105
  abortDrag(event.pointerId)
1012
1106
  }
1013
1107
 
1108
+ /**
1109
+ * Ends the drag when this pane itself loses the pointer capture it holds.
1110
+ * このペイン自身が保持しているポインタキャプチャを失ったときにドラッグを終了する処理。
1111
+ *
1112
+ * ❗ **発生源 (`event.target`) が自分自身のときだけ反応すること。**
1113
+ * `lostpointercapture` はバブリングする。そしてタッチ・ペンでは仕様
1114
+ * (Pointer Events Level 3 の暗黙のポインタキャプチャ) により、`pointerdown` の
1115
+ * 着地点が先にキャプチャを持つ。仮想リストで指が着地するのは通常
1116
+ * **このコンテンツ要素の子孫である行**である (`contentInsets` のパディング帯や
1117
+ * 空リストではこの要素自身が着地点になることもあるが、その場合は付け替えが
1118
+ * 起きないだけで無害)。`startDragging` がドラッグ確定時に祖先であるこの要素へ
1119
+ * キャプチャを**付け替える**と、旧保持者である子孫に `lostpointercapture` が
1120
+ * 発火し、**同じ `pointerId` を載せたまま**ここまでバブルしてくる。
1121
+ *
1122
+ * `pointerId` だけで判定すると、自分が起こした正常な付け替えを
1123
+ * 「OS によるキャプチャ強制剥奪」と誤認し、**開始した直後のドラッグを自分で殺す**。
1124
+ * 実機のスマートフォンでは「6px の閾値を跨いだ 1 フレームだけスクロールして、
1125
+ * あとは指を離すまで完全に無反応」という症状になる (実測で確認)。
1126
+ * マウスには暗黙のポインタキャプチャが無く付け替えが発生しないため、
1127
+ * 同じコードでもデスクトップだけは無傷で通る。これが
1128
+ * 「PC では動くのにスマートフォンだけ壊れる」の正体だった。
1129
+ */
1014
1130
  const handleLostPointerCapture = (event: PointerEvent) => {
1131
+ // 子孫から上がってきた通知は自分のキャプチャ喪失ではない (暗黙キャプチャの付け替え)
1132
+ if (event.target !== element) {
1133
+ return
1134
+ }
1015
1135
  const state = dragStateRef.current
1016
1136
  if (state.pointerId !== event.pointerId) {
1017
1137
  return
@@ -1097,6 +1217,15 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1097
1217
  // 短いリスト上のタッチがページ全体のパンを妨げるデッドゾーン化を防ぐ。
1098
1218
  // 直接操作 (指・ペン) のドラッグを許可していない場合も付けない。
1099
1219
  // touch-action はペン入力にも効くため、判定は touch と pen の論理和にする
1220
+ //
1221
+ // ❗ この値はタッチ開始時にラッチされる (実測: 1ms 後に none へ変えても手遅れ)。
1222
+ // 中身が非同期に届くリストでは、「まだスクロール不能」な一瞬に指が着地すると
1223
+ // **そのジェスチャ 1 回分**はブラウザに接収されたまま戻らない。
1224
+ // 指を離して置き直せば次のジェスチャからは正常に効くため、損失は 1 ジェスチャに
1225
+ // 限られ恒久破損にはならない (実測で確認)。ドラッグ許可の判定 (isScrollable) と
1226
+ // 同一条件で揃えてあるため状態としても一貫している。
1227
+ // 非同期コンテンツを先読みして確保する挙動は意図的に入れていない
1228
+ // (空のペインがページのパンを殺すデッドゾーンになり、そちらの実害の方が大きいため)。
1100
1229
  ...(enablePointerDrag && isScrollable && pointerDragInputs.some((type) => type === "touch" || type === "pen") ? { touchAction: "none" } : {}),
1101
1230
  }}
1102
1231
  id={id}>
@@ -298,6 +298,16 @@ export const TapScrollCircle = memo(
298
298
 
299
299
  const handleLostPointerCapture = useCallback(
300
300
  (event: ReactPointerEvent<HTMLDivElement>) => {
301
+ // ❗ 発生源が自分自身のときだけ反応する。`lostpointercapture` はバブリングし、
302
+ // タッチ・ペンでは仕様 (Pointer Events Level 3 の暗黙のポインタキャプチャ) により
303
+ // `pointerdown` の着地点 — サークルの中身を描く子要素 — が先にキャプチャを持つ。
304
+ // `handlePointerDown` がルートへキャプチャを付け替えると、旧保持者である子孫に
305
+ // `lostpointercapture` が発火し、同じ `pointerId` を載せたままここへバブルしてくる。
306
+ // `pointerId` だけで判定すると、自分が起こした正常な付け替えを強制剥奪と誤認して
307
+ // **触れた瞬間にタップスクロールが死ぬ** (マウスは暗黙キャプチャが無いため無傷)。
308
+ if (event.target !== event.currentTarget) {
309
+ return
310
+ }
301
311
  if (pointerIdRef.current !== event.pointerId) {
302
312
  return
303
313
  }