@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.
Files changed (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. 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
- * Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
54
- * a property group nor real keyframes. The cache currently carries a property
55
- * group, not an animation id, so resolution is safe only when that group has a
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
- // fallow-ignore-next-line complexity
112
- (pct: number): { animId: string; tweenPct: number } | null => {
113
- const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
114
- return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
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
- [domEditSelection?.id, selectedGsapAnimations],
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 anim = selectedGsapAnimations.find((a) => a.keyframes);
135
- if (!anim) return;
136
- handleGsapRemoveAllKeyframes(anim.id);
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: (_elId: string, pct: number) => {
139
- const target = resolveKeyframeTarget(pct);
140
- if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
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
- onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
144
- const target = resolveKeyframeTarget(pct);
145
- if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
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: (_elId: string, fromClipPct: number, toClipPct: number) => {
156
- const target = resolveKeyframeTarget(fromClipPct);
157
- const sel = domEditSelection;
158
- if (!target || !sel) return;
159
- const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
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
- if (anim.keyframes) {
188
- handleGsapResizeKeyframedTween(
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
- decision.duration,
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
- const anim = selectedGsapAnimations.find((a) => a.keyframes);
218
- if (anim?.keyframes) {
219
- const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
220
- if (existing) {
221
- handleGsapRemoveKeyframe(anim.id, existing.percentage);
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
- handleGsapAddKeyframe(anim.id, pct, "x", 0);
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
- } else {
226
- const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
227
- if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
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 { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
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
- const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
22
- if (!animation) return;
23
- // Only the active element's keyframes; a stale cross-element selection must not delete here.
24
- const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
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, pct] of percentages.entries()) {
30
- session.handleGsapRemoveKeyframe(animation.id, pct, {
45
+ for (const [index, target] of targets.entries()) {
46
+ session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
31
47
  ...coalesceOptions,
32
- ...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
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: `#${id}`,
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 = resolveTweenDuration(anim);
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.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
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 = resolveTweenDuration(effectiveAnim);
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 = resolveTweenDuration(anim);
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 = resolveTweenDuration(existingPosAnim);
395
+ const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
317
396
  if (posTs !== null) {
318
397
  await extendTweenAndAddKeyframe(
319
398
  selection,