@hyperframes/studio 0.7.107 → 0.7.109

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,537 @@
1
+ /**
2
+ * The pointer gestures over an automation lane.
3
+ *
4
+ * Its own hook because the lane component sits at the studio's file ceiling and
5
+ * because these are the parts worth testing on their own: which of a press,
6
+ * a drag and a modifier resolves to moving a point, bending a segment,
7
+ * drawing a selection box, or nothing at all.
8
+ *
9
+ * Modifiers follow Ableton's, since that is the muscle memory an automation lane
10
+ * inherits: Shift locks a drag to one axis and fines the value down, Alt over a
11
+ * segment curves it, and Alt during a point drag ignores the grid.
12
+ */
13
+
14
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
15
+ import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
16
+ import { curveForDrag, formatValue, GRAB_PX, POINT_MERGE_SEC } from "./automationLaneGeometry";
17
+ import { pointInSelection } from "./automationLaneSelection";
18
+ import { capturePointer } from "./automationLanePointer";
19
+ import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
20
+ import { useAutomationRangeDrag } from "./useAutomationRangeDrag";
21
+ import {
22
+ armGroupDrag,
23
+ computeGroupMove,
24
+ computeSinglePointMove,
25
+ type GroupDragSnapshot,
26
+ type ShiftAxis,
27
+ } from "./automationLaneDragMath";
28
+
29
+ /** How far a press may travel and still count as a click rather than a drag. */
30
+ const CLICK_SLOP_PX = 3;
31
+
32
+ /** A point's position, or the origin when the index no longer resolves. */
33
+ function originOf(point: HfAutomationLane["points"][number] | undefined): { t: number; v: number } {
34
+ return point ? { t: point.t, v: point.v } : { t: 0, v: 0 };
35
+ }
36
+
37
+ export interface UseAutomationLaneGesturesInput {
38
+ /** The lane's box on screen. A getter, not the ref: the hook only ever needs
39
+ * the rectangle, and a ref read inside a callback is a lint the rule is right
40
+ * about — the value is not a dependency it can track. */
41
+ getBox(): DOMRect | null;
42
+ lane: HfAutomationLane;
43
+ range: AutomationRange;
44
+ /** Pointer position as a clip-local time and a parameter value. */
45
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
46
+ xOf(t: number): number;
47
+ yOf(v: number): number;
48
+ commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
49
+ /** Clip-local times a dragged point snaps to, on top of its own neighbours. */
50
+ snapTimes?: readonly number[] | undefined;
51
+ readOnly?: boolean | undefined;
52
+ onSelect?: (() => void) | undefined;
53
+ /** Live box-select callbacks; absent = background drags do nothing (read-only lanes). */
54
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
55
+ onRangeClear?: (() => void) | undefined;
56
+ duration: number; // clamp bound for box endpoints
57
+ /** Active selection box on this lane, so a press inside it can drag its points. */
58
+ rangeSelection?: { t0: number; t1: number; v0: number; v1: number } | null | undefined;
59
+ }
60
+
61
+ export interface UseAutomationLaneGesturesResult {
62
+ /** Point being dragged, for the cursor and the grab circle's size. */
63
+ dragIndex: number | null;
64
+ /** Segment being bent, identified by the point that owns its curve. */
65
+ curveIndex: number | null;
66
+ /** Value readout to show while a gesture is live. */
67
+ hint: string | null;
68
+ hitIndex(clientX: number, clientY: number): number | null;
69
+ segmentIndex(clientX: number, clientY: number): number | null;
70
+ onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
71
+ onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
72
+ /** Edge being stretched, for the cursor. Null when no stretch is live. */
73
+ edgeDrag: "t0" | "t1" | null;
74
+ /** Pointer resting over a stretch handle with nothing armed, so the cursor
75
+ * can advertise the gesture before it starts. */
76
+ edgeHover: boolean;
77
+ /** `pointercancel`: a live stretch reverts; anything else ends normally. */
78
+ cancelDrag(e: ReactPointerEvent<SVGSVGElement>): void;
79
+ endDrag(e: ReactPointerEvent<SVGSVGElement>): void;
80
+ /** Adds a point, opens the value field on one, or straightens a segment. */
81
+ onDoubleClick(e: ReactPointerEvent<SVGSVGElement>): void;
82
+ /** The point whose value is being typed, and the text so far. */
83
+ editing: { index: number; text: string } | null;
84
+ setEditingText(text: string): void;
85
+ commitEdit(): void;
86
+ cancelEdit(): void;
87
+ }
88
+
89
+ export function useAutomationLaneGestures({
90
+ getBox,
91
+ lane,
92
+ range,
93
+ pointAt,
94
+ xOf,
95
+ yOf,
96
+ commitPoints,
97
+ snapTimes,
98
+ readOnly,
99
+ onSelect,
100
+ onRangeSelect,
101
+ onRangeClear,
102
+ duration,
103
+ rangeSelection,
104
+ }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
105
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
106
+ const [curveIndex, setCurveIndex] = useState<number | null>(null);
107
+ const [hint, setHint] = useState<string | null>(null);
108
+
109
+ /**
110
+ * Stretching a selection by one of its time edges (#3207).
111
+ *
112
+ * Kept as its own module rather than inlined: it is a fifth mutually-exclusive
113
+ * gesture on a hook already at the file's complexity budget, and it owns a
114
+ * snapshot the other gestures have no use for — `retimeRange` scales against
115
+ * the bounds captured when the drag armed, so running it against the live
116
+ * draft would compound the scale factor on every pointermove.
117
+ *
118
+ * Only the time edges stretch. The value extent rides through untouched, which
119
+ * is what keeps this the same gesture it was before the selection became a box.
120
+ */
121
+ const stretch = useAutomationEdgeStretch({
122
+ getBox,
123
+ lane,
124
+ range,
125
+ pointAt,
126
+ xOf,
127
+ commitPoints,
128
+ duration,
129
+ readOnly,
130
+ rangeSelection,
131
+ // The stretch stands down on a press that lands on selected content — that
132
+ // gesture belongs to the group drag below.
133
+ pointInSelectionAt: (clientX, clientY) => {
134
+ if (!rangeSelection) return false;
135
+ const i = hitIndex(clientX, clientY);
136
+ const p = i === null ? null : lane.points[i];
137
+ return p ? pointInSelection(p, rangeSelection) : false;
138
+ },
139
+ onRangeSelect,
140
+ onRangeClear,
141
+ onHint: setHint,
142
+ });
143
+ /** Where a point drag began, so Shift can lock an axis and fine the value. */
144
+ const dragOrigin = useRef<{ t: number; v: number } | null>(null);
145
+ /**
146
+ * The set a group drag moves, snapshotted on the press.
147
+ *
148
+ * Snapshotted rather than recomputed per move for two reasons: every point is
149
+ * moving, so "which ones are selected" has to mean what it meant when the
150
+ * gesture started, and the deltas have to accumulate from the original
151
+ * positions or a rounded move would drift on every pointermove.
152
+ */
153
+ const groupDrag = useRef<GroupDragSnapshot | null>(null);
154
+ /** Point whose value is being typed, and the text so far. */
155
+ const [editing, setEditing] = useState<{ index: number; text: string } | null>(null);
156
+ const rangeDrag = useAutomationRangeDrag({
157
+ pointAt,
158
+ xOf,
159
+ yOf,
160
+ duration,
161
+ snapTimes,
162
+ onRangeSelect,
163
+ onRangeClear,
164
+ });
165
+ /** Where a press on a point landed, and whether it has travelled since. A press
166
+ * that goes nowhere is a click, which is a different gesture from a drag even
167
+ * though both start the same way — see the Shift branch in `endDrag`. */
168
+ const pressAt = useRef<{ x: number; y: number } | null>(null);
169
+ const pressTravelled = useRef(false);
170
+ /**
171
+ * The axis Shift locked, decided on the gesture's first travel and held until
172
+ * the drag ends or Shift is let go. Deciding per event followed whichever way
173
+ * the last move leaned, so a drifting hand unlocked the axis mid-drag.
174
+ */
175
+ const shiftAxis = useRef<ShiftAxis>(null);
176
+
177
+ // The value field's own handlers, above the gestures that close it: a press on the
178
+ // lane applies whatever was typed, so onPointerDown lists commitEdit as a
179
+ // dependency and cannot be declared before it.
180
+ const setEditingText = useCallback((text: string): void => {
181
+ setEditing((current) => (current ? { index: current.index, text } : null));
182
+ }, []);
183
+
184
+ const cancelEdit = useCallback((): void => setEditing(null), []);
185
+
186
+ /** Apply a typed value, or drop the edit when it is not a number. */
187
+ const commitEdit = useCallback((): void => {
188
+ const active = editing;
189
+ setEditing(null);
190
+ if (!active) return;
191
+ const typed = Number(active.text);
192
+ if (!Number.isFinite(typed)) return;
193
+ const clamped = Math.min(range.max, Math.max(range.min, typed));
194
+ commitPoints(
195
+ lane.points.map((p, i) => (i === active.index ? { ...p, v: clamped } : p)),
196
+ true,
197
+ );
198
+ }, [editing, lane, range, commitPoints]);
199
+
200
+ /** Index of a point under the pointer, or null. */
201
+ const hitIndex = useCallback(
202
+ (clientX: number, clientY: number): number | null => {
203
+ const box = getBox();
204
+ if (!box) return null;
205
+ const px = clientX - box.left;
206
+ const py = clientY - box.top;
207
+ for (let i = 0; i < lane.points.length; i += 1) {
208
+ const p = lane.points[i];
209
+ if (p && Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i;
210
+ }
211
+ return null;
212
+ },
213
+ [getBox, lane, xOf, yOf],
214
+ );
215
+
216
+ /** Index of the point owning the segment under the pointer, or null. */
217
+ const segmentIndex = useCallback(
218
+ (clientX: number, clientY: number): number | null => {
219
+ const { t } = pointAt(clientX, clientY);
220
+ for (let i = 0; i + 1 < lane.points.length; i += 1) {
221
+ const a = lane.points[i];
222
+ const b = lane.points[i + 1];
223
+ if (a && b && t > a.t && t < b.t) return i;
224
+ }
225
+ return null;
226
+ },
227
+ [lane, pointAt],
228
+ );
229
+
230
+ /** What a press starts: moving a point, or — with Alt on the line — bending it. */
231
+ const gestureAt = useCallback(
232
+ (e: ReactPointerEvent<SVGSVGElement>): { curve: boolean; index: number } | null => {
233
+ const index = hitIndex(e.clientX, e.clientY);
234
+ if (index !== null) return { curve: false, index };
235
+ if (!e.altKey) return null;
236
+ const segment = segmentIndex(e.clientX, e.clientY);
237
+ return segment === null ? null : { curve: true, index: segment };
238
+ },
239
+ [hitIndex, segmentIndex],
240
+ );
241
+
242
+ const onPointerDown = useCallback(
243
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
244
+ if (e.button !== 0) return;
245
+ // The lane owns this region either way. Letting a press through starts the
246
+ // timeline's own gesture (scrub / marquee / clip drag), which then eats the
247
+ // rest of the sequence — including the second half of a double-click.
248
+ e.stopPropagation();
249
+ // A press anywhere on the lane closes the value field, applying what was
250
+ // typed — the same thing Enter and a blur do. It cannot rely on the blur: the
251
+ // gesture branches below call preventDefault to keep the timeline from
252
+ // scrubbing, and that suppresses the focus change the blur would come from,
253
+ // so the field sat open until Enter however far away the next click landed.
254
+ if (editing) commitEdit();
255
+ if (readOnly) {
256
+ // The lane sits below the clip bar, so the timeline's selection handler
257
+ // never sees this press; selecting here is the only way in. The press then
258
+ // goes on to arm a range drag rather than being spent on the selection: a
259
+ // press that only selected made the first drag over any lane do nothing
260
+ // visible, so a range took two gestures and looked broken on the first.
261
+ onSelect?.();
262
+ rangeDrag.arm(e);
263
+ return;
264
+ }
265
+ // An active selection's edge outranks a point sitting on it. Every range
266
+ // operation leaves a breakpoint exactly on the edge it just created, so a
267
+ // point-first rule made the second stretch of the same edge resolve to a
268
+ // point-drag — which is how the feature silently stopped working.
269
+ if (stretch.arm(e)) return;
270
+ const gesture = gestureAt(e);
271
+ if (!gesture) {
272
+ rangeDrag.arm(e);
273
+ return;
274
+ }
275
+ e.preventDefault();
276
+ capturePointer(e);
277
+ if (gesture.curve) {
278
+ setCurveIndex(gesture.index);
279
+ return;
280
+ }
281
+ dragOrigin.current = originOf(lane.points[gesture.index]);
282
+ // Pressing one of a selected set drags the whole set. Pressing a point
283
+ // outside the selection is an ordinary single-point drag, selection or no.
284
+ groupDrag.current = armGroupDrag(lane, lane.points[gesture.index], rangeSelection);
285
+ pressAt.current = { x: e.clientX, y: e.clientY };
286
+ pressTravelled.current = false;
287
+ setDragIndex(gesture.index);
288
+ },
289
+ [
290
+ gestureAt,
291
+ lane,
292
+ readOnly,
293
+ onSelect,
294
+ rangeDrag,
295
+ editing,
296
+ commitEdit,
297
+ // The press decides whether it starts a group drag, so it has to see the
298
+ // selection as it is now — captured stale, a point pressed straight after
299
+ // selecting a range read the previous selection, or none.
300
+ rangeSelection,
301
+ stretch,
302
+ ],
303
+ );
304
+
305
+ /** Bend the segment under the pointer, which is what Alt-dragging the line does. */
306
+ const bendSegment = useCallback(
307
+ (clientX: number, clientY: number): void => {
308
+ if (curveIndex === null) return;
309
+ const a = lane.points[curveIndex];
310
+ const b = lane.points[curveIndex + 1];
311
+ if (!a || !b) return;
312
+ const { t, v } = pointAt(clientX, clientY);
313
+ const bend = curveForDrag({ range, a, b, t, v });
314
+ if (bend === null) return;
315
+ // Read out where the bend now sits along the segment, which is what the
316
+ // pointer is choosing: a percentage means more here than a curve exponent
317
+ // the author never types.
318
+ setHint(`bend ${Math.round(bend.viaX * 100)}%`);
319
+ commitPoints(
320
+ // `curve` is dropped rather than carried: the via point supersedes it, and
321
+ // leaving a stale exponent behind would make the segment's shape depend on
322
+ // which of the two the reader happened to honour.
323
+ lane.points.map((p, i) =>
324
+ i === curveIndex ? { ...p, curve: undefined, viaX: bend.viaX, viaY: bend.viaY } : p,
325
+ ),
326
+ false,
327
+ );
328
+ },
329
+ [curveIndex, lane, pointAt, range, commitPoints],
330
+ );
331
+
332
+ /**
333
+ * Move a selected set by one delta, taken from the point under the pointer.
334
+ *
335
+ * The whole group has to stop when its first member reaches a boundary, not
336
+ * each point on its own: clamping individually squashes the shape flat against
337
+ * the edge, and the gesture is meant to preserve it. Deltas are in the
338
+ * parameter's own units, so on a logarithmic axis a group moves by Hz rather
339
+ * than by octaves — the same as dragging one point does.
340
+ */
341
+ const moveGroup = useCallback(
342
+ (e: ReactPointerEvent<SVGSVGElement>, group: GroupDragSnapshot): void => {
343
+ const { points, selection, hint } = computeGroupMove({
344
+ group,
345
+ raw: pointAt(e.clientX, e.clientY),
346
+ shiftKey: e.shiftKey,
347
+ altKey: e.altKey,
348
+ range,
349
+ duration,
350
+ snapTimes,
351
+ xOf,
352
+ yOf,
353
+ });
354
+ onRangeSelect?.(selection.t0, selection.t1, selection.v0, selection.v1);
355
+ setHint(hint);
356
+ commitPoints(points, false);
357
+ },
358
+ [commitPoints, duration, onRangeSelect, pointAt, range, snapTimes, xOf, yOf],
359
+ );
360
+
361
+ /** Move the point being dragged, honouring the modifiers held with it. */
362
+ const movePoint = useCallback(
363
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
364
+ if (dragIndex === null) return;
365
+ const group = groupDrag.current;
366
+ if (group) {
367
+ moveGroup(e, group);
368
+ return;
369
+ }
370
+ if (!e.shiftKey) shiftAxis.current = null;
371
+ const {
372
+ t,
373
+ v,
374
+ shiftAxis: nextAxis,
375
+ } = computeSinglePointMove({
376
+ raw: pointAt(e.clientX, e.clientY),
377
+ origin: dragOrigin.current,
378
+ shiftKey: e.shiftKey,
379
+ altKey: e.altKey,
380
+ shiftAxis: shiftAxis.current,
381
+ range,
382
+ duration,
383
+ snapTimes,
384
+ lane,
385
+ dragIndex,
386
+ xOf,
387
+ yOf,
388
+ });
389
+ shiftAxis.current = nextAxis;
390
+ const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
391
+ setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
392
+ commitPoints(next, false);
393
+ },
394
+ [dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
395
+ );
396
+
397
+ const onPointerMove = useCallback(
398
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
399
+ if (stretch.edge !== null) {
400
+ e.stopPropagation();
401
+ // A button-less move means the browser handed the gesture back without a
402
+ // pointerup — revert rather than leaving a half-applied retime.
403
+ if (e.buttons === 0) stretch.cancel();
404
+ else stretch.move(e);
405
+ return;
406
+ }
407
+ if (rangeDrag.dragging) {
408
+ e.stopPropagation();
409
+ rangeDrag.move(e);
410
+ return;
411
+ }
412
+ if (curveIndex === null && dragIndex === null) {
413
+ stretch.updateHover(e);
414
+ return;
415
+ }
416
+ e.stopPropagation();
417
+ const from = pressAt.current;
418
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
419
+ pressTravelled.current = true;
420
+ }
421
+ if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
422
+ else movePoint(e);
423
+ },
424
+ [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
425
+ );
426
+
427
+ const endDrag = useCallback(
428
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
429
+ if (stretch.edge !== null) {
430
+ e.stopPropagation();
431
+ stretch.finish();
432
+ return;
433
+ }
434
+ if (rangeDrag.dragging) {
435
+ e.stopPropagation();
436
+ rangeDrag.finish();
437
+ return;
438
+ }
439
+ if (dragIndex === null && curveIndex === null) return;
440
+ e.stopPropagation();
441
+ const index = dragIndex;
442
+ const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
443
+ setDragIndex(null);
444
+ setCurveIndex(null);
445
+ dragOrigin.current = null;
446
+ groupDrag.current = null;
447
+ shiftAxis.current = null;
448
+ pressAt.current = null;
449
+ setHint(null);
450
+ // Shift+click removes the point. Decided on RELEASE, not on the press: Shift
451
+ // held through a drag is the axis lock, and acting on the press would take
452
+ // that gesture away. A press that never travelled was a click.
453
+ if (shiftClicked) {
454
+ commitPoints(
455
+ lane.points.filter((_, i) => i !== index),
456
+ true,
457
+ );
458
+ return;
459
+ }
460
+ commitPoints(lane.points, true);
461
+ },
462
+ [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
463
+ );
464
+
465
+ /**
466
+ * `pointercancel`: the browser abandoned the gesture, so a stretch reverts
467
+ * rather than persisting the partial retime `endDrag` would have committed.
468
+ * Anything else ends the way a release ends it.
469
+ */
470
+ const cancelDrag = useCallback(
471
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
472
+ if (stretch.edge !== null) {
473
+ e.stopPropagation();
474
+ stretch.cancel();
475
+ return;
476
+ }
477
+ endDrag(e);
478
+ },
479
+ [stretch, endDrag],
480
+ );
481
+
482
+ const onDoubleClick = useCallback(
483
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
484
+ if (readOnly) return;
485
+ e.stopPropagation();
486
+ e.preventDefault();
487
+ const onPoint = hitIndex(e.clientX, e.clientY);
488
+ if (e.altKey) {
489
+ // Straighten the segment back out — the counterpart to Alt-dragging it.
490
+ const segment = onPoint ?? segmentIndex(e.clientX, e.clientY);
491
+ if (segment === null) return;
492
+ commitPoints(
493
+ lane.points.map((p, i) => (i === segment ? { t: p.t, v: p.v } : p)),
494
+ true,
495
+ );
496
+ return;
497
+ }
498
+ if (onPoint !== null) {
499
+ // Typing beats dragging when the value has to be exact — -6.0 dB is not
500
+ // a pixel you can find.
501
+ const p = lane.points[onPoint];
502
+ if (p) setEditing({ index: onPoint, text: String(Number(p.v.toFixed(3))) });
503
+ return;
504
+ }
505
+ const { t, v } = pointAt(e.clientX, e.clientY);
506
+ const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
507
+ // A lane's first point alone would be a constant, which is not what
508
+ // clicking an empty lane means: seed the far end at the same value so the
509
+ // envelope has somewhere to go.
510
+ const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
511
+ commitPoints(
512
+ [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
513
+ true,
514
+ );
515
+ },
516
+ [lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
517
+ );
518
+
519
+ return {
520
+ dragIndex,
521
+ curveIndex,
522
+ edgeDrag: stretch.edge,
523
+ edgeHover: stretch.hover,
524
+ cancelDrag,
525
+ hint,
526
+ hitIndex,
527
+ segmentIndex,
528
+ onPointerDown,
529
+ onPointerMove,
530
+ endDrag,
531
+ onDoubleClick,
532
+ editing,
533
+ setEditingText,
534
+ commitEdit,
535
+ cancelEdit,
536
+ };
537
+ }
@@ -0,0 +1,112 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import { useAutomationLanes, type AutomationLaneBinding } from "./useAutomationLanes";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ /** Bind one element through the hook and hand back what the lane would get. */
11
+ function bindOnce(element: TimelineElement): AutomationLaneBinding {
12
+ let captured: AutomationLaneBinding | null = null;
13
+ function Probe() {
14
+ captured = useAutomationLanes().bind(element, true);
15
+ return null;
16
+ }
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ act(() => {
20
+ createRoot(host).render(<Probe />);
21
+ });
22
+ if (!captured) throw new Error("bind never ran");
23
+ return captured;
24
+ }
25
+
26
+ const el = (over: Partial<TimelineElement> = {}): TimelineElement => ({
27
+ id: "music",
28
+ key: "music",
29
+ tag: "audio",
30
+ start: 0,
31
+ duration: 12,
32
+ track: 10,
33
+ ...over,
34
+ });
35
+
36
+ const CHAIN = JSON.stringify({
37
+ version: 1,
38
+ nodes: [{ type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }],
39
+ });
40
+
41
+ describe("useAutomationLanes", () => {
42
+ it("drops a lane whose effect is no longer in the chain", () => {
43
+ // n1 was deleted from the chain but its lane survived in the attribute.
44
+ // Drawn as-is it landed on the volume axis, with points off the lane and
45
+ // a selector that had no option for it.
46
+ const automation = JSON.stringify({
47
+ version: 1,
48
+ lanes: [
49
+ {
50
+ target: "fx.n1.speed",
51
+ points: [
52
+ { t: 0, v: 0.4 },
53
+ { t: 12, v: 6 },
54
+ ],
55
+ },
56
+ { target: "volume", points: [{ t: 0, v: 0.55 }] },
57
+ ],
58
+ });
59
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
60
+ expect(bound.automation.lanes.map((l) => l.target)).toEqual(["volume"]);
61
+ });
62
+
63
+ it("keeps a lane whose effect is still there", () => {
64
+ const automation = JSON.stringify({
65
+ version: 1,
66
+ lanes: [
67
+ {
68
+ target: "fx.n2.frequency",
69
+ points: [
70
+ { t: 0, v: 400 },
71
+ { t: 4, v: 8000 },
72
+ ],
73
+ },
74
+ ],
75
+ });
76
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
77
+ expect(bound.lanes.map((l) => l.target)).toEqual(["fx.n2.frequency"]);
78
+ });
79
+
80
+ it("gives one lane per automated parameter, in draw order", () => {
81
+ // Draw order is the spectrum: a lane belonging to an effect that sits at a
82
+ // frequency is placed by that frequency, high first, and lanes with none —
83
+ // the track's own volume — follow in written order.
84
+ const automation = JSON.stringify({
85
+ version: 1,
86
+ lanes: [
87
+ { target: "volume", points: [{ t: 0, v: 0.5 }] },
88
+ { target: "fx.n2.frequency", points: [{ t: 0, v: 400 }] },
89
+ { target: "fx.n2.q", points: [{ t: 0, v: 1 }] },
90
+ ],
91
+ });
92
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
93
+ expect(bound.lanes.map((l) => l.target)).toEqual(["fx.n2.frequency", "fx.n2.q", "volume"]);
94
+ });
95
+
96
+ it("reads an element with neither attribute as an empty volume lane", () => {
97
+ const bound = bindOnce(el());
98
+ expect(bound.lanes).toEqual([]);
99
+ expect(bound.chain).toBeNull();
100
+ });
101
+
102
+ it("is read-only without an edit session, whatever the selection", () => {
103
+ // No DomEditProvider in this tree — the bare player case.
104
+ expect(bindOnce(el({ automation: undefined })).readOnly).toBe(true);
105
+ });
106
+
107
+ it("survives an unreadable attribute instead of breaking the row", () => {
108
+ const bound = bindOnce(el({ automation: "{not json", fxChain: "{also not}" }));
109
+ expect(bound.automation.lanes).toEqual([]);
110
+ expect(bound.chain).toBeNull();
111
+ });
112
+ });