@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -4,6 +4,7 @@ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerS
4
4
  import {
5
5
  clearKeyframeCacheForElement,
6
6
  clearKeyframeCacheForFile,
7
+ pruneKeyframeCacheToFiles,
7
8
  updateKeyframeCacheFromParsed,
8
9
  } from "./gsapKeyframeCacheHelpers";
9
10
 
@@ -28,7 +29,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
28
29
  });
29
30
 
30
31
  beforeEach(() => {
31
- usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
32
+ usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
32
33
  });
33
34
 
34
35
  describe("clearKeyframeCacheForElement", () => {
@@ -84,6 +85,20 @@ describe("clearKeyframeCacheForFile", () => {
84
85
  }
85
86
  });
86
87
 
88
+ // Several composition files re-scan concurrently, so a clear that walked the
89
+ // index.html alias would delete rows a sibling file had just written.
90
+ it("leaves an index.html-owned element alone when another file re-scans", () => {
91
+ seed("index.html#title");
92
+ seed("title");
93
+ seed("comp.html#a");
94
+
95
+ clearKeyframeCacheForFile("comp.html");
96
+
97
+ expect(cache().has("index.html#title")).toBe(true);
98
+ expect(cache().has("title")).toBe(true);
99
+ expect(cache().has("comp.html#a")).toBe(false);
100
+ });
101
+
87
102
  it("leaves entries that belong to a different source file", () => {
88
103
  seed("comp.html#a");
89
104
  seed("a");
@@ -97,7 +112,84 @@ describe("clearKeyframeCacheForFile", () => {
97
112
  });
98
113
  });
99
114
 
115
+ describe("pruneKeyframeCacheToFiles", () => {
116
+ // Switching composition leaves the previous comp's elements cached with no
117
+ // owner left to clear them: each file only ever clears its own entries.
118
+ it("drops every element of a file the next scan no longer covers", () => {
119
+ seed("index.html#stress-1");
120
+ seed("stress-1");
121
+ seed("index.html#stress-2");
122
+ seed("stress-2");
123
+ seed("kf200.html#kf200");
124
+ seed("index.html#kf200");
125
+ seed("kf200");
126
+
127
+ pruneKeyframeCacheToFiles(["kf200.html"]);
128
+
129
+ for (const key of ["index.html#stress-1", "stress-1", "index.html#stress-2", "stress-2"]) {
130
+ expect(cache().has(key)).toBe(false);
131
+ }
132
+ expect(cache().has("kf200.html#kf200")).toBe(true);
133
+ });
134
+
135
+ it("prunes gsapAnimations alongside keyframeCache", () => {
136
+ usePlayerStore.getState().setGsapAnimations("index.html#stress-1", [animWithKeyframes("t")]);
137
+ usePlayerStore.getState().setGsapAnimations("kf200.html#kf200", [animWithKeyframes("u")]);
138
+
139
+ pruneKeyframeCacheToFiles(["kf200.html"]);
140
+
141
+ const animations = usePlayerStore.getState().gsapAnimations;
142
+ expect(animations.has("index.html#stress-1")).toBe(false);
143
+ expect(animations.has("kf200.html#kf200")).toBe(true);
144
+ });
145
+
146
+ it("keeps everything when every cached file is still covered", () => {
147
+ seed("index.html#hero");
148
+ seed("comp.html#a");
149
+
150
+ pruneKeyframeCacheToFiles(["index.html", "comp.html"]);
151
+
152
+ expect(cache().has("index.html#hero")).toBe(true);
153
+ expect(cache().has("comp.html#a")).toBe(true);
154
+ });
155
+ });
156
+
100
157
  describe("updateKeyframeCacheFromParsed", () => {
158
+ it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
159
+ const animation: GsapAnimation = {
160
+ ...animWithKeyframes("hero"),
161
+ duration: 2,
162
+ resolvedStart: 3,
163
+ keyframes: {
164
+ format: "percentage",
165
+ keyframes: [
166
+ { percentage: 0, properties: { x: 0 } },
167
+ { percentage: 50, properties: { x: 100 }, ease: "power1.inOut" },
168
+ { percentage: 100, properties: { x: 200 } },
169
+ ],
170
+ easeEach: "power1.inOut",
171
+ },
172
+ };
173
+ usePlayerStore.setState({
174
+ elements: [
175
+ {
176
+ id: "hero-clip",
177
+ domId: "hero",
178
+ tag: "div",
179
+ start: 2,
180
+ duration: 4,
181
+ track: 0,
182
+ },
183
+ ],
184
+ });
185
+
186
+ updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {});
187
+
188
+ expect(JSON.stringify(cache().get("scene.html#hero"))).toBe(
189
+ '{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}',
190
+ );
191
+ });
192
+
101
193
  it("clears the bare key when the selected element no longer has keyframes", () => {
102
194
  // Element previously had keyframes, so a bare entry exists (writes set both).
103
195
  seed("index.html#box");
@@ -118,4 +210,87 @@ describe("updateKeyframeCacheFromParsed", () => {
118
210
  expect(cache().has("index.html#hero")).toBe(true);
119
211
  expect(cache().has("hero")).toBe(true);
120
212
  });
213
+
214
+ it("caches flat tweens as clip-relative start and end keyframes", () => {
215
+ const animation: GsapAnimation = {
216
+ id: "flat-box",
217
+ targetSelector: "#box",
218
+ method: "to",
219
+ position: 1,
220
+ properties: { x: 420 },
221
+ duration: 2,
222
+ resolvedStart: 1,
223
+ ease: "power2.out",
224
+ propertyGroup: "position",
225
+ };
226
+ usePlayerStore.setState({
227
+ elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }],
228
+ });
229
+
230
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
231
+
232
+ expect(cache().get("scene.html#box")).toEqual({
233
+ format: "percentage",
234
+ keyframes: [
235
+ {
236
+ percentage: 0,
237
+ properties: { x: 0 },
238
+ tweenPercentage: 0,
239
+ propertyGroup: "position",
240
+ animationId: "flat-box",
241
+ },
242
+ {
243
+ percentage: 100,
244
+ properties: { x: 420 },
245
+ ease: "power2.out",
246
+ tweenPercentage: 100,
247
+ propertyGroup: "position",
248
+ animationId: "flat-box",
249
+ },
250
+ ],
251
+ });
252
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
253
+ });
254
+
255
+ it("records an ungrouped tween in gsapAnimations too, so the two stores agree", () => {
256
+ // `{ x, opacity }` spans two property groups, so the parser leaves
257
+ // propertyGroup undefined. Skipping it here used to cache diamonds with no
258
+ // source animation behind them: the collapsed row drew keyframes the
259
+ // expanded lanes could not render.
260
+ const animation: GsapAnimation = {
261
+ id: "mixed-box",
262
+ targetSelector: "#box",
263
+ method: "to",
264
+ position: 0,
265
+ properties: { x: 100, opacity: 0 },
266
+ duration: 1,
267
+ resolvedStart: 0,
268
+ };
269
+ usePlayerStore.setState({
270
+ elements: [{ id: "box-clip", domId: "box", tag: "div", start: 0, duration: 1, track: 0 }],
271
+ });
272
+
273
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
274
+
275
+ expect(cache().has("scene.html#box")).toBe(true);
276
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
277
+ });
278
+
279
+ it("does not cache a flat tween without animatable numeric properties", () => {
280
+ const animation: GsapAnimation = {
281
+ id: "flat-box",
282
+ targetSelector: "#box",
283
+ method: "to",
284
+ position: 0,
285
+ properties: { backgroundColor: "#fff" },
286
+ duration: 1,
287
+ propertyGroup: "visual",
288
+ };
289
+
290
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
291
+
292
+ expect(cache().has("scene.html#box")).toBe(false);
293
+ expect(cache().has("box")).toBe(false);
294
+ expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false);
295
+ });
121
296
  });
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
- import { toAbsoluteTime } from "./gsapShared";
7
+ import { idFromSelector, toClipKeyframes } from "./gsapShared";
8
+ import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
8
9
 
