@hyperframes/studio 0.8.126 → 0.8.128

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -0,0 +1,212 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, onTestFinished, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
8
+ import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit";
9
+
10
+ it("names a node drop for its undo entry by what it moved", () => {
11
+ const { autoKeyframeEnabled } = usePlayerStore.getState();
12
+ onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled }));
13
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
14
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe");
15
+ usePlayerStore.setState({ autoKeyframeEnabled: false });
16
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path");
17
+ expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint");
18
+ });
19
+
20
+ afterEach(() => {
21
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null });
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function box(id = "box") {
26
+ const element = document.body.appendChild(document.createElement("div"));
27
+ element.id = id;
28
+ return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection };
29
+ }
30
+
31
+ type Drop = Parameters<typeof commitNodeDrop>[0];
32
+
33
+ function drop(fields: Omit<Drop, "animId" | "commitMutation"> & { anim: GsapAnimation }) {
34
+ const commitMutation = vi.fn<CommitFn>(async () => {});
35
+ const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation });
36
+ const written = () =>
37
+ (commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map(
38
+ (kf) => kf.properties,
39
+ );
40
+ return { done, commitMutation, written };
41
+ }
42
+
43
+ it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => {
44
+ const { element, selection } = box();
45
+ // Over a CSS translate GSAP reads as y 30.
46
+ const { keys, live } = boxSteps([
47
+ [2, { x: 60 }],
48
+ [1, { x: 120 }],
49
+ ]);
50
+ usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true });
51
+
52
+ const { done, commitMutation, written } = drop({
53
+ ref: { type: "keyframe", pct: 100 },
54
+ at: { x: 150, y: 90 },
55
+ anim: keys,
56
+ selection,
57
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
58
+ });
59
+ await done;
60
+
61
+ expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes");
62
+ expect(written()).toEqual([
63
+ { x: 60, y: 30 },
64
+ { x: 150, y: 90 },
65
+ ]);
66
+ });
67
+
68
+ it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => {
69
+ const { element, selection } = box();
70
+ // Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%.
71
+ const { keys, live } = boxSteps([
72
+ [2, { x: 60, y: 30 }],
73
+ [1, { x: 120, y: 30 }],
74
+ ]);
75
+ usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null });
76
+
77
+ const { done, written } = drop({
78
+ ref: { type: "keyframe", pct: 0, step: 0 },
79
+ at: { x: 40, y: 90 },
80
+ anim: keys,
81
+ selection,
82
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
83
+ });
84
+ await done;
85
+
86
+ expect(written()).toEqual([
87
+ { x: 40, y: 90 },
88
+ { x: 120, y: 30 },
89
+ ]);
90
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(2);
91
+ });
92
+
93
+ it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => {
94
+ const [a, b] = [box("a"), box("b")];
95
+ for (const { element } of [a, b]) element.className = "card";
96
+ const shared = tween({
97
+ targetSelector: ".card",
98
+ method: "to",
99
+ properties: {},
100
+ resolvedStart: 0,
101
+ duration: 1,
102
+ keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] },
103
+ });
104
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
105
+
106
+ const { done, commitMutation } = drop({
107
+ ref: { type: "keyframe", pct: 100 },
108
+ at: { x: 5, y: 0 },
109
+ anim: shared,
110
+ selection: a.selection,
111
+ iframe: previewWith(b.element, [], { x: 0, y: 0 }),
112
+ });
113
+
114
+ await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError);
115
+ expect(commitMutation).not.toHaveBeenCalled();
116
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
117
+ });
118
+
119
+ it.each([true, false])(
120
+ "a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)",
121
+ async (autoKeyframeEnabled) => {
122
+ const { element, selection } = box();
123
+ const { keys } = boxSteps([
124
+ [1.5, { x: 100, delay: 0.5 }],
125
+ [1.5, { x: 200, delay: 0.5 }],
126
+ ]);
127
+ usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null });
128
+
129
+ const { done, commitMutation } = drop({
130
+ ref: { type: "keyframe", pct: 100 },
131
+ at: { x: 150, y: 0 },
132
+ anim: keys,
133
+ selection,
134
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
135
+ });
136
+
137
+ await expect(done).rejects.toThrow(/its own delay.*Code tab/);
138
+ expect(commitMutation).not.toHaveBeenCalled();
139
+ },
140
+ );
141
+
142
+ it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => {
143
+ const { selection } = box();
144
+ // Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends.
145
+ const { keys } = boxSteps([
146
+ [4, { x: 60, y: 0 }],
147
+ [1, { x: 120, y: 0 }],
148
+ [1, { x: 180, y: 0 }],
149
+ ]);
150
+ usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null });
151
+
152
+ const { done, written } = drop({
153
+ ref: { type: "keyframe", pct: 50, step: 1 },
154
+ at: { x: 157, y: 0 },
155
+ anim: keys,
156
+ selection,
157
+ iframe: null,
158
+ });
159
+ await done;
160
+
161
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]);
162
+ });
163
+
164
+ it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => {
165
+ const { element, selection } = box();
166
+ const keys = tween({
167
+ id: "#box-to-0-position",
168
+ method: "to",
169
+ properties: {},
170
+ resolvedStart: 0,
171
+ duration: 2,
172
+ ease: "power1.inOut",
173
+ keyframes: {
174
+ format: "percentage",
175
+ keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })),
176
+ },
177
+ });
178
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
179
+
180
+ const { done, written } = drop({
181
+ ref: { type: "keyframe", pct: 50 },
182
+ at: { x: 150, y: 0 },
183
+ anim: keys,
184
+ selection,
185
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
186
+ });
187
+ await done;
188
+
189
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]);
190
+ });
191
+
192
+ it("a drop on a short step under 1% from the one before edits the dragged step", async () => {
193
+ const { element, selection } = box();
194
+ // Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list.
195
+ const { keys, live } = boxSteps([
196
+ [5, { x: 0, y: 0 }],
197
+ [0.05, { x: 100, y: 0 }],
198
+ [4.95, { x: 100, y: 0 }],
199
+ ]);
200
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
201
+
202
+ const { done, written } = drop({
203
+ ref: { type: "keyframe", pct: 50, step: 1 },
204
+ at: { x: 150, y: 0 },
205
+ anim: keys,
206
+ selection,
207
+ iframe: previewWith(element, [live(element)], { x: 0, y: 0 }),
208
+ });
209
+ await done;
210
+
211
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]);
212
+ });
@@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview
5
5
  * GSAP source mutation routed through the (selection-bound) commit facade, which
