@hyperframes/studio 0.7.14 → 0.7.16

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 (72) hide show
  1. package/dist/assets/index-C4cUbqql.js +375 -0
  2. package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
  5. package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +23 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3158 -2809
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -5
  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 +4 -1
  31. package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
  32. package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
  33. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  34. package/src/components/editor/manualEditsTypes.ts +1 -1
  35. package/src/components/editor/manualOffsetDrag.ts +23 -2
  36. package/src/components/editor/motionPathCommit.test.ts +1 -1
  37. package/src/components/editor/motionPathSelection.ts +1 -1
  38. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  39. package/src/components/editor/propertyPanelHelpers.ts +6 -4
  40. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  41. package/src/components/editor/useMotionPathData.ts +47 -1
  42. package/src/contexts/DomEditContext.tsx +16 -0
  43. package/src/contexts/TimelineEditContext.tsx +0 -1
  44. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  45. package/src/hooks/gsapTweenSynth.ts +64 -0
  46. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  47. package/src/hooks/useAppHotkeys.ts +34 -3
  48. package/src/hooks/useDomEditSession.ts +48 -0
  49. package/src/hooks/useDomSelection.ts +73 -9
  50. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  51. package/src/hooks/useEnableKeyframes.ts +29 -1
  52. package/src/hooks/useGroupCommits.ts +188 -0
  53. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  54. package/src/hooks/useGsapTweenCache.ts +56 -72
  55. package/src/hooks/usePreviewInteraction.ts +60 -2
  56. package/src/player/components/ShortcutsPanel.tsx +2 -0
  57. package/src/player/components/Timeline.tsx +0 -15
  58. package/src/player/components/TimelineCanvas.tsx +0 -13
  59. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  60. package/src/player/components/timelineCallbacks.ts +0 -1
  61. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  62. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  63. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  64. package/src/player/store/playerStore.ts +20 -0
  65. package/src/utils/sdkCutoverParity.test.ts +1 -1
  66. package/src/utils/studioPreviewHelpers.ts +37 -0
  67. package/dist/assets/index-BXG-pbr_.js +0 -375
  68. package/dist/assets/index-svYFaNuq.css +0 -1
  69. package/dist/chunk-KZXYQYIU.js.map +0 -1
  70. package/src/components/editor/keyframeMove.test.ts +0 -101
  71. package/src/components/editor/keyframeMove.ts +0 -151
  72. /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
@@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
209
209
  };
210
210
  }
211
211
 
212
+ /**
213
+ * The perspective w-divisor (matrix3d m44) of the element's current transform.
214
+ * For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
215
+ * element renders 1/m44× larger and a translate of `d` composition px moves
216
+ * `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used
217
+ * to keep the drag offset → screen-movement mapping correct for depth elements,
218
+ * which the flat-scale fast path below would otherwise get wrong by 1/m44.
219
+ */
220
+ function readTransformWDivisor(element: HTMLElement): number {
221
+ const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
222
+ if (!t || !t.startsWith("matrix3d(")) return 1;
223
+ const parts = t.slice("matrix3d(".length, -1).split(",");
224
+ const w = Number.parseFloat(parts[15] ?? "");
225
+ return Number.isFinite(w) && w > 0 ? w : 1;
226
+ }
227
+
212
228
  export function measureManualOffsetDragScreenToOffsetMatrix(
213
229
  element: HTMLElement,
214
230
  initialOffset: { x: number; y: number },
@@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
221
237
  ) {
222
238
  const sx = options.scaleX || 1;
223
239
  const sy = options.scaleY || 1;
224
- return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } };
240
+ // Fold in the perspective foreshortening: a depth element (z≠0) moves
241
+ // 1/m44× faster on screen than its flat scale implies, so the screen→offset
242
+ // matrix must scale by m44 or the element outruns the pointer/overlay.
243
+ const w = readTransformWDivisor(element);
244
+ return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } };
225
245
  }
226
246
 
227
247
  const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
@@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: {
360
380
  // drag is acceptable — the final committed position is always exact.
361
381
  const scaleX = input.rect.editScaleX || 1;
362
382
  const scaleY = input.rect.editScaleY || 1;
383
+ const w = readTransformWDivisor(input.element);
363
384
  return {
364
385
  ok: true,
365
386
  member: {
@@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: {
370
391
  baseGsap,
371
392
  initialPathOffset,
372
393
  gestureToken,
373
- screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
394
+ screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
374
395
  originRect: input.rect,
375
396
  },
376
397
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
3
3
  import { editableAnimationId } from "./motionPathSelection";
4
4
  import {
5
5
  commitNode,
@@ -3,7 +3,7 @@
3
3
  * Shared by the overlay and its diagnostics (kept here to avoid a circular
4
4
  * import between the two).
5
5
  */
6
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
7
7
  import type { DomEditSelection } from "./domEditing";
8
8
 
9
9
  export function selectorFor(sel: DomEditSelection | null): string | null {
@@ -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(
@@ -2,7 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
2
2
  import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
6
  import { roundToCenti } from "../../utils/rounding";
7
7
 
8
8
  export interface PropertyPanelProps {
@@ -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>;
@@ -29,7 +31,7 @@ export interface PropertyPanelProps {
29
31
  fontAssets?: ImportedFontAsset[];
30
32
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
31
33
  previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
32
- gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
34
+ gsapAnimations?: import("@hyperframes/parsers/gsap-parser").GsapAnimation[];
33
35
  gsapMultipleTimelines?: boolean;
34
36
  gsapUnsupportedTimelinePattern?: boolean;
35
37
  onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
@@ -49,13 +51,13 @@ export interface PropertyPanelProps {
49
51
  config: {
50
52
  enabled: boolean;
51
53
  autoRotate?: boolean | number;
52
- segments?: import("@hyperframes/core/gsap-parser").ArcPathSegment[];
54
+ segments?: import("@hyperframes/parsers/gsap-parser").ArcPathSegment[];
53
55
  },
54
56
  ) => void;
55
57
  onUpdateArcSegment?: (
56
58
  animId: string,
57
59
  segmentIndex: number,
58
- update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
60
+ update: Partial<import("@hyperframes/parsers/gsap-parser").ArcPathSegment>,
59
61
  ) => void;
60
62
  /** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */
61
63
  onUnroll?: (animationId: string) => 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
+ }