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