@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.
Files changed (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
  4. package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. package/dist/assets/index-gGVKuFg5.css +0 -1
@@ -0,0 +1,37 @@
1
+ // Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold`
2
+ // ease against the engine that actually runs it, and gsap is a studio
3
+ // dependency. core's own customEase.test.ts covers the resolver in isolation.
4
+ import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease";
5
+ import { gsap } from "gsap";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ describe("Studio hold ease", () => {
9
+ it("holds the start value under seek until the destination time", () => {
10
+ const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
11
+ expect(installStudioCustomEase(runtimeGsap)).toBe(true);
12
+ const hold = runtimeGsap.parseEase("hold");
13
+ expect(hold).toBeTypeOf("function");
14
+ if (typeof hold !== "function") return;
15
+
16
+ const target = { value: 0 };
17
+ const timeline = gsap.timeline({ paused: true }).to(
18
+ target,
19
+ {
20
+ value: 100,
21
+ duration: 2,
22
+ ease: hold,
23
+ },
24
+ 0,
25
+ );
26
+
27
+ timeline.seek(0.5);
28
+ expect(target.value).toBe(0);
29
+ timeline.seek(1);
30
+ expect(target.value).toBe(0);
31
+ timeline.seek(1.99);
32
+ expect(target.value).toBe(0);
33
+ timeline.seek(2);
34
+ expect(target.value).toBe(100);
35
+ timeline.kill();
36
+ });
37
+ });
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
7
7
  import type { DomEditSelection } from "./domEditing";
8
+ import { usePlayerStore } from "../../player";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
11
12
  afterEach(() => {
12
13
  document.body.innerHTML = "";
14
+ // The store is module-global, so a test that parks a focused ease segment
15
+ // would otherwise leak it into every test that runs after it.
16
+ usePlayerStore.getState().reset();
13
17
  });
14
18
 
15
19
  function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
@@ -270,3 +274,68 @@ describe("FlatMotionSection", () => {
270
274
  act(() => root.unmount());
271
275
  });
272
276
  });
277
+
278
+ it("forwards focused bulk segment easing through the flat animation card", () => {
279
+ const onUpdateKeyframeEase = vi.fn();
280
+ const onUpdateSegmentEase = vi.fn();
281
+ usePlayerStore.setState({
282
+ focusedEaseSegment: {
283
+ elementId: "index.html#hero",
284
+ animationId: "a1",
285
+ tweenPercentage: 50,
286
+ collidingAnimationTargets: [
287
+ { animationId: "a1", tweenPercentage: 50 },
288
+ { animationId: "a2", tweenPercentage: 75 },
289
+ ],
290
+ },
291
+ });
292
+ const { host, root } = renderInto(
293
+ <FlatMotionSection
294
+ element={baseElement()}
295
+ animations={[
296
+ {
297
+ id: "a1",
298
+ method: "to",
299
+ position: 0,
300
+ duration: 1,
301
+ properties: { x: 100 },
302
+ keyframes: {
303
+ format: "percentage",
304
+ keyframes: [
305
+ { percentage: 0, properties: { x: 0 } },
306
+ { percentage: 50, properties: { x: 100 } },
307
+ ],
308
+ },
309
+ } as never,
310
+ ]}
311
+ showTiming={false}
312
+ showEffects
313
+ onSetAttribute={vi.fn()}
314
+ onAddAnimation={vi.fn()}
315
+ onUpdateProperty={vi.fn()}
316
+ onUpdateMeta={vi.fn()}
317
+ onDeleteAnimation={vi.fn()}
318
+ onAddProperty={vi.fn()}
319
+ onRemoveProperty={vi.fn()}
320
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
321
+ onUpdateSegmentEase={onUpdateSegmentEase}
322
+ />,
323
+ );
324
+
325
+ const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
326
+ expect(dropdown).not.toBeNull();
327
+ act(() => dropdown?.click());
328
+ const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
329
+ expect(preset).not.toBeNull();
330
+ act(() => preset?.click());
331
+
332
+ expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
333
+ [
334
+ { animationId: "a1", tweenPercentage: 50 },
335
+ { animationId: "a2", tweenPercentage: 75 },
336
+ ],
337
+ "power2.out",
338
+ );
339
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
340
+ act(() => root.unmount());
341
+ });
@@ -1,3 +1,4 @@
1
+ import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
1
2
  import { useState } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
