@hyperframes/studio 0.8.133 → 0.8.135

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/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
  2. package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
  3. package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
  4. package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
  5. package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
  6. package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
  7. package/dist/chunk-NM7BXNZ7.js.map +1 -0
  8. package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +911 -691
  12. package/dist/index.js.map +1 -1
  13. package/dist/player.js +2 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/ui.d.ts +3 -2
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/AnimationCard.test.tsx +60 -0
  19. package/src/components/editor/AnimationCard.tsx +16 -6
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -1
  21. package/src/components/editor/LayersPanel.tsx +39 -47
  22. package/src/components/editor/domEditInlineText.ts +1 -1
  23. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +34 -0
  25. package/src/components/editor/layerKind.test.ts +38 -0
  26. package/src/components/editor/layerKind.ts +19 -0
  27. package/src/components/editor/manualEditsDom.ts +5 -1
  28. package/src/components/editor/plainTranslate.test.ts +21 -2
  29. package/src/components/editor/plainTranslate.ts +7 -0
  30. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  31. package/src/components/ui/cn.ts +2 -1
  32. package/src/contexts/DomEditContext.tsx +3 -1
  33. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  34. package/src/hooks/draggedGsapPosition.ts +40 -15
  35. package/src/hooks/gsapDragCommit.ts +17 -10
  36. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  37. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  38. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  39. package/src/hooks/gsapResizeIntercept.ts +84 -17
  40. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  41. package/src/hooks/gsapRuntimePatch.ts +4 -0
  42. package/src/hooks/gsapShared.test.ts +11 -0
  43. package/src/hooks/gsapShared.ts +1 -1
  44. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  45. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  46. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  47. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  48. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  49. package/src/hooks/useDomGeometryCommits.ts +6 -1
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +3 -0
  52. package/src/hooks/useEnableKeyframes.ts +32 -37
  53. package/src/hooks/useGestureCommit.test.tsx +89 -0
  54. package/src/hooks/useGestureCommit.ts +26 -9
  55. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  56. package/src/hooks/useGsapAwareEditing.ts +10 -2
  57. package/src/icons/SystemIcons.tsx +6 -0
  58. package/src/layersPanelExport.test.tsx +45 -4
  59. package/src/utils/elementGsap.ts +6 -5
  60. package/src/utils/gsapKeyframeEases.ts +2 -0
  61. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  62. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  63. package/src/utils/gsapLiveRetime.ts +39 -31
  64. package/src/utils/gsapSoftReload.test.ts +98 -14
  65. package/src/utils/gsapSoftReload.ts +14 -2
  66. package/src/utils/gsapUndoRestore.test.ts +43 -2
  67. package/src/utils/gsapUndoRestore.ts +4 -2
  68. package/src/utils/softReloadTargets.ts +56 -0
  69. package/src/utils/studioPendingEdits.test.ts +123 -0
  70. package/src/utils/studioPendingEdits.ts +39 -3
  71. package/dist/chunk-5VSXRGIX.js.map +0 -1
  72. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
@@ -5,14 +5,18 @@
5
5
  * frame can't jump. Split from gsapRuntimeBridge, which owns the shared
6
6
  * group-tween resolution used by the drag/resize/rotate intercepts.
7
7
  */
8
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
+ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import { clearStudioBoxSize } from "../components/editor/manualEdits";
11
11
  import {
12
12
  STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
13
13
  STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
14
14
  } from "../components/editor/manualEditsTypes";
15
- import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
15
+ import {
16
+ setElementGsapPosition,
17
+ setElementGsapScale,
18
+ setElementGsapSize,
19
+ } from "../utils/elementGsap";
16
20
  import { usePlayerStore } from "../player/store/playerStore";
17
21
  import { readGsapProperty } from "./gsapRuntimeReaders";
