@hyperframes/studio 0.7.78 → 0.7.80

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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -0,0 +1,289 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { parseGsapScript } from "@hyperframes/core/gsap-parser";
4
+ import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
7
+ import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
8
+ import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
9
+ import { promoteSetToKeyframes } from "./useEnableKeyframes";
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ /**
16
+ * A selection built the way production builds it (getDomLayerPatchTarget), so
17
+ * the class-only case under test is the real one: buildStableSelector hands back
18
+ * a BARE class for an element with no id / hf-id / composition id.
19
+ */
20
+ function selectionFor(el: HTMLElement): DomEditSelection {
21
+ const selector = buildStableSelector(el);
22
+ return {
23
+ element: el,
24
+ id: el.id || undefined,
25
+ hfId: el.getAttribute("data-hf-id") || undefined,
26
+ selector,
27
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
28
+ sourceFile: "index.html",
29
+ dataAttributes: { start: "0", duration: "2" },
30
+ } as unknown as DomEditSelection;
31
+ }
32
+
33
+ /** Five class-only siblings — the shape that made one add collapse the timeline. */
34
+ function mountGroupSiblings(): HTMLElement[] {
35
+ document.body.innerHTML = `
36
+ <div id="scene" class="clip" data-start="0" data-duration="2">
37
+ <div class="group"></div>
38
+ <div class="group"></div>
39
+ <div class="group"></div>
40
+ <div class="group"></div>
41
+ <div class="group"></div>
42
+ </div>
43
+ `;
44
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
45
+ }
46
+
47
+ const BASE_SCRIPT = `
48
+ const tl = gsap.timeline({ paused: true });
49
+ tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
50
+ `.trim();
51
+
52
+ const KEYFRAMES = [
53
+ { percentage: 0, properties: { x: 0 } },
54
+ { percentage: 100, properties: { x: 40 } },
55
+ ];
56
+
57
+ /** Author the selector into a real script and read the tween's targets back. */
58
+ function roundTripTargets(selector: string): { script: string; targets: string[] } {
59
+ const { script } = addAnimationWithKeyframesToScript(BASE_SCRIPT, selector, 0, 1, KEYFRAMES);
60
+ const added = parseGsapScript(script).animations.find((a) => a.targetSelector === selector);
61
+ if (!added) throw new Error(`written selector did not re-parse: ${selector}`);
62
+ return { script, targets: resolveSelectorElementIds(added.targetSelector, document) };
63
+ }
64
+
65
+ describe("writeTargetSelector", () => {
66
+ it("addresses exactly one element when the only identity is a shared class", () => {
67
+ const groups = mountGroupSiblings();
68
+ const selection = selectionFor(groups[2]);
69
+
70
+ // Precondition: this is the defect's input — a bare class hitting all five.
71
+ expect(selection.selector).toBe(".group");
72
+ expect(document.querySelectorAll(".group")).toHaveLength(5);
73
+
74
+ const written = writeTargetSelector(selection);
75
+
76
+ expect(written).toBeTruthy();
77
+ expect(document.querySelectorAll(written!)).toHaveLength(1);
78
+ expect(document.querySelector(written!)).toBe(groups[2]);
79
+ });
80
+
81
+ it("keeps a unique #id target", () => {
82
+ document.body.innerHTML = `<div id="box" class="card"></div>`;
83
+ const el = document.querySelector<HTMLElement>("#box")!;
84
+
85
+ expect(writeTargetSelector(selectionFor(el))).toBe("#box");
86
+ });
87
+
88
+ it("prefers data-hf-id over a generated structural selector", () => {
89
+ document.body.innerHTML = `
90
+ <div id="scene"><div class="group"></div><div class="group" data-hf-id="hf-42"></div></div>
91
+ `;
92
+ const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
93
+
94
+ const written = writeTargetSelector(selectionFor(el));
95
+
96
+ expect(written).toBe('[data-hf-id="hf-42"]');
97
+ expect(document.querySelector(written!)).toBe(el);
98
+ });
99
+
100
+ it("keeps an already-unique class selector as authored", () => {
101
+ document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
102
+ const el = document.querySelector<HTMLElement>(".header")!;
103
+
104
+ expect(writeTargetSelector(selectionFor(el))).toBe(".header");
105
+ });
106
+
107
+ it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
108
+ // Mixed identity: the walk must step past the id-less <section>, stop at the
109
+ // data-hf-id row, and NOT keep climbing to #outer.
110
+ document.body.innerHTML = `
111
+ <div id="outer">
112
+ <header></header>
113
+ <section>
114
+ <div data-hf-id="mid">
115
+ <div class="cell"></div>
116
+ <div class="cell"></div>
117
+ <div class="cell"></div>
118
+ </div>
119
+ </section>
120
+ </div>
121
+ `;
122
+ const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
123
+
124
+ const written = writeTargetSelector(selectionFor(el));
125
+
126
+ expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
127
+ expect(document.querySelectorAll(written!)).toHaveLength(1);
128
+ expect(document.querySelector(written!)).toBe(el);
129
+ });
130
+
131
+ it("falls through to the structural walk when the selection's selector is not valid CSS", () => {
132
+ document.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
133
+ const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
134
+ // querySelectorAll throws on this, so the "does it match exactly one?" rung
135
+ // must read as a miss rather than crashing the add.
136
+ const selection = { ...selectionFor(el), selector: "div[unclosed" } as DomEditSelection;
137
+
138
+ expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
139
+ });
140
+
141
+ it("returns null when a live DOM is present and no rung addresses one element", () => {
142
+ document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
143
+ const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
144
+ const selection = selectionFor(el);
145
+ expect(selection.selector).toBe(".group");
146
+ // Detached between selecting and committing: ownerDocument still resolves,
147
+ // but there is no parent to count a :nth-child position in. Returning
148
+ // ".group" here would re-author the very selector this function replaces.
149
+ el.remove();
150
+
151
+ expect(writeTargetSelector(selection)).toBeNull();
152
+ });
153
+
154
+ it("still returns the selection's own selector when there is no DOM to check against", () => {
155
+ const selection = {
156
+ selector: ".group",
157
+ sourceFile: "index.html",
158
+ } as unknown as DomEditSelection;
159
+
160
+ expect(writeTargetSelector(selection)).toBe(".group");
161
+ });
162
+ });
163
+
164
+ describe("writeTargetSelector — write/read round trip", () => {
165
+ it("re-parses and attributes the new tween to the one element it targeted", () => {
166
+ const groups = mountGroupSiblings();
167
+ // Ids let resolveSelectorElementIds name the attributed elements; the
168
+ // SELECTION still has none, so the write path still faces the bare class.
169
+ groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
170
+ const selection = { ...selectionFor(groups[2]), id: undefined } as DomEditSelection;
171
+
172
+ const written = writeTargetSelector(selection);
173
+ const { targets } = roundTripTargets(written!);
174
+
175
+ expect(targets).toEqual(["group-2"]);
176
+ });
177
+
178
+ it("does not widen the add to the element's four class siblings", () => {
179
+ const groups = mountGroupSiblings();
180
+ groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
181
+ const selection = { ...selectionFor(groups[0]), id: undefined } as DomEditSelection;
182
+
183
+ // The old bare-class write attributed the one add to every sibling — the
184
+ // attribution blow-up behind "one add collapsed the timeline to a single row".
185
+ expect(roundTripTargets(".group").targets).toHaveLength(5);
186
+
187
+ expect(roundTripTargets(writeTargetSelector(selection)!).targets).toHaveLength(1);
188
+ });
189
+ });
190
+
191
+ describe("existingTweenTargetSelector", () => {
192
+ it("keeps the narrowed target when a replace re-authors the tween", async () => {
193
+ const groups = mountGroupSiblings();
194
+ const selection = selectionFor(groups[2]);
195
+ const commitMutation = vi.fn(async () => undefined);
196
+ // A tween a previous add already narrowed to one sibling. Re-deriving the
197
+ // target from the selection would widen it back to all five.
198
+ const setAnim = {
199
+ id: "a1",
200
+ targetSelector: "#scene > div:nth-child(3)",
201
+ method: "set",
202
+ position: 0,
203
+ properties: { x: 10 },
204
+ };
205
+
206
+ await promoteSetToKeyframes({ commitMutation } as never, selection, setAnim as never, 0, null);
207
+
208
+ const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
209
+ expect(mutation.targetSelector).toBe("#scene > div:nth-child(3)");
210
+ expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
211
+ });
212
+
213
+ it("falls back to the selection when the tween's own target did not resolve", async () => {
214
+ const groups = mountGroupSiblings();
215
+ const selection = selectionFor(groups[2]);
216
+ const commitMutation = vi.fn(async () => undefined);
217
+ const setAnim = {
218
+ id: "a1",
219
+ targetSelector: "targets[i]",
220
+ hasUnresolvedSelector: true,
221
+ method: "set",
222
+ position: 0,
223
+ properties: { x: 10 },
224
+ };
225
+
226
+ await promoteSetToKeyframes({ commitMutation } as never, selection, setAnim as never, 0, null);
227
+
228
+ const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
229
+ expect(mutation.targetSelector).toBe(".group");
230
+ });
231
+ });
232
+
233
+ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
234
+ it("authors a one-element selector when no tween exists at the playhead", async () => {
235
+ const groups = mountGroupSiblings();
236
+ const selection = selectionFor(groups[3]);
237
+ const commitMutation = vi.fn(async () => undefined);
238
+
239
+ await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
240
+
241
+ const mutation = commitMutation.mock.calls[0]?.[1] as { targetSelector: string };
242
+ expect(mutation.targetSelector).not.toBe(".group");
243
+ expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
244
+ expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
245
+ });
246
+ });
247
+
248
+ describe("tweenTargetsElement", () => {
249
+ it("matches a tween narrowed to this element by a selector the selection does not use", () => {
250
+ const groups = mountGroupSiblings();
251
+ groups[2]!.id = "narrowed";
252
+
253
+ // The write half authored "#narrowed"; the read half still has ".group".
254
+ expect(tweenTargetsElement("#narrowed", ".group", groups[2])).toBe(true);
255
+ });
256
+
257
+ it("does not hand an individual element the group tween it merely inherits", () => {
258
+ const groups = mountGroupSiblings();
259
+
260
+ // Selecting one sibling by its own address must not let an edit mutate the
261
+ // ".group" tween: that write moves all five, not the one being nudged.
262
+ expect(tweenTargetsElement(".group", "#scene > div:nth-child(3)", groups[2])).toBe(false);
263
+ });
264
+
265
+ it("still edits a group tween when the group itself is the selection", () => {
266
+ const groups = mountGroupSiblings();
267
+
268
+ expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
269
+ });
270
+
271
+ it("does not match a target that is not a selector matches() understands", () => {
272
+ const groups = mountGroupSiblings();
273
+
274
+ expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
275
+ });
276
+ });
277
+
278
+ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
279
+ it("drops the keyframe rather than authoring the bare class", async () => {
280
+ const groups = mountGroupSiblings();
281
+ const selection = selectionFor(groups[3]!);
282
+ groups[3]!.remove();
283
+ const commitMutation = vi.fn(async () => undefined);
284
+
285
+ await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
286
+
287
+ expect(commitMutation).not.toHaveBeenCalled();
288
+ });
289
+ });
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
54
54
  });
