@hyperframes/studio 0.7.75 → 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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.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-DzDajONI.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,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
|
}));
|
|
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
4
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
5
|
+
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
|
|
5
6
|
import { roundTo3 } from "../utils/rounding";
|
|
6
7
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
7
8
|
import {
|
|
@@ -11,6 +12,31 @@ import {
|
|
|
11
12
|
materializeIfDynamic,
|
|
12
13
|
} from "./gsapDragCommit";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
|
|
17
|
+
* within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the
|
|
18
|
+
* server takes a replace-with-keyframes list verbatim, so an append-only build
|
|
19
|
+
* could hand it two keyframes a fraction of a percent apart. The invariant lives
|
|
20
|
+
* here rather than in each caller's own pre-check, which is how the two callers
|
|
21
|
+
* ended up with different tolerances in the first place.
|
|
22
|
+
*/
|
|
23
|
+
export function buildTemporalArcKeyframes(
|
|
24
|
+
anim: GsapAnimation,
|
|
25
|
+
percentage: number,
|
|
26
|
+
properties: Record<string, number>,
|
|
27
|
+
) {
|
|
28
|
+
return [
|
|
29
|
+
...(anim.keyframes?.keyframes ?? [])
|
|
30
|
+
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
|
|
31
|
+
.map((keyframe) => ({
|
|
32
|
+
percentage: keyframe.percentage,
|
|
33
|
+
properties: { ...keyframe.properties },
|
|
34
|
+
...(keyframe.ease ? { ease: keyframe.ease } : {}),
|
|
35
|
+
})),
|
|
36
|
+
{ percentage, properties },
|
|
37
|
+
].sort((a, b) => a.percentage - b.percentage);
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
async function extendTweenAndAddKeyframe(
|
|
15
41
|
selection: DomEditSelection,
|
|
16
42
|
anim: GsapAnimation,
|
|
@@ -143,7 +169,7 @@ async function commitFlatViaKeyframes(
|
|
|
143
169
|
): Promise<void> {
|
|
144
170
|
const ct = usePlayerStore.getState().currentTime;
|
|
145
171
|
const ts = resolveTweenStart(anim);
|
|
146
|
-
const td =
|
|
172
|
+
const td = resolveEditableTweenDuration(anim, selection);
|
|
147
173
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
148
174
|
const outsideRange =
|
|
149
175
|
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
@@ -184,8 +210,13 @@ async function commitFlatViaKeyframes(
|
|
|
184
210
|
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
185
211
|
);
|
|
186
212
|
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
|
213
|
+
// By id first: a target with several tweens (two `to`s on the same selector)
|
|
214
|
+
// matches the selector lookup on whichever one happens to be first, and the
|
|
215
|
+
// extend-and-add below would then rewrite a tween the drag never touched.
|
|
187
216
|
const converted =
|
|
188
|
-
fresh.find((a) => a.
|
|
217
|
+
fresh.find((a) => a.id === anim.id && a.keyframes) ??
|
|
218
|
+
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
|
|
219
|
+
anim;
|
|
189
220
|
const convertedStart = resolveTweenStart(converted) ?? ts;
|
|
190
221
|
const convertedDur = resolveTweenDuration(converted) || td;
|
|
191
222
|
await extendTweenAndAddKeyframe(
|
|
@@ -259,13 +290,61 @@ export async function commitGsapPositionFromDrag(
|
|
|
259
290
|
|
|
260
291
|
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
|
|
261
292
|
const ct = usePlayerStore.getState().currentTime;
|
|
293
|
+
if (anim.arcPath?.enabled) {
|
|
294
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
295
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
296
|
+
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
297
|
+
// Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
|
|
298
|
+
// tighter one here meant a drag that landed a fraction of a percent off an
|
|
299
|
+
// authored waypoint skipped the update-point branch and appended instead.
|
|
300
|
+
const pointIndex = keyframes.findIndex(
|
|
301
|
+
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
|
302
|
+
);
|
|
303
|
+
if (pointIndex >= 0) {
|
|
304
|
+
await callbacks.commitMutation(
|
|
305
|
+
selection,
|
|
306
|
+
{
|
|
307
|
+
type: "update-motion-path-point",
|
|
308
|
+
animationId: anim.id,
|
|
309
|
+
pointIndex,
|
|
310
|
+
x: newX,
|
|
311
|
+
y: newY,
|
|
312
|
+
},
|
|
313
|
+
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
|
|
314
|
+
);
|
|
315
|
+
setActiveKeyframePct(null);
|
|
316
|
+
parkPlayheadOnKeyframe(anim, pct);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const tweenStart = resolveTweenStart(anim);
|
|
321
|
+
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
|
|
322
|
+
// default here would collapse a duration-less arc's window on drag.
|
|
323
|
+
const tweenDuration = resolveEditableTweenDuration(anim, selection);
|
|
324
|
+
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
|
|
325
|
+
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
|
|
326
|
+
await callbacks.commitMutation(
|
|
327
|
+
selection,
|
|
328
|
+
{
|
|
329
|
+
type: "replace-with-keyframes",
|
|
330
|
+
animationId: anim.id,
|
|
331
|
+
targetSelector: anim.targetSelector,
|
|
332
|
+
position: roundTo3(tweenStart),
|
|
333
|
+
duration: roundTo3(tweenDuration),
|
|
334
|
+
keyframes: temporalKeyframes,
|
|
335
|
+
ease: "none",
|
|
336
|
+
},
|
|
337
|
+
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
262
341
|
if (anim.keyframes) {
|
|
263
342
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
264
343
|
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
|
265
344
|
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
266
345
|
|
|
267
346
|
const ts = resolveTweenStart(effectiveAnim);
|
|
268
|
-
const td =
|
|
347
|
+
const td = resolveEditableTweenDuration(effectiveAnim, selection);
|
|
269
348
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
270
349
|
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
271
350
|
if (outsideRange && !hasSelectedKeyframe) {
|
|
@@ -293,7 +372,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
293
372
|
} else if (anim.method === "from" || anim.method === "fromTo") {
|
|
294
373
|
const ct = usePlayerStore.getState().currentTime;
|
|
295
374
|
const ts = resolveTweenStart(anim);
|
|
296
|
-
const td =
|
|
375
|
+
const td = resolveEditableTweenDuration(anim, selection);
|
|
297
376
|
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
298
377
|
const outsideRange =
|
|
299
378
|
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
@@ -313,7 +392,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
313
392
|
|
|
314
393
|
if (existingPosAnim?.keyframes) {
|
|
315
394
|
const posTs = resolveTweenStart(existingPosAnim);
|
|
316
|
-
const posTd =
|
|
395
|
+
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
|
|
317
396
|
if (posTs !== null) {
|
|
318
397
|
await extendTweenAndAddKeyframe(
|
|
319
398
|
selection,
|