@hyperframes/studio 0.8.114 → 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 (155) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
  4. package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  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 +43 -19
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +2939 -2596
  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 +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/components/LinkedSelectionToggle.tsx +3 -2
  21. package/src/components/dock/Dock.tsx +5 -7
  22. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  23. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  25. package/src/components/editor/DomEditOverlay.tsx +5 -1
  26. package/src/components/editor/FileTreeNodes.tsx +13 -10
  27. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  28. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  29. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  30. package/src/components/editor/SnapToolbar.tsx +43 -5
  31. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  32. package/src/components/editor/audioFxTelemetry.ts +2 -3
  33. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  34. package/src/components/editor/cropResize.ts +4 -3
  35. package/src/components/editor/domEditingDom.ts +4 -0
  36. package/src/components/editor/domEditingLayers.ts +1 -5
  37. package/src/components/editor/manualEdits.ts +1 -0
  38. package/src/components/editor/manualEditsParsing.ts +7 -0
  39. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  40. package/src/components/editor/marqueeCommit.ts +24 -8
  41. package/src/components/editor/motionPathCommit.test.ts +29 -1
  42. package/src/components/editor/motionPathCommit.ts +21 -9
  43. package/src/components/editor/probeSourceElement.test.ts +45 -0
  44. package/src/components/editor/probeSourceElement.ts +39 -15
  45. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  46. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  47. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  48. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  49. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  50. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  51. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  52. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  53. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  54. package/src/components/editor/propertyPanelTypes.ts +6 -3
  55. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  56. package/src/components/editor/useDomEditNudge.ts +2 -0
  57. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  58. package/src/components/editor/useDomEditZOrder.ts +12 -2
  59. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  60. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  61. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  62. package/src/components/ui/menuStyle.test.ts +61 -0
  63. package/src/components/ui/menuStyle.ts +10 -0
  64. package/src/contexts/DomEditContext.tsx +1 -1
  65. package/src/contexts/TimelineEditContext.tsx +1 -0
  66. package/src/domEditExports.test.tsx +9 -1
  67. package/src/hooks/domEditCommitRunner.ts +1 -1
  68. package/src/hooks/elementOffsetStager.ts +5 -4
  69. package/src/hooks/gsapDragCommit.ts +2 -20
  70. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  71. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  72. package/src/hooks/gsapGestureOutcome.ts +64 -0
  73. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  74. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  75. package/src/hooks/gsapResizeIntercept.ts +1 -0
  76. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  77. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  78. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  79. package/src/hooks/plainRotation.ts +3 -2
  80. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  81. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  82. package/src/hooks/refreshTimelineRowText.ts +29 -0
  83. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  84. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  85. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  86. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  87. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  88. package/src/hooks/useDomEditTextCommits.ts +5 -1
  89. package/src/hooks/useDomEditWiring.ts +4 -2
  90. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  91. package/src/hooks/useDomGeometryCommit.ts +7 -4
  92. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  93. package/src/hooks/useDomGeometryCommits.ts +3 -2
  94. package/src/hooks/useDomSelection.test.ts +26 -0
  95. package/src/hooks/useDomSelection.ts +10 -3
  96. package/src/hooks/useDomSelectionTypes.ts +6 -1
  97. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  98. package/src/hooks/useEnableKeyframes.ts +5 -3
  99. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  100. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  101. package/src/hooks/useGestureCommit.test.tsx +225 -55
  102. package/src/hooks/useGestureCommit.ts +86 -55
  103. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  104. package/src/hooks/useGsapAwareEditing.ts +96 -195
  105. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  106. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  107. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  108. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  109. package/src/hooks/useGsapScriptCommits.ts +3 -0
  110. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  111. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  112. package/src/hooks/useGsapTweenCache.ts +18 -12
  113. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  114. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  115. package/src/hooks/usePreviewInteraction.ts +2 -0
  116. package/src/hooks/useRenderClipContent.test.ts +18 -0
  117. package/src/hooks/useRenderClipContent.ts +4 -5
  118. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  119. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  120. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  121. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  122. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  123. package/src/player/components/ClipContextMenu.tsx +33 -31
  124. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  125. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  126. package/src/player/components/TextClipContent.test.ts +19 -0
  127. package/src/player/components/TextClipContent.tsx +27 -0
  128. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  129. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  130. package/src/player/components/clipMenuToolItems.tsx +13 -9
  131. package/src/player/components/timelineCallbacks.ts +1 -0
  132. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  133. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  134. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  135. package/src/player/components/timelineEditing.ts +3 -1
  136. package/src/player/components/timelineLinkSelection.ts +35 -1
  137. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  138. package/src/player/components/useTimelineClipDrag.ts +6 -7
  139. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  140. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  141. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  142. package/src/player/lib/timelineElementHelpers.ts +6 -15
  143. package/src/player/lib/timelineText.ts +40 -0
  144. package/src/player/store/timelineElement.ts +12 -0
  145. package/src/styles/theme.css +3 -0
  146. package/src/utils/keyframeUsage.ts +79 -0
  147. package/src/utils/previewFeatureUsage.test.ts +32 -0
  148. package/src/utils/previewFeatureUsage.ts +46 -0
  149. package/src/utils/studioTelemetry.test.ts +107 -0
  150. package/src/utils/studioTelemetry.ts +49 -4
  151. package/src/webmcp/StudioAgentTools.tsx +2 -2
  152. package/dist/chunk-BIUXP3PG.js.map +0 -1
  153. package/dist/chunk-K47E2ZJA.js.map +0 -1
  154. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  155. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
