@aiquants/virtualscroll 3.0.0 → 3.1.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @module residualQuantizer
3
+ * @description Residual-carrying delta quantizer for consumers that snap scroll positions to a
4
+ * fixed quantum (row height, column width). High-resolution trackpads emit many sub-quantum
5
+ * deltas; a consumer that rounds each delta independently rounds every one of them to zero and
6
+ * the list never moves. This accumulator carries the sub-quantum remainder across events and
7
+ * emits whole quanta as soon as they accumulate.
8
+ *
9
+ * @description スクロール位置を固定の量子 (行高・列幅) へスナップする消費側のための、
10
+ * 残差持ち越し型のデルタ量子化器。高解像度トラックパッドは 1 量子未満のデルタを大量に発行するため、
11
+ * イベントごとに独立して丸める消費側では**すべてが 0 に丸められ、ゆっくり撫でると 1px も動かない**。
12
+ * 本量子化器は量子未満の余りをイベントを跨いで持ち越し、貯まった分だけ量子単位で放出する。
13
+ *
14
+ * ❗ **時間は扱わない (意図的)。** アイドルタイムアウトでの残差破棄は「いつ捨てるか」の方針が
15
+ * 消費側の UI ごとに異なり、内蔵すると幽霊スクロール (古い残差が後から発火) か不感帯のどちらかを
16
+ * パッケージが押し付けることになる。時間起点の破棄が要る消費側は、自前のタイマーから
17
+ * {@link ResidualQuantizer.reset} を呼ぶこと。既定の防御は**方向反転での残差破棄**のみで、
18
+ * これは時間に依存せず「逆へ動かし始めたのに古い残差が先に順方向へ発火する」事故だけを塞ぐ。
19
+ */
20
+ /**
21
+ * Options for {@link createResidualQuantizer}.
22
+ * {@link createResidualQuantizer} のオプション。
23
+ */
24
+ export type ResidualQuantizerOptions = {
25
+ /**
26
+ * Quantum (px) each emission is a multiple of. Must be finite and positive — anything else
27
+ * throws at creation time (fail fast; a silent default would hide the caller's bug).
28
+ * 放出量の単位となる量子 (px)。有限かつ正であること — それ以外は生成時に throw する
29
+ * (Fail Fast。既定値へ黙って読み替えると呼び出し側のバグを隠す)。
30
+ */
31
+ quantum: number;
32
+ /**
33
+ * Drop the carried residue when the incoming delta's direction opposes it (default: `true`).
34
+ * Without this, scrolling +20px (quantum 24, nothing emitted) and then reversing emits the
35
+ * first reverse quantum "late" — the stale forward residue eats part of the reverse motion.
36
+ * 入力デルタの向きが持ち越し残差と逆のとき残差を捨てる (既定 `true`)。捨てないと、
37
+ * +20px (量子 24 で未放出) の後に反転したとき、古い順方向残差が逆方向の動きを食い、
38
+ * 最初の逆方向量子の発火が「遅れて」体感される。
39
+ */
40
+ resetOnDirectionChange?: boolean;
41
+ };
42
+ /**
43
+ * A quantizer instance. One instance per quantized axis — sharing one across axes mixes residues.
44
+ * 量子化器インスタンス。量子化する軸ごとに 1 つ持つこと — 軸間で共有すると残差が混線する。
45
+ */
46
+ export type ResidualQuantizer = {
47
+ /**
48
+ * Accumulates `delta` and returns the emission: a multiple of the quantum (possibly 0),
49
+ * signed toward the accumulated direction. Non-finite deltas AND deltas whose magnitude
50
+ * exceeds `Number.MAX_SAFE_INTEGER` emit 0 and leave the residue untouched — beyond 2^53
51
+ * the float product stops being an EXACT multiple of the quantum, which is precisely the
52
+ * property a grid-snapping consumer relies on.
53
+ * `delta` を蓄積し、放出量 (量子の倍数。0 のこともある) を蓄積方向の符号で返す。
54
+ * 非有限のデルタ、および絶対値が `Number.MAX_SAFE_INTEGER` を超えるデルタは 0 を返し
55
+ * 残差を汚さない — 2^53 超では浮動小数点積が量子の**正確な**倍数でなくなり、グリッド
56
+ * スナップ消費側が依拠する性質そのものが壊れるため。
57
+ *
58
+ * @param delta - Incoming delta (px) / 入力デルタ (px)
59
+ * @returns Emitted amount (multiple of the quantum) / 放出量 (量子の倍数)
60
+ */
61
+ push: (delta: number) => number;
62
+ /**
63
+ * Returns the carried residue without mutating it (|residue| < quantum).
64
+ * 持ち越し中の残差を変更せずに返す (|残差| < 量子)。
65
+ *
66
+ * @returns Current residue (px) / 現在の残差 (px)
67
+ */
68
+ peekResidue: () => number;
69
+ /**
70
+ * Clears the residue. Call from consumer-side idle timers, on pointer leave, or when the
71
+ * quantized target (row/column layout) changes under the accumulator.
72
+ * 残差を破棄する。消費側のアイドルタイマー・ポインタ離脱・量子化対象 (行/列レイアウト) の
73
+ * 変更時に呼ぶ。
74
+ */
75
+ reset: () => void;
76
+ };
77
+ /**
78
+ * Creates a residual-carrying delta quantizer.
79
+ * 残差持ち越し型のデルタ量子化器を生成する処理。
80
+ *
81
+ * ```ts
82
+ * const quantizer = createResidualQuantizer({ quantum: rowHeight })
83
+ * const handleWheelVertical = (deltaY: number) => {
84
+ * const emitted = quantizer.push(deltaY)
85
+ * if (emitted !== 0) {
86
+ * snapScrollBy(emitted) // 常に量子の倍数
87
+ * }
88
+ * }
89
+ * ```
90
+ *
91
+ * @param options - Quantizer options / 量子化器のオプション
92
+ * @returns A new quantizer instance / 新しい量子化器インスタンス
93
+ * @throws {TypeError} When `quantum` is not a finite positive number / `quantum` が有限の正数でない場合
94
+ */
95
+ export declare const createResidualQuantizer: (options: ResidualQuantizerOptions) => ResidualQuantizer;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module residualQuantizer
3
+ * @description Residual-carrying delta quantizer for consumers that snap scroll positions to a
4
+ * fixed quantum (row height, column width). High-resolution trackpads emit many sub-quantum
5
+ * deltas; a consumer that rounds each delta independently rounds every one of them to zero and
6
+ * the list never moves. This accumulator carries the sub-quantum remainder across events and
7
+ * emits whole quanta as soon as they accumulate.
8
+ *
9
+ * @description スクロール位置を固定の量子 (行高・列幅) へスナップする消費側のための、
10
+ * 残差持ち越し型のデルタ量子化器。高解像度トラックパッドは 1 量子未満のデルタを大量に発行するため、
11
+ * イベントごとに独立して丸める消費側では**すべてが 0 に丸められ、ゆっくり撫でると 1px も動かない**。
12
+ * 本量子化器は量子未満の余りをイベントを跨いで持ち越し、貯まった分だけ量子単位で放出する。
13
+ *
14
+ * ❗ **時間は扱わない (意図的)。** アイドルタイムアウトでの残差破棄は「いつ捨てるか」の方針が
15
+ * 消費側の UI ごとに異なり、内蔵すると幽霊スクロール (古い残差が後から発火) か不感帯のどちらかを
16
+ * パッケージが押し付けることになる。時間起点の破棄が要る消費側は、自前のタイマーから
17
+ * {@link ResidualQuantizer.reset} を呼ぶこと。既定の防御は**方向反転での残差破棄**のみで、
18
+ * これは時間に依存せず「逆へ動かし始めたのに古い残差が先に順方向へ発火する」事故だけを塞ぐ。
19
+ */
20
+ /**
21
+ * Options for {@link createResidualQuantizer}.
22
+ * {@link createResidualQuantizer} のオプション。
23
+ */
24
+ export type ResidualQuantizerOptions = {
25
+ /**
26
+ * Quantum (px) each emission is a multiple of. Must be finite and positive — anything else
27
+ * throws at creation time (fail fast; a silent default would hide the caller's bug).
28
+ * 放出量の単位となる量子 (px)。有限かつ正であること — それ以外は生成時に throw する
29
+ * (Fail Fast。既定値へ黙って読み替えると呼び出し側のバグを隠す)。
30
+ */
31
+ quantum: number;
32
+ /**
33
+ * Drop the carried residue when the incoming delta's direction opposes it (default: `true`).
34
+ * Without this, scrolling +20px (quantum 24, nothing emitted) and then reversing emits the
35
+ * first reverse quantum "late" — the stale forward residue eats part of the reverse motion.
36
+ * 入力デルタの向きが持ち越し残差と逆のとき残差を捨てる (既定 `true`)。捨てないと、
37
+ * +20px (量子 24 で未放出) の後に反転したとき、古い順方向残差が逆方向の動きを食い、
38
+ * 最初の逆方向量子の発火が「遅れて」体感される。
39
+ */
40
+ resetOnDirectionChange?: boolean;
41
+ };
42
+ /**
43
+ * A quantizer instance. One instance per quantized axis — sharing one across axes mixes residues.
44
+ * 量子化器インスタンス。量子化する軸ごとに 1 つ持つこと — 軸間で共有すると残差が混線する。
45
+ */
46
+ export type ResidualQuantizer = {
47
+ /**
48
+ * Accumulates `delta` and returns the emission: a multiple of the quantum (possibly 0),
49
+ * signed toward the accumulated direction. Non-finite deltas AND deltas whose magnitude
50
+ * exceeds `Number.MAX_SAFE_INTEGER` emit 0 and leave the residue untouched — beyond 2^53
51
+ * the float product stops being an EXACT multiple of the quantum, which is precisely the
52
+ * property a grid-snapping consumer relies on.
53
+ * `delta` を蓄積し、放出量 (量子の倍数。0 のこともある) を蓄積方向の符号で返す。
54
+ * 非有限のデルタ、および絶対値が `Number.MAX_SAFE_INTEGER` を超えるデルタは 0 を返し
55
+ * 残差を汚さない — 2^53 超では浮動小数点積が量子の**正確な**倍数でなくなり、グリッド
56
+ * スナップ消費側が依拠する性質そのものが壊れるため。
57
+ *
58
+ * @param delta - Incoming delta (px) / 入力デルタ (px)
59
+ * @returns Emitted amount (multiple of the quantum) / 放出量 (量子の倍数)
60
+ */
61
+ push: (delta: number) => number;
62
+ /**
63
+ * Returns the carried residue without mutating it (|residue| < quantum).
64
+ * 持ち越し中の残差を変更せずに返す (|残差| < 量子)。
65
+ *
66
+ * @returns Current residue (px) / 現在の残差 (px)
67
+ */
68
+ peekResidue: () => number;
69
+ /**
70
+ * Clears the residue. Call from consumer-side idle timers, on pointer leave, or when the
71
+ * quantized target (row/column layout) changes under the accumulator.
72
+ * 残差を破棄する。消費側のアイドルタイマー・ポインタ離脱・量子化対象 (行/列レイアウト) の
73
+ * 変更時に呼ぶ。
74
+ */
75
+ reset: () => void;
76
+ };
77
+ /**
78
+ * Creates a residual-carrying delta quantizer.
79
+ * 残差持ち越し型のデルタ量子化器を生成する処理。
80
+ *
81
+ * ```ts
82
+ * const quantizer = createResidualQuantizer({ quantum: rowHeight })
83
+ * const handleWheelVertical = (deltaY: number) => {
84
+ * const emitted = quantizer.push(deltaY)
85
+ * if (emitted !== 0) {
86
+ * snapScrollBy(emitted) // 常に量子の倍数
87
+ * }
88
+ * }
89
+ * ```
90
+ *
91
+ * @param options - Quantizer options / 量子化器のオプション
92
+ * @returns A new quantizer instance / 新しい量子化器インスタンス
93
+ * @throws {TypeError} When `quantum` is not a finite positive number / `quantum` が有限の正数でない場合
94
+ */
95
+ export declare const createResidualQuantizer: (options: ResidualQuantizerOptions) => ResidualQuantizer;
96
+ //# sourceMappingURL=residualQuantizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"residualQuantizer.d.ts","sourceRoot":"","sources":["../src/residualQuantizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACnC;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAA;CACnC,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC5B;;;;;;;;;;;;;OAaG;IACH,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAC/B;;;;;OAKG;IACH,WAAW,EAAE,MAAM,MAAM,CAAA;IACzB;;;;;OAKG;IACH,KAAK,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,wBAAwB,KAAG,iBAmC3E,CAAA"}
@@ -1,2 +1,2 @@
1
1
  /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
