@aiquants/virtualscroll 1.20.0 → 1.22.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +5 -3
  3. package/dist/ScrollBar.d.cts +123 -0
  4. package/dist/ScrollBar.d.ts +123 -0
  5. package/dist/ScrollBar.d.ts.map +1 -1
  6. package/dist/ScrollPane.d.cts +16 -0
  7. package/dist/ScrollPane.d.ts +16 -0
  8. package/dist/ScrollPane.d.ts.map +1 -1
  9. package/dist/VirtualScroll.d.cts +9 -0
  10. package/dist/VirtualScroll.d.ts +9 -0
  11. package/dist/VirtualScroll.d.ts.map +1 -1
  12. package/dist/_headFenwick.d.cts +450 -0
  13. package/dist/_headFenwick.d.ts +451 -0
  14. package/dist/_headFenwick.d.ts.map +1 -0
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1468 -1360
  20. package/dist/styles/virtualscroll.css +1 -1
  21. package/dist/styles/virtualscroll.standalone.css +1 -1
  22. package/dist/tapScrollCircleSampleVisual.d.ts.map +1 -1
  23. package/dist/useFenwickMapTree.d.cts +43 -0
  24. package/dist/useFenwickMapTree.d.ts +43 -0
  25. package/dist/useFenwickMapTree.d.ts.map +1 -1
  26. package/package.json +4 -1
  27. package/src/ScrollBar.tsx +211 -33
  28. package/src/ScrollPane.tsx +232 -19
  29. package/src/VirtualScroll.tsx +11 -1
  30. package/src/index.ts +1 -1
  31. package/src/styles/virtualscroll.css +3 -3
  32. package/src/tapScrollCircleSampleVisual.tsx +22 -5
  33. package/src/useFenwickMapTree.ts +95 -13
  34. package/src/ScrollBar.spec.tsx +0 -620
  35. package/src/ScrollPane.spec.tsx +0 -496
  36. package/src/TapScrollCircle.spec.tsx +0 -275
  37. package/src/VirtualScroll.spec.ts +0 -641
  38. package/src/cli.server.spec.ts +0 -137
  39. package/src/logger.spec.ts +0 -128
  40. package/src/useFenwickMapTree.huge.spec.ts +0 -388
  41. package/src/useFenwickMapTree.spec.ts +0 -1518
  42. package/src/useLruCache.spec.ts +0 -382
  43. package/src/utils.spec.ts +0 -39
