@hyperframes/studio 0.6.91 → 0.6.93

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 (48) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-BkwsVKGA.js} +24 -24
  2. package/dist/assets/index-DYRWmfMX.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/components/StudioPreviewArea.tsx +48 -13
  7. package/src/components/TimelineToolbar.tsx +0 -21
  8. package/src/components/editor/DomEditOverlay.tsx +79 -0
  9. package/src/components/editor/PropertyPanel.tsx +19 -13
  10. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  11. package/src/components/editor/manualEditingAvailability.test.ts +5 -5
  12. package/src/components/editor/manualEditingAvailability.ts +11 -7
  13. package/src/components/editor/manualEditsDom.ts +25 -5
  14. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  15. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  16. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  17. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  18. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  19. package/src/components/nle/NLELayout.tsx +16 -14
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/hooks/gsapDragCommit.ts +119 -43
  22. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  23. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  24. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  25. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  26. package/src/hooks/useDomEditCommits.ts +7 -1
  27. package/src/hooks/useDomEditSession.ts +13 -0
  28. package/src/hooks/useEnableKeyframes.ts +3 -1
  29. package/src/hooks/useGestureCommit.ts +99 -13
  30. package/src/hooks/useGestureRecording.ts +18 -2
  31. package/src/hooks/useGsapScriptCommits.ts +24 -3
  32. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  33. package/src/hooks/useGsapTweenCache.ts +30 -10
  34. package/src/hooks/useRazorSplit.ts +2 -7
  35. package/src/player/components/ClipContextMenu.tsx +9 -4
  36. package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
  37. package/src/player/components/Timeline.tsx +7 -3
  38. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  39. package/src/player/store/playerStore.ts +12 -0
  40. package/src/utils/globalTimeCompiler.test.ts +2 -2
  41. package/src/utils/globalTimeCompiler.ts +2 -1
  42. package/src/utils/gsapSoftReload.test.ts +16 -0
  43. package/src/utils/gsapSoftReload.ts +43 -8
  44. package/src/utils/rdpSimplify.ts +3 -2
  45. package/src/utils/timelineElementSplit.test.ts +50 -0
  46. package/src/utils/timelineElementSplit.ts +16 -0
  47. package/dist/assets/index-CgYcO2PV.js +0 -146
  48. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -277,14 +277,34 @@ export function applyStudioPathOffsetDraft(
277
277
 
278
278
  const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
279
279
  if (isGsapAnimated) {
280
- // For GSAP-animated elements: use gsap.set for positioning (the timeline
281
- // is paused during drag). Set translate:none explicitly to prevent
282
- // double-counting with the transform.
283
280
  element.style.setProperty("translate", "none");
284
281
  const win = element.ownerDocument.defaultView as
285
- | (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
282
+ | (Window & {
283
+ gsap?: {
284
+ set: (el: Element, vars: Record<string, unknown>) => void;
285
+ getProperty: (el: Element, prop: string) => number;
286
+ };
287
+ })
286
288
  | null;
287
- win?.gsap?.set(element, { x: offset.x, y: offset.y });
289
+ if (win?.gsap) {
290
+ const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
291
+ const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
292
+ const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
293
+ const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
294
+ const gsapBaseX = Number.isFinite(baseX)
295
+ ? baseX
296
+ : (win.gsap.getProperty(element, "x") as number);
297
+ const gsapBaseY = Number.isFinite(baseY)
298
+ ? baseY
299
+ : (win.gsap.getProperty(element, "y") as number);
300
+ if (!Number.isFinite(baseX))
301
+ element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
302
+ if (!Number.isFinite(baseY))
303
+ element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
304
+ const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
305
+ const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
306
+ win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
307
+ }
288
308
  } else {
289
309
  // Non-GSAP elements: use CSS translate as before.
290
310
  element.style.setProperty(
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
 
3
4
  import { describe, it, expect } from "vitest";
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
72
72
 
73
73
  export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
74
74
  const ops: PatchOperation[] = [];
75
- collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops);
75
+ collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
76
+ // When GSAP owns the element's transform, the live inline translate is kept
77
+ // at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
78
+ // Persist the var() expression in that case, so a reload re-folds the offset.
79
+ const inlineTranslate = element.style.getPropertyValue("translate");
80
+ const hasOffsetVars =
81
+ element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
82
+ element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
83
+ const translateValue =
84
+ inlineTranslate && inlineTranslate !== "none"
85
+ ? inlineTranslate
86
+ : hasOffsetVars
87
+ ? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
88
+ : null;
89
+ if (translateValue) {
90
+ ops.push({ type: "inline-style", property: "translate", value: translateValue });
91
+ }
76
92
  ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
77
93
  collectAttributeOps(
78
94
  element,
@@ -183,6 +183,22 @@ export function restoreStudioPathOffset(
183
183
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
184
184
  previous.originalInlineTranslate,
185
185
  );
186
+
187
+ // Restore GSAP x/y if a draft was applied via gsap.set during drag
188
+ const baseX = element.getAttribute("data-hf-drag-gsap-base-x");
189
+ const baseY = element.getAttribute("data-hf-drag-gsap-base-y");
190
+ if (baseX != null || baseY != null) {
191
+ const win = element.ownerDocument.defaultView as
192
+ | (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
193
+ | null;
194
+ if (win?.gsap) {
195
+ const x = Number.parseFloat(baseX ?? "0") || 0;
196
+ const y = Number.parseFloat(baseY ?? "0") || 0;
197
+ win.gsap.set(element, { x, y });
198
+ }
199
+ element.removeAttribute("data-hf-drag-gsap-base-x");
200
+ element.removeAttribute("data-hf-drag-gsap-base-y");
201
+ }
186
202
  }
187
203
 
188
204
  /* ── Clear functions ──────────────────────────────────────────────── */
@@ -13,6 +13,7 @@ type KeyframeEntry = Array<{
13
13
  interface PropertyPanel3dTransformProps {
14
14
  gsapRuntimeValues: Record<string, number>;
15
15
  gsapAnimId: string | null;
16
+ resolveAnimIdForProp?: (prop: string) => string | null;
16
17
  gsapKeyframes: KeyframeEntry;
17
18
  currentPct: number;
18
19
  elStart: number;
@@ -31,6 +32,7 @@ interface PropertyPanel3dTransformProps {
31
32
  export function PropertyPanel3dTransform({
32
33
  gsapRuntimeValues,
33
34
  gsapAnimId,
35
+ resolveAnimIdForProp,
34
36
  gsapKeyframes,
35
37
  currentPct,
36
38
  elStart,
@@ -41,6 +43,7 @@ export function PropertyPanel3dTransform({
41
43
  onRemoveKeyframe,
42
44
  onConvertToKeyframes,
43
45
  }: PropertyPanel3dTransformProps) {
46
+ const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
44
47
  return (
45
48
  <div className="mt-3 border-t border-neutral-800/40 pt-3">
46
49
  <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -72,8 +75,14 @@ export function PropertyPanel3dTransform({
72
75
  void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
73
76
  }
74
77
  }}
75
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
76
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
78
+ onRemoveKeyframe={(pct) => {
79
+ const id = idFor("z");
80
+ if (id) onRemoveKeyframe?.(id, pct);
81
+ }}
82
+ onConvertToKeyframes={() => {
83
+ const id = idFor("z");
84
+ if (id) onConvertToKeyframes?.(id);
85
+ }}
77
86
  />
78
87
  )}
79
88
  </div>
@@ -102,8 +111,14 @@ export function PropertyPanel3dTransform({
102
111
  void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
103
112
  }
104
113
  }}
105
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
106
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
114
+ onRemoveKeyframe={(pct) => {
115
+ const id = idFor("scale");
116
+ if (id) onRemoveKeyframe?.(id, pct);
117
+ }}
118
+ onConvertToKeyframes={() => {
119
+ const id = idFor("scale");
120
+ if (id) onConvertToKeyframes?.(id);
121
+ }}
107
122
  />
108
123
  )}
109
124
  </div>
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Detects GSAP-animated elements whose center is outside the visible composition
3
+ * area and returns edge-clamped indicator positions for each.
4
+ */
5
+ import { useRef, useState, type RefObject } from "react";
6
+ import { useMountEffect } from "../../hooks/useMountEffect";
7
+
8
+ export interface OffScreenIndicator {
9
+ key: string;
10
+ elementId: string;
11
+ left: number;
12
+ top: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ interface CompRect {
18
+ left: number;
19
+ top: number;
20
+ width: number;
21
+ height: number;
22
+ scaleX: number;
23
+ scaleY: number;
24
+ }
25
+
26
+ type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
27
+
28
+ function isHtmlElement(node: unknown): node is HTMLElement {
29
+ return (
30
+ typeof node === "object" &&
31
+ node !== null &&
32
+ typeof (node as HTMLElement).getBoundingClientRect === "function" &&
33
+ typeof (node as HTMLElement).tagName === "string"
34
+ );
35
+ }
36
+
37
+ function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
38
+ const win = iframe.contentWindow as
39
+ | (Window & { __timelines?: Record<string, TimelineLike> })
40
+ | null;
41
+ if (!win) return [];
42
+
43
+ let timelines: Record<string, TimelineLike> | undefined;
44
+ try {
45
+ timelines = win.__timelines;
46
+ } catch {
47
+ return [];
48
+ }
49
+ if (!timelines) return [];
50
+
51
+ const seen = new Set<HTMLElement>();
52
+ for (const tl of Object.values(timelines)) {
53
+ if (!tl?.getChildren) continue;
54
+ try {
55
+ for (const child of tl.getChildren(true)) {
56
+ if (!child.targets) continue;
57
+ for (const t of child.targets()) {
58
+ if (isHtmlElement(t)) seen.add(t);
59
+ }
60
+ }
61
+ } catch {
62
+ // cross-origin or detached timeline — skip
63
+ }
64
+ }
65
+ return Array.from(seen);
66
+ }
67
+
68
+ function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
69
+ if (a.length !== b.length) return false;
70
+ for (let i = 0; i < a.length; i++) {
71
+ const ai = a[i]!;
72
+ const bi = b[i]!;
73
+ if (
74
+ ai.key !== bi.key ||
75
+ Math.abs(ai.left - bi.left) > 0.5 ||
76
+ Math.abs(ai.top - bi.top) > 0.5 ||
77
+ Math.abs(ai.width - bi.width) > 0.5 ||
78
+ Math.abs(ai.height - bi.height) > 0.5
79
+ )
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ export function useOffScreenIndicators({
86
+ iframeRef,
87
+ overlayRef,
88
+ compRect,
89
+ }: {
90
+ iframeRef: RefObject<HTMLIFrameElement | null>;
91
+ overlayRef: RefObject<HTMLDivElement | null>;
92
+ compRect: CompRect;
93
+ }): OffScreenIndicator[] {
94
+ const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
95
+ const prevRef = useRef<OffScreenIndicator[]>([]);
96
+ const compRectRef = useRef(compRect);
97
+ compRectRef.current = compRect;
98
+
99
+ useMountEffect(() => {
100
+ let frame = 0;
101
+
102
+ const update = () => {
103
+ frame = requestAnimationFrame(update);
104
+
105
+ const iframe = iframeRef.current;
106
+ const overlayEl = overlayRef.current;
107
+ const cr = compRectRef.current;
108
+ if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
109
+ if (prevRef.current.length > 0) {
110
+ prevRef.current = [];
111
+ setIndicators([]);
112
+ }
113
+ return;
114
+ }
115
+
116
+ const iframeRect = iframe.getBoundingClientRect();
117
+ const overlayRect = overlayEl.getBoundingClientRect();
118
+
119
+ const doc = iframe.contentDocument;
120
+ const root =
121
+ doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
122
+ if (!root) return;
123
+
124
+ const declaredWidth =
125
+ Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
126
+ const declaredHeight =
127
+ Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
128
+ const rootScaleX = iframeRect.width / declaredWidth;
129
+ const rootScaleY = iframeRect.height / declaredHeight;
130
+
131
+ const targets = collectGsapTargetElements(iframe);
132
+ if (targets.length === 0) {
133
+ if (prevRef.current.length > 0) {
134
+ prevRef.current = [];
135
+ setIndicators([]);
136
+ }
137
+ return;
138
+ }
139
+
140
+ // Composition bounds in overlay coordinates
141
+ const compLeft = cr.left;
142
+ const compTop = cr.top;
143
+ const compRight = compLeft + cr.width;
144
+ const compBottom = compTop + cr.height;
145
+
146
+ const next: OffScreenIndicator[] = [];
147
+ const keyCounts = new Map<string, number>();
148
+
149
+ for (const el of targets) {
150
+ if (!el.isConnected) continue;
151
+
152
+ const elRect = el.getBoundingClientRect();
153
+ if (elRect.width <= 0 && elRect.height <= 0) continue;
154
+
155
+ // Element rect in overlay coordinates
156
+ const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
157
+ const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
158
+ const elW = elRect.width * rootScaleX;
159
+ const elH = elRect.height * rootScaleY;
160
+
161
+ // Check if the element is fully inside the composition
162
+ if (
163
+ elLeft >= compLeft &&
164
+ elTop >= compTop &&
165
+ elLeft + elW <= compRight &&
166
+ elTop + elH <= compBottom
167
+ ) {
168
+ continue;
169
+ }
170
+
171
+ // Only elements with a real id attribute can be selected via getElementById
172
+ if (!el.id) continue;
173
+ const count = keyCounts.get(el.id) ?? 0;
174
+ keyCounts.set(el.id, count + 1);
175
+ const key = count > 0 ? `${el.id}:${count}` : el.id;
176
+ next.push({
177
+ key,
178
+ elementId: el.id,
179
+ left: elLeft,
180
+ top: elTop,
181
+ width: elW,
182
+ height: elH,
183
+ });
184
+ }
185
+
186
+ if (!indicatorsEqual(prevRef.current, next)) {
187
+ prevRef.current = next;
188
+ setIndicators(next);
189
+ }
190
+ };
191
+
192
+ frame = requestAnimationFrame(update);
193
+ return () => cancelAnimationFrame(frame);
194
+ });
195
+
196
+ return indicators;
197
+ }
@@ -366,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
366
366
  {/* Preview + player controls */}
367
367
  <div className="flex-1 min-h-0 flex flex-col">
368
368
  <div
369
- className="flex-1 min-h-0 relative overflow-hidden"
369
+ className="flex-1 min-h-0 relative"
370
370
  data-preview-pan-surface="true"
371
371
  onDragOver={handlePreviewDragOver}
372
372
  onDragLeave={handlePreviewDragLeave}
373
373
  onDrop={handlePreviewDrop}
374
374
  >
375
- <NLEPreview
376
- projectId={projectId}
377
- iframeRef={iframeRef}
378
- onIframeLoad={onIframeLoad}
379
- onCompositionLoadingChange={setCompositionLoading}
380
- portrait={portrait}
381
- directUrl={directUrl}
382
- suppressLoadingOverlay={hasLoadedOnceRef.current}
383
- onStageRef={handleStageRef}
384
- />
385
- {previewDragOver && (
386
- <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
387
- )}
375
+ <div className="absolute inset-0 overflow-hidden">
376
+ <NLEPreview
377
+ projectId={projectId}
378
+ iframeRef={iframeRef}
379
+ onIframeLoad={onIframeLoad}
380
+ onCompositionLoadingChange={setCompositionLoading}
381
+ portrait={portrait}
382
+ directUrl={directUrl}
383
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
384
+ onStageRef={handleStageRef}
385
+ />
386
+ {previewDragOver && (
387
+ <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
388
+ )}
389
+ </div>
388
390
  {!isFullscreen && previewOverlay}
389
391
  </div>
390
392
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { createContext, useContext, useMemo, type ReactNode } from "react";
2
3
  import type { useDomEditSession } from "../hooks/useDomEditSession";
3
4
 
@@ -60,6 +61,7 @@ export function DomEditProvider({
60
61
  handleGsapUpdateProperty,
61
62
  handleGsapUpdateMeta,
62
63
  handleGsapDeleteAnimation,
64
+ handleGsapDeleteAllForElement,
63
65
  handleGsapAddAnimation,
64
66
  handleGsapAddProperty,
65
67
  handleGsapRemoveProperty,
@@ -133,6 +135,7 @@ export function DomEditProvider({
133
135
  handleGsapUpdateProperty,
134
136
  handleGsapUpdateMeta,
135
137
  handleGsapDeleteAnimation,
138
+ handleGsapDeleteAllForElement,
136
139
  handleGsapAddAnimation,
137
140
  handleGsapAddProperty,
138
141
  handleGsapRemoveProperty,
@@ -200,6 +203,7 @@ export function DomEditProvider({
200
203
  handleGsapUpdateProperty,
201
204
  handleGsapUpdateMeta,
202
205
  handleGsapDeleteAnimation,
206
+ handleGsapDeleteAllForElement,
203
207
  handleGsapAddAnimation,
204
208
  handleGsapAddProperty,
205
209
  handleGsapRemoveProperty,
@@ -11,8 +11,6 @@ import {
11
11
  resolveTweenStart,
12
12
  resolveTweenDuration,
13
13
  } from "../utils/globalTimeCompiler";
14
- import { readAllAnimatedProperties } from "./gsapRuntimeReaders";
15
-
16
14
  export interface GsapDragCommitCallbacks {
17
15
  commitMutation: (
18
16
  selection: DomEditSelection,
@@ -25,6 +23,7 @@ export interface GsapDragCommitCallbacks {
25
23
  beforeReload?: () => void;
26
24
  },
27
25
  ) => Promise<void>;
26
+ fetchAnimations?: () => Promise<GsapAnimation[]>;
28
27
  }
29
28
 
30
29
  // ── Percentage computation ─────────────────────────────────────────────────
@@ -114,7 +113,6 @@ async function extendTweenAndAddKeyframe(
114
113
  const newStart = Math.min(targetTime, tweenStart);
115
114
  const newEnd = Math.max(targetTime, tweenEnd);
116
115
  const newDuration = Math.max(0.01, newEnd - newStart);
117
-
118
116
  const existingKfs = anim.keyframes?.keyframes ?? [];
119
117
  const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
120
118
  [];
@@ -126,20 +124,15 @@ async function extendTweenAndAddKeyframe(
126
124
 
127
125
  const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
128
126
  remappedKfs.push({ percentage: targetPct, properties });
129
- remappedKfs.sort((a, b) => a.percentage - b.percentage);
130
127
 
131
- await callbacks.commitMutation(
132
- selection,
133
- { type: "delete", animationId: anim.id },
134
- { label: "Extend tween range", skipReload: true },
135
- );
128
+ remappedKfs.sort((a, b) => a.percentage - b.percentage);
136
129
 
137
- const selector = anim.targetSelector;
138
130
  await callbacks.commitMutation(
139
131
  selection,
140
132
  {
141
- type: "add-with-keyframes",
142
- targetSelector: selector,
133
+ type: "replace-with-keyframes",
134
+ animationId: anim.id,
135
+ targetSelector: anim.targetSelector,
143
136
  position: Math.round(newStart * 1000) / 1000,
144
137
  duration: Math.round(newDuration * 1000) / 1000,
145
138
  keyframes: remappedKfs,
@@ -156,8 +149,8 @@ async function commitKeyframedPosition(
156
149
  callbacks: GsapDragCommitCallbacks,
157
150
  beforeReload?: () => void,
158
151
  ): Promise<void> {
159
- const pct = computeCurrentPercentage(selection, anim);
160
-
152
+ const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
153
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
161
154
  await callbacks.commitMutation(
162
155
  selection,
163
156
  {
@@ -168,6 +161,7 @@ async function commitKeyframedPosition(
168
161
  },
169
162
  { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
170
163
  );
164
+ if (activeKeyframePct != null) setActiveKeyframePct(null);
171
165
  }
172
166
 
173
167
  /**
@@ -182,10 +176,11 @@ async function commitFlatViaKeyframes(
182
176
  callbacks: GsapDragCommitCallbacks,
183
177
  beforeReload?: () => void,
184
178
  ): Promise<void> {
179
+ const coalesceKey = `gsap:convert-drag:${anim.id}`;
185
180
  await callbacks.commitMutation(
186
181
  selection,
187
182
  { type: "convert-to-keyframes", animationId: anim.id },
188
- { label: "Convert to keyframes for drag", skipReload: true },
183
+ { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
189
184
  );
190
185
 
191
186
  const pct = computeCurrentPercentage(selection, anim);
@@ -198,7 +193,7 @@ async function commitFlatViaKeyframes(
198
193
  percentage: pct,
199
194
  properties,
200
195
  },
201
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
196
+ { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
202
197
  );
203
198
  }
204
199
 
@@ -243,19 +238,20 @@ export async function commitGsapPositionFromDrag(
243
238
  el.removeAttribute("data-hf-drag-initial-offset-y");
244
239
  };
245
240
 
241
+ const ct = usePlayerStore.getState().currentTime;
246
242
  if (anim.keyframes) {
247
243
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
248
244
  const effectiveAnim = newId ? { ...anim, id: newId } : anim;
249
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
245
+ const dragProps: Record<string, number> = { x: newX, y: newY };
250
246
 
251
- const ct = usePlayerStore.getState().currentTime;
252
247
  const ts = resolveTweenStart(effectiveAnim);
253
248
  const td = resolveTweenDuration(effectiveAnim);
254
- if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
249
+ const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
250
+ if (outsideRange) {
255
251
  await extendTweenAndAddKeyframe(
256
252
  selection,
257
253
  effectiveAnim,
258
- { ...runtimeProps, x: newX, y: newY },
254
+ dragProps,
259
255
  ct,
260
256
  ts,
261
257
  td,
@@ -263,32 +259,112 @@ export async function commitGsapPositionFromDrag(
263
259
  restoreOffset,
264
260
  );
265
261
  } else {
266
- await commitKeyframedPosition(
262
+ await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
263
+ }
264
+ } else if (anim.method === "from" || anim.method === "fromTo") {
265
+ const ct = usePlayerStore.getState().currentTime;
266
+ const ts = resolveTweenStart(anim);
267
+ const td = resolveTweenDuration(anim);
268
+ const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
269
+ const dragProps: Record<string, number> = { x: newX, y: newY };
270
+
271
+ if (outsideRange && ts !== null) {
272
+ // Split the original from() tween into property groups first.
273
+ await callbacks.commitMutation(
267
274
  selection,
268
- effectiveAnim,
269
- { ...runtimeProps, x: newX, y: newY },
270
- callbacks,
271
- restoreOffset,
275
+ { type: "split-into-property-groups", animationId: anim.id },
276
+ { label: "Split from() for drag", skipReload: true },
277
+ );
278
+
279
+ const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
280
+ const existingPosAnim = allAnims.find(
281
+ (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
282
+ );
283
+
284
+ if (existingPosAnim?.keyframes) {
285
+ // Extend the existing position tween
286
+ const posTs = resolveTweenStart(existingPosAnim);
287
+ const posTd = resolveTweenDuration(existingPosAnim);
288
+ if (posTs !== null) {
289
+ await extendTweenAndAddKeyframe(
290
+ selection,
291
+ existingPosAnim,
292
+ { x: newX, y: newY },
293
+ ct,
294
+ posTs,
295
+ posTd,
296
+ callbacks,
297
+ restoreOffset,
298
+ );
299
+ return;
300
+ }
301
+ }
302
+
303
+ // No existing position tween — create one
304
+ const newStart = Math.min(ct, ts);
305
+ const newEnd = Math.max(ct, ts + td);
306
+ const newDuration = Math.max(0.01, newEnd - newStart);
307
+ const dragBefore = ct < ts;
308
+ const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
309
+ const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
310
+
311
+ const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
312
+ [];
313
+ if (dragBefore) {
314
+ keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
315
+ if (origStartPct > 0.5 && origStartPct < 99.5) {
316
+ keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
317
+ }
318
+ keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
319
+ } else {
320
+ keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
321
+ if (origEndPct > 0.5 && origEndPct < 99.5) {
322
+ keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
323
+ }
324
+ keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
325
+ }
326
+ keyframes.sort((a, b) => a.percentage - b.percentage);
327
+
328
+ await callbacks.commitMutation(
329
+ selection,
330
+ {
331
+ type: "add-with-keyframes",
332
+ targetSelector: anim.targetSelector,
333
+ position: Math.round(newStart * 1000) / 1000,
334
+ duration: Math.round(newDuration * 1000) / 1000,
335
+ keyframes,
336
+ },
337
+ { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
338
+ );
339
+ } else {
340
+ // Inside tween range: convert then add keyframe at current time
341
+ const coalesceKey = `gsap:convert-drag:${anim.id}`;
342
+ await callbacks.commitMutation(
343
+ selection,
344
+ {
345
+ type: "convert-to-keyframes",
346
+ animationId: anim.id,
347
+ },
348
+ { label: "Convert from() for drag", skipReload: true, coalesceKey },
349
+ );
350
+ const pct = computeCurrentPercentage(selection, anim);
351
+ await callbacks.commitMutation(
352
+ selection,
353
+ {
354
+ type: "add-keyframe",
355
+ animationId: anim.id,
356
+ percentage: pct,
357
+ properties: dragProps,
358
+ },
359
+ {
360
+ label: `Move layer (keyframe ${pct}%)`,
361
+ softReload: true,
362
+ beforeReload: restoreOffset,
363
+ coalesceKey,
364
+ },
272
365
  );
273
366
  }
274
- } else if (anim.method === "from" || anim.method === "fromTo") {
275
- await callbacks.commitMutation(
276
- selection,
277
- {
278
- type: "convert-to-keyframes",
279
- animationId: anim.id,
280
- resolvedFromValues: { x: newX, y: newY },
281
- },
282
- { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
283
- );
284
367
  } else {
285
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
286
- await commitFlatViaKeyframes(
287
- selection,
288
- anim,
289
- { ...runtimeProps, x: newX, y: newY },
290
- callbacks,
291
- restoreOffset,
292
- );
368
+ await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
293
369
  }
294
370
  }