@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../player";
6
+ import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
+ import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
+
9
+ installReactActEnvironment();
10
+
11
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
12
+ return { id, tag: "video", start, duration, track, domId: id };
13
+ }
14
+
15
+ describe("applyRippleShifts", () => {
16
+ it("returns survivors unchanged when there are no changes", () => {
17
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
18
+ expect(applyRippleShifts(survivors, null)).toBe(survivors);
19
+ });
20
+
21
+ it("rewrites only the starts named by the changes", () => {
22
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
23
+ const result = applyRippleShifts(survivors, [{ element: survivors[1], start: 2 }]);
24
+ expect(result).toEqual([el("a", 0, 2), el("c", 2, 1)]);
25
+ expect(result[0]).toBe(survivors[0]);
26
+ });
27
+ });
28
+
29
+ describe("useTimelineDeleteOps: ripple undo label", () => {
30
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
31
+ <div data-hf-id="hf-stage" data-hf-root data-duration="6">
32
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
33
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
34
+ <div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
35
+ </div>
36
+ </body></html>`;
37
+
38
+ beforeEach(() => {
39
+ vi.stubGlobal(
40
+ "fetch",
41
+ vi.fn(async (input: RequestInfo | URL) => {
42
+ const url = String(input);
43
+ if (url.includes("/file-mutations/remove-element/")) {
44
+ return new Response(JSON.stringify({ changed: true, content: html }), { status: 200 });
45
+ }
46
+ return new Response(JSON.stringify({ content: html }), { status: 200 });
47
+ }),
48
+ );
49
+ });
50
+
51
+ afterEach(() => {
52
+ document.body.innerHTML = "";
53
+ vi.unstubAllGlobals();
54
+ });
55
+
56
+ type DeleteOpsOptions = Parameters<typeof useTimelineDeleteOps>[0];
57
+
58
+ function mountDeleteHarness(overrides: {
59
+ handleTimelineGroupMove: DeleteOpsOptions["handleTimelineGroupMove"];
60
+ showToast?: DeleteOpsOptions["showToast"];
61
+ }) {
62
+ const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
63
+ let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
64
+ function Harness() {
65
+ hook = useTimelineDeleteOps({
66
+ projectIdRef: { current: "test-project" },
67
+ activeCompPath: "index.html",
68
+ timelineElements: elements,
69
+ showToast: overrides.showToast ?? vi.fn(),
70
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
71
+ recordEdit: vi.fn().mockResolvedValue(undefined),
72
+ reloadPreview: vi.fn(),
73
+ previewIframeRef: { current: null },
74
+ handleTimelineGroupMove: overrides.handleTimelineGroupMove,
75
+ });
76
+ return null;
77
+ }
78
+ mountReactHarness(<Harness />);
79
+ return { b: elements[1], getHook: () => hook! };
80
+ }
81
+
82
+ // editHistory.ts's coalescing keeps the LAST recordEdit call's label, so the
83
+ // folded ripple move must carry the delete's label, not its own.
84
+ it("passes the delete's own label to the folded ripple move, not 'Move timeline clips'", async () => {
85
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
86
+ const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
87
+
88
+ await act(async () => {
89
+ await getHook().handleTimelineElementDelete(b);
90
+ });
91
+
92
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
93
+ expect.anything(),
94
+ expect.objectContaining({ label: "Delete timeline clip" }),
95
+ );
96
+ });
97
+
98
+ it("shows exactly one toast when a ripple fails to persist after a committed delete", async () => {
99
+ const handleTimelineGroupMove = vi.fn().mockRejectedValue(new Error("persist failed"));
100
+ const showToast = vi.fn();
101
+ const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove, showToast });
102
+
103
+ await act(async () => {
104
+ await getHook().handleTimelineElementDelete(b);
105
+ });
106
+
107
+ // The user did one thing (delete); the generic move-failure toast is
108
+ // suppressed on this call so only the specific message reaches them.
109
+ expect(showToast).toHaveBeenCalledTimes(1);
110
+ expect(showToast).toHaveBeenCalledWith(
111
+ "Clip deleted, but the gap could not be closed.",
112
+ "error",
113
+ );
114
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
115
+ expect.anything(),
116
+ expect.objectContaining({ suppressFailureToast: true }),
117
+ );
118
+ });
119
+ });
@@ -2,7 +2,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  // Timeline clip deletion: the marquee/multi path and the single-clip wrapper
3
3
  // the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
4
4
  // it under the studio 600-line cap, following useTimelineAssetDropOps.
5
- import { useCallback, type MutableRefObject, type RefObject } from "react";
5
+ import { useCallback, useRef, type MutableRefObject, type RefObject } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
@@ -12,6 +12,28 @@ import { buildPatchTarget } from "./timelineEditingHelpers";
12
12
  import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
13
13
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
14
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
15
+ import {
16
+ resolveMainTrackDeleteRippleShifts,
17
+ resolveShiftedElements,
18
+ } from "../player/components/timelineGapCommit";
19
+ import type {
20
+ TimelineGroupCommitOptions,
21
+ TimelineGroupMoveChange,
22
+ } from "./useTimelineGroupEditing";
23
+
24
+ /** Apply already-resolved ripple changes to the surviving elements for the
25
+ * optimistic store update after a delete. Pure — no IO. */
26
+ export function applyRippleShifts(
27
+ survivors: TimelineElement[],
28
+ changes: readonly TimelineGroupMoveChange[] | null,
29
+ ): TimelineElement[] {
30
+ if (!changes) return survivors;
31
+ const newStartByElement = new Map(changes.map((c) => [c.element, c.start]));
32
+ return survivors.map((te) => {
33
+ const newStart = newStartByElement.get(te);
34
+ return newStart != null ? { ...te, start: newStart } : te;
35
+ });
36
+ }
15
37
 
16
38
  interface UseTimelineDeleteOpsOptions {
17
39
  projectIdRef: MutableRefObject<string | null>;
@@ -24,8 +46,20 @@ interface UseTimelineDeleteOpsOptions {
24
46
  isRecordingRef?: MutableRefObject<boolean>;
25
47
  forceReloadSdkSession?: () => void;
26
48
  previewIframeRef: RefObject<HTMLIFrameElement | null>;
49
+ /** The same atomic multi-clip move commit the track-gap menu uses, reused
50
+ * to ripple the main track after a delete, folded into the delete's own
51
+ * undo entry via a shared coalesceKey. */
52
+ handleTimelineGroupMove: (
53
+ changes: TimelineGroupMoveChange[],
54
+ options?: TimelineGroupCommitOptions,
55
+ ) => Promise<void>;
27
56
  }
28
57
 
58
+ // Per-gesture-unique coalesce key — a monotonic counter, not Date.now() /
59
+ // Math.random() (determinism rules), mirroring gapCloseGestureSeq in
60
+ // timelineGapCommit.ts.
61
+ let deleteGestureSeq = 0;
62
+
29
63
  export function useTimelineDeleteOps({
30
64
  projectIdRef,
31
65
  activeCompPath,
@@ -37,7 +71,12 @@ export function useTimelineDeleteOps({
37
71
  isRecordingRef,
38
72
  forceReloadSdkSession,
39
73
  previewIframeRef,
74
+ handleTimelineGroupMove,
40
75
  }: UseTimelineDeleteOpsOptions) {
76
+ // First-use-per-session notice for the ripple. Plain info toast: Studio's
77
+ // toast system has no action buttons, so Undo and the toggle location are
78
+ // named in words instead.
79
+ const rippleNoticeShownRef = useRef(false);
41
80
  // fallow-ignore-next-line complexity
42
81
  const handleTimelineElementsDelete = useCallback(
43
82
  // fallow-ignore-next-line complexity
@@ -109,11 +148,17 @@ export function useTimelineDeleteOps({
109
148
  usePlayerStore.getState().setDuration(deleteContentEnd);
110
149
  }
111
150
 
151
+ // Shared with the ripple move below so a folded ripple is one undo
152
+ // step with the delete, not two (editHistory.ts coalesces by key +
153
+ // window across separate recordEdit calls, not by label).
154
+ const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
155
+ const deleteHistoryLabel = "Delete timeline clip";
112
156
  try {
113
157
  await saveProjectFilesWithHistory({
114
158
  projectId: pid,
115
- label: "Delete timeline clip",
159
+ label: deleteHistoryLabel,
116
160
  kind: "timeline",
161
+ coalesceKey,
117
162
  files: { [targetPath]: patchedContent },
118
163
  readFile: async () => originalContent,
119
164
  // remove-element already wrote the removal, so disk holds THAT — not the
@@ -128,17 +173,65 @@ export function useTimelineDeleteOps({
128
173
  }
129
174
 
130
175
  const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
131
- usePlayerStore
132
- .getState()
133
- .setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
176
+ const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
177
+
178
+ // Ripple: close the gap the delete left on the main track. Pure decision
179
+ // first (resolveMainTrackDeleteRippleShifts — off, no main-track clip
180
+ // deleted, already gapless, or a locked survivor all resolve to null),
181
+ // then the one write, folded into the delete's undo entry above.
182
+ const rippleShifts = resolveMainTrackDeleteRippleShifts(
183
+ survivors,
184
+ sameFile,
185
+ usePlayerStore.getState().rippleEditEnabled,
186
+ );
187
+ let rippleApplied: TimelineGroupMoveChange[] | null = null;
188
+ let rippleFailed = false;
189
+ if (rippleShifts) {
190
+ const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
191
+ try {
192
+ await handleTimelineGroupMove(rippleChanges, {
193
+ coalesceKey,
194
+ coalesceMs: Number.POSITIVE_INFINITY,
195
+ // Coalescing keeps the LAST entry's label; without this the undo
196
+ // toast reads "Undid Move timeline clips" after a delete, naming
197
+ // the ripple's mechanics instead of what the user actually did.
198
+ label: deleteHistoryLabel,
199
+ // This call gets its own, more specific failure toast below —
200
+ // the generic one would tell the user about one action twice.
201
+ suppressFailureToast: true,
202
+ });
203
+ rippleApplied = rippleChanges;
204
+ } catch (error) {
205
+ // The delete already committed; a failed ripple leaves the gap the
206
+ // toggle-off behaviour would have left anyway — not worth undoing
207
+ // an otherwise-successful delete over.
208
+ rippleFailed = true;
209
+ console.error("[Timeline] ripple-edit failed to persist after delete", error);
210
+ showToast("Clip deleted, but the gap could not be closed.", "error");
211
+ }
212
+ }
213
+
214
+ usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
134
215
  usePlayerStore.getState().setSelectedElementId(null);
135
216
  usePlayerStore.getState().setSelectedElementIds(new Set());
136
217
  forceReloadSdkSession?.();
137
218
  reloadPreview();
138
- showToast(
139
- `Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
140
- "info",
141
- );
219
+ // A failed ripple already showed its own toast above; the user did one
220
+ // thing (delete), so they get one message, not this generic follow-up too.
221
+ if (!rippleFailed) {
222
+ showToast(
223
+ `Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
224
+ "info",
225
+ );
226
+ }
227
+ if (rippleApplied && !rippleNoticeShownRef.current) {
228
+ rippleNoticeShownRef.current = true;
229
+ showToast(
230
+ "Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
231
+ "Ripple in the timeline toolbar.",
232
+ "info",
233
+ );
234
+ }
142
235
  } catch (error) {
143
236
  const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
144
237
  showToast(message);
@@ -155,6 +248,7 @@ export function useTimelineDeleteOps({
155
248
  isRecordingRef,
156
249
  forceReloadSdkSession,
157
250
  previewIframeRef,
251
+ handleTimelineGroupMove,
158
252
  ],
159
253
  );
160
254
 
@@ -413,6 +413,7 @@ export function useTimelineEditing({
413
413
  isRecordingRef,
414
414
  forceReloadSdkSession,
415
415
  previewIframeRef,
416
+ handleTimelineGroupMove: groupEditing.handleTimelineGroupMove,
416
417
  });
417
418
 
418
419
  const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
@@ -0,0 +1,74 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { Root } from "react-dom/client";
6
+ import type { TimelineElement } from "../player";
7
+ import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
8
+ import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
9
+
10
+ installReactActEnvironment();
11
+
12
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
13
+ return { id, tag: "video", start, duration, track, domId: id };
14
+ }
15
+
16
+ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast", () => {
17
+ let root: Root | null = null;
18
+
19
+ afterEach(() => {
20
+ if (root) act(() => root!.unmount());
21
+ root = null;
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ type GroupEditingOptions = Parameters<typeof useTimelineGroupEditing>[0];
26
+
27
+ // No project id makes enqueueGroupOperation reject before any persist runs,
28
+ // exercising the move's real catch block without faking the SDK/server path.
29
+ function mountFailingHarness(showToast: GroupEditingOptions["showToast"]) {
30
+ let hook: ReturnType<typeof useTimelineGroupEditing> | null = null;
31
+ function Harness() {
32
+ hook = useTimelineGroupEditing({
33
+ activeCompPath: "index.html",
34
+ editQueueRef: { current: Promise.resolve() },
35
+ pendingTimelineEditPathRef: { current: new Set() },
36
+ previewIframeRef: { current: null },
37
+ projectIdRef: { current: null },
38
+ recordEdit: vi.fn().mockResolvedValue(undefined),
39
+ reloadPreview: vi.fn(),
40
+ showToast,
41
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
42
+ });
43
+ return null;
44
+ }
45
+ root = mountReactHarness(<Harness />);
46
+ return () => hook!;
47
+ }
48
+
49
+ const change = { element: el("a", 0, 2), start: 2 };
50
+
51
+ it("shows no toast when suppressFailureToast is set on a failed move", async () => {
52
+ const showToast = vi.fn();
53
+ const getHook = mountFailingHarness(showToast);
54
+
55
+ await act(async () => {
56
+ await expect(
57
+ getHook().handleTimelineGroupMove([change], { suppressFailureToast: true }),
58
+ ).rejects.toThrow();
59
+ });
60
+
61
+ expect(showToast).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("shows one toast when suppressFailureToast is not set on a failed move", async () => {
65
+ const showToast = vi.fn();
66
+ const getHook = mountFailingHarness(showToast);
67
+
68
+ await act(async () => {
69
+ await expect(getHook().handleTimelineGroupMove([change])).rejects.toThrow();
70
+ });
71
+
72
+ expect(showToast).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
@@ -47,6 +47,15 @@ export interface TimelineGroupCommitOptions {
47
47
  coalesceKey?: string;
48
48
  /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
49
49
  coalesceMs?: number;
50
+ /** Overrides the default "Move timeline clips" undo-history label. Coalescing
51
+ * keeps the LAST entry's label (editHistory.ts), so a mechanical follow-up
52
+ * move folded into another gesture's coalesceKey (e.g. the ripple after a
53
+ * delete) should carry that gesture's own label, not its own. */
54
+ label?: string;
55
+ /** Skips this call's own generic failure toast (the error is still logged
56
+ * to the console) — for a caller that shows its own more specific message
57
+ * on the same failure, so the user isn't told about one action twice. */
58
+ suppressFailureToast?: boolean;
50
59
  }
51
60
 
52
61
  interface UseTimelineGroupEditingOptions {
@@ -272,21 +281,22 @@ export function useTimelineGroupEditing({
272
281
  syncPreviewContentDuration(previewIframeRef.current);
273
282
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
274
283
  const coalesceMs = options?.coalesceMs;
275
- return enqueueGroupOperation("Move timeline clips", async (projectId) => {
284
+ const label = options?.label ?? "Move timeline clips";
285
+ return enqueueGroupOperation(label, async (projectId) => {
276
286
  await options?.beforeTiming;
277
287
  const handledBySdk = await trySdkBatchPersist({
278
288
  changes,
279
289
  sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
280
290
  eligible: changes.every((change) => change.track == null),
281
291
  needsExtension,
282
- label: "Move timeline clips",
292
+ label,
283
293
  coalesceKey,
284
294
  coalesceMs,
285
295
  });
286
296
  if (!handledBySdk) {
287
297
  await persistServerBatch(
288
298
  projectId,
289
- "Move timeline clips",
299
+ label,
290
300
  changes.map((change) => ({
291
301
  element: change.element,
292
302
  buildPatches: (original, target) =>
@@ -315,7 +325,7 @@ export function useTimelineGroupEditing({
315
325
  projectId,
316
326
  iframe: previewIframeRef.current,
317
327
  reloadPreview,
318
- label: "Move timeline clips",
328
+ label,
319
329
  errorLabel: "Failed to shift GSAP positions",
320
330
  coalesceKey,
321
331
  recordEdit,
@@ -336,7 +346,11 @@ export function useTimelineGroupEditing({
336
346
  // Failed persist: revert the optimistic duration readout + live root
337
347
  // alongside the gesture owner's store rollback.
338
348
  rollbackDuration();
339
- showToast(getStudioSaveErrorMessage(error), "error");
349
+ if (options?.suppressFailureToast) {
350
+ console.error("[Timeline] group move failed to persist", error);
351
+ } else {
352
+ showToast(getStudioSaveErrorMessage(error), "error");
353
+ }
340
354
  throw error;
341
355
  });
342
356
  },
@@ -175,7 +175,70 @@ describe("useTimelineSelectionPreviewSync", () => {
175
175
  harness.cleanup();
176
176
  });
177
177
 
178
- it("warns once while retrying a timeline selection after preview refreshes", async () => {
178
+ it("does not clear the selection when the selected clip is not in timelineElements yet", async () => {
179
+ // A freshly-dropped clip is selected before the reload that adds it to
180
+ // timelineElements has completed. Bailing quietly here (like the
181
+ // DOM-node-not-ready case above) lets the later effect run, once the
182
+ // element exists, apply the selection instead of wiping it.
183
+ const { timelineElements } = makeSyncFixture();
184
+ const applyDomSelection = vi.fn();
185
+ const applyMarqueeSelection = vi.fn();
186
+ const onSelectionNotFound = vi.fn();
187
+ const buildDomSelectionForTimelineElement = vi.fn(async () => null);
188
+ const harness = renderHarness();
189
+
190
+ await harness.rerender({
191
+ selectedElementId: "clip-3",
192
+ selectedElementIds: new Set(["clip-3"]),
193
+ timelineElements,
194
+ domEditSelection: null,
195
+ domEditGroupSelections: [],
196
+ buildDomSelectionForTimelineElement,
197
+ applyDomSelection,
198
+ applyMarqueeSelection,
199
+ onSelectionNotFound,
200
+ });
201
+
202
+ // Still within the retry grace window: quiet, not yet a warning.
203
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
204
+ expect(applyDomSelection).not.toHaveBeenCalled();
205
+ harness.cleanup();
206
+ });
207
+
208
+ it("does not shrink a pasted or duplicated group's selection when only some members have appeared yet", async () => {
209
+ // Paste/duplicate of a group selects every new id up front; one member
210
+ // (clip-1) is already in timelineElements, the other (a fresh paste/dup
211
+ // target, "clip-3") is not yet. Applying only the resolved member would
212
+ // silently drop the rest of the group from the store.
213
+ const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
214
+ const applyDomSelection = vi.fn();
215
+ const applyMarqueeSelection = vi.fn();
216
+ const onSelectionNotFound = vi.fn();
217
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
218
+ return selectionById.get(element.id) ?? firstSelection;
219
+ });
220
+ const harness = renderHarness();
221
+
222
+ await harness.rerender({
223
+ selectedElementId: "clip-1",
224
+ selectedElementIds: new Set(["clip-1", "clip-3"]),
225
+ timelineElements,
226
+ domEditSelection: null,
227
+ domEditGroupSelections: [],
228
+ buildDomSelectionForTimelineElement,
229
+ applyDomSelection,
230
+ applyMarqueeSelection,
231
+ onSelectionNotFound,
232
+ });
233
+
234
+ // Still within the retry grace window: quiet, not yet a warning.
235
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
236
+ expect(applyDomSelection).not.toHaveBeenCalled();
237
+ expect(applyMarqueeSelection).not.toHaveBeenCalled();
238
+ harness.cleanup();
239
+ });
240
+
241
+ it("stays quiet through retries and resolves once the preview refreshes", async () => {
179
242
  const { secondSelection, timelineElements } = makeSyncFixture();
180
243
  const applyDomSelection = vi.fn();
181
244
  const applyMarqueeSelection = vi.fn();
@@ -199,7 +262,7 @@ describe("useTimelineSelectionPreviewSync", () => {
199
262
  onSelectionNotFound,
200
263
  });
201
264
 
202
- expect(onSelectionNotFound).toHaveBeenCalledOnce();
265
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
203
266
  expect(applyDomSelection).not.toHaveBeenCalled();
204
267
 
205
268
  await harness.rerender({
@@ -214,7 +277,7 @@ describe("useTimelineSelectionPreviewSync", () => {
214
277
  onSelectionNotFound,
215
278
  });
216
279
 
217
- expect(onSelectionNotFound).toHaveBeenCalledOnce();
280
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
218
281
 
219
282
  previewReady = true;
220
283
  await harness.rerender({
@@ -230,6 +293,155 @@ describe("useTimelineSelectionPreviewSync", () => {
230
293
  });
231
294
 
232
295
  expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
296
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
297
+ harness.cleanup();
298
+ });
299
+
300
+ it("degrades to the resolvable subset once a group member never resolves", async () => {
301
+ // clip-2 is a permanent hold-out (deleted out of band, a stale id, or
302
+ // anything else that will never build a selection). Retrying forever
303
+ // would leave the whole group's selection stuck; after a few attempts,
304
+ // apply the members that did resolve instead.
305
+ const { firstSelection, timelineElements } = makeSyncFixture();
306
+ const applyDomSelection = vi.fn();
307
+ const applyMarqueeSelection = vi.fn();
308
+ const onSelectionNotFound = vi.fn();
309
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
310
+ element.id === "clip-1" ? firstSelection : null,
311
+ );
312
+ const selectedElementIds = new Set(["clip-1", "clip-2"]);
313
+ const harness = renderHarness();
314
+
315
+ for (let attempt = 0; attempt < 4; attempt++) {
316
+ await harness.rerender({
317
+ selectedElementId: "clip-1",
318
+ selectedElementIds,
319
+ timelineElements: [...timelineElements],
320
+ domEditSelection: null,
321
+ domEditGroupSelections: [],
322
+ buildDomSelectionForTimelineElement,
323
+ applyDomSelection,
324
+ applyMarqueeSelection,
325
+ onSelectionNotFound,
326
+ });
327
+ }
328
+
329
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
330
+ expect(applyDomSelection).toHaveBeenCalledWith(firstSelection);
331
+ expect(applyMarqueeSelection).not.toHaveBeenCalled();
332
+ harness.cleanup();
333
+ });
334
+
335
+ it("gives a fresh retry budget after deselecting a permanently-unresolvable selection", async () => {
336
+ // Same permanent hold-out as the test above, but the user deselects and
337
+ // reselects the exact same group afterward. Without resetting the retry
338
+ // budget on deselect, reselecting the same key resumes an already-
339
+ // exhausted count and degrades on the very first attempt with no warning.
340
+ const { firstSelection, timelineElements } = makeSyncFixture();
341
+ const applyDomSelection = vi.fn();
342
+ const applyMarqueeSelection = vi.fn();
343
+ const onSelectionNotFound = vi.fn();
344
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
345
+ element.id === "clip-1" ? firstSelection : null,
346
+ );
347
+ const selectedElementIds = new Set(["clip-1", "clip-2"]);
348
+ const harness = renderHarness();
349
+
350
+ for (let attempt = 0; attempt < 4; attempt++) {
351
+ await harness.rerender({
352
+ selectedElementId: "clip-1",
353
+ selectedElementIds,
354
+ timelineElements: [...timelineElements],
355
+ domEditSelection: null,
356
+ domEditGroupSelections: [],
357
+ buildDomSelectionForTimelineElement,
358
+ applyDomSelection,
359
+ applyMarqueeSelection,
360
+ onSelectionNotFound,
361
+ });
362
+ }
363
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
364
+
365
+ await harness.rerender({
366
+ selectedElementId: null,
367
+ selectedElementIds: new Set(),
368
+ timelineElements: [...timelineElements],
369
+ domEditSelection: null,
370
+ domEditGroupSelections: [],
371
+ buildDomSelectionForTimelineElement,
372
+ applyDomSelection,
373
+ applyMarqueeSelection,
374
+ onSelectionNotFound,
375
+ });
376
+
377
+ for (let attempt = 0; attempt < 4; attempt++) {
378
+ await harness.rerender({
379
+ selectedElementId: "clip-1",
380
+ selectedElementIds,
381
+ timelineElements: [...timelineElements],
382
+ domEditSelection: null,
383
+ domEditGroupSelections: [],
384
+ buildDomSelectionForTimelineElement,
385
+ applyDomSelection,
386
+ applyMarqueeSelection,
387
+ onSelectionNotFound,
388
+ });
389
+ }
390
+
391
+ expect(onSelectionNotFound).toHaveBeenCalledTimes(2);
392
+ harness.cleanup();
393
+ });
394
+
395
+ it("resolves every member of a large selection concurrently, not one network round trip at a time", async () => {
396
+ const elementCount = 30;
397
+ const timelineElements: TimelineElement[] = Array.from({ length: elementCount }, (_, i) => ({
398
+ id: `clip-${i}`,
399
+ domId: `clip-${i}`,
400
+ tag: "div",
401
+ start: i,
402
+ duration: 1,
403
+ track: i % 4,
404
+ }));
405
+ const selectionById = new Map(
406
+ timelineElements.map((el) => {
407
+ const domEl = document.createElement("div");
408
+ domEl.id = el.id;
409
+ return [el.id, makeSelection(el.id, domEl)];
410
+ }),
411
+ );
412
+ let inFlight = 0;
413
+ let maxInFlight = 0;
414
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
415
+ inFlight += 1;
416
+ maxInFlight = Math.max(maxInFlight, inFlight);
417
+ // Stands in for the network round trip: yields once so every member's
418
+ // call has a chance to start before any of them finish.
419
+ await Promise.resolve();
420
+ inFlight -= 1;
421
+ return selectionById.get(element.id) ?? null;
422
+ });
423
+ const applyDomSelection = vi.fn();
424
+ const applyMarqueeSelection = vi.fn();
425
+ const harness = renderHarness();
426
+
427
+ await harness.rerender({
428
+ selectedElementId: timelineElements[0].id,
429
+ selectedElementIds: new Set(timelineElements.map((el) => el.id)),
430
+ timelineElements,
431
+ domEditSelection: null,
432
+ domEditGroupSelections: [],
433
+ buildDomSelectionForTimelineElement,
434
+ applyDomSelection,
435
+ applyMarqueeSelection,
436
+ onSelectionNotFound: vi.fn(),
437
+ });
438
+
439
+ expect(buildDomSelectionForTimelineElement).toHaveBeenCalledTimes(elementCount);
440
+ // A sequential loop never has more than one call in flight at once; this
441
+ // asserts every member's probe started before any of them resolved.
442
+ expect(maxInFlight).toBe(elementCount);
443
+ expect(applyMarqueeSelection).toHaveBeenCalledOnce();
444
+ expect(applyMarqueeSelection.mock.calls[0]?.[0]).toHaveLength(elementCount);
233
445
  harness.cleanup();
234
446
  });
235
447
  });