@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
@@ -3,8 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
4
4
  import {
5
5
  clearKeyframeCacheForElement,
6
- clearKeyframeCacheForFile,
7
6
  pruneKeyframeCacheToFiles,
7
+ replaceKeyframeCacheForFile,
8
8
  updateKeyframeCacheFromParsed,
9
9
  } from "./gsapKeyframeCacheHelpers";
10
10
 
@@ -70,48 +70,6 @@ describe("clearKeyframeCacheForElement", () => {
70
70
  });
71
71
  });
72
72
 
73
- describe("clearKeyframeCacheForFile", () => {
74
- it("clears the prefixed, fallback, and bare keys for every element of the file", () => {
75
- seed("comp.html#a");
76
- seed("index.html#a");
77
- seed("a");
78
- seed("comp.html#b");
79
- seed("b");
80
-
81
- clearKeyframeCacheForFile("comp.html");
82
-
83
- for (const key of ["comp.html#a", "index.html#a", "a", "comp.html#b", "b"]) {
84
- expect(cache().has(key)).toBe(false);
85
- }
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
-
102
- it("leaves entries that belong to a different source file", () => {
103
- seed("comp.html#a");
104
- seed("a");
105
- seed("other.html#z");
106
- seed("z");
107
-
108
- clearKeyframeCacheForFile("comp.html");
109
-
110
- expect(cache().has("other.html#z")).toBe(true);
111
- expect(cache().has("z")).toBe(true);
112
- });
113
- });
114
-
115
73
  describe("pruneKeyframeCacheToFiles", () => {
116
74
  // Switching composition leaves the previous comp's elements cached with no
117
75
  // owner left to clear them: each file only ever clears its own entries.
@@ -152,9 +110,140 @@ describe("pruneKeyframeCacheToFiles", () => {
152
110
  expect(cache().has("index.html#hero")).toBe(true);
153
111
  expect(cache().has("comp.html#a")).toBe(true);
154
112
  });
113
+
114
+ // The prune runs immediately before the atomic repopulate on every
115
+ // composition switch. One notification per stale element put every subscriber
116
+ // through a cache that was progressively emptier, which is the flash the
117
+ // atomic repopulate exists to avoid.
118
+ it("drops every stale file in one notification", () => {
119
+ seed("old.html#a");
120
+ seed("old.html#b");
121
+ seed("older.html#c");
122
+ seed("kept.html#k");
123
+ let notifications = 0;
124
+ const unsubscribe = usePlayerStore.subscribe(() => {
125
+ notifications += 1;
126
+ });
127
+
128
+ pruneKeyframeCacheToFiles(["kept.html"]);
129
+ unsubscribe();
130
+
131
+ expect(notifications).toBe(1);
132
+ expect([...cache().keys()]).toEqual(["kept.html#k"]);
133
+ });
134
+
135
+ // A prune that finds nothing stale must not hand subscribers a fresh Map
136
+ // identity: every keyframe consumer re-renders on cache identity alone.
137
+ it("does not publish when nothing is stale", () => {
138
+ seed("kept.html#k");
139
+ const before = cache();
140
+ let notifications = 0;
141
+ const unsubscribe = usePlayerStore.subscribe(() => {
142
+ notifications += 1;
143
+ });
144
+
145
+ pruneKeyframeCacheToFiles(["kept.html"]);
146
+ unsubscribe();
147
+
148
+ expect(notifications).toBe(0);
149
+ expect(cache()).toBe(before);
150
+ });
151
+ });
152
+
153
+ describe("replaceKeyframeCacheForFile", () => {
154
+ it("publishes complete keyframe and animation maps in one notification", () => {
155
+ const staleEntry = entry();
156
+ const otherEntry = entry();
157
+ const staleAnimation = animWithKeyframes("stale");
158
+ const freshAnimation = animWithKeyframes("fresh");
159
+ usePlayerStore.setState({
160
+ keyframeCache: new Map([
161
+ ["scene.html#stale", staleEntry],
162
+ ["index.html#stale", staleEntry],
163
+ ["stale", staleEntry],
164
+ ["other.html#other", otherEntry],
165
+ ["other", otherEntry],
166
+ ]),
167
+ gsapAnimations: new Map([
168
+ ["scene.html#stale", [staleAnimation]],
169
+ ["index.html#stale", [staleAnimation]],
170
+ ["stale", [staleAnimation]],
171
+ ["other.html#other", [staleAnimation]],
172
+ ["other", [staleAnimation]],
173
+ ]),
174
+ });
175
+ const snapshots: Array<{ cacheKeys: string[]; animationKeys: string[] }> = [];
176
+ const unsubscribe = usePlayerStore.subscribe((state) => {
177
+ snapshots.push({
178
+ cacheKeys: [...state.keyframeCache.keys()].sort(),
179
+ animationKeys: [...state.gsapAnimations.keys()].sort(),
180
+ });
181
+ });
182
+
183
+ replaceKeyframeCacheForFile(
184
+ "scene.html",
185
+ new Map([["fresh", entry()]]),
186
+ new Map([["fresh", [freshAnimation]]]),
187
+ );
188
+ unsubscribe();
189
+
190
+ expect(snapshots).toEqual([
191
+ {
192
+ cacheKeys: ["fresh", "index.html#fresh", "other", "other.html#other", "scene.html#fresh"],
193
+ animationKeys: [
194
+ "fresh",
195
+ "index.html#fresh",
196
+ "other",
197
+ "other.html#other",
198
+ "scene.html#fresh",
199
+ ],
200
+ },
201
+ ]);
202
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#fresh")).toEqual([
203
+ freshAnimation,
204
+ ]);
205
+ });
155
206
  });
