@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
@@ -43,3 +43,10 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s
43
43
  if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) return null;
44
44
  return value.map(normalizeStudioFileChangePath);
45
45
  }
46
+
47
+ export function mergeFileChangeAffectedCompositions(waiting: unknown, incoming: unknown): unknown {
48
+ const before = readFileChangeAffectedCompositions(waiting);
49
+ const after = readFileChangeAffectedCompositions(incoming);
50
+ const merged = before && after ? [...new Set([...before, ...after])] : null;
51
+ return { ...asPayloadRecord(incoming), affectedCompositions: merged };
52
+ }
@@ -12,7 +12,7 @@ import {
12
12
  import { applyStudioBoxSize, applyStudioPathOffset, applyStudioRotation } from "./manualEditsDom";
13
13
  import { applyStudioMotionFromDom } from "./studioMotion";
14
14
  import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
15
- import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
15
+ import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
16
16
 
17
17
  function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
18
18
  const ctor = doc.defaultView?.HTMLElement;
@@ -35,7 +35,7 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
35
35
  function reapplyPathOffsets(doc: Document): void {
36
36
  for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
37
37
  // Unlike size below, the offset channels add up: applying both doubles the move.
38
- if (gsapAnimatesProperty(el, "x", "y")) continue;
38
+ if (gsapWritesChannels(el, ["x", "y"])) continue;
39
39
  const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
40
40
  const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
41
41
  if (!x && !y) continue;
@@ -1,10 +1,14 @@
1
1
  // @vitest-environment happy-dom
2
- import { act, useRef } from "react";
2
+ import { act, useRef, type RefObject } from "react";
3
3
  import type { Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
5
  import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
7
+ import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
6
8
  import { useMarqueeGestures } from "./marqueeCommit";
7
9
 
10
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
11
+
8
12
  // Layout stands in for a real preview: every element is visible and sits at the band's start.
9
13
  vi.mock("./domEditingElement", async (importOriginal) => ({
10
14
  ...(await importOriginal<typeof import("./domEditingElement")>()),
@@ -40,9 +44,53 @@ function Overlay() {
40
44
  );
41
45
  }
42
46
 
43
- const pointer = (type: string, clientX: number, clientY: number) =>
47
+ function MarqueeSurface({
48
+ overlayRef,
49
+ marquee,
50
+ }: {
51
+ overlayRef: RefObject<HTMLDivElement | null>;
52
+ marquee: ReturnType<typeof useMarqueeGestures>;
53
+ }) {
54
+ return (
55
+ <div
56
+ ref={overlayRef}
57
+ data-band-overlay
58
+ onPointerDown={marquee.begin}
59
+ onPointerMove={marquee.onPointerMove}
60
+ onPointerUp={marquee.onPointerUp}
61
+ />
62
+ );
63
+ }
64
+
65
+ function TestMarquee({
66
+ iframeRef,
67
+ onSelect,
68
+ resolveHits,
69
+ }: {
70
+ iframeRef: RefObject<HTMLIFrameElement | null>;
71
+ onSelect: (selections: HTMLElement[], additive: boolean) => DomSelectionResult | void;
72
+ resolveHits: (elements: HTMLElement[]) => HTMLElement[] | Promise<HTMLElement[]>;
73
+ }) {
74
+ const overlayRef = useRef<HTMLDivElement>(null);
75
+ const marquee = useMarqueeGestures({
76
+ iframeRef,
77
+ overlayRef,
78
+ activeCompositionPathRef: useRef<string | null>("index.html"),
79
+ onMarqueeSelectRef: { current: onSelect },
80
+ resolveHits,
81
+ });
82
+ return <MarqueeSurface overlayRef={overlayRef} marquee={marquee} />;
83
+ }
84
+
85
+ const pointer = (
86
+ type: string,
87
+ clientX: number,
88
+ clientY: number,
89
+ selector = "[data-overlay]",
90
+ shiftKey = false,
91
+ ) =>
44
92
  act(() => {
45
- document.querySelector("[data-overlay]")!.dispatchEvent(
93
+ document.querySelector(selector)!.dispatchEvent(
46
94
  new PointerEvent(type, {
47
95
  bubbles: true,
48
96
  buttons: type === "pointerup" ? 0 : 1,
@@ -50,6 +98,7 @@ const pointer = (type: string, clientX: number, clientY: number) =>
50
98
  pointerId: 1,
51
99
  clientX,
52
100
  clientY,
101
+ shiftKey,
53
102
  }),
54
103
  );
55
104
  });
@@ -68,6 +117,7 @@ const hostListener = (e: KeyboardEvent) => {
68
117
  };
69
118
 
70
119
  beforeEach(() => {
120
+ vi.clearAllMocks();
71
121
  hostEscapes = [];
72
122
  window.addEventListener("keydown", hostListener);
73
123
  root = mountReactHarness(<Overlay />);
@@ -128,42 +178,17 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
128
178
  const [retired, live] = [preview(), preview()];
129
179
  const picked: HTMLElement[][] = [];
130
180
  const iframeRef = { current: retired as HTMLIFrameElement | null };
131
- function Band() {
132
- const overlayRef = useRef<HTMLDivElement>(null);
133
- const marquee = useMarqueeGestures({
134
- iframeRef,
135
- overlayRef,
136
- activeCompositionPathRef: useRef<string | null>("index.html"),
137
- onMarqueeSelectRef: useRef(() => undefined),
138
- resolveHits: async (elements: HTMLElement[]) => {
181
+ const band = mountReactHarness(
182
+ <TestMarquee
183
+ iframeRef={iframeRef}
184
+ onSelect={() => undefined}
185
+ resolveHits={async (elements) => {
139
186
  picked.push(elements);
140
187
  return [];
141
- },
142
- });
143
- return (
144
- <div
145
- ref={overlayRef}
146
- data-band-overlay
147
- onPointerDown={marquee.begin}
148
- onPointerMove={marquee.onPointerMove}
149
- onPointerUp={marquee.onPointerUp}
150
- />
151
- );
152
- }
153
- const band = mountReactHarness(<Band />);
154
- const fire = (type: string, x: number, y: number) =>
155
- act(() => {
156
- document.querySelector("[data-band-overlay]")!.dispatchEvent(
157
- new PointerEvent(type, {
158
- bubbles: true,
159
- button: 0,
160
- buttons: type === "pointerup" ? 0 : 1,
161
- pointerId: 1,
162
- clientX: x,
163
- clientY: y,
164
- }),
165
- );
166
- });
188
+ }}
189
+ />,
190
+ );
191
+ const fire = (type: string, x: number, y: number) => pointer(type, x, y, "[data-band-overlay]");
167
192
  try {
168
193
  fire("pointerdown", 0, 0);
169
194
  fire("pointermove", 120, 90);
@@ -175,3 +200,47 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
175
200
  act(() => band.unmount());
176
201
  }
177
202
  });
203
+
204
+ it.each([
205
+ [2, false, 2, 0],
206
+ [1, true, 2, 1],
207
+ [2, true, 2, 1],
208
+ [1, true, 1, 0],
209
+ ])(
210
+ "counts marquee receipt: %s hits, changed=%s, group=%s",
211
+ async (hits, changed, count, events) => {
212
+ const iframe = document.createElement("iframe");
213
+ const doc = document.implementation.createHTMLDocument("preview");
214
+ doc.body.innerHTML =
215
+ '<div data-composition-id="main"><h1 id="title">Title</h1><p id="subtitle">Subtitle</p></div>';
216
+ Object.defineProperty(iframe, "contentDocument", { value: doc });
217
+ const apply = vi.fn((_selections: HTMLElement[], _additive: boolean) => ({ changed, count }));
218
+ const band = mountReactHarness(
219
+ <TestMarquee
220
+ iframeRef={{ current: iframe }}
221
+ onSelect={apply}
222
+ resolveHits={(elements) => elements.slice(0, hits)}
223
+ />,
224
+ );
225
+ const fire = (type: string, x: number, y: number) =>
226
+ pointer(type, x, y, "[data-band-overlay]", true);
227
+ try {
228
+ fire("pointerdown", 0, 0);
229
+ fire("pointermove", 120, 90);
230
+ await act(async () => {
231
+ fire("pointerup", 120, 90);
232
+ });
233
+ expect(apply).toHaveBeenCalledTimes(1);
234
+ expect(apply.mock.calls[0]![0]).toHaveLength(hits);
235
+ expect(trackStudioEvent).toHaveBeenCalledTimes(events);
236
+ if (events)
237
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
238
+ feature: "multi_select",
239
+ surface: "preview",
240
+ method: "drag",
241
+ });
242
+ } finally {
243
+ act(() => band.unmount());
244
+ }
245
+ },
246
+ );
@@ -1,3 +1,5 @@
1
+ import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
2
+ import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
1
3
  // fallow-ignore-file code-duplication
2
4
  import { useCallback, useEffect, useRef, useState } from "react";
3
5
  import type { DomEditSelection } from "./domEditing";
@@ -18,6 +20,16 @@ interface MarqueeState {
18
20
  target: Element;
19
21
  }
20
22
 
23
+ function currentMarqueeCandidates(
24
+ cached: MarqueeHit[] | null,
25
+ iframe: HTMLIFrameElement,
26
+ overlay: HTMLDivElement,
27
+ path: string,
28
+ ): MarqueeHit[] {
29
+ if (cached?.every((hit) => hit.element.ownerDocument === iframe.contentDocument)) return cached;
30
+ return collectMarqueeCandidates(iframe, overlay, path);
31
+ }
32
+
21
33
  const MARQUEE_THRESHOLD_PX = 4;
22
34
 
23
35
  interface MarqueeHit {
@@ -103,7 +115,9 @@ export interface MarqueeGesturesDeps<T = DomEditSelection> {
103
115
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
104
116
  overlayRef: React.RefObject<HTMLDivElement | null>;
105
117
  activeCompositionPathRef: React.RefObject<string | null>;
106
- onMarqueeSelectRef: React.RefObject<((selections: T[], additive: boolean) => void) | undefined>;
118
+ onMarqueeSelectRef: React.RefObject<
119
+ ((selections: T[], additive: boolean) => DomSelectionResult | void) | undefined
120
+ >;
107
121
  /** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
108
122
  resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
109
123
  selectionRef?: React.RefObject<DomEditSelection | null>;
@@ -127,6 +141,10 @@ export interface MarqueeGestures {
127
141
  onPointerCancel: () => void;
128
142
  }
129
143
 
144
+ function trackMarqueeSelection(result: DomSelectionResult | void): void {
145
+ if (result?.changed && result.count > 1) trackPreviewFeatureUsed("multi_select", "drag");
146
+ }
147
+
130
148
  function releaseCapture(m: MarqueeState): void {
131
149
  try {
132
150
  m.target.releasePointerCapture(m.pointerId);
@@ -160,18 +178,16 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
160
178
  const overlay = deps.overlayRef.current;
161
179
  if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
162
180
  const acp = deps.activeCompositionPathRef.current ?? "index.html";
163
- const cached = candidatesRef.current;
164
- const measuredOnScreen = cached?.every(
165
- (hit) => hit.element.ownerDocument === iframe.contentDocument,
166
- );
167
- const candidates =
168
- cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
181
+ const candidates = currentMarqueeCandidates(candidatesRef.current, iframe, overlay, acp);
169
182
  const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
170
183
  const resolveHits = deps.resolveHits;
171
184
  const picks = resolveHits
172
185
  ? await resolveHits(elements)
173
186
  : ((await resolveDomEditSelections(elements, acp)) as T[]);
174
- deps.onMarqueeSelectRef.current?.(picks, additive);
187
+ const applySelection = deps.onMarqueeSelectRef.current;
188
+ if (!applySelection) return;
189
+ const result = applySelection(picks, additive);
190
+ trackMarqueeSelection(result);
175
191
  },
176
192
  [
177
193
  deps.iframeRef,
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
2
+ import { beforeEach, describe, it, expect, vi } from "vitest";
3
3
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
4
4
  import { editableAnimationId } from "./motionPathSelection";
5
5
  import {
@@ -148,3 +148,31 @@ describe("commitCreatePath", () => {
148
148
  );
149
149
  });
150
150
  });
151
+
152
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
153
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
154
+ import type { CommitFn } from "./motionPathCommit";
155
+ beforeEach(() => vi.clearAllMocks());
156
+ describe("motion path usage at the writer", () => {
157
+ it("counts a changed drag once without coordinates or target ids", async () => {
158
+ const commit: CommitFn = async (_mutation, options) => {
159
+ options.onResult?.({ ok: true, changed: true });
160
+ };
161
+ await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "private-target", commit);
162
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
163
+ feature: "motion_path",
164
+ surface: "preview",
165
+ method: "drag",
166
+ });
167
+ });
168
+ it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
169
+ "does not count absent or unsuccessful results (%j)",
170
+ async (result) => {
171
+ const commit: CommitFn = async (_mutation, options) => {
172
+ if (result) options.onResult?.(result);
173
+ };
174
+ await commitAddWaypoint("private-target", 1, 20, 40, commit);
175
+ expect(trackStudioEvent).not.toHaveBeenCalled();
176
+ },
177
+ );
178
+ });
@@ -1,3 +1,5 @@
1
+ import type { CommitMutationOptions } from "../../hooks/gsapScriptCommitTypes";
2
+ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/previewFeatureUsage";
1
3
  /**
2
4
  * Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
3
5
  * GSAP source mutation routed through the (selection-bound) commit facade, which
@@ -7,11 +9,21 @@ import type { MotionNodeRef } from "./motionPathGeometry";
7
9
 
8
10
  export type CommitFn = (
9
11
  mutation: Record<string, unknown>,
10
- options: { label: string; softReload?: boolean },
12
+ options: CommitMutationOptions,
11
13
  ) => Promise<void>;
12
14
 
13
15
  const NEW_PATH_DURATION = 1.5;
14
16
 
17
+ function motionPathCommitOptions(label: string, method: PreviewMethod): CommitMutationOptions {
18
+ return {
19
+ label,
20
+ softReload: true,
21
+ onResult: (result) => {
22
+ if (result.ok && result.changed === true) trackPreviewFeatureUsed("motion_path", method);
23
+ },
24
+ };
25
+ }
26
+
15
27
  export function commitNode(
16
28
  ref: MotionNodeRef,
17
29
  x: number,
@@ -23,10 +35,10 @@ export function commitNode(
23
35
  ref.type === "keyframe"
24
36
  ? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
25
37
  : { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
26
- return commit(mutation, {
27
- label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
28
- softReload: true,
29
- });
38
+ return commit(
39
+ mutation,
40
+ motionPathCommitOptions(ref.type === "keyframe" ? "Move keyframe" : "Move waypoint", "drag"),
41
+ );
30
42
  }
31
43
 
32
44
  export function commitAddWaypoint(
@@ -38,7 +50,7 @@ export function commitAddWaypoint(
38
50
  ): Promise<void> {
39
51
  return commit(
40
52
  { type: "add-motion-path-point", animationId, index, x, y },
41
- { label: "Add waypoint", softReload: true },
53
+ motionPathCommitOptions("Add waypoint", "button"),
42
54
  );
43
55
  }
44
56
 
@@ -54,7 +66,7 @@ export function commitAddKeyframe(
54
66
  // at that pct) and converts a flat tween to keyframes form when needed.
55
67
  return commit(
56
68
  { type: "add-keyframe", animationId, percentage, properties: { x, y } },
57
- { label: "Add keyframe", softReload: true },
69
+ motionPathCommitOptions("Add keyframe", "button"),
58
70
  );
59
71
  }
60
72
 
@@ -65,7 +77,7 @@ export function commitRemoveWaypoint(
65
77
  ): Promise<void> {
66
78
  return commit(
67
79
  { type: "remove-motion-path-point", animationId, index },
68
- { label: "Remove waypoint", softReload: true },
80
+ motionPathCommitOptions("Remove waypoint", "button"),
69
81
  );
70
82
  }
71
83
 
@@ -78,6 +90,6 @@ export function commitCreatePath(
78
90
  ): Promise<void> {
79
91
  return commit(
80
92
  { type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
81
- { label: "Create motion path", softReload: true },
93
+ motionPathCommitOptions("Create motion path", "button"),
82
94
  );
83
95
  }
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { probeSourceElement } from "./probeSourceElement";
3
+
4
+ afterEach(() => vi.unstubAllGlobals());
5
+
6
+ const answer = (body: unknown, ok = true) =>
7
+ vi.fn(() => Promise.resolve({ ok, json: () => Promise.resolve(body) }));
8
+
9
+ describe("probeSourceElement", () => {
10
+ it("sends the asks for one file in one tick as a single request", async () => {
11
+ const fetchMock = answer({ exists: [true, false, true] });
12
+ vi.stubGlobal("fetch", fetchMock);
13
+
14
+ const results = await Promise.all([
15
+ probeSourceElement("p", "index.html", { id: "a" }),
16
+ probeSourceElement("p", "index.html", { id: "b" }),
17
+ probeSourceElement("p", "index.html", { id: "c" }),
18
+ ]);
19
+
20
+ expect(results).toEqual([true, false, true]);
21
+ expect(fetchMock).toHaveBeenCalledOnce();
22
+ const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
23
+ expect(JSON.parse(String(init.body)).targets).toEqual([{ id: "a" }, { id: "b" }, { id: "c" }]);
24
+ });
25
+
26
+ it("keeps one request per file", async () => {
27
+ const fetchMock = answer({ exists: [true] });
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ await Promise.all([
30
+ probeSourceElement("p", "index.html", { id: "a" }),
31
+ probeSourceElement("p", "compositions/x.html", { id: "a" }),
32
+ ]);
33
+ expect(fetchMock).toHaveBeenCalledTimes(2);
34
+ });
35
+
36
+ it("reads a failed or malformed answer as existing", async () => {
37
+ vi.stubGlobal("fetch", answer({}, false));
38
+ expect(await probeSourceElement("p", "index.html", { id: "a" })).toBe(true);
39
+ vi.stubGlobal(
40
+ "fetch",
41
+ vi.fn(() => Promise.reject(new Error("offline"))),
42
+ );
43
+ expect(await probeSourceElement("p", "index.html", { id: "a" })).toBe(true);
44
+ });
45
+ });
@@ -1,29 +1,53 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
 
3
- export async function probeSourceElement(
4
- projectId: string,
5
- sourceFile: string,
6
- target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
7
- ): Promise<boolean> {
3
+ type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
4
+
5
+ interface PendingProbe {
6
+ target: ProbeTarget;
7
+ resolve: (exists: boolean) => void;
8
+ }
9
+
10
+ const pending = new Map<string, PendingProbe[]>();
11
+
12
+ async function sendBatch(projectId: string, sourceFile: string, probes: PendingProbe[]) {
13
+ let exists: boolean[] = [];
8
14
  try {
9
15
  const response = await fetch(
10
16
  buildProjectApiPath(
11
17
  projectId,
12
- `/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
18
+ `/file-mutations/probe-elements/${encodeURIComponent(sourceFile)}`,
13
19
  ),
14
20
  {
15
21
  method: "POST",
16
22
  headers: { "Content-Type": "application/json" },
17
- body: JSON.stringify({ target }),
23
+ body: JSON.stringify({ targets: probes.map((probe) => probe.target) }),
18
24
  },
19
25
  );
20
- if (!response.ok) return true;
21
- const data = await response.json();
22
- if (data && typeof data === "object" && "exists" in data && data.exists === false) {
23
- return false;
26
+ if (response.ok) {
27
+ const data = await response.json();
28
+ if (Array.isArray(data?.exists)) exists = data.exists;
29
+ }
30
+ } catch {}
31
+ probes.forEach((probe, index) => probe.resolve(exists[index] !== false));
32
+ }
33
+
34
+ export function probeSourceElement(
35
+ projectId: string,
36
+ sourceFile: string,
37
+ target: ProbeTarget,
38
+ ): Promise<boolean> {
39
+ return new Promise((resolve) => {
40
+ const key = `${projectId}\0${sourceFile}`;
41
+ let probes = pending.get(key);
42
+ if (!probes) {
43
+ const batch: PendingProbe[] = [];
44
+ probes = batch;
45
+ pending.set(key, batch);
46
+ setTimeout(() => {
47
+ pending.delete(key);
48
+ void sendBatch(projectId, sourceFile, batch);
49
+ }, 0);
24
50
  }
25
- return true;
26
- } catch {
27
- return true;
28
- }
51
+ probes.push({ target, resolve });
52
+ });
29
53
  }
@@ -193,7 +193,7 @@ export function ColorField({
193
193
  const resolveColorGestureValue = useCallback((nextValue: string) => {
194
194
  const source = nextValue.startsWith("#") ? "hex" : "picker";
195
195
  // Only a COMPLETE hex resolves, so a half-typed one neither previews nor
196
- // commits. Both lengths parseCssColor accepts count as complete: gating on
196
+ // commits. 3 and 6 digits count as complete (the field edits RGB only): gating on
197
197
  // 6 alone silently dropped #F00 and friends, which the old onBlur committed.
198
198
  if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
199
199
  const nextColor = parseCssColor(nextValue);
@@ -1,3 +1,4 @@
1
+ import type { PropertyPanelProps } from "./propertyPanelTypes";
1
2
  import { useEffect, useState } from "react";
2
3
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
4
  import { Plus, X } from "../../icons/SystemIcons";
@@ -246,13 +247,15 @@ export function FlatTextSection({
246
247
  element: DomEditSelection;
247
248
  styles: Record<string, string>;
248
249
  fontAssets: ImportedFontAsset[];
249
- onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
250
- onSetText: (value: string, fieldKey?: string) => void;
251
- onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
252
- onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
253
- onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
254
- onRemoveTextField: (fieldKey: string) => void;
255
- }) {
250
+ } & Pick<
251
+ PropertyPanelProps,
252
+ | "onImportFonts"
253
+ | "onSetText"
254
+ | "onSetTextFieldStyle"
255
+ | "onPreviewTextFieldStyle"
256
+ | "onAddTextField"
257
+ | "onRemoveTextField"
258
+ >) {
256
259
  const track = useTrackDesignInput();
257
260
  const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
258
261
  element.textFields[0]?.key ?? null,
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * The add-effect shelf: Tone, the composite jobs, and the raw registry.
3
4
  *
@@ -81,6 +82,7 @@ export function FxAddMenu({
81
82
  withJob,
82
83
  withEffect,
83
84
  }: FxAddMenuProps) {
85
+ const trackInput = useTrackDesignInput();
84
86
  return (
85
87
  <div
86
88
  className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
@@ -108,8 +110,15 @@ export function FxAddMenu({
108
110
  title="Listen to this track and even out its loud and quiet parts."
109
111
  disabled={disabled || analysing}
110
112
  onClick={() => {
111
- if (levelled) onRemoveLevel?.();
112
- else onLevel();
113
+ if (levelled) {
114
+ if (onRemoveLevel) {
115
+ onRemoveLevel();
116
+ trackInput("button", "remove-levelling");
117
+ }
118
+ } else {
119
+ onLevel();
120
+ trackInput("button", "add-levelling");
121
+ }
113
122
  onClose();
114
123
  }}
115
124
  // The one module here that cannot answer instantly: it has to
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * The voiceover carve, as one module in the FX rack.
3
4
  *
@@ -165,6 +166,7 @@ export function FxCarveModule({
165
166
  onCarvePreview(carve: HfCarveSettings): void;
166
167
  }) {
167
168
  const on = carve.enabled;
169
+ const trackInput = useTrackDesignInput();
168
170
  const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
169
171
  const summary = carveSummary({ nodes, carve, analysing });
170
172
  // The carve's own colour, used three ways: the module's left edge, the title,
@@ -215,7 +217,10 @@ export function FxCarveModule({
215
217
  aria-pressed={on}
216
218
  title={on ? "Switch the carve off" : "Switch the carve on"}
217
219
  disabled={disabled}
218
- onClick={() => onCarveChange({ ...carve, enabled: !on })}
220
+ onClick={() => {
221
+ onCarveChange({ ...carve, enabled: !on });
222
+ trackInput("toggle", "carve-enabled");
223
+ }}
219
224
  >
220
225
  {on ? "On" : "Off"}
221
226
  </button>
@@ -250,7 +255,10 @@ export function FxCarveModule({
250
255
  value={carve.strength}
251
256
  disabled={disabled || carve.sources.length === 0}
252
257
  onChange={(_k, v) => onCarvePreview({ ...carve, strength: Number(v) })}
253
- onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })}
258
+ onCommit={(_k, v) => {
259
+ onCarveChange({ ...carve, strength: Number(v) });
260
+ trackInput("slider", "carve-strength");
261
+ }}
254
262
  />
255
263
  </div>
256
264
  {/* What the analysis made of all that. Divided rather than boxed: these
@@ -334,6 +342,7 @@ function CarveSourceRow({
334
342
  disabled?: boolean;
335
343
  onCarveChange(carve: HfCarveSettings): void;
336
344
  }) {
345
+ const trackInput = useTrackDesignInput();
337
346
  return (
338
347
  <div className="hf-fx-row flex min-h-6 items-center gap-2">
339
348
  {/* Wraps like every other name in this column (see FxParamRow) — one
@@ -366,14 +375,15 @@ function CarveSourceRow({
366
375
  data-carve-source={o.id}
367
376
  checked={carve.sources.includes(o.id)}
368
377
  disabled={disabled}
369
- onChange={(e) =>
378
+ onChange={(e) => {
370
379
  onCarveChange({
371
380
  ...carve,
372
381
  sources: e.target.checked
373
382
  ? [...carve.sources, o.id]
374
383
  : carve.sources.filter((id) => id !== o.id),
375
- })
376
- }
384
+ });
385
+ trackInput("toggle", "carve-source");
386
+ }}
377
387
  />
378
388
  <span className="truncate">{o.label}</span>
379
389
  </label>
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * An effect row's open face: the derived or primary one-knob control, and
3
4
  * Details underneath it.
@@ -54,6 +55,7 @@ function FxNodeDerivedKnob({
54
55
  onUpdate(index: number, patch: Partial<HfAudioFxNode>): void;
55
56
  trackKind?: string;
56
57
  }) {
58
+ const trackInput = useTrackDesignInput();
57
59
  if (!derived) return null;
58
60
  return (
59
61
  <>
@@ -66,6 +68,7 @@ function FxNodeDerivedKnob({
66
68
  onCommit={(_k, v) => {
67
69
  trackProfileCommitted(node.type, Number(v), { trackKind });
68
70
  onUpdate(index, { params: applyAudioFxProfile(node.type, Number(v), params) });
71
+ trackInput("slider", "profile-strength");
69
72
  }}
70
73
  />
71
74
  </div>