@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
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { trackStudioEvent } from "../../utils/studioTelemetry";
2
3
  // @vitest-environment happy-dom
3
4
  import React, { act } from "react";
@@ -7,7 +8,11 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
7
8
  import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
8
9
  import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
9
10
  import { __resetForTests } from "../../utils/canvasNudgeGate";
10
- import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
11
+ import {
12
+ flushStudioPendingEdits,
13
+ hasStudioPendingEdits,
14
+ trackedStudioEdit,
15
+ } from "../../utils/studioPendingEdits";
11
16
  import type { DomEditSelection } from "./domEditing";
12
17
  import type { OverlayRect } from "./domEditOverlayGeometry";
13
18
 
@@ -392,23 +397,32 @@ describe("useDomEditNudge pauses playback before it snapshots the timelines", ()
392
397
  });
393
398
  });
394
399
 
400
+ /** A burst-ready Harness on a fresh element, under fake timers. */
401
+ function mountBurstHarness(
402
+ id: string,
403
+ onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"],
404
+ ) {
405
+ __resetForTests();
406
+ vi.useFakeTimers();
407
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
408
+ const element = document.body.appendChild(document.createElement("div"));
409
+ element.id = id;
410
+ act(() => {
411
+ root.render(
412
+ React.createElement(Harness, {
413
+ selection: makeSelection("Dot", element),
414
+ onPathOffsetCommit,
415
+ }),
416
+ );
417
+ });
418
+ return root;
419
+ }
420
+
395
421
  describe("useDomEditNudge — undo right after a burst", () => {
396
422
  it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
397
- __resetForTests();
398
- vi.useFakeTimers();
399
- const root = createRoot(document.body.appendChild(document.createElement("div")));
400
- const element = document.body.appendChild(document.createElement("div"));
401
- element.id = "dot-undo";
402
423
  let saved!: () => void;
403
424
  const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
404
- act(() => {
405
- root.render(
406
- React.createElement(Harness, {
407
- selection: makeSelection("Dot", element),
408
- onPathOffsetCommit: commit,
409
- }),
410
- );
411
- });
425
+ const root = mountBurstHarness("dot-undo", commit);
412
426
  act(() => dispatchArrowRight());
413
427
 
414
428
  let drained = false;
@@ -424,21 +438,9 @@ describe("useDomEditNudge — undo right after a burst", () => {
424
438
  });
425
439
 
426
440
  it("counts the burst as a pending edit from its first key until its save lands", async () => {
427
- __resetForTests();
428
- vi.useFakeTimers();
429
- const root = createRoot(document.body.appendChild(document.createElement("div")));
430
- const element = document.body.appendChild(document.createElement("div"));
431
- element.id = "dot-pending";
432
441
  let saved!: () => void;
433
442
  const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
434
- act(() => {
435
- root.render(
436
- React.createElement(Harness, {
437
- selection: makeSelection("Dot", element),
438
- onPathOffsetCommit: commit,
439
- }),
440
- );
441
- });
443
+ const root = mountBurstHarness("dot-pending", commit);
442
444
  expect(hasStudioPendingEdits()).toBe(false);
443
445
  act(() => dispatchArrowRight());
444
446
  expect(hasStudioPendingEdits()).toBe(true);
@@ -452,6 +454,25 @@ describe("useDomEditNudge — undo right after a burst", () => {
452
454
  });
453
455
  });
454
456
 
457
+ describe("useDomEditNudge — a Design-panel edit during a burst", () => {
458
+ it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => {
459
+ const order: string[] = [];
460
+ const root = mountBurstHarness("dot-panel", async () => void order.push("nudge"));
461
+ try {
462
+ act(() => dispatchArrowRight());
463
+ const panelEdit = trackedStudioEdit(async () => void order.push("width"), {
464
+ afterOlderSaves: true,
465
+ })();
466
+ vi.useRealTimers();
467
+ await panelEdit;
468
+ expect(order).toEqual(["nudge", "width"]);
469
+ } finally {
470
+ vi.useRealTimers();
471
+ act(() => root.unmount());
472
+ }
473
+ });
474
+ });
475
+
455
476
  describe("useDomEditNudge — a commit that throws", () => {
456
477
  it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
457
478
  __resetForTests();
@@ -3,6 +3,7 @@
3
3
  * invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
4
4
  */
5
5
 
6
+ import type { CnFunction } from "cn";
6
7
  import { createCn } from "cn/config";
7
8
 
8
9
  /** `text-step-11`, `text-step-9`, … — the type scale from `theme.css`. */
@@ -11,7 +12,7 @@ const isTypeStep = (value: string) => /^step-\d+$/.test(value);
11
12
  /** `ctl`, `ctl-sm`, `ctl-lg` — the control heights from `theme.css`. */
12
13
  const isControlSize = (value: string) => /^ctl(-sm|-lg)?$/.test(value);
13
14
 
14
- export const cn = createCn({
15
+ export const cn: CnFunction = createCn({
15
16
  extend: {
16
17
  classGroups: {
17
18
  "font-size": [{ text: [isTypeStep] }],
@@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
107
107
  const tracked: Record<string, unknown> = { ...actions };
108
108
  for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
109
109
  if (!SAVES_AN_EDIT[key]) continue;
110
- tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
110
+ tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown, {
111
+ afterOlderSaves: true,
112
+ });
111
113
  }
112
114
  return tracked as unknown as DomEditActionsValue;
113
115
  }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { freezeDragStamp, restoreDragOffset } from "./draggedGsapPosition";
5
+ import { commitStaticGsapPosition } from "./gsapDragCommit";
6
+
7
+ function stamp(element: HTMLElement, base: { x: number; y: number }) {
8
+ element.setAttribute("data-hf-drag-gsap-base-x", String(base.x));
9
+ element.setAttribute("data-hf-drag-gsap-base-y", String(base.y));
10
+ element.setAttribute("data-hf-drag-initial-offset-x", "0");
11
+ element.setAttribute("data-hf-drag-initial-offset-y", "0");
12
+ }
13
+
14
+ // A resize released at x 60 moves its anchor by -50; the next drag presses before that write is planned.
15
+ it("a commit that outlives its gesture uses the gesture's stamp, not the next one's", async () => {
16
+ const element = document.createElement("div");
17
+ stamp(element, { x: 60, y: 37.5 });
18
+ const frozen = freezeDragStamp(element);
19
+ stamp(element, { x: 10, y: 4 });
20
+ const commitMutation = vi.fn();
21
+
22
+ await commitStaticGsapPosition(
23
+ { id: "target", selector: "#target", element } as DomEditSelection,
24
+ { x: -50, y: -33.5 },
25
+ { x: 0, y: 0 },
26
+ "#target",
27
+ null,
28
+ { commitMutation, stamp: frozen },
29
+ );
30
+
31
+ expect(commitMutation.mock.calls[0]![1].properties).toEqual({ x: 10, y: 4 });
32
+ restoreDragOffset(element, frozen);
33
+ expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe("0");
34
+ });
@@ -36,10 +36,35 @@ export function readCssRotation(element: HTMLElement, withRotate = true): number
36
36
  }
37
37
  }
38
38
 
39
+ export interface DragStamp {
40
+ origX: number;
41
+ origY: number;
42
+ baseX: number;
43
+ baseY: number;
44
+ frozen?: boolean;
45
+ }
46
+
47
+ /** The drag-start attributes as they are now; NaN for an absent base. */
48
+ export function readDragStamp(element: HTMLElement): DragStamp {
49
+ const read = (name: string) => Number.parseFloat(element.getAttribute(name) ?? "");
50
+ return {
51
+ origX: read("data-hf-drag-initial-offset-x") || 0,
52
+ origY: read("data-hf-drag-initial-offset-y") || 0,
53
+ baseX: read("data-hf-drag-gsap-base-x"),
54
+ baseY: read("data-hf-drag-gsap-base-y"),
55
+ };
56
+ }
57
+
58
+ /** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
59
+ export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
60
+ ...readDragStamp(element),
61
+ frozen: true,
62
+ });
63
+
39
64
  /**
40
65
  * Translate a studio drag offset into absolute GSAP x/y, accounting for the
41
66
  * element's rotation and its drag-start base pose. Reads the drag-start
42
- * attributes stamped by `createManualOffsetDragMember`
67
+ * stamp (`readDragStamp` by default) set by `createManualOffsetDragMember`
43
68
  * (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase`
44
69
  * is used when the base attributes are absent (e.g. a static element that GSAP
45
70
  * hasn't given an x/y yet).
@@ -52,22 +77,19 @@ export function computeDraggedGsapPosition(
52
77
  element: HTMLElement,
53
78
  studioOffset: { x: number; y: number },
54
79
  fallbackBase: { x: number; y: number },
80
+ stamp: DragStamp = readDragStamp(element),
55
81
  ): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } {
56
82
  const rotStyle = element.style.getPropertyValue("--hf-studio-rotation");
57
83
  const rotDeg = Number.parseFloat(rotStyle) || 0;
58
84
  const rad = (-rotDeg * Math.PI) / 180;
59
85
  const cos = Math.cos(rad);
60
86
  const sin = Math.sin(rad);
61
- const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
62
- const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
63
- const deltaX = studioOffset.x - origX;
64
- const deltaY = studioOffset.y - origY;
87
+ const deltaX = studioOffset.x - stamp.origX;
88
+ const deltaY = studioOffset.y - stamp.origY;
65
89
  const adjX = deltaX * cos - deltaY * sin;
66
90
  const adjY = deltaX * sin + deltaY * cos;
67
- const parsedBaseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
68
- const parsedBaseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
69
- const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
70
- const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
91
+ const baseGsapX = Number.isFinite(stamp.baseX) ? stamp.baseX : fallbackBase.x;
92
+ const baseGsapY = Number.isFinite(stamp.baseY) ? stamp.baseY : fallbackBase.y;
71
93
  return {
72
94
  newX: roundTo3(baseGsapX + adjX),
73
95
  newY: roundTo3(baseGsapY + adjY),
@@ -76,12 +98,15 @@ export function computeDraggedGsapPosition(
76
98
  };
77
99
  }
78
100
 
79
- /** Puts the drag's preview offset back once the written position renders instead. */
80
- export function restoreDragOffset(element: HTMLElement): void {
81
- const origin = (axis: "x" | "y") =>
82
- Number.parseFloat(element.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "") || 0;
83
- element.style.setProperty("--hf-studio-offset-x", `${origin("x")}px`);
84
- element.style.setProperty("--hf-studio-offset-y", `${origin("y")}px`);
101
+ /** Puts the drag's preview offset back once the written position renders instead. A frozen stamp's
102
+ * gesture is over: the attributes belong to the one after it. */
103
+ export function restoreDragOffset(
104
+ element: HTMLElement,
105
+ stamp: DragStamp = readDragStamp(element),
106
+ ): void {
107
+ if (stamp.frozen) return;
108
+ element.style.setProperty("--hf-studio-offset-x", `${stamp.origX}px`);
109
+ element.style.setProperty("--hf-studio-offset-y", `${stamp.origY}px`);
85
110
  element.removeAttribute("data-hf-drag-initial-offset-x");
86
111
  element.removeAttribute("data-hf-drag-initial-offset-y");
87
112
  }
@@ -12,7 +12,12 @@ import {
12
12
  } from "../utils/globalTimeCompiler";
13
13
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
14
14
  import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
15
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
15
+ import {
16
+ computeDraggedGsapPosition,
17
+ readDragStamp,
18
+ restoreDragOffset,
19
+ type DragStamp,
20
+ } from "./draggedGsapPosition";
16
21
  import type { CommitMutation } from "./gsapScriptCommitTypes";
17
22
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
18
23
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
@@ -26,6 +31,8 @@ export {
26
31
  export interface GsapDragCommitCallbacks {
27
32
  commitMutation: CommitMutation;
28
33
  fetchAnimations?: () => Promise<GsapAnimation[]>;
34
+ /** The gesture's drag stamp, when its commit can outlive it; else read from the element. */
35
+ stamp?: DragStamp;
29
36
  }
30
37
 
31
38
  /**
@@ -169,7 +176,12 @@ export async function commitStaticGsapPosition(
169
176
  existingSet: GsapAnimation | null,
170
177
  callbacks: GsapDragCommitCallbacks,
171
178
  ): Promise<void> {
172
- const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
179
+ const { newX, newY } = computeDraggedGsapPosition(
180
+ selection.element,
181
+ studioOffset,
182
+ gsapPos,
183
+ callbacks.stamp,
184
+ );
173
185
  if (existingSet) {
174
186
  if (existingSet.keyframes) {
175
187
  // Keyframed zero-duration hold (drag-path corruption): can't update-property
@@ -344,22 +356,17 @@ export async function commitWholePathOffset(
344
356
  callbacks: GsapDragCommitCallbacks,
345
357
  ): Promise<void> {
346
358
  const el = selection.element;
359
+ const stamp = callbacks.stamp ?? readDragStamp(el);
347
360
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
348
361
  el,
349
362
  studioOffset,
350
363
  gsapPos,
364
+ stamp,
351
365
  );
352
366
  const deltaX = newX - baseGsapX;
353
367
  // fallow-ignore-next-line code-duplication
354
368
  const deltaY = newY - baseGsapY;
355
- const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
356
- const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
357
- const restoreOffset = () => {
358
- el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
359
- el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
360
- el.removeAttribute("data-hf-drag-initial-offset-x");
361
- el.removeAttribute("data-hf-drag-initial-offset-y");
362
- };
369
+ const restoreOffset = () => restoreDragOffset(el, stamp);
363
370
 
364
371
  // fallow-ignore-next-line code-duplication
365
372
  let effectiveAnim = anim;
@@ -9,7 +9,11 @@ import {
9
9
  import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
10
10
  import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
11
11
  import { roundTo3 } from "../utils/rounding";
12
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
12
+ import {
13
+ computeDraggedGsapPosition,
14
+ readDragStamp,
15
+ restoreDragOffset,
16
+ } from "./draggedGsapPosition";
13
17
  import {
14
18
  type GsapDragCommitCallbacks,
15
19
  computeCurrentPercentage,
@@ -117,20 +121,15 @@ export async function commitGsapPositionFromDrag(
117
121
  callbacks: GsapDragCommitCallbacks,
118
122
  ): Promise<GsapEditOutcome> {
119
123
  const el = selection.element;
124
+ const stamp = callbacks.stamp ?? readDragStamp(el);
120
125
  // fallow-ignore-next-line code-duplication
121
126
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
122
127
  el,
123
128
  studioOffset,
124
129
  gsapPos,
130
+ stamp,
125
131
  );
126
- const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
127
- const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
128
- const restoreOffset = () => {
129
- el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
130
- el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
131
- el.removeAttribute("data-hf-drag-initial-offset-x");
132
- el.removeAttribute("data-hf-drag-initial-offset-y");
133
- };
132
+ const restoreOffset = () => restoreDragOffset(el, stamp);
134
133
 
135
134
  if (anim.arcPath?.enabled) {
136
135
  const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
@@ -0,0 +1,28 @@
1
+ import { gsap } from "gsap";
2
+
3
+ type KeyframeMutation = {
4
+ duration: number;
5
+ ease?: string;
6
+ easeEach?: string;
7
+ keyframes: Array<{
8
+ percentage: number;
9
+ properties: Record<string, number | string>;
10
+ ease?: string;
11
+ }>;
12
+ };
13
+
14
+ /** Plays a replace-with-keyframes mutation in real GSAP and reads `x` at `time` seconds. */
15
+ export function xAtTime(mutation: KeyframeMutation, time: number): number {
16
+ const target = { x: 0, y: 0, opacity: 1 };
17
+ const frames: Record<string, unknown> = mutation.easeEach ? { easeEach: mutation.easeEach } : {};
18
+ for (const kf of mutation.keyframes) {
19
+ frames[`${kf.percentage}%`] = { ...kf.properties, ...(kf.ease ? { ease: kf.ease } : {}) };
20
+ }
21
+ const vars = {
22
+ duration: mutation.duration,
23
+ keyframes: frames,
24
+ ...(mutation.ease ? { ease: mutation.ease } : {}),
25
+ };
26
+ gsap.timeline({ paused: true }).to(target, vars, 0).seek(time);
27
+ return target.x;
28
+ }
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { afterEach, expect, it, vi } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
@@ -182,6 +182,7 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
182
182
  await expect(
183
183
  tryGsapResizeIntercept(
184
184
  selection,
185
+ // fallow-ignore-next-line code-duplication
185
186
  { width: 344, height: 344 },
186
187
  [],
187
188
  null,
@@ -573,6 +574,76 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
573
574
  expect(commitMutation).not.toHaveBeenCalled();
574
575
  });
575
576
 
577
+ describe("the first resize of a keyframed element under auto-record", () => {
578
+ const positionKeys = {
579
+ id: "#title-to-1-position",
580
+ targetSelector: "#title",
581
+ propertyGroup: "position",
582
+ method: "to",
583
+ properties: {},
584
+ keyframes: { keyframes: [{ percentage: 0, properties: { x: 300, y: 200 } }] },
585
+ position: 1,
586
+ resolvedStart: 1,
587
+ duration: 3,
588
+ } as unknown as GsapAnimation;
589
+
590
+ it("writes a size key at the playhead in one mutation and hands the draft size to GSAP", async () => {
591
+ usePlayerStore.setState({ currentTime: 2 });
592
+ const commitMutation = vi.fn();
593
+ const selection = titleSelection();
594
+ selection.element.setAttribute("data-hf-studio-box-size", "true");
595
+
596
+ // fallow-ignore-next-line code-duplication
597
+ const handled = await tryGsapResizeIntercept(
598
+ selection,
599
+ { width: 424.2, height: 237 },
600
+ [positionKeys],
601
+ null,
602
+ commitMutation,
603
+ );
604
+
605
+ expect(handled).toEqual({ status: "persisted" });
606
+ expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
607
+ {
608
+ type: "add-with-keyframes",
609
+ targetSelector: "#title",
610
+ position: 2,
611
+ duration: 1,
612
+ keyframes: [{ percentage: 0, properties: { width: 424, height: 237 } }],
613
+ },
614
+ ]);
615
+ expect(selection.element.hasAttribute("data-hf-studio-box-size")).toBe(false);
616
+ });
617
+
618
+ const fadeKeys = {
619
+ ...positionKeys,
620
+ id: "#title-to-1-visual",
621
+ propertyGroup: "visual",
622
+ keyframes: { keyframes: [{ percentage: 0, properties: { opacity: 0 } }] },
623
+ } as unknown as GsapAnimation;
624
+
625
+ it.each([
626
+ ["with auto-record off", positionKeys, false],
627
+ ["on an element whose only keyframes fade it", fadeKeys, true],
628
+ ])("hands the size to CSS %s", async (_, keyed, autoKeyframeEnabled) => {
629
+ usePlayerStore.setState({ autoKeyframeEnabled });
630
+ const commitMutation = vi.fn();
631
+ try {
632
+ const handled = await tryGsapResizeIntercept(
633
+ titleSelection(),
634
+ { width: 424, height: 237 },
635
+ [keyed],
636
+ null,
637
+ commitMutation,
638
+ );
639
+ expect(handled).toEqual({ status: "element-size" });
640
+ expect(commitMutation).not.toHaveBeenCalled();
641
+ } finally {
642
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
643
+ }
644
+ });
645
+ });
646
+
576
647
  it.each([
577
648
  ["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
578
649
  ["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],