156
207
 
157
208
  describe("updateKeyframeCacheFromParsed", () => {
209
+ it("records colliding animation targets with their own tween percentages", () => {
210
+ const animation = (
211
+ id: string,
212
+ propertyGroup: string,
213
+ properties: Record<string, number>,
214
+ percentage: number,
215
+ resolvedStart: number,
216
+ ): GsapAnimation => ({
217
+ ...animWithKeyframes(id),
218
+ targetSelector: "#hero",
219
+ propertyGroup,
220
+ resolvedStart,
221
+ keyframes: { format: "percentage", keyframes: [{ percentage, properties }] },
222
+ });
223
+
224
+ usePlayerStore.setState({
225
+ elements: [{ id: "hero", domId: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
226
+ });
227
+
228
+ updateKeyframeCacheFromParsed(
229
+ [
230
+ animation("hero-position", "position", { x: 100 }, 50, 0.5),
231
+ animation("hero-visual", "visual", { opacity: 1 }, 80, 0.2),
232
+ animation("hero-position", "position", { y: 50 }, 25, 0.75),
233
+ animation("hero-scale", "scale", { scale: 2 }, 60, 0.4),
234
+ ],
235
+ "scene.html",
236
+ "hero",
237
+ {},
238
+ );
239
+
240
+ expect(cache().get("scene.html#hero")?.keyframes[0]?.collidingAnimationTargets).toEqual([
241
+ { animationId: "hero-position", tweenPercentage: 50 },
242
+ { animationId: "hero-visual", tweenPercentage: 80 },
243
+ { animationId: "hero-scale", tweenPercentage: 60 },
244
+ ]);
245
+ });
246
+
158
247
  it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
159
248
  const animation: GsapAnimation = {
160
249
  ...animWithKeyframes("hero"),
@@ -276,6 +365,69 @@ describe("updateKeyframeCacheFromParsed", () => {
276
365
  expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
277
366
  });
278
367
 
368
+ // `#stat3 .block` animates the BLOCK inside #stat3, not #stat3. An unanchored
369
+ // `^#([\w-]+)/` prefix match filed it under "stat3", which both stole the
370
+ // child's diamonds and collided with #stat3's own tween at the shared
371
+ // percentage (dropping #stat3's ease as ambiguous).
372
+ it("attributes a descendant selector to the child, not to its ancestor", () => {
373
+ const parent: GsapAnimation = {
374
+ id: "stat3-fromTo",
375
+ targetSelector: "#stat3",
376
+ method: "fromTo",
377
+ position: 8.88,
378
+ resolvedStart: 8.88,
379
+ duration: 0.25,
380
+ fromProperties: { y: 20 },
381
+ properties: { y: 0 },
382
+ ease: "power2.out",
383
+ propertyGroup: "position",
384
+ };
385
+ const child: GsapAnimation = {
386
+ id: "block-from",
387
+ targetSelector: "#stat3 .block",
388
+ method: "from",
389
+ position: 9.13,
390
+ resolvedStart: 9.13,
391
+ duration: 0.3,
392
+ properties: { opacity: 0 },
393
+ ease: "power2.in",
394
+ propertyGroup: "visual",
395
+ };
396
+ usePlayerStore.setState({
397
+ elements: [
398
+ { id: "stat3-clip", domId: "stat3", tag: "div", start: 8.88, duration: 1, track: 0 },
399
+ ],
400
+ });
401
+ const doc = {
402
+ querySelectorAll: (selector: string) =>
403
+ (selector === "#stat3 .block"
404
+ ? [{ id: "stat3-block" }]
405
+ : []) as unknown as NodeListOf<Element>,
406
+ } as unknown as Document;
407
+
408
+ updateKeyframeCacheFromParsed([parent, child], "scene.html", "stat3", {}, doc);
409
+
410
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3")).toEqual([parent]);
411
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3-block")).toEqual([child]);
412
+ // With the collision gone, #stat3's own curve survives instead of being
413
+ // deleted as an ambiguous same-percentage merge.
414
+ const parentKeyframes = cache().get("scene.html#stat3")?.keyframes ?? [];
415
+ expect(parentKeyframes.at(-1)?.ease).toBe("power2.out");
416
+ expect(parentKeyframes.some((keyframe) => "easeAmbiguous" in keyframe)).toBe(false);
417
+ });
418
+
419
+ it("attributes a descendant selector to nothing when there is no document", () => {
420
+ const child: GsapAnimation = {
421
+ ...animWithKeyframes("block-from"),
422
+ targetSelector: "#stat3 .block",
423
+ };
424
+
425
+ updateKeyframeCacheFromParsed([child], "scene.html", "stat3", {});
426
+
427
+ expect(cache().has("scene.html#stat3")).toBe(false);
428
+ expect(usePlayerStore.getState().gsapAnimations.has("scene.html#stat3")).toBe(false);
429
+ });
430
+
279
431
  it("does not cache a flat tween without animatable numeric properties", () => {
280
432
  const animation: GsapAnimation = {
281
433
  id: "flat-box",
@@ -4,66 +4,148 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
- import { idFromSelector, toClipKeyframes } from "./gsapShared";
8
- import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
7
+ import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
8
+ import {
9
+ deduplicateKeyframes,
10
+ synthesizeFlatTweenKeyframes,
11
+ type MergeableKeyframe,
12
+ } from "./gsapTweenSynth";
13
+
14
+ /** Both cache maps mid-edit, before the single store publish. */
15
+ export interface KeyframeCacheDraft {
16
+ keyframeCache: Map<string, KeyframeCacheEntry>;
17
+ gsapAnimations: Map<string, GsapAnimation[]>;
18
+ }
19
+
20
+ function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
21
+ if (before.size !== after.size) return false;
22
+ for (const [key, value] of before) {
23
+ if (after.get(key) !== value) return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Every multi-key cache writer publishes through here: clone both maps once,
30
+ * let the caller edit the drafts, publish once. The per-key store setters turned
31
+ * one logical refresh into N notifications, and every subscriber that rendered
32
+ * in between saw a cache that was half the old file and half the new one. The
33
+ * identity check keeps an edit that changed nothing from allocating fresh maps
34
+ * and re-rendering every subscriber, which is the guard the per-key setters used
35
+ * to carry individually.
36
+ */
37
+ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void): void {
38
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
39
+ const draft: KeyframeCacheDraft = {
40
+ keyframeCache: new Map(keyframeCache),
41
+ gsapAnimations: new Map(gsapAnimations),
42
+ };
43
+ edit(draft);
44
+ if (
45
+ sameEntries(keyframeCache, draft.keyframeCache) &&
46
+ sameEntries(gsapAnimations, draft.gsapAnimations)
47
+ )
48
+ return;
49
+ usePlayerStore.setState({
50
+ keyframeCache: draft.keyframeCache,
51
+ gsapAnimations: draft.gsapAnimations,
52
+ });
53
+ }
9
54
 
10
55
  export function updateKeyframeCacheFromParsed(
11
56
  animations: GsapAnimation[],
12
57
  targetPath: string,
13
58
  selectionId: string | undefined,
14
59
  mutation: Record<string, unknown>,
60
+ doc?: Document | null,
15
61
  ): void {
16
- const { setKeyframeCache, elements } = usePlayerStore.getState();
62
+ const { elements, domClipChildren } = usePlayerStore.getState();
17
63
  const idsWithKeyframes = new Set<string>();
18
- const merged = new Map<string, KeyframeCacheEntry>();
64
+ // Attributed keyframes only: everything in here came from a parsed tween via
65
+ // toClipKeyframes, so the merge can rely on the source identity. It widens
66
+ // back into KeyframeCacheEntry on the way to the store, which also holds the
67
+ // runtime scan's unattributed keyframes.
68
+ const merged = new Map<string, KeyframeCacheEntry & { keyframes: MergeableKeyframe[] }>();
19
69
  const sourceAnimations = new Map<string, GsapAnimation[]>();
20
70
  for (const anim of animations) {
21
- const id = idFromSelector(anim.targetSelector);
22
71
  const kfSource =
23
72
  anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
24
- if (!id || kfSource.length === 0) continue;
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]);
32
-
33
- // Convert tween-relative percentages to clip-relative so diamonds
34
- // render at the correct position within the timeline clip.
35
- const timelineEl = elements.find(
36
- (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
37
- );
38
- const clipKeyframes = toClipKeyframes(
39
- kfSource,
40
- anim,
41
- timelineEl?.start ?? 0,
42
- timelineEl?.duration ?? 1,
43
- );
44
-
45
- const existing = merged.get(id);
46
- if (existing) {
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]);
51
- } else {
52
- merged.set(id, {
53
- ...anim.keyframes,
54
- format: anim.keyframes?.format ?? "percentage",
55
- keyframes: clipKeyframes,
56
- });
73
+ if (kfSource.length === 0) continue;
74
+ // Attribute the tween to every element it actually animates. A leading-id
75
+ // match filed `#stat3 .block` under `#stat3`: the child's diamonds landed on
76
+ // its ancestor AND collided with the ancestor's own tween at the shared
77
+ // percentage, which the same-% merge then resolved by dropping the ease.
78
+ for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
79
+ idsWithKeyframes.add(id);
80
+ // Every tween that fed keyframeCache also lands in gsapAnimations, group or
81
+ // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
82
+ // cache diamonds with no source animation behind them, so the collapsed row
83
+ // drew keyframes the expanded lanes couldn't render. Lane consumers do the
84
+ // group filtering themselves (animationContributesLane).
85
+ sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
86
+
87
+ // Convert tween-relative percentages to clip-relative so diamonds
88
+ // render at the correct position within the timeline clip. The basis comes
89
+ // from the shared resolver, so this writer agrees with the AST load on both
90
+ // the sub-comp host fallback and the tween's own time frame.
91
+ const { elStart, elDuration } = resolveClipTimingBasis(
92
+ id,
93
+ targetPath,
94
+ elements,
95
+ domClipChildren,
96
+ );
97
+ const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
98
+
99
+ const existing = merged.get(id);
100
+ if (existing) {
101
+ // deduplicateKeyframes owns the same-% merge (including the colliding
102
+ // animation targets downstream lanes read); a second copy of that rule
103
+ // here is how the two writers drift.
104
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
105
+ } else {
106
+ merged.set(id, {
107
+ ...anim.keyframes,
108
+ format: anim.keyframes?.format ?? "percentage",
109
+ keyframes: clipKeyframes,
110
+ });
111
+ }
57
112
  }
58
113
  }
59
- for (const [id, entry] of merged) {
60
- for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
61
- writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
114
+ const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
115
+ const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
116
+ const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
117
+ publishKeyframeCache((draft) => {
118
+ for (const [id, entry] of merged) {
119
+ writeElementIntoDraft(draft, targetPath, id, entry, sourceAnimations.get(id));
120
+ }
121
+ for (const targetId of targetIds) {
122
+ if (!idsWithKeyframes.has(targetId)) deleteElementFromDraft(draft, targetPath, targetId);
123
+ }
124
+ });
125
+ }
126
+
127
+ function writeElementIntoDraft(
128
+ draft: KeyframeCacheDraft,
129
+ sourceFile: string,
130
+ elementId: string,
131
+ entry: KeyframeCacheEntry,
132
+ animations: GsapAnimation[] | undefined,
133
+ ): void {
134
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
135
+ draft.keyframeCache.set(key, entry);
136
+ if (animations) draft.gsapAnimations.set(key, animations);
137
+ else draft.gsapAnimations.delete(key);
62
138
  }
63
- const targetId =
64
- idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
65
- if (targetId && !idsWithKeyframes.has(targetId)) {
66
- clearKeyframeCacheForElement(targetPath, targetId);
139
+ }
140
+
141
+ function deleteElementFromDraft(
142
+ draft: KeyframeCacheDraft,
143
+ sourceFile: string,
144
+ elementId: string,
145
+ ): void {
146
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
147
+ draft.keyframeCache.delete(key);
148
+ draft.gsapAnimations.delete(key);
67
149
  }
68
150
  }
69
151
 
@@ -76,50 +158,37 @@ export function updateKeyframeCacheFromParsed(
76
158
  * preview overlay) fall back to the bare id when an element has no
77
159
  * source-prefixed key — so a clear that drops only the prefixed keys leaves the
78
160
  * bare entry behind and those readers keep showing keyframes the element no
79
- * longer has. Each delete is guarded by `has` so an absent key doesn't allocate
80
- * a new cache map and re-render every subscriber.
161
+ * longer has. publishKeyframeCache skips the store write entirely when none of
162
+ * the keys were present, so an absent element doesn't re-render every
163
+ * subscriber.
81
164
  */
82
165
  export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
83
- const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
84
- usePlayerStore.getState();
85
- const keys = elementCacheKeys(sourceFile, elementId);
86
- for (const key of keys) {
87
- if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
88
- if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
89
- }
166
+ publishKeyframeCache((draft) => deleteElementFromDraft(draft, sourceFile, elementId));
90
167
  }
91
168
 
92
- /**
93
- * Clear every cached element of `sourceFile` before a full re-scan repopulates
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.
102
- */
103
- export function clearKeyframeCacheForFile(sourceFile: string): void {
104
- const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
169
+ function cachedElementIdsForFile(
170
+ sourceFile: string,
171
+ keyframeCache: ReadonlyMap<string, KeyframeCacheEntry>,
172
+ gsapAnimations: ReadonlyMap<string, GsapAnimation[]>,
173
+ ): Set<string> {
105
174
  const sfPrefix = `${sourceFile}#`;
106
175
  const ids = new Set<string>();
107
176
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
108
177
  if (!key.startsWith(sfPrefix)) continue;
109
178
  ids.add(key.slice(sfPrefix.length));
110
179
  }
111
- for (const id of ids) {
112
- clearKeyframeCacheForElement(sourceFile, id);
113
- }
180
+ return ids;
114
181
  }
115
182
 
116
183
  /**
117
184
  * 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
185
+ * file only ever writes its OWN prefixed entries (see elementCacheKeys), so
119
186
  * 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.
187
+ * (240 entries per switch on a 120-clip comp, in both keyframeCache and
188
+ * gsapAnimations, with nothing to evict them). Called once before a re-scan,
189
+ * with the full set of files that scan covers, and it publishes once: the prune
190
+ * runs immediately before the atomic repopulate, so a per-element publish here
191
+ * would reintroduce the empty-cache flash that repopulate was made to avoid.
123
192
  */
124
193
  export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
125
194
  const keep = new Set(files);
@@ -127,8 +196,8 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
127
196
  const stale = new Map<string, Set<string>>();
128
197
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
129
198
  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.
199
+ // Bare-id aliases carry no owner; deleteElementFromDraft takes them with
200
+ // their prefixed key, so skipping them here loses nothing.
132
201
  if (hash < 0) continue;
133
202
  const sourceFile = key.slice(0, hash);
134
203
  if (keep.has(sourceFile)) continue;
@@ -136,9 +205,25 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
136
205
  ids.add(key.slice(hash + 1));
137
206
  stale.set(sourceFile, ids);
138
207
  }
139
- for (const [sourceFile, ids] of stale) {
140
- for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
141
- }
208
+ publishKeyframeCache((draft) => {
209
+ for (const [sourceFile, ids] of stale) {
210
+ for (const id of ids) deleteElementFromDraft(draft, sourceFile, id);
211
+ }
212
+ });
213
+ }
214
+
215
+ /**
216
+ * The source-scoped key that names one element across panels and the store
217
+ * (focused ease segment, keyframe cache reads). Four call sites built this
218
+ * string by hand and two of them omitted the index.html fallback, so an element
219
+ * with no sourceFile was addressed as `#box` by one panel and `index.html#box`
220
+ * by another and the two never matched. One builder keeps them on one key.
221
+ */
222
+ export function scopedElementKey(element: {
223
+ sourceFile?: string | null;
224
+ id?: string | null;
225
+ }): string {
226
+ return `${element.sourceFile || "index.html"}#${element.id}`;
142
227
  }
143
228
 
144
229
  /** Every cache key a write for this element sets, in read-preference order. */
@@ -148,15 +233,37 @@ export function elementCacheKeys(sourceFile: string, elementId: string): string[
148
233
  : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
149
234
  }
150
235
 
236
+ /** Replace one file's complete cache snapshot with one atomic store publish. */
237
+ export function replaceKeyframeCacheForFile(
238
+ sourceFile: string,
239
+ entries: ReadonlyMap<string, KeyframeCacheEntry>,
240
+ animationsByElement: ReadonlyMap<string, GsapAnimation[]>,
241
+ ): void {
242
+ publishKeyframeCache((draft) => {
243
+ for (const id of cachedElementIdsForFile(
244
+ sourceFile,
245
+ draft.keyframeCache,
246
+ draft.gsapAnimations,
247
+ )) {
248
+ deleteElementFromDraft(draft, sourceFile, id);
249
+ }
250
+ for (const [id, entry] of entries) {
251
+ writeElementIntoDraft(draft, sourceFile, id, entry, animationsByElement.get(id));
252
+ }
253
+ });
254
+ }
255
+
151
256
  export function writeGsapAnimationsForElement(
152
257
  sourceFile: string,
153
258
  elementId: string,
154
259
  animations: GsapAnimation[] | undefined,
155
260
  ): void {
156
- const { setGsapAnimations } = usePlayerStore.getState();
157
- for (const key of elementCacheKeys(sourceFile, elementId)) {
158
- setGsapAnimations(key, animations);
159
- }
261
+ publishKeyframeCache((draft) => {
262
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
263
+ if (animations) draft.gsapAnimations.set(key, animations);
264
+ else draft.gsapAnimations.delete(key);
265
+ }
266
+ });
160
267
  }
161
268
 
162
269
  function buildCacheKey(sourceFile: string, elementId: string): string {
@@ -1,7 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4
- import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared";
4
+ import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
5
5
  import { roundToCenti } from "../utils/rounding";
6
6
 
7
7
  type CommitFn = (
@@ -22,6 +22,9 @@ export async function commitKeyframeAtTimeImpl(
22
22
  properties: Record<string, number | string>,
23
23
  commitMutation: CommitFn,
24
24
  ): Promise<void> {
25
+ // Matching an authored tween is a string compare against what the author
26
+ // wrote, so it keeps using the selection's own selector; the NEW tween below
27
+ // is authored with the one-element form instead.
25
28
  const selector = selectorFromSelection(selection);
26
29
  if (!selector) return;
27
30
 
@@ -60,12 +63,18 @@ export async function commitKeyframeAtTimeImpl(
60
63
  },
61
64
  );
62
65
  } else {
66
+ // Null means the live DOM could not prove any one-element form. Falling
67
+ // back to the author's own selector would write the group-collapsing
68
+ // target this narrowing exists to prevent, so the keyframe is dropped
69
+ // instead (see writeTargetSelector).
70
+ const target = writeTargetSelector(selection);
71
+ if (!target) return;
63
72
  const defaultDuration = 0.5;
64
73
  await commitMutation(
65
74
  selection,
66
75
  {
67
76
  type: "add-with-keyframes" as const,
68
- targetSelector: selector,
77
+ targetSelector: target,
69
78
  position: absoluteTime,
70
79
  duration: defaultDuration,
71
80
  keyframes: [
@@ -79,7 +79,7 @@ export async function tryGsapResizeIntercept(
79
79
  if (!anim || isInstantHold(anim)) {
80
80
  const sel = selectorFromSelection(selection);
81
81
  if (!sel) return false;
82
- const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
82
+ const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
83
83
 
84
84
  // If the element is animated (has a real tween, not just a static size
85
85
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
@@ -242,7 +242,7 @@ export async function tryGsapResizeIntercept(
242
242
  const currentAnimations = fetchFallbackAnimations
243
243
  ? await fetchFallbackAnimations()
244
244
  : (resolved?.animations ?? animations);
245
- const existingSet = findExistingPositionWrite(currentAnimations, selector);
245
+ const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
246
246
  // Delta chosen so the drag-path math composes back to exactly `corrected`
247
247
  // (no drag scratch attrs exist during a resize, so base = gsapPos).
248
248
  await commitStaticGsapPosition(