55
55
  });
56
56
 
57
- describe("deduplicateKeyframes ease ambiguity", () => {
58
- it("flags a same-% collision from different animations (different eases)", () => {
57
+ describe("deduplicateKeyframes colliding animation targets", () => {
58
+ it("records each animation's tween percentage in first-seen order", () => {
59
59
  const merged = deduplicateKeyframes([
60
- { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
61
- { percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
60
+ {
61
+ percentage: 45,
62
+ tweenPercentage: 20,
63
+ properties: { x: 10 },
64
+ ease: "power2.in",
65
+ animationId: "#a-position",
66
+ },
67
+ {
68
+ percentage: 45,
69
+ tweenPercentage: 80,
70
+ properties: { opacity: 1 },
71
+ ease: "power2.out",
72
+ animationId: "#a-visual",
73
+ },
62
74
  ]);
63
75
  const kf = merged.find((k) => k.percentage === 45);
64
- expect(kf?.easeAmbiguous).toBe(true);
76
+ expect(kf?.collidingAnimationTargets).toEqual([
77
+ { animationId: "#a-position", tweenPercentage: 20 },
78
+ { animationId: "#a-visual", tweenPercentage: 80 },
79
+ ]);
65
80
  });
66
81
 
67
- it("flags a cross-animation collision even when the raw eases match", () => {
68
- // The button can still only target one arbitrary animation, and each may
69
- // inherit a different easeEach/animation ease that raw comparison misses.
82
+ it("deduplicates three colliding animations while preserving first-seen order", () => {
70
83
  const merged = deduplicateKeyframes([
71
- { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
72
- { percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
84
+ {
85
+ percentage: 45,
86
+ tweenPercentage: 20,
87
+ properties: { x: 10 },
88
+ ease: "power2.in",
89
+ animationId: "#a-position",
90
+ },
91
+ {
92
+ percentage: 45,
93
+ tweenPercentage: 80,
94
+ properties: { opacity: 1 },
95
+ ease: "power2.in",
96
+ animationId: "#a-visual",
97
+ },
98
+ {
99
+ percentage: 45,
100
+ tweenPercentage: 40,
101
+ properties: { y: 20 },
102
+ ease: "power2.out",
103
+ animationId: "#a-position",
104
+ },
105
+ {
106
+ percentage: 45,
107
+ tweenPercentage: 60,
108
+ properties: { scale: 2 },
109
+ ease: "power2.in",
110
+ animationId: "#a-scale",
111
+ },
112
+ ]);
113
+ expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toEqual([
114
+ { animationId: "#a-position", tweenPercentage: 20 },
115
+ { animationId: "#a-visual", tweenPercentage: 80 },
116
+ { animationId: "#a-scale", tweenPercentage: 60 },
73
117
  ]);
74
- expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
75
118
  });
76
119
 
77
- it("does not flag a same-% collision within a single animation", () => {
120
+ it("leaves the collision set undefined within a single animation", () => {
78
121
  const merged = deduplicateKeyframes([
79
- { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
80
- { percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
122
+ {
123
+ percentage: 45,
124
+ tweenPercentage: 20,
125
+ properties: { x: 10 },
126
+ ease: "power2.in",
127
+ animationId: "#a-position",
128
+ },
129
+ {
130
+ percentage: 45,
131
+ tweenPercentage: 80,
132
+ properties: { y: 20 },
133
+ ease: "power2.out",
134
+ animationId: "#a-position",
135
+ },
81
136
  ]);
82
- expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
137
+ expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toBeUndefined();
83
138
  });
84
139
  });
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  GsapAnimation,
3
3
  GsapKeyframesData,
4
- GsapPercentageKeyframe,
4
+ SourcedGsapPercentageKeyframe,
5
5
  } from "@hyperframes/core/gsap-parser";
6
6
  import { PROPERTY_DEFAULTS } from "./gsapShared";
7
7
 
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
22
22
  return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
23
23
  }
24
24
 
25
- export function deduplicateKeyframes<
26
- T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
27
- >(keyframes: T[]): T[] {
25
+ export interface AnimationKeyframeTarget {
26
+ animationId: string;
27
+ tweenPercentage: number;
28
+ }
29
+
30
+ function accumulateCollidingAnimationTargets(
31
+ keyframe: AnimationKeyframeTarget & {
32
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
33
+ },
34
+ incoming: AnimationKeyframeTarget,
35
+ ): void {
36
+ const primaryId = keyframe.animationId;
37
+ // One tween meeting itself is not a collision. Both identity fields are
38
+ // required by the parameter types rather than guarded at runtime: a keyframe
39
+ // that arrives without them cannot be attributed to a tween at all, and an
40
+ // early return here would silently record no collision and let the inline
41
+ // ease button edit an arbitrary one of the tweens that met at this
42
+ // percentage. The compiler now refuses the incomplete keyframe instead.
43
+ if (primaryId === incoming.animationId) return;
44
+ const collisionTargets = keyframe.collidingAnimationTargets;
45
+ if (collisionTargets?.some((target) => target.animationId === incoming.animationId)) return;
46
+ keyframe.collidingAnimationTargets = [
47
+ ...(collisionTargets === undefined || collisionTargets.length === 0
48
+ ? [{ animationId: primaryId, tweenPercentage: keyframe.tweenPercentage }]
49
+ : collisionTargets),
50
+ { animationId: incoming.animationId, tweenPercentage: incoming.tweenPercentage },
51
+ ];
52
+ }
53
+
54
+ /**
55
+ * What a keyframe looks like once it has been attributed to its source tween
56
+ * and is ready to be merged with the other tweens landing on the same row. The
57
+ * runtime scan produces unattributed keyframes and they never reach a merge, so
58
+ * they are deliberately not this type.
59
+ */
60
+ export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
61
+ propertyGroup?: string;
62
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
63
+ };
64
+
65
+ export function deduplicateKeyframes<T extends MergeableKeyframe>(keyframes: T[]): T[] {
28
66
  const byPct = new Map<number, T>();
29
67
  for (const kf of keyframes) {
30
68
  const existing = byPct.get(kf.percentage);
31
69
  if (existing) {
32
70
  existing.properties = { ...existing.properties, ...kf.properties };
33
- // Two DIFFERENT source animations with a keyframe at the same clip %: a
34
- // single inline ease button can only target one of them, and which one is
35
- // arbitrary (each may also inherit a different easeEach/animation ease, so
36
- // comparing raw keyframe eases isn't enough). Flag it so the collapsed row
37
- // hides the button there and the user edits per-lane instead.
38
- if (
39
- existing.animationId !== undefined &&
40
- kf.animationId !== undefined &&
41
- existing.animationId !== kf.animationId
42
- ) {
43
- existing.easeAmbiguous = true;
44
- }
45
- // Whichever tween iterated last used to win `ease`, so the merged
46
- // keyframe carried an arbitrary one of the colliding curves. Readers that
47
- // do not check easeAmbiguous (drag readouts, lane hints) then showed a
48
- // curve belonging to a different animation than the one an edit targets.
49
- // Drop it instead: ambiguous means "no single ease", and the flag is the
50
- // only honest answer.
51
- if (existing.easeAmbiguous) delete existing.ease;
71
+ accumulateCollidingAnimationTargets(existing, kf);
72
+ // Whichever tween iterated last used to win `ease`, so the merged keyframe
73
+ // carried an arbitrary one of the colliding curves. Readers that show a
74
+ // single curve (drag readouts, lane hints, the inline ease button) then
75
+ // displayed one belonging to a different animation than the one an edit
76
+ // targets. A collision means "no single ease", and dropping it is the only
77
+ // honest answer; collidingAnimationTargets still names every tween there.
78
+ if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease;
52
79
  else if (kf.ease) existing.ease = kf.ease;
53
80
  } else {
54
81
  byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
@@ -1,60 +1,22 @@
1
1
  /**
2
- * Reading a composition file's GSAP tweens into the keyframe cache: fetch,
3
- * selector -> element id resolution, and the clip-relative timing basis.
2
+ * Reading a composition file's GSAP tweens into the keyframe cache: fetch and
3
+ * selector -> element id resolution.
4
4
  * Split from useGsapTweenCache to keep that file under the 600-line limit.
5
5
  */
6
6
  import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
7
7
  import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
8
8
  import { usePlayerStore } from "../player/store/playerStore";
9
- import {
10
- clearKeyframeCacheForFile,
11
- elementCacheKeys,
12
- writeGsapAnimationsForElement,
13
- } from "./gsapKeyframeCacheHelpers";
14
- import { idFromSelector, toClipKeyframes } from "./gsapShared";
9
+ import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
10
+ import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
15
11
  import {
16
12
  deduplicateKeyframes,
17
13
  isStaticPositionHold,
18
14
  synthesizeFlatTweenKeyframes,
15
+ type MergeableKeyframe,
19
16
  } from "./gsapTweenSynth";
20
17
 
21
- /**
22
- * Resolve a tween's target selector to the ids of the element(s) it animates.
23
- * A bare `#id` resolves directly; anything else (a class like `.dot`, a group
24
- * `.a, .b`, or a descendant selector) is matched against the live preview DOM so
25
- * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
26
- * element they animate — not just one parsed from the string. Falls back to a
27
- * leading `#id` when there's no DOM (so the cache still populates pre-iframe).
28
- */
29
- // fallow-ignore-next-line complexity
30
- export function resolveSelectorElementIds(
31
- selector: string,
32
- doc: Document | null | undefined,
33
- ): string[] {
34
- // A whole-selector id match (either shape) addresses exactly one element.
35
- const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
36
- ? idFromSelector(selector)
37
- : null;
38
- if (bareId) return [bareId];
39
- if (!doc) {
40
- const lead = idFromSelector(selector);
41
- return lead ? [lead] : [];
42
- }
43
- const ids = new Set<string>();
44
- for (const part of selector.split(",")) {
45
- const sel = part.trim();
46
- if (!sel) continue;
47
- try {
48
- for (const el of Array.from(doc.querySelectorAll(sel))) {
49
- if (el.id) ids.add(el.id);
50
- }
51
- } catch {
52
- const lead = idFromSelector(sel);
53
- if (lead) ids.add(lead);
54
- }
55
- }
56
- return Array.from(ids);
57
- }
18
+ export { resolveSelectorElementIds };
19
+
58
20
  /**
59
21
  * The slice of the parse response callers actually read. The endpoint returns
60
22
  * the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
@@ -103,37 +65,6 @@ export async function fetchParsedAnimations(
103
65
  }
104
66
  }
105
67
 
106
- /**
107
- * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
108
- * inside a scene) aren't timeline clips themselves — they're derived at expand time
109
- * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
110
- * and clip-relative keyframe percentages blew past 100% (rendering off the clip).
111
- * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
112
- * data-composition-src is stripped in the rendered DOM, so we can't query it).
113
- */
114
- export function resolveClipTimingBasis(
115
- elementId: string,
116
- sourceFile: string,
117
- elements: ReadonlyArray<{
118
- domId?: string;
119
- key?: string;
120
- id: string;
121
- start: number;
122
- duration: number;
123
- }>,
124
- domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
125
- ): { elStart: number; elDuration: number } {
126
- const direct = elements.find(
127
- (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
128
- );
129
- if (direct) return { elStart: direct.start, elDuration: direct.duration };
130
- const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
131
- const host = hostId
132
- ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
133
- : undefined;
134
- return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
135
- }
136
-
137
68
  /**
138
69
  * Read one composition file's tweens into the keyframe cache. Split out of the
139
70
  * hook so the effect can run it per file without re-nesting the whole body.
@@ -146,10 +77,8 @@ export async function populateKeyframeCacheFromAst(
146
77
  ): Promise<void> {
147
78
  const parsed = await fetchParsedAnimations(projectId, sf);
148
79
  if (!parsed) return;
149
- const { setKeyframeCache } = usePlayerStore.getState();
150
- clearKeyframeCacheForFile(sf);
151
80
  const { elements, domClipChildren } = usePlayerStore.getState();
152
- const mergedByElement = new Map<string, GsapKeyframesData>();
81
+ const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
153
82
  const sourceByElement = new Map<string, GsapAnimation[]>();
154
83
  for (const anim of parsed.animations) {
155
84
  if (anim.hasUnresolvedKeyframes) continue;
@@ -174,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
174
103
  }
175
104
  }
176
105
  }
177
- for (const [id, kfData] of mergedByElement) {
178
- for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
179
- writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
180
- }
106
+ replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
181
107
  }