@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.
- package/CHANGELOG.md +73 -0
- package/README.md +5 -3
- package/dist/ScrollBar.d.cts +123 -0
- package/dist/ScrollBar.d.ts +123 -0
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.cts +16 -0
- package/dist/ScrollPane.d.ts +16 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +9 -0
- package/dist/VirtualScroll.d.ts +9 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/_headFenwick.d.cts +450 -0
- package/dist/_headFenwick.d.ts +451 -0
- package/dist/_headFenwick.d.ts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1468 -1360
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +1 -1
- package/dist/tapScrollCircleSampleVisual.d.ts.map +1 -1
- package/dist/useFenwickMapTree.d.cts +43 -0
- package/dist/useFenwickMapTree.d.ts +43 -0
- package/dist/useFenwickMapTree.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/ScrollBar.tsx +211 -33
- package/src/ScrollPane.tsx +232 -19
- package/src/VirtualScroll.tsx +11 -1
- package/src/index.ts +1 -1
- package/src/styles/virtualscroll.css +3 -3
- package/src/tapScrollCircleSampleVisual.tsx +22 -5
- package/src/useFenwickMapTree.ts +95 -13
- package/src/ScrollBar.spec.tsx +0 -620
- package/src/ScrollPane.spec.tsx +0 -496
- package/src/TapScrollCircle.spec.tsx +0 -275
- package/src/VirtualScroll.spec.ts +0 -641
- package/src/cli.server.spec.ts +0 -137
- package/src/logger.spec.ts +0 -128
- package/src/useFenwickMapTree.huge.spec.ts +0 -388
- package/src/useFenwickMapTree.spec.ts +0 -1518
- package/src/useLruCache.spec.ts +0 -382
- package/src/utils.spec.ts +0 -39
package/src/ScrollPane.tsx
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
729
|
+
const state = dragStateRef.current
|
|
730
|
+
// 進行中のドラッグが今も許可されているなら何もしない
|
|
731
|
+
if (enablePointerDrag && isScrollable && (state.pointerId === null || isPointerDragAllowed(state.pointerType, pointerDragInputs))) {
|
|
600
732
|
return
|
|
601
733
|
}
|
|
602
|
-
|
|
603
|
-
|
|
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 =
|
|
752
|
+
state.shouldCancelNextClick = shouldKeepClickSuppression
|
|
618
753
|
state.velocitySamples = []
|
|
619
|
-
|
|
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
|
-
|
|
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={
|
|
1111
|
+
onScroll={handleScrollBarScroll}
|
|
899
1112
|
enableThumbDrag={enableThumbDrag}
|
|
900
1113
|
enableTrackClick={enableTrackClick}
|
|
901
1114
|
enableArrowButtons={enableArrowButtons}
|
package/src/VirtualScroll.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
80
|
+
className="aqvs-sample-visual-rod"
|
|
64
81
|
style={{
|
|
65
82
|
width: rodWidth,
|
|
66
83
|
height: rodHeight,
|