@hyperframes/studio 0.7.79 → 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-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
- package/dist/assets/{index-hmnoiSEV.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 +652 -465
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -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/MotionPathOverlay.tsx +54 -19
- package/src/components/editor/PropertyPanel.tsx +4 -0
- 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/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/propertyPanelTypes.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +4 -12
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useGsapTweenCache.ts +40 -34
- 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/TimelineClipDiamonds.test.tsx +18 -6
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
- package/src/player/store/keyframeSlice.ts +15 -5
- package/dist/assets/index-gGVKuFg5.css +0 -1
|
@@ -5,7 +5,52 @@
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
7
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
8
|
-
import {
|
|
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[],
|
|
@@ -14,9 +59,13 @@ export function updateKeyframeCacheFromParsed(
|
|
|
14
59
|
mutation: Record<string, unknown>,
|
|
15
60
|
doc?: Document | null,
|
|
16
61
|
): void {
|
|
17
|
-
const {
|
|
62
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
18
63
|
const idsWithKeyframes = new Set<string>();
|
|
19
|
-
|
|
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[] }>();
|
|
20
69
|
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
|
21
70
|
for (const anim of animations) {
|
|
22
71
|
const kfSource =
|
|
@@ -49,9 +98,9 @@ export function updateKeyframeCacheFromParsed(
|
|
|
49
98
|
|
|
50
99
|
const existing = merged.get(id);
|
|
51
100
|
if (existing) {
|
|
52
|
-
// deduplicateKeyframes owns the same-% merge (including the
|
|
53
|
-
//
|
|
54
|
-
// two writers drift.
|
|
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.
|
|
55
104
|
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
56
105
|
} else {
|
|
57
106
|
merged.set(id, {
|
|
@@ -62,15 +111,41 @@ export function updateKeyframeCacheFromParsed(
|
|
|
62
111
|
}
|
|
63
112
|
}
|
|
64
113
|
}
|
|
65
|
-
for (const [id, entry] of merged) {
|
|
66
|
-
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
|
|
67
|
-
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
|
|
68
|
-
}
|
|
69
114
|
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
|
|
70
115
|
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
|
|
71
116
|
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
|
138
|
+
}
|
|
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);
|
|
74
149
|
}
|
|
75
150
|
}
|
|
76
151
|
|
|
@@ -83,50 +158,37 @@ export function updateKeyframeCacheFromParsed(
|
|
|
83
158
|
* preview overlay) fall back to the bare id when an element has no
|
|
84
159
|
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
|
|
85
160
|
* bare entry behind and those readers keep showing keyframes the element no
|
|
86
|
-
* longer has.
|
|
87
|
-
*
|
|
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.
|
|
88
164
|
*/
|
|
89
165
|
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
|
90
|
-
|
|
91
|
-
usePlayerStore.getState();
|
|
92
|
-
const keys = elementCacheKeys(sourceFile, elementId);
|
|
93
|
-
for (const key of keys) {
|
|
94
|
-
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
|
95
|
-
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
|
|
96
|
-
}
|
|
166
|
+
publishKeyframeCache((draft) => deleteElementFromDraft(draft, sourceFile, elementId));
|
|
97
167
|
}
|
|
98
168
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
* index.html alias and the bare key with them — an element whose keyframes were
|
|
105
|
-
* removed (and so is absent from the re-scan) leaves no stale bare entry
|
|
106
|
-
* behind. Reading the alias prefix here instead would collect ids owned by
|
|
107
|
-
* OTHER files, and several files re-scan concurrently, so this file's clear
|
|
108
|
-
* would wipe the entries a sibling file had just written.
|
|
109
|
-
*/
|
|
110
|
-
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
|
111
|
-
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
|
169
|
+
function cachedElementIdsForFile(
|
|
170
|
+
sourceFile: string,
|
|
171
|
+
keyframeCache: ReadonlyMap<string, KeyframeCacheEntry>,
|
|
172
|
+
gsapAnimations: ReadonlyMap<string, GsapAnimation[]>,
|
|
173
|
+
): Set<string> {
|
|
112
174
|
const sfPrefix = `${sourceFile}#`;
|
|
113
175
|
const ids = new Set<string>();
|
|
114
176
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
|
115
177
|
if (!key.startsWith(sfPrefix)) continue;
|
|
116
178
|
ids.add(key.slice(sfPrefix.length));
|
|
117
179
|
}
|
|
118
|
-
|
|
119
|
-
clearKeyframeCacheForElement(sourceFile, id);
|
|
120
|
-
}
|
|
180
|
+
return ids;
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
/**
|
|
124
184
|
* Drop every cached element owned by a file that is no longer on screen. Each
|
|
125
|
-
* file only ever
|
|
185
|
+
* file only ever writes its OWN prefixed entries (see elementCacheKeys), so
|
|
126
186
|
* switching composition left the previous composition's elements cached forever
|
|
127
|
-
*
|
|
128
|
-
* gsapAnimations, with nothing to evict them. Called once before a re-scan,
|
|
129
|
-
* 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.
|
|
130
192
|
*/
|
|
131
193
|
export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
|
132
194
|
const keep = new Set(files);
|
|
@@ -134,8 +196,8 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
|
|
134
196
|
const stale = new Map<string, Set<string>>();
|
|
135
197
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
|
136
198
|
const hash = key.indexOf("#");
|
|
137
|
-
// Bare-id aliases carry no owner;
|
|
138
|
-
//
|
|
199
|
+
// Bare-id aliases carry no owner; deleteElementFromDraft takes them with
|
|
200
|
+
// their prefixed key, so skipping them here loses nothing.
|
|
139
201
|
if (hash < 0) continue;
|
|
140
202
|
const sourceFile = key.slice(0, hash);
|
|
141
203
|
if (keep.has(sourceFile)) continue;
|
|
@@ -143,9 +205,25 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
|
|
143
205
|
ids.add(key.slice(hash + 1));
|
|
144
206
|
stale.set(sourceFile, ids);
|
|
145
207
|
}
|
|
146
|
-
|
|
147
|
-
for (const
|
|
148
|
-
|
|
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}`;
|
|
149
227
|
}
|
|
150
228
|
|
|
151
229
|
/** Every cache key a write for this element sets, in read-preference order. */
|
|
@@ -155,15 +233,37 @@ export function elementCacheKeys(sourceFile: string, elementId: string): string[
|
|
|
155
233
|
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
|
156
234
|
}
|
|
157
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
|
+
|
|
158
256
|
export function writeGsapAnimationsForElement(
|
|
159
257
|
sourceFile: string,
|
|
160
258
|
elementId: string,
|
|
161
259
|
animations: GsapAnimation[] | undefined,
|
|
162
260
|
): void {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
});
|
|
167
267
|
}
|
|
168
268
|
|
|
169
269
|
function buildCacheKey(sourceFile: string, elementId: string): string {
|
|
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
describe("deduplicateKeyframes
|
|
58
|
-
it("
|
|
57
|
+
describe("deduplicateKeyframes colliding animation targets", () => {
|
|
58
|
+
it("records each animation's tween percentage in first-seen order", () => {
|
|
59
59
|
const merged = deduplicateKeyframes([
|
|
60
|
-
{
|
|
61
|
-
|
|
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?.
|
|
76
|
+
expect(kf?.collidingAnimationTargets).toEqual([
|
|
77
|
+
{ animationId: "#a-position", tweenPercentage: 20 },
|
|
78
|
+
{ animationId: "#a-visual", tweenPercentage: 80 },
|
|
79
|
+
]);
|
|
65
80
|
});
|
|
66
81
|
|
|
67
|
-
it("
|
|
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
|
-
{
|
|
72
|
-
|
|
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("
|
|
120
|
+
it("leaves the collision set undefined within a single animation", () => {
|
|
78
121
|
const merged = deduplicateKeyframes([
|
|
79
|
-
{
|
|
80
|
-
|
|
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)?.
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
//
|
|
35
|
-
// arbitrary
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 } });
|
|
@@ -6,16 +6,13 @@
|
|
|
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";
|
|
9
|
+
import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
|
|
14
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
18
|
export { resolveSelectorElementIds };
|
|
@@ -80,10 +77,8 @@ export async function populateKeyframeCacheFromAst(
|
|
|
80
77
|
): Promise<void> {
|
|
81
78
|
const parsed = await fetchParsedAnimations(projectId, sf);
|
|
82
79
|
if (!parsed) return;
|
|
83
|
-
const { setKeyframeCache } = usePlayerStore.getState();
|
|
84
|
-
clearKeyframeCacheForFile(sf);
|
|
85
80
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
86
|
-
const mergedByElement = new Map<string, GsapKeyframesData
|
|
81
|
+
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
|
|
87
82
|
const sourceByElement = new Map<string, GsapAnimation[]>();
|
|
88
83
|
for (const anim of parsed.animations) {
|
|
89
84
|
if (anim.hasUnresolvedKeyframes) continue;
|
|
@@ -108,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
|
|
|
108
103
|
}
|
|
109
104
|
}
|
|
110
105
|
}
|
|
111
|
-
|
|
112
|
-
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
|
|
113
|
-
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
|
114
|
-
}
|
|
106
|
+
replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
|
|
115
107
|
}
|