9
10
  export function updateKeyframeCacheFromParsed(
10
11
  animations: GsapAnimation[],
@@ -15,57 +16,52 @@ export function updateKeyframeCacheFromParsed(
15
16
  const { setKeyframeCache, elements } = usePlayerStore.getState();
16
17
  const idsWithKeyframes = new Set<string>();
17
18
  const merged = new Map<string, KeyframeCacheEntry>();
19
+ const sourceAnimations = new Map<string, GsapAnimation[]>();
18
20
  for (const anim of animations) {
19
- const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
20
- if (!id || !anim.keyframes) continue;
21
+ const id = idFromSelector(anim.targetSelector);
22
+ const kfSource =
23
+ anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
24
+ if (!id || kfSource.length === 0) continue;
21
25
  idsWithKeyframes.add(id);
26
+ // Every tween that fed keyframeCache also lands in gsapAnimations, group or
27
+ // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
28
+ // cache diamonds with no source animation behind them, so the collapsed row
29
+ // drew keyframes the expanded lanes couldn't render. Lane consumers do the
30
+ // group filtering themselves (animationContributesLane).
31
+ sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
22
32
 
23
33
  // Convert tween-relative percentages to clip-relative so diamonds
24
34
  // render at the correct position within the timeline clip.
25
- const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
26
- const tweenDur = anim.duration ?? 1;
27
35
  const timelineEl = elements.find(
28
36
  (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
29
37
  );
30
- const elStart = timelineEl?.start ?? 0;
31
- const elDuration = timelineEl?.duration ?? 1;
32
- const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
33
- const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
34
- const clipPct =
35
- elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
36
- return {
37
- ...kf,
38
- percentage: clipPct,
39
- tweenPercentage: kf.percentage,
40
- propertyGroup: anim.propertyGroup,
41
- };
42
- });
38
+ const clipKeyframes = toClipKeyframes(
39
+ kfSource,
40
+ anim,
41
+ timelineEl?.start ?? 0,
42
+ timelineEl?.duration ?? 1,
43
+ );
43
44
 
44
45
  const existing = merged.get(id);
45
46
  if (existing) {
46
- const byPct = new Map<number, (typeof existing.keyframes)[0]>();
47
- for (const kf of [...existing.keyframes, ...clipKeyframes]) {
48
- const prev = byPct.get(kf.percentage);
49
- if (prev) {
50
- prev.properties = { ...prev.properties, ...kf.properties };
51
- if (kf.ease) prev.ease = kf.ease;
52
- } else {
53
- byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
54
- }
55
- }
56
- existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
47
+ // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
48
+ // flag downstream lanes read); a second copy of that rule here is how the
49
+ // two writers drift.
50
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
57
51
  } else {
58
- merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
52
+ merged.set(id, {
53
+ ...anim.keyframes,
54
+ format: anim.keyframes?.format ?? "percentage",
55
+ keyframes: clipKeyframes,
56
+ });
59
57
  }
60
58
  }
61
59
  for (const [id, entry] of merged) {
62
- setKeyframeCache(`${targetPath}#${id}`, entry);
63
- setKeyframeCache(id, entry);
64
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
60
+ for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
61
+ writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
65
62
  }
66
63
  const targetId =
67
- (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
68
- selectionId;
64
+ idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
69
65
  if (targetId && !idsWithKeyframes.has(targetId)) {
70
66
  clearKeyframeCacheForElement(targetPath, targetId);
71
67
  }
@@ -84,40 +80,85 @@ export function updateKeyframeCacheFromParsed(
84
80
  * a new cache map and re-render every subscriber.
85
81
  */
86
82
  export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
87
- const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
88
- const keys =
89
- sourceFile === "index.html"
90
- ? [`index.html#${elementId}`, elementId]
91
- : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
83
+ const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
84
+ usePlayerStore.getState();
85
+ const keys = elementCacheKeys(sourceFile, elementId);
92
86
  for (const key of keys) {
93
87
  if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
88
+ if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
94
89
  }
95
90
  }
96
91
 
97
92
  /**
98
93
  * Clear every cached element of `sourceFile` before a full re-scan repopulates
99
- * it. Collects the element ids that currently have a prefixed or index.html
100
- * fallback key for the file and drops each through clearKeyframeCacheForElement
101
- * so the bare key goes too an element whose keyframes were removed (and so is
102
- * absent from the re-scan) leaves no stale bare entry behind.
94
+ * it. Only the file's OWN prefixed keys name the ids to clear: every write sets
95
+ * the prefixed key (see elementCacheKeys), so the file's elements are all
96
+ * reachable that way, and clearKeyframeCacheForElement then takes the
97
+ * index.html alias and the bare key with them an element whose keyframes were
98
+ * removed (and so is absent from the re-scan) leaves no stale bare entry
99
+ * behind. Reading the alias prefix here instead would collect ids owned by
100
+ * OTHER files, and several files re-scan concurrently, so this file's clear
101
+ * would wipe the entries a sibling file had just written.
103
102
  */
104
103
  export function clearKeyframeCacheForFile(sourceFile: string): void {
105
- const { keyframeCache } = usePlayerStore.getState();
104
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
106
105
  const sfPrefix = `${sourceFile}#`;
107
- const fallbackPrefix = "index.html#";
108
106
  const ids = new Set<string>();
109
- for (const key of keyframeCache.keys()) {
110
- const matchesFile =
111
- key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
112
- if (!matchesFile) continue;
113
- const hashIdx = key.indexOf("#");
114
- if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
107
+ for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
108
+ if (!key.startsWith(sfPrefix)) continue;
109
+ ids.add(key.slice(sfPrefix.length));
115
110
  }
116
111
  for (const id of ids) {
117
112
  clearKeyframeCacheForElement(sourceFile, id);
118
113
  }
119
114
  }
120
115
 
116
+ /**
117
+ * Drop every cached element owned by a file that is no longer on screen. Each
118
+ * file only ever clears its OWN entries (see clearKeyframeCacheForFile), so
119
+ * switching composition left the previous composition's elements cached forever
120
+ * — 240 entries per switch on a 120-clip comp, in both keyframeCache and
121
+ * gsapAnimations, with nothing to evict them. Called once before a re-scan, with
122
+ * the full set of files that scan covers.
123
+ */
124
+ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
125
+ const keep = new Set(files);
126
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
127
+ const stale = new Map<string, Set<string>>();
128
+ for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
129
+ const hash = key.indexOf("#");
130
+ // Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them
131
+ // with their prefixed key, so skipping them here loses nothing.
132
+ if (hash < 0) continue;
133
+ const sourceFile = key.slice(0, hash);
134
+ if (keep.has(sourceFile)) continue;
135
+ const ids = stale.get(sourceFile) ?? new Set<string>();
136
+ ids.add(key.slice(hash + 1));
137
+ stale.set(sourceFile, ids);
138
+ }
139
+ for (const [sourceFile, ids] of stale) {
140
+ for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
141
+ }
142
+ }
143
+
144
+ /** Every cache key a write for this element sets, in read-preference order. */
145
+ export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
146
+ return sourceFile === "index.html"
147
+ ? [`index.html#${elementId}`, elementId]
148
+ : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
149
+ }
150
+
151
+ export function writeGsapAnimationsForElement(
152
+ sourceFile: string,
153
+ elementId: string,
154
+ animations: GsapAnimation[] | undefined,
155
+ ): void {
156
+ const { setGsapAnimations } = usePlayerStore.getState();
157
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
158
+ setGsapAnimations(key, animations);
159
+ }
160
+ }
161
+
121
162
  function buildCacheKey(sourceFile: string, elementId: string): string {
122
163
  return `${sourceFile}#${elementId}`;
123
164
  }
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
280
280
  expect(types).not.toContain("replace-with-keyframes");
