@hyperframes/studio 0.8.126 → 0.8.127

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 (29) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-nvLF29ZH.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-COhbL1xq.js} +171 -171
  4. package/dist/assets/{index-Cll7uTNy.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +1008 -893
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  10. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  11. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  12. package/src/components/editor/motionPathCommit.ts +58 -0
  13. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  14. package/src/components/editor/motionPathGeometry.ts +4 -2
  15. package/src/hooks/gsapDragCommit.ts +33 -1
  16. package/src/hooks/gsapEditOutcome.ts +27 -2
  17. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  18. package/src/hooks/gsapParsedTween.ts +23 -2
  19. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  20. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  21. package/src/hooks/gsapShared.test.ts +1 -0
  22. package/src/hooks/gsapShared.ts +6 -2
  23. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  24. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  25. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  26. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  27. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  28. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  29. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
@@ -1,6 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import { elementTargets } from "../utils/elementGsap";
3
3
  import { resolveTweenStart } from "../utils/globalTimeCompiler";
4
+ import { KEYFRAME_PCT_MATCH } from "./gsapShared";
4
5
  import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
5
6
  import type { ReadTween } from "./gsapRuntimeKeyframes";
6
7
 
@@ -23,7 +24,7 @@ interface ParsedTween {
23
24
  seek?: (time: number, suppressEvents?: boolean) => unknown;
24
25
  getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
25
26
  };
26
- timeline?: { getChildren?: () => ParsedTween[] };
27
+ timeline?: { getChildren?: () => ParsedTween[]; duration?: () => number };
27
28
  targets?: () => unknown[];
28
29
  startTime?: () => number;
29
30
  duration?: () => number;
@@ -246,7 +247,7 @@ const BUILT_IN_EASES = [
246
247
  export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
247
248
  const data = anim.keyframes;
248
249
  const parts = tween?.timeline?.getChildren?.() ?? [];
249
- const total = tween?.duration?.() ?? 0;
250
+ const total = tween?.timeline?.duration?.() ?? 0;
250
251
  if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
251
252
  return anim;
252
253
  const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
@@ -257,3 +258,23 @@ export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | nul
257
258
  }));
258
259
  return { ...anim, keyframes: { ...data, keyframes } };
259
260
  }
261
+
262
+ /** The keyframe nearest `pct` within {@link KEYFRAME_PCT_MATCH}, or -1: two steps can sit under 1% apart. */
263
+ export function nearestKeyframeIndex(keyframes: { percentage: number }[], pct: number): number {
264
+ let best = -1;
265
+ keyframes.forEach((kf, i) => {
266
+ const off = Math.abs(kf.percentage - pct);
267
+ if (
268
+ off <= KEYFRAME_PCT_MATCH &&
269
+ (best < 0 || off < Math.abs(keyframes[best]!.percentage - pct))
270
+ )
271
+ best = i;
272
+ });
273
+ return best;
274
+ }
275
+
276
+ export function exactKeyframePct(anim: GsapAnimation, tween: ParsedTween | null, pct: number) {
277
+ const authored = anim.keyframes?.keyframes ?? [];
278
+ const i = nearestKeyframeIndex(authored, pct);
279
+ return withExactStepTimes(anim, tween).keyframes?.keyframes[i]?.percentage ?? pct;
280
+ }
@@ -23,6 +23,7 @@ import {
23
23
  computeCurrentPercentage,
24
24
  findExistingPositionWrite,
25
25
  findRotationSetAnimation,
26
+ stepListBlock,
26
27
  } from "./gsapDragCommit";
27
28
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
28
29
  import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -218,7 +219,12 @@ async function planDrag(
218
219
  if (!posAnim) {
219
220
  return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
220
221
  }
221
- if (route === "whole-path") return { status: "persisted" };
222
+ if (route === "whole-path") {
223
+ const step = stepListBlock(posAnim);
224
+ return step
225
+ ? { status: "blocked", reason: "keyframes-uneditable", detail: step }
226
+ : { status: "persisted" };
227
+ }
222
228
  const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
223
229
  return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
224
230
  }
@@ -43,7 +43,7 @@ export interface RuntimeTimeline {
43
43
  to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
44
44
  }
45
45
 
