@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.
- package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- 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 {
|
|
8
|
-
import {
|
|
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 {
|
|
62
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
17
63
|
const idsWithKeyframes = new Set<string>();
|
|
18
|
-
|
|
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 (
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
existing
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
keyframes
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
80
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
121
|
-
* gsapAnimations, with nothing to evict them. Called once before a re-scan,
|
|
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;
|
|
131
|
-
//
|
|
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
|
-
|
|
140
|
-
for (const
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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(
|