- @layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}}
2
+ @layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}.aqvs-live-region{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}
@@ -1,3 +1,3 @@
1
1
  /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
2
  /*! @aiquants/virtualscroll standalone CSS — 非 Tailwind ホスト専用。ホストの Tailwind ビルドと混在させないこと */
3
- @layer theme,base;@layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}}@layer utilities;
3
+ @layer theme,base;@layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}.aqvs-live-region{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}@layer utilities;
package/dist/utils.d.cts CHANGED
@@ -12,3 +12,18 @@
12
12
  * @returns The clamped value. / クランプされた値。
13
13
  */
14
14
  export declare const minmax: (value: number, min: number, max: number) => number;
15
+ /**
16
+ * Computes an element's CSS transform scale along one axis (visual px / layout px).
17
+ *
18
+ * 要素の指定軸方向 CSS transform スケール (視覚px / レイアウトpx) を算出。
19
+ * 祖先に transform: scale(...) があると client 座標 (視覚px) がレイアウトpx とズレるため、
20
+ * その比率でドラッグ量・クリック座標を補正する。分母には整数丸めされる offsetWidth/offsetHeight
21
+ * ではなく、transform の影響を受けず小数精度を持つ getComputedStyle のレイアウト寸法
22
+ * (content + padding + border) を優先して使い、transform なしなら厳密に 1 を返す。
23
+ * 取得不能時 (jsdom 等) は offset 寸法へフォールバックし、それも 0 なら 1 を返す。
24
+ *
25
+ * @param element The element to measure. / 計測対象要素。
26
+ * @param axis The axis to measure along ("x" = width, "y" = height). / 計測軸。
27
+ * @returns The visual/layout scale ratio (1 when unmeasurable). / 視覚/レイアウト比 (計測不能時 1)。
28
+ */
29
+ export declare const getAxisScale: (element: HTMLElement, axis: "x" | "y") => number;
package/dist/utils.d.ts CHANGED
@@ -12,4 +12,19 @@
12
12
  * @returns The clamped value. / クランプされた値。
