@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.
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 +23 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +9 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +296 -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,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: 矢印キーでのスクロールで下スクロールと上スクロールのページ送りの挙動が異なるのを修正またはオプション化する