6
6
  * handles the soft reload, undo snapshot, and save-failure feedback.
7
7
  */
8
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
9
+ import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome";
10
+ import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
11
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
12
+ import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead";
13
+ import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
14
+ import { usePlayerStore } from "../../player/store/playerStore";
15
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
16
+ import type { DomEditSelection } from "./domEditing";
8
17
  import type { MotionNodeRef } from "./motionPathGeometry";
18
+ import { selectorFor } from "./motionPathSelection";
9
19
 
10
20
  export type CommitFn = (
11
21
  mutation: Record<string, unknown>,
@@ -41,6 +51,54 @@ export function commitNode(
41
51
  );
42
52
  }
43
53
 
54
+ type NodeDrop = {
55
+ ref: MotionNodeRef;
56
+ at: { x: number; y: number };
57
+ animId: string;
58
+ anim: GsapAnimation | undefined;
59
+ selection: DomEditSelection | null;
60
+ iframe: HTMLIFrameElement | null;
61
+ commitMutation: CommitFn;
62
+ };
63
+
64
+ /** What a node drop does, for its undo entry and its failure report. */
65
+ export function nodeDropLabel(ref: MotionNodeRef): string {
66
+ if (ref.type !== "keyframe") return "Move waypoint";
67
+ return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path";
68
+ }
69
+
70
+ /** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others
71
+ * (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */
72
+ export function commitNodeDrop(drop: NodeDrop): Promise<void> {
73
+ const { ref, at, anim, selection, iframe, commitMutation } = drop;
74
+ if (ref.type !== "keyframe" || !anim || !selection)
75
+ return commitNode(ref, at.x, at.y, drop.animId, commitMutation);
76
+ const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options));
77
+ const callbacks = { commitMutation: writes.commit! };
78
+ let done: Promise<unknown>;
79
+ const store = usePlayerStore.getState();
80
+ const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step];
81
+ const pct = step?.percentage ?? ref.pct;
82
+ if (store.autoKeyframeEnabled) {
83
+ const selected = store.activeKeyframePct;
84
+ store.setActiveKeyframePct(pct);
85
+ const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? "");
86
+ done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, {
87
+ label: nodeDropLabel(ref),
88
+ backfill: live ?? undefined,
89
+ })
90
+ .then(assertGsapEditPersisted)
91
+ .catch((error: unknown) => {
92
+ usePlayerStore.getState().setActiveKeyframePct(selected);
93
+ throw error;
94
+ });
95
+ } else {
96
+ const label = nodeDropLabel(ref);
97
+ done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label);
98
+ }
99
+ return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
100
+ }
101
+
44
102
  export function commitAddWaypoint(
45
103
  animationId: string,
46
104
  index: number,
@@ -182,3 +182,16 @@ describe("start", () => {
182
182
  );
183
183
  });
