@hyperframes/studio 0.7.76 → 0.7.77
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-DsRdxz7A.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BLICKger.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-Du1RoLiB.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -3,9 +3,8 @@ import { liveTime, type ZoomMode } from "../store/playerStore";
|
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
5
|
import {
|
|
6
|
-
GUTTER,
|
|
7
|
-
TRACKS_LEFT_PAD,
|
|
8
6
|
getTimelinePlayheadLeft,
|
|
7
|
+
getTimelineScrubTime,
|
|
9
8
|
getTimelineScrollLeftForZoomTransition,
|
|
10
9
|
getTimelineScrollLeftForZoomAnchor,
|
|
11
10
|
shouldAutoScrollTimeline,
|
|
@@ -31,6 +30,7 @@ interface UseTimelinePlayheadInput {
|
|
|
31
30
|
setZoomMode: (mode: ZoomMode) => void;
|
|
32
31
|
setManualZoomPercent: (percent: number) => void;
|
|
33
32
|
onSeek?: (time: number) => void;
|
|
33
|
+
contentOrigin: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function useTimelinePlayhead({
|
|
@@ -52,6 +52,7 @@ export function useTimelinePlayhead({
|
|
|
52
52
|
setZoomMode,
|
|
53
53
|
setManualZoomPercent,
|
|
54
54
|
onSeek,
|
|
55
|
+
contentOrigin,
|
|
55
56
|
}: UseTimelinePlayheadInput) {
|
|
56
57
|
const dragScrollRaf = useRef(0);
|
|
57
58
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
@@ -60,6 +61,8 @@ export function useTimelinePlayhead({
|
|
|
60
61
|
// anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
|
|
61
62
|
const previousAnchorPpsRef = useRef(pps);
|
|
62
63
|
const skipCenterAnchorRef = useRef(false);
|
|
64
|
+
const contentOriginRef = useRef(contentOrigin);
|
|
65
|
+
contentOriginRef.current = contentOrigin;
|
|
63
66
|
|
|
64
67
|
useLayoutEffect(() => {
|
|
65
68
|
const scroll = scrollRef.current;
|
|
@@ -74,21 +77,21 @@ export function useTimelinePlayhead({
|
|
|
74
77
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
75
78
|
pointerX: scroll.clientWidth / 2,
|
|
76
79
|
currentScrollLeft: scroll.scrollLeft,
|
|
77
|
-
|
|
80
|
+
contentOrigin,
|
|
78
81
|
currentPixelsPerSecond: prevPps,
|
|
79
82
|
nextPixelsPerSecond: pps,
|
|
80
83
|
duration: durationRef.current,
|
|
81
84
|
});
|
|
82
85
|
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
83
86
|
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
|
|
84
|
-
}, [pps, scrollRef, durationRef]);
|
|
87
|
+
}, [pps, scrollRef, durationRef, contentOrigin]);
|
|
85
88
|
|
|
86
89
|
const syncPlayheadPosition = useCallback(
|
|
87
90
|
(time: number) => {
|
|
88
91
|
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
89
|
-
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
|
|
92
|
+
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current, contentOrigin)}px`;
|
|
90
93
|
},
|
|
91
|
-
[playheadRef, durationRef, ppsRef],
|
|
94
|
+
[playheadRef, durationRef, ppsRef, contentOrigin],
|
|
92
95
|
);
|
|
93
96
|
|
|
94
97
|
useEffect(() => {
|
|
@@ -120,7 +123,7 @@ export function useTimelinePlayhead({
|
|
|
120
123
|
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
121
124
|
// Playback deliberately does NOT scroll the viewport to chase the playhead —
|
|
122
125
|
// the user's scroll position is theirs; the playhead may run off-screen.
|
|
123
|
-
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current)}px`;
|
|
126
|
+
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
|
|
124
127
|
});
|
|
125
128
|
return unsub;
|
|
126
129
|
});
|
|
@@ -130,13 +133,18 @@ export function useTimelinePlayhead({
|
|
|
130
133
|
const el = scrollRef.current;
|
|
131
134
|
if (!el || effectiveDuration <= 0) return;
|
|
132
135
|
const rect = el.getBoundingClientRect();
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
const time = getTimelineScrubTime({
|
|
137
|
+
clientX,
|
|
138
|
+
viewportLeft: rect.left,
|
|
139
|
+
scrollLeft: el.scrollLeft,
|
|
140
|
+
contentOrigin,
|
|
141
|
+
pixelsPerSecond: pps,
|
|
142
|
+
duration: effectiveDuration,
|
|
143
|
+
});
|
|
136
144
|
liveTime.notify(time);
|
|
137
145
|
onSeek?.(time);
|
|
138
146
|
},
|
|
139
|
-
[scrollRef, effectiveDuration, pps, onSeek],
|
|
147
|
+
[scrollRef, effectiveDuration, pps, onSeek, contentOrigin],
|
|
140
148
|
);
|
|
141
149
|
|
|
142
150
|
const autoScrollDuringDrag = useCallback(
|
|
@@ -186,7 +194,7 @@ export function useTimelinePlayhead({
|
|
|
186
194
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
187
195
|
pointerX: e.clientX - rect.left,
|
|
188
196
|
currentScrollLeft: scroll.scrollLeft,
|
|
189
|
-
|
|
197
|
+
contentOrigin,
|
|
190
198
|
currentPixelsPerSecond: ppsRef.current,
|
|
191
199
|
nextPixelsPerSecond: nextPps,
|
|
192
200
|
duration: durationRef.current,
|
|
@@ -209,6 +217,7 @@ export function useTimelinePlayhead({
|
|
|
209
217
|
manualZoomPercentRef,
|
|
210
218
|
setManualZoomPercent,
|
|
211
219
|
setZoomMode,
|
|
220
|
+
contentOrigin,
|
|
212
221
|
],
|
|
213
222
|
);
|
|
214
223
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./timelineEditing";
|
|
8
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
9
9
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
10
|
-
import {
|
|
10
|
+
import { getTimelineScrubTime } from "./timelineLayout";
|
|
11
11
|
import {
|
|
12
12
|
computeMarqueeSelection,
|
|
13
13
|
getMarqueeRect,
|
|
@@ -30,7 +30,9 @@ interface UseTimelineRangeSelectionInput {
|
|
|
30
30
|
setShowPopover: (v: boolean) => void;
|
|
31
31
|
elementsRef: React.RefObject<TimelineElement[]>;
|
|
32
32
|
trackOrderRef: React.RefObject<number[]>;
|
|
33
|
+
rowHeightsRef: React.RefObject<readonly number[]>;
|
|
33
34
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
35
|
+
contentOrigin: number;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface MarqueeDragState {
|
|
@@ -72,12 +74,16 @@ function commitMarqueeSelection(
|
|
|
72
74
|
marquee: MarqueeDragState,
|
|
73
75
|
elements: TimelineElement[],
|
|
74
76
|
trackOrder: number[],
|
|
77
|
+
rowHeights: readonly number[],
|
|
75
78
|
pps: number,
|
|
79
|
+
contentOrigin: number,
|
|
76
80
|
): void {
|
|
77
81
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
78
82
|
clips: toMarqueeClips(elements),
|
|
79
83
|
trackOrder,
|
|
84
|
+
rowHeights,
|
|
80
85
|
pps,
|
|
86
|
+
contentOrigin,
|
|
81
87
|
marquee: rect,
|
|
82
88
|
baseSelection: additive ? marquee.baseIds : undefined,
|
|
83
89
|
});
|
|
@@ -101,7 +107,9 @@ export function useTimelineRangeSelection({
|
|
|
101
107
|
setShowPopover,
|
|
102
108
|
elementsRef,
|
|
103
109
|
trackOrderRef,
|
|
110
|
+
rowHeightsRef,
|
|
104
111
|
onSelectElement,
|
|
112
|
+
contentOrigin,
|
|
105
113
|
}: UseTimelineRangeSelectionInput) {
|
|
106
114
|
const isRangeSelecting = useRef(false);
|
|
107
115
|
const rangeAnchorTime = useRef(0);
|
|
@@ -168,10 +176,12 @@ export function useTimelineRangeSelection({
|
|
|
168
176
|
marquee,
|
|
169
177
|
elementsRef.current ?? [],
|
|
170
178
|
trackOrderRef.current ?? [],
|
|
179
|
+
rowHeightsRef.current,
|
|
171
180
|
ppsRef.current,
|
|
181
|
+
contentOrigin,
|
|
172
182
|
);
|
|
173
183
|
},
|
|
174
|
-
[toContentPoint, elementsRef, trackOrderRef, ppsRef],
|
|
184
|
+
[toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
|
|
175
185
|
);
|
|
176
186
|
|
|
177
187
|
const stopMarqueeAutoScroll = useCallback(() => {
|
|
@@ -228,14 +238,13 @@ export function useTimelineRangeSelection({
|
|
|
228
238
|
setShowPopover(false);
|
|
229
239
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
230
240
|
if (rect) {
|
|
231
|
-
const x =
|
|
232
|
-
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
|
241
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
|
233
242
|
const time = Math.max(0, x / pps);
|
|
234
243
|
rangeAnchorTime.current = time;
|
|
235
244
|
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
236
245
|
}
|
|
237
246
|
},
|
|
238
|
-
[scrollRef, pps, setShowPopover],
|
|
247
|
+
[scrollRef, pps, setShowPopover, contentOrigin],
|
|
239
248
|
);
|
|
240
249
|
|
|
241
250
|
const handlePointerDown = useCallback(
|
|
@@ -286,11 +295,16 @@ export function useTimelineRangeSelection({
|
|
|
286
295
|
const el = scrollRef.current;
|
|
287
296
|
if (el) {
|
|
288
297
|
const rect = el.getBoundingClientRect();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
298
|
+
liveTime.notify(
|
|
299
|
+
getTimelineScrubTime({
|
|
300
|
+
clientX,
|
|
301
|
+
viewportLeft: rect.left,
|
|
302
|
+
scrollLeft: el.scrollLeft,
|
|
303
|
+
contentOrigin,
|
|
304
|
+
pixelsPerSecond: pps,
|
|
305
|
+
duration: el.scrollWidth / pps,
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
294
308
|
}
|
|
295
309
|
if (!seekRafRef.current) {
|
|
296
310
|
seekRafRef.current = requestAnimationFrame(() => {
|
|
@@ -302,7 +316,7 @@ export function useTimelineRangeSelection({
|
|
|
302
316
|
});
|
|
303
317
|
}
|
|
304
318
|
},
|
|
305
|
-
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
|
|
319
|
+
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
|
306
320
|
);
|
|
307
321
|
|
|
308
322
|
const handlePointerMove = useCallback(
|
|
@@ -310,8 +324,7 @@ export function useTimelineRangeSelection({
|
|
|
310
324
|
if (isRangeSelecting.current) {
|
|
311
325
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
312
326
|
if (rect) {
|
|
313
|
-
const x =
|
|
314
|
-
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
|
327
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
|
315
328
|
setRangeSelection((prev) =>
|
|
316
329
|
prev
|
|
317
330
|
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
|
@@ -331,7 +344,15 @@ export function useTimelineRangeSelection({
|
|
|
331
344
|
if (!isDragging.current) return;
|
|
332
345
|
updateScrubDrag(e.clientX);
|
|
333
346
|
},
|
|
334
|
-
[
|
|
347
|
+
[
|
|
348
|
+
pps,
|
|
349
|
+
scrollRef,
|
|
350
|
+
isDragging,
|
|
351
|
+
applyMarqueeAtClient,
|
|
352
|
+
syncMarqueeAutoScroll,
|
|
353
|
+
updateScrubDrag,
|
|
354
|
+
contentOrigin,
|
|
355
|
+
],
|
|
335
356
|
);
|
|
336
357
|
|
|
337
358
|
// Release of a shift time-range gesture: keep a real range (or a shift-click
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { LANE_H, TRACK_H } from "./timelineLayout";
|
|
9
|
+
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
|
10
|
+
|
|
11
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("useTimelineTrackLayout", () => {
|
|
18
|
+
it("counts a flat tween lane and reserves its expanded row height", () => {
|
|
19
|
+
const elements: TimelineElement[] = [
|
|
20
|
+
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
21
|
+
];
|
|
22
|
+
const animations = new Map<string, GsapAnimation[]>([
|
|
23
|
+
[
|
|
24
|
+
"clip-1",
|
|
25
|
+
[
|
|
26
|
+
{
|
|
27
|
+
id: "position-tween",
|
|
28
|
+
targetSelector: "#clip-1",
|
|
29
|
+
method: "to",
|
|
30
|
+
position: 0,
|
|
31
|
+
duration: 1,
|
|
32
|
+
properties: { x: 420 },
|
|
33
|
+
propertyGroup: "position",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
],
|
|
37
|
+
]);
|
|
38
|
+
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
|
39
|
+
|
|
40
|
+
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
41
|
+
function Probe() {
|
|
42
|
+
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const root = createRoot(document.createElement("div"));
|
|
47
|
+
act(() => root.render(React.createElement(Probe)));
|
|
48
|
+
|
|
49
|
+
expect(layout?.laneCounts.get("clip-1")).toBe(1);
|
|
50
|
+
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
|
51
|
+
act(() => root.unmount());
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { animationContributesLane } from "./TimelinePropertyLanes";
|
|
4
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
|
+
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
6
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
7
|
+
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
8
|
+
import {
|
|
9
|
+
TRACK_H,
|
|
10
|
+
getTimelineCanvasHeight,
|
|
11
|
+
getTimelineRowHeight,
|
|
12
|
+
trackHeights,
|
|
13
|
+
type TimelineTrackHeightClip,
|
|
14
|
+
} from "./timelineLayout";
|
|
15
|
+
|
|
16
|
+
export { getTrackStyle } from "./timelineIcons";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The single keyframed element whose property lanes a track shows when expanded.
|
|
20
|
+
* A track can hold several elements (same z-index is common), but keyframes are
|
|
21
|
+
* per-element, so we scope to ONE active element — the selected one if it's on
|
|
22
|
+
* this track, otherwise the element with the most lanes. Selecting a clip is how
|
|
23
|
+
* you switch which element you're keyframing. Returns null when no element on the
|
|
24
|
+
* track has keyframes.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveTrackKeyframeClip(
|
|
27
|
+
elements: readonly TimelineElement[],
|
|
28
|
+
laneCounts: ReadonlyMap<string, number>,
|
|
29
|
+
selectedElementId: string | null,
|
|
30
|
+
selectedElementIds: ReadonlySet<string>,
|
|
31
|
+
): TimelineElement | null {
|
|
32
|
+
const keyframed = elements.filter(
|
|
33
|
+
(element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1,
|
|
34
|
+
);
|
|
35
|
+
if (keyframed.length === 0) return null;
|
|
36
|
+
const selected = keyframed.find((element) => {
|
|
37
|
+
const key = element.key ?? element.id;
|
|
38
|
+
return key === selectedElementId || selectedElementIds.has(key);
|
|
39
|
+
});
|
|
40
|
+
if (selected) return selected;
|
|
41
|
+
// Most lanes wins, first one on a tie (same as the old stable sort), but as a
|
|
42
|
+
// reduce over the already non-empty list so there's no index to assert on.
|
|
43
|
+
const lanesOf = (element: TimelineElement) => laneCounts.get(element.key ?? element.id) ?? 0;
|
|
44
|
+
return keyframed.reduce((best, element) => (lanesOf(element) > lanesOf(best) ? element : best));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Lanes per clip: the count of distinct property groups whose tween contributes
|
|
48
|
+
* a lane (real keyframes or a synthesizable flat tween). */
|
|
49
|
+
function computeLaneCounts(
|
|
50
|
+
tracks: [number, TimelineElement[]][],
|
|
51
|
+
gsapAnimations: Map<string, GsapAnimation[]>,
|
|
52
|
+
): Map<string, number> {
|
|
53
|
+
const laneCounts = new Map<string, number>();
|
|
54
|
+
for (const [, elements] of tracks) {
|
|
55
|
+
for (const element of elements) {
|
|
56
|
+
const clipId = element.key ?? element.id;
|
|
57
|
+
const propertyGroups = new Set<string>();
|
|
58
|
+
for (const animation of gsapAnimations.get(clipId) ?? []) {
|
|
59
|
+
if (animation.propertyGroup && animationContributesLane(animation)) {
|
|
60
|
+
propertyGroups.add(animation.propertyGroup);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
laneCounts.set(clipId, propertyGroups.size);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return laneCounts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function useTimelineRowHeights(
|
|
70
|
+
tracks: [number, TimelineElement[]][],
|
|
71
|
+
gsapAnimations: Map<string, GsapAnimation[]>,
|
|
72
|
+
selectedElementId: string | null,
|
|
73
|
+
selectedElementIds: ReadonlySet<string>,
|
|
74
|
+
) {
|
|
75
|
+
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
76
|
+
const { laneCounts, rowHeights } = useMemo(() => {
|
|
77
|
+
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
|
78
|
+
// Row height follows only the active keyframe clip, so a track with several
|
|
79
|
+
// keyframed elements never reserves empty lanes for the ones not shown.
|
|
80
|
+
const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
|
|
81
|
+
const active = resolveTrackKeyframeClip(
|
|
82
|
+
elements,
|
|
83
|
+
laneCounts,
|
|
84
|
+
selectedElementId,
|
|
85
|
+
selectedElementIds,
|
|
86
|
+
);
|
|
87
|
+
if (!active) return [];
|
|
88
|
+
const clipId = active.key ?? active.id;
|
|
89
|
+
return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
laneCounts,
|
|
93
|
+
rowHeights: trackHeights(
|
|
94
|
+
heightTracks,
|
|
95
|
+
STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined,
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
|
99
|
+
const rowHeightsRef = useRef<readonly number[]>(rowHeights);
|
|
100
|
+
rowHeightsRef.current = rowHeights;
|
|
101
|
+
return { laneCounts, rowHeights, rowHeightsRef };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useTimelineTrackLayout(
|
|
105
|
+
expandedElements: TimelineElement[],
|
|
106
|
+
gsapAnimations: Map<string, GsapAnimation[]>,
|
|
107
|
+
selectedElementId: string | null,
|
|
108
|
+
selectedElementIds: ReadonlySet<string>,
|
|
109
|
+
) {
|
|
110
|
+
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
|
111
|
+
const trackOrderRef = useRef(trackOrder);
|
|
112
|
+
trackOrderRef.current = trackOrder;
|
|
113
|
+
const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
|
|
114
|
+
tracks,
|
|
115
|
+
gsapAnimations,
|
|
116
|
+
selectedElementId,
|
|
117
|
+
selectedElementIds,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
tracks,
|
|
122
|
+
trackStyles,
|
|
123
|
+
trackOrder,
|
|
124
|
+
trackOrderRef,
|
|
125
|
+
laneCounts,
|
|
126
|
+
rowHeights,
|
|
127
|
+
rowHeightsRef,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function useDisplayRowHeights(
|
|
132
|
+
displayTrackOrder: readonly number[],
|
|
133
|
+
trackOrder: readonly number[],
|
|
134
|
+
rowHeights: readonly number[],
|
|
135
|
+
) {
|
|
136
|
+
return useMemo(
|
|
137
|
+
() =>
|
|
138
|
+
displayTrackOrder.map((track) => {
|
|
139
|
+
const row = trackOrder.indexOf(track);
|
|
140
|
+
return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
|
|
141
|
+
}),
|
|
142
|
+
[displayTrackOrder, trackOrder, rowHeights],
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
|
|
147
|
+
return useMemo(() => {
|
|
148
|
+
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
|
149
|
+
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
150
|
+
}, [draggedClip, trackOrder]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function useTimelineDisplayLayout(
|
|
154
|
+
draggedClip: DraggedClipState | null,
|
|
155
|
+
trackOrder: number[],
|
|
156
|
+
rowHeights: readonly number[],
|
|
157
|
+
) {
|
|
158
|
+
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
|
|
159
|
+
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
|
|
160
|
+
const totalH = getTimelineCanvasHeight(displayRowHeights);
|
|
161
|
+
return { displayTrackOrder, displayRowHeights, totalH };
|
|
162
|
+
}
|
|
@@ -278,6 +278,49 @@ describe("resolveTimelineExpansionRawId", () => {
|
|
|
278
278
|
).toBe("scene");
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
it("THE BUG: keeps a composition expanded with the playhead parked on its end", () => {
|
|
282
|
+
// Clip windows are half-open, so at the very end of the timeline the
|
|
283
|
+
// playhead was inside nothing and every expanded row collapsed.
|
|
284
|
+
const manifest = [
|
|
285
|
+
clip({ id: "scene", start: 0, duration: 12 }),
|
|
286
|
+
clip({ id: "headline", start: 0, duration: 12 }),
|
|
287
|
+
];
|
|
288
|
+
const parentMap = new Map([["headline", "scene"]]);
|
|
289
|
+
|
|
290
|
+
expect(
|
|
291
|
+
resolveTimelineExpansionRawId({
|
|
292
|
+
selectedElementId: null,
|
|
293
|
+
isPlaying: false,
|
|
294
|
+
currentTime: 12,
|
|
295
|
+
manifest,
|
|
296
|
+
parentMap,
|
|
297
|
+
}),
|
|
298
|
+
).toBe("scene");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("prefers the starting clip over the ending one on a shared seam", () => {
|
|
302
|
+
const manifest = [
|
|
303
|
+
clip({ id: "first", start: 0, duration: 5 }),
|
|
304
|
+
clip({ id: "first-child", start: 0, duration: 5 }),
|
|
305
|
+
clip({ id: "second", start: 5, duration: 5 }),
|
|
306
|
+
clip({ id: "second-child", start: 5, duration: 5 }),
|
|
307
|
+
];
|
|
308
|
+
const parentMap = new Map([
|
|
309
|
+
["first-child", "first"],
|
|
310
|
+
["second-child", "second"],
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
expect(
|
|
314
|
+
resolveTimelineExpansionRawId({
|
|
315
|
+
selectedElementId: null,
|
|
316
|
+
isPlaying: false,
|
|
317
|
+
currentTime: 5,
|
|
318
|
+
manifest,
|
|
319
|
+
parentMap,
|
|
320
|
+
}),
|
|
321
|
+
).toBe("second");
|
|
322
|
+
});
|
|
323
|
+
|
|
281
324
|
it("auto-expands the innermost active nested composition when paused", () => {
|
|
282
325
|
const manifest = [
|
|
283
326
|
clip({ id: "outer", start: 0, duration: 10 }),
|
|
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
|
|
3
3
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
4
|
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
|
-
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
5
|
+
import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
6
6
|
|
|
7
7
|
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
|
8
8
|
let current = parentMap.get(id);
|
|
@@ -19,18 +19,13 @@ function findTopLevelAncestor(id: string, parentMap: Map<string, string>): strin
|
|
|
19
19
|
return current;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function extractDomId(key: string): string {
|
|
23
|
-
const hashIdx = key.lastIndexOf("#");
|
|
24
|
-
return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
22
|
function resolveRawId(
|
|
28
23
|
selectedId: string | null,
|
|
29
24
|
manifest: ClipManifestClip[],
|
|
30
25
|
parentMap: Map<string, string>,
|
|
31
26
|
): string | null {
|
|
32
27
|
if (!selectedId) return null;
|
|
33
|
-
const rawId =
|
|
28
|
+
const rawId = splitTimelineElementKey(selectedId).domId;
|
|
34
29
|
if (parentMap.has(rawId)) return rawId;
|
|
35
30
|
if (parentMap.has(selectedId)) return selectedId;
|
|
36
31
|
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
|
|
@@ -50,6 +45,11 @@ function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
|
|
|
50
45
|
return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
|
|
51
46
|
}
|
|
52
47
|
|
|
48
|
+
/** Half-open containment plus the closing boundary — see the fallback below. */
|
|
49
|
+
function clipTouchesTime(clip: ClipManifestClip, time: number): boolean {
|
|
50
|
+
return Number.isFinite(time) && time >= clip.start && time <= clip.start + clip.duration;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
53
|
function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
|
|
54
54
|
let depth = 0;
|
|
55
55
|
let parent = parentMap.get(id);
|
|
@@ -70,11 +70,20 @@ function findActiveExpandableCompositionId(
|
|
|
70
70
|
parentMap: Map<string, string>,
|
|
71
71
|
): string | null {
|
|
72
72
|
const parentIds = new Set(parentMap.values());
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
const collect = (matches: (clip: ClipManifestClip, time: number) => boolean) => {
|
|
74
|
+
const ids = new Set<string>();
|
|
75
|
+
for (const clip of manifest) {
|
|
76
|
+
if (clip.id && parentIds.has(clip.id) && matches(clip, currentTime)) ids.add(clip.id);
|
|
77
|
+
}
|
|
78
|
+
return ids;
|
|
79
|
+
};
|
|
80
|
+
// Clip windows are half-open, so a playhead parked exactly on a clip's end is
|
|
81
|
+
// inside nothing — at the end of the timeline that collapsed every expanded
|
|
82
|
+
// sub-composition row and its keyframe lanes. Only when the strict pass finds
|
|
83
|
+
// nothing do we accept the closing boundary, so a playhead landing on the seam
|
|
84
|
+
// between two adjacent clips still expands the one that is starting.
|
|
85
|
+
const strict = collect(clipContainsTime);
|
|
86
|
+
const activeIds = strict.size > 0 ? strict : collect(clipTouchesTime);
|
|
78
87
|
let bestId: string | null = null;
|
|
79
88
|
let bestDepth = -1;
|
|
80
89
|
for (const id of activeIds) {
|
|
@@ -298,6 +298,20 @@ export function buildTimelineElementKey(params: {
|
|
|
298
298
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Inverse of {@link buildTimelineElementKey} for the `sourceFile#domId` form.
|
|
303
|
+
* A key with no `#` is a bare dom id and carries no source file of its own, so
|
|
304
|
+
* the caller supplies the scope it wants to look that id up in.
|
|
305
|
+
*/
|
|
306
|
+
export function splitTimelineElementKey(key: string): {
|
|
307
|
+
sourceFile: string | null;
|
|
308
|
+
domId: string;
|
|
309
|
+
} {
|
|
310
|
+
const hashIndex = key.lastIndexOf("#");
|
|
311
|
+
if (hashIndex < 0) return { sourceFile: null, domId: key };
|
|
312
|
+
return { sourceFile: key.slice(0, hashIndex), domId: key.slice(hashIndex + 1) };
|
|
313
|
+
}
|
|
314
|
+
|
|
301
315
|
export function buildTimelineElementIdentity(params: {
|
|
302
316
|
preferredId?: string | null;
|
|
303
317
|
label: string;
|
|
@@ -366,20 +380,21 @@ function numbersNearlyEqual(a: number, b: number): boolean {
|
|
|
366
380
|
return Math.abs(a - b) < 0.001;
|
|
367
381
|
}
|
|
368
382
|
|
|
383
|
+
const MANIFEST_CLIP_ATTRS: ReadonlyArray<[string, (clip: ClipManifestClip) => number]> = [
|
|
384
|
+
["data-start", (clip) => clip.start],
|
|
385
|
+
["data-duration", (clip) => clip.duration],
|
|
386
|
+
["data-track-index", (clip) => clip.track],
|
|
387
|
+
];
|
|
388
|
+
|
|
369
389
|
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
|
370
390
|
const tagName = clip.tagName?.toLowerCase();
|
|
371
391
|
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
|
380
|
-
if (Number.isFinite(track) && track !== clip.track) return false;
|
|
381
|
-
|
|
382
|
-
return true;
|
|
392
|
+
// An attribute only constrains the match when it parses to a finite number:
|
|
393
|
+
// missing or garbled reads as "unknown", not "mismatch".
|
|
394
|
+
return MANIFEST_CLIP_ATTRS.every(([attr, expected]) => {
|
|
395
|
+
const actual = Number.parseFloat(node.getAttribute(attr) ?? "");
|
|
396
|
+
return !Number.isFinite(actual) || numbersNearlyEqual(actual, expected(clip));
|
|
397
|
+
});
|
|
383
398
|
}
|
|
384
399
|
|
|
385
400
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|