46
- type Pct = { percentage: number; properties: Record<string, number | string> };
46
+ type Pct = { percentage: number; properties: Record<string, number | string>; step?: number };
47
47
  export type ReadTween = {
48
48
  keyframes: Pct[];
49
49
  easeEach?: string;
@@ -64,6 +64,7 @@ describe("parsePercentageKeyframes", () => {
64
64
  { x: 1480, y: 160 },
65
65
  ] as unknown as Record<string, unknown>);
66
66
  expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
67
+ expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
67
68
  expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
68
69
  });
69
70
 
@@ -399,7 +399,11 @@ export function queryIframeElement(
399
399
  // ── Keyframe parsing ──────────────────────────────────────────────────────────
400
400
 
401
401
  export interface ParsedPercentageKeyframes {
402
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
402
+ keyframes: Array<{
403
+ percentage: number;
404
+ properties: Record<string, number | string>;
405
+ step?: number;
406
+ }>;
403
407
  easeEach?: string;
404
408
  }
405
409
 
@@ -442,7 +446,7 @@ export function parsePercentageKeyframes(
442
446
  if (!entry || typeof entry !== "object") return;
443
447
  const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
444
448
  const properties = collectAnimatableKeyframeProperties(entry);
445
- if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
449
+ if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties, step: i });
446
450
  });
447
451
  return keyframes.length > 0 ? { keyframes } : null;
448
452
  }
@@ -2,11 +2,13 @@
2
2
  import { gsap } from "gsap";
3
3
  import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
4
4
  import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
5
- import { afterEach, expect, it } from "vitest";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween";
9
- import { planValueEdit } from "./gsapValueAtPlayhead";
9
+ import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
10
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
11
+ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
10
12
 
11
13
  /** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
12
14
  function play(script: string, at: number) {
@@ -18,14 +20,20 @@ function play(script: string, at: number) {
18
20
  }
19
21
 
20
22
  /** Drags `#x` to `x` at `at`, writes the plan into the script, and replays the written file there. */