@@ -53,6 +53,22 @@ export type ScrollPaneProps = {
53
53
  enableArrowButtons?: boolean
54
54
  /** Whether dragging the content area scrolls the pane. / コンテンツ領域のドラッグでスクロールさせるかどうか。 */
55
55
  enablePointerDrag?: boolean
56
+ /**
57
+ * Pointer types allowed to drag-scroll the content area.
58
+ * コンテンツ領域のドラッグスクロールを許可するポインタ種別。
59
+ *
60
+ * 既定は全種別 (`["mouse", "pen", "touch"]`)。
61
+ *
62
+ * ❗ **`enablePointerDrag` を丸ごと切ってはならない。** このペインはコンテンツを transform で
63
+ * 動かしておりネイティブのスクロール領域を持たない (`.aqvs-scroll-pane-content` は
64
+ * `overflow: hidden`)。ドラッグを無効にするとタッチ端末からスクロール手段が消える。
65
+ * マウスドラッグだけが困る (テキスト選択やクリック判定と競合する) 場合は、
66
+ * ここを `["pen", "touch"]` にして**入力種別ごとに**切ること。
67
+ *
68
+ * `touch-action: none` は `"touch"` が含まれるときだけ付与する。マウス専用に絞った場合に
69
+ * タッチのパンまで奪うと、スクロール手段が無いのに操作も奪う最悪の状態になるため。
70
+ */
71
+ pointerDragInputs?: readonly ("mouse" | "pen" | "touch")[]
56
72
  /** Optional renderer for thumb overlays. / サム付近に表示するオーバーレイのレンダラー。 */
57
73
  renderThumbOverlay?: (props: ScrollBarThumbOverlayRenderProps) => React.ReactNode
58
74
  /** Multiplier applied to wheel delta for faster or slower scrolling. / スクロール速度を調整するためのホイールデルタの倍率。 */
@@ -93,6 +109,26 @@ export type ScrollPaneInertiaOptions = {
93
109
 
94
110
  type ResolvedScrollPaneInertiaOptions = Required<ScrollPaneInertiaOptions>
95
111
 
112
+ /** ドラッグスクロールを許可する既定のポインタ種別 (全種別)。 */
113
+ const DEFAULT_POINTER_DRAG_INPUTS: readonly ("mouse" | "pen" | "touch")[] = ["mouse", "pen", "touch"]
114
+
115
+ /**
116
+ * Reports whether a pointer event may start or continue a content drag.
117
+ * ポインタイベントがコンテンツドラッグを開始・継続してよいかを判定する処理。
118
+ *
119
+ * ❗ **未知の種別は拒否しない。** `PointerEvent.pointerType` は仕様上任意の文字列を取り得
120
+ * (空文字や合成イベントもある)、拒否すると**ネイティブのスクロール領域を持たないペインが
121
+ * 完全に操作不能**になる。既知の 3 種別に一致した場合だけ allow-list を適用する。
122
+ *
123
+ * @param pointerType The event's pointer type. イベントのポインタ種別。
124
+ * @param allowed Allowed pointer types. 許可するポインタ種別。
125
+ * @returns True when the drag may proceed. ドラッグを進めてよい場合に true。
126
+ */
127
+ const isPointerDragAllowed = (pointerType: string, allowed: readonly ("mouse" | "pen" | "touch")[]): boolean => {
128
+ const known = pointerType === "mouse" || pointerType === "pen" || pointerType === "touch"
129
+ return !known || allowed.includes(pointerType)
130
+ }
131
+
96
132
  const DEFAULT_INERTIA_OPTIONS: ResolvedScrollPaneInertiaOptions = {
97
133
  maxVelocity: 6,
98
134
  minVelocity: 0.02,
@@ -109,6 +145,8 @@ type DragSample = {
109
145
  }
110
146
 
111
147
  type DragState = {
148
+ /** 進行中ドラッグを開始したポインタの種別。許可集合の変化を検知するために保持する。 */
149
+ pointerType: string
112
150
  pointerId: number | null
113
151
  startClientY: number
114
152
  startScroll: number
@@ -156,6 +194,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
156
194
  enableTrackClick = true,
157
195
  enableArrowButtons = true,
158
196
  enablePointerDrag = true,
197
+ pointerDragInputs = DEFAULT_POINTER_DRAG_INPUTS,
159
198
  onScroll,
160
199
  className,
161
200
  testId,
@@ -303,6 +342,28 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
303
342
  [onScroll],
304
343
  )
305
344
 
345
+ /**
346
+ * Keeps the latest scroll seam reachable from long-lived loops.
347
+ * 長寿命のループから最新のスクロール口へ届くようにする ref。
348
+ *
349
+ * ❗ 慣性 RAF ループとポインタドラッグはどちらも**張り直されない**。
350
+ * `scrollTo` を字句束縛で捕まえると、`onScroll` prop が差し替わった後も
351
+ * 古いコールバックへ通知し続ける (慣性は指を離してから数秒走る)。
352
+ */
353
+ const scrollToRef = useRef(scrollTo)
354
+
355
+ /**
356
+ * Refreshes the scroll seam reference.
357
+ * スクロール口の参照を最新に保つ副作用。
358
+ *
359
+ * 目的: `onScroll` 差し替え後も進行中のループが最新の口へ通知するようにする。
360
+ * 依存関係: [scrollTo]
361
+ * クリーンアップ: 不要。
362
+ */
363
+ useEffect(() => {
364
+ scrollToRef.current = scrollTo
365
+ }, [scrollTo])
366
+
306
367
  const stopInertia = useCallback(() => {
307
368
  const state = inertiaStateRef.current
308
369
  // 世代を進め、実行中の step (cancelAnimationFrame が効かない同期再入) にも停止を伝える
@@ -383,7 +444,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
383
444
  const previousPosition = scrollPositionRef.current
384
445
 
385
446
  if (distance !== 0) {
386
- scrollTo((prevPositionInternal) => prevPositionInternal + distance)
447
+ // ❗ 字句束縛の `scrollTo` ではなく ref を読む。ループは自身を
448
+ // 再アームし続けるため、束縛すると解放時点の `onScroll` へ数秒間通知し続ける
449
+ scrollToRef.current((prevPositionInternal) => prevPositionInternal + distance)
387
450
  }
388
451
 
389
452
  // scrollTo の onScroll から同期的に stopInertia (handle.scrollTo 等) が呼ばれた場合は
@@ -410,7 +473,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
410
473
 
411
474
  inertiaStateRef.current.frame = requestAnimationFrame(step)
412
475
  },
413
- [isScrollable, resolvedInertiaOptions, scrollTo, stopInertia],
476
+ [isScrollable, resolvedInertiaOptions, stopInertia],
414
477
  )
415
478
 
416
479
  const startInertiaRef = useRef(startInertia)
@@ -535,16 +598,35 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
535
598
  scrollTo((prev) => prev + deltaY)
536
599
  }
537
600
 
601
+ /**
602
+ * Cancels inertia as soon as the user touches anywhere in the pane.
603
+ * ペイン内のどこかに触れた時点で慣性を止めるハンドラー。
604
+ *
605
+ * ❗ スクロールバー (タップスクロールサークルを含む) はコンテンツ領域の**兄弟**であり、
606
+ * コンテンツ側の pointerdown は届かない。慣性走行中にサークルを掴むと
607
+ * **慣性ループと自動スクロールループが同じスクロール位置を奪い合い**、
608
+ * 残差アキュムレータの影響で正味移動量が脈打って引き量と速度が一致しなくなる。
609
+ * ペイン根で capture フェーズに 1 本張り、`enablePointerDrag` のガードの外側で必ず止める。
610
+ *
611
+ * これは「触れた時点で止める」側の防衛線。逆順 (自動スクロール中に慣性が始まる) は
612
+ * ここでは救えないため、`handleScrollBarScroll` が二重に受け持つ。
613
+ */
614
+ const handlePaneInteraction = () => {
615
+ stopInertia()
616
+ }
617
+
538
618
  const scrollContainer = scrollContainerRef.current
539
619
  if (scrollContainer) {
540
620
  // wheel イベントリスナーを passive: false で登録し、preventDefault を可能にする
541
621
  scrollContainer.addEventListener("wheel", handleWheel, { passive: false })
622
+ scrollContainer.addEventListener("pointerdown", handlePaneInteraction, { capture: true, passive: true })
542
623
  }
543
624
 
544
625
  // クリーンアップ関数
545
626
  return () => {
546
627
  if (scrollContainer) {
547
628
  scrollContainer.removeEventListener("wheel", handleWheel)
629
+ scrollContainer.removeEventListener("pointerdown", handlePaneInteraction, { capture: true })
548
630
  }
549
631
  }
550
632
  }, [isScrollable, scrollTo, stopInertia, viewportSize, wheelSpeedMultiplier])
