@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,1397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides a customizable virtual scrollbar component with auxiliary tap scrolling features.
|
|
3
|
+
*
|
|
4
|
+
* カスタマイズ可能な仮想スクロールバーと補助的なタップスクロール機能を提供。
|
|
5
|
+
*/
|
|
6
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
7
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
8
|
+
import { twMerge } from "tailwind-merge"
|
|
9
|
+
import { Logger } from "./logger.ts"
|
|
10
|
+
import { TapScrollCircle, type TapScrollCircleDragState, type TapScrollCircleHandle, type TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
|
|
11
|
+
import { minmax } from "./utils.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration for scrollbar orientation (vertical or horizontal).
|
|
15
|
+
*
|
|
16
|
+
* スクロールバーの向き(垂直または水平)に関する設定。
|
|
17
|
+
*/
|
|
18
|
+
type OrientationConfig = {
|
|
19
|
+
mainSizeKey: "width" | "height"
|
|
20
|
+
crossSizeKey: "width" | "height"
|
|
21
|
+
positionKey: "left" | "top"
|
|
22
|
+
selectDelta: (deltaX: number, deltaY: number) => number
|
|
23
|
+
getPointerCoordinate: (point: { clientX: number; clientY: number }) => number
|
|
24
|
+
arrowLabels: [string, string]
|
|
25
|
+
arrowIcons: [string, string]
|
|
26
|
+
directionClass: string
|
|
27
|
+
orientation: "vertical" | "horizontal"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options for arrow button auto-repeat behavior.
|
|
32
|
+
*
|
|
33
|
+
* 矢印ボタンの自動リピート動作に関するオプション。
|
|
34
|
+
*/
|
|
35
|
+
type ArrowAutoRepeatOptions = {
|
|
36
|
+
/** Whether arrow buttons interaction is allowed / 矢印ボタンの使用が許可されているか */
|
|
37
|
+
canUseArrowButtons: boolean
|
|
38
|
+
/** Whether arrow buttons are enabled in configuration / 設定で矢印ボタンが有効化されているか */
|
|
39
|
+
enableArrowButtons: boolean
|
|
40
|
+
/** Function to reset tap scroll state / タップスクロール状態をリセットする関数 */
|
|
41
|
+
resetTapScroll: () => void
|
|
42
|
+
/** Function to scroll by a step / ステップ単位でスクロールする関数 */
|
|
43
|
+
scrollByStep: (direction: 1 | -1) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handlers for arrow button interactions.
|
|
48
|
+
*
|
|
49
|
+
* 矢印ボタンのインタラクション用ハンドラー。
|
|
50
|
+
*/
|
|
51
|
+
type ArrowAutoRepeatHandlers = {
|
|
52
|
+
handleArrowPointerDown: (direction: 1 | -1) => (event: React.PointerEvent<HTMLButtonElement>) => void
|
|
53
|
+
handleArrowPointerUp: () => void
|
|
54
|
+
handleArrowKeyDown: (direction: 1 | -1) => (event: React.KeyboardEvent<HTMLButtonElement>) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Options for visual feedback on the scroll thumb.
|
|
59
|
+
*
|
|
60
|
+
* スクロールつまみの視覚的フィードバックに関するオプション。
|
|
61
|
+
*/
|
|
62
|
+
type ThumbVisualFeedbackOptions = {
|
|
63
|
+
isDragging: boolean
|
|
64
|
+
isThumbHovered: boolean
|
|
65
|
+
enableThumbDrag: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The state of the scroll thumb for styling purposes.
|
|
70
|
+
*
|
|
71
|
+
* スタイリング目的のスクロールつまみの状態。
|
|
72
|
+
*/
|
|
73
|
+
type ScrollBarThumbState = "disabled" | "idle" | "hover" | "dragging"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Options to customize the auxiliary tap scroll circle.
|
|
77
|
+
*
|
|
78
|
+
* タップスクロール用サークルをカスタマイズするためのオプション。
|
|
79
|
+
*/
|
|
80
|
+
export type ScrollBarTapCircleOptions = {
|
|
81
|
+
/** Enable or disable the tap scroll circle. / タップサークルを有効化または無効化。 */
|
|
82
|
+
enabled?: boolean
|
|
83
|
+
/** Circle diameter in pixels. / サークルの直径 (ピクセル)。 */
|
|
84
|
+
size?: number
|
|
85
|
+
/** Horizontal offset in pixels. / 水平方向オフセット (ピクセル)。 */
|
|
86
|
+
offsetX?: number
|
|
87
|
+
/** Vertical offset in pixels. / 垂直方向オフセット (ピクセル)。 */
|
|
88
|
+
offsetY?: number
|
|
89
|
+
/** Additional class names for the circle. / サークルの追加クラス名。 */
|
|
90
|
+
className?: string
|
|
91
|
+
/** Maximum drag distance used for visual feedback. / ビジュアルフィードバックに利用する最大ドラッグ距離。 */
|
|
92
|
+
maxVisualDistance?: number
|
|
93
|
+
/** Maximum speed multiplier relative to viewport size. / ビューポートサイズに対する最大速度倍率。 */
|
|
94
|
+
maxSpeedMultiplier?: number
|
|
95
|
+
/** Minimum speed multiplier relative to viewport size. / ビューポートサイズに対する最小速度倍率。 */
|
|
96
|
+
minSpeedMultiplier?: number
|
|
97
|
+
/** Base opacity multiplier for the circle visuals. / サークル表示の基準透明度倍率。 */
|
|
98
|
+
opacity?: number
|
|
99
|
+
/** Custom visual renderer for the tap circle. / タップサークルのカスタムビジュアルのレンダラー。 */
|
|
100
|
+
renderVisual?: (props: TapScrollCircleRenderProps) => ReactNode
|
|
101
|
+
/** Optional exponential cap configuration for max speed. / 最大速度に指数関数的な上限を設定するためのオプション設定。 */
|
|
102
|
+
maxSpeedCurve?: {
|
|
103
|
+
/** Steepness factor for the exponential curve. / 指数関数の急峻さを決定する要素。 */
|
|
104
|
+
exponentialSteepness: number
|
|
105
|
+
/** Scale factor for the exponential curve. / 指数関数のスケールファクター。 */
|
|
106
|
+
exponentialScale?: number
|
|
107
|
+
/** Additional eased offset to apply to the curve. / 曲線に適用する追加のイーズドオフセット。 */
|
|
108
|
+
easedOffset?: number
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type ResolvedTapScrollCircleOptions = Required<Omit<ScrollBarTapCircleOptions, "className" | "maxVisualDistance" | "renderVisual" | "maxSpeedCurve">> & {
|
|
113
|
+
className?: string
|
|
114
|
+
maxVisualDistance: number
|
|
115
|
+
renderVisual?: ScrollBarTapCircleOptions["renderVisual"]
|
|
116
|
+
maxSpeedCurve?: ScrollBarTapCircleOptions["maxSpeedCurve"]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type TapScrollCancelEventDetail = {
|
|
120
|
+
paneId?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Custom event name emitted when external interactions should cancel tap scrolling. / 外部操作によってタップスクロールをキャンセルすべき際に発行されるカスタムイベント名。 */
|
|
124
|
+
export const TAP_SCROLL_CANCEL_EVENT = "virtualscroll:tap-scroll-cancel"
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Props for the ScrollBar component.
|
|
128
|
+
*
|
|
129
|
+
* ScrollBar コンポーネントの Props。
|
|
130
|
+
*/
|
|
131
|
+
export type ScrollBarThumbOverlayRenderProps = {
|
|
132
|
+
orientation: "vertical" | "horizontal"
|
|
133
|
+
scrollPosition: number
|
|
134
|
+
maxScrollPosition: number
|
|
135
|
+
contentSize: number
|
|
136
|
+
viewportSize: number
|
|
137
|
+
thumbSize: number
|
|
138
|
+
thumbPosition: number
|
|
139
|
+
thumbCenter: number
|
|
140
|
+
trackSize: number
|
|
141
|
+
isDragging: boolean
|
|
142
|
+
isTapScrollActive: boolean
|
|
143
|
+
visibleStartIndex?: number
|
|
144
|
+
visibleEndIndex?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ScrollBarProps = {
|
|
148
|
+
/** The total size of the content. / コンテンツの総サイズ。 */
|
|
149
|
+
contentSize: number
|
|
150
|
+
/** The size of the visible area. / 表示領域のサイズ。 */
|
|
151
|
+
viewportSize: number
|
|
152
|
+
/** The current scroll position. / 現在のスクロール位置。 */
|
|
153
|
+
scrollPosition: number
|
|
154
|
+
/** A callback function invoked to adjust scroll position. Accepts next position or updater. / スクロール位置を調整するためのコールバック。次位置または更新関数を受け取る。 */
|
|
155
|
+
onScroll?: (scrollPosition: number | ((prevPosition: number) => number), prevPosition?: number) => number | undefined
|
|
156
|
+
/** Whether grabbing the scrollbar thumb is allowed. / スクロールバーのつまみ操作を許可するかどうか。 */
|
|
157
|
+
enableThumbDrag?: boolean
|
|
158
|
+
/** Whether clicking on the track moves the scrollbar. / スクロールバーのトラッククリック操作を許可するかどうか。 */
|
|
159
|
+
enableTrackClick?: boolean
|
|
160
|
+
/** Whether arrow buttons control the scroll position. / 矢印ボタンによるスクロール操作を許可するかどうか。 */
|
|
161
|
+
enableArrowButtons?: boolean
|
|
162
|
+
/** Whether the scrollbar is horizontal. / スクロールバーが水平かどうか。 */
|
|
163
|
+
horizontal?: boolean
|
|
164
|
+
/** The width of the scrollbar. / スクロールバーの幅。 */
|
|
165
|
+
scrollBarWidth?: number
|
|
166
|
+
/** Additional class names for the component. / コンポーネントの追加のクラス名。 */
|
|
167
|
+
className?: string
|
|
168
|
+
/** The ID of the element that the scrollbar controls. / スクロールバーが制御する要素の ID。 */
|
|
169
|
+
ariaControls?: string
|
|
170
|
+
/** Configuration for the tap scroll circle. / タップサークルの設定。 */
|
|
171
|
+
tapScrollCircleOptions?: ScrollBarTapCircleOptions
|
|
172
|
+
/** Total number of scrollable items (optional). / スクロール対象アイテムの総数(任意)。 */
|
|
173
|
+
itemCount?: number
|
|
174
|
+
/** Optional renderer for thumb-adjacent overlays. / サム付近に重ねるオーバーレイのレンダラー。 */
|
|
175
|
+
renderThumbOverlay?: (props: ScrollBarThumbOverlayRenderProps) => ReactNode
|
|
176
|
+
/** The index of the first visible item. / 最初の可視アイテムのインデックス。 */
|
|
177
|
+
visibleStartIndex?: number
|
|
178
|
+
/** The index of the last visible item. / 最後の可視アイテムのインデックス。 */
|
|
179
|
+
visibleEndIndex?: number
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The minimum size of the scrollbar thumb. / スクロールバーのつまみの最小サイズ。 */
|
|
183
|
+
const MIN_THUMB_SIZE = 20
|
|
184
|
+
const ARROW_HOLD_DELAY = 250
|
|
185
|
+
const ARROW_HOLD_INTERVAL = 60
|
|
186
|
+
const MIN_ARROW_STEP = 20
|
|
187
|
+
const ARROW_STEP_DIVISOR = 20
|
|
188
|
+
const TAP_SCROLL_MAX_DISTANCE = 240
|
|
189
|
+
const TAP_SCROLL_INITIAL_STATE: TapScrollCircleDragState = { active: false, offsetX: 0, offsetY: 0, distance: 0, direction: 0, pointerId: null }
|
|
190
|
+
|
|
191
|
+
const TAP_SCROLL_AUTO_SPEED_BASE_MULTIPLIER = 2.2
|
|
192
|
+
const TAP_SCROLL_AUTO_SPEED_PER_ORDER = 8
|
|
193
|
+
const TAP_SCROLL_AUTO_SPEED_MAX_MULTIPLIER = 120
|
|
194
|
+
// Cap the per-frame integration step so a backgrounded tab (paused rAF) does not
|
|
195
|
+
// teleport on resume, while still letting realistic low frame rates (down to ~10fps)
|
|
196
|
+
// advance at the configured px/second speed instead of being throttled to 60fps.
|
|
197
|
+
// バックグラウンド復帰時の飛びを防ぎつつ、低フレームレート環境でも設定速度(px/秒)を維持するための上限。
|
|
198
|
+
const TAP_SCROLL_MAX_FRAME_DELTA_SECONDS = 1 / 10
|
|
199
|
+
|
|
200
|
+
const DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS: ResolvedTapScrollCircleOptions = {
|
|
201
|
+
enabled: true,
|
|
202
|
+
size: 40,
|
|
203
|
+
offsetX: -80,
|
|
204
|
+
offsetY: 0,
|
|
205
|
+
className: undefined,
|
|
206
|
+
maxVisualDistance: TAP_SCROLL_MAX_DISTANCE,
|
|
207
|
+
maxSpeedMultiplier: TAP_SCROLL_AUTO_SPEED_BASE_MULTIPLIER,
|
|
208
|
+
minSpeedMultiplier: 0.2,
|
|
209
|
+
opacity: 0.9,
|
|
210
|
+
renderVisual: undefined,
|
|
211
|
+
maxSpeedCurve: undefined,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Creates the orientation dependent configuration for the scrollbar.
|
|
216
|
+
*
|
|
217
|
+
* スクロールバーの向きに応じた設定を生成。
|
|
218
|
+
*/
|
|
219
|
+
const createOrientationConfig = (horizontal: boolean): OrientationConfig => {
|
|
220
|
+
if (horizontal) {
|
|
221
|
+
return {
|
|
222
|
+
mainSizeKey: "width",
|
|
223
|
+
crossSizeKey: "height",
|
|
224
|
+
positionKey: "left",
|
|
225
|
+
selectDelta: (deltaX: number, _deltaY: number) => deltaX,
|
|
226
|
+
getPointerCoordinate: ({ clientX }) => clientX,
|
|
227
|
+
arrowLabels: ["Scroll left", "Scroll right"],
|
|
228
|
+
arrowIcons: ["◀", "▶"],
|
|
229
|
+
directionClass: "aqvs-scrollbar-horizontal",
|
|
230
|
+
orientation: "horizontal",
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
mainSizeKey: "height",
|
|
236
|
+
crossSizeKey: "width",
|
|
237
|
+
positionKey: "top",
|
|
238
|
+
selectDelta: (_deltaX: number, deltaY: number) => deltaY,
|
|
239
|
+
getPointerCoordinate: ({ clientY }) => clientY,
|
|
240
|
+
arrowLabels: ["Scroll up", "Scroll down"],
|
|
241
|
+
arrowIcons: ["▲", "▼"],
|
|
242
|
+
directionClass: "aqvs-scrollbar-vertical",
|
|
243
|
+
orientation: "vertical",
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolves user supplied tap scroll circle options with internal defaults.
|
|
249
|
+
*
|
|
250
|
+
* ユーザー指定のタップスクロールサークル設定を内部デフォルトで補完。
|
|
251
|
+
*/
|
|
252
|
+
const resolveTapScrollCircleOptions = (options: ScrollBarTapCircleOptions | undefined, itemCount?: number): ResolvedTapScrollCircleOptions => {
|
|
253
|
+
const manualMaxSpeedMultiplier = options?.maxSpeedMultiplier
|
|
254
|
+
const maxSpeedMultiplier = typeof manualMaxSpeedMultiplier === "number" ? manualMaxSpeedMultiplier : computeAutoTapScrollMaxSpeedMultiplier(itemCount)
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
enabled: options?.enabled ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.enabled,
|
|
258
|
+
size: options?.size ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.size,
|
|
259
|
+
offsetX: options?.offsetX ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.offsetX,
|
|
260
|
+
offsetY: options?.offsetY ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.offsetY,
|
|
261
|
+
className: options?.className ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.className,
|
|
262
|
+
maxVisualDistance: options?.maxVisualDistance ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.maxVisualDistance,
|
|
263
|
+
maxSpeedMultiplier,
|
|
264
|
+
minSpeedMultiplier: Math.max(options?.minSpeedMultiplier ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.minSpeedMultiplier, 0),
|
|
265
|
+
opacity: minmax(options?.opacity ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.opacity, 0, 1),
|
|
266
|
+
renderVisual: options?.renderVisual ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.renderVisual,
|
|
267
|
+
maxSpeedCurve: options?.maxSpeedCurve ?? DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS.maxSpeedCurve,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Applies hover and drag visual feedback to the scrollbar thumb.
|
|
273
|
+
*
|
|
274
|
+
* スクロールバーつまみのホバーとドラッグ時の見た目を適用。
|
|
275
|
+
*/
|
|
276
|
+
const useThumbVisualFeedback = ({ isDragging, isThumbHovered, enableThumbDrag }: ThumbVisualFeedbackOptions): ScrollBarThumbState => {
|
|
277
|
+
return useMemo<ScrollBarThumbState>(() => {
|
|
278
|
+
if (!enableThumbDrag) {
|
|
279
|
+
return "disabled"
|
|
280
|
+
}
|
|
281
|
+
if (isDragging) {
|
|
282
|
+
return "dragging"
|
|
283
|
+
}
|
|
284
|
+
if (isThumbHovered) {
|
|
285
|
+
return "hover"
|
|
286
|
+
}
|
|
287
|
+
return "idle"
|
|
288
|
+
}, [enableThumbDrag, isDragging, isThumbHovered])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Provides handlers for scrollbar arrow auto-repeat interactions.
|
|
293
|
+
*
|
|
294
|
+
* スクロールバー矢印の自動リピート操作向けハンドラーを提供。
|
|
295
|
+
*/
|
|
296
|
+
const useArrowAutoRepeat = ({ canUseArrowButtons, enableArrowButtons, resetTapScroll, scrollByStep }: ArrowAutoRepeatOptions): ArrowAutoRepeatHandlers => {
|
|
297
|
+
const arrowHoldIntervalRef = useRef<number | null>(null)
|
|
298
|
+
const arrowHoldTimeoutRef = useRef<number | null>(null)
|
|
299
|
+
// 長押し中に張るグローバル解除リスナ (disabled 化やウィンドウ blur でボタンに pointerup が届かない保険)。
|
|
300
|
+
const globalReleaseRef = useRef<((event: Event) => void) | null>(null)
|
|
301
|
+
|
|
302
|
+
const clearArrowTimers = useCallback(() => {
|
|
303
|
+
if (arrowHoldIntervalRef.current !== null) {
|
|
304
|
+
window.clearInterval(arrowHoldIntervalRef.current)
|
|
305
|
+
arrowHoldIntervalRef.current = null
|
|
306
|
+
}
|
|
307
|
+
if (arrowHoldTimeoutRef.current !== null) {
|
|
308
|
+
window.clearTimeout(arrowHoldTimeoutRef.current)
|
|
309
|
+
arrowHoldTimeoutRef.current = null
|
|
310
|
+
}
|
|
311
|
+
if (globalReleaseRef.current !== null) {
|
|
312
|
+
window.removeEventListener("pointerup", globalReleaseRef.current)
|
|
313
|
+
window.removeEventListener("pointercancel", globalReleaseRef.current)
|
|
314
|
+
window.removeEventListener("blur", globalReleaseRef.current)
|
|
315
|
+
globalReleaseRef.current = null
|
|
316
|
+
}
|
|
317
|
+
}, [])
|
|
318
|
+
|
|
319
|
+
const handleArrowPointerUp = useCallback(() => {
|
|
320
|
+
clearArrowTimers()
|
|
321
|
+
}, [clearArrowTimers])
|
|
322
|
+
|
|
323
|
+
const handleArrowPointerDown = useCallback(
|
|
324
|
+
(direction: 1 | -1) => (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
325
|
+
if (!canUseArrowButtons) {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (event.cancelable) {
|
|
329
|
+
event.preventDefault()
|
|
330
|
+
}
|
|
331
|
+
event.stopPropagation()
|
|
332
|
+
resetTapScroll()
|
|
333
|
+
clearArrowTimers()
|
|
334
|
+
scrollByStep(direction)
|
|
335
|
+
arrowHoldTimeoutRef.current = window.setTimeout(() => {
|
|
336
|
+
arrowHoldIntervalRef.current = window.setInterval(() => {
|
|
337
|
+
scrollByStep(direction)
|
|
338
|
+
}, ARROW_HOLD_INTERVAL)
|
|
339
|
+
}, ARROW_HOLD_DELAY)
|
|
340
|
+
// ボタンが disabled 化 / ウィンドウ blur で pointerup を取りこぼしてもリピートを確実に止める保険。
|
|
341
|
+
const release = () => clearArrowTimers()
|
|
342
|
+
globalReleaseRef.current = release
|
|
343
|
+
window.addEventListener("pointerup", release)
|
|
344
|
+
window.addEventListener("pointercancel", release)
|
|
345
|
+
window.addEventListener("blur", release)
|
|
346
|
+
},
|
|
347
|
+
[canUseArrowButtons, clearArrowTimers, resetTapScroll, scrollByStep],
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
const handleArrowKeyDown = useCallback(
|
|
351
|
+
(direction: 1 | -1) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
352
|
+
if (!enableArrowButtons) {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
|
|
356
|
+
event.preventDefault()
|
|
357
|
+
scrollByStep(direction)
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
[enableArrowButtons, scrollByStep],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
// 矢印ボタンが操作不能 (canUseArrowButtons=false) になったら進行中のリピートを即停止する。
|
|
364
|
+
// disabled なボタンには pointerup/pointerleave が配送されないため、この保険が無いとタイマーが残留する。
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!canUseArrowButtons) {
|
|
367
|
+
clearArrowTimers()
|
|
368
|
+
}
|
|
369
|
+
}, [canUseArrowButtons, clearArrowTimers])
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
return () => {
|
|
373
|
+
clearArrowTimers()
|
|
374
|
+
}
|
|
375
|
+
}, [clearArrowTimers])
|
|
376
|
+
|
|
377
|
+
return { handleArrowPointerDown, handleArrowPointerUp, handleArrowKeyDown }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Computes the auto-scroll speed multiplier based on item count.
|
|
382
|
+
*
|
|
383
|
+
* アイテム数に応じた自動スクロール速度倍率を算出。
|
|
384
|
+
*/
|
|
385
|
+
const computeAutoTapScrollMaxSpeedMultiplier = (itemCount?: number) => {
|
|
386
|
+
if (!itemCount || itemCount <= 0) {
|
|
387
|
+
return TAP_SCROLL_AUTO_SPEED_BASE_MULTIPLIER
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const clampedCount = Math.max(1, itemCount)
|
|
391
|
+
const ordersOfMagnitude = Math.log10(clampedCount)
|
|
392
|
+
const multiplier = TAP_SCROLL_AUTO_SPEED_BASE_MULTIPLIER + ordersOfMagnitude * TAP_SCROLL_AUTO_SPEED_PER_ORDER
|
|
393
|
+
return minmax(multiplier, TAP_SCROLL_AUTO_SPEED_BASE_MULTIPLIER, TAP_SCROLL_AUTO_SPEED_MAX_MULTIPLIER)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A custom scrollbar component.
|
|
398
|
+
*
|
|
399
|
+
* カスタムスクロールバーコンポーネント。
|
|
400
|
+
*/
|
|
401
|
+
export const ScrollBar = ({
|
|
402
|
+
contentSize,
|
|
403
|
+
viewportSize,
|
|
404
|
+
scrollPosition,
|
|
405
|
+
onScroll,
|
|
406
|
+
enableThumbDrag = true,
|
|
407
|
+
enableTrackClick = true,
|
|
408
|
+
enableArrowButtons = true,
|
|
409
|
+
horizontal = false,
|
|
410
|
+
scrollBarWidth = 12,
|
|
411
|
+
className,
|
|
412
|
+
ariaControls,
|
|
413
|
+
tapScrollCircleOptions,
|
|
414
|
+
itemCount,
|
|
415
|
+
renderThumbOverlay,
|
|
416
|
+
visibleStartIndex,
|
|
417
|
+
visibleEndIndex,
|
|
418
|
+
}: ScrollBarProps) => {
|
|
419
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
420
|
+
const [isThumbHovered, setIsThumbHovered] = useState(false)
|
|
421
|
+
const [isTapActive, setIsTapActive] = useState(false)
|
|
422
|
+
const thumbRef = useRef<HTMLDivElement>(null)
|
|
423
|
+
const thumbDragStateRef = useRef<{ pointerId: number | null; startThumbPosition: number; startClientX: number; startClientY: number; scale: number; captureTarget: HTMLElement | null }>({
|
|
424
|
+
pointerId: null,
|
|
425
|
+
startThumbPosition: 0,
|
|
426
|
+
startClientX: 0,
|
|
427
|
+
startClientY: 0,
|
|
428
|
+
scale: 1,
|
|
429
|
+
captureTarget: null,
|
|
430
|
+
})
|
|
431
|
+
const trackDragStateRef = useRef<{ pointerId: number | null; startThumbPosition: number; startClientX: number; startClientY: number; scale: number }>({ pointerId: null, startThumbPosition: 0, startClientX: 0, startClientY: 0, scale: 1 })
|
|
432
|
+
const latestScrollPositionRef = useRef(scrollPosition)
|
|
433
|
+
const onScrollRef = useRef(onScroll)
|
|
434
|
+
const tapDragStateRef = useRef<TapScrollCircleDragState>(TAP_SCROLL_INITIAL_STATE)
|
|
435
|
+
const tapCircleHandleRef = useRef<TapScrollCircleHandle | null>(null)
|
|
436
|
+
const autoScrollFrameRef = useRef<number | null>(null)
|
|
437
|
+
const lastAutoScrollTimestampRef = useRef<number | null>(null)
|
|
438
|
+
// 親が位置を量子化 (行スナップ等) して返す場合でも、1フレームあたりのサブピクセル移動が
|
|
439
|
+
// 丸めで消えて自動スクロールが止まらないよう、未消化の要求量を持ち越すための残差アキュムレータ。
|
|
440
|
+
const autoScrollResidualRef = useRef(0)
|
|
441
|
+
// 直前の自動スクロール方向。方向転換時に逆向きの残差を持ち越さないための記録。
|
|
442
|
+
const autoScrollDirectionRef = useRef<0 | 1 | -1>(0)
|
|
443
|
+
const resolvedTapScrollOptions = useMemo<ResolvedTapScrollCircleOptions>(() => resolveTapScrollCircleOptions(tapScrollCircleOptions, itemCount), [itemCount, tapScrollCircleOptions])
|
|
444
|
+
const orientationConfig = useMemo(() => createOrientationConfig(horizontal), [horizontal])
|
|
445
|
+
const {
|
|
446
|
+
enabled: tapCircleEnabled,
|
|
447
|
+
size: tapCircleSize,
|
|
448
|
+
offsetX: tapCircleOffsetX,
|
|
449
|
+
offsetY: tapCircleOffsetY,
|
|
450
|
+
className: tapCircleClassName,
|
|
451
|
+
maxVisualDistance: tapCircleMaxDistance,
|
|
452
|
+
maxSpeedMultiplier: tapCircleMaxSpeedMultiplier,
|
|
453
|
+
minSpeedMultiplier: tapCircleMinSpeedMultiplier,
|
|
454
|
+
opacity: tapCircleOpacity,
|
|
455
|
+
renderVisual: tapCircleRenderVisual,
|
|
456
|
+
maxSpeedCurve: tapCircleMaxSpeedCurve,
|
|
457
|
+
} = resolvedTapScrollOptions
|
|
458
|
+
const latestMetricsRef = useRef({
|
|
459
|
+
viewportSize,
|
|
460
|
+
maxScrollPosition: Math.max(contentSize - viewportSize, 0),
|
|
461
|
+
scrollBarVisible: contentSize > viewportSize,
|
|
462
|
+
effectiveTapMaxDistance: Math.max(tapCircleMaxDistance, 1),
|
|
463
|
+
tapCircleMaxSpeedMultiplier,
|
|
464
|
+
tapCircleMinSpeedMultiplier,
|
|
465
|
+
tapCircleMaxSpeedCurve,
|
|
466
|
+
tapScrollCircleOptions,
|
|
467
|
+
effectiveTrackLength: 0,
|
|
468
|
+
onScroll,
|
|
469
|
+
scrollPosition,
|
|
470
|
+
})
|
|
471
|
+
const { mainSizeKey, crossSizeKey, positionKey, selectDelta, getPointerCoordinate, arrowLabels, arrowIcons, directionClass, orientation } = orientationConfig
|
|
472
|
+
const effectiveTapMaxDistance = Math.max(tapCircleMaxDistance, 1)
|
|
473
|
+
// 表示領域に対するコンテンツの比率
|
|
474
|
+
const scrollRatio = viewportSize / contentSize
|
|
475
|
+
const trackLength = Math.max(viewportSize - scrollBarWidth * 2, 0)
|
|
476
|
+
const rawThumbSize = scrollRatio * trackLength
|
|
477
|
+
const thumbSize = Math.min(Math.max(MIN_THUMB_SIZE, rawThumbSize || 0), trackLength || MIN_THUMB_SIZE)
|
|
478
|
+
// 最大スクロール位置
|
|
479
|
+
const maxScrollPosition = contentSize - viewportSize
|
|
480
|
+
const effectiveTrackLength = Math.max(trackLength - thumbSize, 0)
|
|
481
|
+
// scrollPosition が範囲外 (コンテンツ縮小直後の未クランプ 1 フレームや iOS バウンス値) でも
|
|
482
|
+
// サムがトラック外へはみ出さないよう、レンダー時に [0, effectiveTrackLength] へクランプする。
|
|
483
|
+
const thumbPosition = maxScrollPosition <= 0 || effectiveTrackLength <= 0 ? 0 : minmax((scrollPosition / maxScrollPosition) * effectiveTrackLength, 0, effectiveTrackLength)
|
|
484
|
+
const thumbCenter = thumbPosition + thumbSize / 2
|
|
485
|
+
|
|
486
|
+
// スクロールバーが表示されるかどうか
|
|
487
|
+
const scrollBarVisible = contentSize > viewportSize || isDragging
|
|
488
|
+
const canUseArrowButtons = scrollBarVisible && enableArrowButtons
|
|
489
|
+
|
|
490
|
+
useLayoutEffect(() => {
|
|
491
|
+
latestMetricsRef.current = {
|
|
492
|
+
viewportSize,
|
|
493
|
+
maxScrollPosition,
|
|
494
|
+
scrollBarVisible,
|
|
495
|
+
effectiveTapMaxDistance,
|
|
496
|
+
tapCircleMaxSpeedMultiplier,
|
|
497
|
+
tapCircleMinSpeedMultiplier,
|
|
498
|
+
tapCircleMaxSpeedCurve,
|
|
499
|
+
tapScrollCircleOptions,
|
|
500
|
+
effectiveTrackLength,
|
|
501
|
+
onScroll,
|
|
502
|
+
scrollPosition,
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Synchronizes the latest scroll position to a ref.
|
|
508
|
+
*
|
|
509
|
+
* 最新のスクロール位置を Ref に同期します。
|
|
510
|
+
* 依存関係: [scrollPosition] - スクロール位置が変更されるたびに更新。
|
|
511
|
+
*/
|
|
512
|
+
useLayoutEffect(() => {
|
|
513
|
+
latestScrollPositionRef.current = scrollPosition
|
|
514
|
+
}, [scrollPosition])
|
|
515
|
+
|
|
516
|
+
useLayoutEffect(() => {
|
|
517
|
+
onScrollRef.current = onScroll
|
|
518
|
+
}, [onScroll])
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Resets thumb hover state when drag logic is disabled.
|
|
522
|
+
*
|
|
523
|
+
* つまみドラッグが無効化された際に、ホバー状態をリセットします。
|
|
524
|
+
* 依存関係: [enableThumbDrag] - ドラッグ設定が変更された時のみ実行。
|
|
525
|
+
*/
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
if (!enableThumbDrag) {
|
|
528
|
+
setIsThumbHovered(false)
|
|
529
|
+
}
|
|
530
|
+
}, [enableThumbDrag])
|
|
531
|
+
|
|
532
|
+
const thumbVisualState = useThumbVisualFeedback({ isDragging, isThumbHovered, enableThumbDrag })
|
|
533
|
+
|
|
534
|
+
const resolveScrollRequest = useCallback((next: number | ((prev: number) => number), previousOverride?: number) => {
|
|
535
|
+
const metrics = latestMetricsRef.current
|
|
536
|
+
const previous = previousOverride ?? latestScrollPositionRef.current
|
|
537
|
+
if (onScrollRef.current) {
|
|
538
|
+
const result = onScrollRef.current(next, previous)
|
|
539
|
+
if (typeof result === "number" && Number.isFinite(result)) {
|
|
540
|
+
latestScrollPositionRef.current = result
|
|
541
|
+
return result
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const candidate = typeof next === "function" ? next(previous) : next
|
|
545
|
+
const maxPosition = Math.max(metrics.maxScrollPosition, 0)
|
|
546
|
+
const clamped = metrics.scrollBarVisible ? minmax(candidate, 0, maxPosition) : 0
|
|
547
|
+
latestScrollPositionRef.current = clamped
|
|
548
|
+
return clamped
|
|
549
|
+
}, [])
|
|
550
|
+
|
|
551
|
+
const applyScrollDelta = useCallback(
|
|
552
|
+
(delta: number) => {
|
|
553
|
+
const metrics = latestMetricsRef.current
|
|
554
|
+
const previousPosition = latestScrollPositionRef.current
|
|
555
|
+
if (!metrics.scrollBarVisible || metrics.maxScrollPosition <= 0) {
|
|
556
|
+
const nextPosition = resolveScrollRequest(0, previousPosition)
|
|
557
|
+
const actualDelta = nextPosition - previousPosition
|
|
558
|
+
return { nextPosition, actualDelta, reachedBoundary: true }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (delta === 0) {
|
|
562
|
+
return { nextPosition: previousPosition, actualDelta: 0, reachedBoundary: false }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const updater = (prev: number) => minmax(prev + delta, 0, metrics.maxScrollPosition)
|
|
566
|
+
const nextPosition = resolveScrollRequest(updater, previousPosition)
|
|
567
|
+
const actualDelta = nextPosition - previousPosition
|
|
568
|
+
// actualDelta===0 単独では境界到達と同一視しない。位置を量子化して返す親では低速時に
|
|
569
|
+
// 丸めで actualDelta=0 になり得るが、それは境界ではなく「まだ動かせていない」状態。
|
|
570
|
+
// ただし「クランプ前の要求候補が端に達しているのに位置が変わらない」場合は境界とみなす。
|
|
571
|
+
// floor 量子化する親では到達可能最大が maxScrollPosition の手前で頭打ちになり、
|
|
572
|
+
// 実位置は端値ちょうどに届かないため、この補強が無いと境界判定が永久に偽のままになる。
|
|
573
|
+
const candidateAtMin = delta < 0 && previousPosition + delta <= 0
|
|
574
|
+
const candidateAtMax = delta > 0 && previousPosition + delta >= metrics.maxScrollPosition
|
|
575
|
+
const reachedBoundary = (delta < 0 && (nextPosition <= 0 || (candidateAtMin && actualDelta === 0))) || (delta > 0 && (nextPosition >= metrics.maxScrollPosition || (candidateAtMax && actualDelta === 0)))
|
|
576
|
+
|
|
577
|
+
return { nextPosition, actualDelta, reachedBoundary }
|
|
578
|
+
},
|
|
579
|
+
[resolveScrollRequest],
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Applies a scroll delta through a residual accumulator so quantizing parents can still advance.
|
|
584
|
+
*
|
|
585
|
+
* 残差アキュムレータ経由でスクロール差分を適用し、量子化する親でも前進できるようにする処理。
|
|
586
|
+
*
|
|
587
|
+
* @param residualRef Accumulator ref holding the unconsumed scroll amount. / 未消化のスクロール量を保持するアキュムレータ ref。
|
|
588
|
+
* @param delta Requested scroll delta. / 要求するスクロール差分。
|
|
589
|
+
* @returns The result of applyScrollDelta. / applyScrollDelta の結果。
|
|
590
|
+
*/
|
|
591
|
+
const applyScrollDeltaWithResidual = useCallback(
|
|
592
|
+
(residualRef: { current: number }, delta: number) => {
|
|
593
|
+
// 要求量を残差に足し込み、実際に消化された分だけ差し引く。量子化する親でも
|
|
594
|
+
// 残差が次の量子に達すれば前進でき、真の境界到達時は残差を破棄する。
|
|
595
|
+
residualRef.current += delta
|
|
596
|
+
// 残差の暴走防止: 全スクロール範囲を超える残差は意味を持たないため上限を設ける。
|
|
597
|
+
// 位置を独自クランプする親などで境界判定が成立しないケースでも無制限成長を防ぐ保険。
|
|
598
|
+
const residualCap = Math.max(latestMetricsRef.current.maxScrollPosition, 0)
|
|
599
|
+
if (residualCap > 0) {
|
|
600
|
+
residualRef.current = minmax(residualRef.current, -residualCap, residualCap)
|
|
601
|
+
}
|
|
602
|
+
const result = applyScrollDelta(residualRef.current)
|
|
603
|
+
residualRef.current -= result.actualDelta
|
|
604
|
+
if (result.reachedBoundary) {
|
|
605
|
+
residualRef.current = 0
|
|
606
|
+
}
|
|
607
|
+
return result
|
|
608
|
+
},
|
|
609
|
+
[applyScrollDelta],
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Stops the tap circle auto-scroll animation.
|
|
614
|
+
*
|
|
615
|
+
* タップサークルによる自動スクロールを停止。
|
|
616
|
+
*/
|
|
617
|
+
const stopAutoScroll = useCallback(() => {
|
|
618
|
+
if (autoScrollFrameRef.current !== null) {
|
|
619
|
+
window.cancelAnimationFrame(autoScrollFrameRef.current)
|
|
620
|
+
autoScrollFrameRef.current = null
|
|
621
|
+
}
|
|
622
|
+
lastAutoScrollTimestampRef.current = null
|
|
623
|
+
autoScrollResidualRef.current = 0
|
|
624
|
+
autoScrollDirectionRef.current = 0
|
|
625
|
+
}, [])
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Resets tap circle state and related motion.
|
|
629
|
+
*
|
|
630
|
+
* タップサークルの状態と動作を初期化。
|
|
631
|
+
*/
|
|
632
|
+
const resetTapScroll = useCallback(() => {
|
|
633
|
+
tapDragStateRef.current = { ...TAP_SCROLL_INITIAL_STATE }
|
|
634
|
+
setIsTapActive(false)
|
|
635
|
+
tapCircleHandleRef.current?.reset()
|
|
636
|
+
stopAutoScroll()
|
|
637
|
+
}, [stopAutoScroll])
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Advances tap circle auto-scroll by animation frame.
|
|
641
|
+
* Calculates speed based on pull distance, viewport size, and optional easing curves.
|
|
642
|
+
*
|
|
643
|
+
* タップサークルの自動スクロールをフレームごとに進行させます。
|
|
644
|
+
* 引っ張り距離、ビューポートサイズ、およびオプションのイージングカーブに基づいてスクロール速度を計算します。
|
|
645
|
+
*
|
|
646
|
+
* @param timestamp Current high-resolution timestamp / 現在の高精度タイムスタンプ
|
|
647
|
+
*/
|
|
648
|
+
const stepAutoScroll = useCallback(
|
|
649
|
+
(timestamp: number) => {
|
|
650
|
+
const state = tapDragStateRef.current
|
|
651
|
+
const metrics = latestMetricsRef.current
|
|
652
|
+
if (!state.active || state.direction === 0) {
|
|
653
|
+
stopAutoScroll()
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
if (!metrics.scrollBarVisible || metrics.maxScrollPosition <= 0) {
|
|
657
|
+
stopAutoScroll()
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const lastTimestamp = lastAutoScrollTimestampRef.current ?? timestamp
|
|
662
|
+
const deltaSecondsRaw = Math.max((timestamp - lastTimestamp) / 1000, 0)
|
|
663
|
+
const deltaSeconds = Math.min(deltaSecondsRaw, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS)
|
|
664
|
+
lastAutoScrollTimestampRef.current = timestamp
|
|
665
|
+
|
|
666
|
+
if (deltaSeconds <= 0) {
|
|
667
|
+
autoScrollFrameRef.current = window.requestAnimationFrame(stepAutoScroll)
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Calculate normalized distance (0.0 to 1.0)
|
|
672
|
+
// 正規化された距離を計算 (0.0 から 1.0)
|
|
673
|
+
const normalized = Math.min(state.distance, metrics.effectiveTapMaxDistance) / metrics.effectiveTapMaxDistance
|
|
674
|
+
// Apply base non-linear easing
|
|
675
|
+
// 基準の非線形イージングを適用
|
|
676
|
+
const eased = normalized ** 1.1
|
|
677
|
+
const hasCustomMaxSpeedMultiplier = typeof metrics.tapScrollCircleOptions?.maxSpeedMultiplier === "number"
|
|
678
|
+
const minSpeed = Math.max(metrics.viewportSize * metrics.tapCircleMinSpeedMultiplier, 40)
|
|
679
|
+
const maxSpeedFloor = hasCustomMaxSpeedMultiplier ? minSpeed : 1200
|
|
680
|
+
const linearMaxSpeed = Math.max(metrics.viewportSize * metrics.tapCircleMaxSpeedMultiplier, maxSpeedFloor)
|
|
681
|
+
|
|
682
|
+
let cappedMaxSpeed = linearMaxSpeed
|
|
683
|
+
const curve = metrics.tapCircleMaxSpeedCurve
|
|
684
|
+
|
|
685
|
+
// Apply exponential curve scaling if configured
|
|
686
|
+
// 設定されている場合、指数関数曲線スケーリングを適用します
|
|
687
|
+
if (curve) {
|
|
688
|
+
const steepness = Math.max(curve.exponentialSteepness, 0)
|
|
689
|
+
const scale = Math.max(curve.exponentialScale ?? metrics.tapCircleMaxSpeedMultiplier, 0)
|
|
690
|
+
const expNumerator = steepness === 0 ? normalized : Math.expm1(steepness * normalized)
|
|
691
|
+
const expDenominator = steepness === 0 ? 1 : Math.expm1(steepness) || 1
|
|
692
|
+
const exponentialRatio = expDenominator === 0 ? normalized : Math.min(Math.max(expNumerator / expDenominator, 0), 1)
|
|
693
|
+
const exponentialMaxSpeed = metrics.viewportSize * scale * exponentialRatio
|
|
694
|
+
cappedMaxSpeed = Math.min(cappedMaxSpeed, Math.max(exponentialMaxSpeed, minSpeed))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const maxSpeed = Math.max(cappedMaxSpeed, minSpeed)
|
|
698
|
+
const easedOffset = Math.max(curve?.easedOffset ?? 0, 0)
|
|
699
|
+
const effectiveEased = Math.min(1, eased + easedOffset)
|
|
700
|
+
const speed = minSpeed + (maxSpeed - minSpeed) * effectiveEased
|
|
701
|
+
|
|
702
|
+
// 方向が転換したら逆向きの残差を破棄する。持ち越すと逆向き残差を燃焼し切るまで
|
|
703
|
+
// スクロール反転が固着する (タップサークルはヒステリシス幅を超える 1 イベントで +1→-1 に反転し得る)。
|
|
704
|
+
if (autoScrollDirectionRef.current !== state.direction) {
|
|
705
|
+
autoScrollDirectionRef.current = state.direction
|
|
706
|
+
autoScrollResidualRef.current = 0
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 残差アキュムレータ経由で適用し、量子化する親でも前進できるようにする。
|
|
710
|
+
// 真の境界到達時のみループを止める。
|
|
711
|
+
const { reachedBoundary } = applyScrollDeltaWithResidual(autoScrollResidualRef, state.direction * speed * deltaSeconds)
|
|
712
|
+
|
|
713
|
+
if (reachedBoundary) {
|
|
714
|
+
stopAutoScroll()
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
autoScrollFrameRef.current = window.requestAnimationFrame(stepAutoScroll)
|
|
719
|
+
},
|
|
720
|
+
[applyScrollDeltaWithResidual, stopAutoScroll],
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Starts tap circle auto-scroll loop.
|
|
725
|
+
*
|
|
726
|
+
* タップサークルによる自動スクロールを開始。
|
|
727
|
+
*/
|
|
728
|
+
const startAutoScroll = useCallback(() => {
|
|
729
|
+
if (autoScrollFrameRef.current === null) {
|
|
730
|
+
lastAutoScrollTimestampRef.current = null
|
|
731
|
+
autoScrollResidualRef.current = 0
|
|
732
|
+
autoScrollFrameRef.current = window.requestAnimationFrame(stepAutoScroll)
|
|
733
|
+
}
|
|
734
|
+
}, [stepAutoScroll])
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Cleanup auto-scroll on unmount.
|
|
738
|
+
*
|
|
739
|
+
* アンマウント時に自動スクロールをクリーンアップします。
|
|
740
|
+
* 依存関係: [stopAutoScroll]
|
|
741
|
+
*/
|
|
742
|
+
useEffect(() => {
|
|
743
|
+
return () => {
|
|
744
|
+
stopAutoScroll()
|
|
745
|
+
}
|
|
746
|
+
}, [stopAutoScroll])
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Handles tap circle drag state changes.
|
|
750
|
+
*
|
|
751
|
+
* タップサークルのドラッグ状態変化を処理。
|
|
752
|
+
*/
|
|
753
|
+
const handleTapCircleDragChange = useCallback(
|
|
754
|
+
(state: TapScrollCircleDragState) => {
|
|
755
|
+
tapDragStateRef.current = state
|
|
756
|
+
setIsTapActive(state.active)
|
|
757
|
+
if (state.active && state.direction !== 0) {
|
|
758
|
+
startAutoScroll()
|
|
759
|
+
} else {
|
|
760
|
+
stopAutoScroll()
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
[startAutoScroll, stopAutoScroll],
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Resets tap scroll when disabled configuration changes.
|
|
768
|
+
*
|
|
769
|
+
* 設定で無効化された際にタップスクロールをリセットします。
|
|
770
|
+
* 依存関係: [resetTapScroll, tapCircleEnabled]
|
|
771
|
+
*/
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
if (!tapCircleEnabled) {
|
|
774
|
+
resetTapScroll()
|
|
775
|
+
}
|
|
776
|
+
}, [resetTapScroll, tapCircleEnabled])
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Subscribes to custom cancel events for tap gestures.
|
|
780
|
+
* Often used to stop scrolling when interacting with other UI elements.
|
|
781
|
+
*
|
|
782
|
+
* タップジェスチャーのカスタムキャンセルイベントを購読します。
|
|
783
|
+
* 他の UI 要素とのインタラクション時にスクロールを停止するためによく使用されます。
|
|
784
|
+
* 依存関係: [ariaControls, resetTapScroll]
|
|
785
|
+
*/
|
|
786
|
+
useEffect(() => {
|
|
787
|
+
const handleTapScrollCancel = (event: Event) => {
|
|
788
|
+
const customEvent = event as CustomEvent<TapScrollCancelEventDetail>
|
|
789
|
+
const targetPaneId = customEvent.detail?.paneId
|
|
790
|
+
if (targetPaneId && ariaControls && targetPaneId !== ariaControls) {
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
resetTapScroll()
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
window.addEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel as EventListener)
|
|
797
|
+
return () => {
|
|
798
|
+
window.removeEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel as EventListener)
|
|
799
|
+
}
|
|
800
|
+
}, [ariaControls, resetTapScroll])
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Global pointer listener to cancel tap scroll on outside clicks.
|
|
804
|
+
*
|
|
805
|
+
* 外部クリック時にタップスクロールをキャンセルするためのグローバルポインタリステナ。
|
|
806
|
+
* 依存関係: [resetTapScroll, tapCircleEnabled]
|
|
807
|
+
*/
|
|
808
|
+
useEffect(() => {
|
|
809
|
+
if (!tapCircleEnabled) {
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
813
|
+
if (!tapDragStateRef.current.active) {
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
// ドラッグ中のポインターと同じポインターによるイベントは無視する
|
|
817
|
+
// (通常はキャプチャされているためここには来ないはずだが、念のため)
|
|
818
|
+
if (tapDragStateRef.current.pointerId === event.pointerId) {
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const targetNode = event.target
|
|
823
|
+
if (!(targetNode instanceof Node)) {
|
|
824
|
+
resetTapScroll()
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const element = tapCircleHandleRef.current?.getElement()
|
|
828
|
+
if (element?.contains(targetNode)) {
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
resetTapScroll()
|
|
832
|
+
}
|
|
833
|
+
document.addEventListener("pointerdown", handlePointerDown, true)
|
|
834
|
+
return () => {
|
|
835
|
+
document.removeEventListener("pointerdown", handlePointerDown, true)
|
|
836
|
+
}
|
|
837
|
+
}, [resetTapScroll, tapCircleEnabled])
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Translates the thumb position to a scroll position.
|
|
841
|
+
*
|
|
842
|
+
* つまみの位置をスクロール位置に変換。
|
|
843
|
+
*
|
|
844
|
+
* @param thumbPositionValue The position of the thumb. / つまみの位置。
|
|
845
|
+
* @returns The scroll position. / スクロール位置。
|
|
846
|
+
*/
|
|
847
|
+
const translateToScrollPosition = (thumbPositionValue: number) => {
|
|
848
|
+
if (!scrollBarVisible || effectiveTrackLength <= 0 || maxScrollPosition <= 0) {
|
|
849
|
+
return 0
|
|
850
|
+
}
|
|
851
|
+
const clampedThumbPosition = minmax(thumbPositionValue, 0, effectiveTrackLength)
|
|
852
|
+
return minmax((clampedThumbPosition / effectiveTrackLength) * maxScrollPosition, 0, maxScrollPosition)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Calculates the scroll position based on thumb position using latest metrics.
|
|
857
|
+
*
|
|
858
|
+
* 最新のメトリクスを使用して、つまみの位置に基づきスクロール位置を計算します。
|
|
859
|
+
*
|
|
860
|
+
* @param thumbPos Finger drag position relative to start. / 開始位置に関連する指のドラッグ位置。
|
|
861
|
+
*/
|
|
862
|
+
const calculateScrollPositionFromThumb = useCallback((thumbPos: number) => {
|
|
863
|
+
const { scrollBarVisible: _visible, effectiveTrackLength: trackLen, maxScrollPosition: maxScroll } = latestMetricsRef.current
|
|
864
|
+
Logger.debug("[ScrollBar] calculateScrollPositionFromThumb", () => ({ thumbPos, trackLen, maxScroll }))
|
|
865
|
+
if (trackLen <= 0 || maxScroll <= 0) {
|
|
866
|
+
return null
|
|
867
|
+
}
|
|
868
|
+
const clamped = minmax(thumbPos, 0, trackLen)
|
|
869
|
+
return minmax((clamped / trackLen) * maxScroll, 0, maxScroll)
|
|
870
|
+
}, [])
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Computes the main-axis CSS transform scale of an element (visual px / layout px).
|
|
874
|
+
*
|
|
875
|
+
* 要素の主軸方向 CSS transform スケール (視覚px / レイアウトpx) を算出。
|
|
876
|
+
* 祖先に transform: scale(...) があると client 座標 (視覚px) がレイアウトpx とズレるため、
|
|
877
|
+
* その比率でドラッグ量・クリック座標を補正する。分母には整数丸めされる offsetWidth/offsetHeight
|
|
878
|
+
* ではなく、transform の影響を受けず小数精度を持つ getComputedStyle のレイアウト寸法
|
|
879
|
+
* (content + padding + border) を優先して使い、transform なしなら厳密に 1 を返す。
|
|
880
|
+
* 取得不能時 (jsdom 等) は offset 寸法へフォールバックし、それも 0 なら 1 を返す。
|
|
881
|
+
*/
|
|
882
|
+
const getMainAxisScale = useCallback(
|
|
883
|
+
(element: HTMLElement) => {
|
|
884
|
+
const rect = element.getBoundingClientRect()
|
|
885
|
+
const visualSize = horizontal ? rect.width : rect.height
|
|
886
|
+
// computed style の値 (px 文字列) を数値化する。解釈不能なら 0 とみなす。
|
|
887
|
+
const parseSize = (value: string) => {
|
|
888
|
+
const parsed = Number.parseFloat(value)
|
|
889
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
890
|
+
}
|
|
891
|
+
const computed = window.getComputedStyle(element)
|
|
892
|
+
const contentSize = parseSize(horizontal ? computed.width : computed.height)
|
|
893
|
+
// rect (border-box) と同系になるよう content + padding + border を合成する。
|
|
894
|
+
const computedLayoutSize =
|
|
895
|
+
contentSize > 0
|
|
896
|
+
? contentSize +
|
|
897
|
+
parseSize(horizontal ? computed.paddingLeft : computed.paddingTop) +
|
|
898
|
+
parseSize(horizontal ? computed.paddingRight : computed.paddingBottom) +
|
|
899
|
+
parseSize(horizontal ? computed.borderLeftWidth : computed.borderTopWidth) +
|
|
900
|
+
parseSize(horizontal ? computed.borderRightWidth : computed.borderBottomWidth)
|
|
901
|
+
: 0
|
|
902
|
+
// computed が取れない環境 (jsdom 等) では従来の offset 寸法へフォールバックする。
|
|
903
|
+
const layoutSize = computedLayoutSize > 0 ? computedLayoutSize : horizontal ? element.offsetWidth : element.offsetHeight
|
|
904
|
+
if (layoutSize <= 0 || visualSize <= 0) {
|
|
905
|
+
return 1
|
|
906
|
+
}
|
|
907
|
+
return visualSize / layoutSize
|
|
908
|
+
},
|
|
909
|
+
[horizontal],
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Pointer move handler for thumb dragging.
|
|
914
|
+
* Calculates new scroll position based on drag delta.
|
|
915
|
+
*
|
|
916
|
+
* つまみドラッグ用のポインタ移動ハンドラー。
|
|
917
|
+
* ドラッグの差分に基づいて新しいスクロール位置を計算します。
|
|
918
|
+
*/
|
|
919
|
+
const handleThumbPointerMove = useCallback(
|
|
920
|
+
(event: PointerEvent) => {
|
|
921
|
+
const state = thumbDragStateRef.current
|
|
922
|
+
if (state.pointerId !== event.pointerId) {
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const deltaX = event.clientX - state.startClientX
|
|
927
|
+
const deltaY = event.clientY - state.startClientY
|
|
928
|
+
// client 座標差分 (視覚px) を transform scale で割ってレイアウトpx に戻す。
|
|
929
|
+
const delta = selectDelta(deltaX, deltaY) / (state.scale || 1)
|
|
930
|
+
Logger.debug("[ScrollBar] handleThumbPointerMove", () => ({
|
|
931
|
+
delta,
|
|
932
|
+
startThumbPosition: state.startThumbPosition,
|
|
933
|
+
clientY: event.clientY,
|
|
934
|
+
startClientY: state.startClientY,
|
|
935
|
+
metrics: latestMetricsRef.current,
|
|
936
|
+
}))
|
|
937
|
+
const nextPosition = calculateScrollPositionFromThumb(state.startThumbPosition + delta)
|
|
938
|
+
|
|
939
|
+
if (nextPosition !== null) {
|
|
940
|
+
resolveScrollRequest(nextPosition)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (event.cancelable) {
|
|
944
|
+
event.preventDefault()
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
[selectDelta, calculateScrollPositionFromThumb, resolveScrollRequest],
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Pointer up handler for thumb dragging.
|
|
952
|
+
* Cleans up drag state and visual feedback.
|
|
953
|
+
*
|
|
954
|
+
* つまみドラッグ用のポインタ上げハンドラー。
|
|
955
|
+
* ドラッグ状態と視覚的フィードバックをクリーンアップします。
|
|
956
|
+
*/
|
|
957
|
+
const handleThumbPointerUp = useCallback(
|
|
958
|
+
(event: PointerEvent) => {
|
|
959
|
+
const state = thumbDragStateRef.current
|
|
960
|
+
if (state.pointerId !== event.pointerId) {
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
document.removeEventListener("pointermove", handleThumbPointerMove)
|
|
965
|
+
document.removeEventListener("pointerup", handleThumbPointerUp)
|
|
966
|
+
document.removeEventListener("pointercancel", handleThumbPointerUp)
|
|
967
|
+
|
|
968
|
+
const captureTarget = state.captureTarget
|
|
969
|
+
if (captureTarget?.hasPointerCapture?.(event.pointerId)) {
|
|
970
|
+
captureTarget.releasePointerCapture(event.pointerId)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
thumbDragStateRef.current = { pointerId: null, startThumbPosition: 0, startClientX: 0, startClientY: 0, scale: 1, captureTarget: null }
|
|
974
|
+
|
|
975
|
+
setIsDragging(false)
|
|
976
|
+
if (thumbRef.current) {
|
|
977
|
+
const rect = thumbRef.current.getBoundingClientRect()
|
|
978
|
+
const isOver = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom
|
|
979
|
+
if (!isOver) {
|
|
980
|
+
setIsThumbHovered(false)
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
setIsThumbHovered(false)
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
[handleThumbPointerMove],
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Removes any lingering thumb-drag document listeners on unmount.
|
|
991
|
+
*
|
|
992
|
+
* アンマウント時にサムドラッグ用の document リスナを確実に解除します。
|
|
993
|
+
* ドラッグ中にアンマウントされても pointermove/pointerup/pointercancel が残留せず、
|
|
994
|
+
* 破棄済みコンポーネントの onScroll が呼ばれ続けるのを防ぎます。
|
|
995
|
+
* 依存関係: [handleThumbPointerMove, handleThumbPointerUp]
|
|
996
|
+
*/
|
|
997
|
+
useEffect(() => {
|
|
998
|
+
return () => {
|
|
999
|
+
document.removeEventListener("pointermove", handleThumbPointerMove)
|
|
1000
|
+
document.removeEventListener("pointerup", handleThumbPointerUp)
|
|
1001
|
+
document.removeEventListener("pointercancel", handleThumbPointerUp)
|
|
1002
|
+
}
|
|
1003
|
+
}, [handleThumbPointerMove, handleThumbPointerUp])
|
|
1004
|
+
|
|
1005
|
+
// 矢印ボタン/キーボードのステップ操作用の残差アキュムレータ。位置を量子化 (行スナップ等) して
|
|
1006
|
+
// 返す親では 1 ステップが量子未満だと丸めで消えるため、複数ステップ分を蓄積して前進させる。
|
|
1007
|
+
const arrowScrollResidualRef = useRef(0)
|
|
1008
|
+
// 直前のステップ方向。方向転換時に逆向きの残差を持ち越さないための記録。
|
|
1009
|
+
const arrowScrollDirectionRef = useRef<0 | 1 | -1>(0)
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Moves scroll position by a fixed step using arrow controls.
|
|
1013
|
+
* Accumulates residual amounts so quantizing parents still advance across repeated steps.
|
|
1014
|
+
*
|
|
1015
|
+
* 矢印操作で一定ステップ分スクロールする処理。量子化する親でも連続ステップで前進できるよう残差を蓄積。
|
|
1016
|
+
* 長押しリピートの setInterval クロージャが pointerdown 時点の関数を掴み続けても
|
|
1017
|
+
* 最新のビューポートに基づくステップ量を使えるよう、参照は props ではなく latestMetricsRef
|
|
1018
|
+
* 経由とし、関数自体も安定した useCallback にする。
|
|
1019
|
+
*/
|
|
1020
|
+
const scrollByStep = useCallback(
|
|
1021
|
+
(direction: 1 | -1) => {
|
|
1022
|
+
const step = Math.max(Math.round(latestMetricsRef.current.viewportSize / ARROW_STEP_DIVISOR), MIN_ARROW_STEP)
|
|
1023
|
+
// 方向が変わったら逆向きの残差を破棄する。
|
|
1024
|
+
if (arrowScrollDirectionRef.current !== direction) {
|
|
1025
|
+
arrowScrollDirectionRef.current = direction
|
|
1026
|
+
arrowScrollResidualRef.current = 0
|
|
1027
|
+
}
|
|
1028
|
+
applyScrollDeltaWithResidual(arrowScrollResidualRef, direction * step)
|
|
1029
|
+
},
|
|
1030
|
+
[applyScrollDeltaWithResidual],
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
const { handleArrowPointerDown, handleArrowPointerUp, handleArrowKeyDown } = useArrowAutoRepeat({
|
|
1034
|
+
canUseArrowButtons,
|
|
1035
|
+
enableArrowButtons,
|
|
1036
|
+
resetTapScroll,
|
|
1037
|
+
scrollByStep,
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
// スクロールバーのつまみをポインタイベントで処理
|
|
1041
|
+
/**
|
|
1042
|
+
* Handles pointer down interaction on the thumb.
|
|
1043
|
+
*
|
|
1044
|
+
* つまみ押下時のインタラクションを処理。
|
|
1045
|
+
*/
|
|
1046
|
+
const handlePointerDownOnThumb = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1047
|
+
if (!scrollBarVisible) {
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
if (!enableThumbDrag) {
|
|
1051
|
+
event.preventDefault()
|
|
1052
|
+
event.stopPropagation()
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
if ((event.pointerType === "mouse" && event.button !== 0) || event.ctrlKey) {
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
resetTapScroll()
|
|
1060
|
+
|
|
1061
|
+
// ポインターキャプチャを取得し、iframe/ウィンドウ外での pointerup 取りこぼしによる
|
|
1062
|
+
// ドラッグ固着を防ぐ。キャプチャ後もイベントは document までバブリングするため既存の
|
|
1063
|
+
// document リスナはそのまま機能する。取得不能なポインターでは例外を握り潰す。
|
|
1064
|
+
const element = event.currentTarget
|
|
1065
|
+
const scale = getMainAxisScale(element)
|
|
1066
|
+
let captureTarget: HTMLElement | null = null
|
|
1067
|
+
if (element.setPointerCapture) {
|
|
1068
|
+
try {
|
|
1069
|
+
element.setPointerCapture(event.pointerId)
|
|
1070
|
+
captureTarget = element
|
|
1071
|
+
} catch {
|
|
1072
|
+
captureTarget = null
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
thumbDragStateRef.current = {
|
|
1077
|
+
pointerId: event.pointerId,
|
|
1078
|
+
startThumbPosition: thumbPosition,
|
|
1079
|
+
startClientX: event.clientX,
|
|
1080
|
+
startClientY: event.clientY,
|
|
1081
|
+
scale,
|
|
1082
|
+
captureTarget,
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
document.addEventListener("pointermove", handleThumbPointerMove)
|
|
1086
|
+
document.addEventListener("pointerup", handleThumbPointerUp)
|
|
1087
|
+
document.addEventListener("pointercancel", handleThumbPointerUp)
|
|
1088
|
+
|
|
1089
|
+
setIsDragging(true)
|
|
1090
|
+
setIsThumbHovered(true)
|
|
1091
|
+
|
|
1092
|
+
event.preventDefault()
|
|
1093
|
+
event.stopPropagation()
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Handles pointer down interaction on the track.
|
|
1098
|
+
*
|
|
1099
|
+
* トラック押下時のインタラクションを処理。
|
|
1100
|
+
*/
|
|
1101
|
+
const handlePointerDownOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1102
|
+
if (!scrollBarVisible) {
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
if (!enableTrackClick) {
|
|
1106
|
+
event.preventDefault()
|
|
1107
|
+
event.stopPropagation()
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
if ((event.pointerType === "mouse" && event.button !== 0) || event.ctrlKey) {
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const element = event.currentTarget
|
|
1115
|
+
const rect = element.getBoundingClientRect()
|
|
1116
|
+
const pointerCoordinate = getPointerCoordinate(event)
|
|
1117
|
+
// getBoundingClientRect は視覚px。transform scale で割ってレイアウトpx (thumbSize/trackLength と同系) に揃える。
|
|
1118
|
+
const scale = getMainAxisScale(element)
|
|
1119
|
+
const clickPositionInTrack = (pointerCoordinate - (horizontal ? rect.left : rect.top)) / scale
|
|
1120
|
+
|
|
1121
|
+
resetTapScroll()
|
|
1122
|
+
|
|
1123
|
+
const startThumbPosition = clickPositionInTrack - thumbSize / 2
|
|
1124
|
+
const initialPosition = translateToScrollPosition(startThumbPosition)
|
|
1125
|
+
resolveScrollRequest(initialPosition)
|
|
1126
|
+
|
|
1127
|
+
// サム側と同様、取得不能なポインター (非アクティブ pointerId 等) では例外を握り潰し、
|
|
1128
|
+
// キャプチャなしのフォールバックでドラッグ状態の設定と preventDefault を継続する。
|
|
1129
|
+
if (element.setPointerCapture) {
|
|
1130
|
+
try {
|
|
1131
|
+
element.setPointerCapture(event.pointerId)
|
|
1132
|
+
} catch {
|
|
1133
|
+
// キャプチャ取得失敗時はキャプチャなしで続行 (要素上の pointermove では追従する)。
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
trackDragStateRef.current = {
|
|
1138
|
+
pointerId: event.pointerId,
|
|
1139
|
+
startThumbPosition,
|
|
1140
|
+
startClientX: event.clientX,
|
|
1141
|
+
startClientY: event.clientY,
|
|
1142
|
+
scale,
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
event.preventDefault()
|
|
1146
|
+
event.stopPropagation()
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Resets the track drag state to its idle values.
|
|
1151
|
+
*
|
|
1152
|
+
* トラックドラッグ状態を待機値へリセットする処理。
|
|
1153
|
+
*/
|
|
1154
|
+
const resetTrackDragState = () => {
|
|
1155
|
+
trackDragStateRef.current = { pointerId: null, startThumbPosition: 0, startClientX: 0, startClientY: 0, scale: 1 }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Handles pointer move interaction on the track.
|
|
1160
|
+
*
|
|
1161
|
+
* トラックドラッグ中の移動を処理。
|
|
1162
|
+
*/
|
|
1163
|
+
const handlePointerMoveOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1164
|
+
const state = trackDragStateRef.current
|
|
1165
|
+
if (state.pointerId !== event.pointerId) {
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// キャプチャ取得失敗時などに pointerup を取りこぼした stale ドラッグの自己回復。
|
|
1170
|
+
// マウスはボタン非押下ならドラッグではないため、状態を破棄してホバー移動での誤スクロールを防ぐ。
|
|
1171
|
+
if (event.pointerType === "mouse" && event.buttons === 0) {
|
|
1172
|
+
resetTrackDragState()
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const deltaX = event.clientX - state.startClientX
|
|
1177
|
+
const deltaY = event.clientY - state.startClientY
|
|
1178
|
+
// client 座標差分 (視覚px) を transform scale で割ってレイアウトpx に戻す。
|
|
1179
|
+
const delta = selectDelta(deltaX, deltaY) / (state.scale || 1)
|
|
1180
|
+
const nextPosition = translateToScrollPosition(state.startThumbPosition + delta)
|
|
1181
|
+
resolveScrollRequest(nextPosition)
|
|
1182
|
+
|
|
1183
|
+
if (event.cancelable) {
|
|
1184
|
+
event.preventDefault()
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Handles pointer up interaction on the track.
|
|
1190
|
+
*
|
|
1191
|
+
* トラックドラッグ終了時の処理。
|
|
1192
|
+
*/
|
|
1193
|
+
const handlePointerUpOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1194
|
+
if (trackDragStateRef.current.pointerId !== event.pointerId) {
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// hasPointerCapture は API 非実装環境 (jsdom 等) を考慮してサム側と同様に存在ガード付きで呼ぶ。
|
|
1199
|
+
const element = event.currentTarget
|
|
1200
|
+
if (element.hasPointerCapture?.(event.pointerId)) {
|
|
1201
|
+
element.releasePointerCapture(event.pointerId)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
resetTrackDragState()
|
|
1205
|
+
|
|
1206
|
+
event.preventDefault()
|
|
1207
|
+
event.stopPropagation()
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Handles pointer cancel interaction on the track.
|
|
1212
|
+
*
|
|
1213
|
+
* トラックドラッグキャンセル時の処理。
|
|
1214
|
+
*/
|
|
1215
|
+
const handlePointerCancelOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1216
|
+
if (trackDragStateRef.current.pointerId !== event.pointerId) {
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// hasPointerCapture は API 非実装環境 (jsdom 等) を考慮してサム側と同様に存在ガード付きで呼ぶ。
|
|
1221
|
+
const element = event.currentTarget
|
|
1222
|
+
if (element.hasPointerCapture?.(event.pointerId)) {
|
|
1223
|
+
element.releasePointerCapture(event.pointerId)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
resetTrackDragState()
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Handles loss of pointer capture during a track drag.
|
|
1231
|
+
*
|
|
1232
|
+
* トラックドラッグ中のポインターキャプチャ喪失を処理。
|
|
1233
|
+
* ブラウザ都合 (要素削除・OS 割り込み等) でキャプチャを失った場合に
|
|
1234
|
+
* pointerup を待たずにドラッグ状態を破棄し、stale な pointerId の残留を防ぐ。
|
|
1235
|
+
*/
|
|
1236
|
+
const handleLostPointerCaptureOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1237
|
+
if (trackDragStateRef.current.pointerId !== event.pointerId) {
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
resetTrackDragState()
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const tapCircleOpacityValue = useMemo(() => {
|
|
1245
|
+
const baseOpacity = isTapActive ? 1 : 0.8
|
|
1246
|
+
return minmax(baseOpacity * tapCircleOpacity, 0, 1)
|
|
1247
|
+
}, [isTapActive, tapCircleOpacity])
|
|
1248
|
+
|
|
1249
|
+
const tapCircleStyle = useMemo<CSSProperties>(() => {
|
|
1250
|
+
const diameter = tapCircleSize
|
|
1251
|
+
const baseTop = `calc(50% - ${diameter / 2}px + ${tapCircleOffsetY}px)`
|
|
1252
|
+
return {
|
|
1253
|
+
left: tapCircleOffsetX,
|
|
1254
|
+
top: baseTop,
|
|
1255
|
+
}
|
|
1256
|
+
}, [tapCircleOffsetX, tapCircleOffsetY, tapCircleSize])
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Renders an arrow button for scrollbar controls.
|
|
1260
|
+
* The buttons stay in the tab order (tabIndex=0) so keyboard users can reach
|
|
1261
|
+
* the Enter/Space handlers, which are the only keyboard scrolling entry point.
|
|
1262
|
+
*
|
|
1263
|
+
* スクロールバー制御用矢印ボタンを描画。キーボードで Enter/Space ハンドラへ
|
|
1264
|
+
* 到達できるよう tabIndex=0 でタブ順序に残す。
|
|
1265
|
+
*/
|
|
1266
|
+
const renderArrowButton = (direction: 1 | -1, label: string, icon: string, key: string) => (
|
|
1267
|
+
<button
|
|
1268
|
+
key={key}
|
|
1269
|
+
type="button"
|
|
1270
|
+
tabIndex={0}
|
|
1271
|
+
className="aqvs-scrollbar-arrow-button"
|
|
1272
|
+
style={{
|
|
1273
|
+
[mainSizeKey]: scrollBarWidth,
|
|
1274
|
+
[crossSizeKey]: scrollBarWidth,
|
|
1275
|
+
}}
|
|
1276
|
+
aria-label={label}
|
|
1277
|
+
onPointerDown={handleArrowPointerDown(direction)}
|
|
1278
|
+
onPointerUp={handleArrowPointerUp}
|
|
1279
|
+
onPointerLeave={handleArrowPointerUp}
|
|
1280
|
+
onPointerCancel={handleArrowPointerUp}
|
|
1281
|
+
onKeyDown={handleArrowKeyDown(direction)}
|
|
1282
|
+
aria-disabled={!enableArrowButtons}
|
|
1283
|
+
disabled={!canUseArrowButtons}>
|
|
1284
|
+
<span aria-hidden="true">{icon}</span>
|
|
1285
|
+
</button>
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
const overlayProps: ScrollBarThumbOverlayRenderProps | null =
|
|
1289
|
+
renderThumbOverlay && scrollBarVisible
|
|
1290
|
+
? {
|
|
1291
|
+
orientation,
|
|
1292
|
+
scrollPosition,
|
|
1293
|
+
maxScrollPosition,
|
|
1294
|
+
contentSize,
|
|
1295
|
+
viewportSize,
|
|
1296
|
+
thumbSize,
|
|
1297
|
+
thumbPosition,
|
|
1298
|
+
thumbCenter,
|
|
1299
|
+
trackSize: trackLength,
|
|
1300
|
+
isDragging,
|
|
1301
|
+
isTapScrollActive: isTapActive,
|
|
1302
|
+
visibleStartIndex,
|
|
1303
|
+
visibleEndIndex,
|
|
1304
|
+
}
|
|
1305
|
+
: null
|
|
1306
|
+
|
|
1307
|
+
return (
|
|
1308
|
+
<div
|
|
1309
|
+
className={twMerge("aqvs-scrollbar", directionClass, !scrollBarVisible && "pointer-events-none opacity-0", className)}
|
|
1310
|
+
style={{
|
|
1311
|
+
[mainSizeKey]: viewportSize,
|
|
1312
|
+
[crossSizeKey]: scrollBarWidth,
|
|
1313
|
+
}}
|
|
1314
|
+
role="scrollbar"
|
|
1315
|
+
tabIndex={-1}
|
|
1316
|
+
aria-controls={ariaControls}
|
|
1317
|
+
aria-valuenow={scrollPosition}
|
|
1318
|
+
aria-valuemin={0}
|
|
1319
|
+
aria-valuemax={maxScrollPosition}
|
|
1320
|
+
aria-orientation={horizontal ? "horizontal" : "vertical"}>
|
|
1321
|
+
{!horizontal && scrollBarVisible && tapCircleEnabled && (
|
|
1322
|
+
<TapScrollCircle
|
|
1323
|
+
key="tap-circle"
|
|
1324
|
+
ref={tapCircleHandleRef}
|
|
1325
|
+
className={twMerge("aqvs-scrollbar-tap-circle-wrapper", tapCircleClassName)}
|
|
1326
|
+
size={tapCircleSize}
|
|
1327
|
+
maxVisualDistance={effectiveTapMaxDistance}
|
|
1328
|
+
style={tapCircleStyle}
|
|
1329
|
+
opacity={tapCircleOpacityValue}
|
|
1330
|
+
renderVisual={tapCircleRenderVisual}
|
|
1331
|
+
onDragChange={handleTapCircleDragChange}
|
|
1332
|
+
/>
|
|
1333
|
+
)}
|
|
1334
|
+
{renderArrowButton(-1, arrowLabels[0], arrowIcons[0], "arrow-start")}
|
|
1335
|
+
<div
|
|
1336
|
+
key="track"
|
|
1337
|
+
className="aqvs-scrollbar-track"
|
|
1338
|
+
style={{
|
|
1339
|
+
borderRadius: scrollBarWidth / 2,
|
|
1340
|
+
}}
|
|
1341
|
+
onPointerDown={handlePointerDownOnTrack}
|
|
1342
|
+
onPointerMove={handlePointerMoveOnTrack}
|
|
1343
|
+
onPointerUp={handlePointerUpOnTrack}
|
|
1344
|
+
onPointerCancel={handlePointerCancelOnTrack}
|
|
1345
|
+
onLostPointerCapture={handleLostPointerCaptureOnTrack}
|
|
1346
|
+
aria-disabled={!enableTrackClick}>
|
|
1347
|
+
{overlayProps && (
|
|
1348
|
+
<div key="overlay" className="aqvs-scrollbar-overlay" aria-hidden>
|
|
1349
|
+
{renderThumbOverlay?.(overlayProps)}
|
|
1350
|
+
</div>
|
|
1351
|
+
)}
|
|
1352
|
+
{/* コンテンツがビューポートより大きい場合、またはドラッグ中の場合にのみつまみを表示 */}
|
|
1353
|
+
{/* DOMの再生成を防ぐため、常にレンダリングしてスタイルで表示制御を行う */}
|
|
1354
|
+
<div
|
|
1355
|
+
key="thumb-wrapper"
|
|
1356
|
+
className={twMerge("aqvs-scrollbar-thumb-wrapper", !(scrollBarVisible || isDragging) && "pointer-events-none opacity-0")}
|
|
1357
|
+
style={{
|
|
1358
|
+
[mainSizeKey]: thumbSize,
|
|
1359
|
+
[positionKey]: thumbPosition,
|
|
1360
|
+
...(horizontal ? { top: 0, bottom: 0 } : { left: 0, right: 0 }),
|
|
1361
|
+
}}
|
|
1362
|
+
onPointerDown={handlePointerDownOnThumb}
|
|
1363
|
+
role="slider"
|
|
1364
|
+
aria-orientation={horizontal ? "horizontal" : "vertical"}
|
|
1365
|
+
aria-valuenow={scrollPosition}
|
|
1366
|
+
aria-valuemin={0}
|
|
1367
|
+
aria-valuemax={maxScrollPosition}
|
|
1368
|
+
aria-disabled={!enableThumbDrag}
|
|
1369
|
+
tabIndex={-1}>
|
|
1370
|
+
{/* スクロールバーのつまみ(可視部分) */}
|
|
1371
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: スクロールバーのつまみは必要なインタラクションです */}
|
|
1372
|
+
<div
|
|
1373
|
+
key="thumb"
|
|
1374
|
+
ref={thumbRef}
|
|
1375
|
+
className={twMerge("aqvs-scrollbar-thumb", horizontal ? "aqvs-scrollbar-thumb-horizontal" : "aqvs-scrollbar-thumb-vertical")}
|
|
1376
|
+
data-thumb-state={thumbVisualState}
|
|
1377
|
+
style={{
|
|
1378
|
+
borderRadius: scrollBarWidth - 1,
|
|
1379
|
+
cursor: enableThumbDrag ? "pointer" : "default",
|
|
1380
|
+
}}
|
|
1381
|
+
onMouseEnter={() => {
|
|
1382
|
+
if (enableThumbDrag) {
|
|
1383
|
+
setIsThumbHovered(true)
|
|
1384
|
+
}
|
|
1385
|
+
}}
|
|
1386
|
+
onMouseLeave={() => {
|
|
1387
|
+
if (enableThumbDrag) {
|
|
1388
|
+
setIsThumbHovered(false)
|
|
1389
|
+
}
|
|
1390
|
+
}}
|
|
1391
|
+
/>
|
|
1392
|
+
</div>
|
|
1393
|
+
</div>
|
|
1394
|
+
{renderArrowButton(1, arrowLabels[1], arrowIcons[1], "arrow-end")}
|
|
1395
|
+
</div>
|
|
1396
|
+
)
|
|
1397
|
+
}
|