281
281
  });
282
282
  });
283
+
284
+ describe("tryGsapDragIntercept — motion paths", () => {
285
+ const motionPathAnim = {
286
+ id: "#puck-b-to-12170-position",
287
+ targetSelector: "#puck-b",
288
+ propertyGroup: "position",
289
+ method: "to",
290
+ position: 12.17,
291
+ resolvedStart: 12.17,
292
+ duration: 16.055,
293
+ ease: "power1.inOut",
294
+ properties: {},
295
+ keyframes: {
296
+ keyframes: [
297
+ { percentage: 0, properties: { x: -184, y: 326 } },
298
+ { percentage: 50, properties: { x: 416, y: 804 } },
299
+ { percentage: 100, properties: { x: 796, y: 237 } },
300
+ ],
301
+ },
302
+ arcPath: {
303
+ enabled: true,
304
+ autoRotate: false,
305
+ segments: [{ curviness: 1 }, { curviness: 1 }],
306
+ },
307
+ } as unknown as GsapAnimation;
308
+ const liveTween = {
309
+ targets: () => [{ id: "puck-b" }],
310
+ vars: { motionPath: { path: [] }, duration: 16.055 },
311
+ duration: () => 16.055,
312
+ startTime: () => 12.17,
313
+ };
314
+
315
+ async function dragMotionPath(activeKeyframePct: number | null) {
316
+ usePlayerStore.setState({
317
+ autoKeyframeEnabled: true,
318
+ activeKeyframePct,
319
+ currentTime: 15.9,
320
+ });
321
+ const commitMutation = vi.fn();
322
+ const handled = await tryGsapDragIntercept(
323
+ selection,
324
+ { x: -50, y: 30 },
325
+ [motionPathAnim],
326
+ fakeIframe("puck-b", [liveTween]),
327
+ commitMutation,
328
+ );
329
+ return { commitMutation, handled };
330
+ }
331
+
332
+ afterEach(() => {
333
+ usePlayerStore.setState({ activeKeyframePct: null });
334
+ });
335
+
336
+ it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
337
+ const { commitMutation, handled } = await dragMotionPath(null);
338
+
339
+ expect(handled).toBe(true);
340
+ expect(commitMutation).toHaveBeenCalledWith(
341
+ selection,
342
+ {
343
+ type: "replace-with-keyframes",
344
+ animationId: motionPathAnim.id,
345
+ targetSelector: "#puck-b",
346
+ position: 12.17,
347
+ duration: 16.055,
348
+ keyframes: [
349
+ { percentage: 0, properties: { x: -184, y: 326 } },
350
+ { percentage: 23.233, properties: { x: -50, y: 30 } },
351
+ { percentage: 50, properties: { x: 416, y: 804 } },
352
+ { percentage: 100, properties: { x: 796, y: 237 } },
353
+ ],
354
+ ease: "none",
355
+ },
356
+ expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
357
+ );
358
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
359
+ "add-motion-path-point",
360
+ );
361
+ });
362
+
363
+ it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
364
+ const { commitMutation, handled } = await dragMotionPath(50);
365
+
366
+ expect(handled).toBe(true);
367
+ expect(commitMutation).toHaveBeenCalledWith(
368
+ selection,
369
+ {
370
+ type: "update-motion-path-point",
371
+ animationId: motionPathAnim.id,
372
+ pointIndex: 1,
373
+ x: -50,
374
+ y: 30,
375
+ },
376
+ expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
377
+ );
378
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
379
+ "replace-with-keyframes",
380
+ );
381
+ });
382
+ });
@@ -2,12 +2,13 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
 
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
+ import { idSelector } from "./gsapShared";
5
6
 