@@ -563,14 +645,10 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
563
645
  [scrollTo, contentSize, viewportSize, stopInertia],
564
646
  )
565
647
 
566
- const scrollToRef = useRef(scrollTo)
567
- useEffect(() => {
568
- scrollToRef.current = scrollTo
569
- }, [scrollTo])
570
-
571
648
  const id = useId()
572
649
 
573
650
  const dragStateRef = useRef<DragState>({
651
+ pointerType: "",
574
652
  pointerId: null,
575
653
  startClientY: 0,
576
654
  startScroll: 0,
@@ -581,10 +659,23 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
581
659
  })
582
660
 
583
661
  const enablePointerDragRef = useRef(enablePointerDrag)
662
+ const pointerDragInputsRef = useRef(pointerDragInputs)
584
663
  useEffect(() => {
585
664
  enablePointerDragRef.current = enablePointerDrag
586
665
  }, [enablePointerDrag])
587
666
 
667
+ /**
668
+ * Keeps the allowed pointer types fresh for the listener closures.
669
+ * リスナークロージャが参照する許可ポインタ種別を最新に保つ副作用。
670
+ *
671
+ * 目的: リスナーは 1 度だけ張るため、設定変更を ref 経由で伝える。
672
+ * 依存関係: [pointerDragInputs]
673
+ * クリーンアップ: 不要。
674
+ */
675
+ useEffect(() => {
676
+ pointerDragInputsRef.current = pointerDragInputs
677
+ }, [pointerDragInputs])
678
+
588
679
  const isScrollableRef = useRef(isScrollable)
