@aiquants/virtualscroll 3.7.0 → 3.7.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 +25 -0
- package/README.md +19 -0
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/TapScrollCircle.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1302 -1332
- package/dist/pointerCapture.d.cts +68 -0
- package/dist/pointerCapture.d.ts +69 -0
- package/dist/pointerCapture.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/ScrollBar.tsx +20 -34
- package/src/ScrollPane.tsx +45 -18
- package/src/TapScrollCircle.tsx +35 -25
- package/src/pointerCapture.ts +89 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module pointerCapture
|
|
3
|
+
* @description The single capability rule for the Pointer Capture API, shared by every pointer interaction in this package.
|
|
4
|
+
*
|
|
5
|
+
* @description 本パッケージのすべてのポインタ操作が共有する、Pointer Capture API の唯一の能力判定モジュール。
|
|
6
|
+
*
|
|
7
|
+
* `ScrollPane` (コンテンツのドラッグスクロール)・`ScrollBar` (サム / トラックのドラッグ)・
|
|
8
|
+
* `TapScrollCircle` (タップスクロールサークル) は、キャプチャの取得・解放・保持確認を
|
|
9
|
+
* 必ずここを通して行う。要素の `setPointerCapture` / `releasePointerCapture` /
|
|
10
|
+
* `hasPointerCapture` を直接呼んではならない。
|
|
11
|
+
*
|
|
12
|
+
* ❗ **3 つのメソッドが揃っている要素だけを「対応」とみなす。** jsdom (React のテストの既定環境) は
|
|
13
|
+
* このどれも実装しないため、直接呼ぶと利用側の `user.click` 1 回で `TypeError` になる。
|
|
14
|
+
* 一部だけを実装する環境で「取得はしたが解放も確認もできない」状態を作らないよう、判定は 1 本にしてある。
|
|
15
|
+
*
|
|
16
|
+
* 非対応の要素ではキャプチャ無しで操作を続ける (縮退)。コンテンツ領域とサムのドラッグは
|
|
17
|
+
* `window` / `document` のリスナーが追従を受け持つため動き続け、トラックとタップサークルは
|
|
18
|
+
* ポインタが要素上にある間だけ追従する。失われるのは、ウインドウ外や iframe 上で離したときの
|
|
19
|
+
* 終端イベントの受信と、`lostpointercapture` による強制解放の検知である。
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Outcome of a pointer capture request.
|
|
23
|
+
* ポインタキャプチャ要求の結果。
|
|
24
|
+
*
|
|
25
|
+
* - `"captured"`: The element now holds the capture. / 要素がキャプチャを保持した。
|
|
26
|
+
* - `"unsupported"`: The element does not implement the Pointer Capture API; nothing was attempted. / 要素が API を実装しておらず、要求自体を行っていない。
|
|
27
|
+
* - `"inactive-pointer"`: The UA rejected the pointer as inactive (`NotFoundError`). / ポインタが非アクティブとして拒否された (`NotFoundError`)。
|
|
28
|
+
* - `"rejected"`: The UA rejected the request for any other reason. / それ以外の理由で拒否された。
|
|
29
|
+
*/
|
|
30
|
+
export type PointerCaptureResult = "captured" | "unsupported" | "inactive-pointer" | "rejected";
|
|
31
|
+
/**
|
|
32
|
+
* Reports whether the element implements the whole Pointer Capture API.
|
|
33
|
+
* 要素が Pointer Capture API (取得・解放・保持確認の 3 メソッド) をすべて実装しているかの判定処理。
|
|
34
|
+
*
|
|
35
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
36
|
+
* @returns True when set / release / has are all callable. 3 メソッドがすべて呼び出し可能な場合に true。
|
|
37
|
+
*/
|
|
38
|
+
export declare const supportsPointerCapture: (element: Element) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Reports whether the element holds (or has a pending) capture of the pointer.
|
|
41
|
+
* 要素が指定ポインタのキャプチャを保持 (または保留) しているかの判定処理。
|
|
42
|
+
*
|
|
43
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
44
|
+
* @param pointerId The pointer to look up. 対象のポインタ ID。
|
|
45
|
+
* @returns True when the capture is held; always false when the API is unsupported. 保持している場合に true。API 非対応の要素では常に false。
|
|
46
|
+
*/
|
|
47
|
+
export declare const isPointerCaptured: (element: Element, pointerId: number) => boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Requests capture of the pointer for the element and classifies the outcome without throwing.
|
|
50
|
+
* 要素へ指定ポインタのキャプチャを要求し、例外を投げずに結果を分類する処理。
|
|
51
|
+
*
|
|
52
|
+
* @param element The element that should receive the capture. キャプチャを受け取る要素。
|
|
53
|
+
* @param pointerId The pointer to capture. キャプチャするポインタ ID。
|
|
54
|
+
* @returns The classified outcome of the request. 要求結果の分類。
|
|
55
|
+
*/
|
|
56
|
+
export declare const capturePointer: (element: Element, pointerId: number) => PointerCaptureResult;
|
|
57
|
+
/**
|
|
58
|
+
* Releases the element's capture of the pointer when it holds one; does nothing otherwise.
|
|
59
|
+
* 要素が指定ポインタのキャプチャを保持していれば解放し、保持していなければ何もしない処理。
|
|
60
|
+
*
|
|
61
|
+
* 呼び出し側が状態を確定させてから呼ぶこと。`lostpointercapture` を同期発火するブラウザでは、
|
|
62
|
+
* この呼び出しの最中に喪失ハンドラーが走る。
|
|
63
|
+
*
|
|
64
|
+
* @param element The element that may hold the capture. キャプチャを保持している可能性のある要素。
|
|
65
|
+
* @param pointerId The pointer to release. 解放するポインタ ID。
|
|
66
|
+
* @returns Nothing. なし。
|
|
67
|
+
*/
|
|
68
|
+
export declare const releaseCapturedPointer: (element: Element, pointerId: number) => void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module pointerCapture
|
|
3
|
+
* @description The single capability rule for the Pointer Capture API, shared by every pointer interaction in this package.
|
|
4
|
+
*
|
|
5
|
+
* @description 本パッケージのすべてのポインタ操作が共有する、Pointer Capture API の唯一の能力判定モジュール。
|
|
6
|
+
*
|
|
7
|
+
* `ScrollPane` (コンテンツのドラッグスクロール)・`ScrollBar` (サム / トラックのドラッグ)・
|
|
8
|
+
* `TapScrollCircle` (タップスクロールサークル) は、キャプチャの取得・解放・保持確認を
|
|
9
|
+
* 必ずここを通して行う。要素の `setPointerCapture` / `releasePointerCapture` /
|
|
10
|
+
* `hasPointerCapture` を直接呼んではならない。
|
|
11
|
+
*
|
|
12
|
+
* ❗ **3 つのメソッドが揃っている要素だけを「対応」とみなす。** jsdom (React のテストの既定環境) は
|
|
13
|
+
* このどれも実装しないため、直接呼ぶと利用側の `user.click` 1 回で `TypeError` になる。
|
|
14
|
+
* 一部だけを実装する環境で「取得はしたが解放も確認もできない」状態を作らないよう、判定は 1 本にしてある。
|
|
15
|
+
*
|
|
16
|
+
* 非対応の要素ではキャプチャ無しで操作を続ける (縮退)。コンテンツ領域とサムのドラッグは
|
|
17
|
+
* `window` / `document` のリスナーが追従を受け持つため動き続け、トラックとタップサークルは
|
|
18
|
+
* ポインタが要素上にある間だけ追従する。失われるのは、ウインドウ外や iframe 上で離したときの
|
|
19
|
+
* 終端イベントの受信と、`lostpointercapture` による強制解放の検知である。
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Outcome of a pointer capture request.
|
|
23
|
+
* ポインタキャプチャ要求の結果。
|
|
24
|
+
*
|
|
25
|
+
* - `"captured"`: The element now holds the capture. / 要素がキャプチャを保持した。
|
|
26
|
+
* - `"unsupported"`: The element does not implement the Pointer Capture API; nothing was attempted. / 要素が API を実装しておらず、要求自体を行っていない。
|
|
27
|
+
* - `"inactive-pointer"`: The UA rejected the pointer as inactive (`NotFoundError`). / ポインタが非アクティブとして拒否された (`NotFoundError`)。
|
|
28
|
+
* - `"rejected"`: The UA rejected the request for any other reason. / それ以外の理由で拒否された。
|
|
29
|
+
*/
|
|
30
|
+
export type PointerCaptureResult = "captured" | "unsupported" | "inactive-pointer" | "rejected";
|
|
31
|
+
/**
|
|
32
|
+
* Reports whether the element implements the whole Pointer Capture API.
|
|
33
|
+
* 要素が Pointer Capture API (取得・解放・保持確認の 3 メソッド) をすべて実装しているかの判定処理。
|
|
34
|
+
*
|
|
35
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
36
|
+
* @returns True when set / release / has are all callable. 3 メソッドがすべて呼び出し可能な場合に true。
|
|
37
|
+
*/
|
|
38
|
+
export declare const supportsPointerCapture: (element: Element) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Reports whether the element holds (or has a pending) capture of the pointer.
|
|
41
|
+
* 要素が指定ポインタのキャプチャを保持 (または保留) しているかの判定処理。
|
|
42
|
+
*
|
|
43
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
44
|
+
* @param pointerId The pointer to look up. 対象のポインタ ID。
|
|
45
|
+
* @returns True when the capture is held; always false when the API is unsupported. 保持している場合に true。API 非対応の要素では常に false。
|
|
46
|
+
*/
|
|
47
|
+
export declare const isPointerCaptured: (element: Element, pointerId: number) => boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Requests capture of the pointer for the element and classifies the outcome without throwing.
|
|
50
|
+
* 要素へ指定ポインタのキャプチャを要求し、例外を投げずに結果を分類する処理。
|
|
51
|
+
*
|
|
52
|
+
* @param element The element that should receive the capture. キャプチャを受け取る要素。
|
|
53
|
+
* @param pointerId The pointer to capture. キャプチャするポインタ ID。
|
|
54
|
+
* @returns The classified outcome of the request. 要求結果の分類。
|
|
55
|
+
*/
|
|
56
|
+
export declare const capturePointer: (element: Element, pointerId: number) => PointerCaptureResult;
|
|
57
|
+
/**
|
|
58
|
+
* Releases the element's capture of the pointer when it holds one; does nothing otherwise.
|
|
59
|
+
* 要素が指定ポインタのキャプチャを保持していれば解放し、保持していなければ何もしない処理。
|
|
60
|
+
*
|
|
61
|
+
* 呼び出し側が状態を確定させてから呼ぶこと。`lostpointercapture` を同期発火するブラウザでは、
|
|
62
|
+
* この呼び出しの最中に喪失ハンドラーが走る。
|
|
63
|
+
*
|
|
64
|
+
* @param element The element that may hold the capture. キャプチャを保持している可能性のある要素。
|
|
65
|
+
* @param pointerId The pointer to release. 解放するポインタ ID。
|
|
66
|
+
* @returns Nothing. なし。
|
|
67
|
+
*/
|
|
68
|
+
export declare const releaseCapturedPointer: (element: Element, pointerId: number) => void;
|
|
69
|
+
//# sourceMappingURL=pointerCapture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pointerCapture.d.ts","sourceRoot":"","sources":["../src/pointerCapture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG,aAAa,GAAG,kBAAkB,GAAG,UAAU,CAAA;AAE/F;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS,OAAO,KAAG,OAAoK,CAAA;AAE9N;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAAI,SAAS,OAAO,EAAE,WAAW,MAAM,KAAG,OAAkF,CAAA;AAE1J;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,SAAS,OAAO,EAAE,WAAW,MAAM,KAAG,oBAYpE,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS,OAAO,EAAE,WAAW,MAAM,KAAG,IAI5E,CAAA"}
|
package/package.json
CHANGED
package/src/ScrollBar.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|
|
8
8
|
import { twMerge } from "tailwind-merge"
|
|
9
9
|
import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type VirtualScrollLocale } from "./labels.ts"
|
|
10
10
|
import { Logger } from "./logger.ts"
|
|
11
|
+
import { capturePointer, releaseCapturedPointer } from "./pointerCapture.ts"
|
|
11
12
|
import { TapScrollCircle, type TapScrollCircleDragState, type TapScrollCircleHandle, type TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
|
|
12
13
|
import { getAxisScale, minmax } from "./utils.ts"
|
|
13
14
|
|
|
@@ -1136,10 +1137,10 @@ export const ScrollBar = ({
|
|
|
1136
1137
|
|
|
1137
1138
|
/**
|
|
1138
1139
|
* Pointer up handler for thumb dragging.
|
|
1139
|
-
* Cleans up drag state and visual feedback.
|
|
1140
|
+
* Cleans up drag state and visual feedback, releasing the capture only when the press acquired one.
|
|
1140
1141
|
*
|
|
1141
1142
|
* つまみドラッグ用のポインタ上げハンドラー。
|
|
1142
|
-
*
|
|
1143
|
+
* ドラッグ状態と視覚的フィードバックをクリーンアップし、押下時にキャプチャを取得できていた場合だけ解放します。
|
|
1143
1144
|
*/
|
|
1144
1145
|
const handleThumbPointerUp = useCallback(
|
|
1145
1146
|
(event: PointerEvent) => {
|
|
@@ -1152,9 +1153,8 @@ export const ScrollBar = ({
|
|
|
1152
1153
|
document.removeEventListener("pointerup", handleThumbPointerUp)
|
|
1153
1154
|
document.removeEventListener("pointercancel", handleThumbPointerUp)
|
|
1154
1155
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
captureTarget.releasePointerCapture(event.pointerId)
|
|
1156
|
+
if (state.captureTarget !== null) {
|
|
1157
|
+
releaseCapturedPointer(state.captureTarget, event.pointerId)
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
1160
|
thumbDragStateRef.current = { pointerId: null, startThumbPosition: 0, startClientX: 0, startClientY: 0, scale: 1, captureTarget: null }
|
|
@@ -1227,8 +1227,10 @@ export const ScrollBar = ({
|
|
|
1227
1227
|
// スクロールバーのつまみをポインタイベントで処理
|
|
1228
1228
|
/**
|
|
1229
1229
|
* Handles pointer down interaction on the thumb.
|
|
1230
|
+
* Captures the pointer when the element supports it; otherwise the drag continues on the document listeners alone.
|
|
1230
1231
|
*
|
|
1231
1232
|
* つまみ押下時のインタラクションを処理。
|
|
1233
|
+
* 要素がキャプチャに対応していれば取得し、取得できなければ document のリスナーだけでドラッグを続ける。
|
|
1232
1234
|
*/
|
|
1233
1235
|
const handlePointerDownOnThumb = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1234
1236
|
if (!scrollBarVisible) {
|
|
@@ -1247,18 +1249,10 @@ export const ScrollBar = ({
|
|
|
1247
1249
|
|
|
1248
1250
|
// ポインターキャプチャを取得し、iframe/ウィンドウ外での pointerup 取りこぼしによる
|
|
1249
1251
|
// ドラッグ固着を防ぐ。キャプチャ後もイベントは document までバブリングするため既存の
|
|
1250
|
-
// document
|
|
1252
|
+
// document リスナはそのまま機能する。取得できなかったときは解放対象を持たない。
|
|
1251
1253
|
const element = event.currentTarget
|
|
1252
1254
|
const scale = getMainAxisScale(element)
|
|
1253
|
-
|
|
1254
|
-
if (element.setPointerCapture) {
|
|
1255
|
-
try {
|
|
1256
|
-
element.setPointerCapture(event.pointerId)
|
|
1257
|
-
captureTarget = element
|
|
1258
|
-
} catch {
|
|
1259
|
-
captureTarget = null
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1255
|
+
const captureTarget = capturePointer(element, event.pointerId) === "captured" ? element : null
|
|
1262
1256
|
|
|
1263
1257
|
thumbDragStateRef.current = {
|
|
1264
1258
|
pointerId: event.pointerId,
|
|
@@ -1282,8 +1276,10 @@ export const ScrollBar = ({
|
|
|
1282
1276
|
|
|
1283
1277
|
/**
|
|
1284
1278
|
* Handles pointer down interaction on the track.
|
|
1279
|
+
* Jumps to the pressed position and captures the pointer when the element supports it.
|
|
1285
1280
|
*
|
|
1286
1281
|
* トラック押下時のインタラクションを処理。
|
|
1282
|
+
* 押下位置へ移動し、要素がキャプチャに対応していれば取得する。
|
|
1287
1283
|
*/
|
|
1288
1284
|
const handlePointerDownOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1289
1285
|
if (!scrollBarVisible) {
|
|
@@ -1311,15 +1307,9 @@ export const ScrollBar = ({
|
|
|
1311
1307
|
const initialPosition = translateToScrollPosition(startThumbPosition)
|
|
1312
1308
|
resolveScrollRequest(initialPosition)
|
|
1313
1309
|
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
try {
|
|
1318
|
-
element.setPointerCapture(event.pointerId)
|
|
1319
|
-
} catch {
|
|
1320
|
-
// キャプチャ取得失敗時はキャプチャなしで続行 (要素上の pointermove では追従する)。
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1310
|
+
// 取得できなくても (非アクティブ pointerId・API 非対応) ドラッグ状態の設定と preventDefault は続ける。
|
|
1311
|
+
// キャプチャ無しでも要素上の pointermove では追従する。
|
|
1312
|
+
capturePointer(element, event.pointerId)
|
|
1323
1313
|
|
|
1324
1314
|
trackDragStateRef.current = {
|
|
1325
1315
|
pointerId: event.pointerId,
|
|
@@ -1374,19 +1364,17 @@ export const ScrollBar = ({
|
|
|
1374
1364
|
|
|
1375
1365
|
/**
|
|
1376
1366
|
* Handles pointer up interaction on the track.
|
|
1367
|
+
* Releases the capture when held and ends the track drag.
|
|
1377
1368
|
*
|
|
1378
1369
|
* トラックドラッグ終了時の処理。
|
|
1370
|
+
* キャプチャを保持していれば解放し、トラックドラッグを終える。
|
|
1379
1371
|
*/
|
|
1380
1372
|
const handlePointerUpOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1381
1373
|
if (trackDragStateRef.current.pointerId !== event.pointerId) {
|
|
1382
1374
|
return
|
|
1383
1375
|
}
|
|
1384
1376
|
|
|
1385
|
-
|
|
1386
|
-
const element = event.currentTarget
|
|
1387
|
-
if (element.hasPointerCapture?.(event.pointerId)) {
|
|
1388
|
-
element.releasePointerCapture(event.pointerId)
|
|
1389
|
-
}
|
|
1377
|
+
releaseCapturedPointer(event.currentTarget, event.pointerId)
|
|
1390
1378
|
|
|
1391
1379
|
resetTrackDragState()
|
|
1392
1380
|
|
|
@@ -1396,19 +1384,17 @@ export const ScrollBar = ({
|
|
|
1396
1384
|
|
|
1397
1385
|
/**
|
|
1398
1386
|
* Handles pointer cancel interaction on the track.
|
|
1387
|
+
* Releases the capture when held and discards the track drag.
|
|
1399
1388
|
*
|
|
1400
1389
|
* トラックドラッグキャンセル時の処理。
|
|
1390
|
+
* キャプチャを保持していれば解放し、トラックドラッグを破棄する。
|
|
1401
1391
|
*/
|
|
1402
1392
|
const handlePointerCancelOnTrack = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
1403
1393
|
if (trackDragStateRef.current.pointerId !== event.pointerId) {
|
|
1404
1394
|
return
|
|
1405
1395
|
}
|
|
1406
1396
|
|
|
1407
|
-
|
|
1408
|
-
const element = event.currentTarget
|
|
1409
|
-
if (element.hasPointerCapture?.(event.pointerId)) {
|
|
1410
|
-
element.releasePointerCapture(event.pointerId)
|
|
1411
|
-
}
|
|
1397
|
+
releaseCapturedPointer(event.currentTarget, event.pointerId)
|
|
1412
1398
|
|
|
1413
1399
|
resetTrackDragState()
|
|
1414
1400
|
}
|
package/src/ScrollPane.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayo
|
|
|
2
2
|
import { twMerge } from "tailwind-merge"
|
|
3
3
|
import type { VirtualScrollLabelOverrides, VirtualScrollLocale } from "./labels.ts"
|
|
4
4
|
import { Logger } from "./logger.ts"
|
|
5
|
+
import { capturePointer, isPointerCaptured, releaseCapturedPointer } from "./pointerCapture.ts"
|
|
5
6
|
import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
|
|
6
7
|
import { getAxisScale, minmax } from "./utils.ts"
|
|
7
8
|
import { resolveWheelAxes } from "./wheelAxes.ts"
|
|
@@ -1133,11 +1134,22 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1133
1134
|
state.velocitySamples = []
|
|
1134
1135
|
state.scale = 1
|
|
1135
1136
|
// 状態を確定させてから解放する (同期 lostpointercapture に引き継ぎを壊させない)
|
|
1136
|
-
if (element
|
|
1137
|
-
element
|
|
1137
|
+
if (element !== null) {
|
|
1138
|
+
releaseCapturedPointer(element, releasedPointerId)
|
|
1138
1139
|
}
|
|
1139
1140
|
}, [enablePointerDrag, pointerDragInputs, isScrollable])
|
|
1140
1141
|
|
|
1142
|
+
/**
|
|
1143
|
+
* Wires the pointer drag-scroll of the content area and tears it down on unmount.
|
|
1144
|
+
* コンテンツ領域のポインタドラッグスクロールを結線し、アンマウント時に解除する処理。
|
|
1145
|
+
*
|
|
1146
|
+
* キャプチャの取得・解放・保持確認はすべて `pointerCapture.ts` の能力判定を通す。
|
|
1147
|
+
* Pointer Capture API の無い環境 (jsdom 等) でもドラッグは `window` のリスナーで追従し、例外は投げない。
|
|
1148
|
+
*
|
|
1149
|
+
* 目的: ドラッグ・慣性・click 抑止のリスナーをコンテンツ要素と `window` へ登録する。
|
|
1150
|
+
* 依存関係: [id]
|
|
1151
|
+
* クリーンアップ: リスナーを外し、保持中のキャプチャを解放してドラッグ状態を初期化する。
|
|
1152
|
+
*/
|
|
1141
1153
|
useEffect(() => {
|
|
1142
1154
|
const element = contentAreaRef.current
|
|
1143
1155
|
if (!element) {
|
|
@@ -1186,6 +1198,16 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1186
1198
|
state.shouldCancelNextClick = false
|
|
1187
1199
|
}
|
|
1188
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* Confirms the drag once the activation threshold is crossed and moves the capture onto the content area.
|
|
1203
|
+
* 発動閾値を跨いだ時点でドラッグを確定し、キャプチャをコンテンツ領域へ移す処理。
|
|
1204
|
+
*
|
|
1205
|
+
* キャプチャを取れない場合 (非アクティブなポインタ・Pointer Capture API の非対応環境) も
|
|
1206
|
+
* ドラッグは確定させる。追従は `window` の `pointermove` が受け持つため、キャプチャが無くても止まらない。
|
|
1207
|
+
*
|
|
1208
|
+
* @param event The pointer move that crossed the threshold. 閾値を跨いだ pointermove。
|
|
1209
|
+
* @returns Nothing. なし。
|
|
1210
|
+
*/
|
|
1189
1211
|
const startDragging = (event: PointerEvent) => {
|
|
1190
1212
|
const state = dragStateRef.current
|
|
1191
1213
|
if (state.isDragging) {
|
|
@@ -1193,16 +1215,13 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1193
1215
|
}
|
|
1194
1216
|
state.isDragging = true
|
|
1195
1217
|
state.shouldCancelNextClick = true
|
|
1196
|
-
if (!element
|
|
1218
|
+
if (!isPointerCaptured(element, event.pointerId)) {
|
|
1197
1219
|
// 注意: キャプチャは pointerdown 時ではなくドラッグ確定時に取得する。
|
|
1198
1220
|
// pointerdown で即キャプチャすると click がキャプチャ要素へリターゲットされ、
|
|
1199
1221
|
// ペイン内の子要素 (行など) の click ハンドラが一切発火しなくなる (Chromium 実測)。
|
|
1200
1222
|
// ドラッグ確定後は shouldCancelNextClick で click を意図的に抑止するため副作用がない。
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
} catch {
|
|
1204
|
-
// ポインタが既に非アクティブだと NotFoundError になり得る。キャプチャ無しでもドラッグは継続可能。
|
|
1205
|
-
}
|
|
1223
|
+
// 取得に失敗してもキャプチャ無しでドラッグは継続できるため、結果は見ない。
|
|
1224
|
+
capturePointer(element, event.pointerId)
|
|
1206
1225
|
}
|
|
1207
1226
|
pushVelocitySample(event.clientY)
|
|
1208
1227
|
}
|
|
@@ -1244,13 +1263,16 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1244
1263
|
*/
|
|
1245
1264
|
const isAuthoritativeForDrag = (event: PointerEvent) => event.isTrusted || event.target === element
|
|
1246
1265
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1266
|
+
/**
|
|
1267
|
+
* Shared teardown for cases where the drag can no longer continue without receiving a pointerup.
|
|
1268
|
+
* pointerup を受け取れないままドラッグ継続が不可能になったときの共通終了処理。
|
|
1269
|
+
*
|
|
1270
|
+
* @param pointerId The pointer whose drag is aborted. ドラッグを打ち切るポインタ ID。
|
|
1271
|
+
* @returns Nothing. なし。
|
|
1272
|
+
*/
|
|
1249
1273
|
const abortDrag = (pointerId: number) => {
|
|
1250
1274
|
const state = dragStateRef.current
|
|
1251
|
-
|
|
1252
|
-
element.releasePointerCapture(pointerId)
|
|
1253
|
-
}
|
|
1275
|
+
releaseCapturedPointer(element, pointerId)
|
|
1254
1276
|
state.shouldCancelNextClick = false
|
|
1255
1277
|
clearClickTimer()
|
|
1256
1278
|
resetDragState()
|
|
@@ -1337,6 +1359,13 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1337
1359
|
return true
|
|
1338
1360
|
}
|
|
1339
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* Ends the drag on pointerup, arms the click suppression and hands the fling velocity to inertia.
|
|
1364
|
+
* pointerup でドラッグを終え、click 抑止を張り、フリック速度を慣性へ渡す処理。
|
|
1365
|
+
*
|
|
1366
|
+
* @param event The terminal pointerup. 終端の pointerup。
|
|
1367
|
+
* @returns Nothing. なし。
|
|
1368
|
+
*/
|
|
1340
1369
|
const handlePointerUp = (event: PointerEvent) => {
|
|
1341
1370
|
const state = dragStateRef.current
|
|
1342
1371
|
if (state.pointerId !== event.pointerId) {
|
|
@@ -1394,9 +1423,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1394
1423
|
// handleLostPointerCapture は pointerId 不一致で無視されるため、abortDrag による
|
|
1395
1424
|
// shouldCancelNextClick / clickResetTimer の破棄 (click 抑止の喪失) が起きない。
|
|
1396
1425
|
resetDragState()
|
|
1397
|
-
|
|
1398
|
-
element.releasePointerCapture(event.pointerId)
|
|
1399
|
-
}
|
|
1426
|
+
releaseCapturedPointer(element, event.pointerId)
|
|
1400
1427
|
|
|
1401
1428
|
if (Math.abs(inertiaVelocity) >= threshold) {
|
|
1402
1429
|
startInertiaRef.current?.(inertiaVelocity)
|
|
@@ -1558,8 +1585,8 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1558
1585
|
toggleListeners(element, elementEntries, "remove")
|
|
1559
1586
|
toggleListeners(window, windowEntries, "remove")
|
|
1560
1587
|
const state = dragStateRef.current
|
|
1561
|
-
if (state.pointerId !== null
|
|
1562
|
-
element
|
|
1588
|
+
if (state.pointerId !== null) {
|
|
1589
|
+
releaseCapturedPointer(element, state.pointerId)
|
|
1563
1590
|
}
|
|
1564
1591
|
clearClickTimer()
|
|
1565
1592
|
resetDragState()
|
package/src/TapScrollCircle.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from "react"
|
|
7
7
|
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from "react"
|
|
8
8
|
import { twMerge } from "tailwind-merge"
|
|
9
|
+
import { capturePointer, releaseCapturedPointer } from "./pointerCapture.ts"
|
|
9
10
|
|
|
10
11
|
export type TapScrollCircleDragState = {
|
|
11
12
|
/** Whether the pointer is actively dragging. / ポインターがドラッグ中かどうか。 */
|
|
@@ -205,13 +206,20 @@ export const TapScrollCircle = memo(
|
|
|
205
206
|
[applyDragState, axis],
|
|
206
207
|
)
|
|
207
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Releases the root's capture of the pointer when it holds one.
|
|
211
|
+
* ルートが指定ポインタのキャプチャを保持していれば解放する処理。
|
|
212
|
+
*
|
|
213
|
+
* @param pointerId The pointer to release, or null when no pointer is tracked. 解放するポインタ ID。追跡中のポインタが無ければ null。
|
|
214
|
+
* @returns Nothing. なし。
|
|
215
|
+
*/
|
|
208
216
|
const releasePointerCapture = useCallback((pointerId: number | null) => {
|
|
209
217
|
if (pointerId === null) {
|
|
210
218
|
return
|
|
211
219
|
}
|
|
212
220
|
const element = rootRef.current
|
|
213
|
-
if (element
|
|
214
|
-
element
|
|
221
|
+
if (element !== null) {
|
|
222
|
+
releaseCapturedPointer(element, pointerId)
|
|
215
223
|
}
|
|
216
224
|
}, [])
|
|
217
225
|
|
|
@@ -228,6 +236,15 @@ export const TapScrollCircle = memo(
|
|
|
228
236
|
[applyDragState, releasePointerCapture],
|
|
229
237
|
)
|
|
230
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Starts a circle drag, or records a second pointer as the hand-off candidate during a drag.
|
|
241
|
+
* サークルのドラッグを開始する処理。ドラッグ中に押された別ポインタはハンドオフ候補として記録する。
|
|
242
|
+
*
|
|
243
|
+
* キャプチャを取得できなくても (非アクティブなポインタ・Pointer Capture API の非対応環境) ドラッグは開始する。
|
|
244
|
+
*
|
|
245
|
+
* @param event The pointerdown on the circle. サークル上の pointerdown。
|
|
246
|
+
* @returns Nothing. なし。
|
|
247
|
+
*/
|
|
231
248
|
const handlePointerDown = useCallback(
|
|
232
249
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
233
250
|
// ドラッグ中に発生した別ポインター (2 本目のタッチ) は待機候補として記録し、
|
|
@@ -246,14 +263,9 @@ export const TapScrollCircle = memo(
|
|
|
246
263
|
const { left, top, width, height } = element.getBoundingClientRect()
|
|
247
264
|
centerRef.current = { x: left + width / 2, y: top + height / 2 }
|
|
248
265
|
pointerIdRef.current = event.pointerId
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// ポインタが既に非アクティブ (ウインドウ外へ出た等) だと setPointerCapture は
|
|
253
|
-
// NotFoundError を投げ得る。キャプチャ無しでもドラッグ処理は継続できるため握り潰す。
|
|
254
|
-
// setPointerCapture can throw NotFoundError if the pointer is already inactive
|
|
255
|
-
// (e.g. moved outside the browser window); dragging still works without capture.
|
|
256
|
-
}
|
|
266
|
+
// ポインタが既に非アクティブ (ウインドウ外へ出た等) でも API 非対応でも、
|
|
267
|
+
// キャプチャ無しでドラッグ処理は継続できるため結果は見ない。
|
|
268
|
+
capturePointer(element, event.pointerId)
|
|
257
269
|
updateDragState(event.clientX, event.clientY, true)
|
|
258
270
|
},
|
|
259
271
|
[updateDragState],
|
|
@@ -274,6 +286,16 @@ export const TapScrollCircle = memo(
|
|
|
274
286
|
[updateDragState],
|
|
275
287
|
)
|
|
276
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Ends the circle drag on pointerup, handing control to a live pending pointer when one exists.
|
|
291
|
+
* pointerup でサークルのドラッグを終える処理。生きている待機候補ポインタがあれば制御を移譲する。
|
|
292
|
+
*
|
|
293
|
+
* 候補の生死は `capturePointer` の結果で判定する。`"inactive-pointer"` だけを死とみなし、
|
|
294
|
+
* API 非対応・それ以外の拒否は生死不明として移譲を続ける。
|
|
295
|
+
*
|
|
296
|
+
* @param event The pointerup or pointercancel on the circle. サークル上の pointerup / pointercancel。
|
|
297
|
+
* @returns Nothing. なし。
|
|
298
|
+
*/
|
|
277
299
|
const handlePointerUp = useCallback(
|
|
278
300
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
279
301
|
if (pointerIdRef.current !== event.pointerId) {
|
|
@@ -296,21 +318,9 @@ export const TapScrollCircle = memo(
|
|
|
296
318
|
// pointers, so a dead candidate resets the state instead of getting promoted.
|
|
297
319
|
releasePointerCapture(event.pointerId)
|
|
298
320
|
const element = rootRef.current
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
element.setPointerCapture(pending.pointerId)
|
|
303
|
-
} catch (error) {
|
|
304
|
-
// NotFoundError = 候補ポインタが既に非アクティブ (サークル外で解放済み等)。
|
|
305
|
-
// クロスレルム (iframe への portal 等) では DOMException の instanceof が
|
|
306
|
-
// false になるため、name ベースで判定する (DOMException も name を持つので包含)。
|
|
307
|
-
// それ以外 (キャプチャ未実装環境等) は生死不明のため移譲を継続する。
|
|
308
|
-
const errorName = typeof error === "object" && error !== null && "name" in error ? (error as { name?: unknown }).name : undefined
|
|
309
|
-
if (errorName === "NotFoundError") {
|
|
310
|
-
candidateAlive = false
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
321
|
+
// NotFoundError (= サークル外で解放済み等) だけが死の証拠。API 非対応やそれ以外の拒否は
|
|
322
|
+
// 生死不明のため移譲を継続する (候補を捨てると 2 本目の指が効かなくなる)。
|
|
323
|
+
const candidateAlive = element === null || capturePointer(element, pending.pointerId) !== "inactive-pointer"
|
|
314
324
|
if (candidateAlive) {
|
|
315
325
|
pointerIdRef.current = pending.pointerId
|
|
316
326
|
updateDragState(pending.clientX, pending.clientY, true)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module pointerCapture
|
|
3
|
+
* @description The single capability rule for the Pointer Capture API, shared by every pointer interaction in this package.
|
|
4
|
+
*
|
|
5
|
+
* @description 本パッケージのすべてのポインタ操作が共有する、Pointer Capture API の唯一の能力判定モジュール。
|
|
6
|
+
*
|
|
7
|
+
* `ScrollPane` (コンテンツのドラッグスクロール)・`ScrollBar` (サム / トラックのドラッグ)・
|
|
8
|
+
* `TapScrollCircle` (タップスクロールサークル) は、キャプチャの取得・解放・保持確認を
|
|
9
|
+
* 必ずここを通して行う。要素の `setPointerCapture` / `releasePointerCapture` /
|
|
10
|
+
* `hasPointerCapture` を直接呼んではならない。
|
|
11
|
+
*
|
|
12
|
+
* ❗ **3 つのメソッドが揃っている要素だけを「対応」とみなす。** jsdom (React のテストの既定環境) は
|
|
13
|
+
* このどれも実装しないため、直接呼ぶと利用側の `user.click` 1 回で `TypeError` になる。
|
|
14
|
+
* 一部だけを実装する環境で「取得はしたが解放も確認もできない」状態を作らないよう、判定は 1 本にしてある。
|
|
15
|
+
*
|
|
16
|
+
* 非対応の要素ではキャプチャ無しで操作を続ける (縮退)。コンテンツ領域とサムのドラッグは
|
|
17
|
+
* `window` / `document` のリスナーが追従を受け持つため動き続け、トラックとタップサークルは
|
|
18
|
+
* ポインタが要素上にある間だけ追従する。失われるのは、ウインドウ外や iframe 上で離したときの
|
|
19
|
+
* 終端イベントの受信と、`lostpointercapture` による強制解放の検知である。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Outcome of a pointer capture request.
|
|
24
|
+
* ポインタキャプチャ要求の結果。
|
|
25
|
+
*
|
|
26
|
+
* - `"captured"`: The element now holds the capture. / 要素がキャプチャを保持した。
|
|
27
|
+
* - `"unsupported"`: The element does not implement the Pointer Capture API; nothing was attempted. / 要素が API を実装しておらず、要求自体を行っていない。
|
|
28
|
+
* - `"inactive-pointer"`: The UA rejected the pointer as inactive (`NotFoundError`). / ポインタが非アクティブとして拒否された (`NotFoundError`)。
|
|
29
|
+
* - `"rejected"`: The UA rejected the request for any other reason. / それ以外の理由で拒否された。
|
|
30
|
+
*/
|
|
31
|
+
export type PointerCaptureResult = "captured" | "unsupported" | "inactive-pointer" | "rejected"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reports whether the element implements the whole Pointer Capture API.
|
|
35
|
+
* 要素が Pointer Capture API (取得・解放・保持確認の 3 メソッド) をすべて実装しているかの判定処理。
|
|
36
|
+
*
|
|
37
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
38
|
+
* @returns True when set / release / has are all callable. 3 メソッドがすべて呼び出し可能な場合に true。
|
|
39
|
+
*/
|
|
40
|
+
export const supportsPointerCapture = (element: Element): boolean => typeof element.setPointerCapture === "function" && typeof element.releasePointerCapture === "function" && typeof element.hasPointerCapture === "function"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reports whether the element holds (or has a pending) capture of the pointer.
|
|
44
|
+
* 要素が指定ポインタのキャプチャを保持 (または保留) しているかの判定処理。
|
|
45
|
+
*
|
|
46
|
+
* @param element The element to inspect. 判定対象の要素。
|
|
47
|
+
* @param pointerId The pointer to look up. 対象のポインタ ID。
|
|
48
|
+
* @returns True when the capture is held; always false when the API is unsupported. 保持している場合に true。API 非対応の要素では常に false。
|
|
49
|
+
*/
|
|
50
|
+
export const isPointerCaptured = (element: Element, pointerId: number): boolean => supportsPointerCapture(element) && element.hasPointerCapture(pointerId)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Requests capture of the pointer for the element and classifies the outcome without throwing.
|
|
54
|
+
* 要素へ指定ポインタのキャプチャを要求し、例外を投げずに結果を分類する処理。
|
|
55
|
+
*
|
|
56
|
+
* @param element The element that should receive the capture. キャプチャを受け取る要素。
|
|
57
|
+
* @param pointerId The pointer to capture. キャプチャするポインタ ID。
|
|
58
|
+
* @returns The classified outcome of the request. 要求結果の分類。
|
|
59
|
+
*/
|
|
60
|
+
export const capturePointer = (element: Element, pointerId: number): PointerCaptureResult => {
|
|
61
|
+
if (!supportsPointerCapture(element)) {
|
|
62
|
+
return "unsupported"
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
element.setPointerCapture(pointerId)
|
|
66
|
+
return "captured"
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// クロスレルム (iframe への portal 等) では DOMException の instanceof が false になるため name で判定する
|
|
69
|
+
const errorName = typeof error === "object" && error !== null && "name" in error ? (error as { name?: unknown }).name : undefined
|
|
70
|
+
return errorName === "NotFoundError" ? "inactive-pointer" : "rejected"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Releases the element's capture of the pointer when it holds one; does nothing otherwise.
|
|
76
|
+
* 要素が指定ポインタのキャプチャを保持していれば解放し、保持していなければ何もしない処理。
|
|
77
|
+
*
|
|
78
|
+
* 呼び出し側が状態を確定させてから呼ぶこと。`lostpointercapture` を同期発火するブラウザでは、
|
|
79
|
+
* この呼び出しの最中に喪失ハンドラーが走る。
|
|
80
|
+
*
|
|
81
|
+
* @param element The element that may hold the capture. キャプチャを保持している可能性のある要素。
|
|
82
|
+
* @param pointerId The pointer to release. 解放するポインタ ID。
|
|
83
|
+
* @returns Nothing. なし。
|
|
84
|
+
*/
|
|
85
|
+
export const releaseCapturedPointer = (element: Element, pointerId: number): void => {
|
|
86
|
+
if (isPointerCaptured(element, pointerId)) {
|
|
87
|
+
element.releasePointerCapture(pointerId)
|
|
88
|
+
}
|
|
89
|
+
}
|