@aiquants/virtualscroll 1.19.3 → 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 +9 -6
- 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 +19 -1
- package/dist/ScrollPane.d.ts +19 -1
- 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 +1470 -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 +252 -35
- 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 -482
- 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. / スクロール速度を調整するためのホイールデルタの倍率。 */
|
|
@@ -61,7 +77,9 @@ export type ScrollPaneProps = {
|
|
|
61
77
|
* Callback delegating horizontal wheel/trackpad delta to an upstream owner.
|
|
62
78
|
* When provided, horizontal-dominant (or shift+wheel) gestures are consumed here
|
|
63
79
|
* (preventDefault + delegate) instead of being ignored; vertical behavior is unchanged.
|
|
64
|
-
*
|
|
80
|
+
* When omitted, horizontal gestures and shift+wheel bypass vertical scrolling so parent native scrolling works.
|
|
81
|
+
* 横方向ホイール/トラックパッド量を上流へ委譲するコールバック。
|
|
82
|
+
* 指定時は横スクロールを委譲処理し、未指定時でも Shift+ホイール 等の横ジェスチャは縦スクロールで横取りせず親要素のスクロールへ委ねる。
|
|
65
83
|
*/
|
|
66
84
|
onWheelHorizontal?: (deltaX: number) => void
|
|
67
85
|
/** Insets applied to the scrollable content area. / スクロール可能領域に適用するインセット。 */
|
|
@@ -91,6 +109,26 @@ export type ScrollPaneInertiaOptions = {
|
|
|
91
109
|
|
|
92
110
|
type ResolvedScrollPaneInertiaOptions = Required<ScrollPaneInertiaOptions>
|
|
93
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
|
+
|
|
94
132
|
const DEFAULT_INERTIA_OPTIONS: ResolvedScrollPaneInertiaOptions = {
|
|
95
133
|
maxVelocity: 6,
|
|
96
134
|
minVelocity: 0.02,
|
|
@@ -107,6 +145,8 @@ type DragSample = {
|
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
type DragState = {
|
|
148
|
+
/** 進行中ドラッグを開始したポインタの種別。許可集合の変化を検知するために保持する。 */
|
|
149
|
+
pointerType: string
|
|
110
150
|
pointerId: number | null
|
|
111
151
|
startClientY: number
|
|
112
152
|
startScroll: number
|
|
@@ -154,6 +194,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
154
194
|
enableTrackClick = true,
|
|
155
195
|
enableArrowButtons = true,
|
|
156
196
|
enablePointerDrag = true,
|
|
197
|
+
pointerDragInputs = DEFAULT_POINTER_DRAG_INPUTS,
|
|
157
198
|
onScroll,
|
|
158
199
|
className,
|
|
159
200
|
testId,
|
|
@@ -301,6 +342,28 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
301
342
|
[onScroll],
|
|
302
343
|
)
|
|
303
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
|
+
|
|
304
367
|
const stopInertia = useCallback(() => {
|
|
305
368
|
const state = inertiaStateRef.current
|
|
306
369
|
// 世代を進め、実行中の step (cancelAnimationFrame が効かない同期再入) にも停止を伝える
|
|
@@ -381,7 +444,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
381
444
|
const previousPosition = scrollPositionRef.current
|
|
382
445
|
|
|
383
446
|
if (distance !== 0) {
|
|
384
|
-
scrollTo
|
|
447
|
+
// ❗ 字句束縛の `scrollTo` ではなく ref を読む。ループは自身を
|
|
448
|
+
// 再アームし続けるため、束縛すると解放時点の `onScroll` へ数秒間通知し続ける
|
|
449
|
+
scrollToRef.current((prevPositionInternal) => prevPositionInternal + distance)
|
|
385
450
|
}
|
|
386
451
|
|
|
387
452
|
// scrollTo の onScroll から同期的に stopInertia (handle.scrollTo 等) が呼ばれた場合は
|
|
@@ -408,7 +473,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
408
473
|
|
|
409
474
|
inertiaStateRef.current.frame = requestAnimationFrame(step)
|
|
410
475
|
},
|
|
411
|
-
[isScrollable, resolvedInertiaOptions,
|
|
476
|
+
[isScrollable, resolvedInertiaOptions, stopInertia],
|
|
412
477
|
)
|
|
413
478
|
|
|
414
479
|
const startInertiaRef = useRef(startInertia)
|
|
@@ -467,14 +532,20 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
467
532
|
useEffect(() => {
|
|
468
533
|
// ホイールイベントのハンドラ
|
|
469
534
|
const handleWheel = (event: WheelEvent) => {
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
535
|
+
// ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする
|
|
536
|
+
if (event.ctrlKey) {
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Shiftキー押下による軸変換(deltaX=0, deltaY!=0) または 通常のdeltaX/deltaYから横優勢判定
|
|
541
|
+
const usesShiftAxis = event.shiftKey && event.deltaX === 0
|
|
542
|
+
const horizontalDelta = usesShiftAxis ? event.deltaY : event.deltaX
|
|
543
|
+
const verticalDelta = usesShiftAxis ? 0 : event.deltaY
|
|
544
|
+
const isHorizontalDominant = horizontalDelta !== 0 && Math.abs(horizontalDelta) >= Math.abs(verticalDelta)
|
|
545
|
+
|
|
546
|
+
if (isHorizontalDominant) {
|
|
547
|
+
// 横委譲コールバックが指定されている場合は横スクロールとして処理
|
|
548
|
+
if (onWheelHorizontalRef.current) {
|
|
478
549
|
event.preventDefault()
|
|
479
550
|
stopInertia()
|
|
480
551
|
let deltaX = horizontalDelta
|
|
@@ -484,17 +555,13 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
484
555
|
deltaX *= viewportSize // DOM_DELTA_PAGE
|
|
485
556
|
}
|
|
486
557
|
onWheelHorizontalRef.current(deltaX)
|
|
487
|
-
return
|
|
488
558
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (!isScrollable) {
|
|
559
|
+
// ハンドラ未指定時であっても、横優勢スクロールや Shift+ホイール は
|
|
560
|
+
// 縦スクロールとして横取りせず、親要素のネイティブ横スクロールへ委ねる
|
|
492
561
|
return
|
|
493
562
|
}
|
|
494
563
|
|
|
495
|
-
|
|
496
|
-
// ctrl+wheel is browser pinch-zoom, so leave it to the browser.
|
|
497
|
-
if (event.ctrlKey) {
|
|
564
|
+
if (!isScrollable) {
|
|
498
565
|
return
|
|
499
566
|
}
|
|
500
567
|
|
|
@@ -531,16 +598,35 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
531
598
|
scrollTo((prev) => prev + deltaY)
|
|
532
599
|
}
|
|
533
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
|
+
|
|
534
618
|
const scrollContainer = scrollContainerRef.current
|
|
535
619
|
if (scrollContainer) {
|
|
536
620
|
// wheel イベントリスナーを passive: false で登録し、preventDefault を可能にする
|
|
537
621
|
scrollContainer.addEventListener("wheel", handleWheel, { passive: false })
|
|
622
|
+
scrollContainer.addEventListener("pointerdown", handlePaneInteraction, { capture: true, passive: true })
|
|
538
623
|
}
|
|
539
624
|
|
|
540
625
|
// クリーンアップ関数
|
|
541
626
|
return () => {
|
|
542
627
|
if (scrollContainer) {
|
|
543
628
|
scrollContainer.removeEventListener("wheel", handleWheel)
|
|
629
|
+
scrollContainer.removeEventListener("pointerdown", handlePaneInteraction, { capture: true })
|
|
544
630
|
}
|
|
545
631
|
}
|
|
546
632
|
}, [isScrollable, scrollTo, stopInertia, viewportSize, wheelSpeedMultiplier])
|
|
@@ -559,14 +645,10 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
559
645
|
[scrollTo, contentSize, viewportSize, stopInertia],
|
|
560
646
|
)
|
|
561
647
|
|
|
562
|
-
const scrollToRef = useRef(scrollTo)
|
|
563
|
-
useEffect(() => {
|
|
564
|
-
scrollToRef.current = scrollTo
|
|
565
|
-
}, [scrollTo])
|
|
566
|
-
|
|
567
648
|
const id = useId()
|
|
568
649
|
|
|
569
650
|
const dragStateRef = useRef<DragState>({
|
|
651
|
+
pointerType: "",
|
|
570
652
|
pointerId: null,
|
|
571
653
|
startClientY: 0,
|
|
572
654
|
startScroll: 0,
|
|
@@ -577,10 +659,23 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
577
659
|
})
|
|
578
660
|
|
|
579
661
|
const enablePointerDragRef = useRef(enablePointerDrag)
|
|
662
|
+
const pointerDragInputsRef = useRef(pointerDragInputs)
|
|
580
663
|
useEffect(() => {
|
|
581
664
|
enablePointerDragRef.current = enablePointerDrag
|
|
582
665
|
}, [enablePointerDrag])
|
|
583
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
|
+
|
|
584
679
|
const isScrollableRef = useRef(isScrollable)
|
|
585
680
|
useEffect(() => {
|
|
586
681
|
isScrollableRef.current = isScrollable
|
|
@@ -591,28 +686,76 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
591
686
|
inertiaOptionsRef.current = resolvedInertiaOptions
|
|
592
687
|
}, [resolvedInertiaOptions])
|
|
593
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
|
+
*/
|
|
594
728
|
useEffect(() => {
|
|
595
|
-
|
|
729
|
+
const state = dragStateRef.current
|
|
730
|
+
// 進行中のドラッグが今も許可されているなら何もしない
|
|
731
|
+
if (enablePointerDrag && isScrollable && (state.pointerId === null || isPointerDragAllowed(state.pointerType, pointerDragInputs))) {
|
|
596
732
|
return
|
|
597
733
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
// pointerId=0 (Firefox のマウス) を falsy として弾かないよう !== null で判定する
|
|
601
|
-
// Compare with null so pointerId=0 (Firefox mouse) is not skipped as falsy.
|
|
602
|
-
if (state.pointerId !== null && element?.hasPointerCapture(state.pointerId)) {
|
|
603
|
-
element.releasePointerCapture(state.pointerId)
|
|
734
|
+
if (state.pointerId === null) {
|
|
735
|
+
return
|
|
604
736
|
}
|
|
737
|
+
const element = contentAreaRef.current
|
|
605
738
|
if (state.clickResetTimer !== null) {
|
|
606
739
|
window.clearTimeout(state.clickResetTimer)
|
|
607
740
|
state.clickResetTimer = null
|
|
608
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
|
|
609
747
|
state.pointerId = null
|
|
748
|
+
state.pointerType = ""
|
|
610
749
|
state.startClientY = 0
|
|
611
750
|
state.startScroll = 0
|
|
612
751
|
state.isDragging = false
|
|
613
|
-
state.shouldCancelNextClick =
|
|
752
|
+
state.shouldCancelNextClick = shouldKeepClickSuppression
|
|
614
753
|
state.velocitySamples = []
|
|
615
|
-
|
|
754
|
+
// 状態を確定させてから解放する (同期 lostpointercapture に引き継ぎを壊させない)
|
|
755
|
+
if (element?.hasPointerCapture(releasedPointerId)) {
|
|
756
|
+
element.releasePointerCapture(releasedPointerId)
|
|
757
|
+
}
|
|
758
|
+
}, [enablePointerDrag, pointerDragInputs, isScrollable])
|
|
616
759
|
|
|
617
760
|
useEffect(() => {
|
|
618
761
|
const element = contentAreaRef.current
|
|
@@ -626,6 +769,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
626
769
|
const resetDragState = () => {
|
|
627
770
|
const state = dragStateRef.current
|
|
628
771
|
state.pointerId = null
|
|
772
|
+
state.pointerType = ""
|
|
629
773
|
state.startClientY = 0
|
|
630
774
|
state.startScroll = 0
|
|
631
775
|
state.isDragging = false
|
|
@@ -707,6 +851,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
707
851
|
if (!(enablePointerDragRef.current && isScrollableRef.current)) {
|
|
708
852
|
return
|
|
709
853
|
}
|
|
854
|
+
if (!isPointerDragAllowed(event.pointerType, pointerDragInputsRef.current)) {
|
|
855
|
+
return
|
|
856
|
+
}
|
|
710
857
|
if (!state.isDragging) {
|
|
711
858
|
const distanceY = Math.abs(event.clientY - state.startClientY)
|
|
712
859
|
if (distanceY < DRAG_ACTIVATION_THRESHOLD) {
|
|
@@ -729,9 +876,34 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
729
876
|
}
|
|
730
877
|
}
|
|
731
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
|
+
|
|
732
903
|
const handlePointerUp = (event: PointerEvent) => {
|
|
733
904
|
const state = dragStateRef.current
|
|
734
905
|
if (state.pointerId !== event.pointerId) {
|
|
906
|
+
armOrphanedClickSuppression()
|
|
735
907
|
return
|
|
736
908
|
}
|
|
737
909
|
|
|
@@ -792,9 +964,19 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
792
964
|
if (dragStateRef.current.pointerId !== null) {
|
|
793
965
|
return
|
|
794
966
|
}
|
|
967
|
+
// 進行中ドラッグが無いのに抑止が残っているなら、click が来なかった前ジェスチャの残骸。
|
|
968
|
+
// 新しいジェスチャへ持ち越すと無関係なクリックを飲み込むのでここで捨てる
|
|
969
|
+
if (dragStateRef.current.shouldCancelNextClick) {
|
|
970
|
+
clearClickTimer()
|
|
971
|
+
dragStateRef.current.shouldCancelNextClick = false
|
|
972
|
+
}
|
|
795
973
|
if (!(enablePointerDragRef.current && isScrollableRef.current)) {
|
|
796
974
|
return
|
|
797
975
|
}
|
|
976
|
+
// 入力種別ごとの許可。マウスだけ外してもタッチのスクロール手段は残る
|
|
977
|
+
if (!isPointerDragAllowed(event.pointerType, pointerDragInputsRef.current)) {
|
|
978
|
+
return
|
|
979
|
+
}
|
|
798
980
|
if (event.button !== 0 && event.pointerType === "mouse") {
|
|
799
981
|
return
|
|
800
982
|
}
|
|
@@ -812,6 +994,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
812
994
|
const state = dragStateRef.current
|
|
813
995
|
clearClickTimer()
|
|
814
996
|
state.pointerId = event.pointerId
|
|
997
|
+
state.pointerType = event.pointerType
|
|
815
998
|
state.startClientY = event.clientY
|
|
816
999
|
state.startScroll = scrollPositionRef.current
|
|
817
1000
|
state.isDragging = false
|
|
@@ -822,6 +1005,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
822
1005
|
const handlePointerCancel = (event: PointerEvent) => {
|
|
823
1006
|
const state = dragStateRef.current
|
|
824
1007
|
if (state.pointerId !== event.pointerId) {
|
|
1008
|
+
armOrphanedClickSuppression()
|
|
825
1009
|
return
|
|
826
1010
|
}
|
|
827
1011
|
abortDrag(event.pointerId)
|
|
@@ -869,18 +1053,51 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
869
1053
|
}
|
|
870
1054
|
}, [id])
|
|
871
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
|
+
|
|
872
1083
|
return (
|
|
873
1084
|
<div ref={scrollContainerRef} data-testid={testId} className={twMerge("aqvs-scroll-pane", className)} style={style}>
|
|
874
1085
|
<div
|
|
875
1086
|
ref={contentAreaRef}
|
|
876
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"
|
|
877
1092
|
style={{
|
|
878
1093
|
height: viewportSize,
|
|
879
1094
|
paddingTop: resolvedInsets.top,
|
|
880
1095
|
paddingBottom: resolvedInsets.bottom,
|
|
881
1096
|
// スクロール不能時 (contentSize <= viewportSize) は touch-action を通常に戻し、
|
|
882
|
-
//
|
|
883
|
-
|
|
1097
|
+
// 短いリスト上のタッチがページ全体のパンを妨げるデッドゾーン化を防ぐ。
|
|
1098
|
+
// 直接操作 (指・ペン) のドラッグを許可していない場合も付けない。
|
|
1099
|
+
// touch-action はペン入力にも効くため、判定は touch と pen の論理和にする
|
|
1100
|
+
...(enablePointerDrag && isScrollable && pointerDragInputs.some((type) => type === "touch" || type === "pen") ? { touchAction: "none" } : {}),
|
|
884
1101
|
}}
|
|
885
1102
|
id={id}>
|
|
886
1103
|
{background}
|
|
@@ -891,7 +1108,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
891
1108
|
contentSize={contentSize}
|
|
892
1109
|
viewportSize={viewportSize}
|
|
893
1110
|
scrollPosition={scrollPositionRef.current}
|
|
894
|
-
onScroll={
|
|
1111
|
+
onScroll={handleScrollBarScroll}
|
|
895
1112
|
enableThumbDrag={enableThumbDrag}
|
|
896
1113
|
enableTrackClick={enableTrackClick}
|
|
897
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,
|