@hyperframes/studio 0.7.76 → 0.7.78
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-uiAAPKvw.js} +1 -1
- package/dist/assets/index-BSZrK0bx.js +428 -0
- package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
- package/dist/assets/index-DpkO2Hvw.css +1 -0
- package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +25030 -23214
- 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/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +66 -32
- 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/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
3
|
import type { TimelineElement } from "../../player";
|
|
3
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
@@ -10,8 +11,15 @@ import {
|
|
|
10
11
|
} from "../../contexts/DomEditContext";
|
|
11
12
|
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
|
12
13
|
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
|
14
|
+
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
13
15
|
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
|
16
|
+
import type { DomEditSelection } from "../editor/domEditingTypes";
|
|
14
17
|
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
18
|
+
import {
|
|
19
|
+
getTimelineElementIdentity,
|
|
20
|
+
splitTimelineElementKey,
|
|
21
|
+
} from "../../player/lib/timelineElementHelpers";
|
|
22
|
+
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
|
|
15
23
|
|
|
16
24
|
export interface TimelineEditCallbackDeps {
|
|
17
25
|
handleTimelineElementMove: (
|
|
@@ -46,15 +54,14 @@ interface TimelineCachedKeyframe {
|
|
|
46
54
|
percentage: number;
|
|
47
55
|
tweenPercentage?: number;
|
|
48
56
|
propertyGroup?: string;
|
|
57
|
+
animationId?: string;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
61
|
* Resolve a rendered timeline diamond back to the animation that authored it.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* single candidate. Ambiguous candidates remain unresolved rather than
|
|
57
|
-
* retiming an arbitrary tween.
|
|
62
|
+
* Prefer the animation identity carried by the rendered keyframe. Legacy cache
|
|
63
|
+
* entries without one are safe only when their property group has one candidate;
|
|
64
|
+
* ambiguous candidates remain unresolved rather than retiming an arbitrary tween.
|
|
58
65
|
*/
|
|
59
66
|
export function resolveTimelineKeyframeTarget(
|
|
60
67
|
pct: number,
|
|
@@ -63,6 +70,14 @@ export function resolveTimelineKeyframeTarget(
|
|
|
63
70
|
): { animId: string; tweenPct: number } | null {
|
|
64
71
|
const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
|
65
72
|
if (!kf) return null;
|
|
73
|
+
const identifiedAnimation = kf.animationId
|
|
74
|
+
? animations.find((animation) => animation.id === kf.animationId)
|
|
75
|
+
: undefined;
|
|
76
|
+
if (kf.animationId) {
|
|
77
|
+
return identifiedAnimation
|
|
78
|
+
? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct }
|
|
79
|
+
: null;
|
|
80
|
+
}
|
|
66
81
|
const group = kf?.propertyGroup;
|
|
67
82
|
const candidates = group
|
|
68
83
|
? animations.filter((animation) => animation.propertyGroup === group)
|
|
@@ -98,22 +113,69 @@ export function useTimelineEditCallbacks({
|
|
|
98
113
|
handleGsapResizeKeyframedTween,
|
|
99
114
|
handleGsapUpdateMeta,
|
|
100
115
|
handleGsapAddKeyframe,
|
|
116
|
+
handleGsapAddKeyframeBatch,
|
|
101
117
|
handleGsapConvertToKeyframes,
|
|
102
118
|
handleGsapRemoveAllKeyframes,
|
|
103
119
|
buildDomSelectionForTimelineElement,
|
|
104
120
|
} = useDomEditActionsContext();
|
|
105
121
|
|
|
122
|
+
const resolveElementAnimations = useCallback(
|
|
123
|
+
(elementKey: string): GsapAnimation[] => {
|
|
124
|
+
const { gsapAnimations } = usePlayerStore.getState();
|
|
125
|
+
const { sourceFile, domId } = splitTimelineElementKey(elementKey);
|
|
126
|
+
const scope = sourceFile ?? activeCompPath ?? "index.html";
|
|
127
|
+
// elementCacheKeys owns the key-variant list the writers use; reading it
|
|
128
|
+
// back by hand here is how the two sides drift.
|
|
129
|
+
for (const key of elementCacheKeys(scope, domId)) {
|
|
130
|
+
const animations = gsapAnimations.get(key);
|
|
131
|
+
if (animations) return animations;
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
},
|
|
135
|
+
[activeCompPath],
|
|
136
|
+
);
|
|
137
|
+
|
|
106
138
|
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
|
107
139
|
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
|
108
140
|
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
|
109
141
|
// anim in the keyframe's property group, falling back to the first keyframed one.
|
|
110
142
|
const resolveKeyframeTarget = useCallback(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
(
|
|
144
|
+
elementKey: string,
|
|
145
|
+
target: TimelineKeyframeTarget,
|
|
146
|
+
animations: GsapAnimation[] = selectedGsapAnimations,
|
|
147
|
+
): { animId: string; tweenPct: number } | null => {
|
|
148
|
+
const carriesIdentity =
|
|
149
|
+
target.propertyGroup !== undefined ||
|
|
150
|
+
target.tweenPercentage !== undefined ||
|
|
151
|
+
target.animationId !== undefined;
|
|
152
|
+
// The clicked element's own cache: the diamond context menu can open on an
|
|
153
|
+
// element that is not the selected one, and reading the selection's cache
|
|
154
|
+
// there resolves against the wrong element.
|
|
155
|
+
const keyframeCache = usePlayerStore.getState().keyframeCache;
|
|
156
|
+
const cached =
|
|
157
|
+
keyframeCache.get(elementKey) ??
|
|
158
|
+
keyframeCache.get(splitTimelineElementKey(elementKey).domId);
|
|
159
|
+
return resolveTimelineKeyframeTarget(
|
|
160
|
+
target.percentage,
|
|
161
|
+
carriesIdentity ? [target] : (cached?.keyframes ?? []),
|
|
162
|
+
animations,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
[selectedGsapAnimations],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const removeKeyframeTarget = useCallback(
|
|
169
|
+
(animationId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
|
|
170
|
+
// A flat tween's two diamonds are SYNTHESIZED endpoints, not authored
|
|
171
|
+
// keyframes, so "remove keyframe" has nothing to remove. Escalating to a
|
|
172
|
+
// whole-animation delete here destroyed the authored tween and its source
|
|
173
|
+
// comment on a single click, with no undo beyond the editor's own stack.
|
|
174
|
+
// Always post remove-keyframe: the writer refuses it for a flat tween
|
|
175
|
+
// (`changed:false`, file untouched), which is the correct no-op.
|
|
176
|
+
handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
|
|
115
177
|
},
|
|
116
|
-
[
|
|
178
|
+
[handleGsapRemoveKeyframe],
|
|
117
179
|
);
|
|
118
180
|
|
|
119
181
|
return useMemo(
|
|
@@ -127,22 +189,59 @@ export function useTimelineEditCallbacks({
|
|
|
127
189
|
onSplitElement: handleTimelineElementSplit,
|
|
128
190
|
onRazorSplit: handleRazorSplit,
|
|
129
191
|
onRazorSplitAll: handleRazorSplitAll,
|
|
130
|
-
onDeleteAllKeyframes: () => {
|
|
192
|
+
onDeleteAllKeyframes: (element) => {
|
|
131
193
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
132
194
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
133
195
|
// that the next drag adds to, flinging the element off-screen.
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
196
|
+
const elementKey = getTimelineElementIdentity(element);
|
|
197
|
+
// Every keyframed tween on the layer, not just the first: a layer with
|
|
198
|
+
// position AND opacity keyframes left the second one keyframed, so
|
|
199
|
+
// "Delete All Keyframes" visibly did half the job.
|
|
200
|
+
const anims = resolveElementAnimations(elementKey).filter(
|
|
201
|
+
(animation) => animation.keyframes,
|
|
202
|
+
);
|
|
203
|
+
if (anims.length === 0) return;
|
|
204
|
+
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
|
205
|
+
if (!selection) return;
|
|
206
|
+
// Serial: each removal rewrites the same source file, so dispatching
|
|
207
|
+
// them together would have the later writes read a pre-edit document.
|
|
208
|
+
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
|
|
209
|
+
});
|
|
137
210
|
},
|
|
138
|
-
onDeleteKeyframe: (
|
|
139
|
-
const
|
|
140
|
-
|
|
211
|
+
onDeleteKeyframe: (elId, keyframe) => {
|
|
212
|
+
const animations = resolveElementAnimations(elId);
|
|
213
|
+
const target = resolveKeyframeTarget(elId, keyframe, animations);
|
|
214
|
+
if (!target) return;
|
|
215
|
+
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
|
216
|
+
if (!element) {
|
|
217
|
+
removeKeyframeTarget(target.animId, target.tweenPct);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Persist through the CLICKED element's own selection so a deletion on a
|
|
221
|
+
// non-selected element (especially one in a different source file) commits
|
|
222
|
+
// against the right element instead of the current domEditSelection.
|
|
223
|
+
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
|
224
|
+
if (selection) removeKeyframeTarget(target.animId, target.tweenPct, selection);
|
|
225
|
+
});
|
|
141
226
|
},
|
|
142
|
-
// Retime the keyframe to the playhead, preserving its value + ease.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
227
|
+
// Retime the keyframe to the playhead, preserving its value + ease. The
|
|
228
|
+
// clicked element owns the whole write: its animations resolve the target,
|
|
229
|
+
// its selection commits it, and its animation computes the playhead
|
|
230
|
+
// percentage. Mixing frames here retimed against the selected element's
|
|
231
|
+
// tween and wrote the result into the clicked element's file.
|
|
232
|
+
onMoveKeyframeToPlayhead: (element, keyframe) => {
|
|
233
|
+
const elementKey = getTimelineElementIdentity(element);
|
|
234
|
+
const animations = resolveElementAnimations(elementKey);
|
|
235
|
+
const target = resolveKeyframeTarget(elementKey, keyframe, animations);
|
|
236
|
+
const animation = target
|
|
237
|
+
? animations.find((candidate) => candidate.id === target.animId)
|
|
238
|
+
: undefined;
|
|
239
|
+
if (!target || !animation) return;
|
|
240
|
+
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
|
241
|
+
if (selection) {
|
|
242
|
+
handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
146
245
|
},
|
|
147
246
|
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
|
148
247
|
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
|
|
@@ -152,14 +251,19 @@ export function useTimelineEditCallbacks({
|
|
|
152
251
|
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
|
153
252
|
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
|
154
253
|
// fallow-ignore-next-line complexity
|
|
155
|
-
onMoveKeyframe: (
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
if (!target
|
|
159
|
-
|
|
254
|
+
onMoveKeyframe: async (elId, keyframe, toClipPct) => {
|
|
255
|
+
const animations = resolveElementAnimations(elId);
|
|
256
|
+
const target = resolveKeyframeTarget(elId, keyframe, animations);
|
|
257
|
+
if (!target) return false;
|
|
258
|
+
// The dragged diamond's OWN element, not the selected one: a drag on a
|
|
259
|
+
// non-selected clip has to read that clip's animations and commit
|
|
260
|
+
// through that clip's selection, or it retimes whatever is selected.
|
|
261
|
+
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
|
262
|
+
const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection;
|
|
263
|
+
if (!sel) return false;
|
|
264
|
+
const anim = animations.find((a) => a.id === target.animId);
|
|
160
265
|
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
|
161
|
-
if (!anim || tweenStart === null) return;
|
|
162
|
-
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
|
266
|
+
if (!anim || tweenStart === null) return Promise.resolve(false);
|
|
163
267
|
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
|
164
268
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
165
269
|
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
@@ -168,6 +272,7 @@ export function useTimelineEditCallbacks({
|
|
|
168
272
|
elements,
|
|
169
273
|
domClipChildren,
|
|
170
274
|
);
|
|
275
|
+
const tweenDuration = resolveTweenDuration(anim, elDuration);
|
|
171
276
|
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
|
172
277
|
const decision = resolveKeyframeRetime({
|
|
173
278
|
keyframes: anim.keyframes?.keyframes ?? [],
|
|
@@ -177,35 +282,37 @@ export function useTimelineEditCallbacks({
|
|
|
177
282
|
dropAbsTime,
|
|
178
283
|
});
|
|
179
284
|
if (decision.kind === "move" && decision.toTweenPct != null) {
|
|
180
|
-
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
|
|
285
|
+
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
|
|
181
286
|
} else if (
|
|
182
287
|
decision.kind === "resize" &&
|
|
183
288
|
decision.pctRemap &&
|
|
184
289
|
decision.position != null &&
|
|
185
290
|
decision.duration != null
|
|
186
291
|
) {
|
|
187
|
-
|
|
188
|
-
|
|
292
|
+
// An empty remap means a FLAT tween's synthesized boundary: there is no
|
|
293
|
+
// keyframe node to re-key, only the window to move. Sending it through
|
|
294
|
+
// the keyframed-resize writer would rewrite the authored flat tween into
|
|
295
|
+
// keyframes form as a side effect of a pure position/duration change, so
|
|
296
|
+
// dispatch update-meta and leave the tween as the author wrote it.
|
|
297
|
+
if (decision.pctRemap.length === 0) {
|
|
298
|
+
// Report the write's real settlement, like every other branch here:
|
|
299
|
+
// answering `true` while the meta update is still in flight tells the
|
|
300
|
+
// diamond the retime landed, so a rejected write never snaps back.
|
|
301
|
+
return handleGsapUpdateMeta(
|
|
189
302
|
target.animId,
|
|
190
|
-
decision.position,
|
|
191
|
-
|
|
192
|
-
decision.pctRemap,
|
|
303
|
+
{ position: decision.position, duration: decision.duration },
|
|
304
|
+
sel,
|
|
193
305
|
);
|
|
194
|
-
} else {
|
|
195
|
-
// resize-keyframed-tween requires an authored `keyframes` AST node
|
|
196
|
-
// and intentionally no-ops for a flat tween. Update its real tween
|
|
197
|
-
// window through the metadata writer (and SDK cutover path) instead.
|
|
198
|
-
handleGsapUpdateMeta(target.animId, {
|
|
199
|
-
position: decision.position,
|
|
200
|
-
duration: decision.duration,
|
|
201
|
-
});
|
|
202
306
|
}
|
|
307
|
+
return handleGsapResizeKeyframedTween(
|
|
308
|
+
target.animId,
|
|
309
|
+
decision.position,
|
|
310
|
+
decision.duration,
|
|
311
|
+
decision.pctRemap,
|
|
312
|
+
sel,
|
|
313
|
+
);
|
|
203
314
|
}
|
|
204
|
-
|
|
205
|
-
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
|
206
|
-
for (const anim of selectedGsapAnimations) {
|
|
207
|
-
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
|
|
208
|
-
}
|
|
315
|
+
return Promise.resolve(false);
|
|
209
316
|
},
|
|
210
317
|
// fallow-ignore-next-line complexity
|
|
211
318
|
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
|
|
@@ -214,18 +321,49 @@ export function useTimelineEditCallbacks({
|
|
|
214
321
|
el.duration > 0
|
|
215
322
|
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
|
|
216
323
|
: 0;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
324
|
+
// Same frame for read and write: the toggled element's animations decide
|
|
325
|
+
// add-vs-remove, and its selection is what the mutation commits through.
|
|
326
|
+
const animations = resolveElementAnimations(getTimelineElementIdentity(el));
|
|
327
|
+
void buildDomSelectionForTimelineElement(el).then((selection) => {
|
|
328
|
+
if (!selection) return;
|
|
329
|
+
const anim = animations.find((a) => a.keyframes);
|
|
330
|
+
if (anim?.keyframes) {
|
|
331
|
+
const existing = anim.keyframes.keyframes.find(
|
|
332
|
+
(k) => Math.abs(k.percentage - pct) <= 1,
|
|
333
|
+
);
|
|
334
|
+
if (existing) {
|
|
335
|
+
handleGsapRemoveKeyframe(anim.id, existing.percentage, undefined, selection);
|
|
336
|
+
} else {
|
|
337
|
+
handleGsapAddKeyframe(anim.id, pct, "x", 0, selection);
|
|
338
|
+
}
|
|
222
339
|
} else {
|
|
223
|
-
|
|
340
|
+
const flatAnim = animations.find((a) => !a.keyframes);
|
|
341
|
+
if (flatAnim) {
|
|
342
|
+
void handleGsapConvertToKeyframes(
|
|
343
|
+
flatAnim.id,
|
|
344
|
+
undefined,
|
|
345
|
+
undefined,
|
|
346
|
+
undefined,
|
|
347
|
+
selection,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
224
350
|
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
onTogglePropertyGroupKeyframe: async (element, target) => {
|
|
354
|
+
const selection = await buildDomSelectionForTimelineElement(element);
|
|
355
|
+
if (!selection) return;
|
|
356
|
+
if (target.remove) {
|
|
357
|
+
removeKeyframeTarget(target.animationId, target.tweenPercentage, selection);
|
|
358
|
+
return;
|
|
228
359
|
}
|
|
360
|
+
await handleGsapAddKeyframeBatch(
|
|
361
|
+
target.animationId,
|
|
362
|
+
target.tweenPercentage,
|
|
363
|
+
target.properties,
|
|
364
|
+
undefined,
|
|
365
|
+
selection,
|
|
366
|
+
);
|
|
229
367
|
},
|
|
230
368
|
}),
|
|
231
369
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -240,14 +378,16 @@ export function useTimelineEditCallbacks({
|
|
|
240
378
|
handleRazorSplit,
|
|
241
379
|
handleRazorSplitAll,
|
|
242
380
|
handleGsapRemoveAllKeyframes,
|
|
381
|
+
resolveElementAnimations,
|
|
243
382
|
resolveKeyframeTarget,
|
|
383
|
+
removeKeyframeTarget,
|
|
244
384
|
selectedGsapAnimations,
|
|
245
|
-
handleGsapRemoveKeyframe,
|
|
246
385
|
handleGsapMoveKeyframeToPlayhead,
|
|
247
386
|
handleGsapMoveKeyframe,
|
|
248
387
|
handleGsapResizeKeyframedTween,
|
|
249
388
|
handleGsapUpdateMeta,
|
|
250
389
|
handleGsapAddKeyframe,
|
|
390
|
+
handleGsapAddKeyframeBatch,
|
|
251
391
|
handleGsapConvertToKeyframes,
|
|
252
392
|
buildDomSelectionForTimelineElement,
|
|
253
393
|
projectId,
|
|
@@ -40,6 +40,47 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
describe("composition card drag", () => {
|
|
43
|
+
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
|
44
|
+
const { host } = mount();
|
|
45
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
46
|
+
expect(thumbnail).not.toBeNull();
|
|
47
|
+
expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
|
|
48
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("shows a fallback when the cached thumbnail fails", () => {
|
|
52
|
+
const { host } = mount();
|
|
53
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
54
|
+
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
|
55
|
+
|
|
56
|
+
act(() => thumbnail.dispatchEvent(new Event("error")));
|
|
57
|
+
|
|
58
|
+
expect(host.textContent).toContain("Preview unavailable");
|
|
59
|
+
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
try {
|
|
66
|
+
const { host, card } = mount();
|
|
67
|
+
act(() => {
|
|
68
|
+
card.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
69
|
+
vi.advanceTimersByTime(300);
|
|
70
|
+
});
|
|
71
|
+
expect(host.querySelectorAll("iframe")).toHaveLength(1);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
|
75
|
+
});
|
|
76
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
77
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
78
|
+
} finally {
|
|
79
|
+
consoleError.mockRestore();
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
43
84
|
it("keeps ordinary click navigation", () => {
|
|
44
85
|
const { card, onSelect } = mount();
|
|
45
86
|
act(() => card.click());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
3
|
+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
3
4
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
4
5
|
|
|
5
6
|
interface CompositionsTabProps {
|
|
@@ -130,6 +131,8 @@ function CompCard({
|
|
|
130
131
|
}) {
|
|
131
132
|
const [hovered, setHovered] = useState(false);
|
|
132
133
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
134
|
+
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
|
135
|
+
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
|
133
136
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
134
137
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
135
138
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -158,10 +161,21 @@ function CompCard({
|
|
|
158
161
|
clearTimeout(hoverTimer.current);
|
|
159
162
|
hoverTimer.current = null;
|
|
160
163
|
}
|
|
164
|
+
if (syncTimer.current) {
|
|
165
|
+
clearTimeout(syncTimer.current);
|
|
166
|
+
syncTimer.current = null;
|
|
167
|
+
}
|
|
161
168
|
setHovered(false);
|
|
169
|
+
setLivePreviewLoaded(false);
|
|
162
170
|
};
|
|
163
171
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
164
172
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
173
|
+
const thumbnailUrl = buildCompositionThumbnailUrl({
|
|
174
|
+
previewUrl,
|
|
175
|
+
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
176
|
+
duration: 0,
|
|
177
|
+
origin: window.location.origin,
|
|
178
|
+
});
|
|
165
179
|
const previewScale = resolveCompositionPreviewScale({
|
|
166
180
|
cardWidth: CARD_W,
|
|
167
181
|
cardHeight: CARD_H,
|
|
@@ -172,7 +186,7 @@ function CompCard({
|
|
|
172
186
|
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
|
173
187
|
|
|
174
188
|
useEffect(() => {
|
|
175
|
-
|
|
189
|
+
if (hovered) requestIframePlaybackSync(true);
|
|
176
190
|
}, [hovered, requestIframePlaybackSync]);
|
|
177
191
|
|
|
178
192
|
useEffect(() => {
|
|
@@ -216,36 +230,56 @@ function CompCard({
|
|
|
216
230
|
}`}
|
|
217
231
|
>
|
|
218
232
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
233
|
+
{thumbnailFailed ? (
|
|
234
|
+
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
|
235
|
+
Preview unavailable
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<img
|
|
239
|
+
src={thumbnailUrl}
|
|
240
|
+
alt=""
|
|
241
|
+
draggable={false}
|
|
242
|
+
loading="lazy"
|
|
243
|
+
decoding="async"
|
|
244
|
+
onError={() => setThumbnailFailed(true)}
|
|
245
|
+
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
|
246
|
+
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
|
247
|
+
}`}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
{hovered && (
|
|
251
|
+
<iframe
|
|
252
|
+
ref={iframeRef}
|
|
253
|
+
src={previewUrl}
|
|
254
|
+
sandbox="allow-scripts allow-same-origin"
|
|
255
|
+
className="absolute border-none pointer-events-none"
|
|
256
|
+
style={{
|
|
257
|
+
transformOrigin: "0 0",
|
|
258
|
+
width: stageSize.width,
|
|
259
|
+
height: stageSize.height,
|
|
260
|
+
left: thumbnailOffsetX,
|
|
261
|
+
top: thumbnailOffsetY,
|
|
262
|
+
transform: `scale(${previewScale})`,
|
|
263
|
+
}}
|
|
264
|
+
onLoad={(e) => {
|
|
265
|
+
try {
|
|
266
|
+
const iframe = e.currentTarget;
|
|
267
|
+
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
268
|
+
const width =
|
|
269
|
+
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
270
|
+
const height =
|
|
271
|
+
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
272
|
+
setStageSize({ width, height });
|
|
273
|
+
setLivePreviewLoaded(true);
|
|
274
|
+
requestIframePlaybackSync(true);
|
|
275
|
+
} catch {
|
|
276
|
+
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
title={`${name} preview`}
|
|
280
|
+
tabIndex={-1}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
249
283
|
</div>
|
|
250
284
|
<div
|
|
251
285
|
className="min-w-0 flex-1"
|
|
@@ -331,7 +365,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
331
365
|
<div className="flex-1 overflow-y-auto">
|
|
332
366
|
{compositions.map((comp) => (
|
|
333
367
|
<CompCard
|
|
334
|
-
key={comp}
|
|
368
|
+
key={`${projectId}:${comp}`}
|
|
335
369
|
projectId={projectId}
|
|
336
370
|
comp={comp}
|
|
337
371
|
isActive={activeComposition === comp}
|
|
@@ -40,10 +40,10 @@ export function TimelineEditProvider({
|
|
|
40
40
|
value.onRazorSplitAll,
|
|
41
41
|
value.onDeleteKeyframe,
|
|
42
42
|
value.onDeleteAllKeyframes,
|
|
43
|
-
value.onChangeKeyframeEase,
|
|
44
43
|
value.onMoveKeyframeToPlayhead,
|
|
45
44
|
value.onMoveKeyframe,
|
|
46
45
|
value.onToggleKeyframeAtPlayhead,
|
|
46
|
+
value.onTogglePropertyGroupKeyframe,
|
|
47
47
|
],
|
|
48
48
|
);
|
|
49
49
|
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
2
|
-
import {
|
|
2
|
+
import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
|
3
3
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
4
4
|
|
|
5
5
|
let deleteKeyframesCommitCounter = 0;
|
|
@@ -18,18 +18,34 @@ export function deleteSelectedKeyframes(session: {
|
|
|
18
18
|
) => void;
|
|
19
19
|
}): void {
|
|
20
20
|
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
21
|
+
if (!selectedElementId) return;
|
|
22
|
+
const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes);
|
|
23
|
+
// A collapsed selection key (an ungrouped animation) carries no animation id,
|
|
24
|
+
// so it only resolves when there is exactly one keyframed animation it could
|
|
25
|
+
// mean. Taking the first of several deletes an arbitrary tween's keyframe.
|
|
26
|
+
const fallbackAnimation = keyframedAnimations.length === 1 ? keyframedAnimations[0] : undefined;
|
|
27
|
+
const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation]));
|
|
28
|
+
const removals = new Map<string, { animationId: string; percentage: number }>();
|
|
29
|
+
for (const key of selectedKeyframes) {
|
|
30
|
+
const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key);
|
|
31
|
+
if (!target) continue;
|
|
32
|
+
const animation = target.animationId
|
|
33
|
+
? animationsById.get(target.animationId)
|
|
34
|
+
: fallbackAnimation;
|
|
35
|
+
if (!animation) continue;
|
|
36
|
+
const percentage = target.tweenPercentage ?? target.percentage;
|
|
37
|
+
removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage });
|
|
38
|
+
}
|
|
39
|
+
const targets = [...removals.values()];
|
|
40
|
+
if (targets.length === 0) return;
|
|
25
41
|
const coalesceOptions = {
|
|
26
42
|
coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
|
|
27
43
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
28
44
|
};
|
|
29
|
-
for (const [index,
|
|
30
|
-
session.handleGsapRemoveKeyframe(
|
|
45
|
+
for (const [index, target] of targets.entries()) {
|
|
46
|
+
session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
|
|
31
47
|
...coalesceOptions,
|
|
32
|
-
...(index ===
|
|
48
|
+
...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }),
|
|
33
49
|
});
|
|
34
50
|
}
|
|
35
51
|
}
|
|
@@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
12
12
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
13
13
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
|
-
import { computeElementPercentage } from "./gsapShared";
|
|
15
|
+
import { computeElementPercentage, idSelector } from "./gsapShared";
|
|
16
16
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
17
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
18
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
@@ -117,7 +117,7 @@ export async function materializeIfDynamic(
|
|
|
117
117
|
const allScanned = scanAllRuntimeKeyframes(iframe);
|
|
118
118
|
if (allScanned.size === 0) return;
|
|
119
119
|
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
|
120
|
-
selector:
|
|
120
|
+
selector: idSelector(id),
|
|
121
121
|
keyframes: data.keyframes,
|
|
122
122
|
easeEach: data.easeEach,
|
|
123
123
|
}));
|