6
7
  export function ensureElementAddressable(selection: DomEditSelection): {
7
8
  selector: string;
8
9
  autoId?: string;
9
10
  } {
10
- if (selection.id) return { selector: `#${selection.id}` };
11
+ if (selection.id) return { selector: idSelector(selection.id) };
11
12
  if (selection.selector) return { selector: selection.selector };
12
13
 
13
14
  const el = selection.element;
@@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): {
20
21
  id = `${tag}-${n}`;
21
22
  }
22
23
  el.setAttribute("id", id);
23
- return { selector: `#${id}`, autoId: id };
24
+ return { selector: idSelector(id), autoId: id };
24
25
  }
25
26
 
26
27
  export class GsapMutationHttpError extends Error {
@@ -16,6 +16,8 @@ export interface MutationResult {
16
16
 
17
17
  export interface CommitMutationOptions {
18
18
  label: string;
19
+ /** Observe the durable writer result without duplicating the request path. */
20
+ onResult?: (result: MutationResult) => void;
19
21
  coalesceKey?: string;
20
22
  coalesceMs?: number;
21
23
  softReload?: boolean;
@@ -1,6 +1,31 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { isInstantHold, parsePercentageKeyframes } from "./gsapShared";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import {
5
+ idFromSelector,
6
+ idSelector,
7
+ isInstantHold,
8
+ parsePercentageKeyframes,
9
+ resolveEditableTweenDuration,
10
+ toClipKeyframes,
11
+ toClipPercentage,
12
+ } from "./gsapShared";
13
+
14
+ // Fixtures carry only the fields the function under test reads; the double-cast
15
+ // is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
16
+ const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
17
+
18
+ describe("resolveEditableTweenDuration", () => {
19
+ const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
20
+
21
+ it("uses the owning clip duration when the tween omits an outer duration", () => {
22
+ expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
23
+ });
24
+
25
+ it("keeps an explicitly-authored tween duration", () => {
26
+ expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
27
+ });
28
+ });
4
29
 
5
30
  describe("isInstantHold", () => {
6
31
  const animation = (method: GsapAnimation["method"], duration?: number) =>
@@ -74,3 +99,86 @@ describe("parsePercentageKeyframes", () => {
74
99
  expect(parsePercentageKeyframes({})).toBeNull();
75
100
  });
76
101
  });
102
+
103
+ describe("idSelector", () => {
104
+ it("uses #id for valid CSS identifiers", () => {
105
+ expect(idSelector("hero-word")).toBe("#hero-word");
106
+ expect(idSelector("el_1")).toBe("#el_1");
107
+ });
108
+
109
+ it("uses an attribute selector for ids that #id can't address (digit-leading, dots, spaces)", () => {
110
+ // #01-... / #a.b / #a b throw a SyntaxError in querySelector / GSAP, crashing
111
+ // the preview when such a target is committed (e.g. dragging the element).
112
+ expect(idSelector("01-hook-hero-word")).toBe('[id="01-hook-hero-word"]');
113
+ expect(idSelector("my.class")).toBe('[id="my.class"]');
114
+ expect(idSelector("1box")).toBe('[id="1box"]');
115
+ });
116
+
117
+ it("escapes quotes and backslashes in the attribute selector value", () => {
118
+ expect(idSelector('1"x')).toBe('[id="1\\"x"]');
119
+ });
120
+
121
+ it("only ever emits #id for ids that can't break querySelector", () => {
122
+ // Every id resolves to either a plain #id (only when safe) or an attribute
123
+ // selector — never a #id that would throw a SyntaxError.
124
+ for (const id of ["hero-word", "01-hook", "a.b", "a b", "1", "--x", '1"q']) {
125
+ const sel = idSelector(id);
126
+ if (sel.startsWith("#")) expect(sel).toBe(`#${id}`);
127
+ else expect(sel.startsWith('[id="')).toBe(true);
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("toClipPercentage", () => {
133
+ // Selection keys embed this number, so every keyframe-cache writer has to round
134
+ // it identically: a coarser writer rewrites the cache with a different value and
135
+ // orphans the live selection key built from the finer one.
136
+ it("keeps three decimals so a beat-snapped keyframe lands on its beat", () => {
137
+ expect(toClipPercentage(1 / 3, 0, 1, 0)).toBe(33.333);
138
+ expect(toClipPercentage(2.5, 2, 4, 0)).toBe(12.5);
139
+ });
140
+
141
+ it("passes the tween percentage through for a zero-length clip", () => {
142
+ expect(toClipPercentage(5, 0, 0, 42)).toBe(42);
143
+ });
144
+ });
145
+
146
+ describe("toClipKeyframes", () => {
147
+ // Fixture carries only the fields the function under test reads; the
148
+ // double-cast is the documented way to stand in for the full runtime shape
149
+ // (CONTRIBUTING.md).
150
+ const durationless = {
151
+ id: "a1",
152
+ method: "to",
153
+ targetSelector: "#box",
154
+ vars: {},
155
+ resolvedStart: 0,
156
+ } as unknown as GsapAnimation;
157
+
158
+ // A tween with no duration spans its clip everywhere else in Studio
159
+ // (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
160
+ // basis put the end keyframe at 25% of a 4s clip instead of 100%.
161
+ it("spans the clip when the tween has no duration", () => {
162
+ const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
163
+ expect(rows.map((row) => row.percentage)).toEqual([0, 100]);
164
+ });
165
+
166
+ it("keeps the tween percentage and the animation identity on every row", () => {
167
+ const rows = toClipKeyframes([{ percentage: 50 }], durationless, 0, 4);
168
+ expect(rows[0]).toMatchObject({ tweenPercentage: 50, animationId: "a1" });
169
+ });
170
+ });
171
+
172
+ describe("idFromSelector", () => {
173
+ it("round-trips every shape idSelector emits", () => {
174
+ for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) {
175
+ expect(idFromSelector(idSelector(id))).toBe(id);
176
+ }
177
+ });
178
+
179
+ it("returns null for a selector that does not address an id", () => {
180
+ expect(idFromSelector(".dot")).toBeNull();
181
+ expect(idFromSelector("[data-hf-id='x']")).toBeNull();
182
+ expect(idFromSelector(undefined)).toBeNull();
183
+ });
184
+ });