@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
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
90
90
  act(() => root.unmount());
91
91
  });
92
92
 
93
+ it("restores its durable value when an async commit rejects", async () => {
94
+ let rejectCommit: ((error: Error) => void) | null = null;
95
+ const onCommit = vi.fn(
96
+ () =>
97
+ new Promise<void>((_resolve, reject) => {
98
+ rejectCommit = reject;
99
+ }),
100
+ );
101
+ const row = (value: string) => (
102
+ <FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
103
+ );
104
+ const { host, root } = renderInto(row("22px"));
105
+ const input = host.querySelector<HTMLInputElement>("input");
106
+ if (!input) throw new Error("expected an input");
107
+ act(() => {
108
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
109
+ window.HTMLInputElement.prototype,
110
+ "value",
111
+ )?.set;
112
+ nativeInputValueSetter?.call(input, "99px");
113
+ input.dispatchEvent(new Event("input", { bubbles: true }));
114
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
115
+ });
116
+ // The parent can echo the preview before persistence settles. That is not a
117
+ // durable acknowledgement and must not invalidate the pending rollback.
118
+ act(() => root.render(row("99px")));
119
+ await act(async () => {
120
+ rejectCommit?.(new Error("save failed"));
121
+ await Promise.resolve();
122
+ });
123
+
124
+ expect(onCommit).toHaveBeenCalledWith("99px");
125
+ expect(input.value).toBe("22px");
126
+ act(() => root.unmount());
127
+ });
128
+
129
+ it("does not let an older rejected commit overwrite a newer draft", async () => {
130
+ let rejectCommit: ((error: Error) => void) | null = null;
131
+ const onCommit = vi.fn(
132
+ () =>
133
+ new Promise<void>((_resolve, reject) => {
134
+ rejectCommit = reject;
135
+ }),
136
+ );
137
+ const { host, root } = renderInto(
138
+ <FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
139
+ );
140
+ const input = host.querySelector<HTMLInputElement>("input");
141
+ if (!input) throw new Error("expected an input");
142
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
143
+ window.HTMLInputElement.prototype,
144
+ "value",
145
+ )?.set;
146
+ act(() => {
147
+ nativeInputValueSetter?.call(input, "99px");
148
+ input.dispatchEvent(new Event("input", { bubbles: true }));
149
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
150
+ nativeInputValueSetter?.call(input, "100px");
151
+ input.dispatchEvent(new Event("input", { bubbles: true }));
152
+ });
153
+ await act(async () => {
154
+ rejectCommit?.(new Error("old save failed"));
155
+ await Promise.resolve();
156
+ });
157
+
158
+ expect(input.value).toBe("100px");
159
+ act(() => root.unmount());
160
+ });
161
+
93
162
  it("persists a rapid numeric arrow-key burst as one commit", () => {
94
163
  vi.useFakeTimers();
95
164
  const onCommit = vi.fn();
@@ -35,7 +35,7 @@ export function FlatRow({
35
35
  /** Renders a trailing 10px caret-down, for select-backed rows. */
36
36
  dropdown?: boolean;
37
37
  onPreview?: (nextValue: string) => void;
38
- onCommit: (nextValue: string) => void;
38
+ onCommit: (nextValue: string) => void | Promise<void>;
39
39
  onReset?: () => void;
40
40
  }) {
41
41
  const track = useTrackDesignInput();
@@ -59,7 +59,7 @@ export function FlatRow({
59
59
  onPreview={onPreview}
60
60
  onCommit={(nextValue) => {
61
61
  track("metric", label);
62
- onCommit(nextValue);
62
+ return onCommit(nextValue);
63
63
  }}
64
64
  />
65
65
  </span>
@@ -29,14 +29,14 @@ export function MetricField({
29
29
  scrub?: boolean;
30
30
  suffix?: string;
31
31
  tooltip?: string;
32
- onCommit: (nextValue: string) => void;
32
+ onCommit: (nextValue: string) => void | Promise<void>;
33
33
  }) {
34
34
  const track = useTrackDesignInput();
35
35
  const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
36
36
  const commit = useCallback(
37
37
  (nextValue: string) => {
38
38
  if (nextValue !== value) track("metric", label);
39
- onCommit(nextValue);
39
+ return onCommit(nextValue);
40
40
  },
41
41
  [label, onCommit, track, value],
42
42
  );
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
6
+ import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
7
+
8
+ describe("createTransformCommitHandlers", () => {
9
+ it.each([
10
+ [
11
+ "position",
12
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
13
+ handlers.commitManualOffset("x", "20px"),
14
+ ],
15
+ [
16
+ "size",
17
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
18
+ handlers.commitManualSize("width", "200px"),
19
+ ],
20
+ [
21
+ "rotation",
22
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
23
+ handlers.commitManualRotation("45"),
24
+ ],
25
+ ])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
26
+ const blocked = new GsapEditBlockedError("unroll-required");
27
+ const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
28
+ const onSetManualOffset = vi.fn();
29
+ const onSetManualSize = vi.fn();
30
+ const onSetManualRotation = vi.fn();
31
+ const element = {
32
+ id: "box",
33
+ selector: "#box",
34
+ element: document.createElement("div"),
35
+ boundingBox: { width: 100, height: 100 },
36
+ } as unknown as DomEditSelection;
37
+ const handlers = createTransformCommitHandlers({
38
+ element,
39
+ styles: {},
40
+ hasGsapAnimation: true,
41
+ gsapAnimId: "#box-to-position",
42
+ gsapKeyframes: null,
43
+ currentPct: 0,
44
+ onCommitAnimatedProperty,
45
+ onAddKeyframe: undefined,
46
+ onSetManualOffset,
47
+ onSetManualSize,
48
+ onSetManualRotation,
49
+ showToast: vi.fn(),
50
+ });
51
+
52
+ await expect(commit(handlers)).rejects.toBe(blocked);
53
+ expect(onSetManualOffset).not.toHaveBeenCalled();
54
+ expect(onSetManualSize).not.toHaveBeenCalled();
55
+ expect(onSetManualRotation).not.toHaveBeenCalled();
56
+ });
57
+ });
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
41
41
  // Route a transform value into the GSAP animation (or a new keyframe) when the
42
42
  // element is animated. Returns true when handled, so callers fall through to
43
43
  // the manual-transform path only for non-animated elements.
44
- const commitAnimatedTransformValue = (
44
+ const commitAnimatedTransformValue = async (
45
45
  property: string,
46
46
  value: number,
47
47
  noCallbacksMessage: string,
48
- ): boolean => {
48
+ ): Promise<boolean> => {
49
49
  if (onCommitAnimatedProperty && hasGsapAnimation) {
50
- void onCommitAnimatedProperty(element, property, value);
50
+ await onCommitAnimatedProperty(element, property, value);
51
51
  return true;
52
52
  }
53
53
  if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
62
62
  return false;
63
63
  };
64
64
 
65
- const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
65
+ const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
66
66
  const parsed = parsePxMetricValue(nextValue);
67
67
  if (parsed == null) return;
68
68
  if (
69
- commitAnimatedTransformValue(
69
+ await commitAnimatedTransformValue(
70
70
  axis,
71
71
  parsed,
72
72
  "Cannot edit position — animation callbacks not available",
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
74
74
  )
75
75
  return;
76
76
  const current = readStudioPathOffset(element.element);
77
- void Promise.resolve(
77
+ await Promise.resolve(
78
78
  onSetManualOffset(element, {
79
79
  x: axis === "x" ? parsed : current.x,
80
80
  y: axis === "y" ? parsed : current.y,
81
81
  }),
82
- ).catch(() => undefined);
82
+ );
83
83
  };
84
84
 
85
85
  // fallow-ignore-next-line complexity
86
- const commitManualSize = (axis: "width" | "height", nextValue: string) => {
86
+ const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
87
87
  const parsed = parsePxMetricValue(nextValue);
88
88
  if (parsed == null || parsed <= 0) return;
89
89
  if (onCommitAnimatedProperty && hasGsapAnimation) {
90
- void onCommitAnimatedProperty(element, axis, parsed);
90
+ await onCommitAnimatedProperty(element, axis, parsed);
91
91
  return;
92
92
  }
93
93
  if (hasGsapAnimation) {
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
103
103
  current.height > 0
104
104
  ? current.height
105
105
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
106
- void Promise.resolve(
106
+ await Promise.resolve(
107
107
  onSetManualSize(element, {
108
108
  width: axis === "width" ? parsed : width,
109
109
  height: axis === "height" ? parsed : height,
110
110
  }),
111
- ).catch(() => undefined);
111
+ );
112
112
  };
113
113
 
114
- const commitManualRotation = (nextValue: string) => {
114
+ const commitManualRotation = async (nextValue: string) => {
115
115
  const parsed = Number.parseFloat(nextValue);
116
116
  if (!Number.isFinite(parsed)) return;
117
117
  if (
118
- commitAnimatedTransformValue(
118
+ await commitAnimatedTransformValue(
119
119
  "rotation",
120
120
  parsed,
121
121
  "Cannot edit rotation — animation callbacks not available",
122
122
  )
123
123
  )
124
124
  return;
125
- void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
125
+ await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
126
126
  };
127
127
 
128
128
  return { commitManualOffset, commitManualSize, commitManualRotation };
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
71
71
  onProgress?: (progress: BackgroundRemovalProgress) => void;
72
72
  },
73
73
  ) => Promise<BackgroundRemovalResult>;
74
- onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
75
- onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
76
- onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
74
+ onSetManualOffset: (
75
+ element: DomEditSelection,
76
+ next: { x: number; y: number },
77
+ ) => void | Promise<void>;
78
+ onSetManualSize: (
79
+ element: DomEditSelection,
80
+ next: { width: number; height: number },
81
+ ) => void | Promise<void>;
82
+ onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
77
83
  onSetText: (value: string, fieldKey?: string) => void;
78
84
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
79
85
  onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -123,8 +129,8 @@ export interface PropertyPanelProps {
123
129
  ) => void;
124
130
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
125
131
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
126
- onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
127
132
  onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
133
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
128
134
  onConvertToKeyframes?: (animationId: string, duration?: number) => void;
129
135
  onCommitAnimatedProperty?: (
130
136
  selection: DomEditSelection,
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
8
8
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
9
 
10
10
  describe("useInspectorGestureTransaction", () => {
11
+ it("restores the durable baseline when an inspector commit rejects", async () => {
12
+ const host = document.createElement("div");
13
+ const root = createRoot(host);
14
+ const onPreview = vi.fn();
15
+ const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
16
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
17
+
18
+ function Probe() {
19
+ gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
20
+ return null;
21
+ }
22
+
23
+ act(() => root.render(<Probe />));
24
+ act(() => {
25
+ gesture?.preview(25);
26
+ gesture?.settle();
27
+ });
28
+ expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
29
+ await act(async () => Promise.resolve());
30
+
31
+ expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
32
+ act(() => root.unmount());
33
+ });
34
+
11
35
  it("keeps a new gesture active when the prior async commit is acknowledged", () => {
12
36
  const host = document.createElement("div");
13
37
  const root = createRoot(host);
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
34
58
 
35
59
  act(() => root.unmount());
36
60
  });
61
+
62
+ it("does not let an older rejected commit roll back a newer successful gesture", async () => {
63
+ const host = document.createElement("div");
64
+ const root = createRoot(host);
65
+ const onPreview = vi.fn();
66
+ let rejectFirst: ((error: Error) => void) | null = null;
67
+ const onCommit = vi
68
+ .fn()
69
+ .mockImplementationOnce((value: number) => {
70
+ onPreview(value);
71
+ return new Promise<void>((_resolve, reject) => {
72
+ rejectFirst = reject;
73
+ });
74
+ })
75
+ .mockImplementationOnce((value: number) => {
76
+ onPreview(value);
77
+ return Promise.resolve();
78
+ });
79
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
80
+
81
+ function Probe() {
82
+ gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
83
+ return null;
84
+ }
85
+
86
+ act(() => root.render(<Probe />));
87
+ act(() => {
88
+ gesture?.preview(20);
89
+ gesture?.settle();
90
+ gesture?.preview(30);
91
+ gesture?.settle();
92
+ });
93
+ await act(async () => {
94
+ rejectFirst?.(new Error("old save failed"));
95
+ await Promise.resolve();
96
+ });
97
+
98
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
99
+ expect(onPreview).toHaveBeenLastCalledWith(30);
100
+ act(() => root.unmount());
101
+ });
37
102
  });
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
+ function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
4
+ return Boolean(result && typeof result.then === "function");
5
+ }
6
+
3
7
  /** One owner for continuous inspector edits: preview freely, persist once. */
4
8
  export function useInspectorGestureTransaction<T>({
5
9
  sourceValue,
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
8
12
  }: {
9
13
  sourceValue: T;
10
14
  onPreview: (value: T) => void;
11
- onCommit: (value: T) => void;
15
+ onCommit: (value: T) => void | Promise<void>;
12
16
  }) {
13
17
  const sourceRef = useRef(sourceValue);
14
18
  const activeRef = useRef<{ before: T; latest: T } | null>(null);
15
19
  const previewRef = useRef(onPreview);
16
20
  const commitRef = useRef(onCommit);
17
- if (!activeRef.current) sourceRef.current = sourceValue;
21
+ const generationRef = useRef(0);
22
+ const pendingRef = useRef<{ before: T; latest: T } | null>(null);
23
+ const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
24
+ const lastSourceValueRef = useRef(sourceValue);
25
+ if (!Object.is(lastSourceValueRef.current, sourceValue)) {
26
+ lastSourceValueRef.current = sourceValue;
27
+ const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
28
+ const matchesOptimisticValue =
29
+ (activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
30
+ (pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
31
+ matchesSourceAck;
32
+ if (matchesSourceAck) awaitingSourceAckRef.current = null;
33
+ if (!matchesOptimisticValue) {
34
+ generationRef.current += 1;
35
+ activeRef.current = null;
36
+ pendingRef.current = null;
37
+ awaitingSourceAckRef.current = null;
38
+ sourceRef.current = sourceValue;
39
+ } else {
40
+ sourceRef.current = sourceValue;
41
+ }
42
+ }
18
43
  previewRef.current = onPreview;
19
44
  commitRef.current = onCommit;
20
45
 
21
46
  const begin = useCallback(() => {
22
47
  if (!activeRef.current) {
48
+ generationRef.current += 1;
23
49
  activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
24
50
  }
25
51
  }, []);
26
52
 
27
53
  const preview = useCallback((value: T) => {
28
54
  if (!activeRef.current) {
55
+ generationRef.current += 1;
29
56
  activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
30
57
  }
31
58
  activeRef.current.latest = value;
32
59
  previewRef.current(value);
33
60
  }, []);
34
61
 
62
+ const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
63
+ if (generation !== generationRef.current) return;
64
+ pendingRef.current = null;
65
+ if (awaitingSourceAckRef.current?.generation === generation) {
66
+ awaitingSourceAckRef.current = null;
67
+ }
68
+ sourceRef.current = active.before;
69
+ previewRef.current(active.before);
70
+ }, []);
71
+
35
72
  const settle = useCallback(() => {
36
73
  const active = activeRef.current;
37
74
  activeRef.current = null;
38
75
  if (active && !Object.is(active.before, active.latest)) {
76
+ const generation = ++generationRef.current;
39
77
  sourceRef.current = active.latest;
40
- // Restore the captured baseline before the persistent commit captures
41
- // rollback state. The commit reapplies `latest` synchronously, so this
42
- // is not visible but a failed save can now correctly restore `before`.
43
- previewRef.current(active.before);
44
- commitRef.current(active.latest);
78
+ pendingRef.current = active;
79
+ awaitingSourceAckRef.current = { generation, value: active.latest };
80
+ try {
81
+ const result = commitRef.current(active.latest);
82
+ if (isPromiseCommit(result)) {
83
+ void result.then(
84
+ () => {
85
+ if (generation === generationRef.current) pendingRef.current = null;
86
+ },
87
+ () => rollbackCommit(active, generation),
88
+ );
89
+ } else if (generation === generationRef.current) {
90
+ pendingRef.current = null;
91
+ // Synchronous inspector consumers historically restore their preview
92
+ // after persisting (color pickers close, curves release the pointer).
93
+ // Async source mutations keep the optimistic preview until the write
94
+ // resolves so they do not flash back to the baseline while pending.
95
+ previewRef.current(active.before);
96
+ }
97
+ } catch {
98
+ rollbackCommit(active, generation);
99
+ }
45
100
  }
46
- }, []);
101
+ }, [rollbackCommit]);
47
102
 
48
103
  const cancel = useCallback(() => {
104
+ generationRef.current += 1;
49
105
  const active = activeRef.current;
50
106
  activeRef.current = null;
51
107
  if (active && !Object.is(active.before, active.latest)) {
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
66
122
  }: {
67
123
  sourceValue: T;
68
124
  onPreview: (value: T) => void;
69
- onCommit: (value: T) => void;
125
+ onCommit: (value: T) => void | Promise<void>;
70
126
  }) {
71
127
  const [draft, setDraft] = useState(sourceValue);
72
128
  const transaction = useInspectorGestureTransaction({
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
77
133
  },
78
134
  onCommit: (next) => {
79
135
  setDraft(next);
80
- onCommit(next);
136
+ return onCommit(next);
81
137
  },
82
138
  });
83
139
 
@@ -317,17 +317,44 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
317
317
  });
318
318
 
319
319
  it("deletes all keyframes through the clicked non-selected element's identity", async () => {
320
- const { circle, selection } = arrangeClickedCircle();
320
+ const circle: TimelineElement = {
321
+ ...element,
322
+ id: "circle",
323
+ key: "scenes/main.html#circle",
324
+ domId: "circle",
325
+ sourceFile: "scenes/main.html",
326
+ };
327
+ const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
328
+ const scaleAnimation: GsapAnimation = {
329
+ ...otherKeyframedAnimation,
330
+ id: "circle-to-0-scale",
331
+ properties: {},
332
+ propertyGroup: "scale",
333
+ keyframes: {
334
+ format: "percentage",
335
+ keyframes: [
336
+ { percentage: 0, properties: { scale: 1 } },
337
+ { percentage: 100, properties: { scale: 2 } },
338
+ ],
339
+ },
340
+ };
341
+ usePlayerStore.setState({
342
+ elements: [element, circle],
343
+ gsapAnimations: new Map([
344
+ ["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]],
345
+ ]),
346
+ });
347
+ mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
321
348
  const view = renderCallbacks();
322
349
 
323
350
  await act(async () => {
324
- view.callbacks.onDeleteAllKeyframes?.(circle);
351
+ view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
325
352
  await Promise.resolve();
326
353
  });
327
354
 
328
355
  expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
329
- otherKeyframedAnimation.id,
330
- selection,
356
+ scaleAnimation.id,
357
+ circleSelection,
331
358
  );
332
359
  view.unmount();
333
360
  });
@@ -360,6 +387,19 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
360
387
  view.unmount();
361
388
  });
362
389
 
390
+ it("does not delete a different lane when an explicit animation identity is stale", async () => {
391
+ const { circle } = arrangeClickedCircle();
392
+ const view = renderCallbacks();
393
+
394
+ await act(async () => {
395
+ view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id");
396
+ await Promise.resolve();
397
+ });
398
+
399
+ expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
400
+ view.unmount();
401
+ });
402
+
363
403
  it("aborts every mutation when the clicked element resolves no selection", async () => {
364
404
  const { circle } = arrangeClickedCircle();
365
405
  mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
@@ -391,6 +431,40 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
391
431
  view.unmount();
392
432
  });
393
433
 
434
+ it("does not delete a different keyframe when its explicit animation identity is stale", () => {
435
+ const view = renderCallbacks();
436
+
437
+ act(() => {
438
+ view.callbacks.onDeleteKeyframe?.("box", {
439
+ percentage: 100,
440
+ propertyGroup: "position",
441
+ tweenPercentage: 100,
442
+ animationId: "missing-animation-id",
443
+ });
444
+ });
445
+
446
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
447
+ expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
448
+ view.unmount();
449
+ });
450
+
451
+ it("does not move a different keyframe when its explicit animation identity is stale", async () => {
452
+ const view = renderCallbacks();
453
+
454
+ await act(async () => {
455
+ view.callbacks.onMoveKeyframeToPlayhead?.(element, {
456
+ percentage: 100,
457
+ propertyGroup: "position",
458
+ tweenPercentage: 100,
459
+ animationId: "missing-animation-id",
460
+ });
461
+ await Promise.resolve();
462
+ });
463
+
464
+ expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
465
+ view.unmount();
466
+ });
467
+
394
468
  it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
395
469
  const { circle, selection } = arrangeClickedCircle();
396
470
  const view = renderCallbacks();
@@ -189,17 +189,22 @@ export function useTimelineEditCallbacks({
189
189
  onSplitElement: handleTimelineElementSplit,
190
190
  onRazorSplit: handleRazorSplit,
191
191
  onRazorSplitAll: handleRazorSplitAll,
192
- onDeleteAllKeyframes: (element) => {
192
+ onDeleteAllKeyframes: (element, animationId) => {
193
193
  // Hold the element where it is (collapse keyframes to a static set) rather
194
194
  // than deleting the whole animation — deleting strands a stale GSAP base
195
195
  // that the next drag adds to, flinging the element off-screen.
196
196
  const elementKey = getTimelineElementIdentity(element);
197
- // Every keyframed tween on the layer, not just the first: a layer with
198
- // position AND opacity keyframes left the second one keyframed, so
199
- // "Delete All Keyframes" visibly did half the job.
200
- const anims = resolveElementAnimations(elementKey).filter(
201
- (animation) => animation.keyframes,
202
- );
197
+ // An explicit animation id scopes the delete to the lane whose menu was
198
+ // opened; without one this is the layer-wide action, and that means
199
+ // EVERY keyframed tween, not just the first. A layer with position AND
200
+ // opacity keyframes used to leave the second one keyframed, so "Delete
201
+ // All Keyframes" visibly did half the job. A stale id matches nothing
202
+ // and deletes nothing, which is the point: it never falls back to a
203
+ // lane the user did not click.
204
+ const animations = resolveElementAnimations(elementKey);
205
+ const anims = animationId
206
+ ? animations.filter((animation) => animation.id === animationId)
207
+ : animations.filter((animation) => animation.keyframes);
203
208
  if (anims.length === 0) return;
204
209
  void buildDomSelectionForTimelineElement(element).then(async (selection) => {
205
210
  if (!selection) return;