@hyperframes/studio 0.8.134 → 0.8.136

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 (54) hide show
  1. package/dist/assets/{hyperframes-player-Bro1gj0Z.js → hyperframes-player-ukvUFqdg.js} +1 -1
  2. package/dist/assets/{index-DnVGIbEC.js → index-BY_myArg.js} +1 -1
  3. package/dist/assets/{index-wnXyQjW-.js → index-C-pD9_xY.js} +185 -184
  4. package/dist/assets/{index-BwNDTmAD.js → index-CRRgIxsS.js} +1 -1
  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 +227 -88
  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/useDomEditNudge.test.tsx +48 -27
  28. package/src/components/ui/cn.ts +2 -1
  29. package/src/contexts/DomEditContext.tsx +3 -1
  30. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  31. package/src/hooks/gsapRuntimePatch.ts +4 -0
  32. package/src/hooks/gsapShared.test.ts +11 -0
  33. package/src/hooks/gsapShared.ts +1 -1
  34. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  35. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  38. package/src/hooks/useDomGeometryCommits.ts +6 -1
  39. package/src/hooks/useGestureCommit.test.tsx +89 -0
  40. package/src/hooks/useGestureCommit.ts +26 -9
  41. package/src/icons/SystemIcons.tsx +6 -0
  42. package/src/layersPanelExport.test.tsx +45 -4
  43. package/src/utils/gsapKeyframeEases.ts +2 -0
  44. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  45. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  46. package/src/utils/gsapLiveRetime.ts +39 -31
  47. package/src/utils/gsapSoftReload.test.ts +98 -14
  48. package/src/utils/gsapSoftReload.ts +14 -2
  49. package/src/utils/gsapUndoRestore.test.ts +21 -2
  50. package/src/utils/softReloadTargets.ts +56 -0
  51. package/src/utils/studioPendingEdits.test.ts +123 -0
  52. package/src/utils/studioPendingEdits.ts +27 -1
  53. package/dist/chunk-5VSXRGIX.js.map +0 -1
  54. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
@@ -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
+ }
@@ -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,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);
@@ -10,6 +10,7 @@ import { mountReactHarness } from "./domSelectionTestHarness";
10
10
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
11
11
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
12
12
  import { useGestureCommit } from "./useGestureCommit";
13
+ import { xAtTime } from "./gsapPlaybackTestHarness";
13
14
 
14
15
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
16
 
@@ -243,6 +244,94 @@ it.each([
243
244
  },
244
245
  );
245
246
 
