@hyperframes/studio 0.7.18 → 0.7.20

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 (39) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
  3. package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
  4. package/dist/assets/index-WxXW8fW3.js +396 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1246 -807
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/components/StudioPreviewArea.tsx +92 -17
  11. package/src/components/TimelineToolbar.tsx +8 -0
  12. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  13. package/src/components/editor/keyframeDrag.test.ts +195 -0
  14. package/src/components/editor/keyframeDrag.ts +105 -0
  15. package/src/components/editor/keyframeRetime.test.ts +140 -0
  16. package/src/components/editor/keyframeRetime.ts +121 -0
  17. package/src/contexts/DomEditContext.tsx +12 -0
  18. package/src/contexts/TimelineEditContext.tsx +2 -0
  19. package/src/hooks/gsapDragCommit.ts +28 -1
  20. package/src/hooks/gsapPositionDetection.ts +98 -0
  21. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  22. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  23. package/src/hooks/useDomEditSession.ts +10 -0
  24. package/src/hooks/useDomEditWiring.ts +17 -0
  25. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  27. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  28. package/src/hooks/useGsapTweenCache.ts +16 -7
  29. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  30. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  31. package/src/player/components/PlayerControls.tsx +40 -13
  32. package/src/player/components/Timeline.tsx +8 -0
  33. package/src/player/components/TimelineCanvas.tsx +7 -0
  34. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  35. package/src/player/components/timelineCallbacks.ts +6 -0
  36. package/src/utils/gsapSoftReload.test.ts +29 -0
  37. package/src/utils/gsapSoftReload.ts +19 -0
  38. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  39. package/dist/assets/index-D0468l1X.js +0 -375
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Pure move-vs-resize decision + absolute-time remap for keyframe drag-to-retime.
3
+ *
4
+ * Keyframes live inside the ANIMATION's window (tween position + duration), which
5
+ * is usually shorter than the clip. Dragging a keyframe is one of:
6
+ * - MOVE: the drop stays within `[tweenStart, tweenEnd]` → re-key the tween-%.
7
+ * - RESIZE: the drop crosses the tween boundary (the LAST keyframe past the end,
8
+ * or the FIRST before the start, but still inside the clip — the gesture layer
9
+ * already clamped to neighbours + clip). The tween's window grows so the
10
+ * dragged keyframe lands exactly where dropped; every OTHER keyframe keeps its
11
+ * ABSOLUTE time (its tween-% remaps onto the new, longer window). Value + ease
12
+ * are preserved per keyframe.
13
+ *
14
+ * Kept pure (no React/store/GSAP) so the trickiest math is unit-testable. The
15
+ * caller supplies the resolved tween window + the drop's absolute time.
16
+ */
17
+
18
+ export interface RetimeKeyframe {
19
+ /** Tween-relative percentage (the writer/runtime key on this). */
20
+ percentage: number;
21
+ properties: Record<string, number | string>;
22
+ ease?: string;
23
+ }
24
+
25
+ /** One existing keyframe's old→new tween-% under a resize remap. */
26
+ export interface KeyframePctRemap {
27
+ /** The existing keyframe's current tween-relative %. */
28
+ from: number;
29
+ /** Its new tween-relative % on the resized window. */
30
+ to: number;
31
+ }
32
+
33
+ export interface KeyframeRetimeResult {
34
+ kind: "noop" | "move" | "resize";
35
+ /** MOVE: tween-relative drop position. */
36
+ toTweenPct?: number;
37
+ /** RESIZE: new tween position (absolute seconds). */
38
+ position?: number;
39
+ /** RESIZE: new tween duration (seconds). */
40
+ duration?: number;
41
+ /**
42
+ * RESIZE: each existing keyframe's old→new tween-%. The commit re-keys each
43
+ * keyframe IN PLACE (round-tripping its value node), so `_auto`, per-keyframe
44
+ * `ease`, `easeEach`, and the outer tween `ease` all survive — unlike rebuilding
45
+ * a fresh keyframes array.
46
+ */
47
+ pctRemap?: KeyframePctRemap[];
48
+ }
49
+
50
+ /** Below this (tween-%) a move resolves onto the source keyframe → skip the write. */
51
+ const NOOP_EPSILON_PCT = 0.1;
52
+ /** Slack (seconds) for the within-tween boundary test. */
53
+ const EPSILON_TIME = 1e-4;
54
+
55
+ const round3 = (n: number) => Math.round(n * 1000) / 1000;
56
+ const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
57
+ const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
58
+
59
+ /**
60
+ * Decide move vs resize for a dragged keyframe and, for resize, return the new
61
+ * tween window + remapped keyframes.
62
+ *
63
+ * - `keyframes`: the tween's keyframes (tween-relative %, with value + ease).
64
+ * - `draggedTweenPct`: identifies which keyframe is being dragged (closest match).
65
+ * - `tweenStart` / `tweenDuration`: the tween's resolved absolute window.
66
+ * - `dropAbsTime`: the drop's absolute time (handler converts clip-% → seconds).
67
+ */
68
+ export function resolveKeyframeRetime(opts: {
69
+ keyframes: ReadonlyArray<RetimeKeyframe>;
70
+ draggedTweenPct: number;
71
+ tweenStart: number;
72
+ tweenDuration: number;
73
+ dropAbsTime: number;
74
+ }): KeyframeRetimeResult {
75
+ const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts;
76
+ if (tweenDuration <= 0) return { kind: "noop" };
77
+ const tweenEnd = tweenStart + tweenDuration;
78
+
79
+ // Within the tween window → plain move (re-key the tween-%). This branch never
80
+ // touches the keyframes array, so it still works for synthesized flat tweens.
81
+ if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
82
+ const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
83
+ if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
84
+ return { kind: "move", toTweenPct };
85
+ }
86
+
87
+ // Boundary resize needs the real keyframes to remap; a flat tween has none here.
88
+ if (keyframes.length === 0) return { kind: "noop" };
89
+
90
+ const newStart = Math.min(dropAbsTime, tweenStart);
91
+ const newEnd = Math.max(dropAbsTime, tweenEnd);
92
+ const newDuration = Math.max(0.01, newEnd - newStart);
93
+
94
+ // The dragged keyframe is the one whose tween-% is closest to draggedTweenPct.
95
+ let draggedIdx = 0;
96
+ let best = Infinity;
97
+ keyframes.forEach((kf, i) => {
98
+ const d = Math.abs(kf.percentage - draggedTweenPct);
99
+ if (d < best) {
100
+ best = d;
101
+ draggedIdx = i;
102
+ }
103
+ });
104
+
105
+ // Map each existing keyframe to its new tween-% on the grown window, preserving
106
+ // its absolute time (the dragged one lands at the drop). Carry only the old→new
107
+ // percentages; the commit re-keys in place so value + ease + _auto + easeEach
108
+ // survive verbatim (no rebuilt keyframes array).
109
+ const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
110
+ const absTime =
111
+ i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
112
+ return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
113
+ });
114
+
115
+ return {
116
+ kind: "resize",
117
+ position: round3(newStart),
118
+ duration: round3(newDuration),
119
+ pctRemap,
120
+ };
121
+ }
@@ -54,6 +54,9 @@ export interface DomEditActionsValue extends Pick<
54
54
  | "handleGsapAddKeyframe"