21
- function dragAndReplay(script: string, x: number, at: number) {
23
+ function dragAndReplay(
24
+ script: string,
25
+ x: number,
26
+ at: number,
27
+ selectedPct: number | null = null,
28
+ { y, backfill }: { y?: number; backfill?: Record<string, number> } = {},
29
+ ) {
22
30
  const box = document.body.appendChild(document.createElement("div"));
23
31
  box.id = "x";
24
32
  const { timeline, iframe } = play(script, at);
25
- usePlayerStore.setState({ currentTime: at, activeKeyframePct: null });
33
+ usePlayerStore.setState({ currentTime: at, activeKeyframePct: selectedPct });
26
34
  const anim = parseGsapScriptAcorn(script).animations[0]!;
27
35
  const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
28
- const plan = planValueEdit(selection, anim, { x }, iframe);
36
+ const plan = planValueEdit(selection, anim, { x, ...(y != null && { y }) }, iframe, { backfill });
29
37
  timeline.kill();
30
38
  if (!plan.ok) return { plan };
31
39
  const written = replaceTweenWithKeyframesInScript(script, anim.id, plan.mutation)!;
@@ -54,6 +62,64 @@ it("writes a delayed linear tween so GSAP shows the new value at the playhead, n
54
62
  expect(shown).toBe(200);
55
63
  });
56
64
 
65
+ it("reads plain array nodes where the lane puts them, though GSAP fills in step durations", () => {
66
+ const source = script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3");
67
+ document.body.appendChild(document.createElement("div")).id = "x";
68
+ const { timeline, iframe } = play(source, 0);
69
+ const frame = { ...iframe, contentDocument: document } as HTMLIFrameElement;
70
+ const read = readRuntimeKeyframes(frame, "#x");
71
+ timeline.kill();
72
+ const parsed = parseGsapScriptAcorn(source).animations[0]!.keyframes!.keyframes;
73
+ expect(read?.keyframes.map((kf) => kf.percentage)).toEqual(parsed.map((kf) => kf.percentage));
74
+ });
75
+
76
+ // Three default 0.5 s steps stretched over 3 s: GSAP reaches the middle one at 2 s, where the parse
77
+ // places it at 50%.
78
+ it.each([
79
+ ["at the playhead, timing array steps on their own timeline", undefined],
80
+ ["selected in the lane, in place", 50],
81
+ ])("changes the middle array keyframe %s", (_, selectedPct) => {
82
+ const { plan, shown } = dragAndReplay(
83
+ script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"),
84
+ 130,
85
+ 2,
86
+ selectedPct,
87
+ );
88
+ expect(plan.ok && plan.mutation.keyframes.map((kf) => kf.properties.x)).toEqual([60, 130, 180]);
89
+ expect(shown).toBeCloseTo(130, 2);
90
+ });
91
+
92
+ it("keeps an array step that leaves a channel out holding it, as GSAP played it", () => {
93
+ const hold = "keyframes: [{ x: 60, duration: 1 }, { duration: 1 }, { x: 180, duration: 1 }]";
94
+ const { written } = dragAndReplay(script(hold), 97, 1);
95
+ const replay = play(written!, 2);
96
+ expect(gsap.getProperty("#x", "x")).toBe(97);
97
+ replay.timeline.kill();
98
+ });
99
+
100
+ it("holds a newly animated channel at rest through a step list's opening pause", () => {
101
+ const pauseFirst =
102
+ "keyframes: [{ duration: 1 }, { x: 60, duration: 1 }, { x: 180, duration: 1 }]";
103
+ const { written } = dragAndReplay(script(pauseFirst), 37, 2, null, { y: 11, backfill: { y: 0 } });
104
+ for (const [t, y] of [
105
+ [0.5, 0],
106
+ [2, 11],
107
+ ]) {
108
+ gsap.set("#x", { clearProps: "all" }); // a reloaded preview starts the tween from rest
109
+ const replay = play(written!, t!);
110
+ expect(gsap.getProperty("#x", "y")).toBeCloseTo(y!, 2);
111
+ replay.timeline.kill();
112
+ }
113
+ });
114
+
115
+ it("holds a channel at its start until the array step that first animates it", () => {
116
+ const late = "keyframes: [{ x: 60 }, { x: 120, y: 50 }], duration: 2";
117
+ const { written } = dragAndReplay(script(late), 200, 2);
118
+ const replay = play(written!, 1);
119
+ expect(gsap.getProperty("#x", "y")).toBe(0);
120
+ replay.timeline.kill();
121
+ });
122
+
57
123
  it("keeps GSAP's default ease, by name, for a tween that authors none", () => {
58
124
  const { plan } = dragAndReplay(script("duration: 1, x: 100"), 60, 0.5);
59
125
  expect(plan.ok && plan.mutation.easeEach).toBe("power1.out");
@@ -177,3 +243,39 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d
177
243
  expect(plan.ok).toBe(true);
178
244
  expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
179
245
  });
246
+
247
+ it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => {
248
+ const flagged = script(
249
+ "keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'",
250
+ );
251
+ const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
252
+ const xAt = (src: string, t: number) => {
253
+ gsap.set(box, { clearProps: "all" });
254
+ const replay = play(src, t);
255
+ const x = gsap.getProperty(box, "x");
256
+ replay.timeline.kill();
257
+ return x;
258
+ };
259
+ // The flag plays the first step from 60 back to the start, so a list without it is another animation.
260
+ expect(xAt(flagged, 0.25)).not.toBe(xAt(flagged.replace(", runBackwards: true", ""), 0.25));
261
+ const { timeline, iframe } = play(flagged, 1.5);
262
+ usePlayerStore.setState({ currentTime: 1.5, activeKeyframePct: null });
263
+ const anim = parseGsapScriptAcorn(flagged).animations[0]!;
264
+ const commitMutation = vi.fn(async () => {});
265
+ const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
266
+
267
+ const edit = commitValueAtPlayhead(
268
+ selection,
269
+ anim,
270
+ { x: 90 },
271
+ iframe,
272
+ { commitMutation },
273
+ {
274
+ label: "Move",
275
+ },
276
+ );
277
+
278
+ await expect(edit).rejects.toBeInstanceOf(GsapEditBlockedError);
279
+ timeline.kill();
280
+ expect(commitMutation).not.toHaveBeenCalled();
281
+ });
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, 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";
6
- import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
7
- import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
6
+ import { GSAP_EDIT_BLOCK_COPY, GsapEditBlockedError } from "./gsapEditOutcome";
7
+ import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
8
8
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
9
9
  import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