18
22
  import {
@@ -23,7 +27,11 @@ import {
23
27
  findSizeSetAnimation,
24
28
  } from "./gsapDragCommit";
25
29
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
26
- import { computeDraggedGsapPosition, restoreDragOffset } from "./draggedGsapPosition";
30
+ import {
31
+ computeDraggedGsapPosition,
32
+ restoreDragOffset,
33
+ type DragStamp,
34
+ } from "./draggedGsapPosition";
27
35
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
28
36
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
29
37
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -39,9 +47,11 @@ import {
39
47
  } from "./gsapEditOutcome";
40
48
  import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
41
49
  import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
50
+ import { singleKeyTweenMutation } from "./useEnableKeyframes";
42
51
 
43
52
  const SIZE_PROPS = new Set(["width", "height"]);
44
53
  const POSITION_XY = new Set(["x", "y"]);
54
+ const MOVED_OR_SIZED = new Set(["x", "y", "xPercent", "yPercent", "width", "height"]);
45
55
 
46
56
  /**
47
57
  * The element's box before the resize draft ran, in CSS pixels.
@@ -88,6 +98,7 @@ export async function commitSizeAtPlayhead(
88
98
  selection.element,
89
99
  dragOffset,
90
100
  readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
101
+ callbacks.stamp,
91
102
  )
92
103
  : null;
93
104
  const written = await commitValueAtPlayhead(
@@ -102,12 +113,43 @@ export async function commitSizeAtPlayhead(
102
113
  ...preGestureBoxSize(selection.element),
103
114
  ...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
104
115
  },
105
- ...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
116
+ ...(anchor && { beforeReload: () => restoreDragOffset(selection.element, callbacks.stamp) }),
106
117
  },
107
118
  );
108
119
  return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
109
120
  }
110
121
 
122
+ /** A keyed size is GSAP's at every time; the gesture's draft, re-applied after each seek, would pin it there. */
123
+ function handOverDraftSize(
124
+ selection: DomEditSelection,
125
+ written: GsapEditOutcome,
126
+ size: Record<string, number>,
127
+ draw: <T>(run: () => T) => T,
128
+ ): GsapEditOutcome {
129
+ const { width, height } = size;
130
+ if (written.status !== "persisted" || width == null || height == null) return written;
131
+ draw(() => {
132
+ clearStudioBoxSize(selection.element);
133
+ setElementGsapSize(selection.element, width, height);
134
+ });
135
+ return written;
136
+ }
137
+
138
+ /** Under auto-record, a keyframed element's first resize is a size key at the playhead, held at all times. */
139
+ function firstSizeKey(
140
+ selection: DomEditSelection,
141
+ size: { width: number; height: number },
142
+ resizeGroup: PropertyGroupName,
143
+ animations: GsapAnimation[],
144
+ ): Record<string, unknown> | null {
145
+ const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
146
+ const keyframed = animations.some(
147
+ (a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
148
+ );
149
+ if (resizeGroup !== "size" || !autoKeyframeEnabled || !keyframed) return null;
150
+ return singleKeyTweenMutation(selection, size, currentTime);
151
+ }
152
+
111
153
  /**
112
154
  * Whether this tween already states scale as `scaleX`/`scaleY`.
113
155
  *
@@ -136,6 +178,7 @@ export async function tryGsapResizeIntercept(
136
178
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
137
179
  dragOffset?: { x: number; y: number },
138
180
  draw: <T>(run: () => T) => T = (run) => run(),
181
+ stamp?: DragStamp,
139
182
  ): Promise<GsapEditOutcome> {
140
183
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
141
184
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -170,10 +213,16 @@ export async function tryGsapResizeIntercept(
170
213
  size,
171
214
  });
172
215
  if (!anim || isInstantHold(anim)) {
216
+ const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
173
217
  const scriptWritesSize = allKnownAnimations.some((a) =>
174
218
  animationWritesAnyProperty(a, SIZE_PROPS),
175
219
  );
176
- if (!scriptWritesSize) return { status: "element-size" };
220
+ if (!scriptWritesSize) {
221
+ const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
222
+ if (!firstKey) return { status: "element-size" };
223
+ await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
224
+ return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
225
+ }
177
226
  const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
178
227
  if (!sel) return { status: "blocked", reason: "no-selector" };
179
228
  // A scale hold is not a size hold.
@@ -191,8 +240,7 @@ export async function tryGsapResizeIntercept(
191
240
  ? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
192
241
  : findSizeSetAnimation(workingAnimations, sel, selection.element);
193
242
 
194
- // Keyframe the size only when a real tween already animates it, as move and
195
- // rotate do; a fade or a slide on the element gets a plain size.
243
+ // Keyframe the size when a real tween already animates it; a static size set stays a set.
196
244
  if (resizeGroup === "size") {
197
245
  const animatedTween = pickClosestToPlayhead(
198
246
  workingAnimations.filter(
@@ -204,11 +252,19 @@ export async function tryGsapResizeIntercept(
204
252
  );
205
253
  if (animatedTween) {
206
254
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
207
- const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
208
- return commitSizeAtPlayhead(selection, animatedTween, sized, iframe, dragOffset, {
209
- commitMutation,
210
- fetchAnimations: fetchFallbackAnimations,
211
- });
255
+ const written = await commitSizeAtPlayhead(
256
+ selection,
257
+ animatedTween,
258
+ sized,
259
+ iframe,
260
+ dragOffset,
261
+ {
262
+ commitMutation,
263
+ fetchAnimations: fetchFallbackAnimations,
264
+ stamp,
265
+ },
266
+ );
267
+ return handOverDraftSize(selection, written, sized, draw);
212
268
  }
213
269
  }
214
270
 
@@ -333,8 +389,7 @@ export async function tryGsapResizeIntercept(
333
389
  // it lands back on the drop point. The compensation only applies to a STATIC
334
390
  // position (a `tl.set` hold or none) — a keyframed position path has no
335
391
  // single anchor to preserve, so it keeps the plain center-scale behavior.
336
- // The size route commits the same width/height channels the draft wrote, so
337
- // it needs none of this.
392
+ // The size route hands its draft to GSAP itself (handOverDraftSize).
338
393
  // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
339
394
  // approximate rather than corner-exact.
340
395
  // fallow-ignore-next-line complexity
@@ -363,6 +418,7 @@ export async function tryGsapResizeIntercept(
363
418
  selection.element,
364
419
  { x: 0, y: 0 },
365
420
  gsapPos,
421
+ stamp,
366
422
  );
367
423
  const base = { x: baseGsapX, y: baseGsapY };
368
424
  setElementGsapPosition(draftEl, base.x, base.y);
@@ -424,6 +480,7 @@ export async function tryGsapResizeIntercept(
424
480
  await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
425
481
  commitMutation,
426
482
  fetchAnimations: fetchFallbackAnimations,
483
+ stamp,
427
484
  }),
428
485
  );
429
486
  return true;
@@ -432,6 +489,7 @@ export async function tryGsapResizeIntercept(
432
489
  await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
433
490
  commitMutation,
434
491
  fetchAnimations: fetchFallbackAnimations,
492
+ stamp,
435
493
  });
436
494
  return true;
437
495
  };
@@ -453,9 +511,18 @@ export async function tryGsapResizeIntercept(
453
511
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
454
512
  }
455
513
 
456
- const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
457
- if (resizeGroup === "size")
458
- return commitSizeAtPlayhead(selection, anim, resizeProps, iframe, dragOffset, callbacks);
514
+ const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
515
+ if (resizeGroup === "size") {
516
+ const written = await commitSizeAtPlayhead(
517
+ selection,
518
+ anim,
519
+ resizeProps,
520
+ iframe,
521
+ dragOffset,
522
+ callbacks,
523
+ );
524
+ return handOverDraftSize(selection, written, resizeProps, draw);
525
+ }
459
526
  const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
460
527
  label: "Resize",
461
528
  backfill: resizeBackfill,
@@ -31,6 +31,7 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
31
31
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
32
32
  import { roundTo3 } from "../utils/rounding";
33
33
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
34
+ import type { DragStamp } from "./draggedGsapPosition";
34
35
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
35
36
  import {
36
37
  findGsapPositionAnimation,
@@ -243,6 +244,7 @@ export async function tryGsapDragIntercept(
243
244
  preflightOnly?: boolean;
244
245
  preflightPassed?: boolean;
245
246
  group?: boolean;
247
+ stamp?: DragStamp;
246
248
  },
247
249
  ): Promise<GsapEditOutcome> {
248
250
  if (!options?.preflightPassed) {
@@ -323,6 +325,7 @@ export async function tryGsapDragIntercept(
323
325
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
324
326
  commitMutation,
325
327
  fetchAnimations: fetchFallbackAnimations,
328
+ stamp: options?.stamp,
326
329
  });
327
330
  return { status: "persisted" };
328
331
  }
@@ -352,7 +355,7 @@ export async function tryGsapDragIntercept(
352
355
  }
353
356
  }
354
357
 
355
- const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
358
+ const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
356
359
  if (route === "whole-path") {
357
360
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
358
361
  } else {
@@ -19,6 +19,7 @@ import {
19
19
  type RuntimeTween,
20
20
  type RuntimeTimeline,
21
21
  } from "./gsapRuntimeKeyframes";
22
+ import { recordLiveSet } from "../utils/softReloadTargets";
22
23
 
23
24
  /** Value-only channels a `tl.set(...)` patch may touch. */
24
25
  export interface SetPatchProps {
@@ -34,6 +35,8 @@ export interface SetPatchProps {
34
35
  scaleY?: number;
35
36
  scale?: number;
36
37
  opacity?: number;
38
+ width?: number;
39
+ height?: number;
37
40
  }
38
41
 
39
42
  /** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
@@ -101,6 +104,7 @@ function applyGlobalSet(
101
104
  const numeric = finiteNumericProps(props);
102
105
  if (!gsapLib?.set || !el || !numeric) return false;
103
106
  gsapLib.set(el, numeric);
107
+ recordLiveSet(el, numeric);
104
108
  return true;
105
109
  } catch {
106
110
  return false;
@@ -4,6 +4,7 @@ import {
4
4
  idFromSelector,
5
5
  idSelector,
6
6
  isInstantHold,
7
+ keyframeEases,
7
8
  parsePercentageKeyframes,
8
9
  resolveClipTimingBasis,
9
10
  playsNear,
@@ -11,6 +12,16 @@ import {
11
12
  toClipPercentage,
12
13
  } from "./gsapShared";
13
14
 
15
+ describe("keyframeEases", () => {
16
+ it("carries the keyframes' own ease into the tween ease, which GSAP plays the same", () => {
17
+ const anim = {
18
+ ease: "back.out",
19
+ keyframes: { keyframes: [], ease: "sine.inOut", easeEach: "power2.out" },
20
+ } as unknown as GsapAnimation;
21
+ expect(keyframeEases(anim)).toEqual({ ease: "sine.inOut", easeEach: "power2.out" });
22
+ });
23
+ });
24
+
14
25
  describe("isInstantHold", () => {
15
26
  const animation = (method: GsapAnimation["method"], duration?: number) =>
16
27
  ({ method, duration }) as unknown as GsapAnimation;
@@ -41,7 +41,7 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
41
41
  * since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
42
42
  export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
43
43
  if (!anim.keyframes) return { easeEach: anim.ease };
44
- return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
44
+ return { ease: runEaseOf(anim), easeEach: anim.keyframes.easeEach };
45
45
  }
46
46
 
47
47
  /**
@@ -0,0 +1,44 @@
1
+ import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
2
+ import { gsap } from "gsap";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const timeline = `const tl = gsap.timeline({ paused: true });\n`;
6
+
7
+ // What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
8
+ const saved = {
9
+ position:
10
+ `tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
11
+ `tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
12
+ size:
13
+ `tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
14
+ `tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
15
+ };
16
+
17
+ function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
18
+ const target: Record<string, number> = { x: 0, width: 240 };
19
+ const run = new Function(
20
+ "gsap",
21
+ "target",
22
+ `${script.replaceAll('"#target"', "target")}\nreturn tl;`,
23
+ );
24
+ const tl = run(gsap, target) as gsap.core.Timeline;
25
+ for (const time of seeks) tl.seek(time);
26
+ return target[prop];
27
+ }
28
+
29
+ describe.each([
30
+ ["position", "x", 47.754],
31
+ ["size", "width", 340],
32
+ ] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
33
+ it("plays and seeks to the same value once its tail is trimmed", () => {
34
+ const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
35
+ expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
36
+ expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
37
+ });
38
+
39
+ it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
40
+ const script = timeline + saved[name];
41
+ expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
42
+ expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
43
+ });
44
+ });
@@ -0,0 +1,37 @@
1
+ // Real gsap: the ease the parser reports for keyframes must be the one GSAP plays.
2
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
3
+ import { gsap } from "gsap";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const STEPS = { "0%": { x: 0 }, "100%": { x: 10 } };
7
+
8
+ function xAt(vars: gsap.TweenVars, time = 0.3): number {
9
+ const target = { x: 0 };
10
+ gsap
11
+ .timeline({ paused: true })
12
+ .to(target, { duration: 1, ...vars }, 0)
13
+ .seek(time);
14
+ return target.x;
15
+ }
16
+
17
+ const parsedEaseEach = (vars: string) =>
18
+ parseGsapScriptAcorn(`const tl = gsap.timeline({ paused: true });
19
+ tl.to("#a", { duration: 1, ${vars} }, 0);`).animations[0]!.keyframes?.easeEach;
20
+
21
+ describe("the keyframes ease the parser reports", () => {
22
+ it("is not a tween-level easeEach, which GSAP ignores", () => {
23
+ expect(xAt({ keyframes: STEPS, easeEach: "power4.in" })).toBe(xAt({ keyframes: STEPS }));
24
+ expect(
25
+ parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 } }, easeEach: "power4.in"'),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ it("is an easeEach inside the keyframes, which GSAP plays", () => {
30
+ expect(xAt({ keyframes: { ...STEPS, easeEach: "power4.in" } })).not.toBe(
31
+ xAt({ keyframes: STEPS }),
32
+ );
33
+ expect(
34
+ parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 }, easeEach: "power4.in" }'),
35
+ ).toBe("power4.in");
36
+ });
37
+ });
@@ -10,6 +10,7 @@ import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarnes
10
10
  import { writeSizeWithCrop } from "../components/editor/cropResize";
11
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
12
  import { trackKeyframeCommit } from "../utils/keyframeUsage";
13
+ import { xAtTime } from "./gsapPlaybackTestHarness";
13
14
  import type { CommitMutationOptions, CommitMutation } from "./gsapScriptCommitTypes";
14
15
 
15
16
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
@@ -205,6 +206,38 @@ function renderCommitHook(
205
206
  // canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
206
207
  // off, it must shift the whole tween instead of adding/updating a keyframe
207
208
  // at the playhead.
209
+ describe("useAnimatedPropertyCommit — extending a keyframed tween to the playhead", () => {
210
+ it("keeps the tween's eases and each keyframe's own ease", async () => {
211
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 3, activeKeyframePct: null });
212
+ const eased = {
213
+ ...keyframedAnim,
214
+ ease: "power2.inOut",
215
+ keyframes: {
216
+ easeEach: "power2.out",
217
+ keyframes: [
218
+ { percentage: 0, properties: { x: 0, y: 0 }, ease: "expo.in" },
219
+ { percentage: 100, properties: { x: 100, y: 0 } },
220
+ ],
221
+ },
222
+ } as unknown as GsapAnimation;
223
+ const mutations: Array<Record<string, unknown>> = [];
224
+ let commit!: Commit;
225
+ const root = renderHookWith(
226
+ [eased],
227
+ (mutation) => mutations.push(mutation),
228
+ (ready) => (commit = ready),
229
+ );
230
+ await act(async () => commit(selection, { x: 50 }));
231
+ act(() => root.unmount());
232
+
233
+ const replace = mutations.find((m) => m.type === "replace-with-keyframes")!;
234
+ expect(replace).toMatchObject({ ease: "power2.inOut", easeEach: "power2.out" });
235
+ expect((replace.keyframes as Array<{ ease?: string }>)[0]!.ease).toBe("expo.in");
236
+ // The old end keyframe still plays at 2 s once the tween runs to the playhead at 3 s.
237
+ expect(xAtTime(replace as unknown as Parameters<typeof xAtTime>[0], 2)).toBeCloseTo(100, 0);
238
+ });
239
+ });
240
+
208
241
  describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
209
242
  async function runCommitWithAutoKeyframe(enabled: boolean) {
210
243
  usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
@@ -18,6 +18,7 @@ import {
18
18
  selectorFromSelection,
19
19
  computeElementPercentage,
20
20
  isInstantHold,
21
+ keyframeEases,
21
22
  writeTargetSelector,
22
23
  tweenTargetsElement,
23
24
  } from "./gsapShared";
@@ -375,7 +376,7 @@ async function commitKeyframeProps(
375
376
  for (const k of Object.keys(properties)) {
376
377
  if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
377
378
  }
378
- return { percentage: newPct, properties: p };
379
+ return { percentage: newPct, properties: p, ...(kf.ease ? { ease: kf.ease } : {}) };
379
380
  });
380
381
  remapped.push({ percentage: toNewPct(ct), properties });
381
382
  remapped.sort((a, b) => a.percentage - b.percentage);
@@ -388,6 +389,7 @@ async function commitKeyframeProps(
388
389
  position: roundTo3(newStart),
389
390
  duration: roundTo3(newDuration),
390
391
  keyframes: remapped,
392
+ ...keyframeEases(anim),
391
393
  },
392
394
  { label: `Edit ${primaryProp} (extended keyframe)`, keyframeAction: "add", softReload: true },
393
395
  );
@@ -376,6 +376,27 @@ describe("Reset after a plain move puts the author's translate back", () => {
376
376
  });
377
377
  });
378
378
 
379
+ describe("useDomGeometryCommits undo steps", () => {
380
+ it("never merges a resize with the next size edit of the same element", async () => {
381
+ const element = document.createElement("div");
382
+ element.id = "box";
383
+ document.body.append(element);
384
+ const selection = { id: "box", selector: "#box", element } as unknown as DomEditSelection;
385
+ const commitPositionPatchToHtml = vi
386
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
387
+ .mockResolvedValue(undefined);
388
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
389
+
390
+ await commits().handleDomBoxSizeCommit(selection, { width: 418, height: 200 });
391
+ await commits().handleDomBoxSizeCommit(selection, { width: 280, height: 200 });
392
+
393
+ const [resize, field] = commitPositionPatchToHtml.mock.calls.map(([, , options]) => options);
394
+ expect(resize!.coalesceKey).not.toBe(field!.coalesceKey);
395
+ unmount();
396
+ element.remove();
397
+ });
398
+ });
399
+
379
400
  describe("useDomGeometryCommits resize of a cropped element", () => {
380
401
  it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
381
402
  const element = withInlineLayoutBox(document.createElement("div"));
@@ -26,6 +26,8 @@ import {
26
26
  } from "../components/editor/manualEditsDomPatches";
27
27
  import type { PatchOperation } from "../utils/sourcePatcher";
28
28
 
29
+ let boxSizeCommitCounter = 0;
30
+
29
31
  // ── Hook ──
30
32
 
31
33
  export interface UseDomGeometryCommitsParams {
@@ -90,7 +92,10 @@ export function useDomGeometryCommits({
90
92
  label: "Resize layer box",
91
93
  ...(undoKey
92
94
  ? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
93
- : { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
95
+ : {
96
+ coalesceKey: `box-size:${++boxSizeCommitCounter}`,
97
+ coalesceMs: Number.POSITIVE_INFINITY,
98
+ }),
94
99
  }).catch((error) => {
95
100
  restoreStudioBoxSize(element, beforeSize);
96
101
  if (offset) restoreStudioPathOffset(element, beforeOffset);
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8
8
  import {
9
9
  beginStudioPendingEdit,
10
+ isStudioEditSaving,
10
11
  setStudioPendingEditClaimClock,
11
12
  trackStudioPendingEdit,
12
13
  } from "../utils/studioPendingEdits";
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
97
98
  });
98
99
  });
99
100
 
101
+ it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
102
+ const { deps, actions } = mount(
103
+ { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
104
+ PREDICTED,
105
+ );
106
+ let step!: () => void;
107
+ const stepped = new Promise<void>((resolve) => (step = resolve));
108
+ const undo = deps.editHistory.undo.getMockImplementation()!;
109
+ deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
110
+
111
+ const undone = actions.undo();
112
+ expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
113
+ expect(isStudioEditSaving()).toBe(true);
114
+ step();
115
+ await act(() => undone);
116
+ await Promise.resolve();
117
+ expect(isStudioEditSaving()).toBe(false);
118
+ });
119
+
100
120
  it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
101
121
  const { deps, actions } = mount(
102
122
  {
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
6
  import {
7
+ beginStudioShownRestore,
7
8
  hasStudioPendingEdits,
8
9
  paintBackNewestStudioPendingEdit,
9
10
  } from "../utils/studioPendingEdits";
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
82
83
  const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
83
84
  const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
84
85
  const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
86
+ const shownLanded = predictedShown ? beginStudioShownRestore() : null;
85
87
  const putBack = predictedShown ?? pendingEditShown?.showAgain;
86
88
  const claimedAfter =
87
89
  pendingEditShown?.claimsAtBegin ??
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
107
109
  : stepped && editClaimed;
108
110
  } finally {
109
111
  if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
112
+ shownLanded?.();
110
113
  }
111
114
  if (!result.ok && result.reason === "content-mismatch") {
112
115
  showToast(
@@ -31,6 +31,7 @@ import {
31
31
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
32
32
  import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
33
33
  import { roundTo3 } from "../utils/rounding";
34
+ import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
34
35
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
35
36
  import {
36
37
  buildExtendedKeyframes,
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
180
181
  return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
181
182
  }
182
183
 
184
+ export function singleKeyTweenMutation(
185
+ sel: DomEditSelection,
186
+ properties: Record<string, number>,
187
+ currentTime: number,
188
+ ): Record<string, unknown> | null {
189
+ // A brand-new tween: author it against the one element the user selected.
190
+ // The bare class selectorFromSelection hands back for an id-less element
191
+ // animates every sibling sharing the class (see writeTargetSelector).
192
+ const targetSelector = writeTargetSelector(sel);
193
+ if (!targetSelector) return null;
194
+ const { start, duration } = resolveNewTweenRange(
195
+ sel.dataAttributes?.start,
196
+ sel.dataAttributes?.duration,
197
+ currentTime,
198
+ );
199
+ // One keyframe at the playhead, a single diamond capturing the current value;
200
+ // motion comes from the keyframes the user adds or drags later.
201
+ return {
202
+ type: "add-with-keyframes",
203
+ targetSelector,
204
+ position: roundTo3(start),
205
+ duration: roundTo3(duration),
206
+ keyframes: [{ percentage: 0, properties }],
207
+ };
208
+ }
209
+
183
210
  // Authoritative parse of the current source for `sel`. Returns `null` when the
184
211
  // fetch can't run (no projectId / request failed) so callers can distinguish
185
212
  // "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
514
541
  }
515
542
  } else {
516
543
  const position = readElementPosition(iframe, sel, null);
517
- const { start: elStart, duration: elDuration } = resolveNewTweenRange(
518
- sel.dataAttributes?.start,
519
- sel.dataAttributes?.duration,
520
- t,
521
- );
522
- // A brand-new tween: author it against the one element the user selected.
523
- // The bare class selectorFromSelection hands back for an id-less element
524
- // animates every sibling sharing the class (see writeTargetSelector).
525
- const selector = writeTargetSelector(sel);
526
-
527
- if (!selector) {
528
- session.handleGsapAddAnimation("to");
529
- return;
530
- }
531
-
532
- if (Object.keys(position).length === 0) {
533
- position.x = 0;
534
- position.y = 0;
535
- }
536
-
537
- // One keyframe at the playhead — a single diamond capturing the current
538
- // value. Motion comes from the user adding/dragging more keyframes later;
539
- // creating 0%+100% up front showed two diamonds for a single "add keyframe".
540
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
541
- [{ percentage: 0, properties: { ...position } }];
542
-
543
- if (session.commitMutation) {
544
- await session.commitMutation(
545
- {
546
- type: "add-with-keyframes",
547
- targetSelector: selector,
548
- position: roundTo3(elStart),
549
- duration: roundTo3(elDuration),
550
- keyframes,
551
- },
552
- { label: "Enable keyframes", softReload: true },
553
- );
544
+ if (Object.keys(position).length === 0)
545
+ Object.assign(position, readTranslatePxLeavingPercent(sel.element));
546
+ const mutation = singleKeyTweenMutation(sel, position, t);
547
+ if (mutation && session.commitMutation) {
548
+ await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
554
549
  } else {
555
550
  session.handleGsapAddAnimation("to");
556
551
  }