@hyperframes/studio 0.8.127 → 0.8.129
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-DfEcBl9r.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
- package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
- 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 +1396 -917
- 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 +82 -63
- 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/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
- 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
|
@@ -1,30 +1,10 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
|
-
import {
|
|
2
|
+
import { useRef } from "react";
|
|
3
3
|
import { useStableHandlers } from "./useStableHandlers";
|
|
4
4
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
5
|
-
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
6
5
|
import { useRazorSplit } from "./useRazorSplit";
|
|
7
6
|
import { selectSplittableElements } from "../utils/timelineElementSplit";
|
|
8
7
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
9
|
-
import {
|
|
10
|
-
applyTimelineStackingReorder,
|
|
11
|
-
patchIframeDomTiming,
|
|
12
|
-
persistTimelineEdit,
|
|
13
|
-
formatTimelineAttributeNumber,
|
|
14
|
-
formatTimelineMediaOffset,
|
|
15
|
-
extendRootDurationIfNeeded,
|
|
16
|
-
buildTimelineMoveTimingPatch,
|
|
17
|
-
buildTimelineResizeTimingPatch,
|
|
18
|
-
} from "./timelineEditingHelpers";
|
|
19
|
-
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
|
|
20
|
-
import {
|
|
21
|
-
captureDurationRollback,
|
|
22
|
-
finishClipTimingFallback,
|
|
23
|
-
sdkTimingGsapSync,
|
|
24
|
-
readFileContent,
|
|
25
|
-
syncPreviewContentDuration,
|
|
26
|
-
} from "./timelineTimingSync";
|
|
27
|
-
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
28
8
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
29
9
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
30
10
|
import { useSetElementsAttribute } from "./timelineElementsAttribute";
|
|
@@ -42,12 +22,10 @@ import {
|
|
|
42
22
|
useTimelineTrackVisibilityEditing,
|
|
43
23
|
} from "./timelineTrackVisibility";
|
|
44
24
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
25
|
+
import { useTimelineClipTimingEditing } from "./useTimelineClipTimingEditing";
|
|
45
26
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
46
27
|
import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
|
|
47
|
-
import {
|
|
48
|
-
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
49
|
-
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
50
|
-
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
28
|
+
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
51
29
|
|
|
52
30
|
export function useTimelineEditing({
|
|
53
31
|
projectId,
|
|
@@ -76,51 +54,6 @@ export function useTimelineEditing({
|
|
|
76
54
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
77
55
|
const guard = useTimelineEditGuard(canEdit, showToast);
|
|
78
56
|
|
|
79
|
-
const enqueueEdit = useCallback(
|
|
80
|
-
(
|
|
81
|
-
element: TimelineElement,
|
|
82
|
-
label: string,
|
|
83
|
-
buildPatches: PersistTimelineEditInput["buildPatches"],
|
|
84
|
-
coalesceKey?: string,
|
|
85
|
-
): Promise<void> => {
|
|
86
|
-
if (isRecordingRef?.current) {
|
|
87
|
-
showToast("Cannot edit timeline while recording", "error");
|
|
88
|
-
return Promise.resolve();
|
|
89
|
-
}
|
|
90
|
-
const pid = projectIdRef.current;
|
|
91
|
-
if (!pid) return Promise.resolve();
|
|
92
|
-
const queued = editQueueRef.current
|
|
93
|
-
.then(() =>
|
|
94
|
-
persistTimelineEdit({
|
|
95
|
-
projectId: pid,
|
|
96
|
-
element,
|
|
97
|
-
activeCompPath,
|
|
98
|
-
label,
|
|
99
|
-
buildPatches,
|
|
100
|
-
writeProjectFile,
|
|
101
|
-
recordEdit,
|
|
102
|
-
pendingTimelineEditPathRef,
|
|
103
|
-
coalesceKey,
|
|
104
|
-
}),
|
|
105
|
-
)
|
|
106
|
-
.then(() => {
|
|
107
|
-
forceReloadSdkSession?.();
|
|
108
|
-
});
|
|
109
|
-
editQueueRef.current = queued.catch((error) => {
|
|
110
|
-
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
111
|
-
});
|
|
112
|
-
return queued;
|
|
113
|
-
},
|
|
114
|
-
[
|
|
115
|
-
activeCompPath,
|
|
116
|
-
recordEdit,
|
|
117
|
-
writeProjectFile,
|
|
118
|
-
pendingTimelineEditPathRef,
|
|
119
|
-
showToast,
|
|
120
|
-
isRecordingRef,
|
|
121
|
-
forceReloadSdkSession,
|
|
122
|
-
],
|
|
123
|
-
);
|
|
124
57
|
const groupEditing = useTimelineGroupEditing({
|
|
125
58
|
activeCompPath,
|
|
126
59
|
editQueueRef,
|
|
@@ -137,231 +70,24 @@ export function useTimelineEditing({
|
|
|
137
70
|
showToast,
|
|
138
71
|
writeProjectFile,
|
|
139
72
|
});
|
|
140
|
-
const handleTimelineElementMove =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
|
|
159
|
-
}
|
|
160
|
-
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const reorderDone = applyTimelineStackingReorder({
|
|
164
|
-
element,
|
|
165
|
-
stackingReorder: updates.stackingReorder,
|
|
166
|
-
timelineElements,
|
|
167
|
-
iframe: previewIframeRef.current,
|
|
168
|
-
activeCompPath,
|
|
169
|
-
commit: handleDomZIndexReorderCommitRef?.current,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (!startChanged && !trackChanged) return reorderDone;
|
|
173
|
-
|
|
174
|
-
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
175
|
-
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
176
|
-
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
177
|
-
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
|
|
178
|
-
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
|
179
|
-
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
|
|
180
|
-
syncPreviewContentDuration(previewIframeRef.current);
|
|
181
|
-
|
|
182
|
-
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
183
|
-
// Persist lane changes too — data-start-only writes let reload snap the lane back.
|
|
184
|
-
const track = trackChanged ? updates.track : undefined;
|
|
185
|
-
return buildTimelineMoveTimingPatch(
|
|
186
|
-
original,
|
|
187
|
-
target,
|
|
188
|
-
authoredStart,
|
|
189
|
-
element.duration,
|
|
190
|
-
track,
|
|
191
|
-
);
|
|
192
|
-
};
|
|
193
|
-
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
|
194
|
-
const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
195
|
-
// One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
|
|
196
|
-
finishClipTimingFallback({
|
|
197
|
-
iframe: previewIframeRef.current,
|
|
198
|
-
reloadPreview,
|
|
199
|
-
projectId: projectIdRef.current,
|
|
200
|
-
targetPath,
|
|
201
|
-
domId: element.domId,
|
|
202
|
-
label: "Move timeline clip",
|
|
203
|
-
coalesceKey,
|
|
204
|
-
recordEdit,
|
|
205
|
-
writeProjectFile,
|
|
206
|
-
edit: { kind: "shift", delta: updates.start - element.start },
|
|
207
|
-
sdkGsap,
|
|
208
|
-
}).finally(() => invalidateGsapCache?.());
|
|
209
|
-
const moveFallback = () =>
|
|
210
|
-
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
|
|
211
|
-
finishMoveGsapSync(),
|
|
212
|
-
);
|
|
213
|
-
return reorderDone
|
|
214
|
-
.then(() => {
|
|
215
|
-
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
216
|
-
if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
|
|
217
|
-
return sdkTimingPersist(
|
|
218
|
-
element.hfId,
|
|
219
|
-
targetPath,
|
|
220
|
-
{ start: authoredStart },
|
|
221
|
-
sdkSession,
|
|
222
|
-
{
|
|
223
|
-
editHistory: { recordEdit },
|
|
224
|
-
writeProjectFile,
|
|
225
|
-
reloadPreview,
|
|
226
|
-
compositionPath: activeCompPath,
|
|
227
|
-
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
|
228
|
-
// restores the file verbatim, not a normalized full-DOM re-emit.
|
|
229
|
-
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
230
|
-
publishSession: publishSdkSession,
|
|
231
|
-
},
|
|
232
|
-
{ label: "Move timeline clip", coalesceKey, skipRefresh: true },
|
|
233
|
-
).then((result) => {
|
|
234
|
-
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
|
235
|
-
return finishMoveGsapSync(sdkTimingGsapSync(result));
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
return moveFallback();
|
|
239
|
-
})
|
|
240
|
-
.catch((error) => {
|
|
241
|
-
// Failed persist: revert the optimistic duration readout + live root.
|
|
242
|
-
rollbackDuration();
|
|
243
|
-
showToast(getStudioSaveErrorMessage(error), "error");
|
|
244
|
-
throw error;
|
|
245
|
-
});
|
|
246
|
-
};
|
|
247
|
-
return updates.stackingReorder ? serializeZLaneGesture(commitMove) : commitMove();
|
|
248
|
-
},
|
|
249
|
-
[
|
|
250
|
-
previewIframeRef,
|
|
251
|
-
enqueueEdit,
|
|
252
|
-
activeCompPath,
|
|
253
|
-
sdkSession,
|
|
254
|
-
publishSdkSession,
|
|
255
|
-
recordEdit,
|
|
256
|
-
writeProjectFile,
|
|
257
|
-
reloadPreview,
|
|
258
|
-
timelineElements,
|
|
259
|
-
handleDomZIndexReorderCommitRef,
|
|
260
|
-
showToast,
|
|
261
|
-
invalidateGsapCache,
|
|
262
|
-
],
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const handleTimelineElementResize = useCallback(
|
|
266
|
-
// fallow-ignore-next-line complexity
|
|
267
|
-
(
|
|
268
|
-
element: TimelineElement,
|
|
269
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
270
|
-
) => {
|
|
271
|
-
const authoredStart = toAuthoredStart(element, updates.start);
|
|
272
|
-
const liveAttrs: Array<[string, string]> = [
|
|
273
|
-
["data-start", formatTimelineAttributeNumber(authoredStart)],
|
|
274
|
-
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
275
|
-
];
|
|
276
|
-
if (updates.playbackStart != null) {
|
|
277
|
-
const liveAttr = playbackStartAttributeForElement(element);
|
|
278
|
-
liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
|
|
279
|
-
}
|
|
280
|
-
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
281
|
-
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
282
|
-
// persist can roll the readout + live root back (see captureDurationRollback).
|
|
283
|
-
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
284
|
-
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
|
|
285
|
-
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
|
|
286
|
-
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
|
|
287
|
-
syncPreviewContentDuration(previewIframeRef.current);
|
|
288
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
289
|
-
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
290
|
-
return buildTimelineResizeTimingPatch(original, target, element, updates);
|
|
291
|
-
};
|
|
292
|
-
const hasPbsAdjustment =
|
|
293
|
-
updates.playbackStart != null ||
|
|
294
|
-
(updates.start !== element.start && element.playbackStart != null);
|
|
295
|
-
// Server-path fallback: after persisting the attr patch, scale GSAP tween
|
|
296
|
-
// positions/durations on the server, then soft-reload with the rewritten
|
|
297
|
-
// script (timing-only resize) — same no-flash path as move; full reload is
|
|
298
|
-
// the fallback.
|
|
299
|
-
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
|
300
|
-
const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
301
|
-
finishClipTimingFallback({
|
|
302
|
-
iframe: previewIframeRef.current,
|
|
303
|
-
reloadPreview,
|
|
304
|
-
projectId: projectIdRef.current,
|
|
305
|
-
targetPath,
|
|
306
|
-
domId: element.domId,
|
|
307
|
-
label: "Resize timeline clip",
|
|
308
|
-
coalesceKey,
|
|
309
|
-
recordEdit,
|
|
310
|
-
writeProjectFile,
|
|
311
|
-
edit: {
|
|
312
|
-
kind: "scale",
|
|
313
|
-
from: { start: toCompositionTime(element, element.start), duration: element.duration },
|
|
314
|
-
to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
|
|
315
|
-
},
|
|
316
|
-
sdkGsap,
|
|
317
|
-
}).finally(() => invalidateGsapCache?.());
|
|
318
|
-
const resizeFallback = () =>
|
|
319
|
-
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
|
|
320
|
-
finishResizeGsapSync(),
|
|
321
|
-
);
|
|
322
|
-
const persistDone =
|
|
323
|
-
sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
|
|
324
|
-
? sdkTimingPersist(
|
|
325
|
-
element.hfId,
|
|
326
|
-
targetPath,
|
|
327
|
-
{ start: authoredStart, duration: updates.duration },
|
|
328
|
-
sdkSession,
|
|
329
|
-
{
|
|
330
|
-
editHistory: { recordEdit },
|
|
331
|
-
writeProjectFile,
|
|
332
|
-
reloadPreview,
|
|
333
|
-
compositionPath: activeCompPath,
|
|
334
|
-
// Capture on-disk bytes as the undo `before` so undoing a timing
|
|
335
|
-
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
|
336
|
-
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
337
|
-
publishSession: publishSdkSession,
|
|
338
|
-
},
|
|
339
|
-
{ label: "Resize timeline clip", coalesceKey, skipRefresh: true },
|
|
340
|
-
).then((result) => {
|
|
341
|
-
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
|
342
|
-
return finishResizeGsapSync(sdkTimingGsapSync(result));
|
|
343
|
-
})
|
|
344
|
-
: resizeFallback();
|
|
345
|
-
return persistDone.catch((error) => {
|
|
346
|
-
// Failed persist: revert the optimistic duration readout + live root.
|
|
347
|
-
rollbackDuration();
|
|
348
|
-
showToast(getStudioSaveErrorMessage(error), "error");
|
|
349
|
-
throw error;
|
|
350
|
-
});
|
|
351
|
-
},
|
|
352
|
-
[
|
|
353
|
-
previewIframeRef,
|
|
354
|
-
enqueueEdit,
|
|
355
|
-
activeCompPath,
|
|
356
|
-
sdkSession,
|
|
357
|
-
publishSdkSession,
|
|
358
|
-
recordEdit,
|
|
359
|
-
writeProjectFile,
|
|
360
|
-
reloadPreview,
|
|
361
|
-
showToast,
|
|
362
|
-
invalidateGsapCache,
|
|
363
|
-
],
|
|
364
|
-
);
|
|
73
|
+
const { handleTimelineElementMove, handleTimelineElementResize } = useTimelineClipTimingEditing({
|
|
74
|
+
activeCompPath,
|
|
75
|
+
timelineElements,
|
|
76
|
+
showToast,
|
|
77
|
+
writeProjectFile,
|
|
78
|
+
recordEdit,
|
|
79
|
+
reloadPreview,
|
|
80
|
+
previewIframeRef,
|
|
81
|
+
pendingTimelineEditPathRef,
|
|
82
|
+
isRecordingRef,
|
|
83
|
+
sdkSession,
|
|
84
|
+
publishSdkSession,
|
|
85
|
+
forceReloadSdkSession,
|
|
86
|
+
invalidateGsapCache,
|
|
87
|
+
handleDomZIndexReorderCommitRef,
|
|
88
|
+
editQueueRef,
|
|
89
|
+
projectIdRef,
|
|
90
|
+
});
|
|
365
91
|
|
|
366
92
|
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
|
|
367
93
|
projectIdRef,
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
createStudioApi,
|
|
9
|
+
openProjectHistory,
|
|
10
|
+
type StudioApiAdapter,
|
|
11
|
+
} from "@hyperframes/studio-server";
|
|
4
12
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
13
|
import type { Root } from "react-dom/client";
|
|
6
14
|
import type { TimelineElement } from "../player";
|
|
7
15
|
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
|
|
8
16
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
17
|
+
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
9
18
|
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
|
|
10
19
|
|
|
11
20
|
installReactActEnvironment();
|
|
@@ -149,3 +158,85 @@ it.each([false, true])(
|
|
|
149
158
|
}
|
|
150
159
|
},
|
|
151
160
|
);
|
|
161
|
+
|
|
162
|
+
const SLOWER_THAN_THE_DEFAULT_WINDOW_MS = 400;
|
|
163
|
+
|
|
164
|
+
it("undoes each group move with its GSAP rewrite in one step, however long the rewrite takes", async () => {
|
|
165
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
166
|
+
const dir = mkdtempSync(join(tmpdir(), "hf-group-move-undo-"));
|
|
167
|
+
const historyRoot = mkdtempSync(join(tmpdir(), "hf-group-move-undo-history-"));
|
|
168
|
+
const before = [
|
|
169
|
+
'<div data-composition-id="root" data-duration="4">',
|
|
170
|
+
'<div id="a" data-start="0" data-duration="2"></div>',
|
|
171
|
+
'<div id="b" data-start="0" data-duration="2"></div>',
|
|
172
|
+
"</div>",
|
|
173
|
+
'<script>const tl = gsap.timeline({ paused: true }); tl.to("#a", { x: 10, duration: 1 }, 0); tl.to("#b", { x: 10, duration: 1 }, 0); window.__timelines["root"] = tl;</script>',
|
|
174
|
+
].join("");
|
|
175
|
+
writeFileSync(join(dir, "index.html"), before);
|
|
176
|
+
const history = await openProjectHistory({ projectDir: dir, historyRoot });
|
|
177
|
+
const api = createStudioApi({
|
|
178
|
+
listProjects: () => [],
|
|
179
|
+
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
|
|
180
|
+
history: () => history,
|
|
181
|
+
} as unknown as StudioApiAdapter);
|
|
182
|
+
let rewriteAsked = () => {};
|
|
183
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
184
|
+
if (url.includes("/gsap-mutations")) {
|
|
185
|
+
rewriteAsked();
|
|
186
|
+
await new Promise((wait) => setTimeout(wait, SLOWER_THAN_THE_DEFAULT_WINDOW_MS));
|
|
187
|
+
}
|
|
188
|
+
return api.request(url.replace(/^\/api/, ""), init);
|
|
189
|
+
});
|
|
190
|
+
let editHistory!: ReturnType<typeof usePersistentEditHistory>;
|
|
191
|
+
let editing!: ReturnType<typeof useTimelineGroupEditing>;
|
|
192
|
+
function Harness() {
|
|
193
|
+
editHistory = usePersistentEditHistory({ projectId: "demo" });
|
|
194
|
+
editing = useTimelineGroupEditing({
|
|
195
|
+
activeCompPath: "index.html",
|
|
196
|
+
editQueueRef: { current: Promise.resolve() },
|
|
197
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
198
|
+
previewIframeRef: { current: null },
|
|
199
|
+
projectIdRef: { current: "demo" },
|
|
200
|
+
recordEdit: editHistory.recordEdit,
|
|
201
|
+
reloadPreview: vi.fn(),
|
|
202
|
+
showToast: vi.fn(),
|
|
203
|
+
writeProjectFile: async (path, content) => writeFileSync(join(dir, path), content),
|
|
204
|
+
});
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const root = mountReactHarness(<Harness />);
|
|
208
|
+
const file = () => readFileSync(join(dir, "index.html"), "utf8");
|
|
209
|
+
const moveTo = (from: number, to: number) =>
|
|
210
|
+
act(async () => {
|
|
211
|
+
const rewriting = new Promise<void>((resolve) => (rewriteAsked = resolve));
|
|
212
|
+
const moved = editing.handleTimelineGroupMove([
|
|
213
|
+
{ element: el("a", from, 2), start: to },
|
|
214
|
+
{ element: el("b", from, 2), start: to },
|
|
215
|
+
]);
|
|
216
|
+
await rewriting;
|
|
217
|
+
await vi.advanceTimersByTimeAsync(SLOWER_THAN_THE_DEFAULT_WINDOW_MS);
|
|
218
|
+
await moved;
|
|
219
|
+
});
|
|
220
|
+
const undo = () =>
|
|
221
|
+
act(() =>
|
|
222
|
+
editHistory.undo({ readFile: async (path) => readFileSync(join(dir, path), "utf8") }),
|
|
223
|
+
);
|
|
224
|
+
try {
|
|
225
|
+
await moveTo(0, 1);
|
|
226
|
+
const once = file();
|
|
227
|
+
await moveTo(1, 2);
|
|
228
|
+
expect(file()).toContain('tl.to("#a", { x: 10, duration: 1 }, 2)');
|
|
229
|
+
|
|
230
|
+
await undo();
|
|
231
|
+
expect(file()).toBe(once);
|
|
232
|
+
await undo();
|
|
233
|
+
expect(file()).toBe(before);
|
|
234
|
+
} finally {
|
|
235
|
+
act(() => root.unmount());
|
|
236
|
+
await history.close();
|
|
237
|
+
vi.useRealTimers();
|
|
238
|
+
vi.unstubAllGlobals();
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
rmSync(historyRoot, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
type PublishSdkSession,
|
|
11
11
|
} from "../utils/sdkCutover";
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
applyTimelineMoveAttributes,
|
|
14
|
+
applyTimelineResizeAttributes,
|
|
15
15
|
extendRootDurationIfNeeded,
|
|
16
16
|
formatTimelineAttributeNumber,
|
|
17
17
|
formatTimelineMediaOffset,
|
|
@@ -24,10 +24,11 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
|
|
|
24
24
|
import {
|
|
25
25
|
captureDurationRollback,
|
|
26
26
|
finishGroupTimingGsapFallback,
|
|
27
|
+
timingGestureStep,
|
|
27
28
|
sdkTimingGsapSync,
|
|
28
29
|
readFileContent,
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
scaleGsapMutation,
|
|
31
|
+
shiftGsapMutation,
|
|
31
32
|
syncPreviewContentDuration,
|
|
32
33
|
} from "./timelineTimingSync";
|
|
33
34
|
import type { GsapMutationStatus } from "./gsapMutationClient";
|
|
@@ -50,8 +51,6 @@ export interface TimelineGroupResizeChange {
|
|
|
50
51
|
export interface TimelineGroupCommitOptions {
|
|
51
52
|
beforeTiming?: Promise<void>;
|
|
52
53
|
coalesceKey?: string;
|
|
53
|
-
/** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
|
|
54
|
-
coalesceMs?: number;
|
|
55
54
|
/** Overrides the default "Move timeline clips" undo-history label. Coalescing
|
|
56
55
|
* keeps the LAST entry's label (editHistory.ts), so a mechanical follow-up
|
|
57
56
|
* move folded into another gesture's coalesceKey (e.g. the ripple after a
|
|
@@ -95,14 +94,6 @@ function allChangesSharePath(
|
|
|
95
94
|
: null;
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
|
|
99
|
-
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
|
|
103
|
-
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
97
|
function toSdkTimingChanges<T extends { element: TimelineElement }>(
|
|
107
98
|
changes: readonly T[],
|
|
108
99
|
timingUpdate: (change: T) => { start: number; duration?: number },
|
|
@@ -287,8 +278,7 @@ export function useTimelineGroupEditing({
|
|
|
287
278
|
// Optimistic duration readout: content-driven (grow AND shrink), read from
|
|
288
279
|
// the just-patched live DOM. See syncPreviewContentDuration.
|
|
289
280
|
syncPreviewContentDuration(previewIframeRef.current);
|
|
290
|
-
const coalesceKey
|
|
291
|
-
const coalesceMs = options?.coalesceMs;
|
|
281
|
+
const { coalesceKey, coalesceMs } = timingGestureStep("timeline-group-move", options);
|
|
292
282
|
const label = options?.label ?? "Move timeline clips";
|
|
293
283
|
return enqueueGroupOperation(label, async (projectId) => {
|
|
294
284
|
await options?.beforeTiming;
|
|
@@ -312,7 +302,7 @@ export function useTimelineGroupEditing({
|
|
|
312
302
|
changes.map((change) => ({
|
|
313
303
|
element: change.element,
|
|
314
304
|
buildPatches: (original, target) =>
|
|
315
|
-
|
|
305
|
+
applyTimelineMoveAttributes(
|
|
316
306
|
original,
|
|
317
307
|
target,
|
|
318
308
|
toAuthoredStart(change.element, change.start),
|
|
@@ -347,12 +337,8 @@ export function useTimelineGroupEditing({
|
|
|
347
337
|
changes,
|
|
348
338
|
sdkGsap: sdk?.sdkGsap,
|
|
349
339
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const domId = change.element.domId;
|
|
353
|
-
if (delta === 0 || !domId) return null;
|
|
354
|
-
return shiftGsapPositions(projectId, changePath, domId, delta);
|
|
355
|
-
},
|
|
340
|
+
mutationFor: (change) =>
|
|
341
|
+
shiftGsapMutation(change.element.domId, change.start - change.element.start),
|
|
356
342
|
});
|
|
357
343
|
} finally {
|
|
358
344
|
invalidateGsapCache?.();
|
|
@@ -411,8 +397,7 @@ export function useTimelineGroupEditing({
|
|
|
411
397
|
// Optimistic duration readout: content-driven (grow AND shrink), read from
|
|
412
398
|
// the just-patched live DOM. See syncPreviewContentDuration.
|
|
413
399
|
syncPreviewContentDuration(previewIframeRef.current);
|
|
414
|
-
const coalesceKey
|
|
415
|
-
const coalesceMs = options?.coalesceMs;
|
|
400
|
+
const { coalesceKey, coalesceMs } = timingGestureStep("timeline-group-resize", options);
|
|
416
401
|
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
|
417
402
|
await options?.beforeTiming;
|
|
418
403
|
const sdk = await trySdkBatchPersist({
|
|
@@ -434,7 +419,7 @@ export function useTimelineGroupEditing({
|
|
|
434
419
|
changes.map((change) => ({
|
|
435
420
|
element: change.element,
|
|
436
421
|
buildPatches: (original, target) =>
|
|
437
|
-
|
|
422
|
+
applyTimelineResizeAttributes(original, target, change.element, {
|
|
438
423
|
start: change.start,
|
|
439
424
|
duration: change.duration,
|
|
440
425
|
playbackStart: change.playbackStart,
|
|
@@ -460,22 +445,14 @@ export function useTimelineGroupEditing({
|
|
|
460
445
|
changes,
|
|
461
446
|
sdkGsap: sdk?.sdkGsap,
|
|
462
447
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
change.start !== change.element.start ||
|
|
467
|
-
change.duration !== change.element.duration;
|
|
468
|
-
if (!timingChanged || !domId) return null;
|
|
469
|
-
return scaleGsapPositions(
|
|
470
|
-
projectId,
|
|
471
|
-
changePath,
|
|
472
|
-
domId,
|
|
448
|
+
mutationFor: (change) =>
|
|
449
|
+
scaleGsapMutation(
|
|
450
|
+
change.element.domId,
|
|
473
451
|
toCompositionTime(change.element, change.element.start),
|
|
474
452
|
change.element.duration,
|
|
475
453
|
toCompositionTime(change.element, change.start),
|
|
476
454
|
change.duration,
|
|
477
|
-
)
|
|
478
|
-
},
|
|
455
|
+
),
|
|
479
456
|
});
|
|
480
457
|
} finally {
|
|
481
458
|
invalidateGsapCache?.();
|
|
@@ -87,10 +87,17 @@ describe("timeline viewport geometry", () => {
|
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
/** The track's content row: a clip's parent, past its `display: contents` wrapper. */
|
|
91
|
+
function trackContentOf(clip: HTMLElement | null | undefined): HTMLElement | null {
|
|
92
|
+
let row = clip?.parentElement ?? null;
|
|
93
|
+
while (row?.style.display === "contents") row = row.parentElement;
|
|
94
|
+
return row;
|
|
95
|
+
}
|
|
96
|
+
|
|
90
97
|
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
|
91
98
|
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
|
|
92
99
|
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
|
|
93
|
-
const trackContent = clip
|
|
100
|
+
const trackContent = trackContentOf(clip);
|
|
94
101
|
if (!trackContent) throw new Error(`Missing content row for ${clipId}`);
|
|
95
102
|
const trackHeader = trackContent.previousElementSibling;
|
|
96
103
|
if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`);
|
|
@@ -708,7 +715,7 @@ describe("Timeline provider boundary", () => {
|
|
|
708
715
|
expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();
|
|
709
716
|
|
|
710
717
|
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
|
|
711
|
-
const row = clip?.parentElement
|
|
718
|
+
const row = trackContentOf(clip)?.parentElement;
|
|
712
719
|
expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
|
|
713
720
|
|
|
714
721
|
// Collapsing sticks (does not bounce back open via auto-expand).
|
|
@@ -731,8 +738,9 @@ describe("Timeline provider boundary", () => {
|
|
|
731
738
|
it("expands and collapses every clip on a shared track together", () => {
|
|
732
739
|
const { host, root } = renderSharedAutomationTimeline();
|
|
733
740
|
|
|
734
|
-
const row =
|
|
735
|
-
|
|
741
|
+
const row = trackContentOf(
|
|
742
|
+
host.querySelector<HTMLElement>('[data-el-id="narration-1"]'),
|
|
743
|
+
)?.parentElement;
|
|
736
744
|
// A row of several clips is named for the track, so the caret is too.
|
|
737
745
|
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
|
|
738
746
|
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");
|
|
@@ -20,6 +20,7 @@ import { clipSpeedSuffix } from "./clipToolAttrs";
|
|
|
20
20
|
interface TimelineClipProps {
|
|
21
21
|
el: TimelineElement;
|
|
22
22
|
pps: number;
|
|
23
|
+
passengerStyle?: CSSProperties;
|
|
23
24
|
clipY: number;
|
|
24
25
|
clipHeight?: number;
|
|
25
26
|
isSelected: boolean;
|
|
@@ -46,6 +47,7 @@ interface TimelineClipProps {
|
|
|
46
47
|
export const TimelineClip = memo(function TimelineClip({
|
|
47
48
|
el,
|
|
48
49
|
pps,
|
|
50
|
+
passengerStyle,
|
|
49
51
|
clipY,
|
|
50
52
|
clipHeight,
|
|
51
53
|
isSelected,
|
|
@@ -123,6 +125,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
123
125
|
padding: 0,
|
|
124
126
|
textAlign: "left",
|
|
125
127
|
transform: isDragging ? "translateY(-1px)" : undefined,
|
|
128
|
+
...passengerStyle,
|
|
126
129
|
};
|
|
127
130
|
|
|
128
131
|
return (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
1
2
|
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
|
2
3
|
import { CLIP_Y } from "./timelineLayout";
|
|
3
4
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
@@ -22,6 +23,7 @@ interface TimelineCompactDiamondsProps extends Pick<
|
|
|
22
23
|
beatsActive: boolean;
|
|
23
24
|
accentColor: string;
|
|
24
25
|
isSelected: boolean;
|
|
26
|
+
passengerStyle?: CSSProperties;
|
|
25
27
|
rovingTargetId: string | null;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -35,6 +37,7 @@ export function TimelineCompactDiamonds({
|
|
|
35
37
|
beatsActive,
|
|
36
38
|
accentColor,
|
|
37
39
|
isSelected,
|
|
40
|
+
passengerStyle,
|
|
38
41
|
currentTime,
|
|
39
42
|
selectedKeyframes,
|
|
40
43
|
rovingTargetId,
|
|
@@ -55,6 +58,7 @@ export function TimelineCompactDiamonds({
|
|
|
55
58
|
width,
|
|
56
59
|
height: rowHeight - 2 * CLIP_Y,
|
|
57
60
|
zIndex: isSelected ? 11 : 6,
|
|
61
|
+
...passengerStyle,
|
|
58
62
|
}}
|
|
59
63
|
>
|
|
60
64
|
<TimelineClipDiamonds
|