@hyperframes/studio 0.7.89 → 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-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-ulQEdWKo.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-CddKwfQ1.js +0 -428
|
@@ -14,20 +14,44 @@ import {
|
|
|
14
14
|
} from "./timelineOptimisticRevision";
|
|
15
15
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
16
16
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
17
|
-
import {
|
|
17
|
+
import type { TimelineElement, usePlayerStore } from "../store/playerStore";
|
|
18
|
+
|
|
19
|
+
export type TimelineGestureKind = "drag" | "resize";
|
|
20
|
+
type TimelineGesturePhase = "active" | "committing" | "cancelled" | "complete";
|
|
21
|
+
|
|
22
|
+
export interface TimelineGestureLifecycle {
|
|
23
|
+
kind: TimelineGestureKind | null;
|
|
24
|
+
phase: TimelineGesturePhase;
|
|
25
|
+
pointerId: number | null;
|
|
26
|
+
sessionEpoch: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TimelineGestureCommit {
|
|
30
|
+
kind: TimelineGestureKind;
|
|
31
|
+
drag: DraggedClipState | null;
|
|
32
|
+
resize: ResizingClipState | null;
|
|
33
|
+
groupResize: TimelineGroupResizeSession | null;
|
|
34
|
+
}
|
|
18
35
|
|
|
19
36
|
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
|
|
20
37
|
|
|
21
38
|
interface TimelineClipDragGestureLifecycleInput {
|
|
39
|
+
lifecycleRef: RefObject<TimelineGestureLifecycle>;
|
|
40
|
+
sessionEpochRef: RefObject<number>;
|
|
41
|
+
cancelGestureRef: RefObject<
|
|
42
|
+
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
|
|
43
|
+
>;
|
|
44
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
22
45
|
draggedClipRef: RefObject<DraggedClipState | null>;
|
|
23
46
|
resizingClipRef: RefObject<ResizingClipState | null>;
|
|
24
47
|
blockedClipRef: RefObject<BlockedClipState | null>;
|
|
25
48
|
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
|
|
26
49
|
suppressClickRef: RefObject<boolean>;
|
|
50
|
+
gestureSelectedKeysRef: RefObject<ReadonlySet<string>>;
|
|
27
51
|
elementsRef: RefObject<TimelineElement[]>;
|
|
28
52
|
trackOrderRef: RefObject<number[]>;
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
setDraggedClipState: Dispatch<SetStateAction<DraggedClipState | null>>;
|
|
54
|
+
setResizingClipState: Dispatch<SetStateAction<ResizingClipState | null>>;
|
|
31
55
|
setShowPopover: (show: boolean) => void;
|
|
32
56
|
setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
|
|
33
57
|
applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
|
|
@@ -36,7 +60,7 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
36
60
|
updateDraggedClipPreviewRef: RefObject<
|
|
37
61
|
(drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
|
|
38
62
|
>;
|
|
39
|
-
|
|
63
|
+
publishDraggedClip: (next: DraggedClipState | null) => void;
|
|
40
64
|
updateElement: UpdateElement;
|
|
41
65
|
onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
|
|
42
66
|
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
|
|
@@ -55,22 +79,27 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
55
79
|
export function mountTimelineClipDragGestureLifecycle({
|
|
56
80
|
// The explicit destructuring mirrors the single call site's dependency object by design.
|
|
57
81
|
// fallow-ignore-next-line code-duplication
|
|
82
|
+
lifecycleRef,
|
|
83
|
+
sessionEpochRef,
|
|
84
|
+
cancelGestureRef,
|
|
85
|
+
scrollRef,
|
|
58
86
|
draggedClipRef,
|
|
59
87
|
resizingClipRef,
|
|
60
88
|
blockedClipRef,
|
|
61
89
|
groupResizeRef,
|
|
62
90
|
suppressClickRef,
|
|
91
|
+
gestureSelectedKeysRef,
|
|
63
92
|
elementsRef,
|
|
64
93
|
trackOrderRef,
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
setDraggedClipState,
|
|
95
|
+
setResizingClipState,
|
|
67
96
|
setShowPopover,
|
|
68
97
|
setRangeSelectionRef,
|
|
69
98
|
applyResizePointerRef,
|
|
70
99
|
syncClipDragAutoScrollRef,
|
|
71
100
|
stopClipDragAutoScrollRef,
|
|
72
101
|
updateDraggedClipPreviewRef,
|
|
73
|
-
|
|
102
|
+
publishDraggedClip,
|
|
74
103
|
updateElement,
|
|
75
104
|
onMoveElementRef,
|
|
76
105
|
onMoveElementsRef,
|
|
@@ -87,9 +116,65 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
87
116
|
});
|
|
88
117
|
};
|
|
89
118
|
|
|
119
|
+
const pointerMatchesGesture = (event: PointerEvent): boolean => {
|
|
120
|
+
const pointerId = lifecycleRef.current.pointerId;
|
|
121
|
+
return pointerId === null || event.pointerId === pointerId;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const releasePointerCapture = (pointerId: number | null) => {
|
|
125
|
+
if (pointerId === null) return;
|
|
126
|
+
const scroll = scrollRef.current;
|
|
127
|
+
try {
|
|
128
|
+
if (scroll?.hasPointerCapture(pointerId)) scroll.releasePointerCapture(pointerId);
|
|
129
|
+
} catch {
|
|
130
|
+
// Window listeners are authoritative when native capture is unavailable.
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const capturePointer = (pointerId: number | null) => {
|
|
135
|
+
if (pointerId === null) return;
|
|
136
|
+
try {
|
|
137
|
+
scrollRef.current?.setPointerCapture(pointerId);
|
|
138
|
+
} catch {
|
|
139
|
+
// Window listeners remain authoritative when native capture is unavailable.
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const clearGestureProjection = (updateReact: boolean) => {
|
|
144
|
+
draggedClipRef.current = null;
|
|
145
|
+
resizingClipRef.current = null;
|
|
146
|
+
groupResizeRef.current = null;
|
|
147
|
+
if (updateReact) {
|
|
148
|
+
setDraggedClipState(null);
|
|
149
|
+
setResizingClipState(null);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const cancelGesture = ({
|
|
154
|
+
updateReact = true,
|
|
155
|
+
suppressClick = false,
|
|
156
|
+
}: {
|
|
157
|
+
updateReact?: boolean;
|
|
158
|
+
suppressClick?: boolean;
|
|
159
|
+
} = {}): boolean => {
|
|
160
|
+
const lifecycle = lifecycleRef.current;
|
|
161
|
+
if (lifecycle.phase !== "active") return false;
|
|
162
|
+
lifecycle.phase = "cancelled";
|
|
163
|
+
stopClipDragAutoScrollRef.current();
|
|
164
|
+
clearGestureProjection(updateReact);
|
|
165
|
+
releasePointerCapture(lifecycle.pointerId);
|
|
166
|
+
if (suppressClick) suppressClickRef.current = true;
|
|
167
|
+
lifecycle.kind = null;
|
|
168
|
+
lifecycle.pointerId = null;
|
|
169
|
+
lifecycle.phase = "complete";
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
cancelGestureRef.current = cancelGesture;
|
|
173
|
+
|
|
90
174
|
const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
|
|
91
175
|
const distance = Math.abs(event.clientX - resize.originClientX);
|
|
92
176
|
if (!resize.started && distance < 2) return;
|
|
177
|
+
if (!resize.started) capturePointer(resize.pointerId);
|
|
93
178
|
setShowPopover(false);
|
|
94
179
|
setRangeSelectionRef.current?.(null);
|
|
95
180
|
applyResizePointerRef.current(resize, event.clientX);
|
|
@@ -106,6 +191,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
106
191
|
if (!blocked.started) {
|
|
107
192
|
blocked.started = true;
|
|
108
193
|
blockedClipRef.current = blocked;
|
|
194
|
+
capturePointer(blocked.pointerId);
|
|
109
195
|
suppressClickRef.current = true;
|
|
110
196
|
setShowPopover(false);
|
|
111
197
|
setRangeSelectionRef.current?.(null);
|
|
@@ -119,34 +205,33 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
119
205
|
event.clientY - drag.originClientY,
|
|
120
206
|
);
|
|
121
207
|
if (!drag.started && distance < 4) return;
|
|
208
|
+
if (!drag.started) capturePointer(drag.pointerId);
|
|
122
209
|
setShowPopover(false);
|
|
123
210
|
setRangeSelectionRef.current?.(null);
|
|
124
|
-
|
|
125
|
-
previous
|
|
126
|
-
? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
|
|
127
|
-
: previous,
|
|
128
|
-
);
|
|
211
|
+
publishDraggedClip(updateDraggedClipPreviewRef.current(drag, event.clientX, event.clientY));
|
|
129
212
|
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
|
|
130
213
|
};
|
|
131
214
|
|
|
132
215
|
const handleWindowPointerMove = (event: PointerEvent) => {
|
|
133
216
|
const resize = resizingClipRef.current;
|
|
134
|
-
if (resize)
|
|
217
|
+
if (resize) {
|
|
218
|
+
if (!pointerMatchesGesture(event)) return;
|
|
219
|
+
return handleResizePointerMove(event, resize);
|
|
220
|
+
}
|
|
135
221
|
const blocked = blockedClipRef.current;
|
|
136
|
-
if (blocked)
|
|
222
|
+
if (blocked) {
|
|
223
|
+
if (blocked.pointerId !== event.pointerId) return;
|
|
224
|
+
return handleBlockedPointerMove(event, blocked);
|
|
225
|
+
}
|
|
137
226
|
const drag = draggedClipRef.current;
|
|
138
|
-
if (drag) handleDragPointerMove(event, drag);
|
|
227
|
+
if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
|
|
139
228
|
};
|
|
140
229
|
|
|
141
|
-
const commitResizePointerUp = (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!resize.started) {
|
|
147
|
-
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
230
|
+
const commitResizePointerUp = (
|
|
231
|
+
resize: ResizingClipState,
|
|
232
|
+
groupSession: TimelineGroupResizeSession | null,
|
|
233
|
+
) => {
|
|
234
|
+
if (!resize.started) return;
|
|
150
235
|
suppressClickRef.current = true;
|
|
151
236
|
clearSuppressedClick();
|
|
152
237
|
if (groupSession) {
|
|
@@ -193,8 +278,6 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
193
278
|
};
|
|
194
279
|
|
|
195
280
|
const commitDragPointerUp = (drag: DraggedClipState) => {
|
|
196
|
-
draggedClipRef.current = null;
|
|
197
|
-
setDraggedClip(null);
|
|
198
281
|
if (!drag.started) return;
|
|
199
282
|
suppressClickRef.current = true;
|
|
200
283
|
clearSuppressedClick();
|
|
@@ -204,25 +287,96 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
204
287
|
updateElement,
|
|
205
288
|
onMoveElement: onMoveElementRef.current,
|
|
206
289
|
onMoveElements: onMoveElementsRef.current,
|
|
207
|
-
selectedKeys:
|
|
290
|
+
selectedKeys: gestureSelectedKeysRef.current,
|
|
208
291
|
readZIndex: readZIndexRef.current,
|
|
209
292
|
onStackingPatches: onStackingPatchesRef.current,
|
|
210
293
|
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
211
294
|
});
|
|
212
295
|
};
|
|
213
296
|
|
|
214
|
-
|
|
297
|
+
/** Group gestures commit atomically: one missing member cancels the whole resize. */
|
|
298
|
+
const gestureSourcesStillExist = (): boolean => {
|
|
299
|
+
const gestureElements = groupResizeRef.current?.members.map((member) => member.element) ?? [
|
|
300
|
+
resizingClipRef.current?.element ?? draggedClipRef.current?.element,
|
|
301
|
+
];
|
|
302
|
+
const liveKeys = new Set(elementsRef.current.map((element) => element.key ?? element.id));
|
|
303
|
+
return gestureElements.every(
|
|
304
|
+
(element) => element === undefined || liveKeys.has(element.key ?? element.id),
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
|
|
309
|
+
const lifecycle = lifecycleRef.current;
|
|
310
|
+
if (lifecycle.phase !== "active") return null;
|
|
311
|
+
if (!pointerMatchesGesture(event)) return "ignored";
|
|
312
|
+
if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
|
|
313
|
+
cancelGesture();
|
|
314
|
+
return "ignored";
|
|
315
|
+
}
|
|
316
|
+
if (!gestureSourcesStillExist()) {
|
|
317
|
+
cancelGesture();
|
|
318
|
+
return "ignored";
|
|
319
|
+
}
|
|
320
|
+
lifecycle.phase = "committing";
|
|
321
|
+
const claimed = lifecycle.kind
|
|
322
|
+
? {
|
|
323
|
+
kind: lifecycle.kind,
|
|
324
|
+
drag: draggedClipRef.current,
|
|
325
|
+
resize: resizingClipRef.current,
|
|
326
|
+
groupResize: groupResizeRef.current,
|
|
327
|
+
}
|
|
328
|
+
: "ignored";
|
|
215
329
|
stopClipDragAutoScrollRef.current();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
330
|
+
clearGestureProjection(true);
|
|
331
|
+
releasePointerCapture(lifecycle.pointerId);
|
|
332
|
+
if (claimed === "ignored") lifecycle.phase = "complete";
|
|
333
|
+
return claimed;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const commitClaimedGesture = (gesture: TimelineGestureCommit) => {
|
|
337
|
+
try {
|
|
338
|
+
if (gesture.kind === "resize" && gesture.resize) {
|
|
339
|
+
commitResizePointerUp(gesture.resize, gesture.groupResize);
|
|
340
|
+
} else if (gesture.kind === "drag" && gesture.drag) {
|
|
341
|
+
commitDragPointerUp(gesture.drag);
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
const lifecycle = lifecycleRef.current;
|
|
345
|
+
lifecycle.kind = null;
|
|
346
|
+
lifecycle.pointerId = null;
|
|
347
|
+
lifecycle.phase = "complete";
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const handleWindowPointerUp = (event: PointerEvent) => {
|
|
352
|
+
const claimed = claimActiveGesture(event);
|
|
353
|
+
if (claimed === "ignored") return;
|
|
354
|
+
if (claimed) {
|
|
355
|
+
commitClaimedGesture(claimed);
|
|
223
356
|
return;
|
|
224
357
|
}
|
|
225
|
-
|
|
358
|
+
const blocked = blockedClipRef.current;
|
|
359
|
+
if (blocked) {
|
|
360
|
+
if (blocked.pointerId !== event.pointerId) return;
|
|
361
|
+
return finishBlockedPointerUp(blocked);
|
|
362
|
+
}
|
|
363
|
+
if (suppressClickRef.current) clearSuppressedClick();
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const handleWindowPointerCancel = (event: PointerEvent) => {
|
|
367
|
+
if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
|
|
368
|
+
if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
|
|
369
|
+
}
|
|
370
|
+
const blocked = blockedClipRef.current;
|
|
371
|
+
if (blocked && blocked.pointerId !== event.pointerId) return;
|
|
372
|
+
blockedClipRef.current = null;
|
|
373
|
+
if (suppressClickRef.current) clearSuppressedClick();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const handleLostPointerCapture = (event: PointerEvent) => {
|
|
377
|
+
if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
|
|
378
|
+
if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
|
|
379
|
+
}
|
|
226
380
|
};
|
|
227
381
|
|
|
228
382
|
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
|
@@ -234,28 +388,22 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
234
388
|
});
|
|
235
389
|
if (!decision.cancel) return;
|
|
236
390
|
event.preventDefault();
|
|
237
|
-
event.stopPropagation();
|
|
238
|
-
stopClipDragAutoScrollRef.current();
|
|
239
|
-
draggedClipRef.current = null;
|
|
240
|
-
setDraggedClip(null);
|
|
241
|
-
resizingClipRef.current = null;
|
|
242
|
-
setResizingClip(null);
|
|
243
|
-
const groupSession = groupResizeRef.current;
|
|
244
|
-
groupResizeRef.current = null;
|
|
245
|
-
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
246
391
|
blockedClipRef.current = null;
|
|
247
|
-
|
|
392
|
+
cancelGesture({ suppressClick: decision.suppressClick });
|
|
248
393
|
};
|
|
249
394
|
|
|
250
395
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
251
396
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
252
|
-
window.addEventListener("pointercancel",
|
|
253
|
-
window.addEventListener("
|
|
397
|
+
window.addEventListener("pointercancel", handleWindowPointerCancel);
|
|
398
|
+
window.addEventListener("lostpointercapture", handleLostPointerCapture);
|
|
399
|
+
window.addEventListener("keydown", handleWindowKeyDown);
|
|
254
400
|
return () => {
|
|
255
|
-
|
|
401
|
+
cancelGesture({ updateReact: false });
|
|
402
|
+
cancelGestureRef.current = () => false;
|
|
256
403
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
257
404
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
258
|
-
window.removeEventListener("pointercancel",
|
|
259
|
-
window.removeEventListener("
|
|
405
|
+
window.removeEventListener("pointercancel", handleWindowPointerCancel);
|
|
406
|
+
window.removeEventListener("lostpointercapture", handleLostPointerCapture);
|
|
407
|
+
window.removeEventListener("keydown", handleWindowKeyDown);
|
|
260
408
|
};
|
|
261
409
|
}
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import {
|
|
4
4
|
computeDragPreview,
|
|
5
5
|
computeResizePreview,
|
|
6
|
+
getTimelineDragOverlayPosition,
|
|
6
7
|
type DragPreviewContext,
|
|
7
8
|
} from "./timelineClipDragPreview";
|
|
8
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -84,6 +85,7 @@ function horizontalDrag(
|
|
|
84
85
|
const originClientX = 800;
|
|
85
86
|
const originClientY = yForRow(grabRowFloat);
|
|
86
87
|
const drag: DraggedClipState = {
|
|
88
|
+
pointerId: 0,
|
|
87
89
|
element,
|
|
88
90
|
originClientX,
|
|
89
91
|
originClientY,
|
|
@@ -129,6 +131,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
129
131
|
const originClientX = 800;
|
|
130
132
|
const originClientY = yForRow(0.5);
|
|
131
133
|
const drag: DraggedClipState = {
|
|
134
|
+
pointerId: 0,
|
|
132
135
|
element: moodboard,
|
|
133
136
|
originClientX,
|
|
134
137
|
originClientY,
|
|
@@ -170,6 +173,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
170
173
|
const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
|
|
171
174
|
const clientY = RULER_H + TRACKS_TOP_PAD + 30;
|
|
172
175
|
const drag: DraggedClipState = {
|
|
176
|
+
pointerId: 0,
|
|
173
177
|
element: dragged,
|
|
174
178
|
originClientX: 0,
|
|
175
179
|
originClientY: clientY,
|
|
@@ -221,3 +225,32 @@ describe("computeResizePreview — composition source continuity", () => {
|
|
|
221
225
|
});
|
|
222
226
|
});
|
|
223
227
|
});
|
|
228
|
+
|
|
229
|
+
describe("getTimelineDragOverlayPosition", () => {
|
|
230
|
+
it("keeps the gesture actor under the pointer across two-axis autoscroll", () => {
|
|
231
|
+
const { drag } = horizontalDrag(moodboard, 0.5, 2);
|
|
232
|
+
const scroll = {
|
|
233
|
+
scrollLeft: 500,
|
|
234
|
+
scrollTop: 300,
|
|
235
|
+
getBoundingClientRect: () => ({ left: 20, top: 40 }),
|
|
236
|
+
} as Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect">;
|
|
237
|
+
expect(
|
|
238
|
+
getTimelineDragOverlayPosition(
|
|
239
|
+
{
|
|
240
|
+
...drag,
|
|
241
|
+
pointerClientX: 900,
|
|
242
|
+
pointerClientY: 700,
|
|
243
|
+
pointerOffsetX: 25,
|
|
244
|
+
pointerOffsetY: 10,
|
|
245
|
+
},
|
|
246
|
+
scroll,
|
|
247
|
+
),
|
|
248
|
+
).toEqual({ left: 1_355, top: 950 });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("does not mount an actor before threshold or without the stable viewport", () => {
|
|
252
|
+
const { drag } = horizontalDrag(moodboard, 0.5, 2);
|
|
253
|
+
expect(getTimelineDragOverlayPosition({ ...drag, started: false }, fakeScroll())).toBeNull();
|
|
254
|
+
expect(getTimelineDragOverlayPosition(drag, null)).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -45,6 +45,19 @@ export interface DragPreviewContext {
|
|
|
45
45
|
audioTracks?: ReadonlySet<number>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Content-space position for the stable viewport drag actor. */
|
|
49
|
+
export function getTimelineDragOverlayPosition(
|
|
50
|
+
drag: DraggedClipState,
|
|
51
|
+
scroll: Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect"> | null,
|
|
52
|
+
): { left: number; top: number } | null {
|
|
53
|
+
if (!drag.started || !scroll) return null;
|
|
54
|
+
const rect = scroll.getBoundingClientRect();
|
|
55
|
+
return {
|
|
56
|
+
left: drag.pointerClientX - rect.left + scroll.scrollLeft - drag.pointerOffsetX,
|
|
57
|
+
top: drag.pointerClientY - rect.top + scroll.scrollTop - drag.pointerOffsetY,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* Max start a drag may reach. Allow dragging past the current content into the
|
|
50
63
|
* rendered timeline extent (the viewport-fill keeps that ≥ the viewport width).
|
|
@@ -328,29 +341,22 @@ export function computeResizePreview(
|
|
|
328
341
|
|
|
329
342
|
/**
|
|
330
343
|
* Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
|
|
331
|
-
* session
|
|
332
|
-
*
|
|
333
|
-
* its store value stays pristine until commit — like the single-clip path).
|
|
344
|
+
* session and publish a coordinator-owned projection. Canonical elements stay
|
|
345
|
+
* pristine until the exactly-once commit.
|
|
334
346
|
*/
|
|
335
347
|
export function previewGroupResize(
|
|
336
348
|
session: TimelineGroupResizeSession,
|
|
337
349
|
next: ResizePreviewResult,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
key: string,
|
|
341
|
-
patch: { start: number; duration: number; playbackStart?: number },
|
|
350
|
+
setResizeState: (
|
|
351
|
+
v: ResizePreviewResult & { groupPreview: TimelineGroupResizeSession["changes"] },
|
|
342
352
|
) => void,
|
|
343
|
-
setResizeState: (v: ResizePreviewResult) => void,
|
|
344
353
|
): void {
|
|
345
354
|
const grabbedChange = applyTimelineGroupResizePreview(session, next);
|
|
346
|
-
for (const c of session.changes) {
|
|
347
|
-
if (c.key === grabbedKey) continue;
|
|
348
|
-
updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
|
|
349
|
-
}
|
|
350
355
|
setResizeState({
|
|
351
356
|
originScrollLeft: next.originScrollLeft,
|
|
352
357
|
previewStart: grabbedChange?.start ?? next.previewStart,
|
|
353
358
|
previewDuration: grabbedChange?.duration ?? next.previewDuration,
|
|
354
359
|
previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
|
|
360
|
+
groupPreview: session.changes,
|
|
355
361
|
});
|
|
356
362
|
}
|
|
@@ -4,6 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
|
4
4
|
|
|
5
5
|
/* ── Shared clip-drag state types ───────────────────────────────── */
|
|
6
6
|
export interface DraggedClipState {
|
|
7
|
+
/** Pointer captured by the stable viewport coordinator for this gesture. */
|
|
8
|
+
pointerId: number;
|
|
7
9
|
element: TimelineElement;
|
|
8
10
|
originClientX: number;
|
|
9
11
|
originClientY: number;
|
|
@@ -39,6 +41,8 @@ export interface DraggedClipState {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface ResizingClipState {
|
|
44
|
+
/** Pointer captured by the stable viewport coordinator for this gesture. */
|
|
45
|
+
pointerId: number;
|
|
42
46
|
element: TimelineElement;
|
|
43
47
|
edge: "start" | "end";
|
|
44
48
|
originClientX: number;
|
|
@@ -53,10 +57,18 @@ export interface ResizingClipState {
|
|
|
53
57
|
previewStart: number;
|
|
54
58
|
previewDuration: number;
|
|
55
59
|
previewPlaybackStart?: number;
|
|
60
|
+
/** Coordinator-owned group projection; canonical elements change only on commit. */
|
|
61
|
+
groupPreview?: readonly {
|
|
62
|
+
key: string;
|
|
63
|
+
start: number;
|
|
64
|
+
duration: number;
|
|
65
|
+
playbackStart?: number;
|
|
66
|
+
}[];
|
|
56
67
|
started: boolean;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
export interface BlockedClipState {
|
|
71
|
+
pointerId: number;
|
|
60
72
|
element: TimelineElement;
|
|
61
73
|
intent: BlockedTimelineEditIntent;
|
|
62
74
|
originClientX: number;
|
|
@@ -93,20 +93,6 @@ export const DIAMOND_RATIO = 0.8;
|
|
|
93
93
|
export const KF_MIN_PCT = -5;
|
|
94
94
|
export const KF_MAX_PCT = 105;
|
|
95
95
|
|
|
96
|
-
export type DragState = {
|
|
97
|
-
kfKey: string;
|
|
98
|
-
startX: number;
|
|
99
|
-
fromClipPct: number;
|
|
100
|
-
moved: boolean;
|
|
101
|
-
/** Latest pointer x, flushed to the preview once per frame. */
|
|
102
|
-
lastX: number;
|
|
103
|
-
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
|
|
104
|
-
index: number;
|
|
105
|
-
/** Escape was pressed: the drag is dead, and the pointerup that follows is
|
|
106
|
-
* swallowed rather than falling through to the click branch. */
|
|
107
|
-
cancelled?: boolean;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
96
|
/**
|
|
111
97
|
* The full identity of a diamond, used by every callback and by the selection
|
|
112
98
|
* key. Collapsed clip rows and expanded property lanes read the same cache, so
|
|
@@ -115,7 +101,11 @@ export type DragState = {
|
|
|
115
101
|
* the other, and would strip the animation id the retime/delete mutations use to
|
|
116
102
|
* pick between two animations that collide at one percentage.
|
|
117
103
|
*/
|
|
118
|
-
export function keyframeTarget(
|
|
104
|
+
export function keyframeTarget(
|
|
105
|
+
// The identity fields only, so callers holding a narrower keyframe row (the
|
|
106
|
+
// retime coordinator's) build the same key instead of re-listing the shape.
|
|
107
|
+
keyframe: Omit<TimelineDiamondKeyframe, "properties">,
|
|
108
|
+
): TimelineKeyframeTarget {
|
|
119
109
|
return {
|
|
120
110
|
percentage: keyframe.percentage,
|
|
121
111
|
tweenPercentage: keyframe.tweenPercentage,
|