@pauloricca/spagbol 0.0.0-stage → 0.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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -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 +1248 -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 +171 -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/nodeDrag.d.ts +7 -0
  114. package/dist/react/nodeDrag.d.ts.map +1 -0
  115. package/dist/react/nodeDrag.js +27 -0
  116. package/dist/react/nodeDrag.js.map +1 -0
  117. package/dist/react/portState.d.ts +10 -0
  118. package/dist/react/portState.d.ts.map +1 -0
  119. package/dist/react/portState.js +35 -0
  120. package/dist/react/portState.js.map +1 -0
  121. package/dist/react/viewport.d.ts +24 -0
  122. package/dist/react/viewport.d.ts.map +1 -0
  123. package/dist/react/viewport.js +55 -0
  124. package/dist/react/viewport.js.map +1 -0
  125. package/dist/styles.css +748 -0
  126. package/package.json +58 -4
  127. package/src/core/controller.ts +844 -0
  128. package/src/core/graph.ts +451 -0
  129. package/src/core/grouping.ts +159 -0
  130. package/src/core/groups.ts +125 -0
  131. package/src/core/index.ts +7 -0
  132. package/src/core/model.ts +304 -0
  133. package/src/core/observations.ts +31 -0
  134. package/src/core/serialization.ts +184 -0
  135. package/src/index.ts +9 -0
  136. package/src/react/Controls.tsx +355 -0
  137. package/src/react/SpagbolCanvas.tsx +1893 -0
  138. package/src/react/SpagbolCanvasControls.tsx +255 -0
  139. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  140. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  141. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  142. package/src/react/SpagbolControlNumber.tsx +297 -0
  143. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  144. package/src/react/SpagbolControlZoom.tsx +102 -0
  145. package/src/react/SpagbolNodeFrame.tsx +330 -0
  146. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  147. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  148. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  149. package/src/react/context.tsx +146 -0
  150. package/src/react/controls.css +122 -0
  151. package/src/react/duplicateDrag.ts +97 -0
  152. package/src/react/flowPresentation.ts +56 -0
  153. package/src/react/geometry.ts +53 -0
  154. package/src/react/index.ts +61 -0
  155. package/src/react/link-controls.css +123 -0
  156. package/src/react/nodeDrag.ts +33 -0
  157. package/src/react/portState.ts +24 -0
  158. package/src/react/styles.css +748 -0
  159. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,297 @@
