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