@pauloricca/spagbol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +330 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1210 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +169 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/portState.d.ts +10 -0
  114. package/dist/react/portState.d.ts.map +1 -0
  115. package/dist/react/portState.js +35 -0
  116. package/dist/react/portState.js.map +1 -0
  117. package/dist/react/viewport.d.ts +24 -0
  118. package/dist/react/viewport.d.ts.map +1 -0
  119. package/dist/react/viewport.js +55 -0
  120. package/dist/react/viewport.js.map +1 -0
  121. package/dist/styles.css +748 -0
  122. package/package.json +58 -4
  123. package/src/core/controller.ts +844 -0
  124. package/src/core/graph.ts +451 -0
  125. package/src/core/grouping.ts +159 -0
  126. package/src/core/groups.ts +125 -0
  127. package/src/core/index.ts +7 -0
  128. package/src/core/model.ts +304 -0
  129. package/src/core/observations.ts +31 -0
  130. package/src/core/serialization.ts +184 -0
  131. package/src/index.ts +9 -0
  132. package/src/react/Controls.tsx +355 -0
  133. package/src/react/SpagbolCanvas.tsx +1859 -0
  134. package/src/react/SpagbolCanvasControls.tsx +255 -0
  135. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  136. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  137. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  138. package/src/react/SpagbolControlNumber.tsx +297 -0
  139. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  140. package/src/react/SpagbolControlZoom.tsx +102 -0
  141. package/src/react/SpagbolNodeFrame.tsx +329 -0
  142. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  143. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  144. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  145. package/src/react/context.tsx +146 -0
  146. package/src/react/controls.css +122 -0
  147. package/src/react/duplicateDrag.ts +97 -0
  148. package/src/react/flowPresentation.ts +56 -0
  149. package/src/react/geometry.ts +53 -0
  150. package/src/react/index.ts +61 -0
  151. package/src/react/link-controls.css +123 -0
  152. package/src/react/portState.ts +24 -0
  153. package/src/react/styles.css +748 -0
  154. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,355 @@
