@hyperframes/studio 0.8.46 → 0.8.48

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 (112) hide show
  1. package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
  2. package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +83 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1118 -661
  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/manualEditingAvailability.test.ts +19 -0
  19. package/src/components/editor/manualEditingAvailability.ts +9 -5
  20. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  21. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  23. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  24. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  25. package/src/components/editor/useVolumeAutomation.ts +23 -2
  26. package/src/components/panels/VariablesPanel.tsx +5 -8
  27. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  29. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  30. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  31. package/src/contexts/FileManagerContext.tsx +0 -3
  32. package/src/hooks/appHotkeysDispatch.ts +256 -0
  33. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  34. package/src/hooks/domEditDeleteMembers.ts +19 -0
  35. package/src/hooks/useActiveComposition.ts +69 -0
  36. package/src/hooks/useAppHotkeys.test.ts +23 -1
  37. package/src/hooks/useAppHotkeys.ts +6 -239
  38. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  39. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  40. package/src/hooks/useDomEditCommits.ts +13 -15
  41. package/src/hooks/useDomEditSession.test.tsx +90 -1
  42. package/src/hooks/useDomEditSession.ts +31 -34
  43. package/src/hooks/useDomSelectionTypes.ts +7 -2
  44. package/src/hooks/useElementLifecycleOps.ts +0 -8
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -2
  47. package/src/hooks/useFileTree.test.tsx +214 -0
  48. package/src/hooks/useFileTree.ts +66 -24
  49. package/src/hooks/useGroupCommits.test.ts +98 -0
  50. package/src/hooks/useGroupCommits.ts +66 -9
  51. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  52. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  53. package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
  54. package/src/hooks/useSdkSession.ts +21 -3
  55. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  56. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  57. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  58. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  59. package/src/hooks/useTimelineEditing.ts +1 -0
  60. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  61. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  62. package/src/index.ts +10 -0
  63. package/src/player/components/Timeline.test.ts +0 -4
  64. package/src/player/components/Timeline.tsx +0 -1
  65. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  66. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  67. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  68. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  69. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  70. package/src/player/components/automationLaneData.test.ts +11 -3
  71. package/src/player/components/automationLaneData.ts +5 -4
  72. package/src/player/components/automationLaneGeometry.ts +15 -0
  73. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  74. package/src/player/components/timelineDragDrop.ts +9 -25
  75. package/src/player/components/timelineGapCommit.test.ts +96 -0
  76. package/src/player/components/timelineGapCommit.ts +56 -11
  77. package/src/player/components/timelineGaps.ts +2 -2
  78. package/src/player/components/timelineLayout.test.ts +0 -1
  79. package/src/player/components/timelineLayout.ts +1 -6
  80. package/src/player/components/timelineZones.test.ts +20 -1
  81. package/src/player/components/timelineZones.ts +10 -3
  82. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  83. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  84. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  85. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  86. package/src/player/hooks/useTimelinePlayer.ts +9 -4
  87. package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
  88. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  89. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  90. package/src/player/store/playerStore.test.ts +42 -0
  91. package/src/player/store/playerStore.ts +25 -28
  92. package/src/player/store/readinessSlice.ts +42 -0
  93. package/src/utils/razorSplitTransaction.test.ts +10 -0
  94. package/src/utils/razorSplitTransaction.ts +5 -0
  95. package/src/utils/sdkCutover.gate.test.ts +5 -4
  96. package/src/utils/sdkCutover.test.ts +93 -0
  97. package/src/utils/sdkCutover.ts +46 -25
  98. package/src/utils/sdkCutoverEligibility.ts +21 -0
  99. package/src/utils/sdkCutoverPolicy.test.ts +11 -2
  100. package/src/utils/sdkCutoverPolicy.ts +5 -3
  101. package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
  102. package/src/utils/sdkEditTransaction.ts +22 -1
  103. package/src/utils/sdkResolverShadow.ts +10 -1
  104. package/src/utils/studioHelpers.test.ts +18 -0
  105. package/src/utils/studioHelpers.ts +41 -5
  106. package/src/utils/studioUiPreferences.test.ts +14 -0
  107. package/src/utils/studioUiPreferences.ts +6 -0
  108. package/src/utils/studioUrlState.ts +15 -7
  109. package/src/utils/timelineInspector.ts +10 -0
  110. package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
  111. package/dist/assets/index-DCblmTX3.js +0 -441
  112. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -1,21 +1,23 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
