@aiquants/virtualscroll 1.18.5 → 1.19.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 (40) hide show
  1. package/README.md +20 -13
  2. package/dist/ScrollPane.d.cts +2 -0
  3. package/dist/ScrollPane.d.ts +2 -0
  4. package/dist/ScrollPane.d.ts.map +1 -1
  5. package/dist/VirtualScroll.d.cts +2 -0
  6. package/dist/VirtualScroll.d.ts +2 -0
  7. package/dist/VirtualScroll.d.ts.map +1 -1
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.cts +6 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1115 -1112
  13. package/dist/styles/virtualscroll.css +1 -1
  14. package/dist/styles/virtualscroll.standalone.css +3 -0
  15. package/package.json +6 -4
  16. package/src/ScrollBar.spec.tsx +620 -0
  17. package/src/ScrollBar.tsx +1397 -0
  18. package/src/ScrollPane.spec.tsx +482 -0
  19. package/src/ScrollPane.tsx +913 -0
  20. package/src/TapScrollCircle.spec.tsx +275 -0
  21. package/src/TapScrollCircle.tsx +363 -0
  22. package/src/VirtualScroll.spec.ts +623 -0
  23. package/src/VirtualScroll.tsx +1891 -0
  24. package/src/cli.server.spec.ts +137 -0
  25. package/src/cli.server.ts +110 -0
  26. package/src/index.ts +24 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +7 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +298 -0
  32. package/src/tapScrollCircleSampleVisual.tsx +74 -0
  33. package/src/useFenwickMapTree.huge.spec.ts +388 -0
  34. package/src/useFenwickMapTree.spec.ts +1518 -0
  35. package/src/useFenwickMapTree.ts +1368 -0
  36. package/src/useHeightCache.ts +32 -0
  37. package/src/useLruCache.spec.ts +382 -0
  38. package/src/useLruCache.ts +301 -0
  39. package/src/utils.spec.ts +39 -0
  40. package/src/utils.ts +16 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Provides regression tests for TapScrollCircle pointer-capture robustness.
