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