13
13
  */
14
14
  export declare const minmax: (value: number, min: number, max: number) => number;
15
+ /**
16
+ * Computes an element's CSS transform scale along one axis (visual px / layout px).
17
+ *
18
+ * 要素の指定軸方向 CSS transform スケール (視覚px / レイアウトpx) を算出。
19
+ * 祖先に transform: scale(...) があると client 座標 (視覚px) がレイアウトpx とズレるため、
20
+ * その比率でドラッグ量・クリック座標を補正する。分母には整数丸めされる offsetWidth/offsetHeight
21
+ * ではなく、transform の影響を受けず小数精度を持つ getComputedStyle のレイアウト寸法
22
+ * (content + padding + border) を優先して使い、transform なしなら厳密に 1 を返す。
23
+ * 取得不能時 (jsdom 等) は offset 寸法へフォールバックし、それも 0 なら 1 を返す。
24
+ *
25
+ * @param element The element to measure. / 計測対象要素。
26
+ * @param axis The axis to measure along ("x" = width, "y" = height). / 計測軸。
27
+ * @returns The visual/layout scale ratio (1 when unmeasurable). / 視覚/レイアウト比 (計測不能時 1)。
28
+ */
29
+ export declare const getAxisScale: (element: HTMLElement, axis: "x" | "y") => number;
15
30
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,MAAM,GAAI,OAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,KAAG,MAEhE,CAAA"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,MAAM,GAAI,OAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,KAAG,MAEhE,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,GAAI,SAAS,WAAW,EAAE,MAAM,GAAG,GAAG,GAAG,KAAG,MA0BpE,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
package/src/ScrollBar.tsx CHANGED
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
8
8
  import { twMerge } from "tailwind-merge"
