@hyperframes/studio 0.6.121 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -9,6 +9,8 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyf
9
9
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
10
10
  import { roundTo3 } from "../utils/rounding";
11
11
  import { computeElementPercentage } from "./gsapShared";
12
+ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
13
+ import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
12
14
  export interface GsapDragCommitCallbacks {
13
15
  commitMutation: (
14
16
  selection: DomEditSelection,
@@ -19,6 +21,13 @@ export interface GsapDragCommitCallbacks {
19
21
  softReload?: boolean;
20
22
  skipReload?: boolean;
21
23
  beforeReload?: () => void;
24
+ /**
25
+ * Value-only fast path: when set, `runCommit` patches the changed tween in
26
+ * the preview runtime in place (instant, no re-run) and only falls back to
27
+ * the soft reload if the patch can't be safely applied. Attached only to
28
+ * value-only `set` commits; structural/keyframe commits omit it.
29
+ */
30
+ instantPatch?: { selector: string; change: RuntimeTweenChange };
22
31
  },
23
32
  ) => Promise<void>;
24
33
  fetchAnimations?: () => Promise<GsapAnimation[]>;
@@ -32,6 +41,17 @@ export function computeCurrentPercentage(
32
41
  return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
33
42
  }
34
43
 
44
+ // When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact
45
+ // time. Otherwise the playhead can sit a frame outside the tween (e.g. 1.1666 vs a
46
+ // 1.2 start), so the post-commit reseek renders the element's base pose and the edit
47
+ // looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
48
+ export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
49
+ const ts = resolveTweenStart(anim);
50
+ const td = resolveTweenDuration(anim);
51
+ if (ts == null || !td || td <= 0) return;
52
+ usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
53
+ }
54
+
35
55
  // ── Dynamic keyframe materialization ──────────────────────────────────────
36
56
 
37
57
  export async function materializeIfDynamic(
@@ -77,198 +97,262 @@ export async function materializeIfDynamic(
77
97
  );
78
98
  }
79
99
 
80
- // ── Extend tween ──────────────────────────────────────────────────────────
100
+ // ── Drag GSAP position math ──────────────────────────────────────────────
101
+
102
+ /** The shape of an `update-property` mutation a static-set nudge POSTs. */
103
+ interface UpdatePropertyMutation {
104
+ type: "update-property";
105
+ animationId: string;
106
+ property: string;
107
+ value: number;
108
+ }
81
109
 
82
110
  /**
83
- * Extend a tween's time range to cover `targetTime`, remap all existing
84
- * keyframe percentages to preserve their absolute positions, then add
85
- * a new keyframe at the target time.
111
+ * Build the `instantPatch` for a value-only `tl.set` from the SAME
112
+ * `update-property` mutation(s) that are POSTed so the patch can never carry a
113
+ * value the source write didn't (one source of truth). Each mutation contributes
114
+ * its `{property: value}` channel to the patch's props.
86
115
  */
