@aiquants/virtualscroll 1.18.3 → 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.
- 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 +9 -7
- 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 +24 -0
- package/src/logger.spec.ts +128 -0
- package/src/logger.ts +229 -0
- package/src/styles/components.entry.css +7 -0
- package/src/styles/standalone.entry.css +11 -0
- package/src/styles/virtualscroll.css +298 -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,913 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react"
|
|
2
|
+
import { twMerge } from "tailwind-merge"
|
|
3
|
+
import { Logger } from "./logger.ts"
|
|
4
|
+
import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
|
|
5
|
+
import { minmax } from "./utils.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for the ScrollPane component.
|
|
9
|
+
*
|
|
10
|
+
* ScrollPane コンポーネントの Props。
|
|
11
|
+
*/
|
|
12
|
+
export type ScrollPaneContentInsets = {
|
|
13
|
+
top?: number
|
|
14
|
+
bottom?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ScrollPaneProps = {
|
|
18
|
+
/**
|
|
19
|
+
* A function that renders the content of the scroll pane.
|
|
20
|
+
* It receives the current scroll position as an argument.
|
|
21
|
+
*
|
|
22
|
+
* スクロールペインのコンテンツをレンダリングする関数です。
|
|
23
|
+
* 現在のスクロール位置を引数として受け取ります。
|
|
24
|
+
*/
|
|
25
|
+
children: (scrollPosition: number) => React.ReactNode
|
|
26
|
+
/** The total size of the content. / コンテンツの総サイズ。 */
|
|
27
|
+
contentSize: number
|
|
28
|
+
/** The size of the visible area. / 表示領域のサイズ。 */
|
|
29
|
+
viewportSize: number
|
|
30
|
+
/** The width of the scrollbar. / スクロールバーの幅。 */
|
|
31
|
+
scrollBarWidth?: number
|
|
32
|
+
/** Whether grabbing the scrollbar thumb is allowed. / スクロールバーのつまみ操作を許可するかどうか。 */
|
|
33
|
+
enableThumbDrag?: boolean
|
|
34
|
+
/** A callback function that is called when the scroll position changes. / スクロール位置が変更されたときに呼び出されるコールバック関数。 */
|
|
35
|
+
onScroll?: (scrollPosition: number, prevPosition: number) => void
|
|
36
|
+
/** Additional class names for the component. / コンポーネントの追加のクラス名。 */
|
|
37
|
+
className?: string
|
|
38
|
+
/** Test id emitted as data-testid on the root element (DOM hooks must use data-* attributes, never class selectors). / ルート要素に data-testid として出力されるテスト ID (DOM フックはクラスセレクタでなく data-* 属性を使う)。 */
|
|
39
|
+
testId?: string
|
|
40
|
+
/** Custom styles for the component. / コンポーネントのカスタムスタイル。 */
|
|
41
|
+
style?: React.CSSProperties
|
|
42
|
+
/** A background element to be rendered behind the content. / コンテンツの背後にレンダリングされる背景要素。 */
|
|
43
|
+
background?: React.ReactNode
|
|
44
|
+
/** Configuration for the tap scroll circle. / タップサークルの設定。 */
|
|
45
|
+
tapScrollCircleOptions?: ScrollBarTapCircleOptions
|
|
46
|
+
/** Configuration for inertia scroll behavior. / 慣性スクロールの設定。 */
|
|
47
|
+
inertiaOptions?: ScrollPaneInertiaOptions
|
|
48
|
+
/** Total number of scrollable items (optional). / スクロール対象アイテムの総数(任意)。 */
|
|
49
|
+
itemCount?: number
|
|
50
|
+
/** Whether clicking the scrollbar track moves the thumb. / スクロールバートラックのクリック操作を許可するかどうか。 */
|
|
51
|
+
enableTrackClick?: boolean
|
|
52
|
+
/** Whether arrow buttons control the scroll position. / 矢印ボタンのスクロール操作を許可するかどうか。 */
|
|
53
|
+
enableArrowButtons?: boolean
|
|
54
|
+
/** Whether dragging the content area scrolls the pane. / コンテンツ領域のドラッグでスクロールさせるかどうか。 */
|
|
55
|
+
enablePointerDrag?: boolean
|
|
56
|
+
/** Optional renderer for thumb overlays. / サム付近に表示するオーバーレイのレンダラー。 */
|
|
57
|
+
renderThumbOverlay?: (props: ScrollBarThumbOverlayRenderProps) => React.ReactNode
|
|
58
|
+
/** Multiplier applied to wheel delta for faster or slower scrolling. / スクロール速度を調整するためのホイールデルタの倍率。 */
|
|
59
|
+
wheelSpeedMultiplier?: number
|
|
60
|
+
/**
|
|
61
|
+
* Callback delegating horizontal wheel/trackpad delta to an upstream owner.
|
|
62
|
+
* When provided, horizontal-dominant (or shift+wheel) gestures are consumed here
|
|
63
|
+
* (preventDefault + delegate) instead of being ignored; vertical behavior is unchanged.
|
|
64
|
+
* 横方向ホイール/トラックパッド量を上流へ委譲するコールバック。指定時のみ横成分を処理する。
|
|
65
|
+
*/
|
|
66
|
+
onWheelHorizontal?: (deltaX: number) => void
|
|
67
|
+
/** Insets applied to the scrollable content area. / スクロール可能領域に適用するインセット。 */
|
|
68
|
+
contentInsets?: ScrollPaneContentInsets
|
|
69
|
+
/** The index of the first visible item. / 最初の可視アイテムのインデックス。 */
|
|
70
|
+
visibleStartIndex?: number
|
|
71
|
+
/** The index of the last visible item. / 最後の可視アイテムのインデックス。 */
|
|
72
|
+
visibleEndIndex?: number
|
|
73
|
+
/** A function to render an overlay on top of the scroll pane. / スクロールペインの上にオーバーレイをレンダリングする関数。 */
|
|
74
|
+
renderOverlay?: () => React.ReactNode
|
|
75
|
+
/** Initial scroll position. / 初期のスクロール位置。 */
|
|
76
|
+
initialScrollPosition?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ScrollPaneInertiaOptions = {
|
|
80
|
+
/** Maximum velocity applied when inertia starts. / 慣性開始時に適用する最大速度。 */
|
|
81
|
+
maxVelocity?: number
|
|
82
|
+
/** Minimum velocity threshold to continue inertia. / 慣性を継続するための最小速度しきい値。 */
|
|
83
|
+
minVelocity?: number
|
|
84
|
+
/** Deceleration applied on every frame. / 各フレームで適用される減速度。 */
|
|
85
|
+
deceleration?: number
|
|
86
|
+
/** Sampling window in milliseconds for pointer velocity calculation. / ポインタの速度計算に用いるミリ秒単位のサンプリング期間。 */
|
|
87
|
+
velocitySampleWindow?: number
|
|
88
|
+
/** Minimum velocity required to start inertia. / 慣性を開始するために必要な最小速度。 */
|
|
89
|
+
startVelocityThreshold?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ResolvedScrollPaneInertiaOptions = Required<ScrollPaneInertiaOptions>
|
|
93
|
+
|
|
94
|
+
const DEFAULT_INERTIA_OPTIONS: ResolvedScrollPaneInertiaOptions = {
|
|
95
|
+
maxVelocity: 6,
|
|
96
|
+
minVelocity: 0.02,
|
|
97
|
+
deceleration: 0.0025,
|
|
98
|
+
velocitySampleWindow: 90,
|
|
99
|
+
startVelocityThreshold: 0.04,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ListenerEntry = [string, EventListenerOrEventListenerObject, AddEventListenerOptions | boolean | undefined]
|
|
103
|
+
|
|
104
|
+
type DragSample = {
|
|
105
|
+
clientY: number
|
|
106
|
+
time: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type DragState = {
|
|
110
|
+
pointerId: number | null
|
|
111
|
+
startClientY: number
|
|
112
|
+
startScroll: number
|
|
113
|
+
isDragging: boolean
|
|
114
|
+
shouldCancelNextClick: boolean
|
|
115
|
+
clickResetTimer: number | null
|
|
116
|
+
velocitySamples: DragSample[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Toggles event listeners on a given target.
|
|
121
|
+
*
|
|
122
|
+
* 指定ターゲットにイベントリスナーを追加または削除。
|
|
123
|
+
*/
|
|
124
|
+
const toggleListeners = (target: HTMLElement | Window, entries: ListenerEntry[], action: "add" | "remove") => {
|
|
125
|
+
for (const [type, listener, options] of entries) {
|
|
126
|
+
if (action === "add") {
|
|
127
|
+
target.addEventListener(type, listener, options)
|
|
128
|
+
} else {
|
|
129
|
+
target.removeEventListener(type, listener, options)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ScrollPaneHandle = {
|
|
135
|
+
scrollTo: (newPosition: number | ((prev: number) => number)) => number
|
|
136
|
+
getScrollPosition: () => number
|
|
137
|
+
getContentSize: () => number
|
|
138
|
+
getViewportSize: () => number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A component that provides a scrollable view with a custom scrollbar.
|
|
143
|
+
*
|
|
144
|
+
* カスタムスクロールバーを備えたスクロール可能なビューを提供するコンポーネントです。
|
|
145
|
+
*/
|
|
146
|
+
export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
147
|
+
(
|
|
148
|
+
{
|
|
149
|
+
children,
|
|
150
|
+
contentSize,
|
|
151
|
+
viewportSize,
|
|
152
|
+
scrollBarWidth = 12,
|
|
153
|
+
enableThumbDrag = true,
|
|
154
|
+
enableTrackClick = true,
|
|
155
|
+
enableArrowButtons = true,
|
|
156
|
+
enablePointerDrag = true,
|
|
157
|
+
onScroll,
|
|
158
|
+
className,
|
|
159
|
+
testId,
|
|
160
|
+
style,
|
|
161
|
+
background,
|
|
162
|
+
tapScrollCircleOptions,
|
|
163
|
+
inertiaOptions,
|
|
164
|
+
itemCount,
|
|
165
|
+
renderThumbOverlay,
|
|
166
|
+
wheelSpeedMultiplier = 1,
|
|
167
|
+
onWheelHorizontal,
|
|
168
|
+
contentInsets,
|
|
169
|
+
visibleStartIndex,
|
|
170
|
+
visibleEndIndex,
|
|
171
|
+
renderOverlay,
|
|
172
|
+
initialScrollPosition = 0,
|
|
173
|
+
},
|
|
174
|
+
ref,
|
|
175
|
+
) => {
|
|
176
|
+
// 負の initialScrollPosition は下限 0 にクランプして初回描画・ScrollBar への伝播を防ぐ
|
|
177
|
+
// Clamp negative initialScrollPosition to 0 so the first render / ScrollBar never receives a negative position.
|
|
178
|
+
const scrollPositionRef = useRef(Number.isFinite(initialScrollPosition) ? Math.max(0, initialScrollPosition) : 0)
|
|
179
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
180
|
+
const contentAreaRef = useRef<HTMLDivElement>(null)
|
|
181
|
+
// 横委譲コールバックは ref 経由で参照し、wheel リスナの再登録を避ける。
|
|
182
|
+
const onWheelHorizontalRef = useRef(onWheelHorizontal)
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
onWheelHorizontalRef.current = onWheelHorizontal
|
|
185
|
+
}, [onWheelHorizontal])
|
|
186
|
+
// generation は慣性ループの世代トークン。stopInertia のたびに進み、
|
|
187
|
+
// step 実行中に同期的な停止指示が入った場合の再アーム (停止の上書き) を防ぐ。
|
|
188
|
+
const inertiaStateRef = useRef<{ frame: number | null; velocity: number; lastTimestamp: number | null; generation: number }>({
|
|
189
|
+
frame: null,
|
|
190
|
+
velocity: 0,
|
|
191
|
+
lastTimestamp: null,
|
|
192
|
+
generation: 0,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const resolvedInertiaOptions = useMemo<ResolvedScrollPaneInertiaOptions>(
|
|
196
|
+
() => ({
|
|
197
|
+
maxVelocity: inertiaOptions?.maxVelocity ?? DEFAULT_INERTIA_OPTIONS.maxVelocity,
|
|
198
|
+
minVelocity: inertiaOptions?.minVelocity ?? DEFAULT_INERTIA_OPTIONS.minVelocity,
|
|
199
|
+
deceleration: inertiaOptions?.deceleration ?? DEFAULT_INERTIA_OPTIONS.deceleration,
|
|
200
|
+
velocitySampleWindow: inertiaOptions?.velocitySampleWindow ?? DEFAULT_INERTIA_OPTIONS.velocitySampleWindow,
|
|
201
|
+
startVelocityThreshold: inertiaOptions?.startVelocityThreshold ?? DEFAULT_INERTIA_OPTIONS.startVelocityThreshold,
|
|
202
|
+
}),
|
|
203
|
+
[inertiaOptions],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const resolvedInsets = useMemo<Required<ScrollPaneContentInsets>>(() => {
|
|
207
|
+
return {
|
|
208
|
+
top: Math.max(0, contentInsets?.top ?? 0),
|
|
209
|
+
bottom: Math.max(0, contentInsets?.bottom ?? 0),
|
|
210
|
+
}
|
|
211
|
+
}, [contentInsets])
|
|
212
|
+
|
|
213
|
+
// ログ引数はサンクで遅延評価し、DEBUG 抑制時のオブジェクト生成コストをゼロにする
|
|
214
|
+
Logger.debug("[ScrollPane] ScrollPane rendered", () => ({
|
|
215
|
+
contentSize,
|
|
216
|
+
viewportSize,
|
|
217
|
+
scrollBarWidth,
|
|
218
|
+
className,
|
|
219
|
+
style,
|
|
220
|
+
tapScrollCircleOptions,
|
|
221
|
+
inertiaOptions,
|
|
222
|
+
enablePointerDrag,
|
|
223
|
+
contentInsets: resolvedInsets,
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
// const size = useMemo(() => ({ contentSize, viewportSize }), [contentSize, viewportSize])
|
|
227
|
+
|
|
228
|
+
// contentSize と viewportSize を ref として保持
|
|
229
|
+
// const sizeRef = useRef({ contentSize, viewportSize })
|
|
230
|
+
|
|
231
|
+
const sizeRef = useRef({ contentSize, viewportSize })
|
|
232
|
+
sizeRef.current = { contentSize, viewportSize }
|
|
233
|
+
|
|
234
|
+
const isScrollable = useMemo(() => contentSize > viewportSize, [contentSize, viewportSize])
|
|
235
|
+
|
|
236
|
+
// useEffect(() => { // useEffect では contentSize や viewportSize の変更が反映される前にレンダリングされて位置がズレる可能性があるため、useLayoutEffect を使用
|
|
237
|
+
// Logger.debug("[ScrollPane] ScrollPane size updated", { contentSize, viewportSize })
|
|
238
|
+
// // サイズが変更されたときに ref を更新
|
|
239
|
+
// sizeRef.current = { contentSize, viewportSize }
|
|
240
|
+
// forceUpdate() // サイズが変更されたときに強制的に再レンダリング
|
|
241
|
+
// }, [contentSize, viewportSize])
|
|
242
|
+
|
|
243
|
+
// const scrollTo = useCallback(
|
|
244
|
+
// (newPosition: number | ((prev: number) => number)) => {
|
|
245
|
+
// const { contentSize: currentContentSize, viewportSize: currentViewportSize } = sizeRef.current
|
|
246
|
+
// const currentIsScrollable = currentContentSize > currentViewportSize
|
|
247
|
+
// const prevPosition = scrollPositionRef.current
|
|
248
|
+
|
|
249
|
+
// Logger.debug("[ScrollPane] scrollTo called", { newPosition, currentContentSize, currentViewportSize, currentIsScrollable, prevPosition })
|
|
250
|
+
|
|
251
|
+
// if (!currentIsScrollable) {
|
|
252
|
+
// // スクロール不可の場合は常に0に
|
|
253
|
+
// if (scrollPositionRef.current !== 0) {
|
|
254
|
+
// scrollPositionRef.current = 0
|
|
255
|
+
// onScroll?.(0, prevPosition)
|
|
256
|
+
// }
|
|
257
|
+
// return
|
|
258
|
+
// }
|
|
259
|
+
// const nextPosition = typeof newPosition === "function" ? newPosition(scrollPositionRef.current) : newPosition
|
|
260
|
+
// const newScrollPosition = minmax(nextPosition, 0, currentContentSize - currentViewportSize)
|
|
261
|
+
// if (scrollPositionRef.current !== newScrollPosition) {
|
|
262
|
+
// scrollPositionRef.current = newScrollPosition
|
|
263
|
+
// onScroll?.(newScrollPosition, prevPosition)
|
|
264
|
+
// }
|
|
265
|
+
// },
|
|
266
|
+
// [onScroll]
|
|
267
|
+
// )
|
|
268
|
+
|
|
269
|
+
const scrollTo = useCallback(
|
|
270
|
+
(newPosition: number | ((prev: number) => number)) => {
|
|
271
|
+
const { contentSize: currentContentSize, viewportSize: currentViewportSize } = sizeRef.current
|
|
272
|
+
const currentIsScrollable = currentContentSize > currentViewportSize
|
|
273
|
+
const prevPosition = scrollPositionRef.current
|
|
274
|
+
|
|
275
|
+
// ホットパスのためログ引数はサンクで遅延評価する
|
|
276
|
+
Logger.debug("[ScrollPane] scrollTo called", () => ({ newPosition, contentSize: currentContentSize, viewportSize: currentViewportSize, currentIsScrollable, prevPosition }))
|
|
277
|
+
|
|
278
|
+
if (!currentIsScrollable) {
|
|
279
|
+
// スクロール不可の場合は常に0に
|
|
280
|
+
if (scrollPositionRef.current !== 0) {
|
|
281
|
+
scrollPositionRef.current = 0
|
|
282
|
+
onScroll?.(0, prevPosition)
|
|
283
|
+
}
|
|
284
|
+
return scrollPositionRef.current
|
|
285
|
+
}
|
|
286
|
+
const nextPosition = typeof newPosition === "function" ? newPosition(scrollPositionRef.current) : newPosition
|
|
287
|
+
// 非有限値 (NaN/Infinity) は現位置を維持して弾く。NaN は minmax を素通しして
|
|
288
|
+
// ref/onScroll に伝播し、以後の相対スクロール (prev + delta) を NaN 連鎖させるため。
|
|
289
|
+
if (!Number.isFinite(nextPosition)) {
|
|
290
|
+
Logger.warn("[ScrollPane] scrollTo received a non-finite position; keeping the current position", { nextPosition })
|
|
291
|
+
return scrollPositionRef.current
|
|
292
|
+
}
|
|
293
|
+
const maxScrollPosition = Math.max(currentContentSize - currentViewportSize, 0)
|
|
294
|
+
const newScrollPosition = minmax(nextPosition, 0, maxScrollPosition)
|
|
295
|
+
if (scrollPositionRef.current !== newScrollPosition) {
|
|
296
|
+
scrollPositionRef.current = newScrollPosition
|
|
297
|
+
onScroll?.(newScrollPosition, prevPosition)
|
|
298
|
+
}
|
|
299
|
+
return scrollPositionRef.current
|
|
300
|
+
},
|
|
301
|
+
[onScroll],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const stopInertia = useCallback(() => {
|
|
305
|
+
const state = inertiaStateRef.current
|
|
306
|
+
// 世代を進め、実行中の step (cancelAnimationFrame が効かない同期再入) にも停止を伝える
|
|
307
|
+
state.generation += 1
|
|
308
|
+
if (state.frame !== null) {
|
|
309
|
+
cancelAnimationFrame(state.frame)
|
|
310
|
+
}
|
|
311
|
+
state.frame = null
|
|
312
|
+
state.velocity = 0
|
|
313
|
+
state.lastTimestamp = null
|
|
314
|
+
}, [])
|
|
315
|
+
|
|
316
|
+
const stopInertiaRef = useRef(stopInertia)
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
stopInertiaRef.current = stopInertia
|
|
319
|
+
}, [stopInertia])
|
|
320
|
+
|
|
321
|
+
// アンマウント時に慣性スクロールの RAF ループを停止し、破棄後に onScroll が発火し続けるのを防ぐ
|
|
322
|
+
// Stop the inertia RAF loop on unmount so onScroll cannot keep firing after the component is destroyed.
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
return () => {
|
|
325
|
+
stopInertiaRef.current()
|
|
326
|
+
}
|
|
327
|
+
}, [])
|
|
328
|
+
|
|
329
|
+
const startInertia = useCallback(
|
|
330
|
+
(initialVelocity: number) => {
|
|
331
|
+
if (!isScrollable) {
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { maxVelocity, minVelocity, deceleration, startVelocityThreshold } = resolvedInertiaOptions
|
|
336
|
+
|
|
337
|
+
const limitedVelocity = minmax(initialVelocity, -maxVelocity, maxVelocity)
|
|
338
|
+
if (Math.abs(limitedVelocity) < startVelocityThreshold) {
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
stopInertia()
|
|
343
|
+
|
|
344
|
+
inertiaStateRef.current.velocity = limitedVelocity
|
|
345
|
+
inertiaStateRef.current.lastTimestamp = null
|
|
346
|
+
// このループ固有の世代を捕捉する (stopInertia は generation を進めるため、
|
|
347
|
+
// 以降どこかで stopInertia が呼ばれると不一致になり step が自壊する)
|
|
348
|
+
const generation = inertiaStateRef.current.generation
|
|
349
|
+
|
|
350
|
+
const step = (timestamp: number) => {
|
|
351
|
+
const state = inertiaStateRef.current
|
|
352
|
+
// 停止指示済みの古い世代の step は何もしない (防御的チェック)
|
|
353
|
+
if (state.generation !== generation) {
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (state.lastTimestamp === null) {
|
|
357
|
+
state.lastTimestamp = timestamp
|
|
358
|
+
state.frame = requestAnimationFrame(step)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const deltaTime = timestamp - state.lastTimestamp
|
|
363
|
+
state.lastTimestamp = timestamp
|
|
364
|
+
|
|
365
|
+
if (deltaTime <= 0) {
|
|
366
|
+
state.frame = requestAnimationFrame(step)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const previousVelocity = state.velocity
|
|
371
|
+
let nextVelocity = previousVelocity
|
|
372
|
+
const decelerationAmount = deceleration * deltaTime
|
|
373
|
+
if (previousVelocity > 0) {
|
|
374
|
+
nextVelocity = Math.max(0, previousVelocity - decelerationAmount)
|
|
375
|
+
} else if (previousVelocity < 0) {
|
|
376
|
+
nextVelocity = Math.min(0, previousVelocity + decelerationAmount)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const averageVelocity = (previousVelocity + nextVelocity) / 2
|
|
380
|
+
const distance = averageVelocity * deltaTime
|
|
381
|
+
const previousPosition = scrollPositionRef.current
|
|
382
|
+
|
|
383
|
+
if (distance !== 0) {
|
|
384
|
+
scrollTo((prevPositionInternal) => prevPositionInternal + distance)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// scrollTo の onScroll から同期的に stopInertia (handle.scrollTo 等) が呼ばれた場合は
|
|
388
|
+
// 停止を尊重し、velocity の上書きも frame の再アームもしない (停止の黙殺を防ぐ)
|
|
389
|
+
if (inertiaStateRef.current.generation !== generation) {
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const nextPosition = scrollPositionRef.current
|
|
394
|
+
const { contentSize: currentContentSize, viewportSize: currentViewportSize } = sizeRef.current
|
|
395
|
+
const maxScrollPosition = Math.max(currentContentSize - currentViewportSize, 0)
|
|
396
|
+
|
|
397
|
+
state.velocity = nextVelocity
|
|
398
|
+
|
|
399
|
+
const reachedBoundary = nextPosition === previousPosition || (nextPosition <= 0 && nextVelocity <= 0) || (nextPosition >= maxScrollPosition && nextVelocity >= 0)
|
|
400
|
+
|
|
401
|
+
if (Math.abs(nextVelocity) < minVelocity || reachedBoundary) {
|
|
402
|
+
stopInertia()
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
state.frame = requestAnimationFrame(step)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
inertiaStateRef.current.frame = requestAnimationFrame(step)
|
|
410
|
+
},
|
|
411
|
+
[isScrollable, resolvedInertiaOptions, scrollTo, stopInertia],
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
const startInertiaRef = useRef(startInertia)
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
startInertiaRef.current = startInertia
|
|
417
|
+
}, [startInertia])
|
|
418
|
+
|
|
419
|
+
useLayoutEffect(() => {
|
|
420
|
+
// contentSize と viewportSize の最新値を保持して scrollTo の参照を安定させる
|
|
421
|
+
sizeRef.current = { contentSize, viewportSize }
|
|
422
|
+
}, [contentSize, viewportSize])
|
|
423
|
+
|
|
424
|
+
// Prevent native scrolling on the content area.
|
|
425
|
+
// Since the content is positioned absolutely, the container's scrollTop should always be 0.
|
|
426
|
+
// However, browsers may sometimes automatically scroll the container (e.g., when DevTools is open, or due to focus management).
|
|
427
|
+
// This effect detects such native scroll events and forces scrollTop back to 0.
|
|
428
|
+
//
|
|
429
|
+
// コンテンツ領域のネイティブスクロールを防止します。
|
|
430
|
+
// コンテンツは絶対配置されているため、コンテナの scrollTop は常に 0 である必要があります。
|
|
431
|
+
// しかし、ブラウザは時折(例:DevToolsが開いている場合やフォーカス管理などにより)コンテナを自動的にスクロールさせることがあります。
|
|
432
|
+
// このエフェクトは、そのようなネイティブスクロールイベントを検出し、scrollTop を強制的に 0 にリセットします。
|
|
433
|
+
useLayoutEffect(() => {
|
|
434
|
+
const element = contentAreaRef.current
|
|
435
|
+
if (!element) return
|
|
436
|
+
|
|
437
|
+
const handleScroll = () => {
|
|
438
|
+
if (element.scrollTop !== 0) {
|
|
439
|
+
// ホットパスのためログ引数はサンクで遅延評価する
|
|
440
|
+
Logger.debug("[ScrollPane] Native scroll detected, resetting to 0", () => ({ scrollTop: element.scrollTop }))
|
|
441
|
+
element.scrollTop = 0
|
|
442
|
+
}
|
|
443
|
+
if (element.scrollLeft !== 0) {
|
|
444
|
+
element.scrollLeft = 0
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
element.addEventListener("scroll", handleScroll)
|
|
449
|
+
return () => element.removeEventListener("scroll", handleScroll)
|
|
450
|
+
}, [])
|
|
451
|
+
|
|
452
|
+
// contentSize または viewportSize が変更されたときにスクロール位置を調整します。
|
|
453
|
+
useLayoutEffect(() => {
|
|
454
|
+
// useEffect では contentSize や viewportSize の変更が反映される前にレンダリングされて位置がズレる可能性があるため、useLayoutEffect を使用。フリッカー防止も。
|
|
455
|
+
if (isScrollable) {
|
|
456
|
+
// ログ引数はサンクで遅延評価し、抑制時のオブジェクト生成を回避する
|
|
457
|
+
Logger.debug("[ScrollPane] Adjusting scroll position due to content or viewport size change", () => ({ contentSize, viewportSize, scrollPosition: scrollPositionRef.current }))
|
|
458
|
+
const maxScrollPosition = minmax(contentSize - viewportSize, 0, contentSize)
|
|
459
|
+
if (scrollPositionRef.current > maxScrollPosition) {
|
|
460
|
+
scrollTo(maxScrollPosition)
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
scrollTo(0)
|
|
464
|
+
}
|
|
465
|
+
}, [isScrollable, scrollTo, contentSize, viewportSize])
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
// ホイールイベントのハンドラ
|
|
469
|
+
const handleWheel = (event: WheelEvent) => {
|
|
470
|
+
// 横委譲: ハンドラがある場合のみ、横優勢ジェスチャ (または shift+ホイール) を横として処理する。
|
|
471
|
+
// ハンドラ未指定時はこのブロックを丸ごとスキップし、既存の縦挙動と完全一致する。
|
|
472
|
+
// ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
|
|
473
|
+
if (onWheelHorizontalRef.current && !event.ctrlKey) {
|
|
474
|
+
const usesShiftAxis = event.shiftKey && event.deltaX === 0
|
|
475
|
+
const horizontalDelta = usesShiftAxis ? event.deltaY : event.deltaX
|
|
476
|
+
const verticalDelta = usesShiftAxis ? 0 : event.deltaY
|
|
477
|
+
if (horizontalDelta !== 0 && Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) {
|
|
478
|
+
event.preventDefault()
|
|
479
|
+
stopInertia()
|
|
480
|
+
let deltaX = horizontalDelta
|
|
481
|
+
if (event.deltaMode === 1) {
|
|
482
|
+
deltaX *= 16 // DOM_DELTA_LINE
|
|
483
|
+
} else if (event.deltaMode === 2) {
|
|
484
|
+
deltaX *= viewportSize // DOM_DELTA_PAGE
|
|
485
|
+
}
|
|
486
|
+
onWheelHorizontalRef.current(deltaX)
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!isScrollable) {
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ctrl+wheel はブラウザのピンチズームなので横取りしない
|
|
496
|
+
// ctrl+wheel is browser pinch-zoom, so leave it to the browser.
|
|
497
|
+
if (event.ctrlKey) {
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 縦方向の移動が無いホイール(水平スワイプ等)は祖先のスクロールへ委ねる
|
|
502
|
+
// A wheel event without vertical delta (e.g. horizontal swipe) is left to ancestor scrolling.
|
|
503
|
+
if (event.deltaY === 0) {
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
event.preventDefault()
|
|
508
|
+
|
|
509
|
+
stopInertia()
|
|
510
|
+
|
|
511
|
+
let deltaY = event.deltaY
|
|
512
|
+
|
|
513
|
+
// deltaMode に応じてスクロール量を調整
|
|
514
|
+
if (event.deltaMode === 1) {
|
|
515
|
+
// DOM_DELTA_LINE: 行単位のスクロール
|
|
516
|
+
const lineHeight = 16 // 1行のおおよその高さ (ピクセル)
|
|
517
|
+
deltaY *= lineHeight
|
|
518
|
+
} else if (event.deltaMode === 2) {
|
|
519
|
+
// DOM_DELTA_PAGE: ページ単位のスクロール
|
|
520
|
+
deltaY *= viewportSize // ビューポートの高さを基準にスクロール
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (wheelSpeedMultiplier !== 1) {
|
|
524
|
+
deltaY *= wheelSpeedMultiplier
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
|
|
528
|
+
Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
|
|
529
|
+
|
|
530
|
+
// スクロール位置を更新
|
|
531
|
+
scrollTo((prev) => prev + deltaY)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const scrollContainer = scrollContainerRef.current
|
|
535
|
+
if (scrollContainer) {
|
|
536
|
+
// wheel イベントリスナーを passive: false で登録し、preventDefault を可能にする
|
|
537
|
+
scrollContainer.addEventListener("wheel", handleWheel, { passive: false })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// クリーンアップ関数
|
|
541
|
+
return () => {
|
|
542
|
+
if (scrollContainer) {
|
|
543
|
+
scrollContainer.removeEventListener("wheel", handleWheel)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}, [isScrollable, scrollTo, stopInertia, viewportSize, wheelSpeedMultiplier])
|
|
547
|
+
|
|
548
|
+
useImperativeHandle(
|
|
549
|
+
ref,
|
|
550
|
+
() => ({
|
|
551
|
+
scrollTo: (pos) => {
|
|
552
|
+
stopInertia()
|
|
553
|
+
return scrollTo(pos)
|
|
554
|
+
},
|
|
555
|
+
getScrollPosition: () => scrollPositionRef.current,
|
|
556
|
+
getContentSize: () => contentSize,
|
|
557
|
+
getViewportSize: () => viewportSize,
|
|
558
|
+
}),
|
|
559
|
+
[scrollTo, contentSize, viewportSize, stopInertia],
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
const scrollToRef = useRef(scrollTo)
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
scrollToRef.current = scrollTo
|
|
565
|
+
}, [scrollTo])
|
|
566
|
+
|
|
567
|
+
const id = useId()
|
|
568
|
+
|
|
569
|
+
const dragStateRef = useRef<DragState>({
|
|
570
|
+
pointerId: null,
|
|
571
|
+
startClientY: 0,
|
|
572
|
+
startScroll: 0,
|
|
573
|
+
isDragging: false,
|
|
574
|
+
shouldCancelNextClick: false,
|
|
575
|
+
clickResetTimer: null,
|
|
576
|
+
velocitySamples: [],
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
const enablePointerDragRef = useRef(enablePointerDrag)
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
enablePointerDragRef.current = enablePointerDrag
|
|
582
|
+
}, [enablePointerDrag])
|
|
583
|
+
|
|
584
|
+
const isScrollableRef = useRef(isScrollable)
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
isScrollableRef.current = isScrollable
|
|
587
|
+
}, [isScrollable])
|
|
588
|
+
|
|
589
|
+
const inertiaOptionsRef = useRef(resolvedInertiaOptions)
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
inertiaOptionsRef.current = resolvedInertiaOptions
|
|
592
|
+
}, [resolvedInertiaOptions])
|
|
593
|
+
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
if (enablePointerDrag) {
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const element = contentAreaRef.current
|
|
599
|
+
const state = dragStateRef.current
|
|
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)
|
|
604
|
+
}
|
|
605
|
+
if (state.clickResetTimer !== null) {
|
|
606
|
+
window.clearTimeout(state.clickResetTimer)
|
|
607
|
+
state.clickResetTimer = null
|
|
608
|
+
}
|
|
609
|
+
state.pointerId = null
|
|
610
|
+
state.startClientY = 0
|
|
611
|
+
state.startScroll = 0
|
|
612
|
+
state.isDragging = false
|
|
613
|
+
state.shouldCancelNextClick = false
|
|
614
|
+
state.velocitySamples = []
|
|
615
|
+
}, [enablePointerDrag])
|
|
616
|
+
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
const element = contentAreaRef.current
|
|
619
|
+
if (!element) {
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const DRAG_ACTIVATION_THRESHOLD = 6
|
|
624
|
+
const getTimestamp = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now())
|
|
625
|
+
|
|
626
|
+
const resetDragState = () => {
|
|
627
|
+
const state = dragStateRef.current
|
|
628
|
+
state.pointerId = null
|
|
629
|
+
state.startClientY = 0
|
|
630
|
+
state.startScroll = 0
|
|
631
|
+
state.isDragging = false
|
|
632
|
+
state.velocitySamples = []
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const clearClickTimer = () => {
|
|
636
|
+
const state = dragStateRef.current
|
|
637
|
+
if (state.clickResetTimer !== null) {
|
|
638
|
+
window.clearTimeout(state.clickResetTimer)
|
|
639
|
+
state.clickResetTimer = null
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pushVelocitySample = (clientY: number) => {
|
|
644
|
+
const state = dragStateRef.current
|
|
645
|
+
const timestamp = getTimestamp()
|
|
646
|
+
state.velocitySamples.push({ clientY, time: timestamp })
|
|
647
|
+
const windowMs = inertiaOptionsRef.current.velocitySampleWindow
|
|
648
|
+
state.velocitySamples = state.velocitySamples.filter((sample) => timestamp - sample.time <= windowMs)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const shouldIgnoreTarget = (target: EventTarget | null) => target instanceof HTMLElement && target.closest("[data-scrollpane-ignore-drag='true']") !== null
|
|
652
|
+
|
|
653
|
+
const handleClickCapture = (event: MouseEvent) => {
|
|
654
|
+
const state = dragStateRef.current
|
|
655
|
+
if (!state.shouldCancelNextClick) {
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
event.preventDefault()
|
|
659
|
+
event.stopPropagation()
|
|
660
|
+
state.shouldCancelNextClick = false
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const startDragging = (event: PointerEvent) => {
|
|
664
|
+
const state = dragStateRef.current
|
|
665
|
+
if (state.isDragging) {
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
state.isDragging = true
|
|
669
|
+
state.shouldCancelNextClick = true
|
|
670
|
+
if (!element.hasPointerCapture(event.pointerId)) {
|
|
671
|
+
// 注意: キャプチャは pointerdown 時ではなくドラッグ確定時に取得する。
|
|
672
|
+
// pointerdown で即キャプチャすると click がキャプチャ要素へリターゲットされ、
|
|
673
|
+
// ペイン内の子要素 (行など) の click ハンドラが一切発火しなくなる (Chromium 実測)。
|
|
674
|
+
// ドラッグ確定後は shouldCancelNextClick で click を意図的に抑止するため副作用がない。
|
|
675
|
+
try {
|
|
676
|
+
element.setPointerCapture(event.pointerId)
|
|
677
|
+
} catch {
|
|
678
|
+
// ポインタが既に非アクティブだと NotFoundError になり得る。キャプチャ無しでもドラッグは継続可能。
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
pushVelocitySample(event.clientY)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// pointerup を受け取れないままドラッグ継続が不可能になったときの共通終了処理。
|
|
685
|
+
// Shared teardown for cases where the drag can no longer continue without receiving a pointerup.
|
|
686
|
+
const abortDrag = (pointerId: number) => {
|
|
687
|
+
const state = dragStateRef.current
|
|
688
|
+
if (element.hasPointerCapture(pointerId)) {
|
|
689
|
+
element.releasePointerCapture(pointerId)
|
|
690
|
+
}
|
|
691
|
+
state.shouldCancelNextClick = false
|
|
692
|
+
clearClickTimer()
|
|
693
|
+
resetDragState()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
697
|
+
const state = dragStateRef.current
|
|
698
|
+
if (state.pointerId !== event.pointerId) {
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
// ボタン非押下の pointermove は pointerup の喪失 (iframe 上での解放・Alt+Tab 等) を意味する。
|
|
702
|
+
// stale な dragState を破棄し、hover 移動がカーソル追従スクロール化するのを根絶する。
|
|
703
|
+
if (event.buttons === 0) {
|
|
704
|
+
abortDrag(event.pointerId)
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
if (!(enablePointerDragRef.current && isScrollableRef.current)) {
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
if (!state.isDragging) {
|
|
711
|
+
const distanceY = Math.abs(event.clientY - state.startClientY)
|
|
712
|
+
if (distanceY < DRAG_ACTIVATION_THRESHOLD) {
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
startDragging(event)
|
|
716
|
+
if (!state.isDragging) {
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
pushVelocitySample(event.clientY)
|
|
722
|
+
|
|
723
|
+
const deltaY = event.clientY - state.startClientY
|
|
724
|
+
const nextPosition = state.startScroll - deltaY
|
|
725
|
+
scrollToRef.current(nextPosition)
|
|
726
|
+
|
|
727
|
+
if (event.cancelable) {
|
|
728
|
+
event.preventDefault()
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const handlePointerUp = (event: PointerEvent) => {
|
|
733
|
+
const state = dragStateRef.current
|
|
734
|
+
if (state.pointerId !== event.pointerId) {
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (state.isDragging && state.shouldCancelNextClick && event.cancelable) {
|
|
739
|
+
event.preventDefault()
|
|
740
|
+
event.stopPropagation()
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 解放時刻を基準にサンプルを再枝刈りする。pushVelocitySample の枝刈りは
|
|
744
|
+
// 「最後の pointermove の時刻」基準のため、ドラッグ後に静止したまま離すと
|
|
745
|
+
// 古いサンプルが残り、静止前の速度で慣性が誤発火してしまう。
|
|
746
|
+
// 静止していた場合は解放時のサンプル 1 件だけが残り、下の length >= 2 ガードで慣性が発火しない。
|
|
747
|
+
if (state.isDragging) {
|
|
748
|
+
pushVelocitySample(event.clientY)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let inertiaVelocity = 0
|
|
752
|
+
if (state.isDragging && state.velocitySamples.length >= 2) {
|
|
753
|
+
const samples = state.velocitySamples
|
|
754
|
+
const windowMs = inertiaOptionsRef.current.velocitySampleWindow
|
|
755
|
+
const lastSample = samples[samples.length - 1]
|
|
756
|
+
const firstSample = samples.find((sample) => lastSample.time - sample.time <= windowMs) ?? samples[0]
|
|
757
|
+
if (lastSample && firstSample && lastSample.time !== firstSample.time) {
|
|
758
|
+
const deltaClientY = lastSample.clientY - firstSample.clientY
|
|
759
|
+
const deltaTime = lastSample.time - firstSample.time
|
|
760
|
+
inertiaVelocity = -(deltaClientY / deltaTime)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
clearClickTimer()
|
|
765
|
+
if (state.shouldCancelNextClick) {
|
|
766
|
+
state.clickResetTimer = window.setTimeout(() => {
|
|
767
|
+
const latest = dragStateRef.current
|
|
768
|
+
latest.shouldCancelNextClick = false
|
|
769
|
+
latest.clickResetTimer = null
|
|
770
|
+
}, 0)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const threshold = inertiaOptionsRef.current.startVelocityThreshold
|
|
774
|
+
// resetDragState で pointerId を null にしてからキャプチャを解放する (意図的解放)。
|
|
775
|
+
// releasePointerCapture が lostpointercapture を同期発火するブラウザでも、
|
|
776
|
+
// handleLostPointerCapture は pointerId 不一致で無視されるため、abortDrag による
|
|
777
|
+
// shouldCancelNextClick / clickResetTimer の破棄 (click 抑止の喪失) が起きない。
|
|
778
|
+
resetDragState()
|
|
779
|
+
if (element.hasPointerCapture(event.pointerId)) {
|
|
780
|
+
element.releasePointerCapture(event.pointerId)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (Math.abs(inertiaVelocity) >= threshold) {
|
|
784
|
+
startInertiaRef.current?.(inertiaVelocity)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
789
|
+
// アクティブなドラッグ中 (pointerId 保持中) の別ポインタの pointerdown は無視する (先勝ち)。
|
|
790
|
+
// 上書きを許すと 1 本目のドラッグが乗っ取られ、shouldCancelNextClick のクリアで
|
|
791
|
+
// 1 本目解放時の click 抑止も失われるため。
|
|
792
|
+
if (dragStateRef.current.pointerId !== null) {
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
if (!(enablePointerDragRef.current && isScrollableRef.current)) {
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
if (event.button !== 0 && event.pointerType === "mouse") {
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
if (shouldIgnoreTarget(event.target)) {
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
window.dispatchEvent(new CustomEvent(TAP_SCROLL_CANCEL_EVENT, { detail: { paneId: id } }))
|
|
809
|
+
|
|
810
|
+
stopInertiaRef.current?.()
|
|
811
|
+
|
|
812
|
+
const state = dragStateRef.current
|
|
813
|
+
clearClickTimer()
|
|
814
|
+
state.pointerId = event.pointerId
|
|
815
|
+
state.startClientY = event.clientY
|
|
816
|
+
state.startScroll = scrollPositionRef.current
|
|
817
|
+
state.isDragging = false
|
|
818
|
+
state.shouldCancelNextClick = false
|
|
819
|
+
state.velocitySamples = []
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const handlePointerCancel = (event: PointerEvent) => {
|
|
823
|
+
const state = dragStateRef.current
|
|
824
|
+
if (state.pointerId !== event.pointerId) {
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
abortDrag(event.pointerId)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const handleLostPointerCapture = (event: PointerEvent) => {
|
|
831
|
+
const state = dragStateRef.current
|
|
832
|
+
if (state.pointerId !== event.pointerId) {
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
// OS/ブラウザ都合でキャプチャが強制解放されると pointerup/pointercancel が届かないことがある。
|
|
836
|
+
// capture 喪失=ドラッグ終了に統一し、stale な dragState が残らないようにする。
|
|
837
|
+
abortDrag(event.pointerId)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// pointermove/up/cancel は window のみに登録する。
|
|
841
|
+
// ポインターキャプチャ取得後はイベントが element にリターゲットされ window までバブリングするため、
|
|
842
|
+
// element にも登録すると 1 回のイベントを二重処理してしまう。window 側だけでキャプチャ未取得時のペイン外追従もカバーできる。
|
|
843
|
+
// Register pointermove/up/cancel on window only. After pointer capture, events retarget to the
|
|
844
|
+
// element and bubble to window, so registering on both would process each event twice.
|
|
845
|
+
// window alone also covers out-of-pane tracking when capture has not been acquired.
|
|
846
|
+
const elementEntries: ListenerEntry[] = [
|
|
847
|
+
["click", handleClickCapture as EventListenerOrEventListenerObject, true],
|
|
848
|
+
["pointerdown", handlePointerDown as EventListenerOrEventListenerObject, { passive: false }],
|
|
849
|
+
["lostpointercapture", handleLostPointerCapture as EventListenerOrEventListenerObject, undefined],
|
|
850
|
+
]
|
|
851
|
+
const windowEntries: ListenerEntry[] = [
|
|
852
|
+
["pointermove", handlePointerMove as EventListenerOrEventListenerObject, { passive: false }],
|
|
853
|
+
["pointerup", handlePointerUp as EventListenerOrEventListenerObject, undefined],
|
|
854
|
+
["pointercancel", handlePointerCancel as EventListenerOrEventListenerObject, undefined],
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
toggleListeners(element, elementEntries, "add")
|
|
858
|
+
toggleListeners(window, windowEntries, "add")
|
|
859
|
+
|
|
860
|
+
return () => {
|
|
861
|
+
toggleListeners(element, elementEntries, "remove")
|
|
862
|
+
toggleListeners(window, windowEntries, "remove")
|
|
863
|
+
const state = dragStateRef.current
|
|
864
|
+
if (state.pointerId !== null && element.hasPointerCapture(state.pointerId)) {
|
|
865
|
+
element.releasePointerCapture(state.pointerId)
|
|
866
|
+
}
|
|
867
|
+
clearClickTimer()
|
|
868
|
+
resetDragState()
|
|
869
|
+
}
|
|
870
|
+
}, [id])
|
|
871
|
+
|
|
872
|
+
return (
|
|
873
|
+
<div ref={scrollContainerRef} data-testid={testId} className={twMerge("aqvs-scroll-pane", className)} style={style}>
|
|
874
|
+
<div
|
|
875
|
+
ref={contentAreaRef}
|
|
876
|
+
className={twMerge("aqvs-scroll-pane-content")}
|
|
877
|
+
style={{
|
|
878
|
+
height: viewportSize,
|
|
879
|
+
paddingTop: resolvedInsets.top,
|
|
880
|
+
paddingBottom: resolvedInsets.bottom,
|
|
881
|
+
// スクロール不能時 (contentSize <= viewportSize) は touch-action を通常に戻し、
|
|
882
|
+
// 短いリスト上のタッチがページ全体のパンを妨げるデッドゾーン化を防ぐ
|
|
883
|
+
...(enablePointerDrag && isScrollable ? { touchAction: "none" } : {}),
|
|
884
|
+
}}
|
|
885
|
+
id={id}>
|
|
886
|
+
{background}
|
|
887
|
+
{children(scrollPositionRef.current)}
|
|
888
|
+
</div>
|
|
889
|
+
<ScrollBar
|
|
890
|
+
key="scrollbar"
|
|
891
|
+
contentSize={contentSize}
|
|
892
|
+
viewportSize={viewportSize}
|
|
893
|
+
scrollPosition={scrollPositionRef.current}
|
|
894
|
+
onScroll={scrollTo}
|
|
895
|
+
enableThumbDrag={enableThumbDrag}
|
|
896
|
+
enableTrackClick={enableTrackClick}
|
|
897
|
+
enableArrowButtons={enableArrowButtons}
|
|
898
|
+
scrollBarWidth={scrollBarWidth}
|
|
899
|
+
ariaControls={id}
|
|
900
|
+
tapScrollCircleOptions={tapScrollCircleOptions}
|
|
901
|
+
itemCount={itemCount}
|
|
902
|
+
renderThumbOverlay={renderThumbOverlay}
|
|
903
|
+
visibleStartIndex={visibleStartIndex}
|
|
904
|
+
visibleEndIndex={visibleEndIndex}
|
|
905
|
+
/>
|
|
906
|
+
{renderOverlay?.()}
|
|
907
|
+
</div>
|
|
908
|
+
)
|
|
909
|
+
},
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
// TODO: 上からスクロールと下からスクロールで fenwick ツリーの走査方向とスクロールペインの描画位置の基準を変えてパフォーマンスを最適化する
|
|
913
|
+
// TODO: 矢印キーでのスクロールで下スクロールと上スクロールのページ送りの挙動が異なるのを修正またはオプション化する
|