5
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
4
6
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
5
7
  import type { DomEditSelection } from "./domEditing";
6
8
  import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
@@ -178,7 +180,7 @@ function buildHarness(
178
180
  };
179
181
 
180
182
  const handlers = createDomEditOverlayGestureHandlers(opts);
181
- return { handlers, commits, selection };
183
+ return { handlers, commits, selection, opts };
182
184
  }
183
185
 
184
186
  type OverlayRectLike = {
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
287
289
  expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
288
290
  });
289
291
  });
292
+
293
+ describe("resize usage at pointer release", () => {
294
+ it("counts one completed resize despite multiple pointer moves", async () => {
295
+ vi.mocked(trackStudioEvent).mockClear();
296
+ const h = buildHarness(async () => ({ ok: true, changed: true }));
297
+ await finishResize(h.handlers);
298
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
299
+ feature: "resize",
300
+ surface: "preview",
301
+ method: "drag",
302
+ });
303
+ });
304
+ it.each([undefined, { ok: true as const, changed: false }])(
305
+ "does not count an unchanged or unconfirmed resize",
306
+ async (result) => {
307
+ vi.mocked(trackStudioEvent).mockClear();
308
+ const h = buildHarness(async () => result);
309
+ await finishResize(h.handlers);
310
+ expect(trackStudioEvent).not.toHaveBeenCalled();
311
+ },
312
+ );
313
+ });
314
+
315
+ describe("move and rotation usage", () => {
316
+ it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
317
+ vi.mocked(trackStudioEvent).mockClear();
318
+ const h = buildHarness();
319
+ h.selection.capabilities.canApplyManualRotation = true;
320
+ const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
321
+ h.opts.onPathOffsetCommitRef.current = saved;
322
+ h.opts.onRotationCommitRef.current = saved;
323
+ h.handlers.startGesture(kind, evt(200, 50));
324
+ h.handlers.onPointerMove(evt(150, 120));
325
+ h.handlers.onPointerMove(evt(100, 150));
326
+ expect(trackStudioEvent).not.toHaveBeenCalled();
327
+ h.handlers.onPointerUp(evt(100, 150));
328
+ await Promise.resolve();
329
+ expect(saved).toHaveBeenCalledTimes(1);
330
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
331
+ feature: kind === "drag" ? "move" : "rotate",
332
+ surface: "preview",
333
+ method: "drag",
334
+ });
335
+ });
336
+ });
@@ -10,9 +10,8 @@
10
10
  * filenames, not composition paths, not chain JSON. Every property below is
