@hyperframes/studio 0.8.126 → 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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -1,30 +1,10 @@
1
1
  // fallow-ignore-file complexity
2
- import { useCallback, useRef } from "react";
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 { serializeZLaneGesture } from "../components/nle/zLaneGesture";
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 = useCallback(
141
- // fallow-ignore-next-line complexity
142
- (element: TimelineElement, updates: TimelineMoveUpdates) => {
143
- const commitMove = () => {
144
- const targetPath = element.sourceFile || activeCompPath || "index.html";
145
- const startChanged = updates.start !== element.start;
146
- // A vertical-only lane move arrives with start unchanged but track changed
147
- // (on this single-element path the drag commit has already folded the
148
- // AUTHORED persist track into updates.track). It must persist like any
149
- // other move — early-returning on !startChanged alone silently dropped
150
- // the file write, so the lane snapped back on reload.
151
- const trackChanged = updates.track !== element.track;
152
- const authoredStart = toAuthoredStart(element, updates.start);
153
- if (startChanged || trackChanged) {
154
- const liveAttrs: Array<[string, string]> = [];
155
- if (startChanged)
156
- liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
157
- if (trackChanged) {
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
- buildTimelineMoveTimingPatch,
14
- buildTimelineResizeTimingPatch,
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
- scaleGsapPositions,
30
- shiftGsapPositions,
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 = options?.coalesceKey ?? moveCoalesceKey(changes);
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
- buildTimelineMoveTimingPatch(
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
- mutateChange: (change, changePath) => {
351
- const delta = change.start - change.element.start;
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 = options?.coalesceKey ?? resizeCoalesceKey(changes);
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
- buildTimelineResizeTimingPatch(original, target, change.element, {
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
- mutateChange: (change, changePath) => {
464
- const domId = change.element.domId;
465
- const timingChanged =
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.parentElement;
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?.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 = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
735
- ?.parentElement;
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