@hyperframes/studio 0.7.107 → 0.7.108

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-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -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,1720 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import { TimelineAutomationLane } from "./TimelineAutomationLane";
6
+ import { PAD_X } from "./automationLaneGeometry";
7
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
8
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
9
+ import {
10
+ normalizeAutomation,
11
+ resolveAutomationRange,
12
+ VOLUME_RANGE,
13
+ type HfAutomation,
14
+ } from "@hyperframes/core/audio-automation";
15
+
16
+ const chain: HfAudioFxChain = {
17
+ version: 1,
18
+ nodes: [
19
+ { type: "lowpass", id: "n1", enabled: true, params: {} },
20
+ // No id: the panel has not touched it, so nothing can address it.
21
+ { type: "peaking", enabled: true, params: {} },
22
+ // Worklet-backed: no AudioParams to schedule.
23
+ { type: "compressor", id: "n3", enabled: true, params: {} },
24
+ ],
25
+ };
26
+
27
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
28
+
29
+ function renderRerenderable(node: React.ReactElement): {
30
+ container: HTMLElement;
31
+ rerender(next: React.ReactElement): void;
32
+ } {
33
+ const host = document.createElement("div");
34
+ document.body.append(host);
35
+ const root = createRoot(host);
36
+ act(() => {
37
+ root.render(node);
38
+ });
39
+ return {
40
+ container: host,
41
+ rerender: (next) => {
42
+ act(() => {
43
+ root.render(next);
44
+ });
45
+ },
46
+ };
47
+ }
48
+
49
+ function render(node: React.ReactElement): { container: HTMLElement } {
50
+ const host = document.createElement("div");
51
+ document.body.append(host);
52
+ const root = createRoot(host);
53
+ act(() => {
54
+ root.render(node);
55
+ });
56
+ return { container: host };
57
+ }
58
+
59
+ /**
60
+ * Mount inside a wrapper so propagation can be observed from a real ancestor.
61
+ * A listener on React's own root node is no test of it: two native listeners on
62
+ * one element both run regardless of stopPropagation.
63
+ */
64
+ function renderNested(node: React.ReactElement): {
65
+ container: HTMLElement;
66
+ ancestor: HTMLElement;
67
+ } {
68
+ const ancestor = document.createElement("div");
69
+ const host = document.createElement("div");
70
+ ancestor.append(host);
71
+ document.body.append(ancestor);
72
+ const root = createRoot(host);
73
+ act(() => {
74
+ root.render(node);
75
+ });
76
+ return { container: host, ancestor };
77
+ }
78
+
79
+ /** happy-dom has no pointer-event constructors wired to React's synthetic ones,
80
+ * so events are dispatched as plain typed events with the coordinates React
81
+ * reads off them. */
82
+ function fire(
83
+ el: Element,
84
+ type: string,
85
+ init: {
86
+ clientX?: number;
87
+ clientY?: number;
88
+ button?: number;
89
+ /** Buttons still held. A move reporting none is how a lost capture shows up. */
90
+ buttons?: number;
91
+ altKey?: boolean;
92
+ shiftKey?: boolean;
93
+ key?: string;
94
+ } = {},
95
+ ): void {
96
+ const event = new Event(type, { bubbles: true, cancelable: true });
97
+ Object.assign(event, {
98
+ clientX: 0,
99
+ clientY: 0,
100
+ button: 0,
101
+ pointerId: 1,
102
+ altKey: false,
103
+ shiftKey: false,
104
+ ...init,
105
+ });
106
+ act(() => {
107
+ el.dispatchEvent(event);
108
+ });
109
+ }
110
+
111
+ /** Slack the lane insets its drawing by, so an end point is not half clipped. */
112
+ const PAD = PAD_X;
113
+
114
+ const EMPTY: HfAutomation = { version: 1, lanes: [] };
115
+
116
+ const ramp: HfAutomation = {
117
+ version: 1,
118
+ lanes: [
119
+ {
120
+ target: "volume",
121
+ points: [
122
+ { t: 0, v: 1 },
123
+ { t: 4, v: 0 },
124
+ ],
125
+ },
126
+ ],
127
+ };
128
+
129
+ function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> = {}) {
130
+ const target = over.target ?? "volume";
131
+ return {
132
+ duration: 4,
133
+ widthPx: 400,
134
+ leftPx: 100,
135
+ topPx: 28,
136
+ automation: EMPTY,
137
+ accentColor: "#0af",
138
+ playheadSec: null,
139
+ onPreview: vi.fn(),
140
+ onCommit: vi.fn(),
141
+ ...over,
142
+ target,
143
+ range: over.range ?? resolveAutomationRange(target, chain) ?? VOLUME_RANGE,
144
+ };
145
+ }
146
+
147
+ /** happy-dom gives every element a zero-size box; the lane maps pointers
148
+ * through it, so tests that click need a real one. */
149
+ function stubBox(el: Element, box: { left: number; top: number; width: number; height: number }) {
150
+ vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
151
+ ...box,
152
+ right: box.left + box.width,
153
+ bottom: box.top + box.height,
154
+ x: box.left,
155
+ y: box.top,
156
+ toJSON: () => ({}),
157
+ } as DOMRect);
158
+ }
159
+
160
+ describe("TimelineAutomationLane", () => {
161
+ it("draws a point per breakpoint", () => {
162
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
163
+ expect(container.querySelectorAll("circle").length).toBe(2);
164
+ });
165
+
166
+ it("draws a dimmed flat line when the lane is empty", () => {
167
+ const { container } = render(<TimelineAutomationLane {...laneProps()} />);
168
+ expect(container.querySelectorAll("circle").length).toBe(0);
169
+ const path = container.querySelector("path");
170
+ expect(Number(path?.getAttribute("opacity"))).toBeLessThan(0.5);
171
+ });
172
+
173
+ it("keeps an end point clear of the lane's edges", () => {
174
+ // A point at t=0 drawn at x=0 is half outside the svg and unclickable; the
175
+ // lane insets its drawing so both ends are whole.
176
+ const ends: HfAutomation = {
177
+ version: 1,
178
+ lanes: [
179
+ {
180
+ target: "volume",
181
+ points: [
182
+ { t: 0, v: 1 },
183
+ { t: 4, v: 0 },
184
+ ],
185
+ },
186
+ ],
187
+ };
188
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ends })} />);
189
+ const svg = container.querySelector("svg")!;
190
+ const points = Array.from(container.querySelectorAll("[data-automation-point]"));
191
+ const radius = Number(points[0]!.getAttribute("r"));
192
+ const first = Number(points[0]!.getAttribute("cx"));
193
+ const last = Number(points[1]!.getAttribute("cx"));
194
+ const svgWidth = Number(svg.getAttribute("width"));
195
+ expect(first).toBeGreaterThanOrEqual(radius);
196
+ expect(last).toBeLessThanOrEqual(svgWidth - radius);
197
+ // Wider than the clip by the padding on both sides, so clip time still
198
+ // lines up with screen position.
199
+ expect(svgWidth).toBe(400 + PAD * 2);
200
+ });
201
+
202
+ it("draws no plate behind the envelope", () => {
203
+ // The lane used to darken its clip's width, which put a box inside the row and
204
+ // made a stack of lanes read as tiles rather than rows of one timeline. Only the
205
+ // selection rectangle may fill area now, and only while a range is selected.
206
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
207
+ expect(container.querySelectorAll("rect")).toHaveLength(0);
208
+ });
209
+
210
+ it("still fills the selected range, which is the one rectangle it draws", () => {
211
+ const { container } = render(
212
+ <TimelineAutomationLane
213
+ {...laneProps({ automation: ramp, rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } })}
214
+ />,
215
+ );
216
+ const rects = container.querySelectorAll("rect");
217
+ expect(rects).toHaveLength(1);
218
+ expect(rects[0]?.getAttribute("data-automation-selection")).toBe("");
219
+ });
220
+
221
+ it("divides one lane from the next with a separator", () => {
222
+ // A stack of envelopes with no rule between them reads as one tall field, and a
223
+ // point near a boundary looks like it belongs to either lane.
224
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
225
+ const border = container.querySelector<HTMLElement>("[data-automation-lane-border]");
226
+ expect(border).not.toBeNull();
227
+ expect(border?.style.height).toBe("1px");
228
+ // Full row width, not just the clip's: it is a lane divider, not part of the clip.
229
+ expect(border?.style.left).toBe("0px");
230
+ expect(border?.style.right).toBe("0px");
231
+ // Cannot eat a press aimed at the envelope underneath it.
232
+ expect(border?.className).toContain("pointer-events-none");
233
+ });
234
+
235
+ it("keeps the lane's own height, so the separator cannot shift the geometry", () => {
236
+ // Drawn as an overlay rather than a CSS border for this reason: hit testing maps
237
+ // pointer positions through the svg's box, and a border would move it by a pixel.
238
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
239
+ const lane = container.querySelector<HTMLElement>(".hf-automation-lane");
240
+ const svg = container.querySelector("svg");
241
+ expect(lane?.style.height).toBe(`${AUTOMATION_LANE_H}px`);
242
+ expect(svg?.getAttribute("height")).toBe(String(AUTOMATION_LANE_H));
243
+ });
244
+
245
+ it("draws no name of its own — the label column owns it", () => {
246
+ // Over the envelope the name obscured the curve it described, and it scrolled
247
+ // with the canvas away from the row it belonged to. TimelineTrackHeader names
248
+ // each lane in the sticky column instead, on the keyframe rows' own tree.
249
+ const { container } = render(
250
+ <TimelineAutomationLane {...laneProps({ target: "fx.n1.frequency", leftPx: 0 })} />,
251
+ );
252
+ expect(container.querySelector(".hf-automation-name")).toBeNull();
253
+ expect(container.textContent).not.toMatch(/Cutoff/);
254
+ });
255
+
256
+ it("offers no control to swap which parameter it draws", () => {
257
+ const { container } = render(
258
+ <TimelineAutomationLane {...laneProps({ target: "fx.n1.frequency" })} />,
259
+ );
260
+ expect(container.querySelector("select")).toBeNull();
261
+ });
262
+
263
+ it("adds a point on double-click, at the value the pointer was at", () => {
264
+ const onCommit = vi.fn();
265
+ const { container } = render(<TimelineAutomationLane {...laneProps({ onCommit })} />);
266
+ const svg = container.querySelector("svg")!;
267
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
268
+ // Half way across, and at the very top of the lane => t=2, v=1.
269
+ fire(svg, "dblclick", { clientX: PAD + 200, clientY: 6 });
270
+ expect(onCommit).toHaveBeenCalledTimes(1);
271
+ const lane = onCommit.mock.calls[0][0].lanes[0];
272
+ expect(lane.target).toBe("volume");
273
+ // Seeded at 0 so the envelope has somewhere to come from.
274
+ expect(lane.points.length).toBe(2);
275
+ expect(lane.points[1].t).toBeCloseTo(2, 5);
276
+ expect(lane.points[1].v).toBeCloseTo(1, 2);
277
+ });
278
+
279
+ it("previews while dragging and persists once on release", () => {
280
+ const onPreview = vi.fn();
281
+ const onCommit = vi.fn();
282
+ const { container } = render(
283
+ <TimelineAutomationLane {...laneProps({ automation: ramp, onPreview, onCommit })} />,
284
+ );
285
+ const svg = container.querySelector("svg")!;
286
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
287
+ // Grab the first point, at x=0 / top of the lane.
288
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
289
+ fire(svg, "pointermove", { clientX: 100, clientY: 42 });
290
+ fire(svg, "pointermove", { clientX: 120, clientY: 40 });
291
+ expect(onPreview).toHaveBeenCalledTimes(2);
292
+ expect(onCommit).not.toHaveBeenCalled();
293
+ fire(svg, "pointerup", { clientX: 120, clientY: 40 });
294
+ expect(onCommit).toHaveBeenCalledTimes(1);
295
+ });
296
+
297
+ it("moves the dragged point on screen without waiting for the prop", () => {
298
+ // The live write skips the preview refresh on purpose, so `automation` does
299
+ // not change under the pointer. Before the draft state existed the circle
300
+ // stayed put and only the audio moved.
301
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
302
+ const svg = container.querySelector("svg")!;
303
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
304
+ const cyBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cy"));
305
+ const cxBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
306
+
307
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
308
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
309
+
310
+ const dragged = container.querySelectorAll("circle")[0]!;
311
+ expect(Number(dragged.getAttribute("cy"))).toBeGreaterThan(cyBefore + 10);
312
+ expect(Number(dragged.getAttribute("cx"))).toBeGreaterThan(cxBefore + 100);
313
+ });
314
+
315
+ it("keeps the dragged position after release, rather than snapping back", () => {
316
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
317
+ const svg = container.querySelector("svg")!;
318
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
319
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
320
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
321
+ const during = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
322
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
323
+ expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
324
+ during,
325
+ 5,
326
+ );
327
+ });
328
+
329
+ it("follows the prop again once the store catches up", () => {
330
+ const { container, rerender } = renderRerenderable(
331
+ <TimelineAutomationLane {...laneProps({ automation: ramp })} />,
332
+ );
333
+ const svg = container.querySelector("svg")!;
334
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
335
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
336
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
337
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
338
+ // The persisted edit lands and the store hands back a different envelope;
339
+ // the lane must defer to it instead of holding the stale draft forever.
340
+ const persisted: HfAutomation = {
341
+ version: 1,
342
+ lanes: [{ target: "volume", points: [{ t: 3, v: 0.25 }] }],
343
+ };
344
+ rerender(<TimelineAutomationLane {...laneProps({ automation: persisted })} />);
345
+ const circles = container.querySelectorAll("circle");
346
+ expect(circles.length).toBe(1);
347
+ expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
348
+ });
349
+
350
+ it("keeps lane order when editing, so the view does not switch parameters", () => {
351
+ // The displayed lane defaults to the first one. Moving the edited lane to
352
+ // the end of the list swapped the lane out from under the pointer on the
353
+ // first edit — a 4-point filter sweep became a 2-point one mid-gesture.
354
+ const onCommit = vi.fn();
355
+ const two: HfAutomation = {
356
+ version: 1,
357
+ lanes: [
358
+ {
359
+ target: "fx.n1.frequency",
360
+ points: [
361
+ { t: 0, v: 400 },
362
+ { t: 4, v: 8000 },
363
+ ],
364
+ },
365
+ { target: "volume", points: [{ t: 0, v: 1 }] },
366
+ ],
367
+ };
368
+ const { container } = render(
369
+ <TimelineAutomationLane
370
+ {...laneProps({ automation: two, target: "fx.n1.frequency", onCommit })}
371
+ />,
372
+ );
373
+ const svg = container.querySelector("svg")!;
374
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
375
+ fire(svg, "dblclick", { clientX: 200, clientY: 20 });
376
+ const next: HfAutomation = onCommit.mock.calls[0][0];
377
+ expect(next.lanes.map((l) => l.target)).toEqual(["fx.n1.frequency", "volume"]);
378
+ expect(next.lanes[0]!.points.length).toBe(3);
379
+ });
380
+
381
+ it("appends a lane that did not exist yet", () => {
382
+ const onCommit = vi.fn();
383
+ const only: HfAutomation = {
384
+ version: 1,
385
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
386
+ };
387
+ const { container } = render(
388
+ <TimelineAutomationLane
389
+ {...laneProps({ automation: only, target: "fx.n1.frequency", onCommit })}
390
+ />,
391
+ );
392
+ const svg = container.querySelector("svg")!;
393
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
394
+ fire(svg, "dblclick", { clientX: 200, clientY: 20 });
395
+ expect(onCommit.mock.calls[0][0].lanes.map((l: { target: string }) => l.target)).toEqual([
396
+ "volume",
397
+ "fx.n1.frequency",
398
+ ]);
399
+ });
400
+
401
+ it("removes a point on right-click", () => {
402
+ const onCommit = vi.fn();
403
+ const { container } = render(
404
+ <TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
405
+ );
406
+ fire(container.querySelectorAll("circle")[0]!, "contextmenu");
407
+ expect(onCommit.mock.calls[0][0].lanes[0].points.length).toBe(1);
408
+ });
409
+
410
+ it("drops the lane entirely once its last point is removed", () => {
411
+ const onCommit = vi.fn();
412
+ const single: HfAutomation = {
413
+ version: 1,
414
+ lanes: [{ target: "volume", points: [{ t: 1, v: 0.5 }] }],
415
+ };
416
+ const { container } = render(
417
+ <TimelineAutomationLane {...laneProps({ automation: single, onCommit })} />,
418
+ );
419
+ fire(container.querySelector("circle")!, "contextmenu");
420
+ expect(onCommit.mock.calls[0][0].lanes).toEqual([]);
421
+ });
422
+
423
+ it("writes nothing when read-only, and lets the press through to select", () => {
424
+ const onCommit = vi.fn();
425
+ const onPreview = vi.fn();
426
+ const { container } = render(
427
+ <TimelineAutomationLane
428
+ {...laneProps({ automation: ramp, onCommit, onPreview, readOnly: true })}
429
+ />,
430
+ );
431
+ const svg = container.querySelector("svg")!;
432
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
433
+ fire(svg, "dblclick", { clientX: 200, clientY: 6 });
434
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
435
+ fire(svg, "pointermove", { clientX: 100, clientY: 42 });
436
+ fire(container.querySelectorAll("circle")[0]!, "contextmenu");
437
+ expect(onCommit).not.toHaveBeenCalled();
438
+ expect(onPreview).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it("marks the points a range has caught, by the same rule Delete uses", () => {
442
+ // Feedback the tinted rectangle cannot give: which POINTS are in the range. The
443
+ // rule has to match the delete path exactly, edges included, or the highlight
444
+ // promises something different from what the key does.
445
+ const dense: HfAutomation = {
446
+ version: 1,
447
+ lanes: [
448
+ {
449
+ target: "volume",
450
+ points: [
451
+ { t: 0, v: 1 },
452
+ { t: 1, v: 0.8 },
453
+ { t: 2, v: 0.5 },
454
+ { t: 3, v: 0.2 },
455
+ { t: 4, v: 0 },
456
+ ],
457
+ },
458
+ ],
459
+ };
460
+ const { container } = render(
461
+ <TimelineAutomationLane
462
+ {...laneProps({ automation: dense, rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } })}
463
+ />,
464
+ );
465
+ const marked = Array.from(container.querySelectorAll("[data-automation-point-in-range]"));
466
+ expect(marked).toHaveLength(3); // t=1 and t=3 sit ON the edges and count
467
+ const radii = marked.map((c) => Number(c.getAttribute("r")));
468
+ const plain = Array.from(container.querySelectorAll("[data-automation-point]"))
469
+ .filter((c) => !c.hasAttribute("data-automation-point-in-range"))
470
+ .map((c) => Number(c.getAttribute("r")));
471
+ // Bigger than the points around them, and ringed rather than recoloured — the
472
+ // fill is the parameter's own colour and stays that way.
473
+ expect(Math.min(...radii)).toBeGreaterThan(Math.max(...plain));
474
+ expect(marked[0]?.getAttribute("stroke")).toBe("#fff");
475
+ });
476
+
477
+ it("marks nothing when there is no range", () => {
478
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
479
+ expect(container.querySelectorAll("[data-automation-point-in-range]")).toHaveLength(0);
480
+ });
481
+
482
+ it("draws a range on the very first drag over a read-only lane", () => {
483
+ // The papercut: the press that selects the clip used to be spent entirely on
484
+ // selecting, so the first drag over a lane did nothing visible and a range took
485
+ // two gestures. One gesture now selects AND draws the range — the selection is
486
+ // ephemeral store state, and nothing can be written through it while the lane
487
+ // is still read-only.
488
+ const onSelect = vi.fn();
489
+ const onRangeSelect = vi.fn();
490
+ const { svg, props } = mount(ramp, { readOnly: true, onSelect, onRangeSelect });
491
+ fire(svg, "pointerdown", at(1, 0.5));
492
+ fire(svg, "pointermove", at(2, 0.5));
493
+ fire(svg, "pointermove", at(3, 0.5));
494
+ fire(svg, "pointerup", at(3, 0.5));
495
+
496
+ expect(onSelect).toHaveBeenCalledTimes(1);
497
+ // A flat drag draws a flat box: same value at both corners, since the pointer
498
+ // never left that height.
499
+ expect(onRangeSelect).toHaveBeenLastCalledWith(
500
+ expect.closeTo(1, 1),
501
+ expect.closeTo(3, 1),
502
+ expect.closeTo(0.5, 1),
503
+ expect.closeTo(0.5, 1),
504
+ );
505
+ // Still read-only: selecting is a separate thing from editing.
506
+ expect(props.onCommit).not.toHaveBeenCalled();
507
+ expect(props.onPreview).not.toHaveBeenCalled();
508
+ });
509
+
510
+ it("selects the clip when pressed read-only, the only route to editing it", () => {
511
+ // The lane sits below the clip bar, so the timeline's own selection handler
512
+ // never sees this press. Without selecting here the lane could never be
513
+ // made editable at all.
514
+ const onSelect = vi.fn();
515
+ const { container } = render(
516
+ <TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true, onSelect })} />,
517
+ );
518
+ const svg = container.querySelector("svg")!;
519
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
520
+ fire(svg, "pointerdown", { clientX: 40, clientY: 24 });
521
+ expect(onSelect).toHaveBeenCalledTimes(1);
522
+ });
523
+
524
+ it("owns a press it cannot act on too, so the timeline does not scrub under it", () => {
525
+ const { container, ancestor } = renderNested(
526
+ <TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
527
+ );
528
+ const svg = container.querySelector("svg")!;
529
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
530
+ let reachedAncestor = false;
531
+ ancestor.addEventListener("pointerdown", () => {
532
+ reachedAncestor = true;
533
+ });
534
+ fire(svg, "pointerdown", { clientX: 40, clientY: 24 });
535
+ expect(reachedAncestor).toBe(false);
536
+ });
537
+
538
+ it("owns the press once live, so a double-click is not eaten by the timeline", () => {
539
+ const { container, ancestor } = renderNested(
540
+ <TimelineAutomationLane {...laneProps({ automation: ramp })} />,
541
+ );
542
+ const svg = container.querySelector("svg")!;
543
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
544
+ let reachedAncestor = false;
545
+ ancestor.addEventListener("pointerdown", () => {
546
+ reachedAncestor = true;
547
+ });
548
+ fire(svg, "pointerdown", { clientX: 200, clientY: 24 });
549
+ expect(reachedAncestor).toBe(false);
550
+ });
551
+
552
+ it("maps a log-read knob so its geometric middle sits mid-lane", () => {
553
+ const sweep: HfAutomation = {
554
+ version: 1,
555
+ lanes: [
556
+ {
557
+ target: "fx.n1.frequency",
558
+ points: [
559
+ { t: 0, v: 100 },
560
+ { t: 4, v: 20000 },
561
+ ],
562
+ },
563
+ ],
564
+ };
565
+ const { container } = render(
566
+ <TimelineAutomationLane {...laneProps({ automation: sweep, target: "fx.n1.frequency" })} />,
567
+ );
568
+ const circles = container.querySelectorAll("circle");
569
+ // 100 Hz is the range floor and 20 kHz its ceiling, so the two points sit at
570
+ // the lane's bottom and top.
571
+ const ys = Array.from(circles).map((c) => Number(c.getAttribute("cy")));
572
+ expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30);
573
+ });
574
+ });
575
+
576
+ /** The lane's own box, so pointer coordinates map to clip time and value. */
577
+ const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H };
578
+
579
+ /** x for a clip time, y for a 0..1 unit height, in client coordinates. The
580
+ * 6px inset and the height have to match the lane's own, or a point sits
581
+ * outside the grab radius and a press silently does nothing. */
582
+ const at = (t: number, unit: number) => ({
583
+ clientX: BOX.left + PAD + (t / 4) * 400,
584
+ clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12),
585
+ });
586
+
587
+ const mount = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
588
+ const base = laneProps({ automation, ...over });
589
+ // Narrowed once here: laneProps types these as the prop signature, and every
590
+ // assertion below reads the calls the lane made.
591
+ const props = {
592
+ ...base,
593
+ onPreview: base.onPreview as ReturnType<typeof vi.fn>,
594
+ onCommit: base.onCommit as ReturnType<typeof vi.fn>,
595
+ };
596
+ const { container } = render(<TimelineAutomationLane {...props} />);
597
+ const svg = container.querySelector("svg")!;
598
+ stubBox(svg, BOX);
599
+ return { container, svg, props };
600
+ };
601
+
602
+ describe("TimelineAutomationLane point visibility", () => {
603
+ const opacityOf = (container: HTMLElement, i = 0): string => {
604
+ const circle = container.querySelectorAll<SVGCircleElement>("[data-automation-point]")[i]!;
605
+ return circle.style.opacity;
606
+ };
607
+ const svgOf = (container: HTMLElement): SVGSVGElement =>
608
+ container.querySelector<SVGSVGElement>(".hf-automation-svg")!;
609
+ // React synthesises onPointerEnter/Leave from pointerover/pointerout, so those
610
+ // are the events a hover has to be driven with.
611
+ const enter = (el: Element) => fire(el, "pointerover");
612
+ const leave = (el: Element) => fire(el, "pointerout");
613
+
614
+ it("keeps its breakpoints out of sight until the lane is pointed at", () => {
615
+ // A stack of lanes across a long clip is hundreds of discs; at rest the shape
616
+ // of each envelope is what matters, and the handles are only wanted by
617
+ // someone about to grab one.
618
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
619
+ expect(opacityOf(container)).toBe("0");
620
+ // The line itself is not hidden — the envelope still reads at rest.
621
+ expect(container.querySelector<SVGPathElement>("path")?.style.opacity).not.toBe("0");
622
+ });
623
+
624
+ it("shows them on hover and hides them again on leave", () => {
625
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
626
+ enter(svgOf(container));
627
+ expect(opacityOf(container)).toBe("1");
628
+ leave(svgOf(container));
629
+ expect(opacityOf(container)).toBe("0");
630
+ });
631
+
632
+ it("keeps a selected range's points visible with the pointer away", () => {
633
+ // The range is the subject of a pending Delete, and which points it caught is
634
+ // the thing the author is checking — it cannot depend on where the mouse is.
635
+ const { container } = render(
636
+ <TimelineAutomationLane
637
+ {...laneProps({ automation: ramp, rangeSelection: { t0: 0, t1: 4, v0: 0, v1: 1 } })}
638
+ />,
639
+ );
640
+ expect(opacityOf(container)).toBe("1");
641
+ });
642
+
643
+ it("keeps them visible through a drag that leaves the lane", () => {
644
+ // Dragging a point past the lane's edge fires pointerleave; losing the handle
645
+ // mid-gesture would be the worst moment for it to disappear.
646
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
647
+ const svg = svgOf(container);
648
+ // happy-dom boxes are zero-sized, and the lane maps a pointer through its own
649
+ // box to find the point under it.
650
+ stubBox(svg, { left: 0, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H });
651
+ enter(svg);
652
+ // The first point of `ramp` is t=0 at full value: the lane's left edge, top.
653
+ fire(svg, "pointerdown", { clientX: PAD, clientY: 6, buttons: 1 });
654
+ fire(svg, "pointermove", { clientX: PAD + 40, clientY: 30, buttons: 1 });
655
+ leave(svg);
656
+ expect(opacityOf(container)).toBe("1");
657
+ });
658
+ });
659
+
660
+ /** `mount`, plus the re-render a real store update causes — the persisted
661
+ * automation and the new selection coming back down as props. */
662
+ const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
663
+ const base = laneProps({ automation, ...over });
664
+ const props = {
665
+ ...base,
666
+ onPreview: base.onPreview as ReturnType<typeof vi.fn>,
667
+ onCommit: base.onCommit as ReturnType<typeof vi.fn>,
668
+ };
669
+ const { container, rerender } = renderRerenderable(<TimelineAutomationLane {...props} />);
670
+ const svg = container.querySelector("svg")!;
671
+ stubBox(svg, BOX);
672
+ return {
673
+ container,
674
+ svg,
675
+ props,
676
+ rerender: (next: Record<string, unknown>) =>
677
+ rerender(<TimelineAutomationLane {...props} {...next} />),
678
+ };
679
+ };
680
+
681
+ describe("TimelineAutomationLane modifiers", () => {
682
+ it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
683
+ // The shape is honoured everywhere it is read — drawn, sampled in preview,
684
+ // baked into the render — and this is the gesture that sets it. What it
685
+ // writes is the via point: where the pointer took the line.
686
+ const { svg, props } = mount(ramp);
687
+ fire(svg, "pointerdown", { ...at(2, 0.5), altKey: true });
688
+ fire(svg, "pointermove", { ...at(2, 0.85), altKey: true });
689
+ const previewed = props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
690
+ const points = previewed?.lanes[0]?.points ?? [];
691
+ expect(points[0]?.viaX).toBeCloseTo(0.5, 2);
692
+ expect(points[0]?.viaY).toBeCloseTo(0.15, 2); // dragged to 0.85 on a falling ramp
693
+ // The breakpoints themselves are untouched: only the shape between them moved.
694
+ expect(points.map((p) => [p.t, p.v])).toEqual([
695
+ [0, 1],
696
+ [4, 0],
697
+ ]);
698
+ });
699
+
700
+ it("bends toward the pointer's own end of the segment", () => {
701
+ // The complaint this model replaced: one exponent always bulged the line near
702
+ // the left-hand breakpoint, wherever it was grabbed. Now grabbing near either
703
+ // end puts the bend there, which is visible in the via point it writes.
704
+ const nearB = mount(ramp);
705
+ fire(nearB.svg, "pointerdown", { ...at(3.4, 0.5), altKey: true });
706
+ fire(nearB.svg, "pointermove", { ...at(3.4, 0.4), altKey: true });
707
+ const right = (nearB.props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
708
+ ?.lanes[0]?.points[0];
709
+ expect(right?.viaX).toBeCloseTo(0.85, 2);
710
+
711
+ const nearA = mount(ramp);
712
+ fire(nearA.svg, "pointerdown", { ...at(0.6, 0.5), altKey: true });
713
+ fire(nearA.svg, "pointermove", { ...at(0.6, 0.6), altKey: true });
714
+ const left = (nearA.props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
715
+ ?.lanes[0]?.points[0];
716
+ expect(left?.viaX).toBeCloseTo(0.15, 2);
717
+ });
718
+
719
+ it("deletes a point on Shift+click", () => {
720
+ // Right-click already removes one, but it is not a gesture a trackpad reaches
721
+ // for. Shift+click is the second route.
722
+ const { svg, props } = mount(ramp);
723
+ fire(svg, "pointerdown", { ...at(0, 1), shiftKey: true });
724
+ fire(svg, "pointerup", { ...at(0, 1), shiftKey: true });
725
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
726
+ expect(written.lanes[0]?.points.map((p) => p.t)).toEqual([4]);
727
+ });
728
+
729
+ it("still locks the axis when Shift is held through a drag", () => {
730
+ // Decided on release, not on the press: acting on the press would take the
731
+ // Shift axis-lock away, since both gestures start identically.
732
+ const { svg, props } = mount(ramp);
733
+ fire(svg, "pointerdown", { ...at(0, 1), shiftKey: true });
734
+ fire(svg, "pointermove", { ...at(1.5, 0.4), shiftKey: true });
735
+ fire(svg, "pointerup", { ...at(1.5, 0.4), shiftKey: true });
736
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
737
+ // Both points still there — a drag is a drag, however it ends.
738
+ expect(written.lanes[0]?.points).toHaveLength(2);
739
+ });
740
+
741
+ it("leaves a point alone on a plain click", () => {
742
+ const { svg, props } = mount(ramp);
743
+ fire(svg, "pointerdown", at(0, 1));
744
+ fire(svg, "pointerup", at(0, 1));
745
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
746
+ expect(written.lanes[0]?.points).toHaveLength(2);
747
+ });
748
+
749
+ it("closes the value field when the lane is pressed, applying what was typed", () => {
750
+ // The field commits on blur, but the lane calls preventDefault on its own
751
+ // pointerdown to keep the timeline from scrubbing — and that suppresses the focus
752
+ // change the blur would have come from, so the field sat open until Enter however
753
+ // far away the next click landed.
754
+ const { svg, container, props } = mount(ramp);
755
+ fire(svg, "dblclick", at(0, 1));
756
+ const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
757
+ expect(input).not.toBeNull();
758
+ act(() => {
759
+ if (!input) return;
760
+ // React tracks the value it set, so assigning `.value` directly is ignored;
761
+ // going through the native setter is what makes onChange fire.
762
+ const setter = Object.getOwnPropertyDescriptor(
763
+ window.HTMLInputElement.prototype,
764
+ "value",
765
+ )?.set;
766
+ setter?.call(input, "0.4");
767
+ input.dispatchEvent(new Event("input", { bubbles: true }));
768
+ });
769
+
770
+ fire(svg, "pointerdown", at(2, 0.5));
771
+ expect(container.querySelector(".hf-automation-value")).toBeNull();
772
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
773
+ expect(written.lanes[0]?.points[0]?.v).toBeCloseTo(0.4, 5);
774
+ });
775
+
776
+ it("straightens a segment on Alt-double-click", () => {
777
+ const curved: HfAutomation = {
778
+ version: 1,
779
+ lanes: [
780
+ {
781
+ target: "volume",
782
+ points: [
783
+ { t: 0, v: 1, curve: 0.6 },
784
+ { t: 4, v: 0 },
785
+ ],
786
+ },
787
+ ],
788
+ };
789
+ const { svg, props } = mount(curved);
790
+ fire(svg, "dblclick", { ...at(2, 0.5), altKey: true });
791
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
792
+ expect(committed?.lanes[0]?.points[0]?.curve).toBeUndefined();
793
+ });
794
+
795
+ it("locks a Shift-drag to one axis", () => {
796
+ const { svg, props } = mount(ramp);
797
+ // Grab the point at t=0, v=1 (top left) and pull mostly sideways.
798
+ fire(svg, "pointerdown", at(0, 1));
799
+ fire(svg, "pointermove", { ...at(2, 0.9), shiftKey: true });
800
+ const points =
801
+ (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
802
+ const moved = points.find((p) => p.t > 0.5);
803
+ expect(moved).toBeDefined();
804
+ // Value held at exactly where the drag started, despite the vertical travel.
805
+ expect(moved?.v).toBe(1);
806
+ });
807
+
808
+ /** Four points, so the middle two have a neighbour on either side. */
809
+ const four: HfAutomation = {
810
+ version: 1,
811
+ lanes: [
812
+ {
813
+ target: "volume",
814
+ points: [
815
+ { t: 0, v: 1 },
816
+ { t: 1, v: 0.8 },
817
+ { t: 2, v: 0.6 },
818
+ { t: 3.5, v: 0.2 },
819
+ ],
820
+ },
821
+ ],
822
+ };
823
+ const draggedTimes = (props: { onPreview: ReturnType<typeof vi.fn> }) => {
824
+ const points = props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as
825
+ | { t: number }[]
826
+ | undefined;
827
+ return (points ?? []).map((p) => Number(p.t.toFixed(2)));
828
+ };
829
+
830
+ it("stops a dragged point at its neighbour rather than letting it cross", () => {
831
+ // Order is the lane's contract: an envelope is a sequence, and a point that
832
+ // swaps places with its neighbour mid-drag pulls the shape inside out under
833
+ // the pointer. Reaching it is allowed — two points at one instant is a step.
834
+ const { svg, props } = mount(four);
835
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
836
+ // Aimed a whole second past the point at t=2.
837
+ fire(svg, "pointermove", { ...at(3, 0.8), buttons: 1, altKey: true });
838
+ expect(draggedTimes(props)).toEqual([0, 2, 2, 3.5]);
839
+ });
840
+
841
+ it("keeps the neighbour it was dragged into, through a normalising round trip", () => {
842
+ // The bug this pins: the lane collapses points that share a `t`, keeping the
843
+ // later one — so a point dragged fully onto its neighbour deleted that
844
+ // neighbour. Stopping a millisecond short is what keeps both, and the check has
845
+ // to run through normalisation because that is where the loss happened, not in
846
+ // the drag.
847
+ const { svg, props } = mount(four);
848
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
849
+ fire(svg, "pointermove", { ...at(3, 0.8), buttons: 1, altKey: true });
850
+ fire(svg, "pointerup", { ...at(3, 0.8), buttons: 0, altKey: true });
851
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
852
+ const normalized = normalizeAutomation(committed!);
853
+ const times = normalized.lanes[0]!.points.map((p) => p.t);
854
+ expect(times).toHaveLength(4);
855
+ // It arrived just under its neighbour rather than on it.
856
+ expect(times[1]).toBeLessThan(times[2]!);
857
+ expect(times[2]! - times[1]!).toBeCloseTo(0.001, 4);
858
+ });
859
+
860
+ it("stops it at the neighbour on the way back too", () => {
861
+ const { svg, props } = mount(four);
862
+ fire(svg, "pointerdown", { ...at(2, 0.6), buttons: 1 });
863
+ fire(svg, "pointermove", { ...at(-1, 0.6), buttons: 1, altKey: true });
864
+ expect(draggedTimes(props)).toEqual([0, 1, 1, 3.5]);
865
+ });
866
+
867
+ it("snaps a dragged point to the beat grid", () => {
868
+ // By eye, "on the beat" and "20 ms off the beat" look identical.
869
+ const { svg, props } = mount(ramp, { snapTimes: [2] });
870
+ fire(svg, "pointerdown", at(0, 1));
871
+ fire(svg, "pointermove", at(2.02, 1));
872
+ const points =
873
+ (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
874
+ expect(points.find((p) => p.t > 0.5)?.t).toBe(2);
875
+ });
876
+
877
+ it("ignores the grid while Alt is held", () => {
878
+ const { svg, props } = mount(ramp, { snapTimes: [2] });
879
+ fire(svg, "pointerdown", at(0, 1));
880
+ fire(svg, "pointermove", { ...at(2.02, 1), altKey: true });
881
+ const points =
882
+ (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
883
+ expect(points.find((p) => p.t > 0.5)?.t).toBeCloseTo(2.02, 2);
884
+ });
885
+
886
+ it("takes a second gesture in the same lane, not just the first", () => {
887
+ // The panel had exactly this bug: one edit worked and every later one was
888
+ // swallowed, because the live write skips the resync the next edit reads.
889
+ const { svg, props } = mount(ramp);
890
+ fire(svg, "pointerdown", at(0, 1));
891
+ fire(svg, "pointermove", at(1, 0.8));
892
+ fire(svg, "pointerup", at(1, 0.8));
893
+ fire(svg, "pointerdown", at(4, 0));
894
+ fire(svg, "pointermove", at(3, 0.4));
895
+ const points =
896
+ (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
897
+ // Both ends moved: the first gesture's point is off t=0, the second's off t=4.
898
+ expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 3]);
899
+ });
900
+
901
+ it("types an exact value into a point", () => {
902
+ // -6.0 dB is not a pixel you can find by dragging.
903
+ const { container, svg, props } = mount(ramp);
904
+ fire(svg, "dblclick", at(0, 1));
905
+ const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
906
+ expect(input).not.toBeNull();
907
+ act(() => {
908
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
909
+ input,
910
+ "0.25",
911
+ );
912
+ input?.dispatchEvent(new Event("input", { bubbles: true }));
913
+ });
914
+ fire(input!, "keydown", {});
915
+ const key = new Event("keydown", { bubbles: true, cancelable: true });
916
+ Object.assign(key, { key: "Enter" });
917
+ act(() => {
918
+ input?.dispatchEvent(key);
919
+ });
920
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
921
+ expect(committed?.lanes[0]?.points[0]?.v).toBe(0.25);
922
+ });
923
+
924
+ it("clamps a typed value to the parameter's range", () => {
925
+ const { container, svg, props } = mount(ramp);
926
+ fire(svg, "dblclick", at(0, 1));
927
+ const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
928
+ act(() => {
929
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99");
930
+ input?.dispatchEvent(new Event("input", { bubbles: true }));
931
+ });
932
+ // React listens for focusout, not blur — blur does not bubble.
933
+ act(() => {
934
+ input?.dispatchEvent(new Event("focusout", { bubbles: true }));
935
+ });
936
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
937
+ expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max);
938
+ });
939
+ });
940
+
941
+ /** Two peaks with a dip between them, so a box can take the peaks alone. */
942
+ const stacked: HfAutomation = {
943
+ version: 1,
944
+ lanes: [
945
+ {
946
+ target: "volume",
947
+ points: [
948
+ { t: 1, v: 0.9 },
949
+ { t: 2, v: 0.1 },
950
+ { t: 3, v: 0.85 },
951
+ ],
952
+ },
953
+ ],
954
+ };
955
+
956
+ describe("TimelineAutomationLane selection box", () => {
957
+ it("drag on the background selects a range, snapped to the grid", () => {
958
+ const onRangeSelect = vi.fn();
959
+ const { svg } = mount(ramp, { snapTimes: [1], onRangeSelect });
960
+ fire(svg, "pointerdown", at(0.98, 0.5)); // background: no point within grab radius
961
+ fire(svg, "pointermove", at(3, 0.5));
962
+ fire(svg, "pointerup", at(3, 0.5));
963
+ const last = onRangeSelect.mock.calls.at(-1);
964
+ expect(last?.[0]).toBe(1); // snapped to the beat
965
+ expect(last?.[1]).toBeCloseTo(3, 1);
966
+ });
967
+
968
+ it("a sub-threshold click clears instead of selecting", () => {
969
+ const onRangeSelect = vi.fn();
970
+ const onRangeClear = vi.fn();
971
+ const { svg } = mount(ramp, { onRangeSelect, onRangeClear });
972
+ fire(svg, "pointerdown", at(1, 0.5));
973
+ fire(svg, "pointerup", at(1.001, 0.5));
974
+ expect(onRangeSelect).not.toHaveBeenCalled();
975
+ expect(onRangeClear).toHaveBeenCalled();
976
+ });
977
+
978
+ it("reports both axes of the box a diagonal drag draws", () => {
979
+ // The gesture the whole feature is: a box round some points, not a span of
980
+ // time. Ordered on the way out, so dragging up-and-left is the same box as
981
+ // dragging down-and-right.
982
+ const onRangeSelect = vi.fn();
983
+ const { svg } = mount(ramp, { onRangeSelect });
984
+ fire(svg, "pointerdown", at(3, 0.8));
985
+ fire(svg, "pointermove", at(1, 0.2));
986
+ const last = onRangeSelect.mock.calls.at(-1);
987
+ expect(last?.[0]).toBeCloseTo(1, 1);
988
+ expect(last?.[1]).toBeCloseTo(3, 1);
989
+ expect(last?.[2]).toBeCloseTo(0.2, 1);
990
+ expect(last?.[3]).toBeCloseTo(0.8, 1);
991
+ });
992
+
993
+ it("a purely vertical drag still crosses the threshold", () => {
994
+ // The threshold is what tells a box from a click that should clear one, and it
995
+ // has to watch both axes: measured on time alone, a straight-down drag over a
996
+ // dense passage read as a click and cleared the selection instead of drawing.
997
+ const onRangeSelect = vi.fn();
998
+ const onRangeClear = vi.fn();
999
+ const { svg } = mount(ramp, { onRangeSelect, onRangeClear });
1000
+ fire(svg, "pointerdown", at(2, 0.9));
1001
+ fire(svg, "pointermove", at(2, 0.1));
1002
+ fire(svg, "pointerup", at(2, 0.1));
1003
+ expect(onRangeSelect).toHaveBeenCalled();
1004
+ expect(onRangeClear).not.toHaveBeenCalled();
1005
+ });
1006
+
1007
+ it("catches the points inside the box and not one at the same time outside it", () => {
1008
+ // What a box is FOR, and the one thing a time range could never do: pick the
1009
+ // top of an envelope out of a passage without taking the bottom with it.
1010
+ const { container } = mount(stacked, {
1011
+ rangeSelection: { t0: 0.5, t1: 3.5, v0: 0.6, v1: 1 },
1012
+ });
1013
+ const caught = Array.from(container.querySelectorAll("[data-automation-point-in-range]")).map(
1014
+ (el) => el.getAttribute("data-automation-point"),
1015
+ );
1016
+ expect(caught).toEqual(["0", "2"]); // the dip at t=2, v=0.1 is below the box
1017
+ });
1018
+
1019
+ it("draws the rect bounded on the value axis, not down the whole lane", () => {
1020
+ const { container } = mount(ramp, {
1021
+ rangeSelection: { t0: 1, t1: 3, v0: 0.25, v1: 0.75 },
1022
+ });
1023
+ const rect = container.querySelector("[data-automation-selection]");
1024
+ const inner = AUTOMATION_LANE_H - 12;
1025
+ // v1 is the upper bound, so it is the smaller y.
1026
+ expect(Number(rect?.getAttribute("y"))).toBeCloseTo(6 + 0.25 * inner, 0);
1027
+ expect(Number(rect?.getAttribute("height"))).toBeCloseTo(0.5 * inner, 0);
1028
+ });
1029
+
1030
+ it("draws the selection rect between its endpoints", () => {
1031
+ const { container } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1032
+ const rect = container.querySelector("[data-automation-selection]");
1033
+ expect(rect).not.toBeNull();
1034
+ expect(Number(rect?.getAttribute("x"))).toBeCloseTo(PAD + 100, 0); // xOf(1) at 400px/4s
1035
+ expect(Number(rect?.getAttribute("width"))).toBeCloseTo(200, 0);
1036
+ });
1037
+
1038
+ it("point drags still win over range selection", () => {
1039
+ const onRangeSelect = vi.fn();
1040
+ const { svg, props } = mount(ramp, { onRangeSelect });
1041
+ fire(svg, "pointerdown", at(0, 1)); // exactly on a point
1042
+ fire(svg, "pointermove", at(1, 0.8));
1043
+ fire(svg, "pointerup", at(1, 0.8));
1044
+ expect(onRangeSelect).not.toHaveBeenCalled();
1045
+ expect(props.onCommit).toHaveBeenCalled();
1046
+ });
1047
+ });
1048
+
1049
+ describe("TimelineAutomationLane group drag", () => {
1050
+ /** Four points; the middle two sit inside the range the tests select. */
1051
+ const four: HfAutomation = {
1052
+ version: 1,
1053
+ lanes: [
1054
+ {
1055
+ target: "volume",
1056
+ points: [
1057
+ { t: 0, v: 1 },
1058
+ { t: 1, v: 0.8 },
1059
+ { t: 2, v: 0.6 },
1060
+ { t: 3.5, v: 0.2 },
1061
+ ],
1062
+ },
1063
+ ],
1064
+ };
1065
+ const pointsOf = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
1066
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
1067
+
1068
+ it("moves every selected point by the same distance", () => {
1069
+ // Dragging one of a selected set has to move the set: nudging a carve's
1070
+ // envelope a beat later, or lifting a passage, is one gesture on a selection,
1071
+ // not one drag per point.
1072
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1073
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1074
+ // A whole second later and a fifth of the lane's height lower, with Alt so the
1075
+ // grid does not round the deltas out from under the assertion.
1076
+ fire(svg, "pointermove", { ...at(2, 0.6), buttons: 1, altKey: true });
1077
+ const points = pointsOf(props);
1078
+ expect(points[1]!.t).toBeCloseTo(2, 2);
1079
+ expect(points[2]!.t).toBeCloseTo(3, 2);
1080
+ expect(points[1]!.v).toBeCloseTo(0.6, 2);
1081
+ expect(points[2]!.v).toBeCloseTo(0.4, 2);
1082
+ });
1083
+
1084
+ it("leaves points outside the selection where they are", () => {
1085
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1086
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1087
+ fire(svg, "pointermove", { ...at(1.5, 0.8), buttons: 1, altKey: true });
1088
+ const points = pointsOf(props);
1089
+ expect(points[0]).toEqual({ t: 0, v: 1 });
1090
+ expect(points.at(-1)).toEqual({ t: 3.5, v: 0.2 });
1091
+ });
1092
+
1093
+ it("moves only the one under the pointer when it is not in the selection", () => {
1094
+ // The selection is elsewhere, so this is an ordinary single-point drag.
1095
+ const { svg, props } = mount(four, { rangeSelection: { t0: 3, t1: 3.6, v0: 0, v1: 1 } });
1096
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1097
+ fire(svg, "pointermove", { ...at(1.5, 0.5), buttons: 1, altKey: true });
1098
+ const points = pointsOf(props);
1099
+ expect(points[1]!.t).toBeCloseTo(1.5, 2);
1100
+ expect(points[2]).toEqual({ t: 2, v: 0.6 });
1101
+ expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
1102
+ });
1103
+
1104
+ it("stops the whole group at the lane's edge rather than piling points up", () => {
1105
+ // Clamping each point on its own would squash the shape flat against the
1106
+ // boundary; the group has to stop when its first member reaches it.
1107
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1108
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1109
+ fire(svg, "pointermove", { ...at(-4, 0.8), buttons: 1, altKey: true });
1110
+ const points = pointsOf(props);
1111
+ // The group stopped with its earliest member on zero, a second apart as it
1112
+ // began — and the unselected point that was already at zero is still there,
1113
+ // which is why the moved pair are the middle two after the sort.
1114
+ expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([0, 0, 1, 3.5]);
1115
+ });
1116
+
1117
+ it("clamps the group's values to the lane's own floor and ceiling", () => {
1118
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1119
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1120
+ fire(svg, "pointermove", { ...at(1, 4), buttons: 1, altKey: true });
1121
+ const points = pointsOf(props);
1122
+ expect(Math.max(...points.map((p) => p.v))).toBeLessThanOrEqual(1);
1123
+ // The gap between the two moved points survives the clamp.
1124
+ expect(points[1]!.v - points[2]!.v).toBeCloseTo(0.2, 2);
1125
+ });
1126
+
1127
+ it("carries the selection along, so the same points stay selected", () => {
1128
+ // Otherwise a second nudge is no longer a group drag, and the highlight sits
1129
+ // where the points used to be.
1130
+ const onRangeSelect = vi.fn();
1131
+ const { svg } = mount(four, {
1132
+ rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 },
1133
+ onRangeSelect,
1134
+ });
1135
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1136
+ fire(svg, "pointermove", { ...at(2, 0.8), buttons: 1, altKey: true });
1137
+ const last = onRangeSelect.mock.calls.at(-1);
1138
+ expect(last?.[0]).toBeCloseTo(1.9, 1);
1139
+ expect(last?.[1]).toBeCloseTo(3.1, 1);
1140
+ });
1141
+
1142
+ it("stops the group at a point it did not select", () => {
1143
+ // A box can catch a non-contiguous set — two peaks and not the dip between
1144
+ // them — so the constraint is per member, not just the ends of the group.
1145
+ // Here the two peaks each have an unselected neighbour a second ahead.
1146
+ const peaks: HfAutomation = {
1147
+ version: 1,
1148
+ lanes: [
1149
+ {
1150
+ target: "volume",
1151
+ points: [
1152
+ { t: 0, v: 1 },
1153
+ { t: 1, v: 0.2 },
1154
+ { t: 2, v: 0.9 },
1155
+ { t: 3, v: 0.5 },
1156
+ ],
1157
+ },
1158
+ ],
1159
+ };
1160
+ const { svg, props } = mount(peaks, {
1161
+ rangeSelection: { t0: 0, t1: 2, v0: 0.8, v1: 1 },
1162
+ });
1163
+ fire(svg, "pointerdown", { ...at(0, 1), buttons: 1 });
1164
+ // Aimed two seconds later; the dip at t=1 is a second away and stays put.
1165
+ fire(svg, "pointermove", { ...at(2, 1), buttons: 1, altKey: true });
1166
+ fire(svg, "pointerup", { ...at(2, 1), buttons: 0, altKey: true });
1167
+ const points = pointsOf(props);
1168
+ expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 1, 3, 3]);
1169
+ // And short of them, not onto them: a group drag must not consume what it runs
1170
+ // into either, which normalisation is where it would have shown.
1171
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
1172
+ const times = normalizeAutomation(committed!).lanes[0]!.points.map((p) => p.t);
1173
+ expect(times).toHaveLength(4);
1174
+ expect(times[0]).toBeLessThan(times[1]!);
1175
+ expect(times[2]).toBeLessThan(times[3]!);
1176
+ });
1177
+
1178
+ it("carries the box's value bounds along too", () => {
1179
+ // Both axes, or a vertical nudge slides its own points out of the box that
1180
+ // caught them and the second nudge moves fewer of them.
1181
+ const onRangeSelect = vi.fn();
1182
+ const { svg } = mount(four, {
1183
+ rangeSelection: { t0: 0.9, t1: 2.1, v0: 0.5, v1: 0.9 },
1184
+ onRangeSelect,
1185
+ });
1186
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1187
+ fire(svg, "pointermove", { ...at(1, 0.6), buttons: 1, altKey: true });
1188
+ const last = onRangeSelect.mock.calls.at(-1);
1189
+ expect(last?.[2]).toBeCloseTo(0.3, 1);
1190
+ expect(last?.[3]).toBeCloseTo(0.7, 1);
1191
+ });
1192
+ });
1193
+
1194
+ describe("TimelineAutomationLane shift axis lock", () => {
1195
+ const two: HfAutomation = {
1196
+ version: 1,
1197
+ lanes: [
1198
+ {
1199
+ target: "volume",
1200
+ points: [
1201
+ { t: 1, v: 0.5 },
1202
+ { t: 3, v: 0.5 },
1203
+ ],
1204
+ },
1205
+ ],
1206
+ };
1207
+ const lastPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
1208
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
1209
+
1210
+ it("holds the value when the pointer travels mostly sideways", () => {
1211
+ const { svg, props } = mount(two);
1212
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
1213
+ fire(svg, "pointermove", { ...at(2, 0.62), buttons: 1, shiftKey: true });
1214
+ const moved = lastPoints(props)[0]!;
1215
+ expect(moved.t).toBeCloseTo(2, 1);
1216
+ expect(moved.v).toBeCloseTo(0.5, 3);
1217
+ });
1218
+
1219
+ it("holds the time when the pointer travels mostly up or down", () => {
1220
+ const { svg, props } = mount(two);
1221
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
1222
+ fire(svg, "pointermove", { ...at(1.05, 0.95), buttons: 1, shiftKey: true });
1223
+ const moved = lastPoints(props).find((p) => p.v !== 0.5)!;
1224
+ expect(moved.t).toBeCloseTo(1, 3);
1225
+ expect(moved.v).toBeGreaterThan(0.5);
1226
+ });
1227
+
1228
+ it("keeps the axis it locked, rather than swapping as the pointer wobbles", () => {
1229
+ // The lock has to be decided once and held: recomputed per event, whichever
1230
+ // axis the last move happened to favour wins, so a hand that drifts up while
1231
+ // dragging sideways sees the point move in both — which is no lock at all.
1232
+ const { svg, props } = mount(two);
1233
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
1234
+ fire(svg, "pointermove", { ...at(1.6, 0.52), buttons: 1, shiftKey: true });
1235
+ // Now drift further vertically than the horizontal travel so far.
1236
+ fire(svg, "pointermove", { ...at(1.7, 0.95), buttons: 1, shiftKey: true });
1237
+ const moved = lastPoints(props)[0]!;
1238
+ expect(moved.v).toBeCloseTo(0.5, 3);
1239
+ expect(moved.t).toBeCloseTo(1.7, 1);
1240
+ });
1241
+
1242
+ it("holds a value lock through sideways drift too", () => {
1243
+ const { svg, props } = mount(two);
1244
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
1245
+ fire(svg, "pointermove", { ...at(1.02, 0.8), buttons: 1, shiftKey: true });
1246
+ fire(svg, "pointermove", { ...at(2.5, 0.85), buttons: 1, shiftKey: true });
1247
+ const moved = lastPoints(props).find((p) => p.v !== 0.5)!;
1248
+ expect(moved.t).toBeCloseTo(1, 3);
1249
+ });
1250
+
1251
+ it("locks an axis for a group drag too", () => {
1252
+ const four: HfAutomation = {
1253
+ version: 1,
1254
+ lanes: [
1255
+ {
1256
+ target: "volume",
1257
+ points: [
1258
+ { t: 0, v: 1 },
1259
+ { t: 1, v: 0.8 },
1260
+ { t: 2, v: 0.6 },
1261
+ { t: 3.5, v: 0.2 },
1262
+ ],
1263
+ },
1264
+ ],
1265
+ };
1266
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1267
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1268
+ fire(svg, "pointermove", { ...at(2, 0.9), buttons: 1, shiftKey: true });
1269
+ const points = lastPoints(props);
1270
+ // Sideways wins, so both selected points keep the values they started with.
1271
+ expect(points[1]!.v).toBeCloseTo(0.8, 3);
1272
+ expect(points[2]!.v).toBeCloseTo(0.6, 3);
1273
+ expect(points[1]!.t).toBeCloseTo(2, 1);
1274
+ });
1275
+ });
1276
+
1277
+ describe("TimelineAutomationLane gesture commits", () => {
1278
+ /**
1279
+ * One gesture, one persisting commit — which is one undo step.
1280
+ *
1281
+ * Every pointermove previews; only the release writes. Persisting each move put
1282
+ * fragments of one drag in the undo stack, and because those writes race, the
1283
+ * chain history needs to coalesce them was broken as often as not: undo took
1284
+ * back a few milliseconds of the gesture and read as doing nothing.
1285
+ */
1286
+ const four: HfAutomation = {
1287
+ version: 1,
1288
+ lanes: [
1289
+ {
1290
+ target: "volume",
1291
+ points: [
1292
+ { t: 0, v: 1 },
1293
+ { t: 1, v: 0.8 },
1294
+ { t: 2, v: 0.6 },
1295
+ { t: 3.5, v: 0.2 },
1296
+ ],
1297
+ },
1298
+ ],
1299
+ };
1300
+
1301
+ it("commits once for a single-point drag, however many moves it took", () => {
1302
+ const { svg, props } = mount(four);
1303
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1304
+ for (const t of [1.2, 1.4, 1.6, 1.8]) fire(svg, "pointermove", { ...at(t, 0.7), buttons: 1 });
1305
+ fire(svg, "pointerup", { ...at(1.8, 0.7), buttons: 0 });
1306
+ expect(props.onPreview.mock.calls.length).toBeGreaterThan(1);
1307
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
1308
+ });
1309
+
1310
+ it("commits once for a group drag", () => {
1311
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
1312
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
1313
+ for (const t of [1.2, 1.5, 1.8]) fire(svg, "pointermove", { ...at(t, 0.8), buttons: 1 });
1314
+ fire(svg, "pointerup", { ...at(1.8, 0.8), buttons: 0 });
1315
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
1316
+ // And what it commits is the moved set, not the shape it started from.
1317
+ const committed = props.onCommit.mock.calls[0]![0].lanes[0].points as { t: number }[];
1318
+ expect(committed[1]!.t).toBeGreaterThan(1);
1319
+ });
1320
+
1321
+ it("commits once for a bend", () => {
1322
+ const { svg, props } = mount(four);
1323
+ // Alt-drag the line between two points curves that segment.
1324
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1, altKey: true });
1325
+ for (const v of [0.6, 0.5, 0.4])
1326
+ fire(svg, "pointermove", { ...at(1.5, v), buttons: 1, altKey: true });
1327
+ fire(svg, "pointerup", { ...at(1.5, 0.4), buttons: 0, altKey: true });
1328
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
1329
+ });
1330
+ });
1331
+
1332
+ describe("TimelineAutomationLane selection menu", () => {
1333
+ it("right-click inside the selection opens the shape menu", () => {
1334
+ const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1335
+ fire(svg, "contextmenu", at(2, 0.5));
1336
+ expect(document.querySelector(".hf-automation-menu")).not.toBeNull();
1337
+ // The menu portals to document.body, outside `container` — dismiss it via
1338
+ // Escape before tearing down, or it leaks into the next test's DOM query.
1339
+ const escape = new Event("keydown", { bubbles: true, cancelable: true });
1340
+ Object.assign(escape, { key: "Escape" });
1341
+ act(() => {
1342
+ document.dispatchEvent(escape);
1343
+ });
1344
+ expect(document.querySelector(".hf-automation-menu")).toBeNull();
1345
+ act(() => container.remove());
1346
+ });
1347
+
1348
+ it("inserting a swell replaces the range and commits once", () => {
1349
+ const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1350
+ fire(svg, "contextmenu", at(2, 0.5));
1351
+ const swell = Array.from(
1352
+ document.querySelectorAll<HTMLButtonElement>(".hf-automation-menu button"),
1353
+ ).find((b) => b.textContent === "Swell");
1354
+ expect(swell).toBeTruthy();
1355
+ act(() => swell?.click());
1356
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
1357
+ const points =
1358
+ (props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
1359
+ expect(points.some((p) => p.t === 2 && p.v === 1)).toBe(true); // peak at range.max
1360
+ });
1361
+
1362
+ it("right-click outside the selection does not open it", () => {
1363
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1364
+ fire(svg, "contextmenu", at(3.8, 0.5));
1365
+ expect(document.querySelector(".hf-automation-menu")).toBeNull();
1366
+ });
1367
+ });
1368
+
1369
+ describe("TimelineAutomationLane stretch", () => {
1370
+ // Most edges here are off any existing point, which is the EASY case. The
1371
+ // normal state after any range operation is the opposite — delete, shape
1372
+ // insert and stretch all leave a breakpoint exactly on the edge they created
1373
+ // — so the priority test below is the one that decides whether the feature is
1374
+ // repeatable, not an edge case.
1375
+
1376
+ /** Press, drag and release the right edge of a stretchable selection — the
1377
+ * shape most of this block's tests share, differing only in where the
1378
+ * drag ends up. */
1379
+ function dragRightEdge(svg: Element, from: number, to: number): void {
1380
+ fire(svg, "pointerdown", at(from, 0.5));
1381
+ fire(svg, "pointermove", at(to, 0.5));
1382
+ fire(svg, "pointerup", at(to, 0.5));
1383
+ }
1384
+
1385
+ const stretchable: HfAutomation = {
1386
+ version: 1,
1387
+ lanes: [
1388
+ {
1389
+ target: "volume",
1390
+ points: [
1391
+ { t: 0, v: 1 },
1392
+ { t: 1, v: 0.5 },
1393
+ { t: 2, v: 0.8 },
1394
+ { t: 4, v: 0 },
1395
+ ],
1396
+ },
1397
+ ],
1398
+ };
1399
+
1400
+ it("dragging the right edge retimes the interior and persists on release", () => {
1401
+ const onRangeSelect = vi.fn();
1402
+ const { svg, props } = mount(stretchable, {
1403
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1404
+ onRangeSelect,
1405
+ });
1406
+ dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3
1407
+
1408
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
1409
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
1410
+ const points = written.lanes[0]?.points ?? [];
1411
+ // Interior points (t=1, t=2) scale by the new/old span ratio (2.8 / 2 = 1.4).
1412
+ expect(points.some((p) => Math.abs(p.t - 1.2) < 0.01 && p.v === 0.5)).toBe(true);
1413
+ expect(points.some((p) => Math.abs(p.t - 2.6) < 0.01 && p.v === 0.8)).toBe(true);
1414
+
1415
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
1416
+ });
1417
+
1418
+ it("moves the selection with the pointer instead of snapping it on release", () => {
1419
+ // The highlight and both edge lines render from the rangeSelection prop, so
1420
+ // a stretch that only reported its bounds on release dragged an invisible
1421
+ // handle: the rect stayed pinned at the pre-drag bounds for the whole
1422
+ // gesture. The marquee drag fires live for exactly this reason.
1423
+ const onRangeSelect = vi.fn();
1424
+ const { svg } = mount(stretchable, {
1425
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1426
+ onRangeSelect,
1427
+ });
1428
+ fire(svg, "pointerdown", at(2.5, 0.5));
1429
+ fire(svg, "pointermove", at(3, 0.5));
1430
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1), 0, 1);
1431
+ fire(svg, "pointermove", at(3.3, 0.5));
1432
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
1433
+ });
1434
+
1435
+ it("a bare click on an edge clears the selection instead of committing a no-op", () => {
1436
+ // Without a movement threshold this pushed an undo entry that changed
1437
+ // nothing, and — because the halo covers both edges — it also made the
1438
+ // "click the background to clear" escape unreachable near either one.
1439
+ const onRangeSelect = vi.fn();
1440
+ const onRangeClear = vi.fn();
1441
+ const { svg, props } = mount(stretchable, {
1442
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1443
+ onRangeSelect,
1444
+ onRangeClear,
1445
+ });
1446
+ fire(svg, "pointerdown", at(2.5, 0.5));
1447
+ fire(svg, "pointerup", at(2.5, 0.5));
1448
+ expect(props.onCommit).not.toHaveBeenCalled();
1449
+ expect(props.onPreview).not.toHaveBeenCalled();
1450
+ expect(onRangeSelect).not.toHaveBeenCalled();
1451
+ expect(onRangeClear).toHaveBeenCalledTimes(1);
1452
+ });
1453
+
1454
+ it("a press that jitters under the threshold still clears rather than retiming", () => {
1455
+ const onRangeClear = vi.fn();
1456
+ const { svg, props } = mount(stretchable, {
1457
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1458
+ onRangeClear,
1459
+ });
1460
+ fire(svg, "pointerdown", at(2.5, 0.5));
1461
+ // ~2px at 100 px/s: a hand resting on the button, not a drag.
1462
+ fire(svg, "pointermove", at(2.52, 0.5));
1463
+ fire(svg, "pointerup", at(2.52, 0.5));
1464
+ expect(props.onPreview).not.toHaveBeenCalled();
1465
+ expect(props.onCommit).not.toHaveBeenCalled();
1466
+ expect(onRangeClear).toHaveBeenCalledTimes(1);
1467
+ });
1468
+
1469
+ it("reverts an interrupted stretch rather than persisting the partial retime", () => {
1470
+ // pointercancel means the browser abandoned the gesture. Routing it to the
1471
+ // same handler as pointerup persisted whatever half-drag it had reached,
1472
+ // with no way back other than undo.
1473
+ const onRangeSelect = vi.fn();
1474
+ const { svg, props } = mount(stretchable, {
1475
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1476
+ onRangeSelect,
1477
+ });
1478
+ fire(svg, "pointerdown", at(2.5, 0.5));
1479
+ fire(svg, "pointermove", at(3.3, 0.5));
1480
+ fire(svg, "pointercancel", at(3.3, 0.5));
1481
+
1482
+ expect(props.onCommit).not.toHaveBeenCalled();
1483
+ // The envelope goes back through the preview channel — nothing to undo —
1484
+ // and the selection returns to the bounds the drag started from.
1485
+ const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
1486
+ ?.points;
1487
+ expect(reverted).toEqual(stretchable.lanes[0]?.points);
1488
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
1489
+ });
1490
+
1491
+ it("gives up a stretch whose pointer capture vanished without a cancel", () => {
1492
+ // A capture taken on a child that unmounts mid-drag is lost silently: no
1493
+ // pointercancel, no pointerup. Every later hover kept retiming and writing.
1494
+ const { svg, props } = mount(stretchable, {
1495
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1496
+ });
1497
+ fire(svg, "pointerdown", at(2.5, 0.5));
1498
+ fire(svg, "pointermove", { ...at(3.3, 0.5), buttons: 1 });
1499
+ fire(svg, "pointermove", { ...at(3.4, 0.5), buttons: 0 });
1500
+ const writes = props.onPreview.mock.calls.length;
1501
+
1502
+ fire(svg, "pointermove", { ...at(3.8, 0.5), buttons: 0 });
1503
+ fire(svg, "pointermove", { ...at(1, 0.5), buttons: 0 });
1504
+ expect(props.onPreview.mock.calls.length).toBe(writes);
1505
+ expect(props.onCommit).not.toHaveBeenCalled();
1506
+ });
1507
+
1508
+ it("previews the stretch on move without persisting, then commits once on release", () => {
1509
+ const onPreview = vi.fn();
1510
+ const onCommit = vi.fn();
1511
+ const { svg } = mount(stretchable, {
1512
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1513
+ onPreview,
1514
+ onCommit,
1515
+ });
1516
+ fire(svg, "pointerdown", at(2.5, 0.5));
1517
+ fire(svg, "pointermove", at(3, 0.5));
1518
+ fire(svg, "pointermove", at(3.3, 0.5));
1519
+ expect(onPreview).toHaveBeenCalledTimes(2);
1520
+ expect(onCommit).not.toHaveBeenCalled();
1521
+ fire(svg, "pointerup", at(3.3, 0.5));
1522
+ expect(onCommit).toHaveBeenCalledTimes(1);
1523
+ });
1524
+
1525
+ /** A breakpoint sitting exactly on the selection's right edge — the state every
1526
+ * range operation leaves behind, and the one a stretch has to be able to grab
1527
+ * a second time. */
1528
+ const pointOnEdge: HfAutomation = {
1529
+ version: 1,
1530
+ lanes: [
1531
+ {
1532
+ target: "volume",
1533
+ points: [
1534
+ { t: 0, v: 1 },
1535
+ { t: 1.5, v: 0.5 },
1536
+ { t: 2, v: 0.8 },
1537
+ { t: 4, v: 0 },
1538
+ ],
1539
+ },
1540
+ ],
1541
+ };
1542
+
1543
+ it("a point sitting exactly on the edge is selected content, not the edge", () => {
1544
+ // #3207's rule was the opposite — the edge won, so a repeated stretch of the
1545
+ // same edge (which pins a breakpoint there) always resolved to a point-drag.
1546
+ // A box selection changes the question: with value bounds as well as time,
1547
+ // a point on the edge is visibly INSIDE the box, and dragging selected
1548
+ // content has to move it or the box means nothing. So the press below drags
1549
+ // the group the box already caught — both points inside it — rather than
1550
+ // stretching the edge; the box travels with them.
1551
+ const onRangeSelect = vi.fn();
1552
+ const { svg, props } = mount(pointOnEdge, {
1553
+ rangeSelection: { t0: 1, t1: 2, v0: 0, v1: 1 },
1554
+ onRangeSelect,
1555
+ });
1556
+ fire(svg, "pointerdown", at(2, 0.8)); // the point at t=2, which is also the right edge
1557
+ fire(svg, "pointermove", at(3, 0.8));
1558
+ fire(svg, "pointerup", at(3, 0.8));
1559
+ expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
1560
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
1561
+ const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
1562
+ // Both points the box caught (t=1.5 and t=2) moved by the same +1 delta.
1563
+ expect(times.some((t) => Math.abs(t - 2.5) < 0.01)).toBe(true);
1564
+ expect(times.some((t) => Math.abs(t - 3) < 0.01)).toBe(true);
1565
+ });
1566
+
1567
+ it("leaves that point reachable once the selection is gone", () => {
1568
+ // The escape hatch the rule above depends on: no selection, no handle, so
1569
+ // the point is an ordinary point again.
1570
+ const onRangeSelect = vi.fn();
1571
+ const { svg, props } = mount(pointOnEdge, { rangeSelection: null, onRangeSelect });
1572
+ fire(svg, "pointerdown", at(2, 0.8));
1573
+ fire(svg, "pointermove", at(3, 0.8));
1574
+ fire(svg, "pointerup", at(3, 0.8));
1575
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
1576
+ const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
1577
+ expect(times).toContain(3);
1578
+ });
1579
+
1580
+ it("stretches the same edge twice in a row", () => {
1581
+ // The whole point of the priority rule, end to end: two stretches of the
1582
+ // right edge, the second grabbing the breakpoint the first one left there.
1583
+ const onRangeSelect = vi.fn();
1584
+ const { svg, props, rerender } = mountRerenderable(stretchable, {
1585
+ rangeSelection: { t0: 0.5, t1: 2, v0: 0, v1: 1 },
1586
+ onRangeSelect,
1587
+ });
1588
+ dragRightEdge(svg, 2, 2.6);
1589
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1), 0, 1);
1590
+ const afterFirst = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
1591
+ // A breakpoint landed on the new edge, which is what used to disarm it.
1592
+ expect((afterFirst.lanes[0]?.points ?? []).some((p) => Math.abs(p.t - 2.6) < 0.05)).toBe(true);
1593
+
1594
+ // The store comes back with the persisted envelope and the new selection.
1595
+ rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6, v0: 0, v1: 1 } });
1596
+ dragRightEdge(svg, 2.6, 3.4);
1597
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1), 0, 1);
1598
+ expect(props.onCommit).toHaveBeenCalledTimes(2);
1599
+ });
1600
+
1601
+ it("keeps the far edge grabbable on a selection narrower than its own halos", () => {
1602
+ // A selection this thin — a pasted short span, or any span at low zoom — has
1603
+ // both handles under one press, so which edge a press takes is decided
1604
+ // entirely by the midpoint split. Characterizing it here because nothing
1605
+ // else does: every other test in this block has halos far enough apart that
1606
+ // the rule never comes up.
1607
+ const onRangeSelect = vi.fn();
1608
+ const { svg } = mount(stretchable, {
1609
+ rangeSelection: { t0: 2, t1: 2.08, v0: 0, v1: 1 }, // 8px wide at 100 px/s: both halos overlap
1610
+ onRangeSelect,
1611
+ });
1612
+ fire(svg, "pointerdown", at(2.07, 0.5)); // nearer t1, inside t0's halo too
1613
+ fire(svg, "pointermove", at(3, 0.5));
1614
+ fire(svg, "pointerup", at(3, 0.5));
1615
+ // t0 stayed put and t1 moved out: the press resolved to the right edge.
1616
+ expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
1617
+ });
1618
+
1619
+ it("clamps the dragged edge so it cannot cross its partner", () => {
1620
+ const onRangeSelect = vi.fn();
1621
+ const { svg } = mount(stretchable, {
1622
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1623
+ onRangeSelect,
1624
+ });
1625
+ dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
1626
+ const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
1627
+ expect(t1).toBeGreaterThan(0.5);
1628
+ });
1629
+
1630
+ it("clamps the dragged edge to the lane's own duration", () => {
1631
+ const onRangeSelect = vi.fn();
1632
+ const { svg } = mount(stretchable, {
1633
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1634
+ onRangeSelect,
1635
+ });
1636
+ dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
1637
+ const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
1638
+ expect(t1).toBeLessThanOrEqual(4);
1639
+ });
1640
+
1641
+ it("retimes identically whether the right edge arrives in one move or several", () => {
1642
+ // moveEdge must always retime from the points snapshotted at arm time,
1643
+ // never from the live draft — retimeRange is a RELATIVE transform (it
1644
+ // scales the lane's OWN current point positions by newSpan/oldSpan), so
1645
+ // feeding it the live draft on every pointermove compounds the scale
1646
+ // factor instead of applying it once. A real drag fires dozens of moves;
1647
+ // this asserts the FINAL preview is identical regardless of how many.
1648
+ const onPreviewSingle = vi.fn();
1649
+ const single = mount(stretchable, {
1650
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1651
+ onPreview: onPreviewSingle,
1652
+ });
1653
+ fire(single.svg, "pointerdown", at(2.5, 0.5));
1654
+ fire(single.svg, "pointermove", at(3.3, 0.5));
1655
+ const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
1656
+ ?.lanes[0]?.points;
1657
+ expect(singleShot).toBeDefined();
1658
+
1659
+ const onPreviewMulti = vi.fn();
1660
+ const multi = mount(stretchable, {
1661
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
1662
+ onPreview: onPreviewMulti,
1663
+ });
1664
+ fire(multi.svg, "pointerdown", at(2.5, 0.5));
1665
+ // At least 3 separate pointermoves crossing the same span, not one jump.
1666
+ fire(multi.svg, "pointermove", at(2.7, 0.5));
1667
+ fire(multi.svg, "pointermove", at(2.9, 0.5));
1668
+ fire(multi.svg, "pointermove", at(3.1, 0.5));
1669
+ fire(multi.svg, "pointermove", at(3.3, 0.5));
1670
+ const afterFourMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
1671
+ ?.lanes[0]?.points;
1672
+ expect(afterFourMoves).toBeDefined();
1673
+
1674
+ // Both interior points (t=1, t=2) land exactly where a single-shot retime
1675
+ // puts them — not compounded, and not dropped.
1676
+ expect(afterFourMoves).toEqual(singleShot);
1677
+ expect(afterFourMoves?.length).toBe(6);
1678
+ expect(afterFourMoves?.some((p) => Math.abs(p.t - 1.2) < 0.001 && p.v === 0.5)).toBe(true);
1679
+ expect(afterFourMoves?.some((p) => Math.abs(p.t - 2.6) < 0.001 && p.v === 0.8)).toBe(true);
1680
+ });
1681
+
1682
+ it("retimes identically whether the left edge arrives in one move or several", () => {
1683
+ const onPreviewSingle = vi.fn();
1684
+ const single = mount(stretchable, {
1685
+ rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
1686
+ onPreview: onPreviewSingle,
1687
+ });
1688
+ fire(single.svg, "pointerdown", at(1, 0.5));
1689
+ fire(single.svg, "pointermove", at(0.2, 0.5));
1690
+ const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
1691
+ ?.lanes[0]?.points;
1692
+ expect(singleShot).toBeDefined();
1693
+
1694
+ const onPreviewMulti = vi.fn();
1695
+ const multi = mount(stretchable, {
1696
+ rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
1697
+ onPreview: onPreviewMulti,
1698
+ });
1699
+ fire(multi.svg, "pointerdown", at(1, 0.5));
1700
+ fire(multi.svg, "pointermove", at(0.7, 0.5));
1701
+ fire(multi.svg, "pointermove", at(0.4, 0.5));
1702
+ fire(multi.svg, "pointermove", at(0.2, 0.5));
1703
+ const afterThreeMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
1704
+ ?.lanes[0]?.points;
1705
+ expect(afterThreeMoves).toBeDefined();
1706
+ expect(afterThreeMoves).toEqual(singleShot);
1707
+ });
1708
+
1709
+ it("shows a resize cursor when hovering an edge with nothing else live", () => {
1710
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1711
+ fire(svg, "pointermove", at(3, 0.5)); // near the right edge, nothing pressed
1712
+ expect(svg.style.cursor).toBe("col-resize");
1713
+ });
1714
+
1715
+ it("keeps the normal cursor away from the selection's edges", () => {
1716
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
1717
+ fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
1718
+ expect(svg.style.cursor).not.toBe("col-resize");
1719
+ });
1720
+ });