1
+ import { useEffect, useRef, useState, type PointerEvent, type KeyboardEvent, type RefObject } from 'react';
2
+ /** Numeric entry, scrubbing and modifier-wheel behavior extracted directly from Teia. */
3
+ export interface SpagbolControlNumberProps {
4
+ value: number; onChange(value: number): void; label?: string;
5
+ min?: number; max?: number; step?: number; integer?: boolean;
6
+ replaced?: boolean; disabled?: boolean; onClear?(): void; onEditStart?(): void;
7
+ /** Optional host input capture while editing (for example MIDI learn). */
8
+ capture?: { id: number; value: number; onCapture(): void };
9
+ onGestureStart?(): void; onGestureEnd?(cancelled: boolean): void;
10
+ }
11
+ export const formatNumber = (value: number) => formatNumericValue(value);
12
+ export function SpagbolControlNumber({
13
+ value,
14
+ min,
15
+ max,
16
+ step: baseStep,
17
+ integer = false,
18
+ replaced = false,
19
+ onChange,
20
+ onClear,
21
+ capture,
22
+ onEditStart, label = "Value", disabled = false, onGestureStart, onGestureEnd,
23
+ }: SpagbolControlNumberProps) {
24
+ const dragStep = integer ? (baseStep ?? 0.01) * 3 : baseStep;
25
+ const [editing, setEditing] = useState(false);
26
+ const [draft, setDraft] = useState(formatNumericValue(value));
27
+ const inputRef = useRef<HTMLInputElement | null>(null);
28
+ const lastCaptureIdRef = useRef<number | null>(null);
29
+ const wheelValueRef = useRef(value);
30
+ const lastWheelTimeRef = useRef(-Infinity);
31
+ const wheelEnd = useRef<ReturnType<typeof setTimeout> | null>(null);
32
+ const wheelTargetRef = useModifiedWheel<HTMLDivElement>((event) => {
33
+ if (disabled) return;
34
+ if (event.timeStamp - lastWheelTimeRef.current > 160) {
35
+ onGestureStart?.();
36
+ wheelValueRef.current = value;
37
+ }
38
+ lastWheelTimeRef.current = event.timeStamp;
39
+ if (wheelEnd.current) clearTimeout(wheelEnd.current);
40
+ wheelEnd.current = setTimeout(() => { wheelEnd.current = null; onGestureEnd?.(false); }, 160);
41
+ const wheelDelta = wheelAxisDeltaPixels(event, 'vertical');
42
+ if (wheelDelta === 0) return;
43
+ const nextValue = constrainValue(
44
+ roundValue(wheelValueRef.current + wheelDelta * scrubberStep(event, dragStep)),
45
+ min,
46
+ max,
47
+ integer,
48
+ );
49
+ wheelValueRef.current = nextValue;
50
+ onChange(nextValue);
51
+ });
52
+ const dragRef = useRef<{
53
+ pointerId: number;
54
+ anchorY: number;
55
+ anchorValue: number;
56
+ currentValue: number;
57
+ step: number;
58
+ dragging: boolean;
59
+ } | null>(null);
60
+
61
+ useEffect(() => {
62
+ if (!editing) {
63
+ setDraft(formatNumericValue(value));
64
+ }
65
+ }, [editing, value]);
66
+
67
+ useEffect(() => {
68
+ if (editing) {
69
+ setDraft(formatNumericValue(value));
70
+ requestAnimationFrame(() => {
71
+ inputRef.current?.focus();
72
+ inputRef.current?.select();
73
+ });
74
+ }
75
+ }, [editing, value]);
76
+
77
+ useEffect(() => {
78
+ if (!editing || !capture) return;
79
+ if (lastCaptureIdRef.current === capture.id) return;
80
+
81
+ lastCaptureIdRef.current = capture.id;
82
+ const nextValue = constrainValue(capture.value, min, max, integer);
83
+ setDraft(formatNumericValue(nextValue));
84
+ capture.onCapture();
85
+ }, [editing, integer, max, capture, min]);
86
+
87
+ function beginEditing() {
88
+ if (disabled) return;
89
+ onGestureStart?.();
90
+ lastCaptureIdRef.current = capture?.id ?? null;
91
+ onEditStart?.();
92
+ setEditing(true);
93
+ }
94
+
95
+ function startDrag(event: PointerEvent<HTMLDivElement>) {
96
+ event.stopPropagation();
97
+ if (disabled || event.button !== 0) return;
98
+ dragRef.current = {
99
+ pointerId: event.pointerId,
100
+ anchorY: event.clientY,
101
+ anchorValue: value,
102
+ currentValue: value,
103
+ step: scrubberStep(event, dragStep),
104
+ dragging: false,
105
+ };
106
+ event.currentTarget.setPointerCapture(event.pointerId);
107
+ }
108
+
109
+ function updateDrag(event: PointerEvent<HTMLDivElement>) {
110
+ const drag = dragRef.current;
111
+ if (!drag || drag.pointerId !== event.pointerId) return;
112
+
113
+ const distance = drag.anchorY - event.clientY;
114
+ if (!drag.dragging && Math.abs(distance) < 3) return;
115
+
116
+ if (!drag.dragging) onGestureStart?.();
117
+ drag.dragging = true;
118
+ event.preventDefault();
119
+ event.stopPropagation();
120
+
121
+ const step = scrubberStep(event, dragStep);
122
+ if (step !== drag.step) {
123
+ drag.anchorY = event.clientY;
124
+ drag.anchorValue = drag.currentValue;
125
+ drag.step = step;
126
+ }
127
+
128
+ const nextValue = constrainValue(
129
+ roundValue(drag.anchorValue + (drag.anchorY - event.clientY) * drag.step),
130
+ min,
131
+ max,
132
+ integer,
133
+ );
134
+ drag.currentValue = nextValue;
135
+ onChange(nextValue);
136
+ }
137
+
138
+ function endDrag(event: PointerEvent<HTMLDivElement>) {
139
+ const drag = dragRef.current;
140
+ if (!drag || drag.pointerId !== event.pointerId) return;
141
+
142
+ event.stopPropagation();
143
+ event.currentTarget.releasePointerCapture(event.pointerId);
144
+ dragRef.current = null;
145
+
146
+ if (drag.dragging) {
147
+ onGestureEnd?.(false);
148
+ event.currentTarget.blur();
149
+ } else {
150
+ beginEditing();
151
+ }
152
+ }
153
+
154
+ function commitDraft() {
155
+ const trimmedDraft = draft.trim();
156
+ if (trimmedDraft.length === 0 && onClear) {
157
+ onClear();
158
+ onGestureEnd?.(false);
159
+ setEditing(false);
160
+ return;
161
+ }
162
+
163
+ const nextValue = Number(trimmedDraft);
164
+ if (Number.isFinite(nextValue)) {
165
+ onChange(constrainValue(nextValue, min, max, integer));
166
+ } else {
167
+ setDraft(formatNumericValue(value));
168
+ }
169
+ setEditing(false);
170
+ onGestureEnd?.(false);
171
+ }
172
+
173
+ function cancelDraft() {
174
+ onGestureEnd?.(true);
175
+ setDraft(formatNumericValue(value));
176
+ setEditing(false);
177
+ }
178
+
179
+ function handleEditKeyDown(event: KeyboardEvent<HTMLInputElement>) {
180
+ event.stopPropagation();
181
+ if (event.key === 'Enter') {
182
+ commitDraft();
183
+ }
184
+ if (event.key === 'Escape') {
185
+ cancelDraft();
186
+ }
187
+ }
188
+
189
+ if (editing) {
190
+ return (
191
+ <input
192
+ ref={inputRef}
193
+ className={`spagbol-control-number spagbol-control-number-editing${replaced ? ' spagbol-control-number-replaced' : ''} nodrag nopan`}
194
+ aria-label={label}
195
+ disabled={disabled}
196
+ type="text"
197
+ inputMode={integer ? 'numeric' : 'decimal'}
198
+ value={draft}
199
+ onChange={(event) => setDraft(event.target.value)}
200
+ onBlur={commitDraft}
201
+ onKeyDown={handleEditKeyDown}
202
+ onPointerDown={(event) => event.stopPropagation()}
203
+ onMouseDown={(event) => event.stopPropagation()}
204
+ onClick={(event) => event.stopPropagation()}
205
+ onDoubleClick={(event) => event.stopPropagation()}
206
+ spellCheck={false}
207
+ />
208
+ );
209
+ }
210
+
211
+ return (
212
+ <div
213
+ ref={wheelTargetRef}
214
+ className={`spagbol-control-number${replaced ? ' spagbol-control-number-replaced' : ''} nodrag nopan`}
215
+ role="spinbutton"
216
+ aria-label={label}
217
+ aria-disabled={disabled}
218
+ tabIndex={-1}
219
+ aria-valuenow={value}
220
+ aria-valuemin={min}
221
+ aria-valuemax={max}
222
+ onPointerDown={startDrag}
223
+ onPointerMove={updateDrag}
224
+ onPointerUp={endDrag}
225
+ onPointerCancel={endDrag}
226
+ onMouseDown={(event) => event.stopPropagation()}
227
+ onClick={(event) => event.stopPropagation()}
228
+ onDoubleClick={(event) => event.stopPropagation()}
229
+ onKeyDown={(event) => {
230
+ if (event.key === 'Enter') {
231
+ event.stopPropagation();
232
+ beginEditing();
233
+ }
234
+ }}
235
+ >
236
+ {formatNumericValue(value)}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function roundValue(value: number): number {
242
+ return Math.round(value * 10000) / 10000;
243
+ }
244
+
245
+ function scrubberStep(event: { metaKey: boolean; shiftKey: boolean }, baseStep = 0.01): number {
246
+ if (event.metaKey && event.shiftKey) return baseStep * 200;
247
+ if (event.metaKey) return baseStep * 20;
248
+ if (event.shiftKey) return baseStep * 0.1;
249
+ return baseStep;
250
+ }
251
+
252
+ function useModifiedWheel<T extends HTMLElement>(
253
+ onWheel: (event: WheelEvent, target: T) => void,
254
+ ): RefObject<T | null> {
255
+ const targetRef = useRef<T | null>(null);
256
+ const handlerRef = useRef(onWheel);
257
+ handlerRef.current = onWheel;
258
+
259
+ useEffect(() => {
260
+ const target = targetRef.current;
261
+ if (!target) return;
262
+
263
+ const handleWheel = (event: WheelEvent) => {
264
+ if (!event.shiftKey && !event.altKey) return;
265
+ event.preventDefault();
266
+ event.stopPropagation();
267
+ event.stopImmediatePropagation();
268
+ handlerRef.current(event, target);
269
+ };
270
+
271
+ target.addEventListener('wheel', handleWheel, { capture: true, passive: false });
272
+ return () => target.removeEventListener('wheel', handleWheel, { capture: true });
273
+ });
274
+
275
+ return targetRef;
276
+ }
277
+
278
+ function wheelAxisDeltaPixels(
279
+ event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
280
+ direction: 'horizontal' | 'vertical',
281
+ ): number {
282
+ const delta = direction === 'vertical' ? event.deltaY : event.deltaX;
283
+ if (event.deltaMode === 1) return delta * 16;
284
+ if (event.deltaMode === 2) return delta * window.innerHeight;
285
+ return delta;
286
+ }
287
+
288
+ function constrainValue(value: number, min?: number, max?: number, integer = false): number {
289
+ const rounded = integer ? Math.round(value) : value;
290
+ return Math.min(Math.max(rounded, min ?? -Infinity), max ?? Infinity);
291
+ }
292
+
293
+ function formatNumericValue(value: number): string {
294
+ if (!Number.isFinite(value)) return '0';
295
+ if (Object.is(value, -0)) return '0';
296
+ return String(value);
297
+ }
@@ -0,0 +1,209 @@
1
+ import { useEffect, useRef, useState, type PointerEvent, type KeyboardEvent } from 'react';
2
+ export interface SpagbolControlNumberLinkProps {
3
+ value: number;
4
+ onChange: (value: number) => void;
5
+ }
6
+
7
+ export function SpagbolControlNumberLink({ value, onChange }: SpagbolControlNumberLinkProps) {
8
+ const [editing, setEditing] = useState(false);
9
+ const [draft, setDraft] = useState(formatNumericValue(value));
10
+ const inputRef = useRef<HTMLInputElement | null>(null);
11
+ const wheelValueRef = useRef(value);
12
+ const lastWheelTimeRef = useRef(-Infinity);
13
+ const wheelTargetRef = useRef<HTMLDivElement | null>(null);
14
+ const latestValueRef = useRef(value);
15
+ const onChangeRef = useRef(onChange);
16
+ latestValueRef.current = value;
17
+ onChangeRef.current = onChange;
18
+ const dragRef = useRef<{
19
+ pointerId: number;
20
+ anchorY: number;
21
+ anchorValue: number;
22
+ currentValue: number;
23
+ step: number;
24
+ dragging: boolean;
25
+ } | null>(null);
26
+
27
+ useEffect(() => {
28
+ if (!editing) {
29
+ setDraft(formatNumericValue(value));
30
+ }
31
+ }, [editing, value]);
32
+
33
+ useEffect(() => {
34
+ if (!editing) return;
35
+
36
+ setDraft(formatNumericValue(value));
37
+ requestAnimationFrame(() => {
38
+ inputRef.current?.focus();
39
+ inputRef.current?.select();
40
+ });
41
+ }, [editing, value]);
42
+
43
+ useEffect(() => {
44
+ const target = wheelTargetRef.current;
45
+ if (!target || editing) return;
46
+
47
+ const handleWheel = (event: WheelEvent) => {
48
+ if (!event.shiftKey && !event.altKey) return;
49
+ event.preventDefault();
50
+ event.stopPropagation();
51
+ event.stopImmediatePropagation();
52
+ if (event.timeStamp - lastWheelTimeRef.current > 160) {
53
+ wheelValueRef.current = latestValueRef.current;
54
+ }
55
+ lastWheelTimeRef.current = event.timeStamp;
56
+ const wheelDelta = verticalWheelDeltaPixels(event);
57
+ if (wheelDelta === 0) return;
58
+ const nextValue = roundValue(wheelValueRef.current + wheelDelta * scrubberStep(event));
59
+ wheelValueRef.current = nextValue;
60
+ onChangeRef.current(nextValue);
61
+ };
62
+
63
+ target.addEventListener('wheel', handleWheel, { capture: true, passive: false });
64
+ return () => target.removeEventListener('wheel', handleWheel, { capture: true });
65
+ }, [editing]);
66
+
67
+ function startDrag(event: PointerEvent<HTMLDivElement>) {
68
+ event.stopPropagation();
69
+ dragRef.current = {
70
+ pointerId: event.pointerId,
71
+ anchorY: event.clientY,
72
+ anchorValue: value,
73
+ currentValue: value,
74
+ step: scrubberStep(event),
75
+ dragging: false,
76
+ };
77
+ event.currentTarget.setPointerCapture(event.pointerId);
78
+ }
79
+
80
+ function updateDrag(event: PointerEvent<HTMLDivElement>) {
81
+ const drag = dragRef.current;
82
+ if (!drag || drag.pointerId !== event.pointerId) return;
83
+
84
+ const distance = drag.anchorY - event.clientY;
85
+ if (!drag.dragging && Math.abs(distance) < 3) return;
86
+
87
+ drag.dragging = true;
88
+ event.preventDefault();
89
+ event.stopPropagation();
90
+
91
+ const step = scrubberStep(event);
92
+ if (step !== drag.step) {
93
+ drag.anchorY = event.clientY;
94
+ drag.anchorValue = drag.currentValue;
95
+ drag.step = step;
96
+ }
97
+
98
+ const nextValue = roundValue(drag.anchorValue + (drag.anchorY - event.clientY) * drag.step);
99
+ drag.currentValue = nextValue;
100
+ onChange(nextValue);
101
+ }
102
+
103
+ function endDrag(event: PointerEvent<HTMLDivElement>) {
104
+ const drag = dragRef.current;
105
+ if (!drag || drag.pointerId !== event.pointerId) return;
106
+
107
+ event.stopPropagation();
108
+ event.currentTarget.releasePointerCapture(event.pointerId);
109
+ dragRef.current = null;
110
+
111
+ if (drag.dragging) {
112
+ event.currentTarget.blur();
113
+ } else {
114
+ setEditing(true);
115
+ }
116
+ }
117
+
118
+ function commitDraft() {
119
+ const nextValue = Number(draft.trim());
120
+ if (Number.isFinite(nextValue)) {
121
+ onChange(nextValue);
122
+ } else {
123
+ setDraft(formatNumericValue(value));
124
+ }
125
+ setEditing(false);
126
+ }
127
+
128
+ function cancelDraft() {
129
+ setDraft(formatNumericValue(value));
130
+ setEditing(false);
131
+ }
132
+
133
+ function handleEditKeyDown(event: KeyboardEvent<HTMLInputElement>) {
134
+ event.stopPropagation();
135
+ if (event.key === 'Enter') {
136
+ commitDraft();
137
+ }
138
+ if (event.key === 'Escape') {
139
+ cancelDraft();
140
+ }
141
+ }
142
+
143
+ if (editing) {
144
+ return (
145
+ <input
146
+ ref={inputRef}
147
+ className="spagbol-control-number-link spagbol-control-number-link-editing nodrag nopan"
148
+ type="text"
149
+ inputMode="decimal"
150
+ value={draft}
151
+ onChange={(event) => setDraft(event.target.value)}
152
+ onBlur={commitDraft}
153
+ onKeyDown={handleEditKeyDown}
154
+ onPointerDown={(event) => event.stopPropagation()}
155
+ onMouseDown={(event) => event.stopPropagation()}
156
+ onDoubleClick={(event) => event.stopPropagation()}
157
+ spellCheck={false}
158
+ />
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div
164
+ ref={wheelTargetRef}
165
+ className="spagbol-control-number-link nodrag nopan"
166
+ role="spinbutton"
167
+ tabIndex={-1}
168
+ aria-valuenow={value}
169
+ onPointerDown={startDrag}
170
+ onPointerMove={updateDrag}
171
+ onPointerUp={endDrag}
172
+ onPointerCancel={endDrag}
173
+ onMouseDown={(event) => event.stopPropagation()}
174
+ onDoubleClick={(event) => event.stopPropagation()}
175
+ onKeyDown={(event) => {
176
+ if (event.key === 'Enter') {
177
+ event.stopPropagation();
178
+ setEditing(true);
179
+ }
180
+ }}
181
+ >
182
+ {formatNumericValue(value)}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function roundValue(value: number): number {
188
+ return Math.round(value * 10000) / 10000;
189
+ }
190
+
191
+ function scrubberStep(event: { metaKey: boolean; shiftKey: boolean }): number {
192
+ if (event.metaKey && event.shiftKey) return 2;
193
+ if (event.metaKey) return 0.2;
194
+ if (event.shiftKey) return 0.001;
195
+ return 0.01;
196
+ }
197
+
198
+ function verticalWheelDeltaPixels(event: Pick<WheelEvent, 'deltaY' | 'deltaMode'>): number {
199
+ const delta = event.deltaY;
200
+ if (event.deltaMode === 1) return delta * 16;
201
+ if (event.deltaMode === 2) return delta * window.innerHeight;
202
+ return delta;
203
+ }
204
+
205
+ function formatNumericValue(value: number): string {
206
+ if (!Number.isFinite(value)) return '0';
207
+ if (Object.is(value, -0)) return '0';
208
+ return String(value);
209
+ }
@@ -0,0 +1,102 @@
1
+ import { ControlButton, Controls, useReactFlow } from "@xyflow/react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface SpagbolControlZoomProps {
5
+ zoom: number;
6
+ className?: string;
7
+ onReset?(): void;
8
+ extra?: ReactNode;
9
+ layout?: "flow" | "corner";
10
+ }
11
+ /** Zoom UI reusable with either Spagbol's controller or a host-owned flow. */
12
+ export function SpagbolControlZoom({
13
+ zoom,
14
+ className,
15
+ onReset,
16
+ extra,
17
+ layout = "flow",
18
+ }: SpagbolControlZoomProps) {
19
+ const flow = useReactFlow();
20
+ const percentage = `${Math.round(zoom * 100)}%`;
21
+ const reset = () => {
22
+ if (onReset) onReset();
23
+ else void flow.zoomTo(1);
24
+ };
25
+ const label = `Reset zoom to 100% (currently ${percentage})`;
26
+ if (layout === "flow")
27
+ return (
28
+ <Controls
29
+ showInteractive={false}
30
+ className={`spagbol-control-zoom ${className ?? ""}`}
31
+ >
32
+ <ControlButton
33
+ className="react-flow__controls-zoom-percentage"
34
+ type="button"
35
+ onClick={reset}
36
+ aria-label={label}
37
+ title={label}
38
+ >
39
+ {percentage}
40
+ </ControlButton>
41
+ {extra}
42
+ </Controls>
43
+ );
44
+ return (
45
+ <div
46
+ className={`spagbol-control-zoom spagbol-control-zoom-corner nodrag nopan ${className ?? ""}`}
47
+ role="group"
48
+ aria-label="Canvas zoom controls"
49
+ onPointerDown={(event) => event.stopPropagation()}
50
+ onKeyDown={(event) => event.stopPropagation()}
51
+ >
52
+ <div className="spagbol-control-zoom-stack">
53
+ <button
54
+ type="button"
55
+ aria-label="Zoom in"
56
+ title="Zoom in"
57
+ disabled={zoom >= 3 - 0.0001}
58
+ onClick={() => {
59
+ void flow.zoomIn();
60
+ }}
61
+ >
62
+ +
63
+ </button>
64
+ <button
65
+ type="button"
66
+ aria-label="Zoom out"
67
+ title="Zoom out"
68
+ disabled={zoom <= 0.05 + 0.0001}
69
+ onClick={() => {
70
+ void flow.zoomOut();
71
+ }}
72
+ >
73
+ −
74
+ </button>
75
+ <button
76
+ type="button"
77
+ aria-label="Fit graph to view"
78
+ title="Fit graph to view"
79
+ onClick={() => {
80
+ void flow.fitView({ padding: 0.18 });
81
+ }}
82
+ >
83
+ <svg viewBox="0 0 16 16" aria-hidden="true">
84
+ <path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4" />
85
+ </svg>
86
+ </button>
87
+ </div>
88
+ <div className="spagbol-control-zoom-extras">
89
+ {extra}
90
+ <button
91
+ type="button"
92
+ className="spagbol-control-zoom-percentage"
93
+ aria-label={label}
94
+ title={label}
95
+ onClick={reset}
96
+ >
97
+ {percentage}
98
+ </button>
99
+ </div>
100
+ </div>
101
+ );
102
+ }