@hyperframes/studio 0.7.76 → 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.
Files changed (134) hide show
  1. package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BLICKger.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-Du1RoLiB.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-BdOfFsR8.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -19,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
19
19
  import { formatTextFieldPreview } from "./propertyPanelSections";
20
20
  import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
21
21
  import { useColorGradingController } from "./useColorGradingController";
22
+ import { usePlayerStore } from "../../player";
22
23
  import {
23
24
  FlatColorGradingAccessory,
24
25
  FlatColorGradingSection,
@@ -231,7 +232,40 @@ export function PropertyPanelFlat({
231
232
  : "layout",
232
233
  );
233
234
 
234
- // Animate only the groups that changed during this toggle cycle.
235
+ // Tracks which group(s) are actively transitioning this toggle cycle, so
236
+ // their header/body gets the fast entrance animation (hf-flat-group-enter)
237
+ // and no one else's does. Deliberately NOT derived from remounting alone:
238
+ // FlatGroupHeader instances are keyed by group id and React normally
239
+ // preserves them across re-renders, but toggling a non-adjacent group still
240
+ // shifts the untouched collapsed siblings between the before/after-open
241
+ // slices below, and Chromium restarts a CSS animation on that kind of
242
+ // position shift even though nothing about the sibling actually changed.
243
+ // Gating on these ids (cleared shortly after the 120ms CSS animation
244
+ // finishes) keeps the animation scoped to only the groups that actually
245
+ // just toggled. Two ids, not one: the clicked (newly-opening/closing) group
246
+ // AND whichever group was open immediately before the click and got
247
+ // implicitly closed by it — both freshly-mounted headers need to animate.
248
+ // When the inline timeline ease button focuses a segment on this element,
249
+ // force the Motion group open so its AnimationCard (which only mounts while
250
+ // the group is expanded) can consume the focus and reveal the ease editor.
251
+ const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
252
+ // The element THIS panel renders, not the store's selectedElementId: that
253
+ // flips synchronously while the panel still renders its predecessor, so a
254
+ // stale panel would consume a request meant for its successor whenever the
255
+ // two share a class-selector animation id.
256
+ const renderedElementId = `${element.sourceFile}#${element.id}`;
257
+ // Adjusted during render (not an effect) so the card mounts on the same
258
+ // commit the request lands on. Keyed on request identity: a group the user
259
+ // closes afterwards stays closed.
260
+ const [consumedFocus, setConsumedFocus] = useState(focusedEaseSegment);
261
+ if (focusedEaseSegment !== consumedFocus) {
262
+ setConsumedFocus(focusedEaseSegment);
263
+ const focusesThisPanel =
264
+ focusedEaseSegment?.elementId === renderedElementId &&
265
+ gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
266
+ if (focusesThisPanel) setOpenGroupId("motion");
267
+ }
268
+
235
269
  const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
236
270
  const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
237
271
  const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -493,6 +527,17 @@ export function PropertyPanelFlat({
493
527
  const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
494
528
  const openGroup = openIndex === -1 ? null : groups[openIndex];
495
529
  const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
530
+ const renderClosedGroup = (group: FlatGroupDescriptor) => (
531
+ <DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
532
+ <FlatGroupHeader
533
+ title={group.title}
534
+ isOpen={false}
535
+ onToggleOpen={() => toggleOpen(group.id)}
536
+ summary={group.summary}
537
+ animateEntrance={justToggledIds.includes(group.id)}
538
+ />
539
+ </DesignPanelInputProvider>
540
+ );
496
541
 
497
542
  return (
498
543
  <DesignPanelInputProvider ui="flat">
@@ -520,17 +565,7 @@ export function PropertyPanelFlat({
520
565
  data-flat-panel-body="true"
521
566
  className="flex min-h-0 flex-1 flex-col overflow-y-auto"
522
567
  >
523
- {beforeOpen.map((g) => (
524
- <DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
525
- <FlatGroupHeader
526
- title={g.title}
527
- isOpen={false}
528
- onToggleOpen={() => toggleOpen(g.id)}
529
- summary={g.summary}
530
- animateEntrance={justToggledIds.includes(g.id)}
531
- />
532
- </DesignPanelInputProvider>
533
- ))}
568
+ {beforeOpen.map(renderClosedGroup)}
534
569
  {openGroup && (
535
570
  <DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
536
571
  <div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
@@ -549,17 +584,7 @@ export function PropertyPanelFlat({
549
584
  </div>
550
585
  </DesignPanelInputProvider>
551
586
  )}
552
- {afterOpen.map((g) => (
553
- <DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
554
- <FlatGroupHeader
555
- title={g.title}
556
- isOpen={false}
557
- onToggleOpen={() => toggleOpen(g.id)}
558
- summary={g.summary}
559
- animateEntrance={justToggledIds.includes(g.id)}
560
- />
561
- </DesignPanelInputProvider>
562
- ))}
587
+ {afterOpen.map(renderClosedGroup)}
563
588
  </div>
564
589
  <DesignPanelInputProvider section="footer">
565
590
  <PropertyPanelFlatFooter
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ type GsapAnimationEditCallbacks,
4
+ withTrackedGsapAnimationCallbacks,
5
+ } from "./gsapAnimationCallbacks";
6
+
7
+ function requiredCallbacks(): GsapAnimationEditCallbacks {
8
+ return {
9
+ onUpdateProperty: vi.fn(),
10
+ onUpdateMeta: vi.fn(),
11
+ onDeleteAnimation: vi.fn(),
12
+ onAddProperty: vi.fn(),
13
+ onRemoveProperty: vi.fn(),
14
+ };
15
+ }
16
+
17
+ function requireCallback<T>(callback: T | undefined): T {
18
+ if (callback === undefined) throw new Error("expected callback to be present");
19
+ return callback;
20
+ }
21
+
22
+ describe("withTrackedGsapAnimationCallbacks", () => {
23
+ it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => {
24
+ const callbacks = requiredCallbacks();
25
+ const onLivePreview = vi.fn();
26
+ const onLivePreviewEnd = vi.fn();
27
+ callbacks.onLivePreview = onLivePreview;
28
+ callbacks.onLivePreviewEnd = onLivePreviewEnd;
29
+
30
+ const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
31
+
32
+ expect(tracked.onUpdateFromProperty).toBeUndefined();
33
+ expect(tracked.onAddFromProperty).toBeUndefined();
34
+ expect(tracked.onRemoveFromProperty).toBeUndefined();
35
+ expect(tracked.onSetArcPath).toBeUndefined();
36
+ expect(tracked.onUpdateArcSegment).toBeUndefined();
37
+ expect(tracked.onUpdateKeyframeEase).toBeUndefined();
38
+ expect(tracked.onSetAllKeyframeEases).toBeUndefined();
39
+ expect(tracked.onUnroll).toBeUndefined();
40
+ expect(tracked.onLivePreview).toBe(onLivePreview);
41
+ expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
42
+ });
43
+
44
+ it("tracks each edit once before invoking its mutation callback", () => {
45
+ const events: string[] = [];
46
+ const mutation = (name: string) => () => events.push(`mutate:${name}`);
47
+ const callbacks: GsapAnimationEditCallbacks = {
48
+ onUpdateProperty: mutation("update-property"),
49
+ onUpdateMeta: mutation("update-meta"),
50
+ onDeleteAnimation: mutation("delete"),
51
+ onAddProperty: mutation("add-property"),
52
+ onRemoveProperty: mutation("remove-property"),
53
+ onUpdateFromProperty: mutation("update-from"),
54
+ onAddFromProperty: mutation("add-from"),
55
+ onRemoveFromProperty: mutation("remove-from"),
56
+ onSetArcPath: mutation("arc-path"),
57
+ onUpdateArcSegment: mutation("arc-segment"),
58
+ onUpdateKeyframeEase: mutation("keyframe-ease"),
59
+ onSetAllKeyframeEases: mutation("all-eases"),
60
+ onUnroll: mutation("unroll"),
61
+ };
62
+ const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
63
+ events.push(`track:${control}:${name}`);
64
+ });
65
+
66
+ tracked.onUpdateProperty("a1", "visibility", 1);
67
+ tracked.onUpdateProperty("a1", "filter", "blur(2px)");
68
+ tracked.onUpdateProperty("a1", "opacity", 0.5);
69
+ tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 });
70
+ tracked.onDeleteAnimation("a1");
71
+ tracked.onAddProperty("a1", "scale");
72
+ tracked.onRemoveProperty("a1", "scale");
73
+ requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none");
74
+ requireCallback(tracked.onAddFromProperty)("a1", "x");
75
+ requireCallback(tracked.onRemoveFromProperty)("a1", "x");
76
+ requireCallback(tracked.onSetArcPath)("a1", { enabled: true });
77
+ requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true });
78
+ requireCallback(tracked.onUpdateArcSegment)("a1", 1, {});
79
+ requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
80
+ requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
81
+ requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
82
+ requireCallback(tracked.onUnroll)("a1");
83
+
84
+ expect(events).toEqual([
85
+ "track:toggle:visibility",
86
+ "mutate:update-property",
87
+ "track:text:filter",
88
+ "mutate:update-property",
89
+ "track:metric:opacity",
90
+ "mutate:update-property",
91
+ "track:metric:Length",
92
+ "track:select:Speed",
93
+ "track:metric:Starts at",
94
+ "mutate:update-meta",
95
+ "track:button:Remove animation",
96
+ "mutate:delete",
97
+ "track:select:Add effect property",
98
+ "mutate:add-property",
99
+ "track:button:Remove scale",
100
+ "mutate:remove-property",
101
+ "track:text:clipPath",
102
+ "mutate:update-from",
103
+ "track:select:Add from property",
104
+ "mutate:add-from",
105
+ "track:button:Remove from x",
106
+ "mutate:remove-from",
107
+ "track:toggle:Arc motion",
108
+ "mutate:arc-path",
109
+ "track:toggle:Auto rotate",
110
+ "mutate:arc-path",
111
+ "track:button:Reset arc segment 2",
112
+ "mutate:arc-segment",
113
+ "mutate:arc-segment",
114
+ "track:select:Keyframe ease",
115
+ "mutate:keyframe-ease",
116
+ "track:select:All keyframe eases",
117
+ "mutate:all-eases",
118
+ "track:button:Unroll animation",
119
+ "mutate:unroll",
120
+ ]);
121
+ });
122
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
+ import { usePlayerStore } from "../../player";
2
3
 
3
4
  /**
4
5
  * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -35,6 +36,18 @@ export interface GsapAnimationEditCallbacks {
35
36
  onUnroll?: (animationId: string) => void;
36
37
  }
37
38
 
39
+ type TrackDesignInput = (control: string, name: string) => void;
40
+
41
+ function trackAnimationProperty(track: TrackDesignInput, property: string): void {
42
+ const control =
43
+ property === "visibility"
44
+ ? "toggle"
45
+ : property === "filter" || property === "clipPath"
46
+ ? "text"
47
+ : "metric";
48
+ track(control, property);
49
+ }
50
+
38
51
  // User-facing control label for each animation-meta field. The ease control is
39
52
  // labelled "Speed" in the card UI, so ease/easeEach map there.
40
53
  const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
@@ -51,13 +64,104 @@ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> =
51
64
  * added later is attributed honestly by its own key instead of poisoning another
52
65
  * control's usage count.
53
66
  */
54
- export function trackAnimationMetaUpdate(
55
- track: (control: string, name: string) => void,
56
- updates: Record<string, unknown>,
57
- ): void {
67
+ function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record<string, unknown>): void {
58
68
  for (const key of Object.keys(updates)) {
59
69
  const mapped = ANIMATION_META_LABELS[key];
60
70
  if (mapped) track(mapped.control, mapped.name);
61
71
  else track("select", key);
62
72
  }
63
73
  }
74
+
75
+ /**
76
+ * Add design-input telemetry to the shared animation-edit callback surface.
77
+ * Optional callbacks remain absent, pass-through preview callbacks keep their
78
+ * original identity, and every tracked event fires once before its mutation.
79
+ */
80
+ export function withTrackedGsapAnimationCallbacks(
81
+ callbacks: GsapAnimationEditCallbacks,
82
+ track: TrackDesignInput,
83
+ ): GsapAnimationEditCallbacks {
84
+ return {
85
+ onUpdateProperty: (animationId, property, value) => {
86
+ trackAnimationProperty(track, property);
87
+ callbacks.onUpdateProperty(animationId, property, value);
88
+ },
89
+ onUpdateMeta: (animationId, updates) => {
90
+ trackAnimationMetaUpdate(track, updates);
91
+ callbacks.onUpdateMeta(animationId, updates);
92
+ },
93
+ onDeleteAnimation: (animationId) => {
94
+ track("button", "Remove animation");
95
+ callbacks.onDeleteAnimation(animationId);
96
+ },
97
+ onAddProperty: (animationId, property) => {
98
+ track("select", "Add effect property");
99
+ callbacks.onAddProperty(animationId, property);
100
+ },
101
+ onRemoveProperty: (animationId, property) => {
102
+ track("button", `Remove ${property}`);
103
+ callbacks.onRemoveProperty(animationId, property);
104
+ },
105
+ onUpdateFromProperty: callbacks.onUpdateFromProperty
106
+ ? (animationId, property, value) => {
107
+ trackAnimationProperty(track, property);
108
+ callbacks.onUpdateFromProperty?.(animationId, property, value);
109
+ }
110
+ : undefined,
111
+ onAddFromProperty: callbacks.onAddFromProperty
112
+ ? (animationId, property) => {
113
+ track("select", "Add from property");
114
+ callbacks.onAddFromProperty?.(animationId, property);
115
+ }
116
+ : undefined,
117
+ onRemoveFromProperty: callbacks.onRemoveFromProperty
118
+ ? (animationId, property) => {
119
+ track("button", `Remove from ${property}`);
120
+ callbacks.onRemoveFromProperty?.(animationId, property);
121
+ }
122
+ : undefined,
123
+ onLivePreview: callbacks.onLivePreview,
124
+ onLivePreviewEnd: callbacks.onLivePreviewEnd,
125
+ onSetArcPath: callbacks.onSetArcPath
126
+ ? (animationId, config) => {
127
+ track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
128
+ callbacks.onSetArcPath?.(animationId, config);
129
+ }
130
+ : undefined,
131
+ onUpdateArcSegment: callbacks.onUpdateArcSegment
132
+ ? (animationId, segmentIndex, update) => {
133
+ if (update.curviness === undefined) {
134
+ track("button", `Reset arc segment ${segmentIndex + 1}`);
135
+ }
136
+ callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
137
+ }
138
+ : undefined,
139
+ onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase
140
+ ? (animationId, percentage, ease) => {
141
+ track("select", "Keyframe ease");
142
+ callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
143
+ }
144
+ : undefined,
145
+ onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases
146
+ ? (animationId, ease) => {
147
+ track("select", "All keyframe eases");
148
+ callbacks.onSetAllKeyframeEases?.(animationId, ease);
149
+ }
150
+ : undefined,
151
+ onUnroll: callbacks.onUnroll
152
+ ? (animationId) => {
153
+ track("button", "Unroll animation");
154
+ callbacks.onUnroll?.(animationId);
155
+ }
156
+ : undefined,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Stable consumer for the store's one-shot ease-focus request. Module-level on
162
+ * purpose: an inline arrow in the section components is a dep of AnimationCard's
163
+ * focus effect, so a fresh identity each render re-runs that effect every render.
164
+ */
165
+ export function clearFocusedEaseSegment(): void {
166
+ usePlayerStore.getState().setFocusedEaseSegment(null);
167
+ }
@@ -1,3 +1,6 @@
1
+ // Boundary cases share an arrange/assert shape on purpose: each case states its
2
+ // own window, drag, and expected remap so a failure reads without cross-referencing.
3
+ // fallow-ignore-file code-duplication
1
4
  import { describe, expect, it } from "vitest";
2
5
  import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
3
6
 
@@ -11,6 +14,22 @@ const KEYFRAMES: RetimeKeyframe[] = [
11
14
  const WINDOW = { tweenStart: 2, tweenDuration: 4 };
12
15
  const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
13
16
 
17
+ function expectLeftResize(
18
+ keyframes: RetimeKeyframe[],
19
+ draggedTweenPct: number,
20
+ pctRemap: Array<{ from: number; to: number }>,
21
+ ): void {
22
+ const result = resolveKeyframeRetime({
23
+ ...LEFT_BOUNDARY_DROP,
24
+ keyframes,
25
+ draggedTweenPct,
26
+ });
27
+ expect(result.kind).toBe("resize");
28
+ expect(result.position).toBeCloseTo(0.5, 5);
29
+ expect(result.duration).toBeCloseTo(5.5, 5);
30
+ expect(result.pctRemap).toEqual(pctRemap);
31
+ }
32
+
14
33
  describe("resolveKeyframeRetime — move (within the tween window)", () => {
15
34
  it("re-keys an interior keyframe to the tween-% of the drop", () => {
16
35
  const r = resolveKeyframeRetime({
@@ -94,29 +113,21 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
94
113
  expect(r.kind).toBe("resize");
95
114
  expect(r.position).toBeCloseTo(2, 5); // start unchanged
96
115
  expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
97
- // abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
116
+ // abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
98
117
  // existing keyframe's old→new tween-%; the commit re-keys in place (value +
99
118
  // ease + _auto preserved by round-tripping the source node, not re-emitted here).
100
119
  expect(r.pctRemap).toEqual([
101
120
  { from: 0, to: 0 },
102
- { from: 50, to: 33.3 },
121
+ { from: 50, to: 33.333 },
103
122
  { from: 100, to: 100 },
104
123
  ]);
105
124
  });
106
125
 
107
126
  it("extends the FIRST keyframe before the start, shifting position earlier", () => {
108
- const r = resolveKeyframeRetime({
109
- ...LEFT_BOUNDARY_DROP,
110
- keyframes: KEYFRAMES,
111
- draggedTweenPct: 0,
112
- });
113
- expect(r.kind).toBe("resize");
114
- expect(r.position).toBeCloseTo(0.5, 5);
115
- expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
116
- // abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
117
- expect(r.pctRemap).toEqual([
127
+ // abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
128
+ expectLeftResize(KEYFRAMES, 0, [
118
129
  { from: 0, to: 0 },
119
- { from: 50, to: 63.6 },
130
+ { from: 50, to: 63.636 },
120
131
  { from: 100, to: 100 },
121
132
  ]);
122
133
  });
@@ -139,15 +150,7 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
139
150
  });
140
151
 
141
152
  it("resizes left before the start", () => {
142
- const r = resolveKeyframeRetime({
143
- ...LEFT_BOUNDARY_DROP,
144
- keyframes: lone,
145
- draggedTweenPct: 100,
146
- });
147
- expect(r.kind).toBe("resize");
148
- expect(r.position).toBeCloseTo(0.5, 5);
149
- expect(r.duration).toBeCloseTo(5.5, 5);
150
- expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
153
+ expectLeftResize(lone, 100, [{ from: 100, to: 0 }]);
151
154
  });
152
155
  });
153
156
 
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
55
55
  const MIN_TWEEN_DURATION = 0.01;
56
56
 
57
57
  const round3 = (n: number) => Math.round(n * 1000) / 1000;
58
- const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
59
58
  const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
60
59
 
61
60
  /** Resolve timing for a flat tween's synthesized start/end diamond. */
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
153
152
  const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
154
153
  const absTime =
155
154
  i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
156
- return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
155
+ return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
157
156
  });
158
157
 
159
158
  return {
@@ -83,10 +83,10 @@ function KeyframeGutter({
83
83
  track("button", `Add ${property} keyframe`);
84
84
  void onCommitAnimatedProperty(element, property, displayValue);
85
85
  }}
86
- onRemoveKeyframe={(pct) => {
86
+ onRemoveKeyframe={(pct, animationId) => {
87
87
  if (!onRemoveKeyframe) return;
88
88
  track("button", `Remove ${property} keyframe`);
89
- onRemoveKeyframe(animIdForProp(property), pct);
89
+ onRemoveKeyframe(animationId ?? animIdForProp(property), pct);
90
90
  }}
91
91
  onConvertToKeyframes={() => {
92
92
  if (!onConvertToKeyframes) return;
@@ -6,12 +6,14 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
6
6
  import { parseTimingValue } from "./propertyPanelTimingSection";
7
7
  import { CommitField } from "./propertyPanelPrimitives";
8
8
  import { AnimationCard } from "./AnimationCard";
9
- import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
10
9
  import {
11
- trackAnimationMetaUpdate,
12
10
  type GsapAnimationEditCallbacks,
11
+ withTrackedGsapAnimationCallbacks,
12
+ clearFocusedEaseSegment,
13
13
  } from "./gsapAnimationCallbacks";
14
14
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
15
+ import { usePlayerStore } from "../../player";
16
+ import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
15
17
 
16
18
  export function FlatTimingRow({
17
19
  element,
@@ -135,15 +137,17 @@ export function FlatMotionSection({
135
137
  } & GsapAnimationEditCallbacks) {
136
138
  const track = useTrackDesignInput();
137
139
  const [addMenuOpen, setAddMenuOpen] = useState(false);
138
- const trackProperty = (property: string) => {
139
- const control =
140
- property === "visibility"
141
- ? "toggle"
142
- : property === "filter" || property === "clipPath"
143
- ? "text"
144
- : "metric";
145
- track(control, property);
146
- };
140
+ const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
141
+ const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
142
+ // Only consume a focus request aimed at the element THIS panel renders (not
143
+ // the store's selectedElementId, which flips synchronously during async
144
+ // selection resolution), so a shared class-selector animation id can't open
145
+ // the wrong element's editor.
146
+ const renderedElementId = `${element.sourceFile}#${element.id}`;
147
+ const focusedHere =
148
+ focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
149
+ ? focusedEaseSegment
150
+ : null;
147
151
 
148
152
  return (
149
153
  <div className="space-y-3">
@@ -172,140 +176,22 @@ export function FlatMotionSection({
172
176
  <div className="space-y-2">
173
177
  {animations.map((anim, index) => (
174
178
  <AnimationCard
179
+ {...trackedCallbacks}
175
180
  key={anim.id}
176
181
  animation={anim}
177
182
  defaultExpanded={index === 0}
178
183
  flat
179
- onUpdateProperty={(animationId, property, value) => {
180
- trackProperty(property);
181
- callbacks.onUpdateProperty(animationId, property, value);
182
- }}
183
- onUpdateMeta={(animationId, updates) => {
184
- trackAnimationMetaUpdate(track, updates);
185
- callbacks.onUpdateMeta(animationId, updates);
186
- }}
187
- onDeleteAnimation={(animationId) => {
188
- track("button", "Remove animation");
189
- callbacks.onDeleteAnimation(animationId);
190
- }}
191
- onAddProperty={(animationId, property) => {
192
- track("select", "Add effect property");
193
- callbacks.onAddProperty(animationId, property);
194
- }}
195
- onRemoveProperty={(animationId, property) => {
196
- track("button", `Remove ${property}`);
197
- callbacks.onRemoveProperty(animationId, property);
198
- }}
199
- onUpdateFromProperty={
200
- callbacks.onUpdateFromProperty
201
- ? (animationId, property, value) => {
202
- trackProperty(property);
203
- callbacks.onUpdateFromProperty?.(animationId, property, value);
204
- }
205
- : undefined
206
- }
207
- onAddFromProperty={
208
- callbacks.onAddFromProperty
209
- ? (animationId, property) => {
210
- track("select", "Add from property");
211
- callbacks.onAddFromProperty?.(animationId, property);
212
- }
213
- : undefined
214
- }
215
- onRemoveFromProperty={
216
- callbacks.onRemoveFromProperty
217
- ? (animationId, property) => {
218
- track("button", `Remove from ${property}`);
219
- callbacks.onRemoveFromProperty?.(animationId, property);
220
- }
221
- : undefined
222
- }
223
- onLivePreview={callbacks.onLivePreview}
224
- onLivePreviewEnd={callbacks.onLivePreviewEnd}
225
- onSetArcPath={
226
- callbacks.onSetArcPath
227
- ? (animationId, config) => {
228
- track(
229
- "toggle",
230
- config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
231
- );
232
- callbacks.onSetArcPath?.(animationId, config);
233
- }
234
- : undefined
235
- }
236
- onUpdateArcSegment={
237
- callbacks.onUpdateArcSegment
238
- ? (animationId, segmentIndex, update) => {
239
- if (update.curviness === undefined) {
240
- track("button", `Reset arc segment ${segmentIndex + 1}`);
241
- }
242
- callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
243
- }
244
- : undefined
245
- }
246
- onUpdateKeyframeEase={
247
- callbacks.onUpdateKeyframeEase
248
- ? (animationId, percentage, ease) => {
249
- track("select", "Keyframe ease");
250
- callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
251
- }
252
- : undefined
253
- }
254
- onSetAllKeyframeEases={
255
- callbacks.onSetAllKeyframeEases
256
- ? (animationId, ease) => {
257
- track("select", "All keyframe eases");
258
- callbacks.onSetAllKeyframeEases?.(animationId, ease);
259
- }
260
- : undefined
261
- }
262
- onUnroll={
263
- callbacks.onUnroll
264
- ? (animationId) => {
265
- track("button", "Unroll animation");
266
- callbacks.onUnroll?.(animationId);
267
- }
268
- : undefined
269
- }
184
+ focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
185
+ onFocusSegmentConsumed={clearFocusedEaseSegment}
270
186
  />
271
187
  ))}
272
- <div className="relative pt-1">
273
- {addMenuOpen ? (
274
- <div className="flex gap-1.5">
275
- {ADD_METHODS.map((method) => (
276
- <button
277
- key={method}
278
- type="button"
279
- title={METHOD_TOOLTIPS[method]}
280
- onClick={() => {
281
- track("button", `Add ${method} animation`);
282
- onAddAnimation(method);
283
- setAddMenuOpen(false);
284
- }}
285
- className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
286
- >
287
- {ADD_METHOD_LABELS[method] ?? method}
288
- </button>
289
- ))}
290
- <button
291
- type="button"
292
- onClick={() => setAddMenuOpen(false)}
293
- className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
294
- >
295
- Cancel
296
- </button>
297
- </div>
298
- ) : (
299
- <button
300
- type="button"
301
- onClick={() => setAddMenuOpen(true)}
302
- className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
303
- title="Add a new animation effect to this element"
304
- >
305
- + Add effect
306
- </button>
307
- )}
308
- </div>
188
+ <GsapAddAnimationControl
189
+ open={addMenuOpen}
190
+ setOpen={setAddMenuOpen}
191
+ onAddAnimation={onAddAnimation}
192
+ track={track}
193
+ variant="flat"
194
+ />
309
195
  </div>
310
196
  )}
311
197
  </>