- import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
3
2
  import { usePlayerStore } from "../player";
4
3
  import type { TimelineElement } from "../player";
5
4
  import type { DomEditSelection } from "../components/editor/domEditing";
6
5
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
7
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
7
  import { isTypingTarget } from "../utils/typingTarget";
9
- import { isEditableTarget } from "../utils/timelineDiscovery";
10
8
  import { useCaptionStore } from "../captions/store";
11
9
  import {
12
10
  applyCaptionModelToIframe,
13
11
  isCaptionPreviewVisible,
14
12
  } from "../captions/components/CaptionOverlayUtils";
15
13
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
16
- import { canSplitElement } from "../utils/timelineElementSplit";
17
- import { trackStudioEvent } from "../utils/studioTelemetry";
18
14
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
15
+ import {
16
+ type HotkeyCallbacks,
17
+ dispatchModifierKey,
18
+ dispatchPlainKey,
19
+ handleUndoRedoKey,
20
+ } from "./appHotkeysDispatch";
19
21
 
20
22
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
21
23
  try {
@@ -40,22 +42,6 @@ function safeRemoveListener(t: EventTarget | null, type: string, h: EventListene
40
42
  }
41
43
  }
42
44
 
43
- // fallow-ignore-next-line complexity
44
- function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
45
- const key = event.key.toLowerCase();
46
- if (key === "z" && !event.shiftKey) {
47
- event.preventDefault();
48
- onUndo();
49
- return true;
50
- }
51
- if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
52
- event.preventDefault();
53
- onRedo();
54
- return true;
55
- }
56
- return false;
57
- }
58
-
59
45
  // Beat edits live in an in-memory stack interleaved with file history by
60
46
  // timestamp. Undo steps to the NEWER op (beatAt >= fileAt); redo replays the
61
47
  // inverse, stepping to the OLDER op (beatAt <= fileAt). Returns true when it
@@ -146,225 +132,6 @@ interface UseAppHotkeysParams {
146
132
  forceReloadSdkSession?: () => void;
147
133
  }
148
134
 
