@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -1,12 +1,18 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from "react";
2
+ import { act, useLayoutEffect } from "react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
4
7
  import type { DomEditSelection } from "../components/editor/domEditing";
5
8
  import { usePlayerStore } from "../player";
6
9
  import { mountReactHarness } from "./domSelectionTestHarness";
7
10
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
11
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
8
12
  import { useGestureCommit } from "./useGestureCommit";
9
13
 
14
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
+
10
16
  const gestureRecording = vi.hoisted(() => ({
11
17
  startRecording: vi.fn(),
12
18
  stopRecording: vi.fn(() => [
@@ -84,44 +90,88 @@ function makeSelection(element: HTMLElement): DomEditSelection {
84
90
  };
85
91
  }
86
92
 
93
+ function mountRecording(
94
+ writer: (
95
+ mutation: Record<string, unknown>,
96
+ options: CommitMutationOptions,
97
+ selection?: DomEditSelection,
98
+ ) => Promise<void>,
99
+ animations: GsapAnimation[] = [],
100
+ readOnlyPreview = false,
101
+ ) {
102
+ const iframe = document.createElement("iframe");
103
+ document.body.append(iframe);
104
+ const element = document.createElement("div");
105
+ element.id = "card";
106
+ const session = {
107
+ current: {
108
+ domEditSelection: makeSelection(element),
109
+ selectedGsapAnimations: animations,
110
+ commitMutation: writer,
111
+ },
112
+ };
113
+ let hook: ReturnType<typeof useGestureCommit> | null = null;
114
+ function Probe() {
115
+ const editing = useGsapAwareEditing({
116
+ domEditSelection: session.current.domEditSelection,
117
+ selectedGsapAnimations: session.current.selectedGsapAnimations,
118
+ gsapCommitMutation: (selection, mutation, options) => writer(mutation, options, selection),
119
+ previewIframeRef: { current: iframe },
120
+ showToast: vi.fn(),
121
+ bumpGsapCache: vi.fn(),
122
+ makeFetchFallback: () => vi.fn(),
123
+ trackGsapInteractionFailure: vi.fn(),
124
+ stageElementPositionOffset: vi.fn(),
125
+ handleDomBoxSizeCommit: vi.fn(),
126
+ handleDomRotationCommit: vi.fn(),
127
+ commitPositionPatchToHtml: vi.fn(),
128
+ addGsapAnimation: vi.fn(),
129
+ convertToKeyframes: vi.fn(),
130
+ setArcPath: vi.fn(),
131
+ updateArcSegment: vi.fn(),
132
+ });
133
+ const currentHook = useGestureCommit({
134
+ domEditSessionRef: session,
135
+ previewIframeRef: { current: iframe },
136
+ showToast: vi.fn(),
137
+ isGestureRecordingRef: { current: false },
138
+ readOnlyPreview,
139
+ });
140
+ useLayoutEffect(() => {
141
+ session.current.commitMutation = editing.commitMutation;
142
+ hook = currentHook;
143
+ });
144
+ return null;
145
+ }
146
+ const root = mountReactHarness(<Probe />);
147
+ cleanup = () => act(() => root.unmount());
148
+ return Object.assign(
149
+ () => {
150
+ if (!hook) throw new Error("hook did not initialize");
151
+ return hook;
152
+ },
153
+ {
154
+ select: (selection: DomEditSelection, nextAnimations: GsapAnimation[]) => {
155
+ session.current.domEditSelection = selection;
156
+ session.current.selectedGsapAnimations = nextAnimations;
157
+ act(() => root.render(<Probe />));
158
+ },
159
+ },
160
+ );
161
+ }
162
+
87
163
  describe("useGestureCommit", () => {
88
164
  it("coalesces property-group commits and reloads only the terminal group", async () => {
89
- const iframe = document.createElement("iframe");
90
- document.body.append(iframe);
91
- const element = document.createElement("div");
92
- element.id = "card";
93
- const commitMutation = vi.fn<
165
+ const writer = vi.fn<
94
166
  (mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
95
167
  >(async () => {});
96
- const sessionRef = {
97
- current: {
98
- domEditSelection: makeSelection(element),
99
- selectedGsapAnimations: [],
100
- commitMutation,
101
- },
102
- };
103
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
104
- function Probe() {
105
- captured.hook = useGestureCommit({
106
- domEditSessionRef: sessionRef,
107
- previewIframeRef: { current: iframe },
108
- showToast: vi.fn(),
109
- isGestureRecordingRef: { current: false },
110
- readOnlyPreview: false,
111
- });
112
- return null;
113
- }
114
- const root = mountReactHarness(<Probe />);
115
- cleanup = () => act(() => root.unmount());
116
- if (!captured.hook) throw new Error("hook did not initialize");
117
-
118
- act(() => captured.hook?.handleToggleRecording());
119
- act(() => captured.hook?.handleToggleRecording());
168
+ const hook = mountRecording(writer);
169
+ act(() => hook().handleToggleRecording());
170
+ act(() => hook().handleToggleRecording());
120
171
  await act(async () => {
121
- await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
172
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(2));
122
173
  });
123
-
124
- const options = commitMutation.mock.calls.map((call) => call[1]);
174
+ const options = writer.mock.calls.map((call) => call[1]);
125
175
  expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
126
176
  expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
127
177
  expect(options[0]).not.toHaveProperty("softReload");
@@ -130,30 +180,150 @@ describe("useGestureCommit", () => {
130
180
  });
131
181
 
132
182
  it("does not start a recording while the preview is read-only", () => {
133
- const element = document.createElement("div");
134
- const commitMutation = vi.fn(async () => {});
135
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
136
- function Probe() {
137
- captured.hook = useGestureCommit({
138
- domEditSessionRef: {
139
- current: {
140
- domEditSelection: makeSelection(element),
141
- selectedGsapAnimations: [],
142
- commitMutation,
143
- },
144
- },
145
- previewIframeRef: { current: document.createElement("iframe") },
146
- showToast: vi.fn(),
147
- isGestureRecordingRef: { current: false },
148
- readOnlyPreview: true,
183
+ const writer = vi.fn(async () => {});
184
+ const hook = mountRecording(writer, [], true);
185
+ act(() => hook().handleToggleRecording());
186
+ expect(gestureRecording.startRecording).not.toHaveBeenCalled();
187
+ expect(writer).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ it.each([
192
+ ["new mixed-property recording", undefined, true, true, 1, "button"],
193
+ ["keyboard recording", undefined, true, true, 1, "keyboard"],
194
+ ["static hold replacement", "set", true, true, 1, "button"],
195
+ ["overlapping tween replacement", "to", true, true, 1, "button"],
196
+ ["unchanged recording", undefined, true, false, 0, "button"],
197
+ ["refused recording", undefined, false, true, 0, "button"],
198
+ ] as const)(
199
+ "counts writer receipts once for %s",
200
+ async (_name, method, ok, changed, count, inputMethod) => {
201
+ const animations = method
202
+ ? [
203
+ {
204
+ id: "card-position",
205
+ targetSelector: "#card",
206
+ propertyGroup: "position",
207
+ method,
208
+ properties: { x: 0, y: 0 },
209
+ resolvedStart: 0,
210
+ position: 0,
211
+ duration: method === "set" ? 0 : 2,
212
+ keyframes: {
213
+ keyframes: [
214
+ { percentage: 0, properties: { x: 0 } },
215
+ { percentage: 100, properties: { x: 100 } },
216
+ ],
217
+ },
218
+ } as unknown as GsapAnimation,
219
+ ]
220
+ : [];
221
+ const writer = vi.fn(
222
+ async (mutation: Record<string, unknown>, options: CommitMutationOptions) => {
223
+ const result = { ok, changed };
224
+ trackKeyframeCommit([mutation], result, options);
225
+ options.onResult?.(result);
226
+ },
227
+ );
228
+ const hook = mountRecording(writer, animations);
229
+ act(() => hook().handleToggleRecording(inputMethod));
230
+ await act(async () => {
231
+ hook().handleToggleRecording();
232
+ await vi.waitFor(() => expect(writer).toHaveBeenCalled());
233
+ });
234
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count * 2);
235
+ if (count) {
236
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
237
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
238
+ feature: "gesture_recording",
239
+ surface: "preview",
240
+ method: inputMethod,
149
241
  });
150
- return null;
151
242
  }
152
- const root = mountReactHarness(<Probe />);
153
- cleanup = () => act(() => root.unmount());
243
+ },
244
+ );
154
245
 
155
- act(() => captured.hook?.handleToggleRecording());
156
- expect(gestureRecording.startRecording).not.toHaveBeenCalled();
157
- expect(commitMutation).not.toHaveBeenCalled();
246
+ it("keeps a pending recording isolated until its writer settles", async () => {
247
+ let release!: () => void;
248
+ const pending = new Promise<void>((resolve) => {
249
+ release = resolve;
250
+ });
251
+ const writer = vi.fn(
252
+ async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
253
+ await pending;
254
+ options.onResult?.({ ok: true, changed: true });
255
+ },
256
+ );
257
+ const hook = mountRecording(writer);
258
+ act(() => hook().handleToggleRecording("keyboard"));
259
+ act(() => hook().handleToggleRecording());
260
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(1));
261
+ act(() => hook().handleToggleRecording("button"));
262
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(1);
263
+ await act(async () => {
264
+ release();
265
+ await vi.waitFor(() =>
266
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
267
+ feature: "gesture_recording",
268
+ surface: "preview",
269
+ method: "keyboard",
270
+ }),
271
+ );
272
+ });
273
+ expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1);
274
+ act(() => hook().handleToggleRecording("button"));
275
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ it("writes one recording to its original selection after selecting another source", async () => {
279
+ const original = {
280
+ id: "original-position",
281
+ targetSelector: "#card",
282
+ propertyGroup: "position",
283
+ method: "set",
284
+ properties: { x: 0, y: 0 },
285
+ resolvedStart: 0,
286
+ position: 0,
287
+ duration: 0,
288
+ } as GsapAnimation;
289
+ const writer = vi.fn(
290
+ async (
291
+ _mutation: Record<string, unknown>,
292
+ options: CommitMutationOptions,
293
+ _selection?: DomEditSelection,
294
+ ) => {
295
+ options.onResult?.({ ok: true, changed: true });
296
+ },
297
+ );
298
+ const hook = mountRecording(writer, [original]);
299
+ act(() => hook().handleToggleRecording("keyboard"));
300
+ const other = document.createElement("div");
301
+ other.id = "other";
302
+ hook.select(
303
+ { ...makeSelection(other), sourceFile: "other.html", compositionPath: "other.html" },
304
+ [],
305
+ );
306
+ await act(async () => {
307
+ hook().handleToggleRecording();
308
+ await vi.waitFor(() => expect(trackStudioEvent).toHaveBeenCalledTimes(2));
309
+ });
310
+ expect(writer).toHaveBeenCalledExactlyOnceWith(
311
+ expect.objectContaining({
312
+ type: "replace-with-keyframes",
313
+ animationId: "original-position",
314
+ targetSelector: "#card",
315
+ }),
316
+ expect.objectContaining({ keyframeAction: "add" }),
317
+ expect.objectContaining({
318
+ id: "card",
319
+ sourceFile: "index.html",
320
+ compositionPath: "index.html",
321
+ }),
322
+ );
323
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
324
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
325
+ feature: "gesture_recording",
326
+ surface: "preview",
327
+ method: "keyboard",
158
328
  });
159
329
  });
@@ -11,9 +11,12 @@ import { usePlayerStore } from "../player";
11
11
  import type { DomEditSelection } from "../components/editor/domEditing";
12
12
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
13
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
14
+ import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
15
+ import { observeGsapGesture } from "./gsapGestureOutcome";
14
16
  import { roundTo3 } from "../utils/rounding";
15
17
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
16
18
  import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
19
+ import { useStableHandlers } from "./useStableHandlers";
17
20
 
18
21
  type RecordedKeyframe = {
19
22
  percentage: number;
@@ -71,6 +74,7 @@ interface GestureSessionRef {
71
74
  commitMutation?: (
72
75
  mutation: Record<string, unknown>,
73
76
  options: CommitMutationOptions,
77
+ selection?: DomEditSelection,
74
78
  ) => Promise<void>;
75
79
  }
76
80
 
@@ -80,9 +84,16 @@ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOpt
80
84
  return index === count - 1 ? { softReload: true } : { skipReload: true };
81
85
  }
82
86
 
87
+ function recordingEnd(selection: DomEditSelection): number | undefined {
88
+ const start = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
89
+ const duration = Number.parseFloat(selection.dataAttributes?.duration ?? "0") || 0;
90
+ return duration > 0 ? start + duration : undefined;
91
+ }
92
+
83
93
  let gestureRecordingCommitCounter = 0;
84
94
 
85
95
  interface UseGestureCommitParams {
96
+ projectId?: string | null;
86
97
  domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
87
98
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
88
99
  showToast: (message: string, tone?: "error" | "info") => void;
@@ -93,11 +104,12 @@ interface UseGestureCommitParams {
93
104
  export interface UseGestureCommitResult {
94
105
  gestureState: "idle" | "recording";
95
106
  gestureRecording: ReturnType<typeof useGestureRecording>;
96
- handleToggleRecording: () => void;
107
+ handleToggleRecording: (method?: "button" | "keyboard") => void;
97
108
  }
98
109
 
99
110
  // fallow-ignore-next-line complexity
100
111
  export function useGestureCommit({
112
+ projectId,
101
113
  domEditSessionRef,
102
114
  previewIframeRef,
103
115
  showToast,
@@ -108,11 +120,13 @@ export function useGestureCommit({
108
120
  const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
109
121
  const gestureStateRef = useRef<"idle" | "recording">("idle");
110
122
  const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
123
+ const recordingMethodRef = useRef<"button" | "keyboard">("button");
111
124
  const recordingStartTimeRef = useRef(0);
112
125
  const commitInFlightRef = useRef(false);
113
126
  // Capture selection at recording start so commit always targets the recorded element,
114
127
  // even if the user's selection changes mid-recording.
115
128
  const capturedSelectionRef = useRef<DomEditSelection | null>(null);
129
+ const capturedAnimationsRef = useRef<GsapAnimation[]>([]);
116
130
 
117
131
  // Unmount: clear auto-stop interval
118
132
  useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
@@ -203,6 +217,12 @@ export function useGestureCommit({
203
217
  return;
204
218
  }
205
219
  if (liveSession.commitMutation) {
220
+ const writer = liveSession.commitMutation;
221
+ const outcome = observeGsapGesture((_selection, mutation, options) =>
222
+ writer(mutation, options, sel),
223
+ );
224
+ const commit = (mutation: Record<string, unknown>, options: CommitMutationOptions) =>
225
+ outcome.commit!(sel, mutation, options);
206
226
  const recStart = recordingStartTimeRef.current;
207
227
  const rawKeyframes = sortedPcts.map((pct) => ({
208
228
  percentage: pct,
@@ -213,7 +233,7 @@ export function useGestureCommit({
213
233
  const hasPositionProps = keyframes.some((kf) =>
214
234
  Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
215
235
  );
216
- const allAnims = liveSession.selectedGsapAnimations ?? [];
236
+ const allAnims = capturedAnimationsRef.current;
217
237
  const existingPositionTween = hasPositionProps
218
238
  ? allAnims.find(
219
239
  (a) =>
@@ -225,7 +245,7 @@ export function useGestureCommit({
225
245
  if (isInstantHold(existingPositionTween)) {
226
246
  // An instant hold is not a tween to merge into — replace it with the
227
247
  // recorded motion (which already starts from the held position).
228
- await liveSession.commitMutation(
248
+ await commit(
229
249
  {
230
250
  type: "replace-with-keyframes",
231
251
  animationId: existingPositionTween.id,
@@ -234,7 +254,7 @@ export function useGestureCommit({
234
254
  duration: roundTo3(duration),
235
255
  keyframes,
236
256
  },
237
- { label: "Gesture recording (replace set)", softReload: true },
257
+ { label: "Gesture recording (replace set)", softReload: true, keyframeAction: "add" },
238
258
  );
239
259
  } else {
240
260
  const tweenStart = existingPositionTween.resolvedStart ?? 0;
@@ -271,7 +291,7 @@ export function useGestureCommit({
271
291
 
272
292
  const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
273
293
 
274
- await liveSession.commitMutation(
294
+ await commit(
275
295
  {
276
296
  type: "replace-with-keyframes",
277
297
  animationId: existingPositionTween.id,
@@ -283,7 +303,7 @@ export function useGestureCommit({
283
303
  duration: tweenDur,
284
304
  keyframes: merged,
285
305
  },
286
- { label: "Gesture recording (merge)", softReload: true },
306
+ { label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
287
307
  );
288
308
  } else {
289
309
  // Emit one tween per property group so a mixed-prop gesture (e.g.
@@ -291,7 +311,7 @@ export function useGestureCommit({
291
311
  // tween that the position-only drag intercept can't edit.
292
312
  const keyframeGroups = partitionKeyframesByGroup(keyframes);
293
313
  for (const [index, groupKfs] of keyframeGroups.entries()) {
294
- await liveSession.commitMutation(
314
+ await commit(
295
315
  {
296
316
  type: "add-with-keyframes",
297
317
  targetSelector: writeSelector,
@@ -317,7 +337,7 @@ export function useGestureCommit({
317
337
  // No existing tween — same per-group split as the new-range branch above.
318
338
  const keyframeGroups = partitionKeyframesByGroup(keyframes);
319
339
  for (const [index, groupKfs] of keyframeGroups.entries()) {
320
- await liveSession.commitMutation(
340
+ await commit(
321
341
  {
322
342
  type: "add-with-keyframes",
323
343
  targetSelector: writeSelector,
@@ -335,6 +355,7 @@ export function useGestureCommit({
335
355
  );
336
356
  }
337
357
  }
358
+ trackPreviewEditResult("gesture_recording", recordingMethodRef.current, outcome.finish());
338
359
  }
339
360
  showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
340
361
  } catch (err) {
@@ -348,58 +369,71 @@ export function useGestureCommit({
348
369
  }
349
370
  }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
350
371
 
351
- // fallow-ignore-next-line complexity
352
- const handleToggleRecording = useCallback(() => {
353
- if (gestureStateRef.current === "recording") {
354
- if (readOnlyPreview) {
355
- cancelRecording();
372
+ const armAutoStop = useCallback(
373
+ (elementEnd: number | undefined) => {
374
+ clearInterval(recordingAutoStopRef.current);
375
+ const autoStopAt = elementEnd ?? Infinity;
376
+ recordingAutoStopRef.current = setInterval(() => {
377
+ const { currentTime: t, duration: d } = usePlayerStore.getState();
378
+ const limit = Math.min(autoStopAt, d);
379
+ if (limit > 0 && t >= limit - 0.05) {
380
+ void stopAndCommitRecording();
381
+ }
382
+ }, 100);
383
+ },
384
+ [stopAndCommitRecording],
385
+ );
386
+
387
+ const startRecording = useCallback(
388
+ (method: "button" | "keyboard") => {
389
+ if (readOnlyPreview) return;
390
+ const sel = domEditSessionRef.current.domEditSelection;
391
+ if (!sel) {
392
+ showToast("Select an element first", "error");
393
+ return;
394
+ }
395
+ const iframe = previewIframeRef.current;
396
+ if (!iframe) {
397
+ showToast("Preview not ready — try again", "error");
356
398
  return;
357
399
  }
358
- void stopAndCommitRecording();
359
- return;
360
- }
361
- if (readOnlyPreview) return;
362
- const sel = domEditSessionRef.current.domEditSelection;
363
- if (!sel) {
364
- showToast("Select an element first", "error");
365
- return;
366
- }
367
- const iframe = previewIframeRef.current;
368
- if (!iframe) {
369
- showToast("Preview not ready — try again", "error");
370
- return;
371
- }
372
400
 
373
- const store = usePlayerStore.getState();
374
- recordingStartTimeRef.current = store.currentTime;
375
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
376
- const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
377
- const elementEnd = elDur > 0 ? elStart + elDur : undefined;
378
- capturedSelectionRef.current = sel;
379
- gestureRecording.startRecording(sel.element, iframe, elementEnd);
380
- gestureStateRef.current = "recording";
381
- isGestureRecordingRef.current = true;
382
- setGestureState("recording");
401
+ const store = usePlayerStore.getState();
402
+ recordingMethodRef.current = method;
403
+ recordingStartTimeRef.current = store.currentTime;
404
+ const elementEnd = recordingEnd(sel);
405
+ capturedSelectionRef.current = sel;
406
+ capturedAnimationsRef.current = domEditSessionRef.current.selectedGsapAnimations ?? [];
407
+ gestureRecording.startRecording(sel.element, iframe, elementEnd);
408
+ gestureStateRef.current = "recording";
409
+ isGestureRecordingRef.current = true;
410
+ setGestureState("recording");
383
411
 
384
- clearInterval(recordingAutoStopRef.current);
385
- const autoStopAt = elementEnd ?? Infinity;
386
- recordingAutoStopRef.current = setInterval(() => {
387
- const { currentTime: t, duration: d } = usePlayerStore.getState();
388
- const limit = Math.min(autoStopAt, d);
389
- if (limit > 0 && t >= limit - 0.05) {
390
- void stopAndCommitRecording();
412
+ armAutoStop(elementEnd);
413
+ },
414
+ [
415
+ gestureRecording,
416
+ showToast,
417
+ previewIframeRef,
418
+ domEditSessionRef,
419
+ isGestureRecordingRef,
420
+ readOnlyPreview,
421
+ armAutoStop,
422
+ ],
423
+ );
424
+
425
+ const handleToggleRecording = useCallback(
426
+ (method: "button" | "keyboard" = "button") => {
427
+ if (commitInFlightRef.current) return;
428
+ if (gestureStateRef.current === "recording") {
429
+ if (readOnlyPreview) cancelRecording();
430
+ else void stopAndCommitRecording();
431
+ return;
391
432
  }
392
- }, 100);
393
- }, [
394
- gestureRecording,
395
- showToast,
396
- stopAndCommitRecording,
397
- cancelRecording,
398
- previewIframeRef,
399
- domEditSessionRef,
400
- isGestureRecordingRef,
401
- readOnlyPreview,
402
- ]);
433
+ startRecording(method);
434
+ },
435
+ [cancelRecording, readOnlyPreview, startRecording, stopAndCommitRecording],
436
+ );
403
437
 
404
- return { gestureState, gestureRecording, handleToggleRecording };
438
+ return useStableHandlers({ gestureState, gestureRecording, handleToggleRecording }, projectId);
405
439
  }
@@ -0,0 +1,33 @@
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import { GestureTrailOverlay } from "../components/editor/GestureTrailOverlay";
3
+ import type { UseGestureCommitResult } from "./useGestureCommit";
4
+
5
+ /** The live gesture trail drawn over the preview while a gesture is recording, or nothing. */
6
+ export function useGestureTrailOverlay(
7
+ {
8
+ gestureState,
9
+ gestureRecording,
10
+ }: Pick<UseGestureCommitResult, "gestureState" | "gestureRecording">,
11
+ previewIframe: HTMLIFrameElement | null,
12
+ compositionSize: { width: number; height: number } | null,
13
+ ) {
14
+ const canvasRectRef = useRef<DOMRect | null>(null);
15
+ useLayoutEffect(() => {
16
+ if (gestureState !== "recording" || !previewIframe) {
17
+ canvasRectRef.current = null;
18
+ return;
19
+ }
20
+ canvasRectRef.current = previewIframe.getBoundingClientRect();
21
+ }, [gestureState, previewIframe]);
22
+ if (gestureState !== "recording" || !previewIframe) return undefined;
23
+ return (
24
+ <GestureTrailOverlay
25
+ samples={gestureRecording.samplesRef.current}
26
+ sampleCount={gestureRecording.samplesRef.current.length}
27
+ trail={gestureRecording.trailRef.current}
28
+ canvasRect={canvasRectRef.current!}
29
+ compositionSize={compositionSize ?? undefined}
30
+ mode="recording"
31
+ />
32
+ );
33
+ }
@@ -74,7 +74,7 @@ function mountResizeHandler(
74
74
  size: { width: number; height: number },
75
75
  offset?: { x: number; y: number },
76
76
  restore?: () => void,
77
- ) => Promise<void>)
77
+ ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
78
78
  | null = null;
79
79
  let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
80
80
  function Harness() {
@@ -131,13 +131,18 @@ function mountGroupHandler({
131
131
  "trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
132
132
  >
133
133
  >) {
134
- let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
134
+ let groupCommit!: (
135
+ updates: DomEditGroupPathOffsetCommit[],
136
+ ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
135
137
  let pathOffsetCommit!: (
136
138
  selection: DomEditSelection,
137
139
  next: { x: number; y: number },
138
140
  route?: { plainTranslate?: boolean },
139
- ) => Promise<void>;
140
- let rotationCommit!: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
141
+ ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
142
+ let rotationCommit!: (
143
+ selection: DomEditSelection,
144
+ next: RotationCommit,
145
+ ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
141
146
  function Harness() {
142
147
  const editing = useGsapAwareEditing({
143
148
  domEditSelection: null,
@@ -589,7 +594,7 @@ describe("useGsapAwareEditing anchored resize", () => {
589
594
  h.selection.element.setAttribute("data-hf-drag-initial-offset-x", "0");
590
595
  h.selection.element.setAttribute("data-hf-drag-initial-offset-y", "0");
591
596
 
592
- let commit!: Promise<void>;
597
+ let commit!: Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
593
598
  act(() => {
594
599
  commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
595
600
  });
@@ -636,7 +641,7 @@ describe("useGsapAwareEditing anchored resize", () => {
636
641
  s: DomEditSelection,
637
642
  m: unknown,
638
643
  o: { coalesceKey?: string; label?: string; softReload?: boolean },
639
- ) => Promise<void>,
644
+ ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>,
640
645
  _fetch: unknown,
641
646
  options?: { preflightOnly?: boolean },
642
647
  ) => {