9
9
  import { Logger } from "./logger.ts"
10
10
  import { TapScrollCircle, type TapScrollCircleDragState, type TapScrollCircleHandle, type TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
11
- import { minmax } from "./utils.ts"
11
+ import { getAxisScale, minmax } from "./utils.ts"
12
12
 
13
13
  /**
14
14
  * Configuration for scrollbar orientation (vertical or horizontal).
@@ -1055,35 +1055,8 @@ export const ScrollBar = ({
1055
1055
  * (content + padding + border) を優先して使い、transform なしなら厳密に 1 を返す。
1056
1056
  * 取得不能時 (jsdom 等) は offset 寸法へフォールバックし、それも 0 なら 1 を返す。
1057
1057
  */
1058
- const getMainAxisScale = useCallback(
1059
- (element: HTMLElement) => {
1060
- const rect = element.getBoundingClientRect()
1061
- const visualSize = horizontal ? rect.width : rect.height
1062
- // computed style の値 (px 文字列) を数値化する。解釈不能なら 0 とみなす。
1063
- const parseSize = (value: string) => {
1064
- const parsed = Number.parseFloat(value)
1065
- return Number.isFinite(parsed) ? parsed : 0
1066
- }
1067
- const computed = window.getComputedStyle(element)
1068
- const contentSize = parseSize(horizontal ? computed.width : computed.height)
1069
- // rect (border-box) と同系になるよう content + padding + border を合成する。
1070
- const computedLayoutSize =
1071
- contentSize > 0
1072
- ? contentSize +
1073
- parseSize(horizontal ? computed.paddingLeft : computed.paddingTop) +
1074
- parseSize(horizontal ? computed.paddingRight : computed.paddingBottom) +
1075
- parseSize(horizontal ? computed.borderLeftWidth : computed.borderTopWidth) +
1076
- parseSize(horizontal ? computed.borderRightWidth : computed.borderBottomWidth)
1077
- : 0
1078
- // computed が取れない環境 (jsdom 等) では従来の offset 寸法へフォールバックする。
1079
- const layoutSize = computedLayoutSize > 0 ? computedLayoutSize : horizontal ? element.offsetWidth : element.offsetHeight
1080
- if (layoutSize <= 0 || visualSize <= 0) {
1081
- return 1
1082
- }
1083
- return visualSize / layoutSize
1084
- },
1085
- [horizontal],
1086
- )
1058
+ // 本体は共有ヘルパ getAxisScale (utils.ts — ScrollPane のポインタパンと同一 SSOT)
1059
+ const getMainAxisScale = useCallback((element: HTMLElement) => getAxisScale(element, horizontal ? "x" : "y"), [horizontal])
1087
1060
 
1088
1061
  /**
1089
1062
  * Pointer move handler for thumb dragging.
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayo
2
2
  import { twMerge } from "tailwind-merge"
3
3
  import { Logger } from "./logger.ts"
4
4
  import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
5
- import { minmax } from "./utils.ts"
5
+ import { getAxisScale, minmax } from "./utils.ts"
6
6
  import { resolveWheelAxes } from "./wheelAxes.ts"
7
7
  import { markWheelConsumed, wasWheelConsumed } from "./wheelConsumption.ts"
8
8
 
@@ -231,6 +231,8 @@ type DragState = {
231
231
  shouldCancelNextClick: boolean
232
232
  clickResetTimer: number | null
233
233
  velocitySamples: DragSample[]
234
+ /** press 時に採取した縦軸の視覚/レイアウト比 (祖先 transform scale — 補正の分母)。 */
235
+ scale: number
234
236
  }