10
10
  import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
@@ -166,31 +166,12 @@ describe("a move on a GSAP-animated layer, at the playhead", () => {
166
166
  });
167
167
 
168
168
  it("adds a keyframe between two steps of a keyframes array", async () => {
169
- const keys = tween({
170
- id: "#box-to-0-position",
171
- method: "to",
172
- properties: {},
173
- resolvedStart: 0,
174
- duration: 3,
175
- keyframes: {
176
- format: "object-array",
177
- keyframes: [
178
- // The parse rounds the step to 66.7%; GSAP times it at 2 s of 3.
179
- { percentage: 66.7, properties: { x: 60 } },
180
- { percentage: 100, properties: { x: 120 } },
181
- ],
182
- },
183
- });
184
- const steps = [
185
- { startTime: () => 0, duration: () => 2 },
186
- { startTime: () => 2, duration: () => 1 },
187
- ];
188
- const live = liveTween(
189
- el,
190
- { start: 0, duration: 3, vars: { keyframes: [] } },
191
- { parts: steps },
192
- );
193
- const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 });
169
+ // The parse rounds the first step to 66.7%; GSAP times it at 2 s of 3.
170
+ const { keys, live } = boxSteps([
171
+ [2, { x: 60 }],
172
+ [1, { x: 120 }],
173
+ ]);
174
+ const { mutations } = await drag([keys], [live(el)], { time: 1, base: [30, 0], dx: 10 });
194
175
  expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]);
195
176
  // An array step eases linearly unless it says otherwise; percentage keyframes would not.
196
177
  expect(mutations[0].keyframes).toEqual([
@@ -527,6 +508,30 @@ describe("planValueAtPlayhead", () => {
527
508
  ]);
528
509
  });
529
510
 
511
+ it("refuses a step that leaves a channel out when GSAP's start for it is unknown", () => {
512
+ const anim = kf([
513
+ { percentage: 50, properties: {} },
514
+ { percentage: 100, properties: { x: 20 } },
515
+ ]);
516
+ const steps = { ...anim, keyframes: { ...anim.keyframes!, format: "object-array" as const } };
517
+ expect(plan({ anim: steps, at: { percentage: 100 }, values: { x: 5 } })).toEqual({
518
+ ok: false,
519
+ reason: "implicit-end-unknown",
520
+ });
521
+ });
522
+
523
+ it("changes the nearer of two keyframes under 1% apart", () => {
524
+ const anim = kf([
525
+ { percentage: 50, properties: { x: 100 } },
526
+ { percentage: 50.5, properties: { x: 100 } },
527
+ { percentage: 100, properties: { x: 100 } },
528
+ ]);
529
+ const result = plan({ anim, at: { percentage: 50.5 }, values: { x: 150 } });
530
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
531
+ 100, 150, 100,
532
+ ]);
533
+ });
534
+
530
535
  describe("keyframes eased as a whole", () => {
531
536
  const eased = {
532
537
  ...kf([
@@ -563,6 +568,29 @@ describe("planValueAtPlayhead", () => {
563
568
  });
564
569
  });
565
570
 
571
+ it("changes an inner keyframe picked by its percentage, which the ease does not move", () => {
572
+ const anim = {
573
+ ...kf([
574
+ { percentage: 0, properties: { x: 0 } },
575
+ { percentage: 50, properties: { x: 100 } },
576
+ { percentage: 100, properties: { x: 300 } },
577
+ ]),
578
+ ease: "power2.out",
579
+ };
580
+ const result = plan({ anim, at: { percentage: 50 }, values: { x: 5 } });
581
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
582
+ 0, 5, 300,
583
+ ]);
584
+ expect(result.ok && result.mutation.ease).toBe("power2.out");
585
+ });
586
+
587
+ it("refuses to add a keyframe at a percentage, whose time the ease would move", () => {
588
+ expect(plan({ anim: eased, at: { percentage: 40 }, values: { x: 5 } })).toEqual({
589
+ ok: false,
590
+ reason: "eased-keyframes",
591
+ });
592
+ });
593
+
566
594
  it("refuses to add a keyframe, whose time the ease would move", () => {
567
595
  expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
568
596
  ok: false,
@@ -593,3 +621,77 @@ it("refuses a tween whose selector also animates a sibling, before it plans anyt
593
621
  });
594
622
  sibling.remove();
595
623
  });
