@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.
- package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -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 "./
|
|
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
|
+
});
|