149
- // ── Extracted keydown dispatch (pure function, no hooks) ──
150
-
151
- interface HotkeyCallbacks {
152
- handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
153
- handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
154
- handleDomEditElementDelete: (
155
- selection: DomEditSelection,
156
- options?: { expandGroup?: boolean },
157
- ) => Promise<void>;
158
- handleUndo: () => Promise<void>;
159
- handleRedo: () => Promise<void>;
160
- handleCopy: () => boolean;
161
- handlePaste: () => Promise<void>;
162
- handleCut: () => Promise<boolean>;
163
- onResetKeyframes: () => boolean;
164
- onDeleteSelectedKeyframes: () => void;
165
- onToggleRecording?: () => void;
166
- onGroupSelection?: () => void;
167
- onUngroupSelection?: () => void;
168
- leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
169
- domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
170
- showToast: (message: string, tone?: "error" | "info") => void;
171
- }
172
-
173
- /** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
174
- * arbitration between an automation range and the clip clipboard be asserted
175
- * without standing up the whole hook. */
176
- export function dispatchModifierKey(
177
- event: KeyboardEvent,
178
- key: string,
179
- cb: HotkeyCallbacks,
180
- ): boolean {
181
- if (
182
- !shouldIgnoreHistoryShortcut(event.target) &&
183
- handleUndoRedoKey(
184
- event,
185
- () => {
186
- trackStudioEvent("keyboard_shortcut", { action: "undo" });
187
- void cb.handleUndo();
188
- },
189
- () => {
190
- trackStudioEvent("keyboard_shortcut", { action: "redo" });
191
- void cb.handleRedo();
192
- },
193
- )
194
- )
195
- return true;
196
-
197
- if (event.key === "1") {
198
- event.preventDefault();
199
- trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
200
- cb.leftSidebarRef.current?.selectTab("compositions");
201
- return true;
202
- }
203
- if (event.key === "2") {
204
- event.preventDefault();
205
- trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
206
- cb.leftSidebarRef.current?.selectTab("assets");
207
- return true;
208
- }
209
-
210
- if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
211
- event.preventDefault();
212
- if (event.shiftKey) cb.onUngroupSelection?.();
213
- else cb.onGroupSelection?.();
214
- return true;
215
- }
216
-
217
- if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
218
- // An active automation range owns Cmd+C/Cmd+V, the same way it owns Delete
219
- // below. This listener is on window/capture and runs before
220
- // useAutomationSelectionKeyboard's document/capture handler, so without
221
- // this the clip clipboard also claimed the key: Cmd+V duplicated the clip
222
- // while the automation paste wrote the same file, and Cmd+C armed both
223
- // clipboards and toasted "Copied clip". Return without preventDefault so
224
- // the downstream handler still sees the key.
225
- if (automationOwnsKey(event)) return true;
226
- if (key === "c") {
227
- if (cb.handleCopy()) {
228
- event.preventDefault();
229
- trackStudioEvent("keyboard_shortcut", { action: "copy" });
230
- }
231
- return true;
232
- }
233
- if (key === "v") {
234
- event.preventDefault();
235
- trackStudioEvent("keyboard_shortcut", { action: "paste" });
236
- void cb.handlePaste();
237
- return true;
238
- }
239
- if (key === "x") {
240
- if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
241
- event.preventDefault();
242
- trackStudioEvent("keyboard_shortcut", { action: "cut" });
243
- void cb.handleCut();
244
- }
245
- return true;
246
- }
247
- }
248
- return false;
249
- }
250
-
251
- // fallow-ignore-next-line complexity
252
- /** Exported for tests: the unmodified-key half of the dispatcher, so the
253
- * Delete arbitration between keyframes, an automation range and the clip can
254
- * be asserted without standing up the whole hook. */
255
- export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
256
- if (key === "f" && !event.shiftKey && !event.altKey) {
257
- event.preventDefault();
258
- if (document.fullscreenElement) void document.exitFullscreen();
259
- else
260
- document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
261
- return;
262
- }
263
-
264
- if (event.key === "s" && !event.altKey) {
265
- // Reserve bare `s` for Split even when the current selection cannot split,
266
- // so secondary listeners do not reinterpret the same key as Snap toggle.
267
- event.preventDefault();
268
- const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
269
- if (selectedElementId) {
270
- const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
271
- if (
272
- el &&
273
- canSplitElement(el) &&
274
- currentTime > el.start &&
275
- currentTime < el.start + el.duration
276
- ) {
277
- void cb.handleTimelineElementSplit(el, currentTime);
278
- return;
279
- }
280
- // Expanded sub-comp children carry a qualified `sourceFile#id` selection
281
- // that isn't in the raw `elements` list, so the s-key can't resolve them.
282
- // Nudge toward the razor tool instead of failing silently.
283
- if (!el && selectedElementId.includes("#")) {
284
- cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
285
- return;
286
- }
287
- }
288
- }
289
-
290
- if (key === "b" && !event.shiftKey && !event.altKey) {
291
- event.preventDefault();
292
- const { activeTool, setActiveTool } = usePlayerStore.getState();
293
- setActiveTool(activeTool === "razor" ? "select" : "razor");
294
- return;
295
- }
296
-
297
- if (key === "v" && !event.shiftKey && !event.altKey) {
298
- event.preventDefault();
299
- usePlayerStore.getState().setActiveTool("select");
300
- return;
301
- }
302
-
303
- if (event.key === "Escape") {
304
- const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
305
- usePlayerStore.getState();
306
- if (activeTool === "razor") {
307
- if (selectedElementId) setSelectedElementId(null);
308
- else setActiveTool("select");
309
- event.preventDefault();
310
- return;
311
- }
312
- }
313
-
314
- if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) {
315
- if (usePlayerStore.getState().selectedKeyframes.size > 0) {
316
- cb.onDeleteSelectedKeyframes();
317
- usePlayerStore.getState().clearSelectedKeyframes();
318
- event.preventDefault();
319
- return;
320
- }
321
- // An active automation range owns Delete: useAutomationSelectionKeyboard
322
- // empties the range in place, pinning the anchors. Fall through WITHOUT
323
- // preventDefault so that document-level handler still sees the key — this
324
- // listener is on window/capture, so it runs first and everything below
325
- // would otherwise win. Without this the press reaches the clip delete
326
- // below and destroys the whole clip the lane belongs to.
327
- if (usePlayerStore.getState().automationSelection) return;
328
- if (event.key === "Backspace") {
329
- const { selectedElementId, keyframeCache } = usePlayerStore.getState();
330
- if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
331
- event.preventDefault();
332
- return;
333
- }
334
- }
335
- // The canvas selection is what the user actually drew a marquee around, so
336
- // it owns Delete whenever it holds something. The timeline mirror of that
337
- // selection is derived and lossy — a member with no timeline row of its own
338
- // is dropped from it — so deleting through the timeline removed the handful
339
- // of clips it knew about and left every other selected element behind,
340
- // still drawn as selected. The timeline path stays as the fallback for rows
341
- // with no canvas node to select (audio, a comp that is not the active one).
342
- const domSel = cb.domEditSelectionRef.current;
343
- if (domSel) {
344
- event.preventDefault();
345
- // The whole marquee group, not just the primary the ref holds.
346
- void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
347
- return;
348
- }
349
- // Takes the WHOLE selection: `find` returned the first match, so selecting
350
- // every clip and pressing Delete removed exactly one of them.
351
- const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
352
- const selectionKeys = new Set(selectedElementIds);
353
- if (selectedElementId) selectionKeys.add(selectedElementId);
354
- const selected = elements.filter((e) => selectionKeys.has(e.key ?? e.id));
355
- if (selected.length > 0) {
356
- event.preventDefault();
357
- void cb.handleTimelineElementsDelete(selected);
358
- }
359
- return;
360
- }
361
-
362
- if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
363
- event.preventDefault();
364
- cb.onToggleRecording();
365
- }
366
- }
367
-
368
135
  // ── Hook ──
