@hyperframes/studio 0.8.47 → 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 (95) hide show
  1. package/dist/assets/{hyperframes-player-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
  2. package/dist/assets/{index-ryL7q1QG.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-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +66 -23
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1015 -621
  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/useDomEditCommits.ts +13 -15
  39. package/src/hooks/useDomEditSession.test.tsx +90 -1
  40. package/src/hooks/useDomEditSession.ts +31 -34
  41. package/src/hooks/useDomSelectionTypes.ts +7 -2
  42. package/src/hooks/useElementLifecycleOps.ts +0 -8
  43. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  44. package/src/hooks/useFileManager.ts +0 -2
  45. package/src/hooks/useFileTree.test.tsx +214 -0
  46. package/src/hooks/useFileTree.ts +66 -24
  47. package/src/hooks/useGroupCommits.test.ts +98 -0
  48. package/src/hooks/useGroupCommits.ts +66 -9
  49. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  50. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  51. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  52. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  53. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  54. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  55. package/src/hooks/useTimelineEditing.ts +1 -0
  56. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  57. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  58. package/src/index.ts +10 -0
  59. package/src/player/components/Timeline.test.ts +0 -4
  60. package/src/player/components/Timeline.tsx +0 -1
  61. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  62. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  63. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  64. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  65. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  66. package/src/player/components/automationLaneData.test.ts +11 -3
  67. package/src/player/components/automationLaneData.ts +5 -4
  68. package/src/player/components/automationLaneGeometry.ts +15 -0
  69. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  70. package/src/player/components/timelineDragDrop.ts +9 -25
  71. package/src/player/components/timelineGapCommit.test.ts +96 -0
  72. package/src/player/components/timelineGapCommit.ts +56 -11
  73. package/src/player/components/timelineGaps.ts +2 -2
  74. package/src/player/components/timelineLayout.test.ts +0 -1
  75. package/src/player/components/timelineLayout.ts +1 -6
  76. package/src/player/components/timelineZones.test.ts +20 -1
  77. package/src/player/components/timelineZones.ts +10 -3
  78. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  79. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  80. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  81. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  82. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  83. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  84. package/src/player/store/playerStore.ts +16 -21
  85. package/src/utils/razorSplitTransaction.test.ts +10 -0
  86. package/src/utils/razorSplitTransaction.ts +5 -0
  87. package/src/utils/sdkCutover.test.ts +51 -0
  88. package/src/utils/studioHelpers.test.ts +18 -0
  89. package/src/utils/studioHelpers.ts +41 -5
  90. package/src/utils/studioUiPreferences.test.ts +14 -0
  91. package/src/utils/studioUiPreferences.ts +6 -0
  92. package/src/utils/studioUrlState.ts +15 -7
  93. package/src/utils/timelineInspector.ts +10 -0
  94. package/dist/assets/index-DnRfAiK2.css +0 -1
  95. package/dist/assets/index-j-4hCrZU.js +0 -441
@@ -24,7 +24,6 @@ export function FileManagerProvider({
24
24
  projectDir,
25
25
  fileTree,
26
26
  fileTreeLoaded,
27
- setFileTree,
28
27
  editingPathRef,
29
28
  projectIdRef,
30
29
  saveRafRef,
@@ -68,7 +67,6 @@ export function FileManagerProvider({
68
67
  projectDir,
69
68
  fileTree,
70
69
  fileTreeLoaded,
71
- setFileTree,
72
70
  editingPathRef,
73
71
  projectIdRef,
74
72
  saveRafRef,
@@ -106,7 +104,6 @@ export function FileManagerProvider({
106
104
  projectDir,
107
105
  fileTree,
108
106
  fileTreeLoaded,
109
- setFileTree,
110
107
  editingPathRef,
111
108
  projectIdRef,
112
109
  saveRafRef,
@@ -0,0 +1,256 @@
1
+ import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
2
+ import { usePlayerStore } from "../player";
3
+ import type { TimelineElement } from "../player";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
+ import { isTypingTarget } from "../utils/typingTarget";
7
+ import { isEditableTarget } from "../utils/timelineDiscovery";
8
+ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
+ import { canSplitElement } from "../utils/timelineElementSplit";
10
+ import { trackStudioEvent } from "../utils/studioTelemetry";
11
+
12
+ // Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
13
+ // following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
14
+ // hook still owns the actual keydown listeners and calls into these.
15
+
16
+ /** Exported so useAppHotkeys's own history-only preview listener can reuse
17
+ * the same undo/redo key arbitration without duplicating it. */
18
+ export function handleUndoRedoKey(
19
+ event: KeyboardEvent,
20
+ onUndo: () => void,
21
+ onRedo: () => void,
22
+ ): boolean {
23
+ const key = event.key.toLowerCase();
24
+ if (key === "z" && !event.shiftKey) {
25
+ event.preventDefault();
26
+ onUndo();
27
+ return true;
28
+ }
29
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
30
+ event.preventDefault();
31
+ onRedo();
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ export interface HotkeyCallbacks {
38
+ handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
39
+ handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
40
+ handleDomEditElementDelete: (
41
+ selection: DomEditSelection,
42
+ options?: { expandGroup?: boolean },
43
+ ) => Promise<void>;
44
+ handleUndo: () => Promise<void>;
45
+ handleRedo: () => Promise<void>;
46
+ handleCopy: () => boolean;
47
+ handlePaste: () => Promise<void>;
48
+ handleCut: () => Promise<boolean>;
49
+ onResetKeyframes: () => boolean;
50
+ onDeleteSelectedKeyframes: () => void;
51
+ onToggleRecording?: () => void;
52
+ onGroupSelection?: () => void;
53
+ onUngroupSelection?: () => void;
54
+ leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
55
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
56
+ showToast: (message: string, tone?: "error" | "info") => void;
57
+ }
58
+
59
+ /** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
60
+ * arbitration between an automation range and the clip clipboard be asserted
61
+ * without standing up the whole hook. */
62
+ export function dispatchModifierKey(
63
+ event: KeyboardEvent,
64
+ key: string,
65
+ cb: HotkeyCallbacks,
66
+ ): boolean {
67
+ if (
68
+ !shouldIgnoreHistoryShortcut(event.target) &&
69
+ handleUndoRedoKey(
70
+ event,
71
+ () => {
72
+ trackStudioEvent("keyboard_shortcut", { action: "undo" });
73
+ void cb.handleUndo();
74
+ },
75
+ () => {
76
+ trackStudioEvent("keyboard_shortcut", { action: "redo" });
77
+ void cb.handleRedo();
78
+ },
79
+ )
80
+ )
81
+ return true;
82
+
83
+ if (event.key === "1") {
84
+ event.preventDefault();
85
+ trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
86
+ cb.leftSidebarRef.current?.selectTab("compositions");
87
+ return true;
88
+ }
89
+ if (event.key === "2") {
90
+ event.preventDefault();
91
+ trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
92
+ cb.leftSidebarRef.current?.selectTab("assets");
93
+ return true;
94
+ }
95
+
96
+ if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
97
+ event.preventDefault();
98
+ if (event.shiftKey) cb.onUngroupSelection?.();
99
+ else cb.onGroupSelection?.();
100
+ return true;
101
+ }
102
+
103
+ if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
104
+ // An active automation range owns Cmd+C/Cmd+V, same as Delete below: this
105
+ // capture listener runs before useAutomationSelectionKeyboard's, so without
106
+ // this both clipboards raced the same key. No preventDefault, so the
107
+ // downstream handler still sees it.
108
+ if (automationOwnsKey(event)) return true;
109
+ if (key === "c") {
110
+ if (cb.handleCopy()) {
111
+ event.preventDefault();
112
+ trackStudioEvent("keyboard_shortcut", { action: "copy" });
113
+ }
114
+ return true;
115
+ }
116
+ if (key === "v") {
117
+ event.preventDefault();
118
+ trackStudioEvent("keyboard_shortcut", { action: "paste" });
119
+ void cb.handlePaste();
120
+ return true;
121
+ }
122
+ if (key === "x") {
123
+ if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
124
+ event.preventDefault();
125
+ trackStudioEvent("keyboard_shortcut", { action: "cut" });
126
+ void cb.handleCut();
127
+ }
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ // fallow-ignore-next-line complexity
135
+ /** Exported for tests: the unmodified-key half of the dispatcher, so the
136
+ * Delete arbitration between keyframes, an automation range and the clip can
137
+ * be asserted without standing up the whole hook. */
138
+ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
139
+ if (key === "f" && !event.shiftKey && !event.altKey) {
140
+ event.preventDefault();
141
+ if (document.fullscreenElement) void document.exitFullscreen();
142
+ else
143
+ document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
144
+ return;
145
+ }
146
+
147
+ if (event.key === "s" && !event.altKey) {
148
+ // Reserve bare `s` for Split even when the current selection cannot split,
149
+ // so secondary listeners do not reinterpret the same key as Snap toggle.
150
+ event.preventDefault();
151
+ const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
152
+ if (selectedElementId) {
153
+ const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
154
+ if (
155
+ el &&
156
+ canSplitElement(el) &&
157
+ currentTime > el.start &&
158
+ currentTime < el.start + el.duration
159
+ ) {
160
+ void cb.handleTimelineElementSplit(el, currentTime);
161
+ return;
162
+ }
163
+ // Expanded sub-comp children carry a qualified `sourceFile#id` selection
164
+ // that isn't in the raw `elements` list, so the s-key can't resolve them.
165
+ // Nudge toward the razor tool instead of failing silently.
166
+ if (!el && selectedElementId.includes("#")) {
167
+ cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
168
+ return;
169
+ }
170
+ }
171
+ }
172
+
173
+ if (key === "b" && !event.shiftKey && !event.altKey) {
174
+ event.preventDefault();
175
+ const { activeTool, setActiveTool } = usePlayerStore.getState();
176
+ setActiveTool(activeTool === "razor" ? "select" : "razor");
177
+ return;
178
+ }
179
+
180
+ if (key === "v" && !event.shiftKey && !event.altKey) {
181
+ event.preventDefault();
182
+ usePlayerStore.getState().setActiveTool("select");
183
+ return;
184
+ }
185
+
186
+ // CapCut returns to the select tool with "A". Scoped to the razor being
187
+ // armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
188
+ // same activeTool to stay out of the way.
189
+ if (key === "a" && !event.shiftKey && !event.altKey) {
190
+ const { activeTool, setActiveTool } = usePlayerStore.getState();
191
+ if (activeTool === "razor") {
192
+ event.preventDefault();
193
+ setActiveTool("select");
194
+ return;
195
+ }
196
+ }
197
+
198
+ if (event.key === "Escape") {
199
+ const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
200
+ usePlayerStore.getState();
201
+ if (activeTool === "razor") {
202
+ if (selectedElementId) setSelectedElementId(null);
203
+ else setActiveTool("select");
204
+ event.preventDefault();
205
+ return;
206
+ }
207
+ }
208
+
209
+ if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) {
210
+ if (usePlayerStore.getState().selectedKeyframes.size > 0) {
211
+ cb.onDeleteSelectedKeyframes();
212
+ usePlayerStore.getState().clearSelectedKeyframes();
213
+ event.preventDefault();
214
+ return;
215
+ }
216
+ // An active automation range owns Delete (empties the range in place).
217
+ // No preventDefault: this capture listener runs first, so falling through
218
+ // is what lets the document-level handler still see the key instead of
219
+ // the press reaching the clip delete below.
220
+ if (usePlayerStore.getState().automationSelection) return;
221
+ if (event.key === "Backspace") {
222
+ const { selectedElementId, keyframeCache } = usePlayerStore.getState();
223
+ if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
224
+ event.preventDefault();
225
+ return;
226
+ }
227
+ }
228
+ // The canvas selection owns Delete first: the timeline mirror of it is
229
+ // lossy (drops a member with no timeline row of its own), so deleting via
230
+ // the timeline left other selected elements behind. Timeline stays as the
231
+ // fallback for rows with no canvas node (audio, an inactive comp).
232
+ const domSel = cb.domEditSelectionRef.current;
233
+ if (domSel) {
234
+ event.preventDefault();
235
+ // The whole marquee group, not just the primary the ref holds.
236
+ void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
237
+ return;
238
+ }
239
+ // Takes the WHOLE selection: `find` returned the first match, so selecting
240
+ // every clip and pressing Delete removed exactly one of them.
241
+ const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
242
+ const selectionKeys = new Set(selectedElementIds);
243
+ if (selectedElementId) selectionKeys.add(selectedElementId);
244
+ const selected = elements.filter((e) => selectionKeys.has(e.key ?? e.id));
245
+ if (selected.length > 0) {
246
+ event.preventDefault();
247
+ void cb.handleTimelineElementsDelete(selected);
248
+ }
249
+ return;
250
+ }
251
+
252
+ if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
253
+ event.preventDefault();
254
+ cb.onToggleRecording();
255
+ }
256
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { timelineElementsForDelete } from "./domEditDeleteMembers";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import type { TimelineElement } from "../player";
5
+
6
+ const sel = (id: string, sourceFile = "index.html") => ({ id, sourceFile }) as DomEditSelection;
7
+
8
+ const clip = (id: string, domId: string, sourceFile = "index.html"): TimelineElement =>
9
+ ({ id, domId, sourceFile, tag: "video", start: 0, duration: 1, track: 0 }) as TimelineElement;
10
+
11
+ describe("timelineElementsForDelete", () => {
12
+ it("resolves every member to its exact timeline row", () => {
13
+ const elements = [clip("a", "clip-a"), clip("b", "clip-b")];
14
+ const result = timelineElementsForDelete([sel("clip-a"), sel("clip-b")], elements);
15
+ expect(result).toEqual([elements[0], elements[1]]);
16
+ });
17
+
18
+ it("returns null when any member has no timeline row of its own", () => {
19
+ const elements = [clip("a", "clip-a")];
20
+ // "plain-child" is not a timeline element — a nested DOM node inside a clip.
21
+ const result = timelineElementsForDelete([sel("clip-a"), sel("plain-child")], elements);
22
+ expect(result).toBeNull();
23
+ });
24
+
25
+ it("does not fall back to the ancestor clip for a nested child", () => {
26
+ // A click inside a clip must not resolve to (and so delete) the whole clip.
27
+ const elements = [clip("a", "clip-a")];
28
+ expect(timelineElementsForDelete([sel("nested-text")], elements)).toBeNull();
29
+ });
30
+ });
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
9
  import type { EditHistoryKind } from "../utils/editHistory";
10
+ import type { TimelineElement } from "../player";
11
+ import { findMatchingTimelineElementId } from "../utils/studioHelpers";
10
12
 
11
13
  /** One entry in the studio's edit history, as `useDomEditSession`'s caller
12
14
  * supplies it. */
@@ -32,3 +34,20 @@ export function membersForDelete(
32
34
  ): DomEditSelection[] {
33
35
  return options?.expandGroup && group.length > 0 ? group : [selection];
34
36
  }
37
+
38
+ /** The timeline row for each member, only when EVERY member is one — a mixed
39
+ * group stays on the DOM path. Exact-identity match only, no ancestor
40
+ * fallback: a click inside a clip must not delete the whole clip. */
41
+ export function timelineElementsForDelete(
42
+ members: readonly DomEditSelection[],
43
+ elements: TimelineElement[],
44
+ ): TimelineElement[] | null {
45
+ const matches: TimelineElement[] = [];
46
+ for (const member of members) {
47
+ const id = findMatchingTimelineElementId(member, elements);
48
+ const match = id ? elements.find((el) => (el.key ?? el.id) === id) : undefined;
49
+ if (!match) return null;
50
+ matches.push(match);
51
+ }
52
+ return matches;
53
+ }
@@ -0,0 +1,69 @@
1
+ import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
2
+ import { useHydrateActiveCompPathFromUrl } from "./useHydrateActiveCompPathFromUrl";
3
+ import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
4
+ import { useCompositionContentLoader } from "./useCompositionContentLoader";
5
+ import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
6
+ import { isHydratedFromUrlState, type StudioUrlState } from "../utils/studioUrlState";
7
+ import type { AppToast, EditingFile } from "../utils/studioHelpers";
8
+
9
+ /** Owns which composition is open: state, URL hydration, auto-open and content loading. */
10
+ export function useActiveComposition({
11
+ projectId,
12
+ initialUrlStateRef,
13
+ fileTree,
14
+ fileTreeLoaded,
15
+ masterCompPath,
16
+ setEditingFile,
17
+ showToast,
18
+ }: {
19
+ projectId: string | null;
20
+ initialUrlStateRef: MutableRefObject<StudioUrlState>;
21
+ fileTree: string[];
22
+ fileTreeLoaded: boolean;
23
+ masterCompPath: string | null;
24
+ setEditingFile: (file: EditingFile) => void;
25
+ showToast: (message: string, tone?: AppToast["tone"]) => void;
26
+ }): {
27
+ activeCompPath: string | null;
28
+ activeCompPathHydrated: boolean;
29
+ setActiveCompPath: Dispatch<SetStateAction<string | null>>;
30
+ handleSelectComposition: (comp: string) => void;
31
+ } {
32
+ const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
33
+ const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
34
+ isHydratedFromUrlState(initialUrlStateRef.current),
35
+ );
36
+
37
+ useResetSelectionOnProjectSwitch({
38
+ projectId,
39
+ initialUrlStateRef,
40
+ setActiveCompPath,
41
+ setActiveCompPathHydrated,
42
+ });
43
+
44
+ useHydrateActiveCompPathFromUrl({
45
+ hydrated: activeCompPathHydrated,
46
+ fileTreeLoaded,
47
+ fileTree,
48
+ initialUrlStateRef,
49
+ setActiveCompPath,
50
+ setHydrated: setActiveCompPathHydrated,
51
+ });
52
+
53
+ const handleSelectComposition = useCompositionContentLoader({
54
+ projectId,
55
+ setEditingFile,
56
+ setActiveCompPath,
57
+ showToast,
58
+ });
59
+
60
+ useAutoOpenRootComposition({
61
+ projectId,
62
+ activeCompPath,
63
+ activeCompPathHydrated,
64
+ masterCompPath,
65
+ onSelectComposition: handleSelectComposition,
66
+ });
67
+
68
+ return { activeCompPath, activeCompPathHydrated, setActiveCompPath, handleSelectComposition };
69
+ }
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { dispatchModifierKey, dispatchPlainKey } from "./useAppHotkeys";
3
+ import { dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
4
4
  import { usePlayerStore } from "../player/store/playerStore";
5
5
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
6
6
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
@@ -277,3 +277,25 @@ describe("dispatchModifierKey — Cmd+C/Cmd+V arbitration", () => {
277
277
  expect(e.defaultPrevented).toBe(false);
278
278
  });
279
279
  });
280
+
281
+ describe('dispatchPlainKey — "A" returns to select while the razor is armed', () => {
282
+ afterEach(() => {
283
+ usePlayerStore.setState({ activeTool: "select" });
284
+ });
285
+
286
+ it("returns to the select tool, matching CapCut's keybinding", () => {
287
+ usePlayerStore.setState({ activeTool: "razor" });
288
+ const e = press("a");
289
+ dispatchPlainKey(e, "a", callbacks());
290
+ expect(usePlayerStore.getState().activeTool).toBe("select");
291
+ expect(e.defaultPrevented).toBe(true);
292
+ });
293
+
294
+ it("does not intercept plain \"a\" when the razor isn't armed, leaving playback's seek-to-in-point live", () => {
295
+ usePlayerStore.setState({ activeTool: "select" });
296
+ const e = press("a");
297
+ dispatchPlainKey(e, "a", callbacks());
298
+ expect(usePlayerStore.getState().activeTool).toBe("select");
299
+ expect(e.defaultPrevented).toBe(false);
300
+ });
301
+ });