624
+
625
+ describe("a keyframes array the percentage rewrite would change", () => {
626
+ const steps = (
627
+ properties: Array<Record<string, number | string>>,
628
+ edit: Partial<PlayheadEdit> = {},
629
+ ) =>
630
+ planValueAtPlayhead({
631
+ anim: tween({
632
+ method: "to",
633
+ properties: {},
634
+ resolvedStart: 0,
635
+ duration: 3,
636
+ keyframes: {
637
+ format: "object-array",
638
+ keyframes: properties.map((p, i) => ({ percentage: ((i + 1) / 3) * 100, properties: p })),
639
+ },
640
+ }),
641
+ at: { percentage: 100 },
642
+ values: { x: 50 },
643
+ implicitEndValue: () => 0,
644
+ ...edit,
645
+ });
646
+
647
+ it.each([
648
+ ["a step delay", { x: 100, delay: 0.5 }, "array-step-delay", "its own delay"],
649
+ ["a step callback", { x: 100, onComplete: "__raw:done" }, "array-step-callback", "runs code"],
650
+ [
651
+ "an unlisted callback",
652
+ { x: 100, onInterrupt: "__raw:stop" },
653
+ "array-step-callback",
654
+ "runs code",
655
+ ],
656
+ [
657
+ "a step repeat",
658
+ { x: 100, repeat: 2 },
659
+ "array-step-config",
660
+ "tween setting (like repeat or stagger)",
661
+ ],
662
+ ["a computed value", { x: "__raw:offset()" }, "array-step-computed", "comes from code"],
663
+ ["a relative value", { x: "-=40" }, "array-step-relative", '"+=40"'],
664
+ ["a random value", { x: "random(0, 300)" }, "array-step-random", "random()"],
665
+ [
666
+ "a random value inside a string",
667
+ { filter: "blur(random(1, 9)px)" },
668
+ "array-step-random",
669
+ "random()",
670
+ ],
671
+ ] as const)("refuses %s and says so", (_, step, reason, says) => {
672
+ expect(steps([{ x: 60 }, step, { x: 180 }])).toEqual({ ok: false, reason });
673
+ const message = new GsapEditBlockedError("keyframes-uneditable", reason).message;
674
+ expect(message).toContain(says);
675
+ expect(message).toContain("Code tab");
676
+ });
677
+
678
+ it("rewrites absolute values, colours included", () => {
679
+ const result = steps([{ x: 60, color: "#fff" }, { x: 120, color: "rgb(0, 0, 0)" }, { x: 180 }]);
680
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
681
+ 60, 120, 50,
682
+ ]);
683
+ });
684
+
685
+ it("holds a pause step at the dropped value of a channel the drop starts animating", () => {
686
+ const result = steps([{ x: 60 }, {}, { x: 180 }], {
687
+ at: { percentage: 100 / 3 },
688
+ values: { x: 37, y: 11 },
689
+ backfill: { y: 0 },
690
+ });
691
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties)).toEqual([
692
+ { x: 37, y: 11 },
693
+ { x: 37, y: 11 },
694
+ { x: 180, y: 0 },
695
+ ]);
696
+ });
697
+ });
@@ -5,11 +5,14 @@ import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeComp
5
5
  import { roundTo3 } from "../utils/rounding";
6
6
  import {
7
7
  materializeIfDynamic,
8
+ stepListBlock,
8
9
  parkPlayheadOnKeyframe,
9
10
  type GsapDragCommitCallbacks,
10
11
  } from "./gsapDragCommit";
11
12
  import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