369
136
 
370
137
  export function useAppHotkeys({
@@ -0,0 +1,151 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ type Props = {
11
+ projectId: string | null;
12
+ activeCompPath: string | null;
13
+ activeCompPathHydrated: boolean;
14
+ masterCompPath: string | null;
15
+ onSelectComposition: (comp: string) => void;
16
+ };
17
+
18
+ let root: Root | null = null;
19
+
20
+ afterEach(() => {
21
+ if (root) act(() => root?.unmount());
22
+ root = null;
23
+ document.body.innerHTML = "";
24
+ });
25
+
26
+ function Harness(props: Props) {
27
+ useAutoOpenRootComposition(props);
28
+ return null;
29
+ }
30
+
31
+ function renderHarness(props: Props) {
32
+ const host = document.createElement("div");
33
+ document.body.append(host);
34
+ root = createRoot(host);
35
+ let current = props;
36
+
37
+ act(() => {
38
+ root?.render(<Harness {...current} />);
39
+ });
40
+
41
+ return {
42
+ rerender: (next: Partial<Props>) => {
43
+ current = { ...current, ...next };
44
+ act(() => {
45
+ root?.render(<Harness {...current} />);
46
+ });
47
+ },
48
+ };
49
+ }
50
+
51
+ describe("useAutoOpenRootComposition", () => {
52
+ it("opens the master composition once hydration settles on no selection", () => {
53
+ const onSelectComposition = vi.fn();
54
+ renderHarness({
55
+ projectId: "demo",
56
+ activeCompPath: null,
57
+ activeCompPathHydrated: true,
58
+ masterCompPath: "index.html",
59
+ onSelectComposition,
60
+ });
61
+
62
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
63
+ });
64
+
65
+ it("waits for the file tree before opening anything", () => {
66
+ const onSelectComposition = vi.fn();
67
+ const { rerender } = renderHarness({
68
+ projectId: "demo",
69
+ activeCompPath: null,
70
+ activeCompPathHydrated: true,
71
+ masterCompPath: null,
72
+ onSelectComposition,
73
+ });
74
+
75
+ expect(onSelectComposition).not.toHaveBeenCalled();
76
+
77
+ rerender({ masterCompPath: "index.html" });
78
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
79
+ });
80
+
81
+ it("never auto-opens once a URL-hydrated selection is already active", () => {
82
+ const onSelectComposition = vi.fn();
83
+ renderHarness({
84
+ projectId: "demo",
85
+ activeCompPath: "hero.html",
86
+ activeCompPathHydrated: true,
87
+ masterCompPath: "index.html",
88
+ onSelectComposition,
89
+ });
90
+
91
+ expect(onSelectComposition).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("does not auto-open before hydration has settled", () => {
95
+ const onSelectComposition = vi.fn();
96
+ const { rerender } = renderHarness({
97
+ projectId: "demo",
98
+ activeCompPath: null,
99
+ activeCompPathHydrated: false,
100
+ masterCompPath: "index.html",
101
+ onSelectComposition,
102
+ });
103
+
104
+ expect(onSelectComposition).not.toHaveBeenCalled();
105
+
106
+ rerender({ activeCompPathHydrated: true });
107
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
108
+ });
109
+
110
+ it("fires only once even if the selection is cleared back to null later", () => {
111
+ const onSelectComposition = vi.fn();
112
+ const { rerender } = renderHarness({
113
+ projectId: "demo",
114
+ activeCompPath: null,
115
+ activeCompPathHydrated: true,
116
+ masterCompPath: "index.html",
117
+ onSelectComposition,
118
+ });
119
+ expect(onSelectComposition).toHaveBeenCalledTimes(1);
120
+
121
+ rerender({ activeCompPath: "index.html" });
122
+ rerender({ activeCompPath: null });
123
+ expect(onSelectComposition).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("auto-opens again for a different project after already settling for the first one", () => {
127
+ const onSelectComposition = vi.fn();
128
+ const { rerender } = renderHarness({
129
+ projectId: "project-a",
130
+ activeCompPath: null,
131
+ activeCompPathHydrated: true,
132
+ masterCompPath: "index.html",
133
+ onSelectComposition,
134
+ });
135
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
136
+
137
+ // This hook's OWN decision logic, given activeCompPath/masterCompPath already reset for
138
+ // the new project (by whatever reset the caller wires up — see
139
+ // useResetSelectionOnProjectSwitch.test.tsx for that end-to-end case with real state).
140
+ rerender({
141
+ projectId: "project-b",
142
+ activeCompPath: null,
143
+ masterCompPath: null,
144
+ });
145
+ expect(onSelectComposition).toHaveBeenCalledTimes(1);
146
+
147
+ rerender({ masterCompPath: "hero.html" });
148
+ expect(onSelectComposition).toHaveBeenCalledTimes(2);
149
+ expect(onSelectComposition).toHaveBeenLastCalledWith("hero.html");
150
+ });
151
+ });
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Opens the root composition once hydration settles with no selection, at most once per `projectId`.
5
+ * Caller must call `useResetSelectionOnProjectSwitch` on a project switch, or a stale selection reads as open.
6
+ */
7
+ export function useAutoOpenRootComposition({
8
+ projectId,
9
+ activeCompPath,
10
+ activeCompPathHydrated,
11
+ masterCompPath,
12
+ onSelectComposition,
13
+ }: {
14
+ projectId: string | null;
15
+ activeCompPath: string | null;
16
+ activeCompPathHydrated: boolean;
17
+ masterCompPath: string | null;
18
+ onSelectComposition: (comp: string) => void;
19
+ }): void {
20
+ const settledForProjectRef = useRef<string | null | undefined>(undefined);
21
+
22
+ useEffect(() => {
23
+ if (settledForProjectRef.current === projectId) return;
24
+ if (!activeCompPathHydrated) return;
25
+ if (activeCompPath !== null) {
26
+ settledForProjectRef.current = projectId; // a URL-hydrated selection is already open
27
+ return;
28
+ }
29
+ if (!masterCompPath) return; // file tree not loaded yet, or no composition at all
30
+ settledForProjectRef.current = projectId;
31
+ onSelectComposition(masterCompPath);
32
+ }, [projectId, activeCompPath, activeCompPathHydrated, masterCompPath, onSelectComposition]);
33
+ }
@@ -456,20 +456,19 @@ export function useDomEditCommits({
456
456
 
457
457
  // ── Element lifecycle (delete, z-index reorder) ──
458
458
 
459
- const { handleDomEditElementDelete, handleDomEditElementsDelete, handleDomZIndexReorderCommit } =
460
- useElementLifecycleOps({
461
- activeCompPath,
462
- showToast,
463
- writeProjectFile,
464
- editHistory,
465
- projectIdRef,
466
- reloadPreview,
467
- clearDomSelection,
468
- onTrySdkDelete,
469
- onReorderShadow,
470
- forceReloadSdkSession,
471
- commitDomEditPatchBatches,
472
- });
459
+ const { handleDomEditElementsDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({
460
+ activeCompPath,
461
+ showToast,
462
+ writeProjectFile,
463
+ editHistory,
464
+ projectIdRef,
465
+ reloadPreview,
466
+ clearDomSelection,
467
+ onTrySdkDelete,
468
+ onReorderShadow,
469
+ forceReloadSdkSession,
470
+ commitDomEditPatchBatches,
471
+ });
473
472
 
474
473
  return {
475
474
  resolveImportedFontAsset,
@@ -491,7 +490,6 @@ export function useDomEditCommits({
491
490
  handleDomBoxSizeCommit,
492
491
  handleDomRotationCommit,
493
492
  handleDomManualEditsReset,
494
- handleDomEditElementDelete,
495
493
  handleDomEditElementsDelete,
496
494
  handleDomZIndexReorderCommit,
497
495
  };
@@ -6,6 +6,7 @@ import { shouldUseSdkCutover } from "../utils/sdkCutover";
6
6
  import type { PatchOperation } from "../utils/sourcePatcher";
7
7
  import type { Composition } from "@hyperframes/sdk";
8
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
+ import type { TimelineElement } from "../player";
9
10
  import type { UseDomEditSessionParams } from "./useDomEditSession";
10
11
 
11
12
  const styleOp = (property: string, value: string): PatchOperation => ({
@@ -97,6 +98,7 @@ function createSessionParams(
97
98
  syncPreviewHotkeys: vi.fn(),
98
99
  reloadPreview: vi.fn(),
99
100
  setRefreshKey: vi.fn(),
101
+ handleTimelineElementsDelete: vi.fn(),
100
102
  ...overrides,
101
103
  };
102
104
  }
@@ -105,6 +107,7 @@ vi.mock("../utils/sdkResolverShadow", () => ({
105
107
  runResolverShadow: vi.fn(),
106
108
  recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
107
109
  }));
110
+ const handleDomEditElementsDeleteMock = vi.fn(async () => ({ ok: true }) as const);
108
111
  vi.mock("./useDomEditCommits", () => ({
109
112
  useDomEditCommits: (params: { onReorderShadow?: (targets: string[]) => void }) => {
110
113
  capturedOnReorderShadow.fn = params.onReorderShadow;
@@ -120,7 +123,7 @@ vi.mock("./useDomEditCommits", () => ({
120
123
  handleDomRemoveTextField: vi.fn(),
121
124
  handleDomBoxSizeCommit: vi.fn(),
122
125
  handleDomManualEditsReset: vi.fn(),
123
- handleDomEditElementDelete: vi.fn(),
126
+ handleDomEditElementsDelete: handleDomEditElementsDeleteMock,
124
127
  handleDomZIndexReorderCommit: vi.fn(),
125
128
  };
126
129
  },
@@ -461,3 +464,89 @@ describe("handleGroupSelection with audio in the selection", () => {
461
464
  expect(groupSelectionSpy).toHaveBeenCalledTimes(1);
462
465
  });
463
466
  });
467
+
468
+ // ── Delete routing: a timeline row goes through the timeline's own delete op ──
469
+
470
+ describe("handleDomEditElementDelete routing", () => {
471
+ const domSel = (id: string): DomEditSelection =>
472
+ ({
473
+ id,
474
+ element: document.createElement("div"),
475
+ sourceFile: "index.html",
476
+ }) as unknown as DomEditSelection;
477
+
478
+ async function deleteViaSession(
479
+ selection: DomEditSelection,
480
+ timelineElements: TimelineElement[],
481
+ options?: { expandGroup?: boolean; group?: DomEditSelection[] },
482
+ ) {
483
+ const { useDomEditSession } = await import("./useDomEditSession");
484
+ handleDomEditElementsDeleteMock.mockClear();
485
+ domEditGroupSelectionsRef.current = options?.group ?? [];
486
+ const handleTimelineElementsDelete = vi.fn(async () => {});
487
+ const captured: {
488
+ fn?: (selection: DomEditSelection, options?: { expandGroup?: boolean }) => Promise<void>;
489
+ } = {};
490
+ function Probe() {
491
+ captured.fn = useDomEditSession(
492
+ createSessionParams({ timelineElements, handleTimelineElementsDelete }),
493
+ ).handleDomEditElementDelete;
494
+ return null;
495
+ }
496
+ const root = createRoot(document.createElement("div"));
497
+ act(() => root.render(<Probe />));
498
+ await act(async () => captured.fn?.(selection, { expandGroup: options?.expandGroup }));
499
+ act(() => root.unmount());
500
+ domEditGroupSelectionsRef.current = [];
501
+ return { handleTimelineElementsDelete };
502
+ }
503
+
504
+ it("hands a selection that IS a timeline row to the timeline delete op, not the REST path", async () => {
505
+ const clip = {
506
+ id: "clip-a",
507
+ domId: "clip-a",
508
+ sourceFile: "index.html",
509
+ tag: "video",
510
+ start: 0,
511
+ duration: 2,
512
+ track: 0,
513
+ } as TimelineElement;
514
+ const { handleTimelineElementsDelete } = await deleteViaSession(domSel("clip-a"), [clip]);
515
+ expect(handleTimelineElementsDelete).toHaveBeenCalledWith([clip]);
516
+ expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
517
+ });
518
+
519
+ it("falls through to the REST path for a selection with no timeline row of its own", async () => {
520
+ const { handleTimelineElementsDelete } = await deleteViaSession(domSel("nested-child"), []);
521
+ expect(handleTimelineElementsDelete).not.toHaveBeenCalled();
522
+ expect(handleDomEditElementsDeleteMock).toHaveBeenCalledWith([domSel("nested-child")]);
523
+ });
524
+
525
+ it("expands a multi-member marquee group to the timeline op when every member resolves", async () => {
526
+ const clipA = {
527
+ id: "clip-a",
528
+ domId: "clip-a",
529
+ sourceFile: "index.html",
530
+ tag: "video",
531
+ start: 0,
532
+ duration: 2,
533
+ track: 0,
534
+ } as TimelineElement;
535
+ const clipB = {
536
+ id: "clip-b",
537
+ domId: "clip-b",
538
+ sourceFile: "index.html",
539
+ tag: "video",
540
+ start: 2,
541
+ duration: 2,
542
+ track: 0,
543
+ } as TimelineElement;
544
+ const group = [domSel("clip-a"), domSel("clip-b")];
545
+ const { handleTimelineElementsDelete } = await deleteViaSession(group[0], [clipA, clipB], {
546
+ expandGroup: true,
547
+ group,
548
+ });
549
+ expect(handleTimelineElementsDelete).toHaveBeenCalledWith([clipA, clipB]);
550
+ expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
551
+ });
552
+ });