589
680
  useEffect(() => {
590
681
  isScrollableRef.current = isScrollable
@@ -595,28 +686,76 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
595
686
  inertiaOptionsRef.current = resolvedInertiaOptions
596
687
  }, [resolvedInertiaOptions])
597
688
 
689
+ /**
690
+ * Tears down an in-flight drag once it is no longer permitted.
691
+ * 進行中のドラッグが許可されなくなったら後始末する副作用。
692
+ *
693
+ * ❗ 判定は `handlePointerMove` が見ている**継続条件と完全に同じ 3 つ**
694
+ * (`enablePointerDrag` / `pointerDragInputs` / `isScrollable`) を見ること。
695
+ * どれか 1 つでも落とすと、その条件が偽になったときに `pointermove` だけが
696
+ * 早期 return して `pointerId` とポインタキャプチャが居座り、
697
+ * (1) 指を置いたまま追従が止まる、(2) 先勝ちガードで新規ドラッグを開始できない、
698
+ * (3) 解放時に `handlePointerUp` が追従しなかった移動分の速度で慣性を起動する、
699
+ * という 3 つの症状が出る。
700
+ * `isScrollable` に至っては 4 つ目の症状もある: `startClientY` / `startScroll` が
701
+ * 基準として残るため、スクロール可能へ戻った瞬間に**取りこぼした移動量が 1 フレームで
702
+ * まとめて適用される**(実測: 190px の空走が 0 → 210 の瞬間移動として再生された)。
703
+ *
704
+ * ❗ **クリック抑止は引き継ぐ。** 既に 6px を超えて `isDragging` が立っていた場合、
705
+ * ブラウザはこのジェスチャの終わりに click を合成する。ここで
706
+ * `shouldCancelNextClick` を落とすと、スクロールしただけなのに指の下の行が
707
+ * 選択・展開される。
708
+ *
709
+ * ❗ **ここで 0ms タイマーを張ってはならない。** このエフェクトが走るのは
710
+ * **指がまだ触れている最中**であり、click が合成されるのは指を離した後 (数百 ms 後の
711
+ * 別マクロタスク) である。`handlePointerUp` の 0ms タイマーは「pointerup → click が
712
+ * 同一タスク列で連続する」から正当なのであって、ここへ流用すると click が届く前に
713
+ * 抑止が解けて**修正が実機で 1 度も効かない**。抑止の解除は
714
+ * (1) `handleClickCapture` が 1 回消費する、(2) ジェスチャ終端の `pointerup` /
715
+ * `pointercancel` が 0ms タイマーを張る、(3) 次の `pointerdown` が捨てる、
716
+ * の 3 経路が受け持つ。
717
+ *
718
+ * ❗ **`pointerId` を null にしてからキャプチャを解放すること。**
719
+ * `releasePointerCapture` が `lostpointercapture` を同期発火するブラウザでは、
720
+ * 順序を誤ると `handleLostPointerCapture` → `abortDrag` が先に走って
721
+ * `shouldCancelNextClick` を落とし、引き継ぎが**この行に到達する前に死ぬ**。
722
+ * `handlePointerUp` が同じ順序を採っているのもこの理由による。
723
+ *
724
+ * 目的: ドラッグ継続条件が偽になった時点で状態とキャプチャを解放する。
725
+ * 依存関係: [enablePointerDrag, pointerDragInputs, isScrollable]
726
+ * クリーンアップ: 不要。
727
+ */
598
728
  useEffect(() => {
599
- if (enablePointerDrag) {
729
+ const state = dragStateRef.current
730
+ // 進行中のドラッグが今も許可されているなら何もしない
731
+ if (enablePointerDrag && isScrollable && (state.pointerId === null || isPointerDragAllowed(state.pointerType, pointerDragInputs))) {
600
732
  return
601
733
  }
602
- const element = contentAreaRef.current
603
- const state = dragStateRef.current
604
- // pointerId=0 (Firefox のマウス) を falsy として弾かないよう !== null で判定する
605
- // Compare with null so pointerId=0 (Firefox mouse) is not skipped as falsy.
606
- if (state.pointerId !== null && element?.hasPointerCapture(state.pointerId)) {
607
- element.releasePointerCapture(state.pointerId)
734
+ if (state.pointerId === null) {
735
+ return
608
736
  }
737
+ const element = contentAreaRef.current
609
738
  if (state.clickResetTimer !== null) {
610
739
  window.clearTimeout(state.clickResetTimer)
611
740
  state.clickResetTimer = null
612
741
  }
742
+ // ドラッグが確定していたなら、このジェスチャ終端で合成される click を握り潰す
743
+ const shouldKeepClickSuppression = state.isDragging && state.shouldCancelNextClick
744
+ // pointerId=0 (Firefox のマウス) を falsy として弾かないよう !== null で判定する
745
+ // Compare with null so pointerId=0 (Firefox mouse) is not skipped as falsy.
746
+ const releasedPointerId = state.pointerId
613
747
  state.pointerId = null
748
+ state.pointerType = ""
614
749
  state.startClientY = 0
615
750
  state.startScroll = 0
616
751
  state.isDragging = false
617
- state.shouldCancelNextClick = false
752
+ state.shouldCancelNextClick = shouldKeepClickSuppression
618
753
  state.velocitySamples = []
619
- }, [enablePointerDrag])
754
+ // 状態を確定させてから解放する (同期 lostpointercapture に引き継ぎを壊させない)
755
+ if (element?.hasPointerCapture(releasedPointerId)) {
756
+ element.releasePointerCapture(releasedPointerId)
757
+ }
758
+ }, [enablePointerDrag, pointerDragInputs, isScrollable])
620
759
 
