@hyperframes/studio 0.7.90 → 0.7.92
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-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
- package/dist/assets/index-DIntkrQl.js +428 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.html +1 -1
- package/dist/index.js +6495 -5429
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/Timeline.test.ts +2 -3
- package/src/player/components/Timeline.tsx +47 -48
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.tsx +6 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
- package/src/player/components/TimelineClipDiamonds.tsx +66 -211
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +0 -1
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -15
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineLaneProps.ts +0 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +11 -4
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/playerStore.test.ts +76 -0
- package/src/player/store/playerStore.ts +19 -11
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-Cql15Yur.js +0 -428
|
@@ -10,6 +10,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
|
10
10
|
import { getTimelineScrubTime } from "./timelineLayout";
|
|
11
11
|
import {
|
|
12
12
|
computeMarqueeSelection,
|
|
13
|
+
getMarqueeClipCandidates,
|
|
13
14
|
getMarqueeRect,
|
|
14
15
|
isMarqueeDrag,
|
|
15
16
|
isTimelineRulerPress,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
} from "./timelineMarquee";
|
|
18
19
|
import type { Rect } from "../../utils/marqueeGeometry";
|
|
19
20
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
21
|
+
import type { TimelineClipIndex } from "../lib/timelineClipIndex";
|
|
20
22
|
|
|
21
23
|
interface UseTimelineRangeSelectionInput {
|
|
22
24
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -30,10 +32,11 @@ interface UseTimelineRangeSelectionInput {
|
|
|
30
32
|
isDragging: React.RefObject<boolean>;
|
|
31
33
|
setShowPopover: (v: boolean) => void;
|
|
32
34
|
elementsRef: React.RefObject<TimelineElement[]>;
|
|
33
|
-
|
|
35
|
+
clipIndex: TimelineClipIndex;
|
|
34
36
|
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
|
35
37
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
36
38
|
contentOrigin: number;
|
|
39
|
+
sessionEpoch: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
interface MarqueeDragState {
|
|
@@ -73,16 +76,21 @@ function commitMarqueeSelection(
|
|
|
73
76
|
rect: Rect,
|
|
74
77
|
additive: boolean,
|
|
75
78
|
marquee: MarqueeDragState,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
rowHeights: readonly number[],
|
|
79
|
+
clipIndex: TimelineClipIndex,
|
|
80
|
+
rowGeometry: TimelineRowGeometry,
|
|
79
81
|
pps: number,
|
|
80
82
|
contentOrigin: number,
|
|
81
83
|
): void {
|
|
84
|
+
const candidates = getMarqueeClipCandidates({
|
|
85
|
+
clipIndex,
|
|
86
|
+
rowGeometry,
|
|
87
|
+
marquee: rect,
|
|
88
|
+
pps,
|
|
89
|
+
contentOrigin,
|
|
90
|
+
});
|
|
82
91
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
83
|
-
clips: toMarqueeClips(
|
|
84
|
-
|
|
85
|
-
rowHeights,
|
|
92
|
+
clips: toMarqueeClips([...candidates]),
|
|
93
|
+
rowGeometry,
|
|
86
94
|
pps,
|
|
87
95
|
contentOrigin,
|
|
88
96
|
marquee: rect,
|
|
@@ -95,6 +103,28 @@ function commitMarqueeSelection(
|
|
|
95
103
|
store.setSelectedElementIds(ids);
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
function canStartPointerGesture(
|
|
107
|
+
event: React.PointerEvent,
|
|
108
|
+
activePointerId: number | null,
|
|
109
|
+
sessionEpoch: number,
|
|
110
|
+
): boolean {
|
|
111
|
+
return (
|
|
112
|
+
event.button === 0 &&
|
|
113
|
+
activePointerId === null &&
|
|
114
|
+
sessionEpoch === usePlayerStore.getState().timelineSessionEpoch
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isMarqueePress(
|
|
119
|
+
point: { x: number; y: number } | null,
|
|
120
|
+
scrollRect: DOMRect | undefined,
|
|
121
|
+
clientY: number,
|
|
122
|
+
): point is { x: number; y: number } {
|
|
123
|
+
return (
|
|
124
|
+
point !== null && scrollRect !== undefined && !isTimelineRulerPress(clientY, scrollRect.top)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
export function useTimelineRangeSelection({
|
|
99
129
|
scrollRef,
|
|
100
130
|
ppsRef,
|
|
@@ -107,10 +137,11 @@ export function useTimelineRangeSelection({
|
|
|
107
137
|
isDragging,
|
|
108
138
|
setShowPopover,
|
|
109
139
|
elementsRef,
|
|
110
|
-
|
|
140
|
+
clipIndex,
|
|
111
141
|
rowGeometryRef,
|
|
112
142
|
onSelectElement,
|
|
113
143
|
contentOrigin,
|
|
144
|
+
sessionEpoch,
|
|
114
145
|
}: UseTimelineRangeSelectionInput) {
|
|
115
146
|
const isRangeSelecting = useRef(false);
|
|
116
147
|
const rangeAnchorTime = useRef(0);
|
|
@@ -126,6 +157,17 @@ export function useTimelineRangeSelection({
|
|
|
126
157
|
|
|
127
158
|
const seekRafRef = useRef(0);
|
|
128
159
|
const pendingClientXRef = useRef(0);
|
|
160
|
+
const activePointerIdRef = useRef<number | null>(null);
|
|
161
|
+
const gestureEpochRef = useRef<number | null>(null);
|
|
162
|
+
const sessionEpochRef = useRef(sessionEpoch);
|
|
163
|
+
sessionEpochRef.current = sessionEpoch;
|
|
164
|
+
|
|
165
|
+
const isGestureSessionCurrent = useCallback(
|
|
166
|
+
() =>
|
|
167
|
+
gestureEpochRef.current === sessionEpochRef.current &&
|
|
168
|
+
gestureEpochRef.current === usePlayerStore.getState().timelineSessionEpoch,
|
|
169
|
+
[],
|
|
170
|
+
);
|
|
129
171
|
|
|
130
172
|
// Marquee (rubber-band) multi-select on the empty timeline body.
|
|
131
173
|
const marqueeRef = useRef<MarqueeDragState | null>(null);
|
|
@@ -159,7 +201,7 @@ export function useTimelineRangeSelection({
|
|
|
159
201
|
const applyMarqueeAtClient = useCallback(
|
|
160
202
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
161
203
|
const marquee = marqueeRef.current;
|
|
162
|
-
if (!marquee) return;
|
|
204
|
+
if (!marquee || !isGestureSessionCurrent()) return;
|
|
163
205
|
const point = toContentPoint(clientX, clientY);
|
|
164
206
|
if (!point) return;
|
|
165
207
|
if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
|
|
@@ -175,14 +217,13 @@ export function useTimelineRangeSelection({
|
|
|
175
217
|
rect,
|
|
176
218
|
additive,
|
|
177
219
|
marquee,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
rowGeometryRef.current.rowHeights,
|
|
220
|
+
clipIndex,
|
|
221
|
+
rowGeometryRef.current,
|
|
181
222
|
ppsRef.current,
|
|
182
223
|
contentOrigin,
|
|
183
224
|
);
|
|
184
225
|
},
|
|
185
|
-
[toContentPoint,
|
|
226
|
+
[toContentPoint, isGestureSessionCurrent, clipIndex, rowGeometryRef, ppsRef, contentOrigin],
|
|
186
227
|
);
|
|
187
228
|
|
|
188
229
|
const stopMarqueeAutoScroll = useCallback(() => {
|
|
@@ -203,14 +244,16 @@ export function useTimelineRangeSelection({
|
|
|
203
244
|
const marquee = marqueeRef.current;
|
|
204
245
|
const pointer = marqueePointerRef.current;
|
|
205
246
|
const scroll = scrollRef.current;
|
|
206
|
-
if (!marquee || !pointer || !scroll)
|
|
247
|
+
if (!marquee || !pointer || !scroll || !isGestureSessionCurrent()) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
207
250
|
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
|
208
251
|
|
|
209
252
|
// Re-run at the SAME client point: toContentPoint folds in the new scroll, so
|
|
210
253
|
// the marquee's moving corner tracks the revealed content.
|
|
211
254
|
applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
|
|
212
255
|
marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
|
|
213
|
-
}, [scrollRef, applyMarqueeAtClient]);
|
|
256
|
+
}, [scrollRef, applyMarqueeAtClient, isGestureSessionCurrent]);
|
|
214
257
|
|
|
215
258
|
const syncMarqueeAutoScroll = useCallback(
|
|
216
259
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
@@ -235,6 +278,8 @@ export function useTimelineRangeSelection({
|
|
|
235
278
|
const beginRangeSelection = useCallback(
|
|
236
279
|
(e: React.PointerEvent) => {
|
|
237
280
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
281
|
+
activePointerIdRef.current = e.pointerId;
|
|
282
|
+
gestureEpochRef.current = sessionEpochRef.current;
|
|
238
283
|
isRangeSelecting.current = true;
|
|
239
284
|
setShowPopover(false);
|
|
240
285
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
@@ -248,9 +293,31 @@ export function useTimelineRangeSelection({
|
|
|
248
293
|
[scrollRef, pps, setShowPopover, contentOrigin],
|
|
249
294
|
);
|
|
250
295
|
|
|
296
|
+
const beginScrub = useCallback(
|
|
297
|
+
(clientX: number) => {
|
|
298
|
+
isDragging.current = true;
|
|
299
|
+
setIsScrubbing(true);
|
|
300
|
+
pendingClientXRef.current = clientX;
|
|
301
|
+
seekFromX(clientX);
|
|
302
|
+
},
|
|
303
|
+
[isDragging, seekFromX],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const beginMarquee = useCallback((point: { x: number; y: number }, additive: boolean) => {
|
|
307
|
+
const base = snapshotSelection();
|
|
308
|
+
marqueeRef.current = {
|
|
309
|
+
originX: point.x,
|
|
310
|
+
originY: point.y,
|
|
311
|
+
baseIds: base.ids,
|
|
312
|
+
basePrimary: base.primary,
|
|
313
|
+
additive,
|
|
314
|
+
active: false,
|
|
315
|
+
};
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
251
318
|
const handlePointerDown = useCallback(
|
|
252
319
|
(e: React.PointerEvent) => {
|
|
253
|
-
if (e.
|
|
320
|
+
if (!canStartPointerGesture(e, activePointerIdRef.current, sessionEpochRef.current)) return;
|
|
254
321
|
if (e.shiftKey) {
|
|
255
322
|
beginRangeSelection(e);
|
|
256
323
|
return;
|
|
@@ -258,6 +325,8 @@ export function useTimelineRangeSelection({
|
|
|
258
325
|
shiftClickClipRef.current = null;
|
|
259
326
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
260
327
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
328
|
+
activePointerIdRef.current = e.pointerId;
|
|
329
|
+
gestureEpochRef.current = sessionEpochRef.current;
|
|
261
330
|
setRangeSelection(null);
|
|
262
331
|
setShowPopover(false);
|
|
263
332
|
const point = toContentPoint(e.clientX, e.clientY);
|
|
@@ -266,30 +335,15 @@ export function useTimelineRangeSelection({
|
|
|
266
335
|
// y (which folds in scrollTop) breaks once the body is scrolled down and
|
|
267
336
|
// the stuck ruler visually overlays scrolled-away track rows.
|
|
268
337
|
const scrollRect = scrollRef.current?.getBoundingClientRect();
|
|
269
|
-
if (!point
|
|
270
|
-
|
|
271
|
-
setIsScrubbing(true);
|
|
272
|
-
// Seed the pending coordinate so a press with no pointermove still
|
|
273
|
-
// replays THIS x on pointerup. `updateScrubDrag` is the only other
|
|
274
|
-
// writer, so without this a plain click settles on the ref's initial
|
|
275
|
-
// 0 and clamps the playhead back to t=0.
|
|
276
|
-
pendingClientXRef.current = e.clientX;
|
|
277
|
-
seekFromX(e.clientX);
|
|
338
|
+
if (!isMarqueePress(point, scrollRect, e.clientY)) {
|
|
339
|
+
beginScrub(e.clientX);
|
|
278
340
|
return;
|
|
279
341
|
}
|
|
280
342
|
// Empty body press → pending marquee. A plain click (no drag past the
|
|
281
343
|
// threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
|
|
282
|
-
|
|
283
|
-
marqueeRef.current = {
|
|
284
|
-
originX: point.x,
|
|
285
|
-
originY: point.y,
|
|
286
|
-
baseIds: base.ids,
|
|
287
|
-
basePrimary: base.primary,
|
|
288
|
-
additive: e.metaKey || e.ctrlKey,
|
|
289
|
-
active: false,
|
|
290
|
-
};
|
|
344
|
+
beginMarquee(point, e.metaKey || e.ctrlKey);
|
|
291
345
|
},
|
|
292
|
-
[beginRangeSelection,
|
|
346
|
+
[beginRangeSelection, beginScrub, beginMarquee, scrollRef, setShowPopover, toContentPoint],
|
|
293
347
|
);
|
|
294
348
|
|
|
295
349
|
// Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
|
|
@@ -325,18 +379,27 @@ export function useTimelineRangeSelection({
|
|
|
325
379
|
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
|
326
380
|
);
|
|
327
381
|
|
|
382
|
+
const updateRangeSelection = useCallback(
|
|
383
|
+
(e: React.PointerEvent) => {
|
|
384
|
+
const scroll = scrollRef.current;
|
|
385
|
+
const rect = scroll?.getBoundingClientRect();
|
|
386
|
+
if (!scroll || !rect) return;
|
|
387
|
+
const x = e.clientX - rect.left + scroll.scrollLeft - contentOrigin;
|
|
388
|
+
setRangeSelection((previous) =>
|
|
389
|
+
previous
|
|
390
|
+
? { ...previous, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
|
391
|
+
: null,
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
[contentOrigin, pps, scrollRef],
|
|
395
|
+
);
|
|
396
|
+
|
|
328
397
|
const handlePointerMove = useCallback(
|
|
329
398
|
(e: React.PointerEvent) => {
|
|
399
|
+
if (!isGestureSessionCurrent()) return;
|
|
400
|
+
if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return;
|
|
330
401
|
if (isRangeSelecting.current) {
|
|
331
|
-
|
|
332
|
-
if (rect) {
|
|
333
|
-
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
|
334
|
-
setRangeSelection((prev) =>
|
|
335
|
-
prev
|
|
336
|
-
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
|
337
|
-
: null,
|
|
338
|
-
);
|
|
339
|
-
}
|
|
402
|
+
updateRangeSelection(e);
|
|
340
403
|
return;
|
|
341
404
|
}
|
|
342
405
|
const marquee = marqueeRef.current;
|
|
@@ -351,13 +414,12 @@ export function useTimelineRangeSelection({
|
|
|
351
414
|
updateScrubDrag(e.clientX);
|
|
352
415
|
},
|
|
353
416
|
[
|
|
354
|
-
pps,
|
|
355
|
-
scrollRef,
|
|
356
417
|
isDragging,
|
|
357
418
|
applyMarqueeAtClient,
|
|
358
419
|
syncMarqueeAutoScroll,
|
|
359
420
|
updateScrubDrag,
|
|
360
|
-
|
|
421
|
+
updateRangeSelection,
|
|
422
|
+
isGestureSessionCurrent,
|
|
361
423
|
],
|
|
362
424
|
);
|
|
363
425
|
|
|
@@ -403,17 +465,15 @@ export function useTimelineRangeSelection({
|
|
|
403
465
|
[stopMarqueeAutoScroll, elementsRef, onSelectElement],
|
|
404
466
|
);
|
|
405
467
|
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
if (!isDragging.current) return;
|
|
468
|
+
const canFinishPointerGesture = useCallback(
|
|
469
|
+
(e?: React.PointerEvent) => {
|
|
470
|
+
const pointerId = activePointerIdRef.current;
|
|
471
|
+
return pointerId !== null && (!e || e.pointerId === pointerId) && isGestureSessionCurrent();
|
|
472
|
+
},
|
|
473
|
+
[isGestureSessionCurrent],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const finishScrub = useCallback(() => {
|
|
417
477
|
if (seekRafRef.current) {
|
|
418
478
|
cancelAnimationFrame(seekRafRef.current);
|
|
419
479
|
seekRafRef.current = 0;
|
|
@@ -422,7 +482,69 @@ export function useTimelineRangeSelection({
|
|
|
422
482
|
isDragging.current = false;
|
|
423
483
|
setIsScrubbing(false);
|
|
424
484
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
425
|
-
}, [
|
|
485
|
+
}, [dragScrollRaf, isDragging, seekFromX]);
|
|
486
|
+
|
|
487
|
+
const handlePointerUp = useCallback(
|
|
488
|
+
(e?: React.PointerEvent) => {
|
|
489
|
+
if (!canFinishPointerGesture(e)) return;
|
|
490
|
+
activePointerIdRef.current = null;
|
|
491
|
+
gestureEpochRef.current = null;
|
|
492
|
+
if (isRangeSelecting.current) {
|
|
493
|
+
finishRangeSelection();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const marquee = marqueeRef.current;
|
|
497
|
+
if (marquee) {
|
|
498
|
+
finishMarquee(marquee);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (isDragging.current) finishScrub();
|
|
502
|
+
},
|
|
503
|
+
[canFinishPointerGesture, finishRangeSelection, finishMarquee, finishScrub, isDragging],
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const cancelActiveGesture = useCallback(
|
|
507
|
+
(updateUi: boolean, restoreSelection: boolean) => {
|
|
508
|
+
activePointerIdRef.current = null;
|
|
509
|
+
gestureEpochRef.current = null;
|
|
510
|
+
isRangeSelecting.current = false;
|
|
511
|
+
isDragging.current = false;
|
|
512
|
+
stopMarqueeAutoScroll();
|
|
513
|
+
if (seekRafRef.current) {
|
|
514
|
+
cancelAnimationFrame(seekRafRef.current);
|
|
515
|
+
seekRafRef.current = 0;
|
|
516
|
+
}
|
|
517
|
+
cancelAnimationFrame(dragScrollRaf.current);
|
|
518
|
+
dragScrollRaf.current = 0;
|
|
519
|
+
|
|
520
|
+
const marquee = marqueeRef.current;
|
|
521
|
+
marqueeRef.current = null;
|
|
522
|
+
if (restoreSelection && marquee?.active) {
|
|
523
|
+
const store = usePlayerStore.getState();
|
|
524
|
+
store.setSelectedElementId(marquee.basePrimary);
|
|
525
|
+
store.setSelectedElementIds(marquee.baseIds);
|
|
526
|
+
}
|
|
527
|
+
if (updateUi) {
|
|
528
|
+
setMarqueeRect(null);
|
|
529
|
+
setRangeSelection(null);
|
|
530
|
+
setIsScrubbing(false);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
[dragScrollRaf, isDragging, stopMarqueeAutoScroll],
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const handlePointerCancel = useCallback(
|
|
537
|
+
(e?: React.PointerEvent) => {
|
|
538
|
+
if (
|
|
539
|
+
activePointerIdRef.current === null ||
|
|
540
|
+
(e && activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current)
|
|
541
|
+
) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
cancelActiveGesture(true, isGestureSessionCurrent());
|
|
545
|
+
},
|
|
546
|
+
[cancelActiveGesture, isGestureSessionCurrent],
|
|
547
|
+
);
|
|
426
548
|
|
|
427
549
|
// Escape: cancel an in-flight marquee (restores the pre-drag selection);
|
|
428
550
|
// otherwise clear any lingering multi-selection.
|
|
@@ -432,15 +554,11 @@ export function useTimelineRangeSelection({
|
|
|
432
554
|
const store = usePlayerStore.getState();
|
|
433
555
|
const marquee = marqueeRef.current;
|
|
434
556
|
if (marquee) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// restore the pre-drag primary before repopulating the base ids.
|
|
441
|
-
store.setSelectedElementId(marquee.basePrimary);
|
|
442
|
-
store.setSelectedElementIds(marquee.baseIds);
|
|
443
|
-
}
|
|
557
|
+
cancelActiveGesture(true, true);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (isRangeSelecting.current || isDragging.current) {
|
|
561
|
+
cancelActiveGesture(true, true);
|
|
444
562
|
return;
|
|
445
563
|
}
|
|
446
564
|
// Escape with no marquee clears the whole selection — primary AND set.
|
|
@@ -451,7 +569,21 @@ export function useTimelineRangeSelection({
|
|
|
451
569
|
};
|
|
452
570
|
window.addEventListener("keydown", onKeyDown);
|
|
453
571
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
454
|
-
}, [
|
|
572
|
+
}, [cancelActiveGesture, isDragging]);
|
|
573
|
+
|
|
574
|
+
const previousSessionEpochRef = useRef(sessionEpoch);
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
if (previousSessionEpochRef.current === sessionEpoch) return;
|
|
577
|
+
previousSessionEpochRef.current = sessionEpoch;
|
|
578
|
+
cancelActiveGesture(true, false);
|
|
579
|
+
}, [cancelActiveGesture, sessionEpoch]);
|
|
580
|
+
|
|
581
|
+
useEffect(
|
|
582
|
+
() => () => {
|
|
583
|
+
cancelActiveGesture(false, isGestureSessionCurrent());
|
|
584
|
+
},
|
|
585
|
+
[cancelActiveGesture, isGestureSessionCurrent],
|
|
586
|
+
);
|
|
455
587
|
|
|
456
588
|
return {
|
|
457
589
|
rangeSelection,
|
|
@@ -462,5 +594,6 @@ export function useTimelineRangeSelection({
|
|
|
462
594
|
handlePointerDown,
|
|
463
595
|
handlePointerMove,
|
|
464
596
|
handlePointerUp,
|
|
597
|
+
handlePointerCancel,
|
|
465
598
|
};
|
|
466
599
|
}
|
|
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
9
9
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
10
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
11
11
|
import { getTimelineRowGeometry } from "./timelineLayout";
|
|
12
|
+
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
@@ -60,7 +61,6 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|
|
60
61
|
const dragScrollRaf = { current: 0 };
|
|
61
62
|
const isDragging = { current: false };
|
|
62
63
|
const elementsRef = { current: [] };
|
|
63
|
-
const trackOrderRef = { current: [] };
|
|
64
64
|
const rowGeometryRef = { current: getTimelineRowGeometry([]) };
|
|
65
65
|
|
|
66
66
|
function Probe(): null {
|
|
@@ -75,7 +75,8 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|
|
75
75
|
isDragging,
|
|
76
76
|
setShowPopover: vi.fn(),
|
|
77
77
|
elementsRef,
|
|
78
|
-
|
|
78
|
+
clipIndex: createTimelineClipIndex([]),
|
|
79
|
+
sessionEpoch: 0,
|
|
79
80
|
rowGeometryRef,
|
|
80
81
|
contentOrigin: 0,
|
|
81
82
|
});
|
|
@@ -24,7 +24,7 @@ interface UseTimelineRowVirtualizationInput {
|
|
|
24
24
|
selectedElementId: string | null;
|
|
25
25
|
revealElementId: string | null;
|
|
26
26
|
draggedRowKey?: number;
|
|
27
|
-
|
|
27
|
+
resizingElementIds?: readonly string[];
|
|
28
28
|
clipContextMenuRowKey?: number;
|
|
29
29
|
keyframeContextMenuRowKey?: number;
|
|
30
30
|
lastScrollLeftRef: RefObject<number>;
|
|
@@ -55,7 +55,7 @@ export function useTimelineRowVirtualization({
|
|
|
55
55
|
selectedElementId,
|
|
56
56
|
revealElementId,
|
|
57
57
|
draggedRowKey,
|
|
58
|
-
|
|
58
|
+
resizingElementIds,
|
|
59
59
|
clipContextMenuRowKey,
|
|
60
60
|
keyframeContextMenuRowKey,
|
|
61
61
|
lastScrollLeftRef,
|
|
@@ -77,11 +77,18 @@ export function useTimelineRowVirtualization({
|
|
|
77
77
|
() => resolveTimelineFocusIdentity(elements, revealElementId),
|
|
78
78
|
[elements, revealElementId],
|
|
79
79
|
);
|
|
80
|
+
const resizingRowKeys = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
resizingElementIds
|
|
83
|
+
?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey)
|
|
84
|
+
.filter((rowKey): rowKey is number => rowKey !== undefined) ?? [],
|
|
85
|
+
[elements, resizingElementIds],
|
|
86
|
+
);
|
|
80
87
|
const pinnedRowKeys = useMemo(
|
|
81
88
|
() =>
|
|
82
89
|
[
|
|
83
90
|
draggedRowKey,
|
|
84
|
-
|
|
91
|
+
...resizingRowKeys,
|
|
85
92
|
revealIdentity?.rowKey,
|
|
86
93
|
clipContextMenuRowKey,
|
|
87
94
|
keyframeContextMenuRowKey,
|
|
@@ -90,7 +97,7 @@ export function useTimelineRowVirtualization({
|
|
|
90
97
|
clipContextMenuRowKey,
|
|
91
98
|
draggedRowKey,
|
|
92
99
|
keyframeContextMenuRowKey,
|
|
93
|
-
|
|
100
|
+
resizingRowKeys,
|
|
94
101
|
revealIdentity,
|
|
95
102
|
],
|
|
96
103
|
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useTrackGapMenu } from "./useTrackGapMenu";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const lane: TimelineElement[] = [
|
|
12
|
+
{ id: "first", tag: "div", start: 0, duration: 1, track: 0 },
|
|
13
|
+
{ id: "second", tag: "div", start: 3, duration: 1, track: 0 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
usePlayerStore.getState().reset();
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("useTrackGapMenu", () => {
|
|
22
|
+
it("does not commit an anchor captured in a previous project session", () => {
|
|
23
|
+
usePlayerStore.setState({ elements: lane, timelineSessionEpoch: 1 });
|
|
24
|
+
const host = document.createElement("div");
|
|
25
|
+
document.body.append(host);
|
|
26
|
+
const root = createRoot(host);
|
|
27
|
+
const onMoveElement = vi.fn();
|
|
28
|
+
const onMoveElements = vi.fn();
|
|
29
|
+
let api: ReturnType<typeof useTrackGapMenu> | null = null;
|
|
30
|
+
|
|
31
|
+
function getApi(): ReturnType<typeof useTrackGapMenu> {
|
|
32
|
+
if (!api) throw new Error("gap menu harness did not render");
|
|
33
|
+
return api;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Probe() {
|
|
37
|
+
api = useTrackGapMenu({
|
|
38
|
+
tracks: [[0, lane]],
|
|
39
|
+
expandedElementsRef: { current: lane },
|
|
40
|
+
trackOrderRef: { current: [0] },
|
|
41
|
+
onMoveElement,
|
|
42
|
+
onMoveElements,
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
act(() => root.render(<Probe />));
|
|
48
|
+
act(() => getApi().openGapMenu({ x: 10, y: 20, track: 0, time: 2 }));
|
|
49
|
+
expect(getApi().gapMenuModel).not.toBeNull();
|
|
50
|
+
const staleCloseTrackGap = getApi().closeTrackGap;
|
|
51
|
+
const staleCloseAllTrackGaps = getApi().closeAllTrackGaps;
|
|
52
|
+
|
|
53
|
+
act(() => {
|
|
54
|
+
usePlayerStore.setState({ timelineSessionEpoch: 2 });
|
|
55
|
+
staleCloseTrackGap();
|
|
56
|
+
staleCloseAllTrackGaps();
|
|
57
|
+
});
|
|
58
|
+
expect(getApi().gapMenuModel).toBeNull();
|
|
59
|
+
|
|
60
|
+
expect(onMoveElement).not.toHaveBeenCalled();
|
|
61
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState, type MutableRefObject } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
4
4
|
import {
|
|
@@ -21,6 +21,7 @@ interface TrackGapMenuAnchor {
|
|
|
21
21
|
y: number;
|
|
22
22
|
track: number;
|
|
23
23
|
time: number;
|
|
24
|
+
sessionEpoch: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Gap strips to paint on one lane while a menu row is hovered. */
|
|
@@ -32,9 +33,9 @@ export interface TrackGapHighlight {
|
|
|
32
33
|
/**
|
|
33
34
|
* Track-gap context menu (right-click on empty lane space) — state, the
|
|
34
35
|
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
|
|
35
|
-
* as a cohesive unit (600-line studio cap)
|
|
36
|
+
* as a cohesive unit (600-line studio cap).
|
|
36
37
|
*
|
|
37
|
-
* Only the ANCHOR (and the hovered row) is state; the menu model (gap under
|
|
38
|
+
* Only the session-stamped ANCHOR (and the hovered row) is state; the menu model (gap under
|
|
38
39
|
* the pointer, compaction, movability) derives from live `tracks` so an open
|
|
39
40
|
* menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
|
|
40
41
|
* paints while "Close gap" / "Close all gaps" is hovered — derives the same
|
|
@@ -55,12 +56,16 @@ export function useTrackGapMenu({
|
|
|
55
56
|
onMoveElements: DragCommitDeps["onMoveElements"];
|
|
56
57
|
}) {
|
|
57
58
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
59
|
+
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
58
60
|
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
|
|
59
61
|
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
|
|
60
62
|
|
|
61
63
|
const gapMenuLaneElements = useMemo(
|
|
62
|
-
() =>
|
|
63
|
-
|
|
64
|
+
() =>
|
|
65
|
+
gapContextMenu?.sessionEpoch === sessionEpoch
|
|
66
|
+
? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? [])
|
|
67
|
+
: null,
|
|
68
|
+
[gapContextMenu, sessionEpoch, tracks],
|
|
64
69
|
);
|
|
65
70
|
const gapMenuModel = useMemo(() => {
|
|
66
71
|
if (!gapContextMenu || !gapMenuLaneElements) return null;
|
|
@@ -99,7 +104,12 @@ export function useTrackGapMenu({
|
|
|
99
104
|
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
|
|
100
105
|
|
|
101
106
|
const closeTrackGap = useCallback(() => {
|
|
102
|
-
if (
|
|
107
|
+
if (
|
|
108
|
+
!gapContextMenu ||
|
|
109
|
+
!gapMenuLaneElements ||
|
|
110
|
+
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
|
111
|
+
)
|
|
112
|
+
return;
|
|
103
113
|
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
|
|
104
114
|
elements: expandedElementsRef.current,
|
|
105
115
|
trackOrder: trackOrderRef.current,
|
|
@@ -117,7 +127,12 @@ export function useTrackGapMenu({
|
|
|
117
127
|
onMoveElements,
|
|
118
128
|
]);
|
|
119
129
|
const closeAllTrackGaps = useCallback(() => {
|
|
120
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
!gapContextMenu ||
|
|
132
|
+
!gapMenuLaneElements ||
|
|
133
|
+
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
|
134
|
+
)
|
|
135
|
+
return;
|
|
121
136
|
commitCloseAllTrackGaps(gapMenuLaneElements, {
|
|
122
137
|
elements: expandedElementsRef.current,
|
|
123
138
|
trackOrder: trackOrderRef.current,
|
|
@@ -126,6 +141,7 @@ export function useTrackGapMenu({
|
|
|
126
141
|
onMoveElements,
|
|
127
142
|
});
|
|
128
143
|
}, [
|
|
144
|
+
gapContextMenu,
|
|
129
145
|
gapMenuLaneElements,
|
|
130
146
|
expandedElementsRef,
|
|
131
147
|
trackOrderRef,
|
|
@@ -134,15 +150,22 @@ export function useTrackGapMenu({
|
|
|
134
150
|
onMoveElements,
|
|
135
151
|
]);
|
|
136
152
|
|
|
137
|
-
const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
|
|
153
|
+
const openGapMenu = useCallback((anchor: Omit<TrackGapMenuAnchor, "sessionEpoch">) => {
|
|
138
154
|
setHoveredGapAction(null);
|
|
139
|
-
setGapContextMenu(
|
|
155
|
+
setGapContextMenu({
|
|
156
|
+
...anchor,
|
|
157
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
158
|
+
});
|
|
140
159
|
}, []);
|
|
141
160
|
const dismissGapMenu = useCallback(() => {
|
|
142
161
|
setHoveredGapAction(null);
|
|
143
162
|
setGapContextMenu(null);
|
|
144
163
|
}, []);
|
|
145
164
|
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (gapContextMenu && gapContextMenu.sessionEpoch !== sessionEpoch) dismissGapMenu();
|
|
167
|
+
}, [dismissGapMenu, gapContextMenu, sessionEpoch]);
|
|
168
|
+
|
|
146
169
|
return {
|
|
147
170
|
gapMenuModel,
|
|
148
171
|
gapHighlight,
|