@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
@@ -81,9 +81,9 @@ async function requestParsedAnimations(
81
81
  try {
82
82
  const res = await fetch(
83
83
  `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
84
- // Always re-read the freshly-parsed source; no per-call timestamp (which
85
- // would defeat caching forever and is a deterministic-render no-no).
86
- { cache: "no-store" },
84
+ // Always revalidate; an unchanged file answers 304. No per-call timestamp
85
+ // (a deterministic-render no-no).
86
+ { cache: "no-cache" },
87
87
  );
88
88
  if (!res.ok) return null;
89
89
  const parsed: unknown = await res.json();
@@ -106,9 +106,9 @@ export async function populateKeyframeCacheFromAst(
106
106
  projectId: string,
107
107
  sf: string,
108
108
  doc: Document | null | undefined,
109
- ): Promise<void> {
109
+ ): Promise<boolean> {
110
110
  const parsed = await fetchParsedAnimations(projectId, sf);
111
- if (!parsed) return;
111
+ if (!parsed) return false;
112
112
  const { elements, domClipChildren } = usePlayerStore.getState();
113
113
  const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
114
114
  const sourceByElement = new Map<string, GsapAnimation[]>();
@@ -136,4 +136,5 @@ export async function populateKeyframeCacheFromAst(
136
136
  }
137
137
  }
138
138
  replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
139
+ return true;
139
140
  }
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "./domEditCommitTypes";
1
2
  import type { DomEditSelection } from "../components/editor/domEditing";
2
3
  import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
3
4
  import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
@@ -17,8 +18,8 @@ export function savePlainRotation(
17
18
  { commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
18
19
  selection: DomEditSelection,
19
20
  next: RotationCommit,
20
- ): Promise<void> {
21
- if (readOnlyPreview) return Promise.resolve();
21
+ ): Promise<DomEditPersistOutcome | undefined> {
22
+ if (readOnlyPreview) return Promise.resolve(undefined);
22
23
  const { element } = selection;
23
24
  const before = captureStudioRotation(element);
24
25
  // Legacy marks go in the same write, or a seek puts their angle back; clearing them voids the press read.
@@ -101,7 +101,7 @@ describe("a shadow reload promoted without a host re-render", () => {
101
101
  });
102
102
  expect(overlayNode).toBe(selection.element);
103
103
 
104
- const commitPositionPatchToHtml = vi.fn(async () => {});
104
+ const commitPositionPatchToHtml = vi.fn(async () => undefined);
105
105
  await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
106
106
  await stageElementOffset(
107
107
  { commitPositionPatchToHtml, showToast: vi.fn() },
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
4
+ import { refreshTimelineRowText } from "./refreshTimelineRowText";
5
+
6
+ afterEach(() => {
7
+ usePlayerStore.getState().reset();
8
+ document.body.innerHTML = "";
9
+ });
10
+
11
+ const row = (key: string, selector: string, sourceFile?: string): TimelineElement => ({
12
+ id: key.split("#").pop()!,
13
+ key,
14
+ tag: "p",
15
+ start: 0,
16
+ duration: 2,
17
+ track: 0,
18
+ selector,
19
+ selectorIndex: 0,
20
+ sourceFile,
21
+ text: { value: "Old" },
22
+ });
23
+
24
+ describe("refreshTimelineRowText", () => {
25
+ it("gives the edited layer's row, addressed by its store key, its new words", () => {
26
+ document.body.innerHTML = `<h1 id="title">New words</h1>`;
27
+ usePlayerStore.getState().setElements([row("index.html#title", "#title")]);
28
+
29
+ refreshTimelineRowText(document.getElementById("title")!);
30
+
31
+ expect(usePlayerStore.getState().elements[0]?.text?.value).toBe("New words");
32
+ });
33
+
34
+ it("finds a row known only by its data-hf-id", () => {
35
+ document.body.innerHTML = `<p data-hf-id="caption-a">New</p>`;
36
+ usePlayerStore
37
+ .getState()
38
+ .setElements([
39
+ { ...row("index.html#caption-a", ""), selector: undefined, hfId: "caption-a" },
40
+ ]);
41
+
42
+ refreshTimelineRowText(document.querySelector("p")!);
43
+
44
+ expect(usePlayerStore.getState().elements[0]?.text?.value).toBe("New");
45
+ });
46
+
47
+ it("finds the row in the edited layer's own composition when two share a selector", () => {
48
+ document.body.innerHTML = `
49
+ <div data-composition-id="a" data-composition-file="a.html"><p class="caption">A</p></div>
50
+ <div data-composition-id="b" data-composition-file="b.html"><p class="caption">B new</p></div>`;
51
+ usePlayerStore
52
+ .getState()
53
+ .setElements([
54
+ row("a.html#a-caption", ".caption", "a.html"),
55
+ row("b.html#b-caption", ".caption", "b.html"),
56
+ ]);
57
+
58
+ refreshTimelineRowText(document.querySelectorAll<HTMLElement>(".caption")[1]!);
59
+
60
+ expect(usePlayerStore.getState().elements.map((element) => element.text?.value)).toEqual([
61
+ "Old",
62
+ "B new",
63
+ ]);
64
+ });
65
+ });
@@ -0,0 +1,29 @@
1
+ import {
2
+ getTimelineElementIdentity,
3
+ getTimelineElementSelector,
4
+ getTimelineElementSelectorIndex,
5
+ getTimelineElementSourceFile,
6
+ } from "../player/lib/timelineElementHelpers";
7
+ import { readTimelineText } from "../player/lib/timelineText";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+
10
+ /** A text commit skips the preview reload, so the edited layer's row re-reads its words here. */
11
+ export function refreshTimelineRowText(el: HTMLElement): void {
12
+ const sourceFile = getTimelineElementSourceFile(el);
13
+ const hfId = el.getAttribute("data-hf-id");
14
+ const selector = getTimelineElementSelector(el);
15
+ const selectorIndex = selector
16
+ ? (getTimelineElementSelectorIndex(el.ownerDocument, el, selector) ?? 0)
17
+ : 0;
18
+ const { elements, updateElement } = usePlayerStore.getState();
19
+ const row = elements.find(
20
+ (candidate) =>
21
+ candidate.sourceFile === sourceFile &&
22
+ (hfId
23
+ ? candidate.hfId === hfId
24
+ : !!selector &&
25
+ candidate.selector === selector &&
26
+ (candidate.selectorIndex ?? 0) === selectorIndex),
27
+ );
28
+ if (row) updateElement(getTimelineElementIdentity(row), { text: readTimelineText(el) });
29
+ }
@@ -2,17 +2,22 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
9
9
  import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
10
10
  import { writeSizeWithCrop } from "../components/editor/cropResize";
11
- import type { CommitMutation } from "./gsapScriptCommitTypes";
11
+ import { trackStudioEvent } from "../utils/studioTelemetry";
12
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
13
+ import type { CommitMutationOptions, CommitMutation } from "./gsapScriptCommitTypes";
14
+
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
12
16
 
13
17
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
18
 
15
19
  afterEach(() => {
20
+ vi.clearAllMocks();
16
21
  document.body.innerHTML = "";
17
22
  usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
18
23
  });
@@ -43,7 +48,11 @@ type Commit = (
43
48
  /** Renders the hook and hands its commit function to the caller via a ref callback. */
44
49
  function renderHookWith(
45
50
  animations: GsapAnimation[],
46
- onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
51
+ onMutation: (
52
+ mutation: Record<string, unknown>,
53
+ label: string,
54
+ options: CommitMutationOptions,
55
+ ) => unknown | Promise<unknown>,
47
56
  onReady: (commit: Commit) => void,
48
57
  bumpGsapCache = vi.fn(),
49
58
  onBatch?: (
@@ -56,9 +65,9 @@ function renderHookWith(
56
65
  async (
57
66
  _sel: DomEditSelection,
58
67
  mutation: Record<string, unknown>,
59
- options: { label: string },
68
+ options: CommitMutationOptions,
60
69
  ) => {
61
- await onMutation(mutation, options.label);
70
+ await onMutation(mutation, options.label, options);
62
71
  },
63
72
  onBatch
64
73
  ? {
@@ -401,3 +410,35 @@ describe("useAnimatedPropertyCommit — a size write and its crop are one undo s
401
410
  act(() => root.unmount());
402
411
  });
403
412
  });
413
+
414
+ it("counts one add when an inspector edit first converts a flat animation", async () => {
415
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 1 });
416
+ const flat = parseGsapScript(
417
+ 'const tl = gsap.timeline(); tl.to("#box", { rotationX: 0, duration: 2 }, 0);',
418
+ ).animations[0]!;
419
+ const mutations: Record<string, unknown>[] = [];
420
+ let commit!: Commit;
421
+ const root = renderHookWith(
422
+ [flat, keyframedAnim],
423
+ (mutation, _label, options) => {
424
+ mutations.push(mutation);
425
+ trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
426
+ },
427
+ (value) => {
428
+ commit = value;
429
+ },
430
+ );
431
+ try {
432
+ await act(async () => {
433
+ await commit(selection, { rotationX: 20 });
434
+ });
435
+ expect(mutations.map((mutation) => mutation.type)).toEqual([
436
+ "convert-to-keyframes",
437
+ "add-keyframe",
438
+ ]);
439
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
440
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
441
+ } finally {
442
+ act(() => root.unmount());
443
+ }
444
+ });
@@ -333,7 +333,7 @@ async function commitKeyframeProps(
333
333
  await commit(
334
334
  selection,
335
335
  { type: "convert-to-keyframes", animationId: anim.id },
336
- { label: "Convert to keyframes", skipReload: true },
336
+ { label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
337
337
  );
338
338
  }
339
339
  const ct = usePlayerStore.getState().currentTime;
@@ -389,7 +389,7 @@ async function commitKeyframeProps(
389
389
  duration: roundTo3(newDuration),
390
390
  keyframes: remapped,
391
391
  },
392
- { label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
392
+ { label: `Edit ${primaryProp} (extended keyframe)`, keyframeAction: "add", softReload: true },
393
393
  );
394
394
  return;
395
395
  }
@@ -8,19 +8,28 @@
8
8
 
9
9
  import { useCallback, useRef } from "react";
10
10
  import type { TimelineElement } from "../player";
11
+ import {
12
+ MAX_HAND_EDIT_CLIPS,
13
+ type BlockedTimelineEditIntent,
14
+ } from "../player/components/timelineEditing";
11
15
 
12
16
  const BLOCKED_TOAST_INTERVAL_MS = 1500;
13
17
 
14
18
  export function useBlockedTimelineEditToast(
15
19
  showToast: (message: string, tone?: "info" | "error") => void,
16
- ): (element: TimelineElement) => void {
20
+ ): (element: TimelineElement, intent: BlockedTimelineEditIntent) => void {
17
21
  const lastAtRef = useRef(0);
18
22
  return useCallback(
19
- (_element: TimelineElement) => {
23
+ (_element: TimelineElement, intent: BlockedTimelineEditIntent) => {
20
24
  const now = Date.now();
21
25
  if (now - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
22
26
  lastAtRef.current = now;
23
- showToast("This clip can't be moved or resized from the timeline yet.", "info");
27
+ showToast(
28
+ intent === "edit-many"
29
+ ? `Select ${MAX_HAND_EDIT_CLIPS} or fewer clips to move or resize by hand, or describe the change.`
30
+ : "This clip can't be moved or resized from the timeline yet.",
31
+ "info",
32
+ );
24
33
  },
25
34
  [showToast],
26
35
  );
@@ -33,30 +33,25 @@ export function useDomEditPositionPatchCommit({
33
33
  coalesceMs: options.coalesceMs,
34
34
  skipRefresh: options.skipRefresh ?? true,
35
35
  deferRender: options.deferRender,
36
- })
37
- .then(() => undefined)
38
- .catch((error) => {
39
- // A paused save queue is not worth a toast: the paused-save banner is
40
- // already on screen, and one toast per blocked edit is what this branch
41
- // exists to prevent. It still has to REJECT, though. Swallowing it
42
- // resolved the commit, which skipped the caller's revert, so the element
43
- // stayed where the drag put it while nothing reached the file.
44
- if (error instanceof DomEditSaveQueueOpenError) throw error;
45
- if (!wasAlreadyToasted(error)) {
46
- showToast(error instanceof Error ? error.message : "Failed to save position");
47
- }
48
- trackStudioSaveFailure({
49
- source: "dom_edit",
50
- error,
51
- filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
52
- mutationType: "position",
53
- label: options.label,
54
- targetId: selection.id,
55
- targetSelector: selection.selector,
56
- targetSourceFile: selection.sourceFile,
57
- });
58
- throw error;
36
+ }).catch((error) => {
37
+ // The paused-save banner already explains this refusal. Rethrow so the
38
+ // caller reverts the preview instead of treating an unsaved edit as committed.
39
+ if (error instanceof DomEditSaveQueueOpenError) throw error;
40
+ if (!wasAlreadyToasted(error)) {
41
+ showToast(error instanceof Error ? error.message : "Failed to save position");
42
+ }
43
+ trackStudioSaveFailure({
44
+ source: "dom_edit",
45
+ error,
46
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
47
+ mutationType: "position",
48
+ label: options.label,
49
+ targetId: selection.id,
50
+ targetSelector: selection.selector,
51
+ targetSourceFile: selection.sourceFile,
59
52
  });
53
+ throw error;
54
+ });
60
55
  },
61
56
  [activeCompPath, persistDomEditOperations, showToast],
62
57
  );
@@ -160,6 +160,21 @@ describe("useDomEditTextCommits", () => {
160
160
  };
161
161
  }
162
162
 
163
+ it.each([true, false])(
164
+ "counts in-place text only after a changed write (%s)",
165
+ async (changed) => {
166
+ vi.mocked(trackStudioEvent).mockClear();
167
+ const { persist, save } = richTextProbe();
168
+ persist.mockResolvedValue({ changed, sourceFile: "private.html", version: "v2" });
169
+ await save();
170
+ expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
171
+ changed
172
+ ? [["feature_used", { feature: "text_edit", surface: "preview", method: "field" }]]
173
+ : [],
174
+ );
175
+ },
176
+ );
177
+
163
178
  it("saves in-place text while the preview is editable, and refreshes the selection it edited", async () => {
164
179
  const applyDomSelection = vi.fn();
165
180
  const { persist, element, save } = richTextProbe(undefined, () => ({ applyDomSelection }));
@@ -464,3 +479,6 @@ describe("useDomEditTextCommits", () => {
464
479
  expect(agentElement.textContent).toBe("Edited");
465
480
  });
466
481
  });
482
+
483
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
484
+ import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -1,4 +1,6 @@
1
+ import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage";
1
2
  import { useCallback, useRef } from "react";
3
+ import { refreshTimelineRowText } from "./refreshTimelineRowText";
2
4
  import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
3
5
  import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
4
6
  import {
@@ -63,6 +65,7 @@ async function resyncDomTextSelectionFromPreview(
63
65
  if (!doc) return;
64
66
  const refreshed = findElementForSelection(doc, selection, activeCompPath);
65
67
  if (!refreshed) return;
68
+ refreshTimelineRowText(refreshed);
66
69
  const nextSelection = await buildDomSelectionFromTarget(refreshed);
67
70
  if (!nextSelection) return;
68
71
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
@@ -272,11 +275,12 @@ export function useDomEditTextCommits({
272
275
  appliedHtml = element.innerHTML;
273
276
  },
274
277
  persist: async () => {
275
- await persistDomEditOperations(selection, operations, {
278
+ const result = await persistDomEditOperations(selection, operations, {
276
279
  label: "Edit text",
277
280
  skipRefresh: true,
278
281
  shouldSave: isLatestTextCommit,
279
282
  });
283
+ if (result?.changed) trackPreviewFeatureUsed("text_edit", "field");
280
284
  },
281
285
  shouldRevert: () => isLatestTextCommit(),
282
286
  revert: () => {
@@ -104,8 +104,10 @@ export interface UseDomEditWiringParams {
104
104
  animId: string,
105
105
  resolvedFromValues?: Record<string, number | string>,
106
106
  ) => Promise<void>;
107
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
108
- handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
107
+ removeAllKeyframes: Parameters<typeof useGsapSelectionHandlers>[0]["removeAllKeyframes"];
108
+ handleDomManualEditsReset: (
109
+ sel: DomEditSelection,
110
+ ) => Promise<void | import("./domEditCommitTypes").DomEditPersistOutcome>;
109
111
  }
110
112
 
111
113
  // fallow-ignore-next-line complexity
@@ -127,7 +127,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
127
127
  y: 20,
128
128
  });
129
129
 
130
- expect(outcome).toEqual({ ok: true });
130
+ expect(outcome).toEqual({ ok: true, changed: true });
131
131
  expect(mutations).toEqual([
132
132
  expect.objectContaining({ type: "add", targetSelector: "#card", method: "set" }),
133
133
  ]);
@@ -146,7 +146,14 @@ describe("useDomGeometryCommit, from the package entry", () => {
146
146
  const url = String(input instanceof Request ? input.url : input);
147
147
  calls.urls.push(url);
148
148
  if (url.includes("/patch-element/")) calls.patches.push(JSON.parse(String(init?.body)));
149
- const saved = { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
149
+ const saved = {
150
+ ok: true,
151
+ changed: true,
152
+ matched: true,
153
+ path: "index.html",
154
+ content: "AFTER",
155
+ version: "v2",
156
+ };
150
157
  const body = url.includes("/files/") ? { content: SOURCE } : saved;
151
158
  const status = url.includes("/patch-element/") ? patchStatus : 200;
152
159
  return new Response(JSON.stringify(body), {
@@ -163,7 +170,10 @@ describe("useDomGeometryCommit, from the package entry", () => {
163
170
  const { element, recordEdit, hook, unmount } = renderHost();
164
171
 
165
172
  const card = makeSelection("card", element);
166
- await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({ ok: true });
173
+ await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({
174
+ ok: true,
175
+ changed: true,
176
+ });
167
177
 
168
178
  expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
169
179
  expect(calls.patches).toEqual([
@@ -185,7 +195,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
185
195
 
186
196
  await expect(
187
197
  hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
188
- ).resolves.toEqual({ ok: true });
198
+ ).resolves.toEqual({ ok: true, changed: true });
189
199
 
190
200
  expect(element.style.getPropertyValue("width")).toBe("300px");
191
201
  expect(calls.patches).toEqual([
@@ -247,7 +257,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
247
257
  { x: 1, y: 2 },
248
258
  { plainTranslate: true },
249
259
  ),
250
- ).resolves.toEqual({ ok: true });
260
+ ).resolves.toEqual({ ok: true, changed: true });
251
261
  expect(calls.patches).toHaveLength(1);
252
262
  unmount();
253
263
  });
@@ -396,7 +406,14 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
396
406
  headers: { "content-type": "application/json" },
397
407
  });
398
408
  }
399
- return json({ ok: true, changed: true, matched: true, content: "AFTER", version: "v2" });
409
+ return json({
410
+ ok: true,
411
+ changed: true,
412
+ matched: true,
413
+ path: "index.html",
414
+ content: "AFTER",
415
+ version: "v2",
416
+ });
400
417
  }
401
418
  if (url.includes("/api/projects/p1/gsap-mutations/")) {
402
419
  calls.gsapMutations.push(JSON.parse(String(init?.body)));
@@ -426,6 +443,7 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
426
443
 
427
444
  await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
428
445
  ok: true,
446
+ changed: true,
429
447
  });
430
448
 
431
449
  expect(calls.gsapMutations).toHaveLength(0);
@@ -455,6 +473,7 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
455
473
  ]);
456
474
  await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
457
475
  ok: true,
476
+ changed: true,
458
477
  });
459
478
 
460
479
  expect(calls.patches).toHaveLength(2);
@@ -1,3 +1,4 @@
1
+ import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
1
2
  import type { RotationCommit } from "../components/editor/rotationDraft";
2
3
  import { useCallback, useMemo, useRef, useState } from "react";
3
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
@@ -67,7 +68,7 @@ export function useDomGeometryCommit({
67
68
  }: UseDomGeometryCommitOptions): DomGeometryCommits {
68
69
  const projectIdRef = useRef(projectId);
69
70
  projectIdRef.current = projectId;
70
- const pending = useRef(new Set<Promise<void>>()).current;
71
+ const pending = useRef(new Set<Promise<GeometryCommitResult | undefined>>()).current;
71
72
  const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
72
73
  const reload = useCallback(
73
74
  () => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
@@ -142,7 +143,10 @@ export function useDomGeometryCommit({
142
143
  updateArcSegment: gsap.updateArcSegment,
143
144
  });
144
145
  const saved = useCallback(
145
- async (commit: () => Promise<void>, restore = noop): Promise<DomEditCommitOutcome> => {
146
+ async (
147
+ commit: () => Promise<GeometryCommitResult | undefined>,
148
+ restore = noop,
149
+ ): Promise<DomEditCommitOutcome> => {
146
150
  const refusal = !projectIdRef.current
147
151
  ? "No project is open"
148
152
  : !isPreviewBooted(projectIdRef.current)
@@ -156,11 +160,10 @@ export function useDomGeometryCommit({
156
160
  const run = commit();
157
161
  pending.add(run);
158
162
  try {
159
- await run;
163
+ return { ok: true, changed: (await run)?.changed === true };
160
164
  } finally {
161
165
  pending.delete(run);
162
166
  }
163
- return { ok: true };
164
167
  },
165
168
  [pending, showToast],
166
169
  );
@@ -305,6 +305,7 @@ describe("Reset after a plain move puts the author's translate back", () => {
305
305
  async (_selection, patches) => {
306
306
  project.history.push(project.file);
307
307
  project.file = patches.reduce((html, op) => applyPatch(html, "layer", op), project.file);
308
+ return undefined;
308
309
  },
309
310
  );
310
311
  const selection = { id: "layer", selector: "#layer", element } as unknown as DomEditSelection;
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "./domEditCommitTypes";
1
2
  import type { RotationCommit } from "../components/editor/rotationDraft";
2
3
  import { useCallback } from "react";
3
4
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
@@ -39,7 +40,7 @@ export interface UseDomGeometryCommitsParams {
39
40
  skipRefresh?: boolean;
40
41
  deferRender?: boolean;
41
42
  },
42
- ) => Promise<void>;
43
+ ) => Promise<DomEditPersistOutcome | undefined>;
43
44
  readOnlyPreview: boolean;
44
45
  }
45
46
 
@@ -73,7 +74,7 @@ export function useDomGeometryCommits({
73
74
  restore?: () => void,
74
75
  undoKey?: string,
75
76
  ) => {
76
- if (readOnlyPreview) return Promise.resolve();
77
+ if (readOnlyPreview) return Promise.resolve(undefined);
77
78
  const element = selection.element;
78
79
  const beforeSize = captureStudioBoxSize(element);
79
80
  const beforeOffset = captureStudioPathOffset(element);
@@ -334,3 +334,29 @@ describe("useDomSelection", () => {
334
334
  harness.cleanup();
335
335
  });
336
336
  });
337
+
338
+ it("reports unchanged marquee membership and one new additive member", () => {
339
+ const { harness, card, chip } = renderCardAndChip();
340
+ try {
341
+ act(() => harness.current().applyDomSelection(card));
342
+ let receipt!: ReturnType<ReturnType<typeof useDomSelection>["applyMarqueeSelection"]>;
343
+ act(() => {
344
+ receipt = harness.current().applyMarqueeSelection([chip], true);
345
+ });
346
+ expect(receipt).toEqual({ changed: true, count: 2 });
347
+ act(() => {
348
+ receipt = harness.current().applyMarqueeSelection([card, chip], true);
349
+ });
350
+ expect(receipt).toEqual({ changed: false, count: 2 });
351
+ act(() => {
352
+ receipt = harness.current().applyMarqueeSelection([], true);
353
+ });
354
+ expect(receipt).toEqual({ changed: false, count: 2 });
355
+ act(() => {
356
+ receipt = harness.current().applyMarqueeSelection([], false);
357
+ });
358
+ expect(receipt).toEqual({ changed: true, count: 0 });
359
+ } finally {
360
+ harness.cleanup();
361
+ }
362
+ });
@@ -487,12 +487,13 @@ export function useDomSelection({
487
487
  // fallow-ignore-next-line complexity
488
488
  (selections: DomEditSelection[], additive: boolean) => {
489
489
  logSelect("marquee", { hits: selections.length, additive });
490
+ const current = domEditSelectionRef.current;
491
+ const currentGroup = domEditGroupSelectionsRef.current;
490
492
  if (selections.length === 0) {
493
+ const count = seedDomEditGroupWithSelection(currentGroup, current).length;
491
494
  if (!additive) applyDomSelection(null, { revealPanel: false });
492
- return;
495
+ return { changed: !additive && count > 0, count: additive ? count : 0 };
493
496
  }
494
- const current = domEditSelectionRef.current;
495
- const currentGroup = domEditGroupSelectionsRef.current;
496
497
  let nextGroup: DomEditSelection[];
497
498
  if (additive) {
498
499
  nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
@@ -507,11 +508,17 @@ export function useDomSelection({
507
508
  }
508
509
  }
509
510
  const nextSelection = additive && current ? current : selections[0];
511
+ const previous = seedDomEditGroupWithSelection(currentGroup, current);
512
+ const changed =
513
+ previous.length !== nextGroup.length ||
514
+ nextGroup.some((selection) => !domEditSelectionInGroup(previous, selection)) ||
515
+ !domEditSelectionsTargetSame(current, nextSelection);
510
516
  domEditSelectionRef.current = nextSelection;
511
517
  domEditGroupSelectionsRef.current = nextGroup;
512
518
  setDomEditSelection(nextSelection);
513
519
  setDomEditGroupSelections(nextGroup);
514
520
  announceTimelineSelection(nextGroup, nextSelection, true);
521
+ return { changed, count: nextGroup.length };
515
522
  },
516
523
  [applyDomSelection, announceTimelineSelection],
517
524
  );
@@ -3,6 +3,11 @@ import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { SelectElementOptions, TimelineElement } from "../player";
4
4
  import type { RightPanelTab } from "../utils/studioHelpers";
5
5
 
6
+ export interface DomSelectionResult {
7
+ changed: boolean;
8
+ count: number;
9
+ }
10
+
6
11
  export interface ApplyDomSelectionOptions {
7
12
  revealPanel?: boolean;
8
13
  additive?: boolean;
@@ -85,5 +90,5 @@ export interface UseDomSelectionReturn {
85
90
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
86
91
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
87
92
  refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
88
- applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
93
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult;
89
94
  }