621
760
  useEffect(() => {
622
761
  const element = contentAreaRef.current
@@ -630,6 +769,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
630
769
  const resetDragState = () => {
631
770
  const state = dragStateRef.current
632
771
  state.pointerId = null
772
+ state.pointerType = ""
633
773
  state.startClientY = 0
634
774
  state.startScroll = 0
635
775
  state.isDragging = false
@@ -711,6 +851,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
711
851
  if (!(enablePointerDragRef.current && isScrollableRef.current)) {
712
852
  return
713
853
  }
854
+ if (!isPointerDragAllowed(event.pointerType, pointerDragInputsRef.current)) {
855
+ return
856
+ }
714
857
  if (!state.isDragging) {
715
858
  const distanceY = Math.abs(event.clientY - state.startClientY)
716
859
  if (distanceY < DRAG_ACTIVATION_THRESHOLD) {
@@ -733,9 +876,34 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
733
876
  }
734
877
  }
735
878
 
879
+ /**
880
+ * Arms the one-shot click suppression left behind by the teardown effect.
881
+ * 後始末エフェクトが残したクリック抑止を、ジェスチャ終端で 1 回限りに落とす処理。
882
+ *
883
+ * 後始末はドラッグ枠を空にするため、以降の `pointerup` / `pointercancel` は
884
+ * pointerId 不一致で素通りしてしまう。ここで拾って `handlePointerUp` と同じ
885
+ * 0ms タイマーを張り、直後に合成される click を 1 回だけ握り潰したうえで解除する。
886
+ *
887
+ * @returns True when the orphaned suppression was handled. 残骸を処理したなら true。
888
+ */
889
+ const armOrphanedClickSuppression = () => {
890
+ const state = dragStateRef.current
891
+ if (state.pointerId !== null || !state.shouldCancelNextClick) {
892
+ return false
893
+ }
894
+ clearClickTimer()
895
+ state.clickResetTimer = window.setTimeout(() => {
896
+ const latest = dragStateRef.current
897
+ latest.shouldCancelNextClick = false
898
+ latest.clickResetTimer = null
899
+ }, 0)
900
+ return true
901
+ }
902
+
736
903
  const handlePointerUp = (event: PointerEvent) => {
737
904
  const state = dragStateRef.current
738
905
  if (state.pointerId !== event.pointerId) {
906
+ armOrphanedClickSuppression()
739
907
  return
740
908
  }
741
909
 
@@ -796,9 +964,19 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
796
964
  if (dragStateRef.current.pointerId !== null) {
797
965
  return
798
966
  }
967
+ // 進行中ドラッグが無いのに抑止が残っているなら、click が来なかった前ジェスチャの残骸。
968
+ // 新しいジェスチャへ持ち越すと無関係なクリックを飲み込むのでここで捨てる
969
+ if (dragStateRef.current.shouldCancelNextClick) {
970
+ clearClickTimer()
971
+ dragStateRef.current.shouldCancelNextClick = false
972
+ }
799
973
  if (!(enablePointerDragRef.current && isScrollableRef.current)) {
800
974
  return
801
975
  }
976
+ // 入力種別ごとの許可。マウスだけ外してもタッチのスクロール手段は残る
977
+ if (!isPointerDragAllowed(event.pointerType, pointerDragInputsRef.current)) {
978
+ return
979
+ }
802
980
  if (event.button !== 0 && event.pointerType === "mouse") {
803
981
  return
804
982
  }
@@ -816,6 +994,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
816
994
  const state = dragStateRef.current
817
995
  clearClickTimer()
818
996
  state.pointerId = event.pointerId
997
+ state.pointerType = event.pointerType
819
998
  state.startClientY = event.clientY
820
999
  state.startScroll = scrollPositionRef.current
821
1000
  state.isDragging = false
@@ -826,6 +1005,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
826
1005
  const handlePointerCancel = (event: PointerEvent) => {
827
1006
  const state = dragStateRef.current
828
1007
  if (state.pointerId !== event.pointerId) {
1008
+ armOrphanedClickSuppression()
829
1009
  return
830
1010
  }
831
1011
  abortDrag(event.pointerId)
@@ -873,18 +1053,51 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
873
1053
  }
874
1054
  }, [id])
875
1055
 
1056
+ /**
1057
+ * Scroll seam handed to the scrollbar, which always cancels inertia first.
1058
+ * スクロールバーへ渡すスクロール口。必ず先に慣性を止める。
1059
+ *
1060
+ * ❗ ペイン根の `pointerdown` で止めるだけでは足りない。あちらが救えるのは
1061
+ * 「慣性が先、スクロールバー操作が後」の順序だけで、**逆順が抜ける**。
1062
+ * サークルを掴んで自動スクロールを始めた後にコンテンツを弾いて慣性を起動すると、
1063
+ * 慣性ループと自動スクロールループが同じスクロール位置を奪い合い、位置が往復する
1064
+ * (実測: 140 → 100 → 30 → 184 と脈打つ)。矢印ボタンのキー操作のように
1065
+ * pointerdown を伴わない経路も同様に抜ける。
1066
+ * スクロールバー由来のスクロールが届いた時点で毎回止めれば、順序と経路に依らず排他になる。
1067
+ *
1068
+ * ❗ **`scrollTo` の戻り値は必ず返すこと。** `ScrollBar` の `resolveScrollRequest` は、
1069
+ * 戻り値が有限数のときだけそれを自分の位置追跡 (`latestScrollPositionRef`) に採用し、
1070
+ * 無ければ**自前の (古い) 基準から次位置を再計算**する。`ScrollPane` はステートレスで
1071
+ * ホイールやドラッグで動いても `ScrollBar` を再描画しないため、その基準は簡単に陳腐化する。
1072
+ * 戻り値を落とすと `ScrollBar` は「実際は末端に居るのに境界と判定できない」状態になり、
1073
+ * タップ自動スクロールのループが止まらなくなる。
1074
+ */
1075
+ const handleScrollBarScroll = useCallback(
1076
+ (position: number | ((prev: number) => number)) => {
1077
+ stopInertia()
1078
+ return scrollTo(position)
1079
+ },
1080
+ [scrollTo, stopInertia],
1081
+ )
1082
+
876
1083
  return (
877
1084
  <div ref={scrollContainerRef} data-testid={testId} className={twMerge("aqvs-scroll-pane", className)} style={style}>
878
1085
  <div
879
1086
  ref={contentAreaRef}
880
1087
  className={twMerge("aqvs-scroll-pane-content")}
1088
+ // 常時出力する固定の DOM フック。DOM フックはクラスセレクタでなく data-* 属性を使う
1089
+ // (AGENTS.md 準拠)。ルート要素は `testId` prop で利用側が名前を決められるが、
1090
+ // コンテンツ領域には prop が無いため、パッケージ側で固定値を出す
1091
+ data-testid="aqvs-scroll-pane-content"
881
1092
  style={{
882
1093
  height: viewportSize,
883
1094
  paddingTop: resolvedInsets.top,
884
1095
  paddingBottom: resolvedInsets.bottom,
885
1096
  // スクロール不能時 (contentSize <= viewportSize) は touch-action を通常に戻し、
886
- // 短いリスト上のタッチがページ全体のパンを妨げるデッドゾーン化を防ぐ
887
- ...(enablePointerDrag && isScrollable ? { touchAction: "none" } : {}),
1097
+ // 短いリスト上のタッチがページ全体のパンを妨げるデッドゾーン化を防ぐ。
1098
+ // 直接操作 (指・ペン) のドラッグを許可していない場合も付けない。
1099
+ // touch-action はペン入力にも効くため、判定は touch と pen の論理和にする
1100
+ ...(enablePointerDrag && isScrollable && pointerDragInputs.some((type) => type === "touch" || type === "pen") ? { touchAction: "none" } : {}),
888
1101
  }}
889
1102
  id={id}>
890
1103
  {background}
@@ -895,7 +1108,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
895
1108
  contentSize={contentSize}
896
1109
  viewportSize={viewportSize}
897
1110
  scrollPosition={scrollPositionRef.current}
898
- onScroll={scrollTo}
1111
+ onScroll={handleScrollBarScroll}
899
1112
  enableThumbDrag={enableThumbDrag}
900
1113
  enableTrackClick={enableTrackClick}
901
1114
  enableArrowButtons={enableArrowButtons}
@@ -65,6 +65,15 @@ export type VirtualScrollScrollBarOptions = {
65
65
 
66
66
  export type VirtualScrollBehaviorOptions = {
67
67
  enablePointerDrag?: boolean
68
+ /**
69
+ * Pointer types allowed to drag-scroll the content area (default: all).
70
+ * コンテンツ領域のドラッグスクロールを許可するポインタ種別 (既定: 全種別)。
71
+ *
72
+ * マウスドラッグだけが困る場合に `["pen", "touch"]` を指定する。
73
+ * `enablePointerDrag` を丸ごと切るとタッチ端末からスクロール手段が消えるため、
74
+ * 種別で絞れるならこちらを使うこと (`ScrollPaneProps["pointerDragInputs"]` 参照)。
75
+ */
76
+ pointerDragInputs?: ScrollPaneProps["pointerDragInputs"]
68
77
  enableKeyboardNavigation?: boolean
69
78
  wheelSpeedMultiplier?: number
70
79
  inertiaOptions?: ScrollPaneProps["inertiaOptions"]
@@ -663,7 +672,7 @@ const VirtualScrollInner = <T,>(
663
672
  ) => {
664
673
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
665
674
 
666
- const { enablePointerDrag, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
675
+ const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
667
676
 
668
677
  const scrollPaneRef = useRef<VirtualScrollHandle>(null)
669
678
  const currentRangeRef = useRef<VirtualScrollRange>({
@@ -1868,6 +1877,7 @@ const VirtualScrollInner = <T,>(
1868
1877
  enableTrackClick={enableTrackClick}
1869
1878
  enableArrowButtons={enableArrowButtons}
1870
1879
  enablePointerDrag={enablePointerDrag}
1880
+ pointerDragInputs={pointerDragInputs}
1871
1881
  renderThumbOverlay={renderThumbOverlay}
1872
1882
  wheelSpeedMultiplier={wheelSpeedMultiplier}
1873
1883
  onWheelHorizontal={onWheelHorizontal}
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * 可変なアイテム高さに対応したReact用の高性能仮想スクロールコンポーネント。
6
6
  */
7
7
 
8
- export { ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps } from "./ScrollBar.tsx"
8
+ export { computeAutoTapScrollMaxSpeedMultiplier, computeTapScrollSpeed, ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_SPEED_DEFAULTS, type TapScrollSpeedInput } from "./ScrollBar.tsx"
9
9
  export { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneInertiaOptions, type ScrollPaneProps } from "./ScrollPane.tsx"
10
10
  export type { TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
11
11
  export { tapScrollCircleSampleVisual } from "./tapScrollCircleSampleVisual.tsx"
@@ -46,7 +46,7 @@
46
46
  border-radius: 9999px;
47
47
  }
48
48
 
49
- .aqvs-sample-visual-rod {
49
+ .aqvs-sample-visual-pupil {
50
50
  position: absolute;
51
51
  top: 50%;
52
52
  left: 50%;
@@ -56,7 +56,7 @@
56
56
  background-color: rgba(255, 255, 255, 0.85);
57
57
  }
58
58
 
59
- .aqvs-sample-visual-pupil {
59
+ .aqvs-sample-visual-highlight {
60
60
  position: absolute;
61
61
  top: 50%;
62
62
  left: 50%;
@@ -64,7 +64,7 @@
64
64
  background-color: rgba(255, 255, 255, 0.8);
65
65
  }
66
66
 
67
- .aqvs-sample-visual-highlight {
67
+ .aqvs-sample-visual-rod {
68
68
  position: absolute;
69
69
  top: 50%;
70
70
  left: 50%;
@@ -1,6 +1,23 @@
1
1
  /**
2
- * Provides the sample tap scroll circle visual used for demos.
3
- * デモ用に使用されるサンプルのタップスクロールビジュアルを提供するモジュール。
2
+ * Provides an opt-in tap scroll circle visual (an eye-like sphere with a direction rod).
3
+ * タップスクロールサークル用の、明示的に選択して使うビジュアル (方向ロッド付きの目玉状) を提供するモジュール。
4
+ *
5
+ * ❗ **これは既定ではない。** `renderVisual` を省略した場合に描かれるのは
6
+ * `TapScrollCircle` 内部の `defaultRenderVisual` であり、この関数は
7
+ * `tapScrollCircleOptions.renderVisual` へ明示的に渡した消費側だけが目にする。
8
+ *
9
+ * ❗ **一方で「デモ専用」でもない。** 本リポジトリでは日報の 2 つの一覧、
10
+ * コンタクトマップの 2 ビュー、`ExplorerTreePanel` が本番でこれを選んでいる。
11
+ * `aqvs-sample-visual-*` というクラス名は歴史的経緯で「サンプル」を名乗っているだけなので、
12
+ * **本番 UI の一部として扱うこと**。
13
+ *
14
+ * 3 つの要素とクラス名の対応 (役割と名前が一致していること):
15
+ *
16
+ * | 要素 | クラス | 寸法・位置を決める値 |
17
+ * | --- | --- | --- |
18
+ * | 瞳 | `aqvs-sample-visual-pupil` | `coreSize` / `pupilScale` / `pupilX,Y` |
19
+ * | ハイライト | `aqvs-sample-visual-highlight` | `highlightSize` / `highlightOpacity` |
20
+ * | 尾 (ロッド) | `aqvs-sample-visual-rod` | `rodWidth` / `rodHeight` / `rodTranslate` |
4
21
  */
5
22
 
6
23
  import type { TapScrollCircleProps, TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
@@ -40,7 +57,7 @@ export const tapScrollCircleSampleVisual: NonNullable<TapScrollCircleProps["rend
40
57
  }}
41
58
  />
42
59
  <div
43
- className="aqvs-sample-visual-rod"
60
+ className="aqvs-sample-visual-pupil"
44
61
  style={{
45
62
  width: coreSize,
46
63
  height: coreSize,
@@ -49,7 +66,7 @@ export const tapScrollCircleSampleVisual: NonNullable<TapScrollCircleProps["rend
49
66
  }}
50
67
  />
51
68
  <div
52
- className="aqvs-sample-visual-pupil"
69
+ className="aqvs-sample-visual-highlight"
53
70
  style={{
54
71
  width: highlightSize,
55
72
  height: highlightSize,
@@ -60,7 +77,7 @@ export const tapScrollCircleSampleVisual: NonNullable<TapScrollCircleProps["rend
60
77
  }}
61
78
  />
62
79
  <div
63
- className="aqvs-sample-visual-highlight"
80
+ className="aqvs-sample-visual-rod"
64
81
  style={{
65
82
  width: rodWidth,
66
83
  height: rodHeight,