55
55
  | "handleGsapAddKeyframeBatch"
56
56
  | "handleGsapRemoveKeyframe"
57
+ | "handleGsapMoveKeyframeToPlayhead"
58
+ | "handleGsapMoveKeyframe"
59
+ | "handleGsapResizeKeyframedTween"
57
60
  | "handleGsapConvertToKeyframes"
58
61
  | "handleGsapRemoveAllKeyframes"
59
62
  | "handleResetSelectedElementKeyframes"
@@ -169,6 +172,9 @@ export function DomEditProvider({
169
172
  handleGsapAddKeyframe,
170
173
  handleGsapAddKeyframeBatch,
171
174
  handleGsapRemoveKeyframe,
175
+ handleGsapMoveKeyframeToPlayhead,
176
+ handleGsapMoveKeyframe,
177
+ handleGsapResizeKeyframedTween,
172
178
  handleGsapConvertToKeyframes,
173
179
  handleGsapRemoveAllKeyframes,
174
180
  handleResetSelectedElementKeyframes,
@@ -247,6 +253,9 @@ export function DomEditProvider({
247
253
  handleGsapAddKeyframe,
248
254
  handleGsapAddKeyframeBatch,
249
255
  handleGsapRemoveKeyframe,
256
+ handleGsapMoveKeyframeToPlayhead,
257
+ handleGsapMoveKeyframe,
258
+ handleGsapResizeKeyframedTween,
250
259
  handleGsapConvertToKeyframes,
251
260
  handleGsapRemoveAllKeyframes,
252
261
  handleResetSelectedElementKeyframes,
@@ -311,6 +320,9 @@ export function DomEditProvider({
311
320
  handleGsapAddKeyframe,
312
321
  handleGsapAddKeyframeBatch,
313
322
  handleGsapRemoveKeyframe,
323
+ handleGsapMoveKeyframeToPlayhead,
324
+ handleGsapMoveKeyframe,
325
+ handleGsapResizeKeyframedTween,
314
326
  handleGsapConvertToKeyframes,
315
327
  handleGsapRemoveAllKeyframes,
316
328
  handleResetSelectedElementKeyframes,
@@ -39,6 +39,8 @@ export function TimelineEditProvider({
39
39
  value.onDeleteKeyframe,
40
40
  value.onDeleteAllKeyframes,
41
41
  value.onChangeKeyframeEase,
42
+ value.onMoveKeyframeToPlayhead,
43
+ value.onMoveKeyframe,
42
44
  value.onToggleKeyframeAtPlayhead,
43
45
  ],
44
46
  );
@@ -156,6 +156,33 @@ function findPositionSetAnimation(
156
156
  );
157
157
  }
158
158
 
159
+ /**
160
+ * Find the EXISTING static position HOLD to update for a static-hold drag. Not
161
+ * just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
162
+ * remove-all-keyframes leaves behind) is a held position too, and the next drag
163
+ * must UPDATE it in place rather than append a second `gsap.set` that fights it
164
+ * (the duplicate-position-write bug). Only zero-duration holds qualify — a
165
+ * live-duration `to`/`from` is NOT a static hold (and in the static path it's a
166
+ * stale/phantom parse: re-committing it would resurrect a just-deleted tween).
167
+ * Prefers a `set` (the canonical static channel) when both forms exist.
168
+ */
169
+ function findExistingPositionWrite(
170
+ animations: GsapAnimation[],
171
+ selector: string,
172
+ ): GsapAnimation | null {
173
+ const set = findPositionSetAnimation(animations, selector);
174
+ if (set) return set;
175
+ return (
176
+ animations.find(
177
+ (a) =>
178
+ a.targetSelector === selector &&
179
+ a.propertyGroup === "position" &&
180
+ !a.keyframes &&
181
+ (a.duration ?? 0) === 0,
182
+ ) ?? null
183
+ );
184
+ }
185
+
159
186
  /**
160
187
  * Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
161
188
  * position channel for elements with no position animation. Idempotent: a
@@ -235,7 +262,7 @@ export async function commitStaticGsapPosition(
235
262
  );
236
263
  }
237
264
 
238
- export { findPositionSetAnimation };
265
+ export { findExistingPositionWrite };
239
266
 
240
267
  function findRotationSetAnimation(
241
268
  animations: GsapAnimation[],
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GSAP position-write detection helpers for the drag bridge: read the live
3
+ * runtime position from the preview iframe, and find/score the position
4
+ * animation for a selector (and pick the tween closest to the playhead).
5
+ *
6
+ * Extracted from gsapRuntimeBridge.ts to keep that file under the size cap.
7
+ */
8
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
+ import { usePlayerStore } from "../player/store/playerStore";
10
+ import { getIframeGsap, queryIframeElement } from "./gsapShared";
11
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
12
+
13
+ // fallow-ignore-next-line complexity
14
+ export function readGsapPositionFromIframe(
15
+ iframe: HTMLIFrameElement | null,
16
+ elementSelector: string,
17
+ ): { x: number; y: number } | null {
18
+ const gsap = getIframeGsap(iframe);
19
+ if (!gsap) return null;
20
+
21
+ const element = queryIframeElement(iframe, elementSelector);
22
+ if (!element) return null;
23
+
24
+ const x = Number(gsap.getProperty(element, "x")) || 0;
25
+ const y = Number(gsap.getProperty(element, "y")) || 0;
26
+ return { x, y };
27
+ }
28
+
29
+ // fallow-ignore-next-line complexity
30
+ function animHasPosition(anim: GsapAnimation): boolean {
31
+ if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
32
+ return true;
33
+ if (anim.method === "fromTo") {
34
+ const from = anim.fromProperties;
35
+ return (
36
+ "x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
37
+ );
38
+ }
39
+ return "x" in anim.properties || "y" in anim.properties;
40
+ }
41
+
42
+ // fallow-ignore-next-line complexity
43
+ export function findGsapPositionAnimation(
44
+ animations: GsapAnimation[],
45
+ selector?: string,
46
+ ): GsapAnimation | null {
47
+ if (animations.length === 0) return null;
48
+ const currentTime = usePlayerStore.getState().currentTime;
49
+
50
+ const scored = animations
51
+ .filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
52
+ .map((a) => {
53
+ let score = 0;
54
+ if (animHasPosition(a)) score += 10;
55
+ if (a.keyframes) score += 5;
56
+ if (selector && a.targetSelector === selector) score += 8;
57
+ else if (a.targetSelector.includes(",")) score -= 5;
58
+ const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
59
+ const dur = a.duration ?? 0;
60
+ if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
61
+ else
62
+ score -= Math.round(
63
+ Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
64
+ );
65
+ return { anim: a, score };
66
+ });
67
+ scored.sort((a, b) => b.score - a.score);
68
+ return scored[0]?.anim ?? animations[0];
69
+ }
70
+
71
+ /**
72
+ * From a set of candidate tweens, pick the one whose time range is closest to
73
+ * the current playhead. A tween that *contains* the playhead wins outright;
74
+ * otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
75
+ * extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
76
+ * gesture-recorded tween beats a stub when both are equidistant).
77
+ */
78
+ // fallow-ignore-next-line complexity
79
+ export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
80
+ if (anims.length <= 1) return anims[0] ?? null;
81
+ const ct = usePlayerStore.getState().currentTime;
82
+ return anims.reduce((best, a) => {
83
+ const s = resolveTweenStart(a) ?? 0;
84
+ const e = s + resolveTweenDuration(a);
85
+ const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
86
+ const bestS = resolveTweenStart(best) ?? 0;
87
+ const bestE = bestS + resolveTweenDuration(best);
88
+ const bestDist =
89
+ ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
90
+ if (dist < bestDist) return a;
91
+ if (
92
+ dist === bestDist &&
93
+ (a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
94
+ )
95
+ return a;
96
+ return best;
97
+ });
98
+ }
@@ -127,6 +127,39 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
127
127
  });
128
128
  });
129
129
 
130
+ it("updates a degenerate duration:0 hold-`to` in place instead of appending a gsap.set", async () => {
131
+ const commitMutation = vi.fn();
132
+ const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
133
+ // What remove-all-keyframes leaves behind: a zero-duration immediateRender
134
+ // `tl.to` hold. A drag must UPDATE it, not append a 2nd (gsap.set) position
135
+ // write that silently overrides it (the duplicate-position-write bug).
136
+ const degenerateHold = {
137
+ id: "#puck-b-to-0-position",
138
+ targetSelector: "#puck-b",
139
+ method: "to",
140
+ propertyGroup: "position",
141
+ properties: { x: -766, y: 314 },
142
+ position: 1.333,
143
+ resolvedStart: 1.333,
144
+ duration: 0,
145
+ } as unknown as GsapAnimation;
146
+
147
+ const handled = await tryGsapDragIntercept(
148
+ selection,
149
+ { x: -50, y: 30 },
150
+ [degenerateHold],
151
+ iframe,
152
+ commitMutation,
153
+ );
154
+
155
+ expect(handled).toBe(true);
156
+ // In-place update (2 coalesced update-property), NOT an `add`/`add-keyframe`.
157
+ const types = commitMutation.mock.calls.map(([, m]) => m.type);
158
+ expect(types.every((t: string) => t === "update-property")).toBe(true);
159
+ expect(types).not.toContain("add");
160
+ expect(types).not.toContain("add-keyframe");
161
+ });
162
+
130
163
  it("does not trip the stale-parse guard when the runtime still has the tween", async () => {
131
164
  const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
132
165
  const liveTween = {
@@ -21,110 +21,24 @@ import {
21
21
  commitKeyframedSizeFromResize,
22
22
  commitWholePathOffset,
23
23
  computeCurrentPercentage,
24
- findPositionSetAnimation,
24
+ findExistingPositionWrite,
25
25
  findRotationSetAnimation,
26
26
  findSizeSetAnimation,
27
27
  materializeIfDynamic,
28
28
  } from "./gsapDragCommit";
29
29
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
30
30
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
31
- import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared";
31
+ import { selectorFromSelection } from "./gsapShared";
32
+ import {
33
+ findGsapPositionAnimation,
34
+ pickClosestToPlayhead,
35
+ readGsapPositionFromIframe,
36
+ } from "./gsapPositionDetection";
32
37
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
33
38
  import { roundTo3 } from "../utils/rounding";
34
39
 
35
- // ── Runtime reads ──────────────────────────────────────────────────────────
36
-
37
- // fallow-ignore-next-line complexity
38
- function readGsapPositionFromIframe(
39
- iframe: HTMLIFrameElement | null,
40
- elementSelector: string,
41
- ): { x: number; y: number } | null {
42
- const gsap = getIframeGsap(iframe);
43
- if (!gsap) return null;
44
-
45
- const element = queryIframeElement(iframe, elementSelector);
46
- if (!element) return null;
47
-
48
- const x = Number(gsap.getProperty(element, "x")) || 0;
49
- const y = Number(gsap.getProperty(element, "y")) || 0;
50
- return { x, y };
51
- }
52
-
53
- // ── Animation matching ─────────────────────────────────────────────────────
54
-
55
- // fallow-ignore-next-line complexity
56
- function animHasPosition(anim: GsapAnimation): boolean {
57
- if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
58
- return true;
59
- if (anim.method === "fromTo") {
60
- const from = anim.fromProperties;
61
- return (
62
- "x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
63
- );
64
- }
65
- return "x" in anim.properties || "y" in anim.properties;
66
- }
67
-
68
- function findGsapPositionAnimation(
69
- animations: GsapAnimation[],
70
- selector?: string,
71
- ): GsapAnimation | null {
72
- if (animations.length === 0) return null;
73
- const currentTime = usePlayerStore.getState().currentTime;
74
-
75
- const scored = animations
76
- .filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
77
- .map((a) => {
78
- let score = 0;
79
- if (animHasPosition(a)) score += 10;
80
- if (a.keyframes) score += 5;
81
- if (selector && a.targetSelector === selector) score += 8;
82
- else if (a.targetSelector.includes(",")) score -= 5;
83
- const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
84
- const dur = a.duration ?? 0;
85
- if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
86
- else
87
- score -= Math.round(
88
- Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
89
- );
90
- return { anim: a, score };
91
- });
92
- scored.sort((a, b) => b.score - a.score);
93
- return scored[0]?.anim ?? animations[0];
94
- }
95
-
96
- // ── Selector resolution ────────────────────────────────────────────────────
97
-
98
40
  // ── Property-group tween resolution ───────────────────────────────────────
99
41
 
100
- /**
101
- * From a set of candidate tweens, pick the one whose time range is closest to
102
- * the current playhead. A tween that *contains* the playhead wins outright;
103
- * otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
104
- * extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
105
- * gesture-recorded tween beats a stub when both are equidistant).
106
- */
107
- function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
108
- if (anims.length <= 1) return anims[0] ?? null;
109
- const ct = usePlayerStore.getState().currentTime;
110
- return anims.reduce((best, a) => {
111
- const s = resolveTweenStart(a) ?? 0;
112
- const e = s + resolveTweenDuration(a);
113
- const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
114
- const bestS = resolveTweenStart(best) ?? 0;
115
- const bestE = bestS + resolveTweenDuration(best);
116
- const bestDist =
117
- ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
118
- if (dist < bestDist) return a;
119
- if (
120
- dist === bestDist &&
121
- (a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
122
- )
123
- return a;
124
- return best;
125
- });
126
- }
127
-
128
42
  /**
129
43
  * Find the tween for a given property group, splitting a legacy mixed tween
130
44
  * if necessary. Returns the resolved animation or null if none exists.
@@ -212,18 +126,48 @@ export async function tryGsapDragIntercept(
212
126
  return false;
213
127
  }
214
128
 
129
+ // Self-heal: enforce a single position write BEFORE committing. A corrupted
130
+ // file can carry 2+ conflicting position writes for one selector (e.g. a
131
+ // degenerate `tl.to(...,{duration:0,x,y})` AND a `gsap.set(...,{x,y})`) — the
132
+ // later one silently overrides the earlier, so the element "can't move". Keep
133
+ // the live keyframed/real tween if present (else any), strip the rest, so the
134
+ // commit below updates ONE write instead of fighting duplicates.
135
+ let workingAnimations = animations;
136
+ const isPosWrite = (a: GsapAnimation) =>
137
+ a.targetSelector === selector && a.propertyGroup === "position";
138
+ if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
139
+ const fresh = await fetchFallbackAnimations();
140
+ const dupes = fresh.filter(isPosWrite);
141
+ if (dupes.length > 1) {
142
+ const keeper =
143
+ dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
144
+ await commitMutation(
145
+ selection,
146
+ {
147
+ type: "consolidate-position-writes",
148
+ targetSelector: selector,
149
+ keepAnimationId: keeper.id,
150
+ },
151
+ { label: "Consolidate position writes", skipReload: true },
152
+ );
153
+ workingAnimations = await fetchFallbackAnimations();
154
+ } else {
155
+ workingAnimations = fresh;
156
+ }
157
+ }
158
+
215
159
  const resolved = await resolveGroupTween(
216
160
  "position",
217
- animations,
161
+ workingAnimations,
218
162
  selection,
219
163
  commitMutation,
220
164
  fetchFallbackAnimations,
221
165
  );
222
166
 
223
167
  let posAnim = resolved?.anim ?? null;
224
- let resolvedAnimations = resolved?.animations ?? animations;
168
+ let resolvedAnimations = resolved?.animations ?? workingAnimations;
225
169
  if (!posAnim) {
226
- posAnim = findGsapPositionAnimation(animations, selector);
170
+ posAnim = findGsapPositionAnimation(workingAnimations, selector);
227
171
  if (!posAnim && fetchFallbackAnimations) {
228
172
  const fresh = await fetchFallbackAnimations();
229
173
  resolvedAnimations = fresh;
@@ -253,7 +197,7 @@ export async function tryGsapDragIntercept(
253
197
  const existingSet =
254
198
  posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
255
199
  ? posAnim
256
- : findPositionSetAnimation(resolvedAnimations, selector);
200
+ : findExistingPositionWrite(resolvedAnimations, selector);
257
201
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
258
202
  commitMutation,
259
203
  fetchAnimations: fetchFallbackAnimations,
@@ -201,6 +201,8 @@ export function useDomEditSession({
201
201
  addKeyframe,
202
202
  addKeyframeBatch,
203
203
  removeKeyframe,
204
+ moveKeyframe,
205
+ resizeKeyframedTween,
204
206
  convertToKeyframes,
205
207
  removeAllKeyframes,
206
208
  setArcPath,
@@ -356,6 +358,9 @@ export function useDomEditSession({
356
358
  handleGsapAddKeyframe,
357
359
  handleGsapAddKeyframeBatch,
358
360
  handleGsapRemoveKeyframe,
361
+ handleGsapMoveKeyframeToPlayhead,
362
+ handleGsapMoveKeyframe,
363
+ handleGsapResizeKeyframedTween,
359
364
  handleGsapConvertToKeyframes,
360
365
  handleGsapRemoveAllKeyframes,
361
366
  handleResetSelectedElementKeyframes,
@@ -393,6 +398,8 @@ export function useDomEditSession({
393
398
  addKeyframe,
394
399
  addKeyframeBatch,
395
400
  removeKeyframe,
401
+ moveKeyframe,
402
+ resizeKeyframedTween,
396
403
  convertToKeyframes,
397
404
  removeAllKeyframes,
398
405
  handleDomManualEditsReset,
@@ -553,6 +560,9 @@ export function useDomEditSession({
553
560
  handleGsapAddKeyframe,
554
561
  handleGsapAddKeyframeBatch,
555
562
  handleGsapRemoveKeyframe,
563
+ handleGsapMoveKeyframeToPlayhead,
564
+ handleGsapMoveKeyframe,
565
+ handleGsapResizeKeyframedTween,
556
566
  handleGsapConvertToKeyframes,
557
567
  handleGsapRemoveAllKeyframes,
558
568
  handleResetSelectedElementKeyframes,
@@ -87,6 +87,19 @@ export interface UseDomEditWiringParams {
87
87
  properties: Record<string, number | string>,
88
88
  ) => Promise<void>;
89
89
  removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
90
+ moveKeyframe: (
91
+ sel: DomEditSelection,
92
+ animId: string,
93
+ fromPercentage: number,
94
+ toPercentage: number,
95
+ ) => void;
96
+ resizeKeyframedTween: (
97
+ sel: DomEditSelection,
98
+ animId: string,
99
+ position: number,
100
+ duration: number,
101
+ pctRemap: Array<{ from: number; to: number }>,
102
+ ) => void;
90
103
  convertToKeyframes: (
91
104
  sel: DomEditSelection,
92
105
  animId: string,
@@ -131,6 +144,8 @@ export function useDomEditWiring({
131
144
  addKeyframe,
132
145
  addKeyframeBatch,
133
146
  removeKeyframe,
147
+ moveKeyframe,
148
+ resizeKeyframedTween,
134
149
  convertToKeyframes,
135
150
  removeAllKeyframes,
136
151
  handleDomManualEditsReset,
@@ -224,6 +239,8 @@ export function useDomEditWiring({
224
239
  addKeyframe,
225
240
  addKeyframeBatch,
226
241
  removeKeyframe,
242
+ moveKeyframe,
243
+ resizeKeyframedTween,
227
244
  convertToKeyframes,
228
245
  removeAllKeyframes,
229
246
  handleDomManualEditsReset,