@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.
- package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- 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
|
+
});
|