@aiquants/virtualscroll 3.5.0 → 3.6.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/CHANGELOG.md +66 -0
- package/README.md +11 -1
- package/dist/ScrollBar.d.cts +24 -1
- package/dist/ScrollBar.d.ts +24 -1
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/TapScrollCircle.d.cts +11 -4
- package/dist/TapScrollCircle.d.ts +11 -4
- package/dist/TapScrollCircle.d.ts.map +1 -1
- package/dist/VirtualGrid.d.ts.map +1 -1
- package/dist/computeTapScrollVelocity.d.cts +120 -0
- package/dist/computeTapScrollVelocity.d.ts +121 -0
- package/dist/computeTapScrollVelocity.d.ts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2160 -1961
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +1 -1
- package/dist/useGridTapScroll.d.cts +83 -0
- package/dist/useGridTapScroll.d.ts +84 -0
- package/dist/useGridTapScroll.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/ScrollBar.tsx +16 -4
- package/src/ScrollPane.tsx +1 -0
- package/src/TapScrollCircle.tsx +41 -9
- package/src/VirtualGrid.tsx +181 -8
- package/src/computeTapScrollVelocity.ts +150 -0
- package/src/index.ts +1 -0
- package/src/styles/virtualscroll.css +31 -0
- package/src/useGridTapScroll.ts +370 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-axis rAF driver hook for the grid-owned unified tap circle (v3.6.0). Transplants the
|
|
3
|
+
* ScrollBar tap-loop semantics per axis: residual accumulators with candidate-at-edge boundary
|
|
4
|
+
* reinforcement, per-axis direction-flip residual discard, per-frame immobility stop rule,
|
|
5
|
+
* extent-growth re-arm, and the fail-closed cancel trio.
|
|
6
|
+
* グリッド所有の統合タップサークル用 2 軸 rAF ドライバフック (v3.6.0)。ScrollBar タップループの
|
|
7
|
+
* 意味論を軸別に移植: 端候補補強つき残差アキュムレータ・軸別方向反転残差破棄・毎フレーム
|
|
8
|
+
* immobile 停止則・延長成長の再武装・fail-closed のキャンセル 3 系。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
12
|
+
import { computeTapScrollVelocity, type TapScrollAxisSpeedParams } from "./computeTapScrollVelocity.ts"
|
|
13
|
+
import { TAP_SCROLL_CANCEL_EVENT, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS } from "./ScrollBar.tsx"
|
|
14
|
+
import type { TapScrollCircleDragState, TapScrollCircleHandle } from "./TapScrollCircle.tsx"
|
|
15
|
+
import { minmax } from "./utils.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default corner-relative offset (px) for BOTH grid tap-circle axes — the reachability arm (R)
|
|
19
|
+
* of the three-arm placement law (plan §5). Derivation: full speed needs
|
|
20
|
+
* `maxVisualDistance = 240 px` of advancing pull toward the far screen edge, which is a hard
|
|
21
|
+
* stop on touch (`touch-action: none`). With `off = −200` the disc center sits
|
|
22
|
+
* `sbw − off − size/2 = 12 + 200 − 20 = 192 px` inboard of each far root edge, giving a
|
|
23
|
+
* worst-case (fullscreen, flush) reachable pull fraction `n = 192/240 = 0.80` (`0.66` with the
|
|
24
|
+
* ~34 px OS gesture-navigation zone) ≈ 78 % / 63 % of full-pull speed under the default curve
|
|
25
|
+
* (`n^1.1`). K = 120 (= −200 − (−80)) is half the pull range — the smallest K keeping the
|
|
26
|
+
* nav-zone envelope ≥ ~2/3. Deliberately a LITERAL: it must NOT track a consumer-supplied
|
|
27
|
+
* `maxVisualDistance` (coupling two knobs would make one silently move the other), and
|
|
28
|
+
* shrinking `maxVisualDistance` instead is forbidden (it changes the speed law's `maxDistance`
|
|
29
|
+
* input and breaks the T1 identity anchor).
|
|
30
|
+
* グリッドタップサークル両軸のコーナー相対既定オフセット (px) — 3 アーム配置則の到達性アーム
|
|
31
|
+
* (R)。導出: 全速には前進方向へ 240 px の引きが要り、タッチではスクリーン端がハードストップ。
|
|
32
|
+
* −200 で中心は各遠端から 192 px 内側 = 最悪ケース引き分率 0.80 (ナビ帯込み 0.66)。意図的な
|
|
33
|
+
* リテラル — 消費側 `maxVisualDistance` へ連動させない (ノブ連動の登記済み禁止)。
|
|
34
|
+
*/
|
|
35
|
+
export const GRID_TAP_CIRCLE_DEFAULT_OFFSET = -200
|
|
36
|
+
|
|
37
|
+
/** Per-axis driver bookkeeping (residual + last travel direction). / 軸別のドライバ内部状態 (残差 + 直前進行方向)。 */
|
|
38
|
+
type AxisDriveState = {
|
|
39
|
+
/** Unconsumed scroll amount carried across frames. / フレームを跨いで持ち越す未消化スクロール量。 */
|
|
40
|
+
residual: number
|
|
41
|
+
/** Last non-zero travel direction (flip discards the residual). / 直前の非ゼロ進行方向 (反転で残差破棄)。 */
|
|
42
|
+
direction: -1 | 0 | 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Idle drag state (the TapScrollCircle INITIAL_STATE shape — component-private, mirrored here like ScrollBar does). / 待機ドラッグ状態 (TapScrollCircle の INITIAL_STATE と同形 — ScrollBar と同じくコンポーネント私有形の写し)。 */
|
|
46
|
+
const GRID_TAP_INITIAL_STATE: TapScrollCircleDragState = { active: false, offsetX: 0, offsetY: 0, distance: 0, direction: 0, pointerId: null }
|
|
47
|
+
|
|
48
|
+
/** Parameters for `useGridTapScroll` (read live through a params ref — identity-stability is not required of callers). / `useGridTapScroll` のパラメータ (params ref 経由で毎フレーム最新を読む — 呼出し側に identity 安定を要求しない)。 */
|
|
49
|
+
export type UseGridTapScrollParams = {
|
|
50
|
+
/** Whether the unified circle is live (resolved `enabled` ∧ `gridScrollable`). / 統合サークルが生きているか (解決済み `enabled` ∧ `gridScrollable`)。 */
|
|
51
|
+
enabled: boolean
|
|
52
|
+
/** Shared pull range (= `max(maxVisualDistance, 1)`). / 共有引き範囲 (= `max(maxVisualDistance, 1)`)。 */
|
|
53
|
+
maxDistance: number
|
|
54
|
+
/** Horizontal speed parameters (§4.1 Pₓ). / 横軸速度パラメータ (§4.1 Pₓ)。 */
|
|
55
|
+
xSpeedParams: TapScrollAxisSpeedParams
|
|
56
|
+
/** Vertical speed parameters (§4.1 P_y). / 縦軸速度パラメータ (§4.1 P_y)。 */
|
|
57
|
+
ySpeedParams: TapScrollAxisSpeedParams
|
|
58
|
+
/** The grid's x apply seam (absolute position in, clamped applied position out). / グリッドの x 適用シーム (絶対位置 → クランプ後位置)。 */
|
|
59
|
+
applyHxRef: { readonly current: (next: number) => number }
|
|
60
|
+
/** Synchronous-fresh hx read. / hx の同期・最新読み。 */
|
|
61
|
+
getHx: () => number
|
|
62
|
+
/** The ONE extracted x clamp law (`getMaxHx` SSOT — §3.5-7). / 唯一抽出の x クランプ則 (`getMaxHx` SSOT — §3.5-7)。 */
|
|
63
|
+
getMaxHx: () => number
|
|
64
|
+
/** y apply seam: wraps `scrollBy(delta)` AND syncs `vyRef.current = applied` before returning (§3.5-8 — NOT a bare handle passthrough). / y 適用シーム: `scrollBy(delta)` をラップし `vyRef.current = applied` を同期してから返す (§3.5-8 — 素のハンドル素通しではない)。 */
|
|
65
|
+
applyVy: (delta: number) => number
|
|
66
|
+
/** Synchronous-fresh vy read — MUST read the same authority `applyVy` resolves against (the embedded pane's position), never a notification mirror with a second writer (v3.6.1 — a lagging mirror base overstates `actualDelta` and drives the residual negative = yo-yo). / vy の同期・最新読み — `applyVy` が解決するのと同一権威 (埋め込みペイン位置) を読むこと。第 2 の書き手を持つ通知鏡像は禁止 (v3.6.1 — 遅延鏡像基準は `actualDelta` を過大化し残差を負へ落とすヨーヨー)。 */
|
|
67
|
+
getVy: () => number
|
|
68
|
+
/** y extent read through the embedded handle's freshness channel (never re-derived). / 埋め込みハンドルの鮮度チャネル経由の y 延長 (再導出禁止)。 */
|
|
69
|
+
getMaxVy: () => number
|
|
70
|
+
/** Pending column anchor — cleared ONLY on frames whose APPLIED hx delta ≠ 0 (§3.5-5). / 保留列アンカー — 適用済み hx デルタ ≠ 0 のフレームのみ解除 (§3.5-5)。 */
|
|
71
|
+
pendingColAnchorRef: { current: unknown }
|
|
72
|
+
/** x extent-growth freshness key (columnWindow / total width — §3.5-3). / x 延長成長の鮮度キー (columnWindow / 総幅 — §3.5-3)。 */
|
|
73
|
+
xExtentFreshness: unknown
|
|
74
|
+
/** y extent-growth freshness key (the vertical range state — §3.5-3). / y 延長成長の鮮度キー (縦レンジ state — §3.5-3)。 */
|
|
75
|
+
yExtentFreshness: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Result of `useGridTapScroll`. / `useGridTapScroll` の返り値。 */
|
|
79
|
+
export type UseGridTapScrollResult = {
|
|
80
|
+
/** Whether a tap drag is engaged (drives the opacity law). / タップドラッグ係合中か (opacity 則を駆動)。 */
|
|
81
|
+
isTapActive: boolean
|
|
82
|
+
/** onDragChange handler for the grid's TapScrollCircle. / グリッドの TapScrollCircle へ渡す onDragChange。 */
|
|
83
|
+
handleTapCircleDragChange: (state: TapScrollCircleDragState) => void
|
|
84
|
+
/** Handle ref for the grid's TapScrollCircle (reset + containment reads). / グリッドの TapScrollCircle 用ハンドル ref (リセットと内包判定)。 */
|
|
85
|
+
tapCircleHandleRef: React.RefObject<TapScrollCircleHandle | null>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Owns the unified tap circle's drag state and the two-axis rAF integration loop.
|
|
90
|
+
* 統合タップサークルのドラッグ状態と 2 軸 rAF 積分ループを所有するフック。
|
|
91
|
+
*
|
|
92
|
+
* Per-frame semantics (plan §8.3 — implemented verbatim):
|
|
93
|
+
*
|
|
94
|
+
* ```text
|
|
95
|
+
* dt = min(max((t − last)/1000, 0), TAP_SCROLL_MAX_FRAME_DELTA_SECONDS); skip if dt ≤ 0
|
|
96
|
+
* (vx, vy) = computeTapScrollVelocity({ offsetX, offsetY, maxDistance, x: Pₓ, y: P_y })
|
|
97
|
+
* x: request = vx·dt → residual-apply via applyHx; applied hx delta ≠ 0 clears the col anchor
|
|
98
|
+
* y: request = vy·dt → residual-apply via applyVy (scrollBy + vyRef sync)
|
|
99
|
+
* immobile_a = (maxExtent_a ≤ 0) || atBoundary_a || (o_a === 0) // atBoundary_a from THIS
|
|
100
|
+
* // frame's apply — never latched
|
|
101
|
+
* stop iff !active || direction === 0 || (immobile_x && immobile_y)
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @param params Injected seams (read live through a ref). / 注入シーム (ref 経由で最新を読む)。
|
|
105
|
+
* @returns Drag-state wiring for the grid's circle. / グリッドサークル用のドラッグ結線。
|
|
106
|
+
*/
|
|
107
|
+
export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScrollResult => {
|
|
108
|
+
// パラメータは ref 経由で毎フレーム最新を読む (既存フック配線 effect に deps を足さない規律)
|
|
109
|
+
const paramsRef = useRef(params)
|
|
110
|
+
paramsRef.current = params
|
|
111
|
+
|
|
112
|
+
const tapDragStateRef = useRef<TapScrollCircleDragState>(GRID_TAP_INITIAL_STATE)
|
|
113
|
+
const tapCircleHandleRef = useRef<TapScrollCircleHandle | null>(null)
|
|
114
|
+
const [isTapActive, setIsTapActive] = useState(false)
|
|
115
|
+
const frameRef = useRef<number | null>(null)
|
|
116
|
+
const lastTimestampRef = useRef<number | null>(null)
|
|
117
|
+
const xDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
|
|
118
|
+
const yDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stops the rAF loop and clears per-axis integration state.
|
|
122
|
+
* rAF ループを停止し、軸別積分状態をクリアする処理。
|
|
123
|
+
*/
|
|
124
|
+
const stopLoop = useCallback(() => {
|
|
125
|
+
if (frameRef.current !== null) {
|
|
126
|
+
window.cancelAnimationFrame(frameRef.current)
|
|
127
|
+
frameRef.current = null
|
|
128
|
+
}
|
|
129
|
+
lastTimestampRef.current = null
|
|
130
|
+
xDriveRef.current = { residual: 0, direction: 0 }
|
|
131
|
+
yDriveRef.current = { residual: 0, direction: 0 }
|
|
132
|
+
}, [])
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Applies one axis's per-frame request through its residual accumulator and reports the
|
|
136
|
+
* applied delta plus THIS frame's boundary flag (candidate-at-edge ∧ no movement, or exact
|
|
137
|
+
* edge arrival). The flag is never stored across frames (§3.5-2 freshness rule).
|
|
138
|
+
* 1 軸の毎フレーム要求を残差アキュムレータ経由で適用し、適用デルタと**このフレームの**
|
|
139
|
+
* 境界フラグ (端候補 ∧ 不動、または端値到達) を返す処理。フラグはフレームを跨いで保存
|
|
140
|
+
* しない (§3.5-2 の鮮度規律)。
|
|
141
|
+
*
|
|
142
|
+
* @param drive Axis bookkeeping (mutated in place). / 軸別内部状態 (この場で更新)。
|
|
143
|
+
* @param velocity Signed axis velocity (px/s). / 符号付き軸速度 (px/s)。
|
|
144
|
+
* @param dt Frame delta (s). / フレーム時間 (秒)。
|
|
145
|
+
* @param getPos Fresh position read from the SAME authority `applyDelta` resolves against (the residual feedback `residual -= applied − before` is only exact on one shared authority — v3.6.1). / `applyDelta` が解決するのと同一権威からの位置最新読み (残差フィードバック `residual -= applied − before` は権威共有時のみ正確 — v3.6.1)。
|
|
146
|
+
* @param getMax Fresh extent read. / 延長の最新読み。
|
|
147
|
+
* @param applyDelta Applies a delta and returns the applied position. / 差分を適用し適用後位置を返す。
|
|
148
|
+
* @returns Applied delta and the per-frame boundary flag. / 適用デルタと毎フレーム境界フラグ。
|
|
149
|
+
*/
|
|
150
|
+
const stepAxis = useCallback((drive: AxisDriveState, velocity: number, dt: number, getPos: () => number, getMax: () => number, applyDelta: (delta: number) => number): { actualDelta: number; atBoundary: boolean } => {
|
|
151
|
+
// 方向反転の残差破棄 (軸別 — ScrollBar の方向転換破棄の成分双子): 逆向き残差を
|
|
152
|
+
// 持ち越すと燃焼し切るまで反転が固着する
|
|
153
|
+
const travelDirection: -1 | 0 | 1 = velocity > 0 ? 1 : velocity < 0 ? -1 : 0
|
|
154
|
+
if (travelDirection !== 0 && travelDirection !== drive.direction) {
|
|
155
|
+
drive.direction = travelDirection
|
|
156
|
+
drive.residual = 0
|
|
157
|
+
}
|
|
158
|
+
drive.residual += velocity * dt
|
|
159
|
+
const maxExtent = Math.max(getMax(), 0)
|
|
160
|
+
// 残差の暴走防止: 全スクロール範囲を超える残差は意味を持たない (ScrollBar と同じ保険)
|
|
161
|
+
if (maxExtent > 0) {
|
|
162
|
+
drive.residual = minmax(drive.residual, -maxExtent, maxExtent)
|
|
163
|
+
}
|
|
164
|
+
const request = drive.residual
|
|
165
|
+
if (request === 0) {
|
|
166
|
+
return { actualDelta: 0, atBoundary: false }
|
|
167
|
+
}
|
|
168
|
+
const before = getPos()
|
|
169
|
+
const appliedPosition = applyDelta(request)
|
|
170
|
+
const actualDelta = appliedPosition - before
|
|
171
|
+
drive.residual -= actualDelta
|
|
172
|
+
// 端候補補強 (ScrollBar applyScrollDelta の双子): 量子化する親では実位置が端値ちょうどに
|
|
173
|
+
// 届かないため、「クランプ前の要求候補が端に達しているのに位置が変わらない」も境界とみなす
|
|
174
|
+
const candidateAtMin = request < 0 && before + request <= 0
|
|
175
|
+
const candidateAtMax = request > 0 && before + request >= maxExtent
|
|
176
|
+
const atBoundary = (request < 0 && (appliedPosition <= 0 || (candidateAtMin && actualDelta === 0))) || (request > 0 && (appliedPosition >= maxExtent || (candidateAtMax && actualDelta === 0)))
|
|
177
|
+
if (atBoundary) {
|
|
178
|
+
drive.residual = 0
|
|
179
|
+
}
|
|
180
|
+
return { actualDelta, atBoundary }
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One integration frame of the two-axis loop.
|
|
185
|
+
* 2 軸ループの 1 積分フレーム。
|
|
186
|
+
*/
|
|
187
|
+
const step = useCallback(
|
|
188
|
+
(timestamp: number) => {
|
|
189
|
+
// この rAF ループ意味論は ScrollBar.tsx の tap ループ (stepAutoScroll) と双子 — 片方を直したらもう片方も直すこと (ADR-27)
|
|
190
|
+
const current = paramsRef.current
|
|
191
|
+
const state = tapDragStateRef.current
|
|
192
|
+
if (!state.active || state.direction === 0) {
|
|
193
|
+
stopLoop()
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const lastTimestamp = lastTimestampRef.current ?? timestamp
|
|
197
|
+
const deltaSecondsRaw = Math.max((timestamp - lastTimestamp) / 1000, 0)
|
|
198
|
+
const deltaSeconds = Math.min(deltaSecondsRaw, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS)
|
|
199
|
+
lastTimestampRef.current = timestamp
|
|
200
|
+
if (deltaSeconds <= 0) {
|
|
201
|
+
frameRef.current = window.requestAnimationFrame(step)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
// live state ref から毎フレーム分解 (§4.1 — 方向余弦分解)
|
|
205
|
+
const velocity = computeTapScrollVelocity({ offsetX: state.offsetX, offsetY: state.offsetY, maxDistance: current.maxDistance, x: current.xSpeedParams, y: current.ySpeedParams })
|
|
206
|
+
const xResult = stepAxis(xDriveRef.current, velocity.x, deltaSeconds, current.getHx, current.getMaxHx, (delta) => current.applyHxRef.current(current.getHx() + delta))
|
|
207
|
+
if (xResult.actualDelta !== 0) {
|
|
208
|
+
// 適用済み hx デルタのみが保留列アンカーを解除する (§3.5-5 — バーの要求時規則より厳格:
|
|
209
|
+
// 余弦則下ではほぼ縦のドラッグも常に付随 vx ≠ 0 を運ぶため、要求ゲートでは
|
|
210
|
+
// 右端で休んでいるだけでアンカーが無言脱落する)
|
|
211
|
+
current.pendingColAnchorRef.current = null
|
|
212
|
+
}
|
|
213
|
+
const yResult = stepAxis(yDriveRef.current, velocity.y, deltaSeconds, current.getVy, current.getMaxVy, current.applyVy)
|
|
214
|
+
// 停止則 (§3.5-2): 軸別 immobile を毎フレーム評価。o_a === 0 腕は厳密 (v_a ≡ 0) で、
|
|
215
|
+
// 軸上保持のアイドル空転とコーナー保持空転の両穴を閉じる。片軸境界は他軸の積分を続行
|
|
216
|
+
const immobileX = current.getMaxHx() <= 0 || xResult.atBoundary || state.offsetX === 0
|
|
217
|
+
const immobileY = current.getMaxVy() <= 0 || yResult.atBoundary || state.offsetY === 0
|
|
218
|
+
if (immobileX && immobileY) {
|
|
219
|
+
// パーク: onDragChange は毎 pointermove 発火 + 冪等 start なので、角度が付けば再開する
|
|
220
|
+
stopLoop()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
frameRef.current = window.requestAnimationFrame(step)
|
|
224
|
+
},
|
|
225
|
+
[stopLoop, stepAxis],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Starts the loop if it is not already scheduled (idempotent — the ScrollBar twin).
|
|
230
|
+
* 未予約ならループを開始する処理 (冪等 — ScrollBar の双子)。
|
|
231
|
+
*/
|
|
232
|
+
const startLoop = useCallback(() => {
|
|
233
|
+
if (frameRef.current === null) {
|
|
234
|
+
lastTimestampRef.current = null
|
|
235
|
+
xDriveRef.current = { residual: 0, direction: 0 }
|
|
236
|
+
yDriveRef.current = { residual: 0, direction: 0 }
|
|
237
|
+
frameRef.current = window.requestAnimationFrame(step)
|
|
238
|
+
}
|
|
239
|
+
}, [step])
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resets the drag state, the circle, and the loop (the fail-closed reset target).
|
|
243
|
+
* ドラッグ状態・サークル・ループをリセットする処理 (fail-closed のリセット着地点)。
|
|
244
|
+
*/
|
|
245
|
+
const resetTapScroll = useCallback(() => {
|
|
246
|
+
tapDragStateRef.current = { ...GRID_TAP_INITIAL_STATE }
|
|
247
|
+
setIsTapActive(false)
|
|
248
|
+
tapCircleHandleRef.current?.reset()
|
|
249
|
+
stopLoop()
|
|
250
|
+
}, [stopLoop])
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Receives drag-state updates from the circle and starts/stops the loop.
|
|
254
|
+
* サークルからのドラッグ状態更新を受け、ループを開始 / 停止する処理。
|
|
255
|
+
*/
|
|
256
|
+
const handleTapCircleDragChange = useCallback(
|
|
257
|
+
(state: TapScrollCircleDragState) => {
|
|
258
|
+
tapDragStateRef.current = state
|
|
259
|
+
setIsTapActive(state.active)
|
|
260
|
+
if (state.active && state.direction !== 0) {
|
|
261
|
+
startLoop()
|
|
262
|
+
} else {
|
|
263
|
+
stopLoop()
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[startLoop, stopLoop],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const { enabled, xExtentFreshness, yExtentFreshness } = params
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Re-arms a boundary-parked loop when either axis's extent grows under a held finger.
|
|
273
|
+
* 保持中にどちらかの軸の延長が伸びたとき、境界パーク中のループを張り直す副作用。
|
|
274
|
+
*
|
|
275
|
+
* ❗ ループは境界 / immobile で自分を止め、`startLoop()` はポインタイベントからしか呼ばれ
|
|
276
|
+
* ない。無限スクロール / ストリーミング取得では指を離すまで動かない状態になるため、
|
|
277
|
+
* グリッド自身の鮮度チャネル (x: columnWindow / 総幅、y: 縦レンジ state) をキーに再開する
|
|
278
|
+
* (ScrollBar の contentSize / viewportSize 再武装の 2 軸双子)。冪等 start のため多重起動なし。
|
|
279
|
+
*
|
|
280
|
+
* 目的: 延長成長時の再前進 (§3.5-3)。
|
|
281
|
+
* 依存関係: [xExtentFreshness, yExtentFreshness, startLoop]
|
|
282
|
+
* クリーンアップ: 不要 (停止はループ自身とアンマウント副作用が担う)。
|
|
283
|
+
*/
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
const state = tapDragStateRef.current
|
|
286
|
+
if (!state.active || state.direction === 0) {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
startLoop()
|
|
290
|
+
}, [xExtentFreshness, yExtentFreshness, startLoop])
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Cancel path (a): the package-wide cancel event — UNCONDITIONAL reset.
|
|
294
|
+
* キャンセル系 (a): パッケージ全域のキャンセルイベント — 無条件リセット。
|
|
295
|
+
*
|
|
296
|
+
* ❗ 出荷済み hbar 意味論の継承: `ariaControls` を持たないバーはページ全域のキャンセルに
|
|
297
|
+
* 反応する (paneId フィルタなし)。統合サークルも一様に無条件 = 厳密 fail-closed。
|
|
298
|
+
*
|
|
299
|
+
* 目的: コンテンツパン等の外部操作でタップスクロールを止める。
|
|
300
|
+
* 依存関係: [resetTapScroll]
|
|
301
|
+
* クリーンアップ: リスナ解除。
|
|
302
|
+
*/
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
const handleTapScrollCancel = () => {
|
|
305
|
+
resetTapScroll()
|
|
306
|
+
}
|
|
307
|
+
window.addEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel)
|
|
308
|
+
return () => {
|
|
309
|
+
window.removeEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel)
|
|
310
|
+
}
|
|
311
|
+
}, [resetTapScroll])
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Cancel path (b): document-capture pointerdown outside the circle (another pointer).
|
|
315
|
+
* キャンセル系 (b): サークル外の別ポインタによる document 捕捉 pointerdown。
|
|
316
|
+
*
|
|
317
|
+
* 目的: ドラッグ中の別指タッチで fail-closed に停止する (ScrollBar の双子)。
|
|
318
|
+
* 依存関係: [enabled, resetTapScroll]
|
|
319
|
+
* クリーンアップ: リスナ解除。
|
|
320
|
+
*/
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!enabled) {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
326
|
+
if (!tapDragStateRef.current.active) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
// ドラッグ中のポインター自身は無視 (通常はキャプチャ済みでここへ来ないが念のため)
|
|
330
|
+
if (tapDragStateRef.current.pointerId === event.pointerId) {
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const targetNode = event.target
|
|
334
|
+
if (!(targetNode instanceof Node)) {
|
|
335
|
+
resetTapScroll()
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const element = tapCircleHandleRef.current?.getElement()
|
|
339
|
+
if (element?.contains(targetNode)) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
resetTapScroll()
|
|
343
|
+
}
|
|
344
|
+
document.addEventListener("pointerdown", handlePointerDown, true)
|
|
345
|
+
return () => {
|
|
346
|
+
document.removeEventListener("pointerdown", handlePointerDown, true)
|
|
347
|
+
}
|
|
348
|
+
}, [enabled, resetTapScroll])
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Cancel path (c): `enabled` → false resets; unmount stops the loop.
|
|
352
|
+
* キャンセル系 (c): `enabled` → false でリセット、アンマウントでループ停止。
|
|
353
|
+
*
|
|
354
|
+
* 目的: 無効化 / 退場時に rAF を残さない。
|
|
355
|
+
* 依存関係: [enabled, resetTapScroll] / [stopLoop]
|
|
356
|
+
* クリーンアップ: アンマウント時のループ停止。
|
|
357
|
+
*/
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!enabled) {
|
|
360
|
+
resetTapScroll()
|
|
361
|
+
}
|
|
362
|
+
}, [enabled, resetTapScroll])
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
return () => {
|
|
365
|
+
stopLoop()
|
|
366
|
+
}
|
|
367
|
+
}, [stopLoop])
|
|
368
|
+
|
|
369
|
+
return { isTapActive, handleTapCircleDragChange, tapCircleHandleRef }
|
|
370
|
+
}
|