@hyperframes/studio 0.8.127 → 0.8.128
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-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
- package/dist/assets/{index-CLBNYlDY.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1193 -869
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
4
|
+
import {
|
|
5
|
+
applyTimelineStackingReorder,
|
|
6
|
+
patchIframeDomTiming,
|
|
7
|
+
persistTimelineEdit,
|
|
8
|
+
formatTimelineAttributeNumber,
|
|
9
|
+
formatTimelineMediaOffset,
|
|
10
|
+
extendRootDurationIfNeeded,
|
|
11
|
+
buildTimelineMoveTimingPatch,
|
|
12
|
+
buildTimelineResizeTimingPatch,
|
|
13
|
+
type PersistTimelineEditInput,
|
|
14
|
+
} from "./timelineEditingHelpers";
|
|
15
|
+
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
|
|
16
|
+
import {
|
|
17
|
+
captureDurationRollback,
|
|
18
|
+
finishClipTimingFallback,
|
|
19
|
+
sdkTimingGsapSync,
|
|
20
|
+
readFileContent,
|
|
21
|
+
syncPreviewContentDuration,
|
|
22
|
+
timingGestureStep,
|
|
23
|
+
} from "./timelineTimingSync";
|
|
24
|
+
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
25
|
+
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
26
|
+
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
27
|
+
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
28
|
+
|
|
29
|
+
type UseTimelineClipTimingEditingOptions = Pick<
|
|
30
|
+
UseTimelineEditingOptions,
|
|
31
|
+
| "activeCompPath"
|
|
32
|
+
| "timelineElements"
|
|
33
|
+
| "showToast"
|
|
34
|
+
| "writeProjectFile"
|
|
35
|
+
| "recordEdit"
|
|
36
|
+
| "reloadPreview"
|
|
37
|
+
| "previewIframeRef"
|
|
38
|
+
| "pendingTimelineEditPathRef"
|
|
39
|
+
| "isRecordingRef"
|
|
40
|
+
| "sdkSession"
|
|
41
|
+
| "publishSdkSession"
|
|
42
|
+
| "forceReloadSdkSession"
|
|
43
|
+
| "invalidateGsapCache"
|
|
44
|
+
| "handleDomZIndexReorderCommitRef"
|
|
45
|
+
> & {
|
|
46
|
+
editQueueRef: MutableRefObject<Promise<unknown>>;
|
|
47
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** One clip's move and resize: the timing write, then its tween rewrite, as one undo step. */
|
|
51
|
+
export function useTimelineClipTimingEditing({
|
|
52
|
+
activeCompPath,
|
|
53
|
+
timelineElements,
|
|
54
|
+
showToast,
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
recordEdit,
|
|
57
|
+
reloadPreview,
|
|
58
|
+
previewIframeRef,
|
|
59
|
+
pendingTimelineEditPathRef,
|
|
60
|
+
isRecordingRef,
|
|
61
|
+
sdkSession,
|
|
62
|
+
publishSdkSession,
|
|
63
|
+
forceReloadSdkSession,
|
|
64
|
+
invalidateGsapCache,
|
|
65
|
+
handleDomZIndexReorderCommitRef,
|
|
66
|
+
editQueueRef,
|
|
67
|
+
projectIdRef,
|
|
68
|
+
}: UseTimelineClipTimingEditingOptions) {
|
|
69
|
+
const enqueueEdit = useCallback(
|
|
70
|
+
(
|
|
71
|
+
element: TimelineElement,
|
|
72
|
+
label: string,
|
|
73
|
+
buildPatches: PersistTimelineEditInput["buildPatches"],
|
|
74
|
+
step: ReturnType<typeof timingGestureStep>,
|
|
75
|
+
): Promise<void> => {
|
|
76
|
+
if (isRecordingRef?.current) {
|
|
77
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
}
|
|
80
|
+
const pid = projectIdRef.current;
|
|
81
|
+
if (!pid) return Promise.resolve();
|
|
82
|
+
const queued = editQueueRef.current
|
|
83
|
+
.then(() =>
|
|
84
|
+
persistTimelineEdit({
|
|
85
|
+
projectId: pid,
|
|
86
|
+
element,
|
|
87
|
+
activeCompPath,
|
|
88
|
+
label,
|
|
89
|
+
buildPatches,
|
|
90
|
+
writeProjectFile,
|
|
91
|
+
recordEdit,
|
|
92
|
+
pendingTimelineEditPathRef,
|
|
93
|
+
...step,
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
.then(() => {
|
|
97
|
+
forceReloadSdkSession?.();
|
|
98
|
+
});
|
|
99
|
+
editQueueRef.current = queued.catch((error) => {
|
|
100
|
+
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
101
|
+
});
|
|
102
|
+
return queued;
|
|
103
|
+
},
|
|
104
|
+
[
|
|
105
|
+
activeCompPath,
|
|
106
|
+
recordEdit,
|
|
107
|
+
writeProjectFile,
|
|
108
|
+
pendingTimelineEditPathRef,
|
|
109
|
+
showToast,
|
|
110
|
+
isRecordingRef,
|
|
111
|
+
forceReloadSdkSession,
|
|
112
|
+
editQueueRef,
|
|
113
|
+
projectIdRef,
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
const handleTimelineElementMove = useCallback(
|
|
117
|
+
// fallow-ignore-next-line complexity
|
|
118
|
+
(element: TimelineElement, updates: TimelineMoveUpdates) => {
|
|
119
|
+
// fallow-ignore-next-line complexity
|
|
120
|
+
const commitMove = () => {
|
|
121
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
122
|
+
const startChanged = updates.start !== element.start;
|
|
123
|
+
// A vertical-only lane move (start unchanged, authored track already in updates.track) must
|
|
124
|
+
// persist like any other move, or the lane snaps back on reload.
|
|
125
|
+
const trackChanged = updates.track !== element.track;
|
|
126
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
127
|
+
if (startChanged || trackChanged) {
|
|
128
|
+
const liveAttrs: Array<[string, string]> = [];
|
|
129
|
+
if (startChanged)
|
|
130
|
+
liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
|
|
131
|
+
if (trackChanged) {
|
|
132
|
+
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
|
|
133
|
+
}
|
|
134
|
+
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reorderDone = applyTimelineStackingReorder({
|
|
138
|
+
element,
|
|
139
|
+
stackingReorder: updates.stackingReorder,
|
|
140
|
+
timelineElements,
|
|
141
|
+
iframe: previewIframeRef.current,
|
|
142
|
+
activeCompPath,
|
|
143
|
+
commit: handleDomZIndexReorderCommitRef?.current,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!startChanged && !trackChanged) return reorderDone;
|
|
147
|
+
|
|
148
|
+
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
149
|
+
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
150
|
+
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
151
|
+
// Read before the readout sync below: the SDK path (setTiming) can't grow the root duration.
|
|
152
|
+
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
|
153
|
+
// Optimistic duration readout from the just-patched live DOM (grows and shrinks).
|
|
154
|
+
syncPreviewContentDuration(previewIframeRef.current);
|
|
155
|
+
|
|
156
|
+
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
157
|
+
// Persist lane changes too — data-start-only writes let reload snap the lane back.
|
|
158
|
+
const track = trackChanged ? updates.track : undefined;
|
|
159
|
+
return buildTimelineMoveTimingPatch(
|
|
160
|
+
original,
|
|
161
|
+
target,
|
|
162
|
+
authoredStart,
|
|
163
|
+
element.duration,
|
|
164
|
+
track,
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
const step = timingGestureStep("timeline-move");
|
|
168
|
+
const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
169
|
+
// One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
|
|
170
|
+
finishClipTimingFallback({
|
|
171
|
+
iframe: previewIframeRef.current,
|
|
172
|
+
reloadPreview,
|
|
173
|
+
projectId: projectIdRef.current,
|
|
174
|
+
targetPath,
|
|
175
|
+
domId: element.domId,
|
|
176
|
+
label: "Move timeline clip",
|
|
177
|
+
coalesceKey: step.coalesceKey,
|
|
178
|
+
recordEdit,
|
|
179
|
+
writeProjectFile,
|
|
180
|
+
edit: { kind: "shift", delta: updates.start - element.start },
|
|
181
|
+
sdkGsap,
|
|
182
|
+
}).finally(() => invalidateGsapCache?.());
|
|
183
|
+
const moveFallback = () =>
|
|
184
|
+
enqueueEdit(element, "Move timeline clip", buildMovePatches, step).then(() =>
|
|
185
|
+
finishMoveGsapSync(),
|
|
186
|
+
);
|
|
187
|
+
return reorderDone
|
|
188
|
+
.then(() => {
|
|
189
|
+
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
190
|
+
if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
|
|
191
|
+
return sdkTimingPersist(
|
|
192
|
+
element.hfId,
|
|
193
|
+
targetPath,
|
|
194
|
+
{ start: authoredStart },
|
|
195
|
+
sdkSession,
|
|
196
|
+
{
|
|
197
|
+
editHistory: { recordEdit },
|
|
198
|
+
writeProjectFile,
|
|
199
|
+
reloadPreview,
|
|
200
|
+
compositionPath: activeCompPath,
|
|
201
|
+
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
|
202
|
+
// restores the file verbatim, not a normalized full-DOM re-emit.
|
|
203
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
204
|
+
publishSession: publishSdkSession,
|
|
205
|
+
},
|
|
206
|
+
{ label: "Move timeline clip", ...step, skipRefresh: true },
|
|
207
|
+
).then((result) => {
|
|
208
|
+
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
|
209
|
+
return finishMoveGsapSync(sdkTimingGsapSync(result));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return moveFallback();
|
|
213
|
+
})
|
|
214
|
+
.catch((error) => {
|
|
215
|
+
// Failed persist: revert the optimistic duration readout + live root.
|
|
216
|
+
rollbackDuration();
|
|
217
|
+
showToast(getStudioSaveErrorMessage(error), "error");
|
|
218
|
+
throw error;
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
return updates.stackingReorder ? serializeZLaneGesture(commitMove) : commitMove();
|
|
222
|
+
},
|
|
223
|
+
[
|
|
224
|
+
previewIframeRef,
|
|
225
|
+
enqueueEdit,
|
|
226
|
+
activeCompPath,
|
|
227
|
+
sdkSession,
|
|
228
|
+
publishSdkSession,
|
|
229
|
+
recordEdit,
|
|
230
|
+
writeProjectFile,
|
|
231
|
+
reloadPreview,
|
|
232
|
+
timelineElements,
|
|
233
|
+
handleDomZIndexReorderCommitRef,
|
|
234
|
+
showToast,
|
|
235
|
+
invalidateGsapCache,
|
|
236
|
+
projectIdRef,
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const handleTimelineElementResize = useCallback(
|
|
241
|
+
// fallow-ignore-next-line complexity
|
|
242
|
+
(
|
|
243
|
+
element: TimelineElement,
|
|
244
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
245
|
+
) => {
|
|
246
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
247
|
+
const liveAttrs: Array<[string, string]> = [
|
|
248
|
+
["data-start", formatTimelineAttributeNumber(authoredStart)],
|
|
249
|
+
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
250
|
+
];
|
|
251
|
+
if (updates.playbackStart != null) {
|
|
252
|
+
const liveAttr = playbackStartAttributeForElement(element);
|
|
253
|
+
liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
|
|
254
|
+
}
|
|
255
|
+
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
256
|
+
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
257
|
+
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
258
|
+
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
259
|
+
// Read before the readout sync below: the SDK path (setTiming) can't grow the root duration.
|
|
260
|
+
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
|
|
261
|
+
// Optimistic duration readout from the just-patched live DOM (grows and shrinks).
|
|
262
|
+
syncPreviewContentDuration(previewIframeRef.current);
|
|
263
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
264
|
+
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
265
|
+
return buildTimelineResizeTimingPatch(original, target, element, updates);
|
|
266
|
+
};
|
|
267
|
+
const hasPbsAdjustment =
|
|
268
|
+
updates.playbackStart != null ||
|
|
269
|
+
(updates.start !== element.start && element.playbackStart != null);
|
|
270
|
+
// Server-path fallback: after persisting the attr patch, scale GSAP tween
|
|
271
|
+
// positions/durations on the server, then soft-reload with the rewritten
|
|
272
|
+
// script (timing-only resize) — same no-flash path as move; full reload is
|
|
273
|
+
// the fallback.
|
|
274
|
+
const step = timingGestureStep("timeline-resize");
|
|
275
|
+
const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
276
|
+
finishClipTimingFallback({
|
|
277
|
+
iframe: previewIframeRef.current,
|
|
278
|
+
reloadPreview,
|
|
279
|
+
projectId: projectIdRef.current,
|
|
280
|
+
targetPath,
|
|
281
|
+
domId: element.domId,
|
|
282
|
+
label: "Resize timeline clip",
|
|
283
|
+
coalesceKey: step.coalesceKey,
|
|
284
|
+
recordEdit,
|
|
285
|
+
writeProjectFile,
|
|
286
|
+
edit: {
|
|
287
|
+
kind: "scale",
|
|
288
|
+
from: { start: toCompositionTime(element, element.start), duration: element.duration },
|
|
289
|
+
to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
|
|
290
|
+
},
|
|
291
|
+
sdkGsap,
|
|
292
|
+
}).finally(() => invalidateGsapCache?.());
|
|
293
|
+
const resizeFallback = () =>
|
|
294
|
+
enqueueEdit(element, "Resize timeline clip", buildResizePatches, step).then(() =>
|
|
295
|
+
finishResizeGsapSync(),
|
|
296
|
+
);
|
|
297
|
+
const persistDone =
|
|
298
|
+
sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
|
|
299
|
+
? sdkTimingPersist(
|
|
300
|
+
element.hfId,
|
|
301
|
+
targetPath,
|
|
302
|
+
{ start: authoredStart, duration: updates.duration },
|
|
303
|
+
sdkSession,
|
|
304
|
+
{
|
|
305
|
+
editHistory: { recordEdit },
|
|
306
|
+
writeProjectFile,
|
|
307
|
+
reloadPreview,
|
|
308
|
+
compositionPath: activeCompPath,
|
|
309
|
+
// Capture on-disk bytes as the undo `before` so undoing a timing
|
|
310
|
+
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
|
311
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
312
|
+
publishSession: publishSdkSession,
|
|
313
|
+
},
|
|
314
|
+
{ label: "Resize timeline clip", ...step, skipRefresh: true },
|
|
315
|
+
).then((result) => {
|
|
316
|
+
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
|
317
|
+
return finishResizeGsapSync(sdkTimingGsapSync(result));
|
|
318
|
+
})
|
|
319
|
+
: resizeFallback();
|
|
320
|
+
return persistDone.catch((error) => {
|
|
321
|
+
// Failed persist: revert the optimistic duration readout + live root.
|
|
322
|
+
rollbackDuration();
|
|
323
|
+
showToast(getStudioSaveErrorMessage(error), "error");
|
|
324
|
+
throw error;
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
[
|
|
328
|
+
previewIframeRef,
|
|
329
|
+
enqueueEdit,
|
|
330
|
+
activeCompPath,
|
|
331
|
+
sdkSession,
|
|
332
|
+
publishSdkSession,
|
|
333
|
+
recordEdit,
|
|
334
|
+
writeProjectFile,
|
|
335
|
+
reloadPreview,
|
|
336
|
+
showToast,
|
|
337
|
+
invalidateGsapCache,
|
|
338
|
+
projectIdRef,
|
|
339
|
+
],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
return { handleTimelineElementMove, handleTimelineElementResize };
|
|
343
|
+
}
|
|
@@ -62,6 +62,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
62
62
|
function mountDeleteHarness(overrides: {
|
|
63
63
|
handleTimelineGroupMove: DeleteOpsOptions["handleTimelineGroupMove"];
|
|
64
64
|
showToast?: DeleteOpsOptions["showToast"];
|
|
65
|
+
recordEdit?: DeleteOpsOptions["recordEdit"];
|
|
65
66
|
}) {
|
|
66
67
|
const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
|
|
67
68
|
let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
|
|
@@ -72,7 +73,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
72
73
|
timelineElements: elements,
|
|
73
74
|
showToast: overrides.showToast ?? vi.fn(),
|
|
74
75
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
75
|
-
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
recordEdit: overrides.recordEdit ?? vi.fn().mockResolvedValue(undefined),
|
|
76
77
|
reloadPreview: vi.fn(),
|
|
77
78
|
previewIframeRef: { current: null },
|
|
78
79
|
handleTimelineGroupMove: overrides.handleTimelineGroupMove,
|
|
@@ -120,6 +121,24 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
120
121
|
);
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
it("holds the delete's undo step open under the key its ripple joins, however late the ripple lands", async () => {
|
|
125
|
+
stubRemoveElementsFetch(html, html.replace(/<div data-hf-id="hf-b"[^\n]*\n/, ""));
|
|
126
|
+
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
|
127
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
128
|
+
const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove, recordEdit });
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
await getHook().handleTimelineElementDelete(b);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const [[deleted]] = recordEdit.mock.calls;
|
|
135
|
+
expect(deleted).toMatchObject({ coalesceMs: Infinity });
|
|
136
|
+
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
|
|
137
|
+
expect.anything(),
|
|
138
|
+
expect.objectContaining({ coalesceKey: deleted.coalesceKey }),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
123
142
|
it("removes a multi-clip selection with one request, however many clips it holds", async () => {
|
|
124
143
|
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
|
125
144
|
const { elements, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
|
|
@@ -9,7 +9,7 @@ import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/stu
|
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
11
|
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
|
12
|
-
import { captureDurationRollback } from "./timelineTimingSync";
|
|
12
|
+
import { captureDurationRollback, timingGestureStep } from "./timelineTimingSync";
|
|
13
13
|
import { setLinkInSource } from "../components/editor/mediaLinkEdits";
|
|
14
14
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
15
15
|
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
@@ -57,11 +57,6 @@ interface UseTimelineDeleteOpsOptions {
|
|
|
57
57
|
) => Promise<void>;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Per-gesture-unique coalesce key — a monotonic counter, not Date.now() /
|
|
61
|
-
// Math.random() (determinism rules), mirroring gapCloseGestureSeq in
|
|
62
|
-
// timelineGapCommit.ts.
|
|
63
|
-
let deleteGestureSeq = 0;
|
|
64
|
-
|
|
65
60
|
function unlinkInSource(source: string, survivors: readonly TimelineElement[]): string {
|
|
66
61
|
const targets = survivors.map(buildPatchTarget).filter((target) => target !== null);
|
|
67
62
|
return targets.length > 0 ? setLinkInSource(source, targets, null) : source;
|
|
@@ -107,10 +102,8 @@ export function useTimelineDeleteOps({
|
|
|
107
102
|
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
108
103
|
);
|
|
109
104
|
try {
|
|
110
|
-
// Shared with the ripple move below so a folded ripple is one undo
|
|
111
|
-
|
|
112
|
-
// window across separate recordEdit calls, not by label).
|
|
113
|
-
const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
|
|
105
|
+
// Shared with the ripple move below so a folded ripple is one undo step with the delete.
|
|
106
|
+
const step = timingGestureStep("main-track-ripple-delete");
|
|
114
107
|
const deleteHistoryLabel = "Delete timeline clip";
|
|
115
108
|
let rollbackDuration = () => {};
|
|
116
109
|
try {
|
|
@@ -118,7 +111,7 @@ export function useTimelineDeleteOps({
|
|
|
118
111
|
projectId: pid,
|
|
119
112
|
path: targetPath,
|
|
120
113
|
label: deleteHistoryLabel,
|
|
121
|
-
|
|
114
|
+
...step,
|
|
122
115
|
writeFile: writeProjectFile,
|
|
123
116
|
recordEdit,
|
|
124
117
|
rewrite: async (originalContent) => {
|
|
@@ -189,8 +182,7 @@ export function useTimelineDeleteOps({
|
|
|
189
182
|
if (rippleChanges) {
|
|
190
183
|
try {
|
|
191
184
|
await handleTimelineGroupMove(rippleChanges, {
|
|
192
|
-
coalesceKey,
|
|
193
|
-
coalesceMs: Number.POSITIVE_INFINITY,
|
|
185
|
+
coalesceKey: step.coalesceKey,
|
|
194
186
|
// Coalescing keeps the LAST entry's label; without this the undo
|
|
195
187
|
// toast reads "Undid Move timeline clips" after a delete, naming
|
|
196
188
|
// the ripple's mechanics instead of what the user actually did.
|
|
@@ -306,9 +306,10 @@ function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unk
|
|
|
306
306
|
(url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
|
|
307
307
|
],
|
|
308
308
|
[
|
|
309
|
-
"/api/projects/p1/gsap-mutations
|
|
309
|
+
"/api/projects/p1/gsap-mutations",
|
|
310
310
|
(url) => {
|
|
311
|
-
const content =
|
|
311
|
+
const content =
|
|
312
|
+
fileContent(pathAfter(url, url.includes("-batch/") ? "-batch/" : "s/")) ?? "";
|
|
312
313
|
return jsonResponse(
|
|
313
314
|
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
|
314
315
|
);
|
|
@@ -1166,6 +1167,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
1166
1167
|
expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
|
|
1167
1168
|
group.unmount();
|
|
1168
1169
|
});
|
|
1170
|
+
|
|
1171
|
+
it("gives each single-clip move and resize its own undo step, which its GSAP rewrite joins", async () => {
|
|
1172
|
+
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
|
1173
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
1174
|
+
stubProjectFetch(source, {
|
|
1175
|
+
mutated: true,
|
|
1176
|
+
scriptText: null,
|
|
1177
|
+
before: "tweens at 0",
|
|
1178
|
+
after: source,
|
|
1179
|
+
});
|
|
1180
|
+
const recordEdit = vi.fn(async (_edit: { coalesceKey?: string; coalesceMs?: number }) => {});
|
|
1181
|
+
const h = renderTimelineEditingHook({
|
|
1182
|
+
timelineElements: [clip],
|
|
1183
|
+
iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
|
|
1184
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1185
|
+
projectId: "p1",
|
|
1186
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
1187
|
+
recordEdit,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
await act(async () => {
|
|
1191
|
+
await h.move(clip, { start: 0.5, track: clip.track });
|
|
1192
|
+
await h.move(clip, { start: 1, track: clip.track });
|
|
1193
|
+
await h.resize(clip, { start: 0, duration: 2 });
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
const steps = recordEdit.mock.calls.map(([edit]) => edit);
|
|
1197
|
+
expect(steps.map((edit) => edit.coalesceMs)).toEqual(Array(6).fill(Infinity));
|
|
1198
|
+
const [move, fold, again, foldAgain, resize, resizeFold] = steps.map(
|
|
1199
|
+
(edit) => edit.coalesceKey,
|
|
1200
|
+
);
|
|
1201
|
+
expect([fold, foldAgain, resizeFold]).toEqual([move, again, resize]);
|
|
1202
|
+
expect(new Set([move, again, resize]).size).toBe(3);
|
|
1203
|
+
h.unmount();
|
|
1204
|
+
});
|
|
1169
1205
|
});
|
|
1170
1206
|
|
|
1171
1207
|
describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
@@ -1936,8 +1972,11 @@ function setupNestedHarness() {
|
|
|
1936
1972
|
const fetchMock = stubProjectFetch(NESTED_FILES);
|
|
1937
1973
|
const scaleCalls = () =>
|
|
1938
1974
|
fetchMock.mock.calls
|
|
1939
|
-
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations
|
|
1940
|
-
.
|
|
1975
|
+
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations"))
|
|
1976
|
+
.flatMap((call) => {
|
|
1977
|
+
const body = JSON.parse(String((call[1] as RequestInit).body));
|
|
1978
|
+
return body.mutations ?? [body];
|
|
1979
|
+
})
|
|
1941
1980
|
.filter((body) => body.type === "scale-positions");
|
|
1942
1981
|
usePlayerStore.getState().setDuration(20);
|
|
1943
1982
|
const hook = renderTimelineEditingHook({
|
|
@@ -2285,6 +2324,19 @@ describe("clip timing edits sync GSAP exactly once", () => {
|
|
|
2285
2324
|
applyServerMutation(pathAfter(url, "/gsap-mutations/"), JSON.parse(String(init?.body))),
|
|
2286
2325
|
);
|
|
2287
2326
|
}
|
|
2327
|
+
if (url.includes("/gsap-mutations-batch/")) {
|
|
2328
|
+
const path = pathAfter(url, "/gsap-mutations-batch/");
|
|
2329
|
+
const steps = (
|
|
2330
|
+
JSON.parse(String(init?.body)) as { mutations: Record<string, unknown>[] }
|
|
2331
|
+
).mutations.map((mutation) => applyServerMutation(path, mutation));
|
|
2332
|
+
const last = steps[steps.length - 1]!;
|
|
2333
|
+
return jsonResponse({
|
|
2334
|
+
...last,
|
|
2335
|
+
before: steps[0]!.before,
|
|
2336
|
+
mutated: steps[0]!.before !== last.after,
|
|
2337
|
+
changed: steps[0]!.before !== last.after,
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2288
2340
|
if (url.includes("/files/"))
|
|
2289
2341
|
return jsonResponse({ content: files[pathAfter(url, "/files/")] });
|
|
2290
2342
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
@@ -2404,6 +2456,13 @@ describe("clip timing edits sync GSAP exactly once", () => {
|
|
|
2404
2456
|
`tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
|
|
2405
2457
|
`tl.to("#side", { x: 5, duration: 1 }, 3);`,
|
|
2406
2458
|
]);
|
|
2459
|
+
if (!withSdk) {
|
|
2460
|
+
const gsapUrls = h.fetchMock.mock.calls
|
|
2461
|
+
.map((call) => requestUrl(call[0]))
|
|
2462
|
+
.filter((url) => url.includes("/gsap-mutations"));
|
|
2463
|
+
// Both clips live in one file, so their rewrites travel as one request.
|
|
2464
|
+
expect(gsapUrls).toEqual([expect.stringContaining("/gsap-mutations-batch/")]);
|
|
2465
|
+
}
|
|
2407
2466
|
h.hook.unmount();
|
|
2408
2467
|
});
|
|
2409
2468
|
}
|