@@ -143,7 +144,7 @@ export function FlatMotionSection({
143
144
  // the store's selectedElementId, which flips synchronously during async
144
145
  // selection resolution), so a shared class-selector animation id can't open
145
146
  // the wrong element's editor.
146
- const renderedElementId = `${element.sourceFile}#${element.id}`;
147
+ const renderedElementId = scopedElementKey(element);
147
148
  const focusedHere =
148
149
  focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
149
150
  ? focusedEaseSegment
@@ -0,0 +1,91 @@
1
+ import type { resolveEditingSections } from "@hyperframes/core/editing";
2
+ import type { DomEditSelection } from "./domEditing";
3
+ import type { PropertyPanelProps } from "./propertyPanelHelpers";
4
+ import type { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
5
+
6
+ export type PropertyPanelFlatProps = Pick<
7
+ PropertyPanelProps,
8
+ | "projectId"
9
+ | "projectDir"
10
+ | "assets"
11
+ | "previewIframeRef"
12
+ | "onClearSelection"
13
+ | "onUngroup"
14
+ | "onSetStyle"
15
+ | "onPreviewStyle"
16
+ | "onSetAttribute"
17
+ | "onSetAttributes"
18
+ | "onSetAttributeLive"
19
+ | "onApplyColorGradingScope"
20
+ | "onSetHtmlAttribute"
21
+ | "onRemoveBackground"
22
+ | "onSetText"
23
+ | "onSetTextFieldStyle"
24
+ | "onPreviewTextFieldStyle"
25
+ | "onAddTextField"
26
+ | "onRemoveTextField"
27
+ | "onAskAgent"
28
+ | "onToggleElementHidden"
29
+ | "onImportAssets"
30
+ | "onAddMediaOverlay"
31
+ | "onImportFonts"
32
+ | "fontAssets"
33
+ | "gsapAnimations"
34
+ | "gsapMultipleTimelines"
35
+ | "gsapUnsupportedTimelinePattern"
36
+ | "onUpdateGsapProperty"
37
+ | "onUpdateGsapMeta"
38
+ | "onDeleteGsapAnimation"
39
+ | "onAddGsapProperty"
40
+ | "onRemoveGsapProperty"
41
+ | "onUpdateGsapFromProperty"
42
+ | "onAddGsapFromProperty"
43
+ | "onRemoveGsapFromProperty"
44
+ | "onAddGsapAnimation"
45
+ | "onSetArcPath"
46
+ | "onUpdateArcSegment"
47
+ | "onUnroll"
48
+ | "onUpdateKeyframeEase"
49
+ | "onSetAllKeyframeEases"
50
+ | "onUpdateSegmentEase"
51
+ | "recordingState"
52
+ | "recordingDuration"
53
+ | "onToggleRecording"
54
+ > &
55
+ Pick<
56
+ Parameters<typeof FlatLayoutSection>[0],
57
+ | "displayX"
58
+ | "displayY"
59
+ | "displayW"
60
+ | "displayH"
61
+ | "displayR"
62
+ | "manualOffsetEditingDisabled"
63
+ | "manualSizeEditingDisabled"
64
+ | "manualRotationEditingDisabled"
65
+ | "commitManualOffset"
66
+ | "commitManualSize"
67
+ | "commitManualRotation"
68
+ | "gsapAnimId"
69
+ | "navKeyframes"
70
+ | "animIdForProp"
71
+ | "gsapRuntimeValues"
72
+ | "elStart"
73
+ | "elDuration"
74
+ | "onCommitAnimatedProperty"
75
+ | "onCommitAnimatedProperties"
76
+ | "onSeekToTime"
77
+ | "onRemoveKeyframe"
78
+ | "onConvertToKeyframes"
79
+ > & {
80
+ element: DomEditSelection;
81
+ styles: Record<string, string>;
82
+ sections: ReturnType<typeof resolveEditingSections>;
83
+ sourceLabel: string;
84
+ gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
85
+ showEditableSections: boolean;
86
+ selectedElementHidden: boolean;
87
+ selectedElementId: string | null;
88
+ clipboardCopied: boolean;
89
+ onCopyElementInfo: () => void;
90
+ currentTime: number;
91
+ };
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
2
2
  import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
5
6
 
6
7
  export interface BackgroundRemovalProgress {
7
8
  status: "processing" | "complete" | "failed";
@@ -123,6 +124,7 @@ export interface PropertyPanelProps {
123
124
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
124
125
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
125
126
  onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
127
+ onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
126
128
  onConvertToKeyframes?: (animationId: string, duration?: number) => void;
127
129
  onCommitAnimatedProperty?: (
128
130
  selection: DomEditSelection,
@@ -71,6 +71,7 @@ export interface DomEditActionsValue extends Pick<
71
71
  | "commitMutation"
72
72
  | "applyMarqueeSelection"
73
73
  | "handleUpdateKeyframeEase"
74
+ | "handleUpdateSegmentEase"
74
75
  | "handleSetAllKeyframeEases"
75
76
  > {}
76
77
 
@@ -200,6 +201,7 @@ export function DomEditProvider({
200
201
  applyMarqueeSelection,
201
202
  handleUpdateKeyframeEase,
202
203
  handleSetAllKeyframeEases,
204
+ handleUpdateSegmentEase,
203
205
  },
204
206
  children,
205
207
  }: {
@@ -281,6 +283,7 @@ export function DomEditProvider({
281
283
  commitMutation: stableCommitMutation,
282
284
  applyMarqueeSelection,
283
285
  handleUpdateKeyframeEase,
286
+ handleUpdateSegmentEase,
284
287
  handleSetAllKeyframeEases,
285
288
  }),
286
289
  [
@@ -349,6 +352,7 @@ export function DomEditProvider({
349
352
  stableCommitMutation,
350
353
  applyMarqueeSelection,
351
354
  handleUpdateKeyframeEase,
355
+ handleUpdateSegmentEase,
352
356
  handleSetAllKeyframeEases,
353
357
  ],
354
358
  );
@@ -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"),