12
13
  import {
14
+ exactKeyframePct,
15
+ nearestKeyframeIndex,
13
16
  findParsedTween,
14
17
  parsedImplicitEndValue,
15
18
  parsedTweenEase,
@@ -86,6 +89,8 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
86
89
  if (data) {
87
90
  if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
88
91
  const arrayStep = data.format === "object-array";
92
+ const blocked = stepListBlock(anim);
93
+ if (blocked) return { reason: blocked };
89
94
  return {
90
95
  runEase: data.ease ?? anim.ease,
91
96
  keyframes: data.keyframes.map((kf) => ({
@@ -129,7 +134,7 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
129
134
  }
130
135
 
131
136
  const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
132
- keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
137
+ keyframes[nearestKeyframeIndex(keyframes, percentage)];
133
138
 
134
139
  function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
135
140
  const hit = keyframeAt(keyframes, percentage);
@@ -164,6 +169,27 @@ function heldEnds(
164
169
  return held;
165
170
  }
166
171
 
172
+ /** An array step without a channel holds it, where a percentage keyframe would tween through it. */
173
+ function holdOmittedChannels(
174
+ steps: Keyframe[],
175
+ implicit: ImplicitEndValue,
176
+ backfilled: Record<string, number>,
177
+ ): boolean {
178
+ const props = new Set(steps.flatMap((kf) => Object.keys(kf.properties)));
179
+ for (const prop of props) {
180
+ let held: number | string | null | undefined;
181
+ for (const kf of steps) {
182
+ if (kf.properties[prop] != null) held = kf.properties[prop];
183
+ else {
184
+ held ??= backfilled[prop] ?? implicit(prop, "start");
185
+ if (held == null) return false;
186
+ kf.properties[prop] = held;
187
+ }
188
+ }
189
+ }
190
+ return true;
191
+ }
192
+
167
193
  /** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
168
194
  * and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
169
195
  // fallow-ignore-next-line complexity
@@ -176,6 +202,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
176
202
  if ("reason" in norm) return refuse(norm.reason);
177
203
  const keyframes = norm.keyframes;
178
204
  const authored = keyframes.length;
205
+ const steps = anim.keyframes?.format === "object-array" ? [...keyframes] : [];
179
206
  // GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
180
207
  if ("scaleX" in values || "scaleY" in values) {
181
208
  for (const kf of keyframes) {
@@ -184,11 +211,14 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
184
211
  }
185
212
  }
186
213
  const backfilled: Record<string, number> = {};
214
+ const pauseStepsHoldThePrevious = new Set(
215
+ steps.filter((kf) => Object.keys(kf.properties).length === 0),
216
+ );
187
217
  for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
188
218
  if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
189
219
  if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0))
190
220
  keyframes.unshift({ percentage: 0, properties: {} });
191
- for (const kf of keyframes) kf.properties[prop] = value;
221
+ for (const kf of keyframes) if (!pauseStepsHoldThePrevious.has(kf)) kf.properties[prop] = value;
192
222
  backfilled[prop] = value;
193
223
  }
194
224
  if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null)))
@@ -204,7 +234,9 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
204
234
  if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
205
235
  const at = Math.min(100, Math.max(0, pct));
206
236
  const hit = keyframeAt(keyframes, at);
207
- if (eased && hit?.percentage !== 0 && hit?.percentage !== 100) return refuse("eased-keyframes");
237
+ // The ease moves where a time lands, not a keyframe's own percentage or the tween's ends.
238
+ const onKeyframe = "percentage" in edit.at || hit?.percentage === 0 || hit?.percentage === 100;
239
+ if (eased && !(hit && onKeyframe)) return refuse("eased-keyframes");
208
240
  const next = [...keyframes]
209
241
  .sort((a, b) => a.percentage - b.percentage)
210
242
  .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
@@ -231,6 +263,8 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
231
263
  upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
232
264
  }
233
265
 
266
+ if (!holdOmittedChannels(steps, edit.implicitEndValue, backfilled))
267
+ return refuse("implicit-end-unknown");
234
268
  keyframes.sort((a, b) => a.percentage - b.percentage);
235
269
  return {
236
270
  ok: true,
@@ -263,7 +297,10 @@ export function planValueEdit(
263
297
  const timed = withExactStepTimes(anim, tween);
264
298
  return planValueAtPlayhead({
265
299
  anim: withLiveTiming(timed, tween),
266
- at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime },
300
+ at:
301
+ activeKeyframePct != null
302
+ ? { percentage: exactKeyframePct(anim, tween, activeKeyframePct) }
303
+ : { time: currentTime },
267
304
  values,
268
305
  backfill,
269
306
  holdFromStart,
@@ -286,6 +323,9 @@ export async function commitValueAtPlayhead(
286
323
  ): Promise<GsapEditOutcome> {
287
324
  await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
288
325
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
326
+ const tween = findParsedTween(iframe, selection.element, anim);
327
+ const parkAt =
328
+ activeKeyframePct == null ? null : exactKeyframePct(anim, tween, activeKeyframePct);
289
329
  const plan = planValueEdit(selection, anim, values, iframe, options);
290
330
  if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
291
331
  await callbacks.commitMutation(selection, plan.mutation, {
@@ -294,9 +334,9 @@ export async function commitValueAtPlayhead(
294
334
  beforeReload: options.beforeReload,
295
335
  ...(plan.added && { keyframeAction: "add" as const }),
296
336
  });
297
- if (activeKeyframePct != null) {
337
+ if (parkAt != null) {
298
338
  setActiveKeyframePct(null);
299
- parkPlayheadOnKeyframe(anim, activeKeyframePct);
339
+ parkPlayheadOnKeyframe(anim, parkAt);
300
340
  }
301
341
  return { status: "persisted" };
302
342
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
4
+ import { commitWholePathOffset, type GsapDragCommitCallbacks } from "./gsapDragCommit";
5
5
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
6
6
 
7
7
  // Regression (#1808): with auto-keyframe recording off, a manual edit on an
@@ -203,3 +203,74 @@ describe("commitWholePropertyOffset", () => {
203
203
  expect(mutations[0]).toMatchObject({ easeEach: "none" });
204
204
  });
205
205
  });
206
+
207
+ it.each([
208
+ [{ rotation: "+=40" }, "array-step-relative"],
209
+ [{ rotation: 40, onComplete: "__raw:done" }, "array-step-callback"],
210
+ [{ rotation: "random(0, 90)" }, "array-step-random"],
211
+ ])("refuses to rewrite a step list holding %o and writes nothing", async (last, detail) => {
212
+ const steps = [{ rotation: 10 }, last].map((properties, i) => ({
213
+ percentage: (i + 1) * 50,
214
+ properties,
215
+ }));
216
+ const anim = {
217
+ id: "#box-steps",
218
+ targetSelector: "#box",
219
+ method: "to",
220
+ resolvedStart: 0,
221
+ duration: 2,
222
+ keyframes: { format: "object-array", keyframes: steps },
223
+ } as unknown as GsapAnimation;
224
+ const { mutations, callbacks } = recordingCallbacks();
225
+ const rotate = commitWholePropertyOffset(
226
+ selection(),
227
+ anim,
228
+ { rotation: 50 },
229
+ 100,
230
+ null,
231
+ callbacks,
232
+ "Rotate",
233
+ );
234
+ await expect(rotate).rejects.toMatchObject({ reason: "keyframes-uneditable", detail });
235
+ expect(mutations).toEqual([]);
236
+ });
237
+
238
+ it("refuses to shift a layer's step list whose step has its own delay, writing nothing", async () => {
239
+ // No drag baseline on the element: the move starts from a zero offset.
240
+ const el = {
241
+ style: { getPropertyValue: () => "", setProperty: () => {} },
242
+ getAttribute: () => null,
243
+ removeAttribute: () => {},
244
+ getBoundingClientRect: () => ({ top: 0, left: 0 }),
245
+ };
246
+ const anim = {
247
+ id: "#box-steps",
248
+ targetSelector: "#box",
249
+ method: "to",
250
+ resolvedStart: 0,
251
+ duration: 2,
252
+ keyframes: {
253
+ format: "object-array",
254
+ keyframes: [
255
+ { percentage: 50, properties: { x: 100, delay: 0.5 } },
256
+ { percentage: 100, properties: { x: 200 } },
257
+ ],
258
+ },
259
+ } as unknown as GsapAnimation;
260
+ const { mutations, callbacks } = recordingCallbacks();
261
+ const sel = { ...selection(), element: el } as DomEditSelection;
262
+ const move = commitWholePathOffset(
263
+ sel,
264
+ anim,
265
+ { x: 40, y: 0 },
266
+ { x: 0, y: 0 },
267
+ null,
268
+ "#box",
269
+ callbacks,
270
+ );
271
+ await expect(move).rejects.toMatchObject({
272
+ reason: "keyframes-uneditable",
273
+ detail: "array-step-delay",
274
+ });
275
+ expect(mutations).toEqual([]);
276
+ });