@hyperframes/studio 0.8.12 → 0.8.14
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-B243szNa.js → hyperframes-player-OIfv_7Ae.js} +1 -1
- package/dist/assets/{index-BZfUJ2He.js → index-6x3VcnTA.js} +179 -179
- package/dist/assets/{index-DQ_lkuqY.js → index-Bem14CzG.js} +1 -1
- package/dist/assets/{index-BTP86sD-.js → index-BtYWYq1P.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5067 -4851
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/domSelectionTimelineMirror.ts +12 -3
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
- package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +113 -0
- package/src/player/components/TimelineAutomationLane.tsx +41 -25
- package/src/player/components/TimelineGroupLaneLabels.tsx +14 -3
- package/src/player/components/TimelineGroupRow.test.tsx +80 -4
- package/src/player/components/TimelineGroupRow.tsx +18 -8
- package/src/player/components/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- package/src/player/components/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -0
|
@@ -17,6 +17,7 @@ import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
|
|
17
17
|
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
|
|
18
18
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
19
19
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
20
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
20
21
|
|
|
21
22
|
/** Accent rail on a group-owned lane — the same green the member rail uses, so
|
|
22
23
|
* "this belongs to the group" reads the same in both places (groups doc §5). */
|
|
@@ -92,19 +93,27 @@ export function TimelineGroupRow({
|
|
|
92
93
|
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
|
|
93
94
|
useTimelineEditContextOptional();
|
|
94
95
|
const domEditActions = useDomEditActionsContextOptional();
|
|
96
|
+
const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
|
|
95
97
|
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
|
|
96
98
|
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
|
97
99
|
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
|
98
100
|
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
|
99
101
|
};
|
|
100
|
-
const openGroupFxRack = () => {
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
const openGroupFxRack = (automationTarget?: string) => {
|
|
103
|
+
// Use the guarded timeline-selection path even though the bus is synthetic:
|
|
104
|
+
// it invalidates an older clip selection that may still be resolving. The
|
|
105
|
+
// old direct build/apply path let that late clip reclaim the rack.
|
|
106
|
+
const selection = domEditActions?.handleTimelineElementSelect(groupElement);
|
|
107
|
+
if (!selection || !automationTarget) return;
|
|
108
|
+
// Bus selection clears the clip selection, which intentionally retires any
|
|
109
|
+
// old reveal request. Publish this one afterwards so the newly mounted bus
|
|
110
|
+
// rack can consume it rather than losing it during that clear.
|
|
111
|
+
void selection.then(() =>
|
|
112
|
+
revealAudioFx({
|
|
113
|
+
elementKey: group.id,
|
|
114
|
+
automationTarget,
|
|
115
|
+
}),
|
|
103
116
|
);
|
|
104
|
-
if (!target) return;
|
|
105
|
-
void domEditActions
|
|
106
|
-
?.buildDomSelectionFromTarget(target)
|
|
107
|
-
.then((selection) => selection && domEditActions.applyDomSelection(selection));
|
|
108
117
|
};
|
|
109
118
|
return (
|
|
110
119
|
<TimelineTrackRow
|
|
@@ -150,7 +159,7 @@ export function TimelineGroupRow({
|
|
|
150
159
|
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
|
151
160
|
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
|
152
161
|
auditionSpans={memberElements}
|
|
153
|
-
onOpenFxRack={openGroupFxRack}
|
|
162
|
+
onOpenFxRack={() => openGroupFxRack()}
|
|
154
163
|
// Same width as every other row's header. The group row needs a real
|
|
155
164
|
// label column, but it gets one by turning `labelMode` on for the whole
|
|
156
165
|
// timeline (see Timeline.tsx) rather than by overhanging alone — an
|
|
@@ -172,6 +181,7 @@ export function TimelineGroupRow({
|
|
|
172
181
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
173
182
|
gutterBackground={theme.gutterBackground}
|
|
174
183
|
accentColor={GROUP_LANE_ACCENT}
|
|
184
|
+
onReveal={openGroupFxRack}
|
|
175
185
|
/>
|
|
176
186
|
)}
|
|
177
187
|
</div>
|
|
@@ -59,6 +59,34 @@ export function armGroupDrag(
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Snapshot the two endpoints owned by a segment drag.
|
|
64
|
+
*
|
|
65
|
+
* The pointer itself is the anchor: a line can be grabbed anywhere along its
|
|
66
|
+
* span, and the segment must not jump so either endpoint takes the pointer's
|
|
67
|
+
* place when the gesture starts.
|
|
68
|
+
*/
|
|
69
|
+
export function armSegmentDrag(
|
|
70
|
+
lane: HfAutomationLane,
|
|
71
|
+
index: number,
|
|
72
|
+
anchor: { t: number; v: number },
|
|
73
|
+
): GroupDragSnapshot | null {
|
|
74
|
+
const a = lane.points[index];
|
|
75
|
+
const b = lane.points[index + 1];
|
|
76
|
+
if (!a || !b) return null;
|
|
77
|
+
return {
|
|
78
|
+
points: lane.points.map((p) => ({ ...p })),
|
|
79
|
+
indices: [index, index + 1],
|
|
80
|
+
anchor: { ...anchor },
|
|
81
|
+
selection: {
|
|
82
|
+
t0: a.t,
|
|
83
|
+
t1: b.t,
|
|
84
|
+
v0: Math.min(a.v, b.v),
|
|
85
|
+
v1: Math.max(a.v, b.v),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
export interface GroupMoveResult {
|
|
63
91
|
points: HfAutomationLane["points"];
|
|
64
92
|
selection: AutomationSelectionBox;
|
|
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
|
|
|
35
35
|
export const MIN_POINT_GAP_SEC = 0.001;
|
|
36
36
|
/** Hit radius for grabbing a point, in px. */
|
|
37
37
|
export const GRAB_PX = 7;
|
|
38
|
+
/** Distance from the drawn envelope that offers a segment drag, in px. */
|
|
39
|
+
export const SEGMENT_GRAB_PX = 5;
|
|
38
40
|
/** Samples used to draw a segment the eye should see as curved. */
|
|
39
41
|
export const DRAW_SAMPLES = 64;
|
|
40
42
|
/**
|
|
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
|
|
|
222
224
|
return best;
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
/** Draw commands from one breakpoint to the next, sampled when it is curved. */
|
|
228
|
+
function segmentLineCommands(input: {
|
|
229
|
+
lane: HfAutomationLane;
|
|
230
|
+
range: AutomationRange;
|
|
231
|
+
index: number;
|
|
232
|
+
xOf(t: number): number;
|
|
233
|
+
yOf(v: number): number;
|
|
234
|
+
}): string[] {
|
|
235
|
+
const { lane, range, index, xOf, yOf } = input;
|
|
236
|
+
const a = lane.points[index];
|
|
237
|
+
const b = lane.points[index + 1];
|
|
238
|
+
if (!a || !b) return [];
|
|
239
|
+
// A via point bends the segment with no `curve` of its own, so the
|
|
240
|
+
// straight-line shortcut has to rule out both.
|
|
241
|
+
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
|
|
242
|
+
return [`L ${xOf(b.t)} ${yOf(b.v)}`];
|
|
243
|
+
}
|
|
244
|
+
return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
|
|
245
|
+
const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
|
|
246
|
+
return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
225
250
|
/**
|
|
226
251
|
* The svg path for one lane's envelope.
|
|
227
252
|
*
|
|
@@ -247,24 +272,38 @@ export function envelopePath(input: {
|
|
|
247
272
|
}
|
|
248
273
|
const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
|
|
249
274
|
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
|
250
|
-
|
|
251
|
-
const b = lane.points[i + 1];
|
|
252
|
-
if (!a || !b) continue;
|
|
253
|
-
// A via point bends the segment with no `curve` of its own, so the
|
|
254
|
-
// straight-line shortcut has to rule out both.
|
|
255
|
-
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
|
|
256
|
-
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
|
|
260
|
-
const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
|
|
261
|
-
pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
|
|
262
|
-
}
|
|
275
|
+
pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
|
|
263
276
|
}
|
|
264
277
|
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
|
|
265
278
|
return pts.join(" ");
|
|
266
279
|
}
|
|
267
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The visible path for one segment, without the lane's constant extensions.
|
|
283
|
+
*
|
|
284
|
+
* Used for the hover/drag affordance: only the segment under the pointer grows
|
|
285
|
+
* heavier, rather than making the entire envelope look selected. It samples by
|
|
286
|
+
* the same rule as `envelopePath`, so a curved or logarithmic segment's hover
|
|
287
|
+
* stroke sits exactly on the line the audio model draws.
|
|
288
|
+
*/
|
|
289
|
+
export function envelopeSegmentPath(input: {
|
|
290
|
+
lane: HfAutomationLane;
|
|
291
|
+
range: AutomationRange;
|
|
292
|
+
index: number;
|
|
293
|
+
xOf(t: number): number;
|
|
294
|
+
yOf(v: number): number;
|
|
295
|
+
}): string | null {
|
|
296
|
+
const { lane, range, index, xOf, yOf } = input;
|
|
297
|
+
const a = lane.points[index];
|
|
298
|
+
const b = lane.points[index + 1];
|
|
299
|
+
if (!a || !b) return null;
|
|
300
|
+
const pts = [
|
|
301
|
+
`M ${xOf(a.t)} ${yOf(a.v)}`,
|
|
302
|
+
...segmentLineCommands({ lane, range, index, xOf, yOf }),
|
|
303
|
+
];
|
|
304
|
+
return pts.join(" ");
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
|
|
269
308
|
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
|
|
270
309
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type GroupDragSnapshot,
|
|
26
26
|
type ShiftAxis,
|
|
27
27
|
} from "./automationLaneDragMath";
|
|
28
|
+
import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
|
|
28
29
|
|
|
29
30
|
/** How far a press may travel and still count as a click rather than a drag. */
|
|
30
31
|
const CLICK_SLOP_PX = 3;
|
|
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
|
|
|
63
64
|
dragIndex: number | null;
|
|
64
65
|
/** Segment being bent, identified by the point that owns its curve. */
|
|
65
66
|
curveIndex: number | null;
|
|
67
|
+
/** Segment whose two endpoints are being translated together. */
|
|
68
|
+
segmentDragIndex: number | null;
|
|
69
|
+
/** Segment close enough to the pointer to offer that translation. */
|
|
70
|
+
segmentHoverIndex: number | null;
|
|
66
71
|
/** Value readout to show while a gesture is live. */
|
|
67
72
|
hint: string | null;
|
|
68
73
|
hitIndex(clientX: number, clientY: number): number | null;
|
|
69
74
|
segmentIndex(clientX: number, clientY: number): number | null;
|
|
75
|
+
/** Clear hover feedback when the pointer leaves the lane. */
|
|
76
|
+
onPointerLeave(): void;
|
|
70
77
|
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
71
78
|
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
72
79
|
/** Edge being stretched, for the cursor. Null when no stretch is live. */
|
|
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
|
|
|
227
234
|
[lane, pointAt],
|
|
228
235
|
);
|
|
229
236
|
|
|
230
|
-
|
|
237
|
+
const segmentDrag = useAutomationSegmentDrag({
|
|
238
|
+
getBox,
|
|
239
|
+
lane,
|
|
240
|
+
range,
|
|
241
|
+
pointAt,
|
|
242
|
+
xOf,
|
|
243
|
+
yOf,
|
|
244
|
+
segmentIndex,
|
|
245
|
+
commitPoints,
|
|
246
|
+
duration,
|
|
247
|
+
snapTimes,
|
|
248
|
+
readOnly,
|
|
249
|
+
onHint: setHint,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** What a press starts: point move, segment move, or Alt segment bend. */
|
|
231
253
|
const gestureAt = useCallback(
|
|
232
|
-
(
|
|
254
|
+
(
|
|
255
|
+
e: ReactPointerEvent<SVGSVGElement>,
|
|
256
|
+
): { kind: "point" | "segment" | "curve"; index: number } | null => {
|
|
233
257
|
const index = hitIndex(e.clientX, e.clientY);
|
|
234
|
-
if (index !== null) return {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
if (index !== null) return { kind: "point", index };
|
|
259
|
+
// Alt is an explicit bend gesture and retains its span-wide hit target.
|
|
260
|
+
// The unmodified translation is offered only close to the drawn line.
|
|
261
|
+
const segment = e.altKey
|
|
262
|
+
? segmentIndex(e.clientX, e.clientY)
|
|
263
|
+
: segmentDrag.hitIndex(e.clientX, e.clientY);
|
|
264
|
+
if (segment === null) return null;
|
|
265
|
+
return { kind: e.altKey ? "curve" : "segment", index: segment };
|
|
238
266
|
},
|
|
239
|
-
[hitIndex, segmentIndex],
|
|
267
|
+
[hitIndex, segmentDrag, segmentIndex],
|
|
240
268
|
);
|
|
241
269
|
|
|
242
270
|
const onPointerDown = useCallback(
|
|
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
|
|
|
274
302
|
}
|
|
275
303
|
e.preventDefault();
|
|
276
304
|
capturePointer(e);
|
|
277
|
-
|
|
305
|
+
segmentDrag.clearHover();
|
|
306
|
+
if (gesture.kind === "curve") {
|
|
278
307
|
setCurveIndex(gesture.index);
|
|
279
308
|
return;
|
|
280
309
|
}
|
|
310
|
+
if (gesture.kind === "segment") {
|
|
311
|
+
segmentDrag.arm(gesture.index, e.clientX, e.clientY);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
281
314
|
dragOrigin.current = originOf(lane.points[gesture.index]);
|
|
282
315
|
// Pressing one of a selected set drags the whole set. Pressing a point
|
|
283
316
|
// outside the selection is an ordinary single-point drag, selection or no.
|
|
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
|
|
|
299
332
|
// selecting a range read the previous selection, or none.
|
|
300
333
|
rangeSelection,
|
|
301
334
|
stretch,
|
|
335
|
+
segmentDrag,
|
|
302
336
|
],
|
|
303
337
|
);
|
|
304
338
|
|
|
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
|
|
|
394
428
|
[dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
|
|
395
429
|
);
|
|
396
430
|
|
|
431
|
+
/** Route a live point, bend, or segment gesture after global gestures stand down. */
|
|
432
|
+
const moveLiveGesture = useCallback(
|
|
433
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
434
|
+
const from = pressAt.current;
|
|
435
|
+
if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
|
|
436
|
+
pressTravelled.current = true;
|
|
437
|
+
}
|
|
438
|
+
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
|
439
|
+
else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
|
|
440
|
+
else movePoint(e);
|
|
441
|
+
},
|
|
442
|
+
[bendSegment, curveIndex, movePoint, segmentDrag],
|
|
443
|
+
);
|
|
444
|
+
|
|
397
445
|
const onPointerMove = useCallback(
|
|
398
446
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
399
447
|
if (stretch.edge !== null) {
|
|
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
|
|
|
409
457
|
rangeDrag.move(e);
|
|
410
458
|
return;
|
|
411
459
|
}
|
|
412
|
-
if (curveIndex === null && dragIndex === null) {
|
|
460
|
+
if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
|
|
413
461
|
stretch.updateHover(e);
|
|
462
|
+
segmentDrag.updateHover(e.clientX, e.clientY);
|
|
414
463
|
return;
|
|
415
464
|
}
|
|
416
465
|
e.stopPropagation();
|
|
417
|
-
|
|
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);
|
|
466
|
+
moveLiveGesture(e);
|
|
423
467
|
},
|
|
424
|
-
[rangeDrag, curveIndex, dragIndex,
|
|
468
|
+
[rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
|
|
425
469
|
);
|
|
426
470
|
|
|
427
471
|
const endDrag = useCallback(
|
|
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
|
|
|
436
480
|
rangeDrag.finish();
|
|
437
481
|
return;
|
|
438
482
|
}
|
|
439
|
-
if (dragIndex === null && curveIndex === null) return;
|
|
483
|
+
if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
|
|
440
484
|
e.stopPropagation();
|
|
441
485
|
const index = dragIndex;
|
|
442
486
|
const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
|
|
443
487
|
setDragIndex(null);
|
|
444
488
|
setCurveIndex(null);
|
|
489
|
+
segmentDrag.finish();
|
|
445
490
|
dragOrigin.current = null;
|
|
446
491
|
groupDrag.current = null;
|
|
447
492
|
shiftAxis.current = null;
|
|
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
|
|
|
459
504
|
}
|
|
460
505
|
commitPoints(lane.points, true);
|
|
461
506
|
},
|
|
462
|
-
[rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
|
|
507
|
+
[rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
|
|
463
508
|
);
|
|
464
509
|
|
|
465
510
|
/**
|
|
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
|
|
|
519
564
|
return {
|
|
520
565
|
dragIndex,
|
|
521
566
|
curveIndex,
|
|
567
|
+
segmentDragIndex: segmentDrag.dragIndex,
|
|
568
|
+
segmentHoverIndex: segmentDrag.hoverIndex,
|
|
522
569
|
edgeDrag: stretch.edge,
|
|
523
570
|
edgeHover: stretch.hover,
|
|
524
571
|
cancelDrag,
|
|
525
572
|
hint,
|
|
526
573
|
hitIndex,
|
|
527
574
|
segmentIndex,
|
|
575
|
+
onPointerLeave: segmentDrag.clearHover,
|
|
528
576
|
onPointerDown,
|
|
529
577
|
onPointerMove,
|
|
530
578
|
endDrag,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grab target and translation gesture for one automation segment.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the lane's gesture router: proximity is measured against
|
|
5
|
+
* the sampled envelope, while movement reuses the same shape-preserving group
|
|
6
|
+
* math as a box selection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
10
|
+
import {
|
|
11
|
+
sampleAutomationLane,
|
|
12
|
+
type AutomationRange,
|
|
13
|
+
type HfAutomationLane,
|
|
14
|
+
} from "@hyperframes/core/audio-automation";
|
|
15
|
+
import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
|
|
16
|
+
import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
|
|
17
|
+
|
|
18
|
+
interface UseAutomationSegmentDragInput {
|
|
19
|
+
getBox(): DOMRect | null;
|
|
20
|
+
lane: HfAutomationLane;
|
|
21
|
+
range: AutomationRange;
|
|
22
|
+
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
|
23
|
+
xOf(t: number): number;
|
|
24
|
+
yOf(v: number): number;
|
|
25
|
+
segmentIndex(clientX: number, clientY: number): number | null;
|
|
26
|
+
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
|
27
|
+
duration: number;
|
|
28
|
+
snapTimes: readonly number[] | undefined;
|
|
29
|
+
readOnly: boolean | undefined;
|
|
30
|
+
onHint(hint: string | null): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useAutomationSegmentDrag({
|
|
34
|
+
getBox,
|
|
35
|
+
lane,
|
|
36
|
+
range,
|
|
37
|
+
pointAt,
|
|
38
|
+
xOf,
|
|
39
|
+
yOf,
|
|
40
|
+
segmentIndex,
|
|
41
|
+
commitPoints,
|
|
42
|
+
duration,
|
|
43
|
+
snapTimes,
|
|
44
|
+
readOnly,
|
|
45
|
+
onHint,
|
|
46
|
+
}: UseAutomationSegmentDragInput) {
|
|
47
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
48
|
+
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
|
49
|
+
const snapshot = useRef<GroupDragSnapshot | null>(null);
|
|
50
|
+
|
|
51
|
+
/** Segment whose drawn line is close enough to grab, or null. */
|
|
52
|
+
const hitIndex = useCallback(
|
|
53
|
+
(clientX: number, clientY: number): number | null => {
|
|
54
|
+
const box = getBox();
|
|
55
|
+
if (!box) return null;
|
|
56
|
+
const index = segmentIndex(clientX, clientY);
|
|
57
|
+
if (index === null) return null;
|
|
58
|
+
const { t } = pointAt(clientX, clientY);
|
|
59
|
+
const value = sampleAutomationLane(lane, t, range.scale);
|
|
60
|
+
return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
|
|
61
|
+
},
|
|
62
|
+
[getBox, lane, pointAt, range.scale, segmentIndex, yOf],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const arm = useCallback(
|
|
66
|
+
(index: number, clientX: number, clientY: number): void => {
|
|
67
|
+
snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
|
|
68
|
+
setHoverIndex(null);
|
|
69
|
+
setDragIndex(index);
|
|
70
|
+
},
|
|
71
|
+
[lane, pointAt],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const move = useCallback(
|
|
75
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
76
|
+
const group = snapshot.current;
|
|
77
|
+
if (!group) return;
|
|
78
|
+
const moved = computeGroupMove({
|
|
79
|
+
group,
|
|
80
|
+
raw: pointAt(e.clientX, e.clientY),
|
|
81
|
+
shiftKey: e.shiftKey,
|
|
82
|
+
altKey: e.altKey,
|
|
83
|
+
range,
|
|
84
|
+
duration,
|
|
85
|
+
snapTimes,
|
|
86
|
+
xOf,
|
|
87
|
+
yOf,
|
|
88
|
+
});
|
|
89
|
+
onHint(moved.hint);
|
|
90
|
+
commitPoints(moved.points, false);
|
|
91
|
+
},
|
|
92
|
+
[commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const finish = useCallback((): void => {
|
|
96
|
+
snapshot.current = null;
|
|
97
|
+
setDragIndex(null);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const updateHover = useCallback(
|
|
101
|
+
(clientX: number, clientY: number): void => {
|
|
102
|
+
setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
|
|
103
|
+
},
|
|
104
|
+
[hitIndex, readOnly],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const clearHover = useCallback((): void => setHoverIndex(null), []);
|
|
108
|
+
|
|
109
|
+
return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
|
|
110
|
+
}
|