247
+ const FROM_0_TO_100 = [
248
+ { percentage: 0, properties: { x: 0 } },
249
+ { percentage: 100, properties: { x: 100 } },
250
+ ];
251
+
252
+ function positionTween(
253
+ duration: number,
254
+ eases: { ease?: string; easeEach?: string } = {},
255
+ keyframes = FROM_0_TO_100,
256
+ ) {
257
+ return {
258
+ id: "card-position",
259
+ targetSelector: "#card",
260
+ propertyGroup: "position",
261
+ method: "to",
262
+ properties: { x: 0, y: 0 },
263
+ resolvedStart: 0,
264
+ position: 0,
265
+ duration,
266
+ ...(eases.ease ? { ease: eases.ease } : {}),
267
+ keyframes: {
268
+ ...(eases.easeEach ? { easeEach: eases.easeEach } : {}),
269
+ keyframes,
270
+ },
271
+ } as unknown as GsapAnimation;
272
+ }
273
+
274
+ /** Records the mocked 1 s gesture (x 0, 50, 100) from 0 s over `tween`; resolves to the merge mutation. */
275
+ async function recordMergeInto(tween: GsapAnimation) {
276
+ const writer = vi.fn(
277
+ async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
278
+ options.onResult?.({ ok: true, changed: true });
279
+ },
280
+ );
281
+ const hook = mountRecording(writer, [tween]);
282
+ act(() => hook().handleToggleRecording("button"));
283
+ await act(async () => {
284
+ hook().handleToggleRecording();
285
+ await vi.waitFor(() => expect(writer).toHaveBeenCalled());
286
+ });
287
+ expect(writer.mock.calls[0]![1]).toMatchObject({ label: "Gesture recording (merge)" });
288
+ return writer.mock.calls[0]![0] as unknown as Parameters<typeof xAtTime>[0] & {
289
+ type: string;
290
+ ease?: string;
291
+ easeEach?: string;
292
+ };
293
+ }
294
+
295
+ it("keeps the tween's eases when a recording merges into it", async () => {
296
+ const merge = await recordMergeInto(
297
+ positionTween(2, { ease: "back.out", easeEach: "power2.out" }),
298
+ );
299
+ expect(merge).toMatchObject({
300
+ type: "replace-with-keyframes",
301
+ ease: "back.out",
302
+ easeEach: "power2.out",
303
+ });
304
+ // Recorded segments the fitter left unset stay constant speed instead of taking easeEach.
305
+ expect(merge.keyframes.map((kf) => kf.ease)).toEqual([undefined, "none", "none", undefined]);
306
+ });
307
+
308
+ it("plays a recording merged into an eased tween at the times it was recorded", async () => {
309
+ const merge = await recordMergeInto(positionTween(3, { ease: "power2.out" }));
310
+ expect(xAtTime(merge, 0.5)).toBeCloseTo(50, 0);
311
+ expect(xAtTime(merge, 1)).toBeCloseTo(100, 0);
312
+ });
313
+
314
+ it("keeps recorded keyframes apart over the tail of a power4.out tween", async () => {
315
+ usePlayerStore.setState({ currentTime: 9 });
316
+ const merge = await recordMergeInto(positionTween(10, { ease: "power4.out" }));
317
+ expect(xAtTime(merge, 9.5)).toBeCloseTo(50, 0);
318
+ });
319
+
320
+ it("keeps a keyframe playing at 8.5 s when a recording covers 9-10 s of a power2.out tween", async () => {
321
+ usePlayerStore.setState({ currentTime: 9 });
322
+ const at8_5s = { percentage: 99.6625, properties: { x: 80 } };
323
+ const merge = await recordMergeInto(
324
+ positionTween(10, { ease: "power2.out" }, [FROM_0_TO_100[0]!, at8_5s, FROM_0_TO_100[1]!]),
325
+ );
326
+ expect(merge.keyframes).toContainEqual(at8_5s);
327
+ expect(xAtTime(merge, 8.5)).toBeCloseTo(80, 0);
328
+ });
329
+
330
+ it("writes merged keyframe percentages rounded, not as float noise", async () => {
331
+ const merge = await recordMergeInto(positionTween(3));
332
+ expect(merge.keyframes.map((kf) => kf.percentage)).toEqual([0, 16.667, 33.333, 100]);
333
+ });
334
+
246
335
  it("keeps a pending recording isolated until its writer settles", async () => {
247
336
  let release!: () => void;
248
337
  const pending = new Promise<void>((resolve) => {
@@ -16,8 +16,15 @@ import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
16
16
  import { observeGsapGesture } from "./gsapGestureOutcome";
17
17
  import { roundTo3 } from "../utils/rounding";
18
18
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
19
- import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
19
+ import {
20
+ isInstantHold,
21
+ idSelector,
22
+ keyframeEases,
23
+ writeTargetSelector,
24
+ tweenTargetsElement,
25
+ } from "./gsapShared";
20
26
  import { useStableHandlers } from "./useStableHandlers";
27
+ import { progressAtTime, runEaseOf, timeAtProgress, warpsTime } from "../utils/gsapKeyframeEases";
21
28
 
22
29
  type RecordedKeyframe = {
23
30
  percentage: number;
@@ -269,25 +276,34 @@ export function useGestureCommit({
269
276
 
270
277
  if (overlaps) {
271
278
  const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
272
- const rangeStartPct =
279
+ const startTimePct =
273
280
  tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
274
- const rangeEndPct =
281
+ const endTimePct =
275
282
  tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
283
+ const runEase = runEaseOf(existingPositionTween);
284
+ const roundPct = warpsTime(runEase) ? (pct: number) => pct : roundTo3;
276
285
 
277
286
  const preserved = existingKfs
278
- .filter(
279
- (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
280
- )
287
+ .filter((kf) => {
288
+ const playsAt = timeAtProgress(runEase, kf.percentage);
289
+ return playsAt < startTimePct - 0.5 || playsAt > endTimePct + 0.5;
290
+ })
281
291
  .map((kf) => ({
282
292
  percentage: kf.percentage,
283
293
  properties: kf.properties,
284
294
  ...(kf.ease ? { ease: kf.ease } : {}),
285
295
  }));
286
296
 
287
- const mapped = keyframes.map((kf) => ({
288
- percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
297
+ const constantSpeed = { ease: "none" };
298
+ const mapped = keyframes.map((kf, index) => ({
299
+ percentage: roundPct(
300
+ progressAtTime(
301
+ runEase,
302
+ startTimePct + (kf.percentage / 100) * (endTimePct - startTimePct),
303
+ ),
304
+ ),
289
305
  properties: kf.properties,
290
- ...(kf.ease ? { ease: kf.ease } : {}),
306
+ ...(kf.ease ? { ease: kf.ease } : index > 0 ? constantSpeed : {}),
291
307
  }));
292
308
 
293
309
  const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
@@ -303,6 +319,7 @@ export function useGestureCommit({
303
319
  : tweenStart,
304
320
  duration: tweenDur,
305
321
  keyframes: merged,
322
+ ...keyframeEases(existingPositionTween),
306
323
  },
307
324
  { label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
308
325
  );
@@ -24,6 +24,9 @@ import {
24
24
  Link as PhLink,
25
25
  Eyedropper as PhEyedropper,
26
26
  Trash as PhTrash,
27
+ Image as PhImage,
28
+ BezierCurve,
29
+ FolderSimple,
27
30
  } from "@phosphor-icons/react";
28
31
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
29
32
 
@@ -75,3 +78,6 @@ export const Scissors = makeIcon(PhScissors);
75
78
  export const Link = makeIcon(PhLink);
76
79
  export const Eyedropper = makeIcon(PhEyedropper);
77
80
  export const Trash = makeIcon(PhTrash);
81
+ export const ImageIcon = makeIcon(PhImage);
82
+ export const Vector = makeIcon(BezierCurve);
83
+ export const Folder = makeIcon(FolderSimple);
@@ -29,12 +29,17 @@ afterEach(() => {
29
29
  });
30
30
 
31
31
  /** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
32
- async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
32
+ const TWO_BOXES =
33
+ '<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
34
+
35
+ async function mountPanel(
36
+ wrap: (panel: ReactNode) => ReactNode = (panel) => panel,
37
+ picture = TWO_BOXES,
38
+ ) {
33
39
  const iframe = document.createElement("iframe");
34
40
  document.body.appendChild(iframe);
35
41
  const doc = iframe.contentDocument!;
36
- doc.body.innerHTML =
37
- '<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
42
+ doc.body.innerHTML = picture;
38
43
  usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
39
44
  const session = {
40
45
  domEditSelection: null,
@@ -84,7 +89,7 @@ async function dragBackRowToFront() {
84
89
  it("lists, selects and reorders the host's preview layers through the host's session", async () => {
85
90
  const { doc, session, root } = await mountPanel();
86
91
 
87
- expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
92
+ expect(rowLabels()).toEqual(["Title", "Bg"]);
88
93
 
89
94
  const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
90
95
  await act(async () => rows[0]!.click());
@@ -113,3 +118,39 @@ it("mirrors the reorder into the timeline rows through the host's move handler",
113
118
  expect(bgTrack()).not.toBe(1);
114
119
  await act(async () => root.unmount());
115
120
  });
121
+
122
+ it("puts a caret only on a group's row and starts each child's icon where its parent's label starts", async () => {
123
+ const { root } = await mountPanel(
124
+ undefined,
125
+ '<div data-composition-id="main"><h1 id="title" style="z-index:2">Hi</h1>' +
126
+ '<div id="intro" data-hf-group="Intro" style="z-index:1"><img id="logo">' +
127
+ '<div id="inner" data-hf-group="Inner"><img id="mark"></div></div>' +
128
+ '<div id="wrap" style="z-index:0"><div id="box"></div></div></div>',
129
+ );
130
+ const rows = [...document.querySelectorAll<HTMLElement>("[data-layer-index]")];
131
+ const byLabel = (label: string) => rows.find((row) => row.textContent?.startsWith(label))!;
132
+ const kind = (label: string) =>
133
+ byLabel(label).querySelector("[data-layer-kind]")?.getAttribute("data-layer-kind");
134
+ expect(["Title", "Intro", "Logo", "Inner", "Mark"].map(kind)).toEqual([
135
+ "text",
136
+ "group",
137
+ "image",
138
+ "group",
139
+ "image",
140
+ ]);
141
+ expect([kind("Wrap"), kind("Box")]).toEqual(["group", "shape"]);
142
+ expect(byLabel("Wrap").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
143
+ const icons = ["Title", "Intro", "Logo", "Box"].map(
144
+ (l) => byLabel(l).querySelector("[data-layer-kind]")!.innerHTML,
145
+ );
146
+ expect(new Set(icons).size).toBe(4);
147
+ for (const row of rows.filter((r) => r.title)) {
148
+ expect(row.querySelector<HTMLElement>("[data-layer-kind]")!.title).toContain(row.title);
149
+ }
150
+ expect(byLabel("Title").firstElementChild?.hasAttribute("data-layer-kind")).toBe(true);
151
+ expect(byLabel("Intro").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
152
+ expect(
153
+ ["Title", "Intro", "Logo", "Inner", "Mark"].map((l) => byLabel(l).style.paddingLeft),
154
+ ).toEqual(["8px", "8px", "56px", "56px", "104px"]);
155
+ await act(async () => root.unmount());
156
+ });
@@ -50,6 +50,8 @@ function timingEase(runEase: string | undefined): ((progress: number) => number)
50
50
  return timingEases.get(runEase) ?? null;
51
51
  }
52
52
 
53
+ export const warpsTime = (runEase: string | undefined) => timingEase(runEase) !== null;
54
+
53
55
  /** The keyframe progress (0-100) GSAP shows at `timePercentage` of a tween with this run ease. */
54
56
  export function progressAtTime(runEase: string | undefined, timePercentage: number): number {
55
57
  const run = timingEase(runEase);
@@ -0,0 +1,39 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const reads = vi.hoisted(() => ({ shape: [] as string[] }));
4
+
5
+ vi.mock("@hyperframes/parsers/gsap-parser-acorn", async (importOriginal) => {
6
+ const real = await importOriginal<typeof import("@hyperframes/parsers/gsap-parser-acorn")>();
7
+ return {
8
+ ...real,
9
+ scriptShape: (code: string, mask?: boolean) => (
10
+ reads.shape.push(code), real.scriptShape(code, mask)
11
+ ),
12
+ };
13
+ });
14
+
15
+ const { planLiveRetime } = await import("./gsapLiveRetime");
16
+
17
+ const at = (start: number) =>
18
+ `var tl = gsap.timeline({ paused: true });\ntl.to("#a", { x: 1, duration: 1 }, ${start});\nwindow.__timelines["t"] = tl;`;
19
+
20
+ describe("planLiveRetime", () => {
21
+ beforeEach(() => {
22
+ reads.shape.length = 0;
23
+ });
24
+
25
+ it("reads each script's shape once across two drags in a row", () => {
26
+ expect(planLiveRetime(at(0), at(1)).kind).toBe("retime");
27
+ expect(planLiveRetime(at(1), at(2)).kind).toBe("retime");
28
+
29
+ expect(reads.shape).toEqual([at(0), at(1), at(2)]);
30
+ });
31
+
32
+ it("keeps the live script's shape while saves land before the preview catches up", () => {
33
+ planLiveRetime(at(10), at(11));
34
+ planLiveRetime(at(10), at(12));
35
+ planLiveRetime(at(10), at(13));
36
+
37
+ expect(reads.shape).toEqual([at(10), at(11), at(12), at(13)]);
38
+ });
39
+ });