87
- async function extendTweenAndAddKeyframe(
116
+ function setPatchFromUpdateProperties(
117
+ selector: string,
118
+ mutations: UpdatePropertyMutation[],
119
+ ): { selector: string; change: RuntimeTweenChange } {
120
+ const props: SetPatchProps = {};
121
+ for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
122
+ return { selector, change: { kind: "set", props } };
123
+ }
124
+
125
+ /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
126
+ function setPatchFromUpdateProperty(
127
+ selector: string,
128
+ mutation: UpdatePropertyMutation,
129
+ ): { selector: string; change: RuntimeTweenChange } {
130
+ return setPatchFromUpdateProperties(selector, [mutation]);
131
+ }
132
+
133
+ /**
134
+ * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
135
+ * with no duration. This is what a static-element nudge writes/updates.
136
+ */
137
+ function findPositionSetAnimation(
138
+ animations: GsapAnimation[],
139
+ selector: string,
140
+ ): GsapAnimation | null {
141
+ return (
142
+ animations.find(
143
+ (a) =>
144
+ a.method === "set" &&
145
+ a.targetSelector === selector &&
146
+ ("x" in a.properties || "y" in a.properties),
147
+ ) ?? null
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
153
+ * position channel for elements with no position animation. Idempotent: a
154
+ * re-nudge of an element that already has a `set` UPDATES that set's x/y
155
+ * (two `update-property` mutations) rather than stacking a second set or
156
+ * converting it to keyframes (plan R2 / KTD3). New elements get one `add`
157
+ * mutation with `method:"set"` at position 0.
158
+ */
159
+ export async function commitStaticGsapPosition(
88
160
  selection: DomEditSelection,
89
- anim: GsapAnimation,
90
- properties: Record<string, number>,
91
- targetTime: number,
92
- tweenStart: number,
93
- tweenDuration: number,
161
+ studioOffset: { x: number; y: number },
162
+ gsapPos: { x: number; y: number },
163
+ selector: string,
164
+ existingSet: GsapAnimation | null,
94
165
  callbacks: GsapDragCommitCallbacks,
95
- beforeReload?: () => void,
96
166
  ): Promise<void> {
97
- const tweenEnd = tweenStart + tweenDuration;
98
- const newStart = Math.min(targetTime, tweenStart);
99
- const newEnd = Math.max(targetTime, tweenEnd);
100
- const newDuration = Math.max(0.01, newEnd - newStart);
101
- const existingKfs = anim.keyframes?.keyframes ?? [];
102
- const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
103
- [];
104
- for (const kf of existingKfs) {
105
- const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
106
- const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
107
- remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
167
+ const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
168
+ if (existingSet) {
169
+ // Update in place — two single-property mutations (the API updates one prop
170
+ // per call). Coalesce them and reload only after the second lands.
171
+ const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
172
+ // Build each mutation FIRST, then derive its instantPatch from the SAME
173
+ // object that's POSTed — so a future caller can't ship a clean mutation with
174
+ // a stale/malformed patch (the validated `value` flows straight into the
175
+ // patch). `findUnsafeMutationValues` validates the mutation upstream.
176
+ const xMutation = {
177
+ type: "update-property",
178
+ animationId: existingSet.id,
179
+ property: "x",
180
+ value: newX,
181
+ } as const;
182
+ const yMutation = {
183
+ type: "update-property",
184
+ animationId: existingSet.id,
185
+ property: "y",
186
+ value: newY,
187
+ } as const;
188
+ // Patch BOTH coalesced commits. If the SECOND POST fails server-side, the
189
+ // first (x) already persisted — patching its commit too means the live
190
+ // preview still reflects what DID persist. The x commit carries skipReload
191
+ // (no reload), so its instantPatch gives instant feedback without a reload;
192
+ // the y commit triggers the soft reload (skipped when the patch applies).
193
+ await callbacks.commitMutation(selection, xMutation, {
194
+ label: "Move layer",
195
+ skipReload: true,
196
+ coalesceKey,
197
+ instantPatch: setPatchFromUpdateProperty(selector, xMutation),
198
+ });
199
+ await callbacks.commitMutation(selection, yMutation, {
200
+ label: "Move layer",
201
+ softReload: true,
202
+ coalesceKey,
203
+ // Final commit of the coalesced x/y pair: carry both channels so the
204
+ // runtime `tl.set` lands the complete {x,y} pose in place.
205
+ instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
206
+ });
207
+ return;
108
208
  }
109
-
110
- const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
111
- remappedKfs.push({ percentage: targetPct, properties });
112
-
113
- remappedKfs.sort((a, b) => a.percentage - b.percentage);
114
-
115
209
  await callbacks.commitMutation(
116
210
  selection,
117
211
  {
118
- type: "replace-with-keyframes",
119
- animationId: anim.id,
120
- targetSelector: anim.targetSelector,
121
- position: roundTo3(newStart),
122
- duration: roundTo3(newDuration),
123
- keyframes: remappedKfs,
212
+ type: "add",
213
+ targetSelector: selector,
214
+ method: "set",
215
+ position: 0,
216
+ properties: { x: newX, y: newY },
124
217
  },
125
- { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
218
+ { label: "Move layer", softReload: true },
126
219
  );
127
220
  }
128
221
 
129
- // fallow-ignore-next-line complexity
130
- async function commitKeyframedPosition(
222
+ export { findPositionSetAnimation };
223
+
224
+ function findRotationSetAnimation(
225
+ animations: GsapAnimation[],
226
+ selector: string,
227
+ ): GsapAnimation | null {
228
+ return (
229
+ animations.find(
230
+ (a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
231
+ ) ?? null
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single-
237
+ * source rotation channel for elements with no rotation animation (mirrors
238
+ * `commitStaticGsapPosition`). `newRotation` is the already-resolved absolute angle
239
+ * (current runtime rotation + drag delta). Idempotent: re-rotating an element that
240
+ * already has a rotation `set` UPDATES it in place (one `update-property`, rotation
241
+ * is a single value unlike x/y); a new element gets one `add` with `method:"set"`.
242
+ */
243
+ export async function commitStaticGsapRotation(
131
244
  selection: DomEditSelection,
132
- anim: GsapAnimation,
133
- properties: Record<string, number>,
245
+ newRotation: number,
246
+ selector: string,
247
+ existingSet: GsapAnimation | null,
134
248
  callbacks: GsapDragCommitCallbacks,
135
- beforeReload?: () => void,
136
249
  ): Promise<void> {
137
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
138
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
250
+ if (existingSet) {
251
+ // Derive the instantPatch from the SAME mutation object that's POSTed (single
252
+ // source of truth — see commitStaticGsapPosition), so the validated `value`
253
+ // flows into the patch and the two can't drift.
254
+ const rotationMutation = {
255
+ type: "update-property",
256
+ animationId: existingSet.id,
257
+ property: "rotation",
258
+ value: newRotation,
259
+ } as const;
260
+ await callbacks.commitMutation(selection, rotationMutation, {
261
+ label: "Rotate layer",
262
+ softReload: true,
263
+ // Value-only rotation set: patch the runtime `tl.set` rotation in place.
264
+ instantPatch: setPatchFromUpdateProperty(selector, rotationMutation),
265
+ });
266
+ return;
267
+ }
139
268
  await callbacks.commitMutation(
140
269
  selection,
141
270
  {
142
- type: "add-keyframe",
143
- animationId: anim.id,
144
- percentage: pct,
145
- properties,
271
+ type: "add",
272
+ targetSelector: selector,
273
+ method: "set",
274
+ position: 0,
275
+ properties: { rotation: newRotation },
146
276
  },
147
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
277
+ { label: "Rotate layer", softReload: true },
278
+ );
279
+ }
280
+
281
+ export { findRotationSetAnimation };
282
+
283
+ function findSizeSetAnimation(animations: GsapAnimation[], selector: string): GsapAnimation | null {
284
+ return (
285
+ animations.find(
286
+ (a) =>
287
+ a.method === "set" &&
288
+ a.targetSelector === selector &&
289
+ ("width" in a.properties || "height" in a.properties),
290
+ ) ?? null
148
291
  );
149
- if (activeKeyframePct != null) setActiveKeyframePct(null);
150
292
  }
151
293
 
152
294
  /**
153
- * For flat to()/set() tweens, convert to keyframes first so we can place the
154
- * drag position at the current percentage.
295
+ * Commit a STATIC element resize as a `tl.set("#el",{width,height})` the
296
+ * single-source size channel for elements with no size animation (mirrors
297
+ * `commitStaticGsapPosition`). Use this instead of a single-stop `keyframes`
298
+ * tween: one keyframe at the playhead % renders NaN/0 at every other frame, so
299
+ * the element collapses/disappears (worst when resized off the 0% mark). A `set`
300
+ * holds the size at all times. Re-resizing an element that already has a size
301
+ * `set` UPDATES it in place (two `update-property`, like x/y); a new element
302
+ * gets one `add` with `method:"set"`.
155
303
  */
156
- // fallow-ignore-next-line complexity
157
- async function commitFlatViaKeyframes(
304
+ export async function commitStaticGsapSize(
158
305
  selection: DomEditSelection,
159
- anim: GsapAnimation,
160
- properties: Record<string, number>,
306
+ size: { width: number; height: number },
307
+ selector: string,
308
+ existingSet: GsapAnimation | null,
161
309
  callbacks: GsapDragCommitCallbacks,
162
- beforeReload?: () => void,
163
- iframe?: HTMLIFrameElement | null,
164
- selector?: string,
165
310
  ): Promise<void> {
166
- const ct = usePlayerStore.getState().currentTime;
167
- const ts = resolveTweenStart(anim);
168
- const td = resolveTweenDuration(anim);
169
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
170
-
171
- // Read the runtime position at the tween's start time so the 0% keyframe
172
- // captures the actual interpolated value (e.g. x=300 after a preceding slide),
173
- // not the identity value (x=0) that a blind convert would produce.
174
- const resolvedFromValues: Record<string, number | string> = {};
175
- if (iframe && selector && ts !== null) {
176
- try {
177
- const iframeWin = iframe.contentWindow as any;
178
- const gsapLib = iframeWin?.gsap;
179
- const el = iframe.contentDocument?.querySelector(selector);
180
- const timelines = iframeWin?.__timelines;
181
- const mainTl = timelines ? (Object.values(timelines)[0] as any) : null;
182
- if (gsapLib && el && mainTl?.seek) {
183
- mainTl.seek(ts);
184
- for (const key of Object.keys(properties)) {
185
- const v = Number(gsapLib.getProperty(el, key));
186
- if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
187
- }
188
- mainTl.seek(ct);
189
- }
190
- } catch {
191
- /* iframe access failed — fall back to identity values */
192
- }
193
- }
194
-
195
- if (outsideRange && ts !== null) {
196
- // Outside the tween's range: add a brand new keyframed tween at the drag
197
- // time instead of extending/replacing the existing one. This keeps all
198
- // existing tweens untouched and creates a clean hold at the dragged position.
199
- const tweenEnd = ts + td;
200
- const holdStart = ct > tweenEnd ? tweenEnd : ct;
201
- const holdEnd = ct > tweenEnd ? ct : ts;
202
- const holdDur = Math.max(0.01, holdEnd - holdStart);
203
- const kfs =
204
- ct > tweenEnd
205
- ? [
206
- { percentage: 0, properties: resolvedFromValues },
207
- { percentage: 100, properties },
208
- ]
209
- : [
210
- { percentage: 0, properties },
211
- { percentage: 100, properties: resolvedFromValues },
212
- ];
213
- console.log(
214
- "[drag:5] outside range — adding new tween",
215
- JSON.stringify({
216
- ct,
217
- ts,
218
- td,
219
- holdStart: roundTo3(holdStart),
220
- holdDur: roundTo3(holdDur),
221
- from: resolvedFromValues,
222
- to: properties,
223
- }),
311
+ const width = Math.round(size.width);
312
+ const height = Math.round(size.height);
313
+ if (existingSet) {
314
+ await callbacks.commitMutation(
315
+ selection,
316
+ { type: "delete", animationId: existingSet.id },
317
+ { label: "Resize layer", skipReload: true },
224
318
  );
225
319
  await callbacks.commitMutation(
226
320
  selection,
227
321
  {
228
- type: "add-with-keyframes",
229
- targetSelector: anim.targetSelector,
230
- position: roundTo3(holdStart),
231
- duration: roundTo3(holdDur),
232
- keyframes: kfs,
322
+ type: "add",
323
+ targetSelector: selector,
324
+ method: "set",
325
+ position: 0,
326
+ properties: { width, height },
233
327
  },
234
- { label: "Move layer (new keyframe)", softReload: true, beforeReload },
328
+ { label: "Resize layer", softReload: true },
235
329
  );
236
330
  return;
237
331
  }
238
-
239
- // Inside range: convert the flat tween to keyframes, then add at current %.
240
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
241
332
  await callbacks.commitMutation(
242
333
  selection,
243
334
  {
244
- type: "convert-to-keyframes",
245
- animationId: anim.id,
246
- ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
335
+ type: "add",
336
+ targetSelector: selector,
337
+ method: "set",
338
+ position: 0,
339
+ properties: { width, height },
247
340
  },
248
- { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
249
- );
250
- const pct = computeCurrentPercentage(selection, anim);
251
-
252
- await callbacks.commitMutation(
253
- selection,
254
- {
255
- type: "add-keyframe",
256
- animationId: anim.id,
257
- percentage: pct,
258
- properties,
259
- },
260
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
341
+ { label: "Resize layer", softReload: true },
261
342
  );
262
343
  }
263
344
 
264
- // ── Main drag commit ──────────────────────────────────────────────────────
345
+ export { findSizeSetAnimation };
346
+
347
+ // ── Whole-path offset (plain drag on animated element) ──────────────────
265
348
 
266
349
  /**
267
- * Compute the new GSAP position values from runtime-read positions + drag
268
- * offset, then commit the mutation to the GSAP script.
350
+ * Offset the entire animation path by the drag delta every keyframe's x/y
351
+ * shifts together so the animation shape is preserved and the element can't
352
+ * dart off-screen. For flat tweens (no keyframes), convert first then shift.
269
353
  */
270
354
  // fallow-ignore-next-line complexity
271
- export async function commitGsapPositionFromDrag(
355
+ export async function commitWholePathOffset(
272
356
  selection: DomEditSelection,
273
357
  anim: GsapAnimation,
274
358
  studioOffset: { x: number; y: number },
@@ -277,24 +361,16 @@ export async function commitGsapPositionFromDrag(
277
361
  selector: string,
278
362
  callbacks: GsapDragCommitCallbacks,
279
363
  ): Promise<void> {
280
- const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
281
- const rotDeg = Number.parseFloat(rotStyle) || 0;
282
- const rad = (-rotDeg * Math.PI) / 180;
283
- const cos = Math.cos(rad);
284
- const sin = Math.sin(rad);
285
364
  const el = selection.element;
365
+ const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
366
+ el,
367
+ studioOffset,
368
+ gsapPos,
369
+ );
370
+ const deltaX = newX - baseGsapX;
371
+ const deltaY = newY - baseGsapY;
286
372
  const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
287
373
  const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
288
- const deltaX = studioOffset.x - origX;
289
- const deltaY = studioOffset.y - origY;
290
- const adjX = deltaX * cos - deltaY * sin;
291
- const adjY = deltaX * sin + deltaY * cos;
292
- const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
293
- const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
294
- const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x;
295
- const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y;
296
- const newX = Math.round(baseGsapX + adjX);
297
- const newY = Math.round(baseGsapY + adjY);
298
374
  const restoreOffset = () => {
299
375
  el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
300
376
  el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
@@ -302,141 +378,53 @@ export async function commitGsapPositionFromDrag(
302
378
  el.removeAttribute("data-hf-drag-initial-offset-y");
303
379
  };
304
380
 
305
- const ct = usePlayerStore.getState().currentTime;
381
+ let effectiveAnim = anim;
306
382
  if (anim.keyframes) {
307
383
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
308
- const effectiveAnim = newId ? { ...anim, id: newId } : anim;
309
- const dragProps: Record<string, number> = { x: newX, y: newY };
310
-
311
- const ts = resolveTweenStart(effectiveAnim);
312
- const td = resolveTweenDuration(effectiveAnim);
313
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
314
- if (outsideRange) {
315
- await extendTweenAndAddKeyframe(
316
- selection,
317
- effectiveAnim,
318
- dragProps,
319
- ct,
320
- ts,
321
- td,
322
- callbacks,
323
- restoreOffset,
324
- );
325
- } else {
326
- await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
327
- }
328
- } else if (anim.method === "from" || anim.method === "fromTo") {
329
- const ct = usePlayerStore.getState().currentTime;
330
- const ts = resolveTweenStart(anim);
331
- const td = resolveTweenDuration(anim);
332
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
333
- const dragProps: Record<string, number> = { x: newX, y: newY };
334
-
335
- if (outsideRange && ts !== null) {
336
- // Split the original from() tween into property groups first.
337
- await callbacks.commitMutation(
338
- selection,
339
- { type: "split-into-property-groups", animationId: anim.id },
340
- { label: "Split from() for drag", skipReload: true },
341
- );
342
-
343
- const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
344
- const existingPosAnim = allAnims.find(
345
- (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
346
- );
384
+ if (newId) effectiveAnim = { ...anim, id: newId };
385
+ }
347
386
 
348
- if (existingPosAnim?.keyframes) {
349
- // Extend the existing position tween
350
- const posTs = resolveTweenStart(existingPosAnim);
351
- const posTd = resolveTweenDuration(existingPosAnim);
352
- if (posTs !== null) {
353
- await extendTweenAndAddKeyframe(
354
- selection,
355
- existingPosAnim,
356
- { x: newX, y: newY },
357
- ct,
358
- posTs,
359
- posTd,
360
- callbacks,
361
- restoreOffset,
362
- );
363
- return;
364
- }
365
- }
387
+ const ts = resolveTweenStart(effectiveAnim);
388
+ const td = resolveTweenDuration(effectiveAnim);
389
+ const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
366
390
 
367
- // No existing position tween — create one
368
- const newStart = Math.min(ct, ts);
369
- const newEnd = Math.max(ct, ts + td);
370
- const newDuration = Math.max(0.01, newEnd - newStart);
371
- const dragBefore = ct < ts;
372
- const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
373
- const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
391
+ let kfs = effectiveAnim.keyframes?.keyframes ?? [];
392
+ if (kfs.length === 0) {
393
+ const fromProps = effectiveAnim.fromProperties ?? {};
394
+ const toProps = effectiveAnim.properties ?? {};
395
+ const startX =
396
+ typeof fromProps.x === "number" ? fromProps.x : typeof toProps.x === "number" ? 0 : 0;
397
+ const startY =
398
+ typeof fromProps.y === "number" ? fromProps.y : typeof toProps.y === "number" ? 0 : 0;
399
+ const endX = typeof toProps.x === "number" ? toProps.x : startX;
400
+ const endY = typeof toProps.y === "number" ? toProps.y : startY;
401
+ kfs = [
402
+ { percentage: 0, properties: { x: startX, y: startY } },
403
+ { percentage: 100, properties: { x: endX, y: endY } },
404
+ ];
405
+ }
374
406
 
375
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
376
- [];
377
- if (dragBefore) {
378
- keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
379
- if (origStartPct > 0.5 && origStartPct < 99.5) {
380
- keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
381
- }
382
- keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
383
- } else {
384
- keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
385
- if (origEndPct > 0.5 && origEndPct < 99.5) {
386
- keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
387
- }
388
- keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
389
- }
390
- keyframes.sort((a, b) => a.percentage - b.percentage);
407
+ const shifted = kfs.map((kf) => ({
408
+ percentage: kf.percentage,
409
+ properties: {
410
+ ...kf.properties,
411
+ x: roundTo3((typeof kf.properties.x === "number" ? kf.properties.x : 0) + deltaX),
412
+ y: roundTo3((typeof kf.properties.y === "number" ? kf.properties.y : 0) + deltaY),
413
+ },
414
+ ...(kf.ease ? { ease: kf.ease } : {}),
415
+ }));
391
416
 
392
- await callbacks.commitMutation(
393
- selection,
394
- {
395
- type: "add-with-keyframes",
396
- targetSelector: anim.targetSelector,
397
- position: roundTo3(newStart),
398
- duration: roundTo3(newDuration),
399
- keyframes,
400
- },
401
- { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
402
- );
403
- } else {
404
- // Inside tween range: convert then add keyframe at current time
405
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
406
- await callbacks.commitMutation(
407
- selection,
408
- {
409
- type: "convert-to-keyframes",
410
- animationId: anim.id,
411
- },
412
- { label: "Convert from() for drag", skipReload: true, coalesceKey },
413
- );
414
- const pct = computeCurrentPercentage(selection, anim);
415
- await callbacks.commitMutation(
416
- selection,
417
- {
418
- type: "add-keyframe",
419
- animationId: anim.id,
420
- percentage: pct,
421
- properties: dragProps,
422
- },
423
- {
424
- label: `Move layer (keyframe ${pct}%)`,
425
- softReload: true,
426
- beforeReload: restoreOffset,
427
- coalesceKey,
428
- },
429
- );
430
- }
431
- } else {
432
- await commitFlatViaKeyframes(
433
- selection,
434
- anim,
435
- { x: newX, y: newY },
436
- callbacks,
437
- restoreOffset,
438
- iframe,
439
- selector,
440
- );
441
- }
417
+ await callbacks.commitMutation(
418
+ selection,
419
+ {
420
+ type: "replace-with-keyframes",
421
+ animationId: effectiveAnim.id,
422
+ targetSelector: effectiveAnim.targetSelector,
423
+ position: roundTo3(ts ?? 0),
424
+ duration: roundTo3(td || 1),
425
+ keyframes: shifted,
426
+ ease,
427
+ },
428
+ { label: "Move animation path", softReload: true, beforeReload: restoreOffset },
429
+ );
442
430
  }