@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
7
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
8
- import { usePlayerStore } from "../../player";
8
+ import { usePlayerStore } from "../../player/store/playerStore";
9
9
  import { GsapAnimationSection } from "./GsapAnimationSection";
10
10
 
11
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
+ const animationCardMock = vi.hoisted(() => ({
14
+ consumers: [] as Array<{ tweenPercentage: number | null; consume: () => void }>,
15
+ }));
16
+
13
17
  vi.mock("./AnimationCard", () => ({
14
18
  AnimationCard: ({
15
19
  animation,
@@ -19,22 +23,27 @@ vi.mock("./AnimationCard", () => ({
19
23
  animation: GsapAnimation;
20
24
  focusedSegment: { tweenPercentage: number } | null;
21
25
  onFocusSegmentConsumed: () => void;
22
- }) => (
23
- <button
24
- type="button"
25
- data-testid={`animation-${animation.id}`}
26
- data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
27
- // Mirrors the real card: the consume callback only fires from the effect
28
- // that runs when this card actually received a focusedSegment.
29
- onClick={() => focusedSegment && onFocusSegmentConsumed()}
30
- />
31
- ),
26
+ }) => {
27
+ animationCardMock.consumers.push({
28
+ tweenPercentage: focusedSegment?.tweenPercentage ?? null,
29
+ consume: onFocusSegmentConsumed,
30
+ });
31
+ return (
32
+ <button
33
+ type="button"
34
+ data-testid={`animation-${animation.id}`}
35
+ data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
36
+ onClick={onFocusSegmentConsumed}
37
+ />
38
+ );
39
+ },
32
40
  }));
33
41
 
34
42
  vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
35
43
 
36
44
  afterEach(() => {
37
45
  document.body.innerHTML = "";
46
+ animationCardMock.consumers = [];
38
47
  usePlayerStore.getState().reset();
39
48
  });
40
49
 
@@ -76,8 +85,22 @@ function renderSection(elementId: string) {
76
85
  return { host, root, render };
77
86
  }
78
87
 
88
+ function focusSharedAnimation(elementId: string, tweenPercentage: number) {
89
+ const store = usePlayerStore.getState();
90
+ store.beginTimelineSession("project-a");
91
+ store.setSelectedElementId(elementId);
92
+ store.setFocusedEaseSegment({
93
+ elementId,
94
+ animationId: sharedAnimation.id,
95
+ tweenPercentage,
96
+ });
97
+ return store;
98
+ }
99
+
79
100
  describe("GsapAnimationSection", () => {
80
101
  it("consumes a shared animation id only for the focused element", () => {
102
+ usePlayerStore.getState().beginTimelineSession("project-a");
103
+ usePlayerStore.getState().setSelectedElementId("index.html#second");
81
104
  usePlayerStore.getState().setFocusedEaseSegment({
82
105
  elementId: "index.html#second",
83
106
  animationId: sharedAnimation.id,
@@ -99,4 +122,70 @@ describe("GsapAnimationSection", () => {
99
122
  expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
100
123
  act(() => view.root.unmount());
101
124
  });
125
+
126
+ it("does not let an older consumer clear a newer request", () => {
127
+ const store = focusSharedAnimation("index.html#first", 25);
128
+ const view = renderSection("index.html#first");
129
+ const staleConsumer = animationCardMock.consumers.at(-1)?.consume;
130
+ if (!staleConsumer) throw new Error("expected first focus consumer");
131
+
132
+ act(() => {
133
+ store.setFocusedEaseSegment({
134
+ elementId: "index.html#first",
135
+ animationId: sharedAnimation.id,
136
+ tweenPercentage: 75,
137
+ });
138
+ });
139
+ const current = usePlayerStore.getState().focusedEaseSegment;
140
+ if (!current) throw new Error("expected replacement request");
141
+
142
+ act(() => staleConsumer());
143
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(current);
144
+
145
+ const currentConsumer = animationCardMock.consumers.at(-1)?.consume;
146
+ if (!currentConsumer) throw new Error("expected replacement focus consumer");
147
+ act(() => currentConsumer());
148
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
149
+ act(() => view.root.unmount());
150
+ });
151
+
152
+ it("keeps the focus consumer stable across unrelated parent renders", () => {
153
+ focusSharedAnimation("index.html#first", 25);
154
+ const view = renderSection("index.html#first");
155
+ const firstConsumer = animationCardMock.consumers.at(-1)?.consume;
156
+ if (!firstConsumer) throw new Error("expected focus consumer");
157
+
158
+ view.render("index.html#first");
159
+
160
+ expect(animationCardMock.consumers.at(-1)?.consume).toBe(firstConsumer);
161
+ act(() => view.root.unmount());
162
+ });
163
+
164
+ it("rejects a request from an earlier project session", () => {
165
+ const store = usePlayerStore.getState();
166
+ store.beginTimelineSession("project-a");
167
+ store.setSelectedElementId("index.html#first");
168
+ store.setFocusedEaseSegment({
169
+ elementId: "index.html#first",
170
+ animationId: sharedAnimation.id,
171
+ tweenPercentage: 50,
172
+ });
173
+ const staleRequest = usePlayerStore.getState().focusedEaseSegment;
174
+ if (!staleRequest) throw new Error("expected request");
175
+
176
+ const view = renderSection("index.html#first");
177
+
178
+ act(() => {
179
+ store.beginTimelineSession("project-b");
180
+ store.setSelectedElementId("index.html#first");
181
+ usePlayerStore.setState({ focusedEaseSegment: staleRequest });
182
+ });
183
+
184
+ const card = view.host.querySelector<HTMLButtonElement>(
185
+ "[data-testid='animation-shared-animation']",
186
+ );
187
+ expect(card?.dataset.focused).toBe("");
188
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
189
+ act(() => view.root.unmount());
190
+ });
102
191
  });
@@ -1,16 +1,9 @@
1
- import { memo, useState } from "react";
1
+ import { memo } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { Film } from "../../icons/SystemIcons";
4
4
  import { Section } from "./propertyPanelPrimitives";
5
- import { AnimationCard } from "./AnimationCard";
6
- import {
7
- type GsapAnimationEditCallbacks,
8
- withTrackedGsapAnimationCallbacks,
9
- clearFocusedEaseSegment,
10
- } from "./gsapAnimationCallbacks";
11
- import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
12
- import { usePlayerStore } from "../../player";
13
- import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
5
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6
+ import { GsapAnimationList } from "./GsapAnimationList";
14
7
 
15
8
  interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
16
9
  elementId: string;
@@ -21,18 +14,13 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
21
14
  }
22
15
 
23
16
  export const GsapAnimationSection = memo(function GsapAnimationSection({
24
- animations,
25
17
  elementId,
18
+ animations,
26
19
  multipleTimelines,
27
20
  unsupportedTimelinePattern,
28
21
  onAddAnimation,
29
22
  ...callbacks
30
23
  }: GsapAnimationSectionProps) {
31
- const track = useTrackDesignInput();
32
- const [addMenuOpen, setAddMenuOpen] = useState(false);
33
- const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
34
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
35
-
36
24
  return (
37
25
  <Section title="Animation" icon={<Film size={15} />}>
38
26
  {multipleTimelines && (
@@ -49,31 +37,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
49
37
  </p>
50
38
  )}
51
39
  {multipleTimelines || unsupportedTimelinePattern ? null : (
52
- <div className="space-y-2">
53
- {animations.map((anim, index) => (
54
- <AnimationCard
55
- {...trackedCallbacks}
56
- key={anim.id}
57
- animation={anim}
58
- defaultExpanded={index === 0}
59
- focusedSegment={
60
- focusedEaseSegment?.elementId === elementId &&
61
- focusedEaseSegment.animationId === anim.id
62
- ? focusedEaseSegment
63
- : null
64
- }
65
- onFocusSegmentConsumed={clearFocusedEaseSegment}
66
- />
67
- ))}
68
-
69
- <GsapAddAnimationControl
70
- open={addMenuOpen}
71
- setOpen={setAddMenuOpen}
72
- onAddAnimation={onAddAnimation}
73
- track={track}
74
- variant="classic"
75
- />
76
- </div>
40
+ <GsapAnimationList
41
+ {...callbacks}
42
+ elementId={elementId}
43
+ animations={animations}
44
+ onAddAnimation={onAddAnimation}
45
+ variant="classic"
46
+ />
77
47
  )}
78
48
  </Section>
79
49
  );
@@ -570,8 +570,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
570
570
  onUpdateArcSegment={onUpdateArcSegment}
571
571
  onUnroll={onUnroll}
572
572
  onUpdateKeyframeEase={onUpdateKeyframeEase}
573
- onSetAllKeyframeEases={onSetAllKeyframeEases}
574
573
  onUpdateSegmentEase={onUpdateSegmentEase}
574
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
575
575
  />
576
576
  )}
577
577
 
@@ -1,5 +1,6 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useShallow } from "zustand/react/shallow";
3
4
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
4
5
  import { slugifyDesignInput } from "../../utils/designInputTracking";
5
6
  import { isTextEditableSelection } from "./domEditing";
@@ -18,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
18
19
  import { formatTextFieldPreview } from "./propertyPanelSections";
19
20
  import { useColorGradingController } from "./useColorGradingController";
20
21
  import { usePlayerStore } from "../../player";
22
+ import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
21
23
  import {
22
24
  FlatColorGradingAccessory,
23
25
  FlatColorGradingSection,
@@ -31,24 +33,10 @@ import {
31
33
  deriveMediaOverlayPlacement,
32
34
  FlatOverlaysSection,
33
35
  } from "./propertyPanelFlatOverlaysSection";
34
-
35
- type FlatGroupDescriptor = {
36
- id: string;
37
- title: string;
38
- summary?: string;
39
- accessory?: ReactNode;
40
- content: ReactNode;
41
- };
42
-
43
- // Required callback shape for the gated-off Motion effect list.
44
- const EMPTY_GSAP_EFFECT_HANDLERS = {
45
- onAddAnimation: () => {},
46
- onUpdateProperty: () => {},
47
- onUpdateMeta: () => {},
48
- onDeleteAnimation: () => {},
49
- onAddProperty: () => {},
50
- onRemoveProperty: () => {},
51
- };
36
+ import {
37
+ EMPTY_GSAP_EFFECT_HANDLERS,
38
+ type FlatGroupDescriptor,
39
+ } from "./propertyPanelFlatDescriptors";
52
40
 
53
41
  /** The flat inspector shell with one shared open-group state. */
54
42
  // fallow-ignore-next-line complexity
@@ -161,11 +149,18 @@ export function PropertyPanelFlat({
161
149
  // When the inline timeline ease button focuses a segment on this element,
162
150
  // force the Motion group open so its AnimationCard (which only mounts while
163
151
  // the group is expanded) can consume the focus and reveal the ease editor.
164
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
165
- // The element THIS panel renders, not the store's selectedElementId: that
166
- // flips synchronously while the panel still renders its predecessor, so a
167
- // stale panel would consume a request meant for its successor whenever the
168
- // two share a class-selector animation id.
152
+ const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch } = usePlayerStore(
153
+ useShallow((state) => ({
154
+ focusedEaseSegment: state.focusedEaseSegment,
155
+ timelineProjectId: state.timelineProjectId,
156
+ timelineSessionEpoch: state.timelineSessionEpoch,
157
+ })),
158
+ );
159
+ // Identity of the element THIS panel actually renders (not the store's
160
+ // selectedElementId, which flips synchronously on selection while the panel
161
+ // still renders the previous element during async DOM-selection resolution):
162
+ // a stale panel would otherwise consume a focus request meant for its
163
+ // successor when both share a class-selector animation id.
169
164
  const renderedElementId = scopedElementKey(element);
170
165
  // Adjusted during render (not an effect) so the card mounts on the same
171
166
  // commit the request lands on. Keyed on request identity: a group the user
@@ -174,8 +169,16 @@ export function PropertyPanelFlat({
174
169
  if (focusedEaseSegment !== consumedFocus) {
175
170
  setConsumedFocus(focusedEaseSegment);
176
171
  const focusesThisPanel =
177
- focusedEaseSegment?.elementId === renderedElementId &&
178
- gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
172
+ focusedEaseSegment !== null &&
173
+ // A request from a previous project/session/selection is stale: it must
174
+ // not reopen Motion on whichever panel happens to be mounted now.
175
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
176
+ timelineProjectId,
177
+ timelineSessionEpoch,
178
+ selectedElementId,
179
+ }) &&
180
+ focusedEaseSegment.elementId === renderedElementId &&
181
+ gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId);
179
182
  if (focusesThisPanel) setOpenGroupId("motion");
180
183
  }
181
184
 
@@ -1,5 +1,4 @@
1
1
  import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
- import { usePlayerStore } from "../../player";
3
2
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
4
3
 
5
4
  /**
@@ -164,12 +163,3 @@ export function withTrackedGsapAnimationCallbacks(
164
163
  : undefined,
165
164
  };
166
165
  }
167
-
168
- /**
169
- * Stable consumer for the store's one-shot ease-focus request. Module-level on
170
- * purpose: an inline arrow in the section components is a dep of AnimationCard's
171
- * focus effect, so a fresh identity each render re-runs that effect every render.
172
- */
173
- export function clearFocusedEaseSegment(): void {
174
- usePlayerStore.getState().setFocusedEaseSegment(null);
175
- }
@@ -253,7 +253,7 @@ function Transform3dField({
253
253
  onCommit={(next) => {
254
254
  const v = parse(next);
255
255
  if (v != null && onCommitAnimatedProperty) {
256
- void onCommitAnimatedProperty(ctx.element, prop, v);
256
+ return onCommitAnimatedProperty(ctx.element, prop, v);
257
257
  }
258
258
  }}
259
259
  />
@@ -21,7 +21,7 @@ export function CommitField({
21
21
  liveCommit?: boolean;
22
22
  align?: "left" | "right";
23
23
  onPreview?: (nextValue: string) => void;
24
- onCommit: (nextValue: string) => void;
24
+ onCommit: (nextValue: string) => void | Promise<void>;
25
25
  }) {
26
26
  const [draft, setDraft] = useState(value);
27
27
  const valueRef = useRef(value);
@@ -29,6 +29,19 @@ export function CommitField({
29
29
  const inputRef = useRef<HTMLInputElement>(null);
30
30
  const focusedRef = useRef(false);
31
31
  const dirtyRef = useRef(false);
32
+ const commitGenerationRef = useRef(0);
33
+ const pendingCommitRef = useRef<{
34
+ baseline: string;
35
+ optimistic: string;
36
+ } | null>(null);
37
+ const lastValueRef = useRef(value);
38
+ if (!Object.is(lastValueRef.current, value)) {
39
+ lastValueRef.current = value;
40
+ if (!Object.is(pendingCommitRef.current?.optimistic, value)) {
41
+ commitGenerationRef.current += 1;
42
+ pendingCommitRef.current = null;
43
+ }
44
+ }
32
45
  valueRef.current = value;
33
46
  draftRef.current = draft;
34
47
 
@@ -67,14 +80,34 @@ export function CommitField({
67
80
  }, 250);
68
81
  };
69
82
  const cancelGesture = () => {
83
+ commitGenerationRef.current += 1;
70
84
  clearGestureSettleTimer();
71
85
  gestureActiveRef.current = false;
72
86
  gestureTransaction.cancel();
73
87
  };
74
88
  const commitDraft = (nextValue: string) => {
89
+ const generation = ++commitGenerationRef.current;
75
90
  setDraft(nextValue);
76
91
  onPreview?.(nextValue);
77
- if (nextValue !== valueRef.current) onCommit(nextValue);
92
+ if (nextValue !== valueRef.current) {
93
+ const baseline = valueRef.current;
94
+ pendingCommitRef.current = { baseline, optimistic: nextValue };
95
+ const rollback = () => {
96
+ if (generation !== commitGenerationRef.current) return;
97
+ pendingCommitRef.current = null;
98
+ // The source write is authoritative. A rejected mutation must not leave
99
+ // the field showing an optimistic value that will disappear on seek.
100
+ setDraft(baseline);
101
+ onPreview?.(baseline);
102
+ };
103
+ try {
104
+ void Promise.resolve(onCommit(nextValue)).then(() => {
105
+ if (generation === commitGenerationRef.current) pendingCommitRef.current = null;
106
+ }, rollback);
107
+ } catch {
108
+ rollback();
109
+ }
110
+ }
78
111
  };
79
112
  const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
80
113
  if (!gestureActiveRef.current) return false;
@@ -89,6 +122,7 @@ export function CommitField({
89
122
  const nextDraft = adjustNumericToken(draftRef.current, direction, event);
90
123
  if (!nextDraft) return;
91
124
  event.preventDefault();
125
+ commitGenerationRef.current += 1;
92
126
  dirtyRef.current = false;
93
127
  gestureActiveRef.current = true;
94
128
  gestureTransaction.preview(nextDraft);
@@ -148,6 +182,7 @@ export function CommitField({
148
182
  focusedRef.current = true;
149
183
  }}
150
184
  onChange={(event) => {
185
+ commitGenerationRef.current += 1;
151
186
  settleGesture();
152
187
  dirtyRef.current = true;
153
188
  setDraft(event.target.value);
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type FlatGroupDescriptor = {
4
+ id: string;
5
+ title: string;
6
+ summary?: string;
7
+ accessory?: ReactNode;
8
+ content: ReactNode;
9
+ };
10
+
11
+ // FlatMotionSection never calls these while effect cards are hidden; they only
12
+ // provide its required callback shape on the gated-off path.
13
+ export const EMPTY_GSAP_EFFECT_HANDLERS = {
14
+ onAddAnimation: () => {},
15
+ onUpdateProperty: () => {},
16
+ onUpdateMeta: () => {},
17
+ onDeleteAnimation: () => {},
18
+ onAddProperty: () => {},
19
+ onRemoveProperty: () => {},
20
+ };
@@ -273,13 +273,14 @@ describe("FlatMotionSection", () => {
273
273
  expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
274
274
  act(() => root.unmount());
275
275
  });
276
- });
277
276
 
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: {
277
+ it("forwards focused bulk segment easing through the flat animation card", () => {
278
+ const onUpdateKeyframeEase = vi.fn();
279
+ const onUpdateSegmentEase = vi.fn();
280
+ const store = usePlayerStore.getState();
281
+ store.beginTimelineSession("project-a");
282
+ store.setSelectedElementId("index.html#hero");
283
+ store.setFocusedEaseSegment({
283
284
  elementId: "index.html#hero",
284
285
  animationId: "a1",
285
286
  tweenPercentage: 50,
@@ -287,55 +288,55 @@ it("forwards focused bulk segment easing through the flat animation card", () =>
287
288
  { animationId: "a1", tweenPercentage: 50 },
288
289
  { animationId: "a2", tweenPercentage: 75 },
289
290
  ],
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
- );
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
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());
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
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());
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
+ });
341
342
  });
@@ -1,20 +1,13 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { useState } from "react";
3
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
3
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
5
4
  import type { DomEditSelection } from "./domEditing";
6
5
  import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
7
6
  import { parseTimingValue } from "./propertyPanelTimingSection";
8
7
  import { CommitField } from "./propertyPanelPrimitives";
9
- import { AnimationCard } from "./AnimationCard";
10
- import {
11
- type GsapAnimationEditCallbacks,
12
- withTrackedGsapAnimationCallbacks,
13
- clearFocusedEaseSegment,
14
- } from "./gsapAnimationCallbacks";
8
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
15
9
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
16
- import { usePlayerStore } from "../../player";
17
- import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
10
+ import { GsapAnimationList } from "./GsapAnimationList";
18
11
 
19
12
  export function FlatTimingRow({
20
13
  element,
@@ -136,19 +129,11 @@ export function FlatMotionSection({
136
129
  onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
137
130
  onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
138
131
  } & GsapAnimationEditCallbacks) {
139
- const track = useTrackDesignInput();
140
- const [addMenuOpen, setAddMenuOpen] = useState(false);
141
- const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
142
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
143
132
  // Only consume a focus request aimed at the element THIS panel renders (not
144
133
  // the store's selectedElementId, which flips synchronously during async
145
134
  // selection resolution), so a shared class-selector animation id can't open
146
135
  // the wrong element's editor.
147
136
  const renderedElementId = scopedElementKey(element);
148
- const focusedHere =
149
- focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
150
- ? focusedEaseSegment
151
- : null;
152
137
 
153
138
  return (
154
139
  <div className="space-y-3">
@@ -174,26 +159,13 @@ export function FlatMotionSection({
174
159
  </p>
175
160
  )}
176
161
  {!multipleTimelines && !unsupportedTimelinePattern && (
177
- <div className="space-y-2">
178
- {animations.map((anim, index) => (
179
- <AnimationCard
180
- {...trackedCallbacks}
181
- key={anim.id}
182
- animation={anim}
183
- defaultExpanded={index === 0}
184
- flat
185
- focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
186
- onFocusSegmentConsumed={clearFocusedEaseSegment}
187
- />
188
- ))}
189
- <GsapAddAnimationControl
190
- open={addMenuOpen}
191
- setOpen={setAddMenuOpen}
192
- onAddAnimation={onAddAnimation}
193
- track={track}
194
- variant="flat"
195
- />
196
- </div>
162
+ <GsapAnimationList
163
+ {...callbacks}
164
+ elementId={renderedElementId}
165
+ animations={animations}
166
+ onAddAnimation={onAddAnimation}
167
+ variant="flat"
168
+ />
197
169
  )}
198
170
  </>
199
171
  )}