@aiquants/virtualscroll 1.18.5 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -13
- package/dist/ScrollPane.d.cts +2 -0
- package/dist/ScrollPane.d.ts +2 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +2 -0
- package/dist/VirtualScroll.d.ts +2 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1115 -1112
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +3 -0
- package/package.json +6 -4
- package/src/ScrollBar.spec.tsx +620 -0
- package/src/ScrollBar.tsx +1397 -0
- package/src/ScrollPane.spec.tsx +482 -0
- package/src/ScrollPane.tsx +913 -0
- package/src/TapScrollCircle.spec.tsx +275 -0
- package/src/TapScrollCircle.tsx +363 -0
- package/src/VirtualScroll.spec.ts +623 -0
- package/src/VirtualScroll.tsx +1891 -0
- package/src/cli.server.spec.ts +137 -0
- package/src/cli.server.ts +110 -0
- package/src/index.ts +23 -0
- package/src/logger.spec.ts +128 -0
- package/src/logger.ts +229 -0
- package/src/styles/components.entry.css +9 -0
- package/src/styles/standalone.entry.css +11 -0
- package/src/styles/virtualscroll.css +296 -0
- package/src/tapScrollCircleSampleVisual.tsx +74 -0
- package/src/useFenwickMapTree.huge.spec.ts +388 -0
- package/src/useFenwickMapTree.spec.ts +1518 -0
- package/src/useFenwickMapTree.ts +1368 -0
- package/src/useHeightCache.ts +32 -0
- package/src/useLruCache.spec.ts +382 -0
- package/src/useLruCache.ts +301 -0
- package/src/utils.spec.ts +39 -0
- 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"
|