1
+ // Extracted from Teia; public component/type names and CSS classes are generic.
2
+ import { useEffect, useRef, useState, type PointerEvent, type RefObject } from 'react';
3
+
4
+ export interface SpagbolControlSliderProps {
5
+ value: number;
6
+ displayValue?: number;
7
+ direction: 'horizontal' | 'vertical';
8
+ onChange: (value: number) => void;
9
+ onGestureStart?(): void;
10
+ onGestureEnd?(cancel: boolean): void;
11
+ }
12
+
13
+ const SLIDER_WHEEL_SPEED = 0.5;
14
+
15
+ function sliderPrecisionDivisor(event: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): number {
16
+ if (event.metaKey || event.ctrlKey) return 5;
17
+ if (event.shiftKey && event.altKey) return 3;
18
+ return 1;
19
+ }
20
+
21
+ export function SpagbolControlSlider({ value, displayValue, direction, onChange, onGestureStart, onGestureEnd }: SpagbolControlSliderProps) {
22
+ const [interactionValue, setInteractionValue] = useState<number | null>(null);
23
+ const gestureEnd = useRef(onGestureEnd); gestureEnd.current = onGestureEnd;
24
+ const wheelTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+ const releaseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+ const pendingDisplay = useRef<number | null>(null);
27
+ function clearPendingDisplay() {
28
+ if (releaseTimer.current !== null) clearTimeout(releaseTimer.current);
29
+ releaseTimer.current = null; pendingDisplay.current = null;
30
+ }
31
+ function releaseDisplay(requested: number, cancel: boolean) {
32
+ clearPendingDisplay();
33
+ if (cancel || Math.abs((displayValue ?? value) - requested) <= 0.001) { setInteractionValue(null); return; }
34
+ pendingDisplay.current = requested;
35
+ releaseTimer.current = setTimeout(() => { clearPendingDisplay(); setInteractionValue(null); }, 200);
36
+ }
37
+ useEffect(() => {
38
+ if (pendingDisplay.current !== null && Math.abs((displayValue ?? value) - pendingDisplay.current) <= 0.001) {
39
+ clearPendingDisplay(); setInteractionValue(null);
40
+ }
41
+ }, [value, displayValue]);
42
+ const dragRef = useRef<{
43
+ pointerId: number;
44
+ lastPointerValue: number;
45
+ value: number;
46
+ cursorBias: number;
47
+ precisionDivisor: number;
48
+ } | null>(null);
49
+ // The pointer owns the display during an edit; delayed engine observations
50
+ // resume when it ends. This also works in apps without audio observations.
51
+ const normalized = clamp(interactionValue ?? displayValue ?? value, 0, 1);
52
+ const wheelValueRef = useRef(normalized);
53
+ const lastWheelTimeRef = useRef(-Infinity);
54
+ const wheelTargetRef = useModifiedWheel<HTMLInputElement>((event, target) => {
55
+ if (dragRef.current) return;
56
+ if (event.timeStamp - lastWheelTimeRef.current > 160) {
57
+ wheelValueRef.current = normalized;
58
+ }
59
+ lastWheelTimeRef.current = event.timeStamp;
60
+ const travel = direction === 'vertical' ? target.clientHeight : target.clientWidth;
61
+ const precisionDivisor = sliderPrecisionDivisor(event);
62
+ const wheelDelta = wheelAxisDeltaPixels(event, direction);
63
+ if (wheelDelta === 0) return;
64
+ const nextValue = clamp(
65
+ wheelValueRef.current
66
+ + wheelDelta * (direction === 'vertical' ? 1 : -1) * SLIDER_WHEEL_SPEED
67
+ / Math.max(1, travel)
68
+ / precisionDivisor,
69
+ 0,
70
+ 1,
71
+ );
72
+ if (wheelTimer.current === null) { clearPendingDisplay(); onGestureStart?.(); }
73
+ else clearTimeout(wheelTimer.current);
74
+ wheelValueRef.current = nextValue;
75
+ setInteractionValue(nextValue);
76
+ onChange(nextValue);
77
+ wheelTimer.current = setTimeout(() => {
78
+ wheelTimer.current = null; releaseDisplay(wheelValueRef.current, false); gestureEnd.current?.(false);
79
+ }, 160);
80
+ });
81
+ function finishPointer(pointerId: number, cancel: boolean) {
82
+ if (dragRef.current?.pointerId !== pointerId) return;
83
+ const requested = dragRef.current.value;
84
+ dragRef.current = null; releaseDisplay(requested, cancel); gestureEnd.current?.(cancel);
85
+ }
86
+ useEffect(() => {
87
+ const cancel = () => {
88
+ const active = dragRef.current !== null || wheelTimer.current !== null;
89
+ dragRef.current = null;
90
+ if (wheelTimer.current !== null) clearTimeout(wheelTimer.current);
91
+ wheelTimer.current = null;
92
+ clearPendingDisplay();
93
+ if (active) { setInteractionValue(null); gestureEnd.current?.(true); }
94
+ };
95
+ window.addEventListener('blur', cancel);
96
+ return () => { window.removeEventListener('blur', cancel); cancel(); };
97
+ }, []);
98
+ const fillStyle = direction === 'vertical'
99
+ ? { height: `${normalized * 100}%` }
100
+ : { width: `${normalized * 100}%` };
101
+
102
+ function pointerValue(event: PointerEvent<HTMLInputElement>) {
103
+ const bounds = event.currentTarget.getBoundingClientRect();
104
+ // Keep this unbounded while dragging. Fine control uses relative travel,
105
+ // so clamping here would lose movement once the pointer passed an edge.
106
+ return direction === 'vertical'
107
+ ? 1 - ((event.clientY - bounds.top) / Math.max(1, bounds.height))
108
+ : (event.clientX - bounds.left) / Math.max(1, bounds.width);
109
+ }
110
+
111
+ function updateFromPointer(event: PointerEvent<HTMLInputElement>) {
112
+ const drag = dragRef.current;
113
+ if (!drag || drag.pointerId !== event.pointerId) return;
114
+
115
+ const cursorValue = pointerValue(event);
116
+ const precisionDivisor = sliderPrecisionDivisor(event);
117
+ if (precisionDivisor !== drag.precisionDivisor) {
118
+ // Re-anchor when the modifier changes so switching speeds never snaps to
119
+ // the cursor. Without fine control, the bias preserves this position.
120
+ drag.lastPointerValue = cursorValue;
121
+ drag.cursorBias = drag.value - cursorValue;
122
+ drag.precisionDivisor = precisionDivisor;
123
+ return;
124
+ }
125
+
126
+ const nextValue = precisionDivisor > 1
127
+ ? drag.value + (cursorValue - drag.lastPointerValue) / precisionDivisor
128
+ : cursorValue + drag.cursorBias;
129
+ drag.lastPointerValue = cursorValue;
130
+ drag.value = clamp(nextValue, 0, 1);
131
+ setInteractionValue(drag.value);
132
+ onChange(drag.value);
133
+ }
134
+
135
+ return (
136
+ <div className={[
137
+ 'spagbol-control-slider nodrag nopan',
138
+ direction === 'vertical' ? 'spagbol-control-slider-vertical' : 'spagbol-control-slider-horizontal',
139
+ ].join(' ')}
140
+ onClick={(event) => event.stopPropagation()}
141
+ >
142
+ <span className="spagbol-control-slider-fill" style={fillStyle} aria-hidden="true" />
143
+ <input
144
+ ref={wheelTargetRef}
145
+ aria-label="Slider value"
146
+ type="range"
147
+ tabIndex={-1}
148
+ min={0}
149
+ max={1}
150
+ step={0.001}
151
+ value={normalized}
152
+ onChange={(event) => {
153
+ if (dragRef.current) return;
154
+ onChange(Number(event.currentTarget.value));
155
+ event.currentTarget.blur();
156
+ }}
157
+ onFocus={(event) => event.currentTarget.blur()}
158
+ onPointerDown={(event) => {
159
+ event.preventDefault();
160
+ event.stopPropagation();
161
+ if (!event.isPrimary || event.button !== 0) return;
162
+ if (wheelTimer.current !== null) {
163
+ clearTimeout(wheelTimer.current); wheelTimer.current = null; gestureEnd.current?.(false);
164
+ }
165
+ clearPendingDisplay();
166
+ onGestureStart?.();
167
+ const cursorValue = pointerValue(event);
168
+ const precisionDivisor = sliderPrecisionDivisor(event);
169
+ const initialValue = precisionDivisor > 1 ? normalized : clamp(cursorValue, 0, 1);
170
+ dragRef.current = {
171
+ pointerId: event.pointerId,
172
+ lastPointerValue: cursorValue,
173
+ value: initialValue,
174
+ cursorBias: initialValue - cursorValue,
175
+ precisionDivisor,
176
+ };
177
+ setInteractionValue(initialValue);
178
+ event.currentTarget.setPointerCapture(event.pointerId);
179
+ if (precisionDivisor === 1) onChange(initialValue);
180
+ }}
181
+ onPointerMove={(event) => {
182
+ if (dragRef.current?.pointerId !== event.pointerId) return;
183
+ event.preventDefault();
184
+ event.stopPropagation();
185
+ updateFromPointer(event);
186
+ }}
187
+ onPointerUp={(event) => {
188
+ event.stopPropagation();
189
+ if (dragRef.current?.pointerId === event.pointerId) {
190
+ finishPointer(event.pointerId, false);
191
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
192
+ event.currentTarget.releasePointerCapture(event.pointerId);
193
+ }
194
+ }
195
+ event.currentTarget.blur();
196
+ }}
197
+ onPointerCancel={(event) => {
198
+ event.stopPropagation();
199
+ finishPointer(event.pointerId, true);
200
+ event.currentTarget.blur();
201
+ }}
202
+ onLostPointerCapture={(event) => {
203
+ finishPointer(event.pointerId, true);
204
+ }}
205
+ onClick={(event) => {
206
+ event.preventDefault();
207
+ event.stopPropagation();
208
+ event.currentTarget.blur();
209
+ }}
210
+ onDoubleClick={(event) => event.stopPropagation()}
211
+ />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ export type SpagbolControlButtonMode = 'toggle' | 'trigger' | 'temporary';
217
+ const BUTTON_CLICK_FLASH_MS = 120;
218
+
219
+ export interface SpagbolControlButtonProps {
220
+ mode: SpagbolControlButtonMode;
221
+ pressed: number;
222
+ onPressedChange: (value: number) => void;
223
+ onClickPulse: () => void;
224
+ }
225
+
226
+ export function SpagbolControlButton({ mode, pressed, onPressedChange, onClickPulse }: SpagbolControlButtonProps) {
227
+ const [pointerActive, setPointerActive] = useState(false);
228
+ const [clickFlashActive, setClickFlashActive] = useState(false);
229
+ const clickFlashTimeoutRef = useRef<number | null>(null);
230
+ const previousPressedRef = useRef(false);
231
+ const isPressed = pressed >= 0.5;
232
+ const isLit = mode === 'trigger'
233
+ ? clickFlashActive
234
+ : mode === 'temporary'
235
+ ? pointerActive || isPressed
236
+ : isPressed;
237
+
238
+ function flashClick() {
239
+ if (clickFlashTimeoutRef.current !== null) {
240
+ window.clearTimeout(clickFlashTimeoutRef.current);
241
+ }
242
+ setClickFlashActive(true);
243
+ clickFlashTimeoutRef.current = window.setTimeout(() => {
244
+ clickFlashTimeoutRef.current = null;
245
+ setClickFlashActive(false);
246
+ }, BUTTON_CLICK_FLASH_MS);
247
+ }
248
+
249
+ useEffect(() => {
250
+ if (mode === 'trigger' && isPressed && !previousPressedRef.current) {
251
+ flashClick();
252
+ }
253
+ previousPressedRef.current = isPressed;
254
+ }, [isPressed, mode]);
255
+
256
+ useEffect(() => () => {
257
+ if (clickFlashTimeoutRef.current !== null) {
258
+ window.clearTimeout(clickFlashTimeoutRef.current);
259
+ }
260
+ }, []);
261
+
262
+ function releaseTemporary() {
263
+ setPointerActive(false);
264
+ if (mode === 'temporary') {
265
+ onPressedChange(0);
266
+ }
267
+ }
268
+
269
+ return (
270
+ <button
271
+ className={[
272
+ 'spagbol-control-button nodrag nopan',
273
+ isLit ? 'spagbol-control-button-active' : '',
274
+ ].filter(Boolean).join(' ')}
275
+ type="button"
276
+ aria-label="Button value"
277
+ aria-pressed={isLit}
278
+ onPointerDown={(event) => {
279
+ event.preventDefault();
280
+ event.stopPropagation();
281
+ event.currentTarget.setPointerCapture(event.pointerId);
282
+ if (mode === 'temporary') {
283
+ setPointerActive(true);
284
+ onPressedChange(1);
285
+ } else if (mode === 'trigger') {
286
+ flashClick();
287
+ onClickPulse();
288
+ }
289
+ }}
290
+ onPointerUp={(event) => {
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
294
+ event.currentTarget.releasePointerCapture(event.pointerId);
295
+ }
296
+ releaseTemporary();
297
+ }}
298
+ onPointerCancel={(event) => {
299
+ event.stopPropagation();
300
+ releaseTemporary();
301
+ }}
302
+ onLostPointerCapture={releaseTemporary}
303
+ onClick={(event) => {
304
+ event.preventDefault();
305
+ event.stopPropagation();
306
+ if (mode === 'toggle') {
307
+ onPressedChange(isPressed ? 0 : 1);
308
+ }
309
+ }}
310
+ onDoubleClick={(event) => event.stopPropagation()}
311
+ />
312
+ );
313
+ }
314
+
315
+ function useModifiedWheel<T extends HTMLElement>(
316
+ onWheel: (event: WheelEvent, target: T) => void,
317
+ ): RefObject<T | null> {
318
+ const targetRef = useRef<T | null>(null);
319
+ const handlerRef = useRef(onWheel);
320
+ handlerRef.current = onWheel;
321
+
322
+ useEffect(() => {
323
+ const target = targetRef.current;
324
+ if (!target) return;
325
+
326
+ const handleWheel = (event: WheelEvent) => {
327
+ if (!event.shiftKey && !event.altKey) return;
328
+ event.preventDefault();
329
+ event.stopPropagation();
330
+ event.stopImmediatePropagation();
331
+ handlerRef.current(event, target);
332
+ };
333
+
334
+ target.addEventListener('wheel', handleWheel, { capture: true, passive: false });
335
+ return () => target.removeEventListener('wheel', handleWheel, { capture: true });
336
+ });
337
+
338
+ return targetRef;
339
+ }
340
+
341
+ function wheelAxisDeltaPixels(
342
+ event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
343
+ direction: 'horizontal' | 'vertical',
344
+ ): number {
345
+ const delta = direction === 'vertical' ? event.deltaY : event.deltaX;
346
+ if (event.deltaMode === 1) return delta * 16;
347
+ if (event.deltaMode === 2) return delta * window.innerHeight;
348
+ return delta;
349
+ }
350
+
351
+ function clamp(value: number, min: number, max: number): number {
352
+ if (!Number.isFinite(value)) return min;
353
+ return Math.min(max, Math.max(min, value));
354
+ }
355
+