11
11
  * either a fixed identifier from our own catalogue (a preset id, an effect
12
12
  * type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
13
- * strips the query string off `url_hash` for exactly this reason — the ids in
14
- * it are the author's own. The rack sees the same class of data and must hold
15
- * the same line.
13
+ * sends only a route kind in `url_hash` for the same reason. The rack sees
14
+ * the same class of data and must hold the same line.
16
15
  *
17
16
  * The second rule is about volume: **commit, not preview.** Every control in
18
17
  * the rack has a preview path that fires continuously while a slider moves and
@@ -287,7 +287,7 @@ describe("handles follow what Studio would commit", () => {
287
287
  { x: 0, y: 0 },
288
288
  commitList,
289
289
  preview,
290
- vi.fn(),
290
+ vi.fn(async () => undefined),
291
291
  undefined,
292
292
  preflightOnly,
293
293
  );
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
401
401
  { x: 0, y: 0 },
402
402
  [stagger],
403
403
  preview,
404
- vi.fn(),
404
+ vi.fn(async () => undefined),
405
405
  undefined,
406
406
  groupPreflight,
407
407
  );
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
1
2
  import type { PatchOperation } from "../../utils/sourcePatcher";
2
3
  import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
3
4
  import type { DomEditSelection } from "./domEditingTypes";
@@ -94,7 +95,7 @@ type PatchCommit = (
94
95
  selection: DomEditSelection,
95
96
  patches: PatchOperation[],
96
97
  options: { label: string; coalesceKey: string; coalesceMs?: number },
97
- ) => Promise<void>;
98
+ ) => Promise<DomEditPersistOutcome | undefined>;
98
99
 
99
100
  /** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
100
101
  export async function saveCropResize(
@@ -102,11 +103,11 @@ export async function saveCropResize(
102
103
  selection: DomEditSelection,
103
104
  commit: PatchCommit,
104
105
  coalesceKey: string,
105
- ): Promise<void> {
106
+ ): Promise<DomEditPersistOutcome | undefined> {
106
107
  const crop = stage();
107
108
  if (!crop) return;
108
109
  try {
109
- await commit(selection, [crop.patch], {
110
+ return await commit(selection, [crop.patch], {
110
111
  label: "Resize layer",
111
112
  coalesceKey,
112
113
  coalesceMs: Number.POSITIVE_INFINITY,
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
30
30
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
31
31
  }
32
32
 
33
+ export function isEditableTextLeaf(el: HTMLElement): boolean {
34
+ return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
35
+ }
36
+
33
37
  /** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
34
38
  * rule; the walk below only decides which nodes to ask it about. */
35
39
  function elementRendersItself(win: Window, el: HTMLElement): boolean {
@@ -23,8 +23,8 @@ import {
23
23
  getInlineStyles,
24
24
  getSelectorIndex,
25
25
  getSourceFileForElement,
26
+ isEditableTextLeaf,
26
27
  isHtmlElement,
27
- isTextBearingTag,
28
28
  } from "./domEditingDom";
29
29
  import {
30
30
  findElementForSelection,
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
35
35
  import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
36
36
  import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
37
37
 
38
- export function isEditableTextLeaf(el: HTMLElement): boolean {
39
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
40
- }
41
-
42
38
  function sameTagChildIndex(el: HTMLElement): number {
43
39
  let index = 0;
44
40
  let sibling = el.previousElementSibling;
@@ -10,6 +10,7 @@ export {
10
10
  } from "./manualEditsTypes";
11
11
 
12
12
  export {
13
+ mergeFileChangeAffectedCompositions,
13
14
  readFileChangeAffectedCompositions,
14
15
  readFileChangeAffectsPreview,
15
16
  readFileChangeField,
@@ -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
+ }
@@ -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
  }