184
184
  });
185
+
186
+ it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => {
187
+ const read = {
188
+ keyframes: [
189
+ { percentage: 0, properties: { x: 60 }, step: 0 },
190
+ { percentage: 100, properties: { x: 120 }, step: 1 },
191
+ ],
192
+ };
193
+ expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([
194
+ { type: "keyframe", pct: 0, step: 0 },
195
+ { type: "keyframe", pct: 100, step: 1 },
196
+ ]);
197
+ });
@@ -8,7 +8,7 @@ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
8
8
 
9
9
  /** Which source edit a dragged node maps to. */
10
10
  export type MotionNodeRef =
11
- | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
11
+ | { type: "keyframe"; pct: number; step?: number } // x/y keyframe at this tween-relative %, array slot
12
12
  | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
13
13
 
14
14
  /** An offset on the path and the layout width/height set there, when one is. */
@@ -141,7 +141,9 @@ export function buildMotionPathGeometry(
141
141
  if (!at) return;
142
142
  nodes.push({
143
143
  ...at,
144
- ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
144
+ ref: isArc
145
+ ? { type: "waypoint", index: i }
146
+ : { type: "keyframe", pct: kf.percentage, ...(kf.step == null ? {} : { step: kf.step }) },
145
147
  });
146
148
  });
147
149
 
@@ -1,7 +1,23 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { studioApiFetch } from "../../utils/studioApiFetch";
3
+ import type { DomEditSelection } from "./domEditingTypes";
3
4
 
4
- type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
5
+ export type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
6
+
7
+ export function knownSourceAnswer(
8
+ previous: DomEditSelection | null | undefined,
9
+ element: HTMLElement,
10
+ sourceFile: string,
11
+ target: ProbeTarget,
12
+ ): boolean | undefined {
13
+ if (!previous || previous.element !== element || previous.sourceFile !== sourceFile) return;
14
+ const same =
15
+ previous.id === target.id &&
16
+ previous.hfId === target.hfId &&
17
+ previous.selector === target.selector &&
18
+ previous.selectorIndex === target.selectorIndex;
19
+ return same ? previous.existsInSource : undefined;
20
+ }
5
21
 
