@hyperframes/studio 0.7.15 → 0.7.17

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 (65) hide show
  1. package/dist/assets/{index-CTXExGxO.js → index-BCAdHPz_.js} +1 -1
  2. package/dist/assets/{index-Db9KG66z.js → index-BDx28x9R.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/index-hiqLDAtz.js +375 -0
  5. package/dist/{chunk-UUEDZ4UJ.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-NWCBED4R.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +18 -1
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3157 -2808
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -0
  14. package/src/components/StudioPreviewArea.tsx +0 -41
  15. package/src/components/StudioRightPanel.tsx +2 -0
  16. package/src/components/TimelineToolbar.tsx +4 -4
  17. package/src/components/editor/DomEditOverlay.tsx +5 -2
  18. package/src/components/editor/GestureRecordControl.tsx +2 -2
  19. package/src/components/editor/GsapAnimationSection.tsx +3 -3
  20. package/src/components/editor/InspectorHeaderActions.tsx +62 -0
  21. package/src/components/editor/LayersPanel.tsx +35 -2
  22. package/src/components/editor/MotionPathOverlay.tsx +24 -8
  23. package/src/components/editor/PropertyPanel.tsx +48 -60
  24. package/src/components/editor/Transform3DCube.tsx +84 -85
  25. package/src/components/editor/domEditOverlayGeometry.ts +28 -0
  26. package/src/components/editor/domEditingDom.ts +36 -2
  27. package/src/components/editor/domEditingGroups.ts +41 -0
  28. package/src/components/editor/domEditingLayers.test.ts +97 -1
  29. package/src/components/editor/domEditingLayers.ts +13 -29
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualOffsetDrag.ts +23 -2
  32. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  33. package/src/components/editor/propertyPanelHelpers.ts +2 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  35. package/src/components/editor/useMotionPathData.ts +47 -1
  36. package/src/contexts/DomEditContext.tsx +16 -0
  37. package/src/contexts/TimelineEditContext.tsx +0 -1
  38. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  39. package/src/hooks/gsapTweenSynth.ts +64 -0
  40. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  41. package/src/hooks/useAppHotkeys.ts +34 -3
  42. package/src/hooks/useDomEditSession.ts +48 -0
  43. package/src/hooks/useDomSelection.ts +73 -9
  44. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  45. package/src/hooks/useEnableKeyframes.ts +29 -1
  46. package/src/hooks/useGroupCommits.ts +188 -0
  47. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  48. package/src/hooks/useGsapTweenCache.ts +56 -72
  49. package/src/hooks/usePreviewInteraction.ts +60 -2
  50. package/src/player/components/ShortcutsPanel.tsx +2 -0
  51. package/src/player/components/Timeline.tsx +0 -15
  52. package/src/player/components/TimelineCanvas.tsx +0 -13
  53. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  54. package/src/player/components/timelineCallbacks.ts +0 -1
  55. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  56. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  57. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  58. package/src/player/store/playerStore.ts +20 -0
  59. package/src/utils/studioPreviewHelpers.ts +37 -0
  60. package/dist/assets/index-B7VWFKy1.js +0 -375
  61. package/dist/assets/index-svYFaNuq.css +0 -1
  62. package/dist/chunk-UUEDZ4UJ.js.map +0 -1
  63. package/src/components/editor/keyframeMove.test.ts +0 -101
  64. package/src/components/editor/keyframeMove.ts +0 -151
  65. /package/dist/{domEditingLayers-NWCBED4R.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
@@ -6,6 +6,20 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
6
6
  import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
7
7
  import { Transform3DCube, type CubePose } from "./Transform3DCube";
8
8
 
9
+ // translateZ only foreshortens under a perspective lens. Rather than hardcode one
10
+ // (an arbitrary px value reads wrong at different canvas sizes), derive it from the
11
+ // element's composition: perspective = composition height puts the virtual camera
12
+ // one comp-height back, a natural ~53° vertical FOV that looks the same whether the
13
+ // canvas is 720p or 4K. Falls back to the element's own height only if the comp size
14
+ // can't be read (detached/unmeasured), never to a fixed magic number.
15
+ function naturalDepthPerspective(el: HTMLElement | null | undefined): number {
16
+ if (!el) return 0;
17
+ const root = el.closest("[data-hf-inner-root],[data-composition-id]") as HTMLElement | null;
18
+ const compHeight = root?.offsetHeight || el.ownerDocument?.documentElement?.clientHeight || 0;
19
+ if (compHeight > 0) return Math.round(compHeight);
20
+ return Math.round((el.offsetHeight || 0) * 4) || 0;
21
+ }
22
+
9
23
  type KeyframeEntry = Array<{
10
24
  percentage: number;
11
25
  properties: Record<string, number | string>;
@@ -38,17 +52,10 @@ interface PropertyPanel3dTransformProps {
38
52
  onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
39
53
  }
40
54
 
41
- type CommitAnimatedProperty = (
42
- element: DomEditSelection,
43
- property: string,
44
- value: number,
45
- ) => Promise<void>;
46
-
47
55
  /** The draggable cube + its commit/recenter/live-preview wiring. */
48
56
  function Cube3dControl({
49
57
  element,
50
58
  gsapRuntimeValues,
51
- onCommitAnimatedProperty,
52
59
  onCommitAnimatedProperties,
53
60
  onLivePreviewProps,
54
61
  onKeyframe,
@@ -56,8 +63,7 @@ function Cube3dControl({
56
63
  }: {
57
64
  element: DomEditSelection;
58
65
  gsapRuntimeValues: Record<string, number>;
59
- onCommitAnimatedProperty: CommitAnimatedProperty;
60
- onCommitAnimatedProperties?: (
66
+ onCommitAnimatedProperties: (
61
67
  element: DomEditSelection,
62
68
  props: Record<string, number | string>,
63
69
  ) => Promise<void>;
@@ -70,6 +76,15 @@ function Cube3dControl({
70
76
  rotationY: gsapRuntimeValues.rotationY ?? 0,
71
77
  rotationZ: gsapRuntimeValues.rotationZ ?? 0,
72
78
  };
79
+ // Comp-derived lens (see naturalDepthPerspective) applied the first time depth is
80
+ // set, so the scene's foreshortening scales with the canvas instead of a magic 800.
81
+ const depthPerspective = naturalDepthPerspective(element.element);
82
+ // A gentle, fixed "depth pose" tilt (degrees) dropped on a flat element the first
83
+ // time it gets depth, so translateZ reads as 3D foreshortening instead of a plain
84
+ // resize — small enough to look like a premium card, not a flip.
85
+ const DEPTH_POSE_X = 10;
86
+ const DEPTH_POSE_Y = -15;
87
+ const isFlat = Math.round(pose.rotationX) === 0 && Math.round(pose.rotationY) === 0;
73
88
  // Commit only the rotation axes the drag actually changed (each rounded to a
74
89
  // whole degree). Reuses the keyframe-aware animated-property commit, so a drag
75
90
  // at the playhead writes/updates a keyframe just like the numeric fields.
@@ -82,13 +97,8 @@ function Cube3dControl({
82
97
  const axes = Object.keys(changedProps);
83
98
  if (axes.length === 0) return;
84
99
  // ONE keyframe for the whole pose change — avoids per-axis commits racing into
85
- // adjacent duplicate keyframes. Fall back to per-axis if no batched commit.
86
- if (onCommitAnimatedProperties) {
87
- void onCommitAnimatedProperties(element, changedProps);
88
- } else {
89
- for (const [axis, v] of Object.entries(changedProps))
90
- onCommitAnimatedProperty(element, axis, v);
91
- }
100
+ // adjacent duplicate keyframes.
101
+ void onCommitAnimatedProperties(element, changedProps);
92
102
  };
93
103
  const recenter = () => {
94
104
  // ONE commit for the whole reset — six per-axis commits meant six soft-reloads
@@ -101,15 +111,10 @@ function Cube3dControl({
101
111
  scale: 1,
102
112
  transformPerspective: 0,
103
113
  };
104
- if (onCommitAnimatedProperties) {
105
- void onCommitAnimatedProperties(element, identity);
106
- } else {
107
- for (const [prop, v] of Object.entries(identity))
108
- void onCommitAnimatedProperty(element, prop, v);
109
- }
114
+ void onCommitAnimatedProperties(element, identity);
110
115
  };
111
116
  // Immediate element feedback while dragging — set the live transform without a
112
- // source write; the release commits via onCommitAnimatedProperty.
117
+ // source write; the release commits via commitPose.
113
118
  const livePreview = (next: CubePose) =>
114
119
  onLivePreviewProps?.(element, {
115
120
  rotationX: next.rotationX,
@@ -123,18 +128,54 @@ function Cube3dControl({
123
128
  <Transform3DCube
124
129
  pose={pose}
125
130
  perspective={gsapRuntimeValues.transformPerspective ?? 0}
131
+ defaultPerspective={depthPerspective}
132
+ z={gsapRuntimeValues.z ?? 0}
126
133
  onPoseDraft={livePreview}
127
134
  onPoseCommit={commitPose}
128
- onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
129
- onPerspectiveCommit={(px) =>
130
- void onCommitAnimatedProperty(element, "transformPerspective", px)
131
- }
135
+ onDepthDraft={(z) => {
136
+ // Preview WITH a lens so depth is visible while scrolling — the same
137
+ // default the commit applies, so the element doesn't snap on release.
138
+ const preview: Record<string, number> = gsapRuntimeValues.transformPerspective
139
+ ? { z }
140
+ : { z, transformPerspective: depthPerspective };
141
+ // Depth-pose preview: a flat element only scales under Z, so mirror the
142
+ // commit and preview the gentle tilt that makes the depth read as 3D.
143
+ if (isFlat) {
144
+ preview.rotationX = DEPTH_POSE_X;
145
+ preview.rotationY = DEPTH_POSE_Y;
146
+ }
147
+ onLivePreviewProps?.(element, preview);
148
+ }}
149
+ onDepthCommit={(z) => {
150
+ // Best-UX depth: scroll moves Z, and a 3D transform always has a lens —
151
+ // like an After Effects camera. translateZ is invisible without a
152
+ // perspective, so the FIRST time depth is added (Perspective still 0) we
153
+ // set a sensible comp-derived lens ONCE. Every later scroll touches Z
154
+ // only, and Perspective stays an independent, editable field. The cube's
155
+ // scroll is clamped in front of the lens, so Z can't run away past it.
156
+ const props: Record<string, number> = { z };
157
+ if (!gsapRuntimeValues.transformPerspective && depthPerspective > 0) {
158
+ props.transformPerspective = depthPerspective;
159
+ }
160
+ // Depth-pose: a flat element (no tilt) only scales under Z — it can't read
161
+ // as depth. So the first time depth lands on a flat element, also drop a
162
+ // gentle fixed tilt; the foreshortening makes depth read as 3D IN PLACE
163
+ // (no screen travel, per-element lens unchanged). Once the element has any
164
+ // tilt, depth scrolls touch Z only. Reset tilt to 0 to go flat again.
165
+ if (isFlat) {
166
+ props.rotationX = DEPTH_POSE_X;
167
+ props.rotationY = DEPTH_POSE_Y;
168
+ }
169
+ // One commit for all props so the writes can't race read-modify-write on
170
+ // the same script (which dropped a prop and reverted after a seek).
171
+ void onCommitAnimatedProperties(element, props);
172
+ }}
132
173
  onRecenter={recenter}
133
174
  onKeyframe={onKeyframe}
134
175
  keyframed={keyframed}
135
176
  />
136
177
  <p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
137
- Drag to tilt · Shift-drag to roll
178
+ Drag to tilt · Shift-drag to roll · Scroll for depth
138
179
  </p>
139
180
  </div>
140
181
  </div>
@@ -286,11 +327,10 @@ export function PropertyPanel3dTransform({
286
327
  </button>
287
328
  {collapsed ? null : (
288
329
  <>
289
- {onCommitAnimatedProperty && (
330
+ {onCommitAnimatedProperties && (
290
331
  <Cube3dControl
291
332
  element={element}
292
333
  gsapRuntimeValues={gsapRuntimeValues}
293
- onCommitAnimatedProperty={onCommitAnimatedProperty}
294
334
  onCommitAnimatedProperties={onCommitAnimatedProperties}
295
335
  onLivePreviewProps={onLivePreviewProps}
296
336
  keyframed={(gsapKeyframes ?? []).some(
@@ -13,6 +13,8 @@ export interface PropertyPanelProps {
13
13
  multiSelectCount?: number;
14
14
  copiedAgentPrompt: boolean;
15
15
  onClearSelection: () => void;
16
+ /** Dissolve the selected data-hf-group wrapper (shown only for group selections). */
17
+ onUngroup?: () => void;
16
18
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
17
19
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
18
20
  onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
@@ -11,6 +11,7 @@ import {
11
11
  type ResolvedElementRef,
12
12
  groupOverlayItemsEqual,
13
13
  isElementVisibleForOverlay,
14
+ groupAwareOverlayRect,
14
15
  rectsEqual,
15
16
  resolveElementForOverlay,
16
17
  selectionCacheKey,
@@ -155,7 +156,7 @@ export function useDomEditOverlayRects({
155
156
  // backgroundless full-bleed scene above a subcomposition), which would wrongly
156
157
  // hide the selection box. Occlusion stays for hover, where a false hide is cheap.
157
158
  if (el && isElementVisibleForOverlay(el)) {
158
- const nextRect = toOverlayRect(overlayEl, iframe, el);
159
+ const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
159
160
  setOverlayRect(nextRect);
160
161
  const descendants = el.querySelectorAll("*");
161
162
  if (descendants.length > 0 && descendants.length <= 60) {
@@ -196,9 +197,13 @@ export function useDomEditOverlayRects({
196
197
  const liveGroupKeys = new Set<string>();
197
198
  for (const groupSelection of group) {
198
199
  const key = selectionCacheKey(groupSelection);
200
+ // Members of the same group collapse to one selection under select-as-unit,
201
+ // so a multi-select can hold the same group twice — dedupe by key to avoid
202
+ // duplicate React keys (and a doubled overlay box).
203
+ if (liveGroupKeys.has(key)) continue;
199
204
  liveGroupKeys.add(key);
200
205
  const el = resolveGroupElement(doc, groupSelection);
201
- const rect = el ? toOverlayRect(overlayEl, iframe, el) : null;
206
+ const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
202
207
  if (el && rect)
203
208
  nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
204
209
  }
@@ -235,7 +240,7 @@ export function useDomEditOverlayRects({
235
240
  return;
236
241
  }
237
242
 
238
- setHoverRect(toOverlayRect(overlayEl, iframe, hoverEl));
243
+ setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
239
244
  };
240
245
 
241
246
  frame = requestAnimationFrame(update);
@@ -5,6 +5,38 @@ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGe
5
5
 
6
6
  type Rect = { left: number; top: number; width: number; height: number };
7
7
 
8
+ // The translate (e/f) components of an element's computed transform, in comp px.
9
+ // A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top.
10
+ function transformTranslate(el: HTMLElement): { x: number; y: number } {
11
+ const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
12
+ if (!t || t === "none") return { x: 0, y: 0 };
13
+ const m3 = t.match(/matrix3d\(([^)]+)\)/);
14
+ if (m3) {
15
+ const v = m3[1].split(",").map(Number);
16
+ return { x: v[12] || 0, y: v[13] || 0 };
17
+ }
18
+ const m = t.match(/matrix\(([^)]+)\)/);
19
+ if (m) {
20
+ const v = m[1].split(",").map(Number);
21
+ return { x: v[4] || 0, y: v[5] || 0 };
22
+ }
23
+ return { x: 0, y: 0 };
24
+ }
25
+
26
+ // Perspective foreshortening of the element's OWN transform (matrix3d m44). A
27
+ // depth element (translateZ toward the viewer) renders 1/m44× larger, so its
28
+ // animated x/y offsets travel 1/m44× further on screen than the flat preview
29
+ // scale implies. Returns 1 for 2D transforms. The motion path magnifies its
30
+ // offset points by 1/m44 (and de-magnifies pointer→offset) so the drawn path and
31
+ // its draggable nodes track the projected element instead of drifting off it.
32
+ export function transformWDivisor(el: HTMLElement): number {
33
+ const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
34
+ if (!t || !t.startsWith("matrix3d(")) return 1;
35
+ const v = t.slice("matrix3d(".length, -1).split(",");
36
+ const w = Number.parseFloat(v[15] ?? "");
37
+ return Number.isFinite(w) && w > 0 ? w : 1;
38
+ }
39
+
8
40
  export function elementHome(el: HTMLElement): { x: number; y: number } {
9
41
  let left = 0;
10
42
  let top = 0;
@@ -12,6 +44,14 @@ export function elementHome(el: HTMLElement): { x: number; y: number } {
12
44
  while (node) {
13
45
  left += node.offsetLeft;
14
46
  top += node.offsetTop;
47
+ // Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the
48
+ // element actually renders, so the path must anchor on top of them. The element's
49
+ // OWN transform is excluded — that's the animated offset the path itself draws.
50
+ if (node !== el) {
51
+ const t = transformTranslate(node);
52
+ left += t.x;
53
+ top += t.y;
54
+ }
15
55
  const parent = node.offsetParent as HTMLElement | null;
16
56
  if (!parent || parent.hasAttribute("data-composition-id")) break;
17
57
  node = parent;
@@ -62,6 +102,7 @@ export function useMotionPathData(
62
102
  geometryResolved: boolean;
63
103
  visibleInPreview: boolean;
64
104
  home: { x: number; y: number } | null;
105
+ pScale: number;
65
106
  } {
66
107
  const [rect, setRect] = useState<Rect | null>(null);
67
108
  const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
@@ -69,6 +110,9 @@ export function useMotionPathData(
69
110
  const geometryResolved = resolvedForRef.current === selector;
70
111
  const [visibleInPreview, setVisibleInPreview] = useState(true);
71
112
  const [home, setHome] = useState<{ x: number; y: number } | null>(null);
113
+ // Perspective magnification (1/m44) of the selected element — applied to the
114
+ // path's offset points so depth (translateZ) elements' paths track on screen.
115
+ const [pScale, setPScale] = useState(1);
72
116
 
73
117
  useEffect(() => {
74
118
  if (!selector) {
@@ -105,6 +149,8 @@ export function useMotionPathData(
105
149
  setHome((prev) =>
106
150
  prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
107
151
  );
152
+ const ps = 1 / transformWDivisor(live);
153
+ setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
108
154
  }
109
155
  }
110
156
  raf = requestAnimationFrame(tick);
@@ -132,5 +178,5 @@ export function useMotionPathData(
132
178
  return () => window.clearInterval(id);
133
179
  }, [selector, iframeRef]);
134
180
 
135
- return { rect, geometry, geometryResolved, visibleInPreview, home };
181
+ return { rect, geometry, geometryResolved, visibleInPreview, home, pScale };
136
182
  }
@@ -31,6 +31,9 @@ export interface DomEditActionsValue extends Pick<
31
31
  | "handleBlockedDomMove"
32
32
  | "handleDomManualDragStart"
33
33
  | "handleDomEditElementDelete"
34
+ | "handleGroupSelection"
35
+ | "handleUngroupSelection"
36
+ | "setActiveGroupElement"
34
37
  | "buildDomSelectionFromTarget"
35
38
  | "buildDomSelectionForTimelineElement"
36
39
  | "updateDomEditHoverSelection"
@@ -72,6 +75,7 @@ export interface DomEditSelectionValue extends Pick<
72
75
  | "domEditSelection"
73
76
  | "domEditGroupSelections"
74
77
  | "domEditHoverSelection"
78
+ | "activeGroupElement"
75
79
  | "domEditSelectionRef"
76
80
  | "selectedGsapAnimations"
77
81
  | "gsapMultipleTimelines"
@@ -138,6 +142,10 @@ export function DomEditProvider({
138
142
  handleBlockedDomMove,
139
143
  handleDomManualDragStart,
140
144
  handleDomEditElementDelete,
145
+ handleGroupSelection,
146
+ handleUngroupSelection,
147
+ setActiveGroupElement,
148
+ activeGroupElement,
141
149
  buildDomSelectionFromTarget,
142
150
  buildDomSelectionForTimelineElement,
143
151
  updateDomEditHoverSelection,
@@ -216,6 +224,9 @@ export function DomEditProvider({
216
224
  handleBlockedDomMove,
217
225
  handleDomManualDragStart,
218
226
  handleDomEditElementDelete,
227
+ handleGroupSelection,
228
+ handleUngroupSelection,
229
+ setActiveGroupElement,
219
230
  buildDomSelectionFromTarget,
220
231
  buildDomSelectionForTimelineElement,
221
232
  updateDomEditHoverSelection,
@@ -277,6 +288,9 @@ export function DomEditProvider({
277
288
  handleBlockedDomMove,
278
289
  handleDomManualDragStart,
279
290
  handleDomEditElementDelete,
291
+ handleGroupSelection,
292
+ handleUngroupSelection,
293
+ setActiveGroupElement,
280
294
  buildDomSelectionFromTarget,
281
295
  buildDomSelectionForTimelineElement,
282
296
  updateDomEditHoverSelection,
@@ -319,6 +333,7 @@ export function DomEditProvider({
319
333
  domEditSelection,
320
334
  domEditGroupSelections,
321
335
  domEditHoverSelection,
336
+ activeGroupElement,
322
337
  domEditSelectionRef,
323
338
  selectedGsapAnimations,
324
339
  gsapMultipleTimelines,
@@ -332,6 +347,7 @@ export function DomEditProvider({
332
347
  domEditSelection,
333
348
  domEditGroupSelections,
334
349
  domEditHoverSelection,
350
+ activeGroupElement,
335
351
  domEditSelectionRef,
336
352
  selectedGsapAnimations,
337
353
  gsapMultipleTimelines,
@@ -39,7 +39,6 @@ export function TimelineEditProvider({
39
39
  value.onDeleteKeyframe,
40
40
  value.onDeleteAllKeyframes,
41
41
  value.onChangeKeyframeEase,
42
- value.onMoveKeyframe,
43
42
  value.onToggleKeyframeAtPlayhead,
44
43
  ],
45
44
  );
@@ -241,8 +241,15 @@ export async function tryGsapDragIntercept(
241
241
  // place (idempotent), else add a new one. This also covers the stale-cache
242
242
  // phantom — committing a set is correct because the element genuinely has no live motion.
243
243
  const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
244
-
245
- if (!hasNonHold) {
244
+ // A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
245
+ // e.g. 0% and 100% identical) — is still an animation the user is building, so a
246
+ // drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
247
+ // dragging an element whose position tween is constant writes a `gsap.set` that
248
+ // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
249
+ // static path is only for elements with NO keyframed position tween (truly static,
250
+ // or just a leftover position-hold `set`).
251
+ const hasKeyframedPosTween = !!posAnim?.keyframes;
252
+ if (!hasNonHold && !hasKeyframedPosTween) {
246
253
  const existingSet =
247
254
  posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
248
255
  ? posAnim
@@ -0,0 +1,64 @@
1
+ import type {
2
+ GsapAnimation,
3
+ GsapKeyframesData,
4
+ GsapPercentageKeyframe,
5
+ } from "@hyperframes/core/gsap-parser";
6
+ import { PROPERTY_DEFAULTS } from "./gsapShared";
7
+
8
+ export function deduplicateKeyframes(
9
+ keyframes: GsapPercentageKeyframe[],
10
+ ): GsapPercentageKeyframe[] {
11
+ const byPct = new Map<number, GsapPercentageKeyframe>();
12
+ for (const kf of keyframes) {
13
+ const existing = byPct.get(kf.percentage);
14
+ if (existing) {
15
+ existing.properties = { ...existing.properties, ...kf.properties };
16
+ if (kf.ease) existing.ease = kf.ease;
17
+ } else {
18
+ byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
19
+ }
20
+ }
21
+ return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
22
+ }
23
+
24
+ // fallow-ignore-next-line complexity
25
+ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
26
+ if (anim.method === "set") {
27
+ // A `set` is a STATIC HOLD — a value applied at one point, not an animated
28
+ // keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
29
+ // phantom diamond for a value that doesn't animate. This holds for a base
30
+ // `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
31
+ // path with the runtime scan, which already skips every zero-duration set.
32
+ return null;
33
+ }
34
+ const toProps = anim.properties;
35
+ const fromProps = anim.fromProperties;
36
+ if (!toProps || Object.keys(toProps).length === 0) return null;
37
+
38
+ const startProps: Record<string, number | string> = {};
39
+ const endProps: Record<string, number | string> = {};
40
+
41
+ if (anim.method === "from") {
42
+ for (const [k, v] of Object.entries(toProps)) {
43
+ startProps[k] = v;
44
+ endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
45
+ }
46
+ } else if (anim.method === "fromTo" && fromProps) {
47
+ Object.assign(startProps, fromProps);
48
+ Object.assign(endProps, toProps);
49
+ } else {
50
+ for (const [k, v] of Object.entries(toProps)) {
51
+ startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
52
+ endProps[k] = v;
53
+ }
54
+ }
55
+
56
+ return {
57
+ format: "percentage",
58
+ keyframes: [
59
+ { percentage: 0, properties: startProps },
60
+ { percentage: 100, properties: endProps },
61
+ ],
62
+ ...(anim.ease ? { ease: anim.ease } : {}),
63
+ };
64
+ }
@@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
17
  import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
18
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
+ import { roundTo3 } from "../utils/rounding";
18
20
 
19
21
  interface CommitAnimatedPropertyDeps {
20
22
  selectedGsapAnimations: GsapAnimation[];
@@ -45,14 +47,22 @@ function pickBestAnimation(
45
47
  selector: string | null,
46
48
  property?: string,
47
49
  ): GsapAnimation | undefined {
48
- if (animations.length <= 1) return animations[0];
49
- const currentTime = usePlayerStore.getState().currentTime;
50
50
  const targetGroup = property ? classifyPropertyGroup(property) : undefined;
51
-
52
- // fallow-ignore-next-line complexity
53
- const scored = animations.map((a) => {
51
+ // Group-aware: never hand back a tween from a DIFFERENT property group. The old
52
+ // `animations.length <= 1` early return merged a rotation/3D edit into the element's
53
+ // only tween even when that was a `position` tween — contaminating it and leaving the
54
+ // new property with no clean keyframe baseline. When a target group is known, only
55
+ // same-group tweens are candidates; if none exist we return undefined and the caller
56
+ // creates a fresh same-group tween.
57
+ const candidates =
58
+ targetGroup !== undefined
59
+ ? animations.filter((a) => a.propertyGroup === targetGroup)
60
+ : animations;
61
+ if (candidates.length === 0) return undefined;
62
+ if (candidates.length === 1) return candidates[0];
63
+ const currentTime = usePlayerStore.getState().currentTime;
64
+ const scored = candidates.map((a) => {
54
65
  let score = 0;
55
- if (targetGroup && a.propertyGroup === targetGroup) score += 20;
56
66
  if (a.keyframes) score += 10;
57
67
  if (selector && a.targetSelector === selector) score += 5;
58
68
  else if (a.targetSelector.includes(",")) score -= 3;
@@ -196,14 +206,15 @@ async function commitKeyframeProps(
196
206
  iframe: HTMLIFrameElement | null,
197
207
  commit: Commit,
198
208
  ): Promise<void> {
199
- if (!anim.keyframes) {
209
+ const wasKeyframed = !!anim.keyframes;
210
+ if (!wasKeyframed) {
200
211
  await commit(
201
212
  selection,
202
213
  { type: "convert-to-keyframes", animationId: anim.id },
203
214
  { label: "Convert to keyframes", skipReload: true },
204
215
  );
205
216
  }
206
- const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
217
+ const ct = usePlayerStore.getState().currentTime;
207
218
  const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
208
219
  const properties: Record<string, number | string> = { ...runtimeProps, ...props };
209
220
 
@@ -216,6 +227,52 @@ async function commitKeyframeProps(
216
227
  backfillDefaults[property] = value;
217
228
  }
218
229
 
230
+ // Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it
231
+ // and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe.
232
+ // The add-keyframe below only writes WITHIN the existing range, so without this a
233
+ // depth edit past the tween end just overwrites the last keyframe (the bug: no new
234
+ // diamond appears at a playhead beyond the tween). Only for an already-keyframed
235
+ // tween — a freshly-converted set has no prior range worth remapping.
236
+ const kfs = anim.keyframes?.keyframes;
237
+ const ts = resolveTweenStart(anim);
238
+ const td = resolveTweenDuration(anim);
239
+ const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
240
+ const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
241
+ const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe;
242
+ if (willExtend && kfs && ts !== null) {
243
+ const newStart = Math.min(ct, ts);
244
+ const newEnd = Math.max(ct, ts + td);
245
+ const newDuration = Math.max(0.01, newEnd - newStart);
246
+ const remapped = kfs.map((kf) => {
247
+ const absTime = ts + (kf.percentage / 100) * td;
248
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
249
+ const p: Record<string, number | string> = { ...kf.properties };
250
+ for (const k of Object.keys(properties)) {
251
+ if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
252
+ }
253
+ return { percentage: newPct, properties: p };
254
+ });
255
+ remapped.push({
256
+ percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10,
257
+ properties,
258
+ });
259
+ remapped.sort((a, b) => a.percentage - b.percentage);
260
+ await commit(
261
+ selection,
262
+ {
263
+ type: "replace-with-keyframes",
264
+ animationId: anim.id,
265
+ targetSelector: anim.targetSelector,
266
+ position: roundTo3(newStart),
267
+ duration: roundTo3(newDuration),
268
+ keyframes: remapped,
269
+ },
270
+ { label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
271
+ );
272
+ return;
273
+ }
274
+
275
+ const pct = computeElementPercentage(ct, selection, anim);
219
276
  const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
220
277
  // Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
221
278
  // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
@@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
275
332
  // so the rejection doesn't escape as an uncaught promise, and bump the cache
276
333
  // so selectedGsapAnimations re-syncs and the user's next edit self-heals.
277
334
  try {
278
- // Existing static hold merge the props into the `set`, then auto-keyframe
279
- // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not).
335
+ // Animated element keyframe at the playhead, EXACTLY like manual drag /
336
+ // resize / rotate: if the picked anim is still a static `set`,
337
+ // commitKeyframeProps converts it to keyframes first, then writes the new
338
+ // value as a keyframe at the current time — so the 3D animates instead of
339
+ // holding a flat constant. This MUST come before the `set`-update path below,
340
+ // or a 3D `set` would short-circuit to an in-place update and the playhead
341
+ // keyframe would never land (the bug: scrolling depth on a keyframed element
342
+ // just changed the constant instead of dropping a keyframe).
343
+ if (elementHasKeyframes && anim) {
344
+ await commitKeyframeProps(
345
+ selection,
346
+ anim,
347
+ props,
348
+ propEntries,
349
+ primaryProp,
350
+ selector,
351
+ iframe,
352
+ gsapCommitMutation,
353
+ );
354
+ return;
355
+ }
356
+
357
+ // Existing static hold on a NON-animated element — merge the props into the
358
+ // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
280
359
  if (anim?.method === "set") {
281
360
  await commitSetProps(
282
361
  selection,
@@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
289
368
  return;
290
369
  }
291
370
 
292
- // Static element — persist as a `tl.set`, never keyframes (incl. the
293
- // no-animation case, which now creates a set instead of a keyframed tween).
371
+ // Static element (no keyframes anywhere) — persist as a `tl.set`, never
372
+ // keyframes (incl. the no-animation case, which creates a fresh set).
294
373
  if (!elementHasKeyframes) {
295
374
  await commitStaticSet(
296
375
  selection,
@@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
302
381
  return;
303
382
  }
304
383
 
305
- // Animated element write ALL props into ONE keyframe so a multi-axis cube
306
- // edit doesn't race into adjacent duplicates.
307
- if (!anim) {
308
- bumpGsapCache();
384
+ // Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D
385
+ // keyframe on an element that only has a position tween). Create a fresh
386
+ // same-group keyframed tween WITH a 0% baseline at the playhead, instead of
387
+ // contaminating a foreign-group tween. Mirror an existing keyframed tween's
388
+ // time range so the new group animates over the same span. The 0% baseline is
389
+ // an `_auto` endpoint so it tracks the nearest keyframe as you add more.
390
+ if (selector) {
391
+ const template = selectedGsapAnimations.find((a) => !!a.keyframes);
392
+ const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
393
+ const tDur = template ? resolveTweenDuration(template) || 1 : 1;
394
+ const ct = usePlayerStore.getState().currentTime;
395
+ const pct =
396
+ tDur > 0
397
+ ? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10))
398
+ : 0;
399
+ const newProps = Object.fromEntries(propEntries);
400
+ const keyframes =
401
+ pct <= 0.05
402
+ ? [{ percentage: 0, properties: newProps }]
403
+ : [
404
+ { percentage: 0, properties: { ...newProps, _auto: 1 } },
405
+ { percentage: pct, properties: newProps },
406
+ ];
407
+ await gsapCommitMutation(
408
+ selection,
409
+ {
410
+ type: "add-with-keyframes",
411
+ targetSelector: selector,
412
+ position: roundTo3(tStart),
413
+ duration: roundTo3(tDur),
414
+ keyframes,
415
+ },
416
+ { label: `Add ${primaryProp} keyframe`, softReload: true },
417
+ );
309
418
  return;
310
419
  }
311
- await commitKeyframeProps(
312
- selection,
313
- anim,
314
- props,
315
- propEntries,
316
- primaryProp,
317
- selector,
318
- iframe,
319
- gsapCommitMutation,
320
- );
420
+ bumpGsapCache();
321
421
  } catch {
322
422
  bumpGsapCache();
323
423
  }