235
237
 
236
238
  /**
@@ -989,6 +991,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
989
991
  shouldCancelNextClick: false,
990
992
  clickResetTimer: null,
991
993
  velocitySamples: [],
994
+ scale: 1,
992
995
  })
993
996
 
994
997
  const enablePointerDragRef = useRef(enablePointerDrag)
@@ -1084,6 +1087,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1084
1087
  state.isDragging = false
1085
1088
  state.shouldCancelNextClick = shouldKeepClickSuppression
1086
1089
  state.velocitySamples = []
1090
+ state.scale = 1
1087
1091
  // 状態を確定させてから解放する (同期 lostpointercapture に引き継ぎを壊させない)
1088
1092
  if (element?.hasPointerCapture(releasedPointerId)) {
1089
1093
  element.releasePointerCapture(releasedPointerId)
@@ -1107,6 +1111,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1107
1111
  state.startScroll = 0
1108
1112
  state.isDragging = false
1109
1113
  state.velocitySamples = []
1114
+ state.scale = 1
1110
1115
  }
1111
1116
 
1112
1117
  const clearClickTimer = () => {
@@ -1239,7 +1244,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1239
1244
 
1240
1245
  pushVelocitySample(event.clientY)
1241
1246
 
1242
- const deltaY = event.clientY - state.startClientY
1247
+ // ÷scale: 視覚 px の指移動をレイアウト (論理) px へ (発動閾値 6px は指の物理
1248
+ // 移動量の判別なので視覚 px のまま — 意図的非対称)
1249
+ const deltaY = (event.clientY - state.startClientY) / (state.scale || 1)
1243
1250
  const nextPosition = state.startScroll - deltaY
1244
1251
  scrollToRef.current(nextPosition)
1245
1252
 
@@ -1309,7 +1316,8 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1309
1316
  if (lastSample && firstSample && lastSample.time !== firstSample.time) {
1310
1317
  const deltaClientY = lastSample.clientY - firstSample.clientY
1311
1318
  const deltaTime = lastSample.time - firstSample.time
1312
- inertiaVelocity = -(deltaClientY / deltaTime)
1319
+ // ÷scale: フリック速度も視覚 px/ms のままだと慣性走行距離が z 倍に歪む
1320
+ inertiaVelocity = -(deltaClientY / deltaTime) / (state.scale || 1)
1313
1321
  }
1314
1322
  }
1315
1323
 
@@ -1405,6 +1413,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1405
1413
  state.pointerType = event.pointerType
1406
1414
  state.startClientY = event.clientY
1407
1415
  state.startScroll = scrollPositionRef.current
1416
+ // 祖先 transform scale の押下時採取 (ScrollBar サム / トラックと同じ press キャッシュ
1417
+ // 規約): 視覚 px の指移動をレイアウト px へ換算しないと scale z 下でコンテンツが
1418
+ // 指速の z 倍で動く。move 毎の gBCR + getComputedStyle は 60-120Hz 熱経路の
1419
+ // 強制レイアウトになるため押下時 1 回に固定する
1420
+ state.scale = getAxisScale(element, "y")
1408
1421
  state.isDragging = false
1409
1422
  state.shouldCancelNextClick = false
1410
1423
  state.velocitySamples = []