@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
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
|
|
6
6
|
import type { TimelineElement } from "../player";
|
|
7
7
|
import { usePlayerStore } from "../player";
|
|
8
8
|
import { addBlockToProject } from "../utils/blockInstaller";
|
|
9
|
+
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
9
10
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
10
11
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
12
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
@@ -148,7 +149,9 @@ export function useBlockHandlers({
|
|
|
148
149
|
previewIframe: previewIframeRef.current,
|
|
149
150
|
currentTime: usePlayerStore.getState().currentTime,
|
|
150
151
|
}),
|
|
151
|
-
)
|
|
152
|
+
).then((result) => {
|
|
153
|
+
if (result) selectAndRevealTimelineElement(result.hostKey);
|
|
154
|
+
});
|
|
152
155
|
},
|
|
153
156
|
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
154
157
|
);
|
|
@@ -184,7 +187,9 @@ export function useBlockHandlers({
|
|
|
184
187
|
previewIframe: previewIframeRef.current,
|
|
185
188
|
currentTime: usePlayerStore.getState().currentTime,
|
|
186
189
|
}),
|
|
187
|
-
)
|
|
190
|
+
).then((result) => {
|
|
191
|
+
if (result) selectAndRevealTimelineElement(result.hostKey);
|
|
192
|
+
});
|
|
188
193
|
},
|
|
189
194
|
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
190
195
|
);
|
|
@@ -456,20 +456,19 @@ export function useDomEditCommits({
|
|
|
456
456
|
|
|
457
457
|
// ── Element lifecycle (delete, z-index reorder) ──
|
|
458
458
|
|
|
459
|
-
const {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
+
});
|