6
22
  interface PendingProbe {
7
23
  target: ProbeTarget;
@@ -79,24 +79,14 @@ function mountMirrorHarness(history: {
79
79
  moveCoalesceKeys: string[];
80
80
  records: RecordedWrite[];
81
81
  }) {
82
- const record = (
83
- label: string,
84
- coalesceKey: string,
85
- _coalesceMs: number | undefined,
86
- after: string,
87
- ) => {
82
+ const record = (label: string, coalesceKey: string, after: string) => {
88
83
  history.records.push({ label, coalesceKey, after });
89
84
  history.fileContent.current = after;
90
85
  };
91
86
 
92
- const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
93
- _edits,
94
- coalesceKey,
95
- _operation,
96
- coalesceMs,
97
- ) => {
87
+ const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (_edits, coalesceKey) => {
98
88
  history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
99
- record("Move timeline clips", coalesceKey ?? "<none>", coalesceMs, "C-move");
89
+ record("Move timeline clips", coalesceKey ?? "<none>", "C-move");
100
90
  };
101
91
 
102
92
  const api: Partial<HarnessApi> = {};
@@ -104,7 +94,7 @@ function mountMirrorHarness(history: {
104
94
  const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
105
95
  makeLifecycleOpsParams({
106
96
  commitDomEditPatchBatches: async (_batches, options) => {
107
- record(options.label, options.coalesceKey, options.coalesceMs, "B-z");
97
+ record(options.label, options.coalesceKey, "B-z");
108
98
  return { durable: true, allMatched: true, changed: true };
109
99
  },
110
100
  }),
@@ -163,13 +163,6 @@ function useMirrorLaneMoveCommit(): (
163
163
  // stacking sync must not re-trigger and fight the just-set z values.
164
164
  },
165
165
  coalesceKey,
166
- // Unbounded fold window: this record lands only AFTER the z persist's
167
- // server round-trip resolved, so the gap between the gesture's two
168
- // records exceeds editHistory's 300ms default under real latency and
169
- // the fold would silently split into two undo entries. The shared key
170
- // is unique per gesture (zReorderCoalesceKey's gesture seq), so the
171
- // unbounded window can never merge two distinct user actions.
172
- Number.POSITIVE_INFINITY,
173
166
  );
174
167
  },
175
168
  [onMoveElements],
@@ -679,6 +679,33 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
679
679
  view.unmount();
680
680
  });
681
681
 
682
+ it("retimes a duration-less tween's keyframe in the window GSAP plays it, not the clip", async () => {
683
+ const durationless = { ...authoredInteriorAnimation(), duration: undefined };
684
+ mocks.animations = [durationless];
685
+ usePlayerStore.setState({ gsapAnimations: new Map([["box", [durationless]]]) });
686
+ const view = renderCallbacks();
687
+
688
+ // GSAP plays it 0-0.5 s of the 1 s clip, so a drop at 0.4 s is 80% of the tween.
689
+ await view.callbacks.onMoveKeyframe?.(
690
+ "box",
691
+ {
692
+ percentage: 25,
693
+ propertyGroup: "position",
694
+ tweenPercentage: 50,
695
+ animationId: durationless.id,
696
+ },
697
+ 40,
698
+ );
699
+
700
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
701
+ durationless.id,
702
+ 50,
703
+ 80,
704
+ mocks.selection,
705
+ );
706
+ view.unmount();
707
+ });
708
+
682
709
  // A drag starts on whatever diamond the pointer is over, which need not be the
683
710
  // selected element. Resolving against the selection would retime the selected
684
711
  // element's tween and commit it through the selected element's file.
@@ -777,46 +804,4 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
777
804
  expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
778
805
  view.unmount();
779
806
  });
780
-
781
- it("uses the clip timing basis when retiming a duration-less tween", async () => {
782
- const durationless = {
783
- ...authoredInteriorAnimation(),
784
- position: 3.2,
785
- resolvedStart: 3.2,
786
- duration: undefined,
787
- };
788
- const wideElement = { ...element, start: 10.94, duration: 16.26 };
789
- mocks.animations = [durationless];
790
- usePlayerStore.setState({
791
- elements: [wideElement],
792
- gsapAnimations: new Map([["box", [durationless]]]),
793
- });
794
- const view = renderCallbacks();
795
-
796
- await expect(
797
- view.callbacks.onMoveKeyframe?.(
798
- "box",
799
- {
800
- percentage: 19.1,
801
- propertyGroup: "position",
802
- tweenPercentage: 50,
803
- animationId: durationless.id,
804
- },
805
- 40,
806
- ),
807
- ).resolves.toBe(true);
808
-
809
- // The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
810
- // 17.444s, and the duration-less tween borrows the clip's 16.26s window from
811
- // its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
812
- // zero-length tween, or the clip's own 0-100 %) produces a different number.
813
- expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
814
- durationless.id,
815
- 50,
816
- expect.closeTo(87.601, 3),
817
- mocks.selection,
818
- );
819
- expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
820
- view.unmount();
821
- });
822
807
  });
@@ -12,6 +12,7 @@ import {
12
12
  useDomEditSelectionContext,
13
13
  } from "../../contexts/DomEditContext";
14
14
  import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
15
+ import { runEaseOf } from "../../utils/gsapKeyframeEases";
15
16
  import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
16
17
  import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
17
18
  import { resolveKeyframeRetime } from "../editor/keyframeRetime";