3
+ *
4
+ * TapScrollCircle のポインタキャプチャ堅牢化に関する回帰テストを提供。
5
+ */
6
+ import { act, cleanup, fireEvent, render } from "@testing-library/react"
7
+ import { createRef } from "react"
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
9
+ import { TapScrollCircle, type TapScrollCircleDragState, type TapScrollCircleHandle } from "./TapScrollCircle.tsx"
10
+
11
+ // jsdom は PointerEvent とポインタキャプチャ API を持たないため補完する。
12
+ // jsdom lacks PointerEvent and pointer-capture APIs; polyfill them for these tests.
13
+ beforeAll(() => {
14
+ if (typeof window.PointerEvent === "undefined") {
15
+ class PointerEventPolyfill extends MouseEvent {
16
+ pointerId: number
17
+ pointerType: string
18
+ constructor(type: string, params: PointerEventInit = {}) {
19
+ super(type, params)
20
+ this.pointerId = params.pointerId ?? 0
21
+ this.pointerType = params.pointerType ?? "mouse"
22
+ }
23
+ }
24
+ // @ts-expect-error jsdom への PointerEvent 補完
25
+ window.PointerEvent = PointerEventPolyfill
26
+ }
27
+ HTMLElement.prototype.setPointerCapture ??= function setPointerCapture() {}
28
+ HTMLElement.prototype.releasePointerCapture ??= function releasePointerCapture() {}
29
+ HTMLElement.prototype.hasPointerCapture ??= function hasPointerCapture() {
30
+ return false
31
+ }
32
+ })
33
+
34
+ afterEach(() => {
35
+ cleanup()
36
+ })
37
+
38
+ /**
39
+ * Builds a PointerEvent-like init for jsdom fireEvent.pointer*.
40
+ *
41
+ * jsdom の fireEvent.pointer* 用に PointerEvent 相当の初期化を生成。
42
+ */
43
+ const pointerInit = (pointerId: number, clientX: number, clientY: number) => ({
44
+ pointerId,
45
+ clientX,
46
+ clientY,
47
+ // jsdom は PointerEvent を持たないため bubbles/pointerType を明示する
48
+ bubbles: true,
49
+ pointerType: "touch",
50
+ })
51
+
52
+ describe("TapScrollCircle pointer capture robustness", () => {
53
+ it("does not throw when setPointerCapture fails (pointer already inactive)", () => {
54
+ const onDragChange = vi.fn()
55
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
56
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
57
+
58
+ // ウインドウ外へ出た直後などをシミュレート: setPointerCapture が NotFoundError を投げる
59
+ circle.setPointerCapture = () => {
60
+ throw new DOMException("pointer not found", "NotFoundError")
61
+ }
62
+
63
+ expect(() => {
64
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
65
+ }).not.toThrow()
66
+
67
+ // キャプチャ失敗後もドラッグ状態は開始している
68
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
69
+ expect(last.active).toBe(true)
70
+ })
71
+
72
+ it("resets drag state and notifies inactive on lostpointercapture", () => {
73
+ const onDragChange = vi.fn()
74
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
75
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
76
+ circle.setPointerCapture = vi.fn()
77
+
78
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
79
+ // 下方向へドラッグして direction を確定させる
80
+ fireEvent.pointerMove(circle, pointerInit(1, 0, 140))
81
+ const dragging = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
82
+ expect(dragging.active).toBe(true)
83
+ expect(dragging.direction).not.toBe(0)
84
+
85
+ // 強制的なキャプチャ解放 (pointerup を受け取れないケース)
86
+ fireEvent.lostPointerCapture(circle, { pointerId: 1, bubbles: true })
87
+
88
+ const afterLost = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
89
+ expect(afterLost.active).toBe(false)
90
+ expect(afterLost.direction).toBe(0)
91
+ expect(afterLost.pointerId).toBeNull()
92
+ })
93
+
94
+ it("ignores lostpointercapture for a non-matching pointerId", () => {
95
+ const onDragChange = vi.fn()
96
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
97
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
98
+ circle.setPointerCapture = vi.fn()
99
+
100
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
101
+ fireEvent.pointerMove(circle, pointerInit(1, 0, 140))
102
+ const callsBefore = onDragChange.mock.calls.length
103
+
104
+ // 別ポインターの lostpointercapture では状態を触らない
105
+ fireEvent.lostPointerCapture(circle, { pointerId: 99, bubbles: true })
106
+ expect(onDragChange.mock.calls.length).toBe(callsBefore)
107
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
108
+ expect(last.active).toBe(true)
109
+ })
110
+
111
+ it("ignores a second pointer while dragging with the first", () => {
112
+ const onDragChange = vi.fn()
113
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
114
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
115
+ const capture = vi.fn()
116
+ circle.setPointerCapture = capture
117
+
118
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
119
+ expect(capture).toHaveBeenCalledTimes(1)
120
+
121
+ // 2 本目のタッチ (別 pointerId) は無視されるべき
122
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 100))
123
+ expect(capture).toHaveBeenCalledTimes(1)
124
+
125
+ // ドラッグ中の pointerId は 1 のまま
126
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
127
+ expect(last.pointerId).toBe(1)
128
+
129
+ // 2 本目のポインターの move は無視される
130
+ const callsBeforeMove = onDragChange.mock.calls.length
131
+ fireEvent.pointerMove(circle, pointerInit(2, 0, 200))
132
+ expect(onDragChange.mock.calls.length).toBe(callsBeforeMove)
133
+ })
134
+
135
+ it("hands off control to a waiting second pointer when the primary is released", () => {
136
+ const onDragChange = vi.fn()
137
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
138
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
139
+ const capture = vi.fn()
140
+ circle.setPointerCapture = capture
141
+
142
+ // finger1 でドラッグ開始
143
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
144
+ fireEvent.pointerMove(circle, pointerInit(1, 0, 140))
145
+ expect((onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState).active).toBe(true)
146
+
147
+ // finger2 をサークルに置く (待機候補として記録される)
148
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 60))
149
+
150
+ // finger1 を離すと finger2 へ制御が移譲される
151
+ fireEvent.pointerUp(circle, pointerInit(1, 0, 140))
152
+ const afterHandoff = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
153
+ expect(afterHandoff.active).toBe(true)
154
+ expect(afterHandoff.pointerId).toBe(2)
155
+ expect(capture).toHaveBeenCalledWith(2)
156
+
157
+ // 移譲後は finger2 の move でドラッグが継続する
158
+ fireEvent.pointerMove(circle, pointerInit(2, 0, 200))
159
+ const moved = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
160
+ expect(moved.active).toBe(true)
161
+ expect(moved.pointerId).toBe(2)
162
+ expect(moved.offsetY).toBe(200)
163
+
164
+ // finger2 を離すと通常どおりリセットされる
165
+ fireEvent.pointerUp(circle, pointerInit(2, 0, 200))
166
+ const released = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
167
+ expect(released.active).toBe(false)
168
+ expect(released.pointerId).toBeNull()
169
+ })
170
+
171
+ it("tracks the waiting pointer position so the handoff starts at its latest location", () => {
172
+ const onDragChange = vi.fn()
173
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
174
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
175
+ circle.setPointerCapture = vi.fn()
176
+
177
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
178
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 60))
179
+ // 待機中の finger2 が移動しても座標は追従する (onDragChange は発火しない)
180
+ const callsBefore = onDragChange.mock.calls.length
181
+ fireEvent.pointerMove(circle, pointerInit(2, 0, 160))
182
+ expect(onDragChange.mock.calls.length).toBe(callsBefore)
183
+
184
+ fireEvent.pointerUp(circle, pointerInit(1, 0, 100))
185
+ const afterHandoff = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
186
+ expect(afterHandoff.active).toBe(true)
187
+ expect(afterHandoff.pointerId).toBe(2)
188
+ expect(afterHandoff.offsetY).toBe(160)
189
+ })
190
+
191
+ it("resets normally when the waiting pointer was already released before the primary", () => {
192
+ const onDragChange = vi.fn()
193
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
194
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
195
+ circle.setPointerCapture = vi.fn()
196
+
197
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
198
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 60))
199
+ // finger2 が先に離れた場合、候補は破棄され通常のリセットになる
200
+ fireEvent.pointerUp(circle, pointerInit(2, 0, 60))
201
+ fireEvent.pointerUp(circle, pointerInit(1, 0, 100))
202
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
203
+ expect(last.active).toBe(false)
204
+ expect(last.pointerId).toBeNull()
205
+ })
206
+
207
+ it("treats a cross-realm NotFoundError (name-based) as a dead candidate on handoff", () => {
208
+ const onDragChange = vi.fn()
209
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
210
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
211
+
212
+ // クロスレルムの DOMException を模倣: instanceof DOMException は false だが name は NotFoundError
213
+ class CrossRealmDOMException extends Error {}
214
+ const crossRealmError = new CrossRealmDOMException("pointer not found")
215
+ crossRealmError.name = "NotFoundError"
216
+ expect(crossRealmError instanceof DOMException).toBe(false)
217
+
218
+ // 候補ポインタ (2) の生存プローブだけが失敗する
219
+ circle.setPointerCapture = (pointerId: number) => {
220
+ if (pointerId === 2) {
221
+ throw crossRealmError
222
+ }
223
+ }
224
+
225
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
226
+ fireEvent.pointerMove(circle, pointerInit(1, 0, 140))
227
+ // finger2 を待機候補として置くが、既に非アクティブ (プローブが NotFoundError)
228
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 60))
229
+ fireEvent.pointerUp(circle, pointerInit(1, 0, 140))
230
+
231
+ // 死んだ候補へは移譲されず、状態はリセットされる (active=true 固着の防止)
232
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
233
+ expect(last.active).toBe(false)
234
+ expect(last.pointerId).toBeNull()
235
+ })
236
+
237
+ it("continues the handoff when the liveness probe fails with a non-NotFoundError error", () => {
238
+ const onDragChange = vi.fn()
239
+ const { getByTestId } = render(<TapScrollCircle onDragChange={onDragChange} opacity={1} />)
240
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
241
+
242
+ // NotFoundError 以外 (キャプチャ未サポート等) は生死不明のため移譲を継続する
243
+ circle.setPointerCapture = (pointerId: number) => {
244
+ if (pointerId === 2) {
245
+ throw new Error("capture not supported")
246
+ }
247
+ }
248
+
249
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
250
+ fireEvent.pointerDown(circle, pointerInit(2, 0, 60))
251
+ fireEvent.pointerUp(circle, pointerInit(1, 0, 100))
252
+
253
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
254
+ expect(last.active).toBe(true)
255
+ expect(last.pointerId).toBe(2)
256
+ })
257
+
258
+ it("reset() clears state via imperative handle", () => {
259
+ const onDragChange = vi.fn()
260
+ const ref = createRef<TapScrollCircleHandle>()
261
+ const { getByTestId } = render(<TapScrollCircle ref={ref} onDragChange={onDragChange} opacity={1} />)
262
+ const circle = getByTestId("virtual-scroll-tap-circle") as HTMLDivElement
263
+ circle.setPointerCapture = vi.fn()
264
+
265
+ fireEvent.pointerDown(circle, pointerInit(1, 0, 100))
266
+ fireEvent.pointerMove(circle, pointerInit(1, 0, 140))
267
+
268
+ act(() => {
269
+ ref.current?.reset()
270
+ })
271
+ const last = onDragChange.mock.calls.at(-1)?.[0] as TapScrollCircleDragState
272
+ expect(last.active).toBe(false)
273
+ expect(last.pointerId).toBeNull()
274
+ })
275
+ })
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Provides a touch-friendly tap circle for continuous scrolling interactions.
3
+ * タッチ操作向けのタップサークルを提供し、連続スクロール操作を実現するモジュール。
4
+ */
5
+
6
+ import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from "react"
7
+ import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from "react"
8
+ import { twMerge } from "tailwind-merge"
9
+
10
+ export type TapScrollCircleDragState = {
11
+ /** Whether the pointer is actively dragging. / ポインターがドラッグ中かどうか。 */
12
+ active: boolean
13
+ /** Signed horizontal offset from the circle center. / サークル中心からの符号付き水平方向オフセット。 */
14
+ offsetX: number
15
+ /** Signed vertical offset from the circle center. / サークル中心からの符号付き垂直オフセット。 */
16
+ offsetY: number
17
+ /** Absolute distance between pointer and center. / ポインターと中心の距離。 */
18
+ distance: number
19
+ /** Drag direction relative to the circle. / サークルに対するドラッグ方向。 */
20
+ direction: -1 | 0 | 1
21
+ /** The ID of the pointer that initiated the drag. / ドラッグを開始したポインターの ID。 */
22
+ pointerId: number | null
23
+ }
24
+
25
+ export type TapScrollCircleProps = {
26
+ /** Called whenever the drag state updates. / ドラッグ状態が更新された際に呼び出されるコールバック。 */
27
+ onDragChange: (state: TapScrollCircleDragState) => void
28
+ /** Additional class names for styling. / 追加のスタイル用クラス名。 */
29
+ className?: string
30
+ /** Maximum distance used for visual stretching. / 視覚的な伸縮に使用する最大距離。 */
31
+ maxVisualDistance?: number
32
+ /** Diameter of the circle in pixels. / サークルの直径 (ピクセル)。 */
33
+ size?: number
34
+ /** Additional inline styles. / 追加のインラインスタイル。 */
35
+ style?: CSSProperties
36
+ /** Base opacity applied to the circle. / サークル全体に適用する基準透明度。 */
37
+ opacity: number
38
+ /** Custom rendering logic for the visual contents. / ビジュアル内容を描画するカスタムロジック。 */
39
+ renderVisual?: (props: TapScrollCircleRenderProps) => ReactNode
40
+ }
41
+
42
+ export type TapScrollCircleHandle = {
43
+ /**
44
+ * Resets the drag state and releases pointer capture if active.
45
+ *
46
+ * ドラッグ状態をリセットし、必要に応じてポインタキャプチャを解除する。
47
+ */
48
+ reset: () => void
49
+ /**
50
+ * Returns the underlying DOM element of the tap circle.
51
+ *
52
+ * タップサークルの DOM 要素を返す。
53
+ */
54
+ getElement: () => HTMLDivElement | null
55
+ }
56
+
57
+ const INITIAL_STATE: TapScrollCircleDragState = {
58
+ active: false,
59
+ offsetX: 0,
60
+ offsetY: 0,
61
+ distance: 0,
62
+ direction: 0,
63
+ pointerId: null,
64
+ }
65
+
66
+ const DEAD_ZONE = 6
67
+ const DIRECTION_HYSTERESIS = 8
68
+
69
+ export type TapScrollCircleRenderProps = {
70
+ /** Current drag state snapshot. / 現在のドラッグ状態のスナップショット。 */
71
+ dragState: TapScrollCircleDragState
72
+ /** Normalized distance within [0, 1]. / 正規化距離 (0 から 1 の範囲)。 */
73
+ normalizedDistance: number
74
+ /** Relative scale based on size. / サイズに基づく相対スケール。 */
75
+ sizeScale: number
76
+ /** Circle diameter in pixels. / サークル直径 (ピクセル)。 */
77
+ size: number
78
+ /** Applied opacity value. / 適用中の不透明度値。 */
79
+ opacity: number
80
+ }
81
+
82
+ const defaultRenderVisual = ({ dragState, normalizedDistance }: TapScrollCircleRenderProps) => {
83
+ const scale = 1 + normalizedDistance * 0.18
84
+ const glow = 0.16 + normalizedDistance * 0.24
85
+ const innerOpacity = 0.38 + normalizedDistance * 0.28
86
+ const baseTransition = dragState.active ? "80ms ease-out" : "220ms ease"
87
+
88
+ return (
89
+ <>
90
+ <div
91
+ className="aqvs-tap-scroll-circle-visual-outer"
92
+ style={{
93
+ background: "linear-gradient(140deg, rgba(255,255,255,0.62), rgba(72,72,72,0.48))",
94
+ boxShadow: `0 0 0 1px rgba(255,255,255,0.28), 0 10px 22px rgba(0,0,0,${glow})`,
95
+ transform: `scale(${scale})`,
96
+ transition: `${baseTransition}, ${dragState.active ? "80ms" : "260ms"} box-shadow ease`,
97
+ }}
98
+ />
99
+ <div
100
+ className="aqvs-tap-scroll-circle-inner"
101
+ style={{
102
+ background: "linear-gradient(140deg, rgba(255,255,255,0.72), rgba(28,28,28,0.58))",
103
+ boxShadow: "inset 0 4px 10px rgba(0,0,0,0.24), inset 0 0 2px rgba(255,255,255,0.55)",
104
+ opacity: innerOpacity,
105
+ transition: dragState.active ? "120ms opacity ease-out" : "220ms opacity ease",
106
+ }}
107
+ />
108
+ </>
109
+ )
110
+ }
111
+
112
+ /**
113
+ * A circular touch handle that stretches toward the drag direction.
114
+ * タップした方向へ伸縮するタッチ操作用サークル。
115
+ */
116
+ export const TapScrollCircle = memo(
117
+ forwardRef<TapScrollCircleHandle, TapScrollCircleProps>(({ onDragChange, className, maxVisualDistance = 160, size = 40, style, opacity = 1, renderVisual }, ref) => {
118
+ const [dragState, setDragState] = useState<TapScrollCircleDragState>(INITIAL_STATE)
119
+ const pointerIdRef = useRef<number | null>(null)
120
+ // アクティブなドラッグ中に押下された別ポインタ (2 本目のタッチ) を「待機候補」として保持する。
121
+ // プライマリの解放時にこの候補へ制御を移譲し、指のハンドオフ (指の持ち替え) を可能にする。
122
+ // Holds a second pointer pressed during an active drag as a pending candidate; control is
123
+ // handed off to it when the primary pointer is released, enabling finger handoff.
124
+ const pendingPointerRef = useRef<{ pointerId: number; clientX: number; clientY: number } | null>(null)
125
+ const centerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
126
+ const rootRef = useRef<HTMLDivElement>(null)
127
+ const lastStableDirectionRef = useRef<-1 | 0 | 1>(0)
128
+
129
+ /**
130
+ * Updates drag state and notifies listeners.
131
+ *
132
+ * ドラッグ状態を更新してリスナーに通知。
133
+ */
134
+ const applyDragState = useCallback(
135
+ (nextState: TapScrollCircleDragState) => {
136
+ setDragState(nextState)
137
+ onDragChange(nextState)
138
+ },
139
+ [onDragChange],
140
+ )
141
+
142
+ const updateDragState = useCallback(
143
+ (clientX: number, clientY: number, capture: boolean = false) => {
144
+ const { x, y } = centerRef.current
145
+ const offsetX = clientX - x
146
+ const offsetY = clientY - y
147
+ const distance = Math.abs(offsetY)
148
+ const rawDirection: -1 | 0 | 1 = distance < DEAD_ZONE ? 0 : offsetY < 0 ? -1 : 1
149
+ const previousDirection = lastStableDirectionRef.current
150
+ let direction: -1 | 0 | 1 = rawDirection
151
+
152
+ const hysteresisThreshold = DEAD_ZONE + DIRECTION_HYSTERESIS
153
+
154
+ if (rawDirection === 0) {
155
+ if (previousDirection !== 0 && distance < hysteresisThreshold) {
156
+ direction = previousDirection
157
+ } else {
158
+ direction = 0
159
+ if (!capture) {
160
+ lastStableDirectionRef.current = 0
161
+ }
162
+ }
163
+ } else if (rawDirection !== previousDirection && previousDirection !== 0 && distance < hysteresisThreshold) {
164
+ direction = previousDirection
165
+ } else {
166
+ lastStableDirectionRef.current = rawDirection
167
+ }
168
+
169
+ // 僅かなドラッグ揺れによる方向反転を防ぐヒステリシスを適用
170
+ applyDragState({
171
+ active: capture || distance >= DEAD_ZONE,
172
+ offsetX,
173
+ offsetY,
174
+ distance,
175
+ direction,
176
+ pointerId: pointerIdRef.current,
177
+ })
178
+ },
179
+ [applyDragState],
180
+ )
181
+
182
+ const releasePointerCapture = useCallback((pointerId: number | null) => {
183
+ if (pointerId === null) {
184
+ return
185
+ }
186
+ const element = rootRef.current
187
+ if (element?.hasPointerCapture(pointerId)) {
188
+ element.releasePointerCapture(pointerId)
189
+ }
190
+ }, [])
191
+
192
+ const resetState = useCallback(
193
+ (releasePointer: boolean = false) => {
194
+ if (releasePointer) {
195
+ releasePointerCapture(pointerIdRef.current)
196
+ }
197
+ pointerIdRef.current = null
198
+ pendingPointerRef.current = null
199
+ lastStableDirectionRef.current = 0
200
+ applyDragState(INITIAL_STATE)
201
+ },
202
+ [applyDragState, releasePointerCapture],
203
+ )
204
+
205
+ const handlePointerDown = useCallback(
206
+ (event: ReactPointerEvent<HTMLDivElement>) => {
207
+ // ドラッグ中に発生した別ポインター (2 本目のタッチ) は待機候補として記録し、
208
+ // プライマリの解放時に制御を移譲できるようにする (指のハンドオフ)。
209
+ // Record a second pointer pressed mid-drag as a pending candidate so control can be
210
+ // handed off to it when the primary pointer is released (finger handoff).
211
+ if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) {
212
+ pendingPointerRef.current = { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY }
213
+ event.preventDefault()
214
+ event.stopPropagation()
215
+ return
216
+ }
217
+ event.preventDefault()
218
+ event.stopPropagation()
219
+ const element = rootRef.current ?? event.currentTarget
220
+ const { left, top, width, height } = element.getBoundingClientRect()
221
+ centerRef.current = { x: left + width / 2, y: top + height / 2 }
222
+ pointerIdRef.current = event.pointerId
223
+ try {
224
+ element.setPointerCapture(event.pointerId)
225
+ } catch {
226
+ // ポインタが既に非アクティブ (ウインドウ外へ出た等) だと setPointerCapture は
227
+ // NotFoundError を投げ得る。キャプチャ無しでもドラッグ処理は継続できるため握り潰す。
228
+ // setPointerCapture can throw NotFoundError if the pointer is already inactive
229
+ // (e.g. moved outside the browser window); dragging still works without capture.
230
+ }
231
+ updateDragState(event.clientX, event.clientY, true)
232
+ },
233
+ [updateDragState],
234
+ )
235
+
236
+ const handlePointerMove = useCallback(
237
+ (event: ReactPointerEvent<HTMLDivElement>) => {
238
+ if (pointerIdRef.current !== event.pointerId) {
239
+ // 待機候補ポインタの移動は座標のみ追従させ、ハンドオフ時の開始位置に使う
240
+ if (pendingPointerRef.current !== null && pendingPointerRef.current.pointerId === event.pointerId) {
241
+ pendingPointerRef.current = { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY }
242
+ }
243
+ return
244
+ }
245
+ event.preventDefault()
246
+ updateDragState(event.clientX, event.clientY)
247
+ },
248
+ [updateDragState],
249
+ )
250
+
251
+ const handlePointerUp = useCallback(
252
+ (event: ReactPointerEvent<HTMLDivElement>) => {
253
+ if (pointerIdRef.current !== event.pointerId) {
254
+ // 待機候補ポインタが先に離れた/キャンセルされた場合は候補を破棄する
255
+ if (pendingPointerRef.current !== null && pendingPointerRef.current.pointerId === event.pointerId) {
256
+ pendingPointerRef.current = null
257
+ }
258
+ return
259
+ }
260
+ event.preventDefault()
261
+ event.stopPropagation()
262
+ const pending = pendingPointerRef.current
263
+ pendingPointerRef.current = null
264
+ if (pending !== null) {
265
+ // プライマリの解放時、待機中の 2 本目のポインタへ制御を移譲する (指のハンドオフ)。
266
+ // 候補がサークル外で既に離されていると要素は pointerup を受け取れず候補が残留するため、
267
+ // setPointerCapture を生存プローブとして使う: 非アクティブなポインタには仕様上
268
+ // NotFoundError が投げられるので、失敗時は移譲せずリセットに倒す (active=true 固着の防止)。
269
+ // Use setPointerCapture as a liveness probe: it throws NotFoundError for inactive
270
+ // pointers, so a dead candidate resets the state instead of getting promoted.
271
+ releasePointerCapture(event.pointerId)
272
+ const element = rootRef.current
273
+ let candidateAlive = true
274
+ if (element !== null && typeof element.setPointerCapture === "function") {
275
+ try {
276
+ element.setPointerCapture(pending.pointerId)
277
+ } catch (error) {
278
+ // NotFoundError = 候補ポインタが既に非アクティブ (サークル外で解放済み等)。
279
+ // クロスレルム (iframe への portal 等) では DOMException の instanceof が
280
+ // false になるため、name ベースで判定する (DOMException も name を持つので包含)。
281
+ // それ以外 (キャプチャ未実装環境等) は生死不明のため移譲を継続する。
282
+ const errorName = typeof error === "object" && error !== null && "name" in error ? (error as { name?: unknown }).name : undefined
283
+ if (errorName === "NotFoundError") {
284
+ candidateAlive = false
285
+ }
286
+ }
287
+ }
288
+ if (candidateAlive) {
289
+ pointerIdRef.current = pending.pointerId
290
+ updateDragState(pending.clientX, pending.clientY, true)
291
+ return
292
+ }
293
+ }
294
+ resetState(true)
295
+ },
296
+ [resetState, releasePointerCapture, updateDragState],
297
+ )
298
+
299
+ const handleLostPointerCapture = useCallback(
300
+ (event: ReactPointerEvent<HTMLDivElement>) => {
301
+ if (pointerIdRef.current !== event.pointerId) {
302
+ return
303
+ }
304
+ // OS/ブラウザ都合でキャプチャが強制解放されると pointerup/pointercancel を受け取れず、
305
+ // ドラッグ状態が固着して連続スクロールが止まらなくなる。ここで状態をリセットする。
306
+ // A forced capture release (by the OS/browser) means pointerup/pointercancel may never
307
+ // arrive; reset the drag state here so continuous scrolling does not get stuck.
308
+ // キャプチャは既に解放済みなので、再解放は試みない (releasePointer=false)。
309
+ resetState(false)
310
+ },
311
+ [resetState],
312
+ )
313
+
314
+ useImperativeHandle(
315
+ ref,
316
+ () => ({
317
+ reset: () => {
318
+ resetState(true)
319
+ },
320
+ getElement: () => rootRef.current,
321
+ }),
322
+ [resetState],
323
+ )
324
+
325
+ const clampedOpacity = Math.min(Math.max(opacity, 0), 1)
326
+ const sizeScale = size / 64
327
+ const normalized = Math.min(dragState.distance, maxVisualDistance) / maxVisualDistance
328
+ const pullOffset = dragState.direction * normalized * 10 * sizeScale
329
+ const visualRenderer = renderVisual ?? defaultRenderVisual
330
+ const visualProps: TapScrollCircleRenderProps = {
331
+ dragState,
332
+ normalizedDistance: normalized,
333
+ sizeScale,
334
+ size,
335
+ opacity: clampedOpacity,
336
+ }
337
+ const rootStyle: CSSProperties = {
338
+ ...style,
339
+ width: size,
340
+ height: size,
341
+ transform: `translateY(${pullOffset}px)`,
342
+ }
343
+ rootStyle.opacity = clampedOpacity
344
+ return (
345
+ <div
346
+ ref={rootRef}
347
+ data-testid="virtual-scroll-tap-circle"
348
+ className={twMerge("aqvs-tap-scroll-circle", className)}
349
+ style={rootStyle}
350
+ tabIndex={-1}
351
+ onPointerDown={handlePointerDown}
352
+ onPointerMove={handlePointerMove}
353
+ onPointerUp={handlePointerUp}
354
+ onPointerCancel={handlePointerUp}
355
+ onLostPointerCapture={handleLostPointerCapture}
356
+ role="presentation">
357
+ {visualRenderer(visualProps)}
358
+ </div>
359
+ )
360
+ }),
361
+ )
362
+
363
+ TapScrollCircle.displayName = "TapScrollCircle"