@hyperframes/studio 0.8.46 → 0.8.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- 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/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- 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/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/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- 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/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/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- 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/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- 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/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- 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
|
},
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,16 @@ export { FileTree } from "./components/editor/FileTree";
|
|
|
27
27
|
// App
|
|
28
28
|
export { StudioApp } from "./App";
|
|
29
29
|
|
|
30
|
+
// Ask-agent flow
|
|
31
|
+
export { AskAgentModal } from "./components/AskAgentModal";
|
|
32
|
+
export type { AskAgentModalProps } from "./components/AskAgentModal";
|
|
33
|
+
export type { AgentModalAnchorPoint } from "./utils/studioHelpers";
|
|
34
|
+
export {
|
|
35
|
+
buildPickerAgentPrompt,
|
|
36
|
+
buildPickerAgentContextPreview,
|
|
37
|
+
} from "./components/editor/domEditingAgentPrompt";
|
|
38
|
+
export type { AgentPromptElementInfo } from "./components/editor/domEditingAgentPrompt";
|
|
39
|
+
|
|
30
40
|
// Hooks
|
|
31
41
|
export { useElementPicker } from "./hooks/useElementPicker";
|
|
32
42
|
export type { PickedElement } from "./hooks/useElementPicker";
|
|
@@ -166,7 +166,6 @@ describe("Timeline provider boundary", () => {
|
|
|
166
166
|
scrollTop: 0,
|
|
167
167
|
contentOrigin: GUTTER,
|
|
168
168
|
pixelsPerSecond: 100,
|
|
169
|
-
duration: 60,
|
|
170
169
|
trackOrder: [0],
|
|
171
170
|
},
|
|
172
171
|
1132,
|
|
@@ -282,7 +281,6 @@ describe("Timeline provider boundary", () => {
|
|
|
282
281
|
scrollTop: 0,
|
|
283
282
|
contentOrigin: LABEL_COL_W + GUTTER,
|
|
284
283
|
pixelsPerSecond: 100,
|
|
285
|
-
duration: 60,
|
|
286
284
|
trackOrder: [0],
|
|
287
285
|
},
|
|
288
286
|
100 + LABEL_COL_W + GUTTER + 1000,
|
|
@@ -1263,7 +1261,6 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1263
1261
|
scrollTop: 0,
|
|
1264
1262
|
contentOrigin: GUTTER,
|
|
1265
1263
|
pixelsPerSecond: 100,
|
|
1266
|
-
duration: 10,
|
|
1267
1264
|
trackHeight: 72,
|
|
1268
1265
|
trackOrder: [0, 3, 7],
|
|
1269
1266
|
},
|
|
@@ -1285,7 +1282,6 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1285
1282
|
scrollTop: 0,
|
|
1286
1283
|
contentOrigin: GUTTER,
|
|
1287
1284
|
pixelsPerSecond: 100,
|
|
1288
|
-
duration: 10,
|
|
1289
1285
|
trackHeight: 72,
|
|
1290
1286
|
trackOrder: [0, 3, 7],
|
|
1291
1287
|
},
|
|
@@ -13,7 +13,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
|
|
13
13
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
14
14
|
import { getTimelineLaneTop } from "./timelineLayout";
|
|
15
15
|
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
|
16
|
-
import {
|
|
16
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
17
17
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
18
18
|
import type { TimelineElement } from "../store/playerStore";
|
|
19
19
|
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
|
@@ -174,7 +174,9 @@ export function TimelineAutomationLaneSlot({
|
|
|
174
174
|
currentTime,
|
|
175
175
|
beatTimes,
|
|
176
176
|
}: TimelineAutomationLaneSlotProps) {
|
|
177
|
-
|
|
177
|
+
// Broader than a raw-attribute check — a clip mid-edit (see the stale-
|
|
178
|
+
// selection test) has lanes in its live binding before the attribute commits.
|
|
179
|
+
const clips = elements.filter(isAudioOrVideoTimelineElement);
|
|
178
180
|
const rowsByClip = new Map<string, ClipLaneRow[]>();
|
|
179
181
|
groupAutomationLanes(clips).forEach((group, rowIndex) => {
|
|
180
182
|
for (const entry of group.entries) {
|
|
@@ -1275,7 +1275,8 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1275
1275
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
1276
1276
|
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
|
|
1277
1277
|
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
|
|
1278
|
-
expect(ease.
|
|
1278
|
+
expect(ease.getAttribute("title")).toBe("Edit none easing");
|
|
1279
|
+
expect(ease.classList.contains("opacity-40")).toBe(true);
|
|
1279
1280
|
act(() => ease.click());
|
|
1280
1281
|
expect(onSelectSegment).toHaveBeenCalledOnce();
|
|
1281
1282
|
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
|
@@ -1318,6 +1319,17 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1318
1319
|
act(() => root.unmount());
|
|
1319
1320
|
});
|
|
1320
1321
|
|
|
1322
|
+
it("keeps the ease button hover-only on a segment too narrow to show it at rest", () => {
|
|
1323
|
+
// Same narrow clip as above: showing the button at rest here would sit on
|
|
1324
|
+
// top of both diamonds instead of clear of them.
|
|
1325
|
+
const { host, root } = renderSegmentLane(false, 40);
|
|
1326
|
+
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
1327
|
+
|
|
1328
|
+
expect(ease?.classList.contains("opacity-0")).toBe(true);
|
|
1329
|
+
expect(ease?.classList.contains("opacity-40")).toBe(false);
|
|
1330
|
+
act(() => root.unmount());
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1321
1333
|
it("hides the inline ease button on a segment with no source animation id", () => {
|
|
1322
1334
|
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
|
1323
1335
|
// target; the segment ending on it must not render a (dead) ease button.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasRoomForRestingEase } from "./TimelineDiamondConnectors";
|
|
3
|
+
|
|
4
|
+
describe("hasRoomForRestingEase", () => {
|
|
5
|
+
it("is false just under the threshold", () => {
|
|
6
|
+
expect(hasRoomForRestingEase(23)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("is true exactly at the threshold", () => {
|
|
10
|
+
expect(hasRoomForRestingEase(24)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is true well above the threshold", () => {
|
|
14
|
+
expect(hasRoomForRestingEase(78)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -13,6 +13,17 @@ export interface TimelineDiamondMarker {
|
|
|
13
13
|
visualSize: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const EASE_BUTTON_PX = 16;
|
|
17
|
+
const EASE_BUTTON_REST_GAP_PX = 4;
|
|
18
|
+
|
|
19
|
+
/** Whether a segment is wide enough to show the ease button at rest (button
|
|
20
|
+
* box plus a clear gap each side) without overlapping either neighboring
|
|
21
|
+
* diamond. 24 coincides with roomForFullTarget's threshold below but is a
|
|
22
|
+
* separate constraint; don't assume changing one keeps the other in sync. */
|
|
23
|
+
export function hasRoomForRestingEase(connectorWidth: number): boolean {
|
|
24
|
+
return connectorWidth >= EASE_BUTTON_PX + EASE_BUTTON_REST_GAP_PX * 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/**
|
|
17
28
|
* The line between each pair of adjacent diamonds, plus the ease control that
|
|
18
29
|
* sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
|
|
@@ -96,6 +107,7 @@ export function TimelineDiamondConnectors({
|
|
|
96
107
|
// the hit test and steal their clicks at fit zoom. Grow the
|
|
97
108
|
// target only where the room exists.
|
|
98
109
|
roomForFullTarget={connectorWidth >= 24}
|
|
110
|
+
restingVisible={hasRoomForRestingEase(connectorWidth)}
|
|
99
111
|
onSelectSegment={onSelectSegment}
|
|
100
112
|
/>
|
|
101
113
|
)}
|
|
@@ -133,6 +145,7 @@ function SegmentEaseControl({
|
|
|
133
145
|
rovingTargetId,
|
|
134
146
|
afterLabel,
|
|
135
147
|
roomForFullTarget,
|
|
148
|
+
restingVisible,
|
|
136
149
|
onSelectSegment,
|
|
137
150
|
}: {
|
|
138
151
|
left: number;
|
|
@@ -146,6 +159,9 @@ function SegmentEaseControl({
|
|
|
146
159
|
/** Time label of the keyframe this segment starts at, for the accessible name. */
|
|
147
160
|
afterLabel: string;
|
|
148
161
|
roomForFullTarget: boolean;
|
|
162
|
+
/** Whether the segment is wide enough to show the button at rest; too
|
|
163
|
+
* narrow and it stays hover-only so it doesn't smear over the diamonds. */
|
|
164
|
+
restingVisible: boolean;
|
|
149
165
|
onSelectSegment: (target: TimelineKeyframeTarget) => void;
|
|
150
166
|
}) {
|
|
151
167
|
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
|
@@ -187,7 +203,7 @@ function SegmentEaseControl({
|
|
|
187
203
|
// for that overlay the button keeps its 16x16 hit area, which is WCAG's
|
|
188
204
|
// target-spacing exception: the neighbouring diamonds are themselves the
|
|
189
205
|
// reason it cannot grow, and stealing their clicks is the worse failure.
|
|
190
|
-
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
206
|
+
className={`absolute flex items-center justify-center rounded ${restingVisible ? "opacity-40" : "opacity-0"} transition-opacity hover:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
191
207
|
style={{
|
|
192
208
|
left: "50%",
|
|
193
209
|
top: "50%",
|
|
@@ -406,7 +406,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
406
406
|
expect(laneDiamonds(host, "position")).toHaveLength(3);
|
|
407
407
|
const buttons = laneEaseButtons(host, "position");
|
|
408
408
|
expect(buttons).toHaveLength(2);
|
|
409
|
-
expect(buttons.every((button) => button.classList.contains("opacity-
|
|
409
|
+
expect(buttons.every((button) => button.classList.contains("opacity-40"))).toBe(true);
|
|
410
410
|
expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
|
|
411
411
|
true,
|
|
412
412
|
);
|
|
@@ -260,9 +260,17 @@ describe("groupAutomationLanes", () => {
|
|
|
260
260
|
expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
it("
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
it("draws lanes for a video element, matching the reserved height", () => {
|
|
264
|
+
// A <video>'s volume can be automated from the property panel same as an
|
|
265
|
+
// audio clip's; gating this on `isAudioTimelineElement` alone hid every
|
|
266
|
+
// keyframe the panel wrote for a video clip.
|
|
267
|
+
const video = el({ id: "titles", key: "titles", tag: "video", automation: lanesOf("volume") });
|
|
268
|
+
expect(groupAutomationLanes([video]).map((g) => g.key)).toEqual(["Volume"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("ignores a clip that is neither audio nor video, the way the reserved height does", () => {
|
|
272
|
+
const div = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
|
|
273
|
+
expect(groupAutomationLanes([div])).toEqual([]);
|
|
266
274
|
});
|
|
267
275
|
|
|
268
276
|
it("skips a target that does not resolve against its clip's chain", () => {
|