@@ -32,7 +33,6 @@ export interface TimelineEditCallbackDeps {
32
33
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
33
34
  coalesceKey?: string,
34
35
  operation?: TimelineMoveOperation,
35
- coalesceMs?: number,
36
36
  ) => Promise<void> | void;
37
37
  handleTimelineElementResize: (
38
38
  element: TimelineElement,
@@ -332,7 +332,7 @@ export function useTimelineEditCallbacks({
332
332
  elements,
333
333
  domClipChildren,
334
334
  );
335
- const tweenDuration = resolveTweenDuration(anim, elDuration);
335
+ const tweenDuration = resolveTweenDuration(anim);
336
336
  const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
337
337
  const decision = resolveKeyframeRetime({
338
338
  keyframes: anim.keyframes?.keyframes ?? [],
@@ -340,6 +340,7 @@ export function useTimelineEditCallbacks({
340
340
  tweenStart,
341
341
  tweenDuration,
342
342
  dropAbsTime,
343
+ runEase: runEaseOf(anim),
343
344
  });
344
345
  if (decision.kind === "move" && decision.toTweenPct != null) {
345
346
  return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
@@ -5,14 +5,19 @@
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
- import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
8
+ import {
9
+ percentageToAbsoluteForAnimation,
10
+ resolveTweenStart,
11
+ resolveTweenDuration,
12
+ } from "../utils/globalTimeCompiler";
9
13
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
10
14
  import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
11
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
12
16
  import type { CommitMutation } from "./gsapScriptCommitTypes";
13
17
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
14
18
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
15
- import { GsapEditBlockedError } from "./gsapEditOutcome";
19
+ import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome";
20
+ import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants";
16
21
  export {
17
22
  findExistingPositionWrite,
18
23
  findRotationSetAnimation,
@@ -53,10 +58,9 @@ export function computeCurrentPercentage(
53
58
  // 1.2 start), so the post-commit reseek renders the element's base pose and the edit
54
59
  // looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
55
60
  export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
56
- const ts = resolveTweenStart(anim);
57
- const td = resolveTweenDuration(anim);
58
- if (ts == null || !td || td <= 0) return;
59
- usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
61
+ const time = percentageToAbsoluteForAnimation(pct, anim);
62
+ if (time === null || resolveTweenDuration(anim) <= 0) return;
63
+ usePlayerStore.getState().requestSeek(roundTo3(time));
60
64
  }
61
65
 
62
66
  async function replaceKeyframedPositionHold(
@@ -117,6 +121,36 @@ export async function materializeIfDynamic(
117
121
  throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
118
122
  }
119
123
 
124
+ /** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */
125
+ function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null {
126
+ if (key === "delay") return "array-step-delay";
127
+ if (/^on[A-Z]/.test(key)) return "array-step-callback";
128
+ if (isTweenConfigKey(key)) return "array-step-config";
129
+ if (typeof value === "number") return null;
130
+ return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
131
+ }
132
+
133
+ /** First match wins: code is computed, even when it calls random(). */
134
+ const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [
135
+ [/^__raw:/, "array-step-computed"],
136
+ [/random\(/, "array-step-random"],
137
+ [/[-+*/]=/, "array-step-relative"],
138
+ ];
139
+
140
+ /** Why a step list can't be rewritten as percentage keyframes, or null. */
141
+ export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null {
142
+ const data = anim.keyframes;
143
+ if (data?.format !== "object-array") return null;
144
+ const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties));
145
+ return entries.map(stepBlock).find(Boolean) ?? null;
146
+ }
147
+
148
+ /** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */
149
+ export function refuseStepListRewrite(anim: GsapAnimation): void {
150
+ const step = stepListBlock(anim);
151
+ if (step) throw new GsapEditBlockedError("keyframes-uneditable", step);
152
+ }
153
+
120
154
  // ── Drag → GSAP position math ──────────────────────────────────────────────
121
155
 
122
156
  /**
@@ -330,6 +364,7 @@ export async function commitWholePathOffset(
330
364
  // fallow-ignore-next-line code-duplication
331
365
  let effectiveAnim = anim;
332
366
  if (anim.keyframes) {
367
+ refuseStepListRewrite(anim);
333
368
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
334
369
  if (newId) effectiveAnim = { ...anim, id: newId };
335
370
  }